El cliente de ejecución (execution client) tiene que ocuparse a la vez de la sincronización de bloques, el pool de transacciones, el almacenamiento de estado, JSON-RPC y el enlace con el consenso; la máquina virtual de Ethereum (Ethereum Virtual Machine, EVM) es solo una de esas piezas. La EVM no sabe de dónde vienen los bloques ni decide qué regla fija el precio del gas de una transacción: recibe bytecode, datos de llamada, entorno de ejecución y gas, y devuelve datos de retorno, gas restante, un estado de excepción y un conjunto de cambios de estado.
Solo trazando bien esta frontera se entiende por qué go-ethereum, evmone y revm ocupan posiciones distintas: uno es un intérprete integrado en el cliente, otra es una biblioteca independiente reutilizable desde varios lenguajes y el tercero es un framework de Rust que se importa como dependencia. A continuación se trata únicamente cómo se incrusta la EVM en un cliente de propósito general y cómo se garantiza la consistencia entre implementaciones; el despacho paralelo y el diseño multi-motor no se desarrollan aquí, y se remiten a las lecturas relacionadas del final.
La frontera entre la EVM y el resto del cliente
Sea cual sea la implementación, la interacción entre la EVM y el host se reduce siempre al mismo conjunto de operaciones: leer el saldo, el nonce y el código de una cuenta, leer slots de almacenamiento, leer hashes de bloques históricos; escribir los cambios de almacenamiento, emitir logs, crear o eliminar cuentas; y el cobro y la devolución de gas. La única diferencia está en la forma en que se expone este conjunto de operaciones.
El core/vm de go-ethereum implementa la EVM como un struct más un bucle de intérprete. El intérprete elige según el fork una tabla de saltos (jump table) de 256 entradas, y cada entrada contiene la función de ejecución, el gas constante, la función de cálculo del gas dinámico, las restricciones de altura de pila y los requisitos de memoria. El orden del bucle es: obtener el opcode, validar la pila, descontar el gas constante, calcular el gas dinámico, ampliar la memoria si hace falta y llamar a la función de ejecución; cuando el gas no llega, se devuelve siempre ErrOutOfGas. El acceso al estado pasa por StateDB, y el contexto de bloque y de transacción, por BlockContext y TxContext. El gas intrínseco, la validación del nonce, el descuento de comisiones y el cálculo de reembolsos no están en el intérprete, sino que se hacen en la capa de transición de estado.
La estructura de revm es más explícita: Evm se compone de un Context y un constructor, y Context a su vez consta de tres bloques: el entorno (Transaction, Block, Cfg), el Journal y la Database. Database es una interfaz con solo cuatro métodos obligatorios: basic lee cuentas, code_by_hash lee código, storage lee slots de almacenamiento y block_hash lee hashes de bloques históricos. Estos datos, una vez leídos, se cachean en el Journal; el Journal registra además los cambios producidos durante la ejecución y devuelve la diferencia de estado al terminar. El intérprete no toca la base de datos directamente, sino que accede a través del trait Host, que agrupa por bloque, transacción, configuración, base de datos y Journal, y cubre sload, sstore, tload, tstore, la carga de cuentas, los logs y la autodestrucción.
evmone convierte esta frontera en una ABI de C. Implementa EVMC (Ethereum Client-VM Connector API), una interfaz definida como la ABI de bajo nivel entre la EVM y el cliente, y cuyo lado de cliente fija cómo accede la EVM al entorno y al estado. Como la frontera es una ABI de C, evmone puede cargarse desde clientes escritos en otros lenguajes; la retrospectiva de Erigon cuenta que integró evmone en su propia capa de ejecución a través de EVMC y que además envolvió una capa de código C++ para reducir el coste de la interfaz.
Diferencias de posicionamiento entre las tres implementaciones
| Implementación | Lenguaje y licencia | Forma | Interfaz de estado | Forma típica de reutilización |
|---|---|---|---|---|
go-ethereum core/vm | Go, código de biblioteca LGPL-3.0, GPL-3.0 en cmd/ | Intérprete integrado en el cliente | StateDB | Bifurcado como cliente de ejecución de L2, por ejemplo op-geth de OP Stack |
| evmone | C++20, Apache-2.0 | Módulo independiente, expuesto a través de EVMC | Interfaz de host de EVMC | Cargado como módulo de ejecución por clientes como Silkworm |
| revm | Rust, MIT | Biblioteca y framework, dividido en crates | Traits Database y Host | Reth, Foundry, Helios y otros lo usan directamente como dependencia; también es una opción habitual en L2 y en zkVM |
Las tres formas corresponden a tres disyuntivas de ingeniería. Un intérprete integrado puede optimizarse junto con su propia capa de estado, su tabla de saltos y sus trazadores, a cambio de que resulta muy difícil reutilizarlo en otro cliente. Una biblioteca independiente puede reutilizarse desde varios lenguajes, pero tiene que atravesar una capa de interfaz genérica, y el coste de llamada a ambos lados de esa interfaz es el precio. La misma retrospectiva cuenta que EVMC permite por diseño que el host llame a la EVM y que la EVM vuelva a llamar al host, y que esto último generaba un coste de interfaz notable al integrarse en Erigon a través de CGo, hasta el punto de que hubo que añadir código C++ para eliminarlo. La lección es que conectar la EVM más rápida a un cliente y hacer que el cliente se acelere de inmediato son dos cosas distintas.
El beneficio de la forma de biblioteca se ve más claro en el plano del ecosistema. Entre los usuarios que enumera la documentación de revm hay constructores de bloques, clientes como Reth y Helios, herramientas como Foundry y Hardhat, varias L2 y algunos proyectos de zkVM. A la inversa, la ruta de bifurcar go-ethereum da señales de contracción en el lado de las L2: la documentación de Optimism muestra que el soporte de op-geth terminó el 31 de mayo de 2026 y que no admite la bifurcación dura Karst actualmente activa, y el cliente de ejecución que se promueve pasa a ser op-reth, basado en Reth y revm.
La consistencia no se demuestra con pruebas propias: state test y pruebas multicliente
El consenso exige que todos los nodos lleguen exactamente al mismo estado a partir del mismo fragmento de bytecode. Cualquier prueba que un cliente se haga a sí mismo solo puede demostrar coherencia interna, no consistencia.
El coste de las inconsistencias históricas puede cuantificarse. El 24 de noviembre de 2016, Ethereum se bifurcó en el bloque 2 686 351: cuando una transacción que provocaba la eliminación de cuentas vacías terminó con out-of-gas, go-ethereum no revirtió esas eliminaciones y Parity sí lo hizo. La cadena minoritaria se abandonó alrededor del bloque 2 686 516 y se invalidó la producción de unos 165 bloques; go-ethereum corrigió el mecanismo de logs en la v1.5.3 para igualar el comportamiento de Parity, y la nota oficial señalaba además que Parity también era inconsistente en un escenario más estrecho, el de «una llamada out-of-gas a un contrato precompilado». Esta revisión añadió a EIP-161 la aclaración de que «al revertir estado, la eliminación de cuentas vacías también debe revertirse». En el código de los clientes queda otra huella del mismo tipo: el log de estado de go-ethereum conserva una marca explícita de touch para la dirección del precompilado RIPEMD160, 0x03, y un comentario del código fuente explica que sirve para reproducir el caso especial de touch/revert de una cuenta vacía del bloque 1 714 175. Este tipo de compatibilidad codificada entre la especificación y la implementación perdurará mucho tiempo, y es también la razón de que la consistencia entre implementaciones tenga que comprobarse caso por caso.
La respuesta de la industria ha sido hacer que la especificación y los vectores de prueba tengan el mismo origen. La especificación de la capa de ejecución se mantiene como implementación de referencia en Python (Execution Layer Specification, conocida como EELS), y el framework de pruebas genera los vectores de prueba (fixture) a partir de la especificación. execution-spec-tests era originalmente un framework de Python y una colección de casos para generar vectores de prueba, y en noviembre de 2025 se trasladó por completo a ethereum/execution-specs (el repositorio antiguo se archivó después); la nota oficial indica que la ubicación de publicación de los fixture para clientes no cambia, y desde entonces especificación y pruebas comparten origen.
La forma de evaluar un state test es muy directa: dados un estado previo a la ejecución pre, un entorno env, una transacción transaction y un resultado esperado por fork post, el cliente debe coincidir, tras aplicar la transacción, con la raíz de estado hash y el resumen de logs logs registrados en post; para las transacciones que deben fallar, expectException describe el tipo de excepción esperado. Las pruebas de bloque cubren a partir de ahí el procesamiento a nivel de bloque. Estos vectores pueden ejecutarse directamente con el punto de entrada de pruebas de cada cliente (go-ethereum usa evm statetest, Besu usa evmtool state-test, Nethermind usa nethtest y evmone usa evmone-statetest y evmone-blockchaintest), y también pueden meterse en Hive para enviar cargas de bloque a los clientes con la Engine API y comprobar la respuesta y la sincronización de estado, que es la forma de prueba más cercana al comportamiento en producción. El repositorio hive-tests de ethpandaops convierte estas pruebas en una tubería que se ejecuta a diario y cubre Besu, Erigon, EthereumJS, Ethrex, go-ethereum, Nethermind, Nimbus-EL y Reth.
También conviene dejar claros los límites de las pruebas. Solo pueden cubrir comportamientos que ya se han escrito; cada fork que se introduce abre una ventana más que los vectores aún no fijan. Lo que restringen es la semántica observable, no el rendimiento ni la estructura interna.
Despacho de instrucciones: tabla de saltos, switch generado e inlining
El bucle principal del intérprete, antes de ejecutar cada instrucción, tiene que localizar su función de tratamiento. La tabla de saltos consiste en consultar una vez un array de 256 entradas y llamar después a través de un puntero a función. Go no puede hacer inlining a través de valores de función, así que cada instrucción paga una carga de array más una llamada indirecta.
En 2026, go-ethereum presentó un cambio para pasar el despacho a un switch generado (el PR #35144 y el PR #35638 que lo sustituye). Un switch denso sobre el byte del opcode se compila como tabla de saltos, y el compilador puede insertar en línea dentro del bucle las funciones de tratamiento de los opcodes calientes. El cambio tiene tres niveles: los opcodes calientes cuyo comportamiento es estable entre forks pasan a ser case propios, con el gas constante y los límites inferior y superior de la pila escritos directamente como constantes; los pocos opcodes con gas dinámico pero invariantes entre forks siguen tarifándose por tabla, aunque llaman a su función de tratamiento por nombre; y todos los opcodes que cambian con el fork (CALL, CREATE, SSTORE, SLOAD, los de logs y los de copia) siguen despachándose por la tabla del fork actual. El bucle del intérprete original se conserva como implementación de referencia, y las pruebas ejecutan ambos bucles y comparan salida, gas, errores, reembolsos, logs y raíz de estado; además hay un fuzzing diferencial sobre bytecode arbitrario.
Los números que da el benchmark A/B del PR #35638 son los siguientes, con 2000 bloques de la mainnet (bloques 25 677 501 a 25 679 500), la máquina geth-benchmark-1, go1.27.0 y 3 ejecuciones por lado: el rendimiento pasa de 391,2 MGas/s a 424,9 MGas/s, el tiempo medio de newPayload baja de 71,6 ms a 65,1 ms y el p50 de la fase de ejecución baja de 37,47 ms a 31,58 ms. A 18 de septiembre de 2026, el PR sigue abierto en GitHub y sin fusionar, así que estas cifras son un benchmark interno del PR, no una medición de una versión publicada, y deben tratarse como pendientes de verificación al citarlas.
evmone tomó otro camino. Su intérprete baseline por defecto solo hace un análisis mínimo de JUMPDEST; el intérprete advanced, opcional, usa encadenamiento de llamadas indirectas (indirect call threading), representa el programa EVM cargado como una tabla de punteros a funciones que implementan instrucciones virtuales, y precalcula por bloque básico el gas y las necesidades de pila, validándolos de una vez al llegar a la entrada del bloque. El precio es un análisis del bytecode más pesado antes de la ejecución.
Contabilidad de gas: separación de constante y dinámico, y tarificación del acceso frío/caliente por slot
El intérprete tiene que calcular el coste antes de ejecutar el opcode. go-ethereum lo divide en una parte constante y una parte dinámica: el gas constante se descuenta directamente y el dinámico lo calcula la función de tratamiento según la pila, la memoria y el estado. El coste de ampliación de memoria crece de forma superlineal con el número de palabras necesarias, así que el intérprete primero calcula el tamaño de memoria que exige el opcode y después tarifa.
Después de EIP-2929, la contabilidad de gas quedó atada a la capa de acceso al estado. La propuesta exige mantener por transacción dos conjuntos, el de direcciones ya accedidas y el de slots de almacenamiento ya accedidos, con elementos de tipo Set[Tuple[Address, Bytes32]] en el segundo; el primer acceso a un slot cobra COLD_SLOAD_COST, es decir, 2100 gas, un acceso repetido cobra WARM_STORAGE_READ_COST, es decir, 100 gas, y el primer acceso a una dirección cobra COLD_ACCOUNT_ACCESS_COST, es decir, 2600 gas. Los conjuntos de acceso pertenecen al contexto de la transacción y los mantiene físicamente el entorno de ejecución del cliente, así que el intérprete tiene que volver a consultarlos y actualizarlos en cada SLOAD o llamada. Es también la razón de que modificar el intérprete sea muy difícil sin tocar la capa de estado.
Precalcular el gas por bloque tiene efectos secundarios observables. La documentación de evmone señala de forma explícita que, como las necesidades de todo el bloque básico se comprueban de antemano, pueden aparecer operaciones visibles desde fuera que con la tarificación instrucción a instrucción se habrían ejecutado y con la tarificación anticipada no se ejecutan; la documentación considera que esto no constituye un problema de consenso, porque la ejecución termina con una excepción dura y todos los efectos se revierten, pero puede producir trazas de ejecución distintas o terminar con un tipo de excepción distinto. Este tipo de diferencias cae justamente dentro de lo que restringen los vectores de prueba.
Memoria y marcos de llamada: sacar la asignación de la ruta caliente
Cada llamada a un contrato necesita pila y memoria. La pila de la EVM tiene un límite de 1024 elementos, la memoria se amplía a demanda y se tarifa, y los datos de retorno también necesitan un búfer. Estos objetos viven poco y se crean con mucha frecuencia, así que las implementaciones tienden en general a reutilizarlos en lugar de asignarlos cada vez: go-ethereum crea un objeto Memory en la entrada del intérprete y lo amplía por palabras cuando un opcode pide más memoria; la capa de contexto de revm mantiene un FrameStack con agrupación de elementos (item-pooling) que reutiliza los marcos de llamada por índice.
Precompilados: implementación nativa y disyuntivas de dependencias
Los contratos precompilados son implementaciones nativas en unas pocas direcciones fijas: en tiempo de ejecución los calcula directamente el host, sin interpretar bytecode, y el intérprete solo se encarga del enrutado y de la tarificación de gas; el coste de algunos precompilados depende además de la longitud de la entrada.
Las disyuntivas de una biblioteca independiente en esta capa merecen un análisis aparte. La documentación de evmone explica dos compromisos: ecrecover lo implementa evmone por su cuenta, con una pérdida de rendimiento; y expmod usa por defecto una implementación de relleno (stub) que solo responde correctamente a entradas conocidas, de modo que para obtener la implementación completa hay que activar EVMONE_PRECOMPILES_GMP=1 en la compilación e incorporar GMP como dependencia de compilación y de ejecución. Esto refleja un problema general: la corrección de un precompilado debe valer para cualquier entrada, y conseguir que el código nativo de aritmética de enteros grandes, recuperación de curva elíptica y hash Keccak coincida bit a bit con el de otras implementaciones es en sí mismo un punto propenso a divergencias.
Por qué el JIT no se ha convertido en la opción mayoritaria
El JIT (just-in-time compilation, compilación en tiempo de ejecución) dentro de los clientes de EVM solo ha tenido un intento formal: ethereum/evmjit, basado en LLVM, compilaba el código del contrato a código máquina en tiempo de ejecución para sustituir a la EVM basada en intérprete del cliente. El repositorio está ahora archivado y su README indica que el proyecto ya no se mantiene y que no debe usarse para nada importante.
El README no explica por qué se dejó de mantener; desde el punto de vista de las condiciones técnicas hay varios obstáculos. Los destinos de salto de la EVM provienen de valores de la pila, el código puede leerse en tiempo de ejecución y la estructura del programa no se determina hasta que se ejecuta, así que al compilador le resulta muy difícil obtener en tiempo de compilación un grafo de flujo de control estable como en la JVM; además, la cantidad de cómputo disponible por llamada está limitada por el tope de gas del bloque, de modo que el beneficio total tiene techo. La alternativa relativamente madura es llevar el análisis a la fase estática, y EOF (EVM Object Format, EIP-7692) es precisamente un intento en esa dirección, con elementos como el formato de contenedor, los saltos relativos estáticos, la validación de pila y la separación entre sección de código y sección de datos. Pero esta ruta no está activa por ahora: EOF se retiró del alcance de la actualización Fusaka el 28 de abril de 2025, el PR #9703 de EIPs se fusionó ese mismo día y solo cubre la retirada en la fase Fusaka; el estado de la propia EIP-7692 figura como stagnant y tampoco aparece en la lista oficial de alcance de Glamsterdam, la EIP-7773. El registro de cambios del sitio de seguimiento externo eipsinsight muestra que EIP-7692 se sacó del grupo de candidatas en la reorganización del alcance de Glamsterdam del 27 de agosto de 2026, y este dato procede únicamente de una fuente externa. Por eso, a corto plazo el campo de batalla principal de la optimización en los clientes de propósito general sigue siendo el intérprete, y la ejecución compilada aún no tiene hueco para aterrizar.
En qué condiciones estas optimizaciones no funcionan
Cuando la ejecución deja de ser la parte dominante del tiempo, el beneficio es decreciente. En el benchmark de go-ethereum citado antes, el p50 de la fase de ejecución baja de 37,47 ms a 31,58 ms, mientras que el p50 del tiempo total del bloque baja de 62,1 ms a 56,2 ms: según las cuentas de esa misma tabla, la lectura de estado, el hashing de estado y el commit suman alrededor de un tercio del tiempo total y, añadiendo el coste de la capa del motor, cerca de la mitad, de modo que el techo de la optimización del intérprete lo fija esa proporción. Cuanto más se inclina la carga hacia el acceso al estado y la E/S de disco, menor es el beneficio marginal de optimizar el intérprete.
Las diferencias entre forks impiden aplicar la optimización de forma global. La tabla de saltos se genera por fork, y el switch generado solo puede insertar en línea los opcodes cuyo comportamiento es estable entre forks; los opcodes cuyos metadatos cambian con el fork tienen que quedarse en la ruta de despacho por tabla, porque de lo contrario se escribirían constantes erróneas.
Las restricciones semánticas marcan la frontera de la optimización. Una optimización no puede alterar el orden de los logs, el orden de los hooks de trazado, el tipo de excepción ni la contabilidad de gas, todo lo cual está fijado por los vectores de prueba. El ejemplo de evmone en el que la comprobación anticipada a nivel de bloque básico puede terminar antes de tiempo es justamente uno de los puntos donde hay que sopesar de forma explícita la optimización y la semántica observable.
El coste de la consistencia sube con el número de implementaciones. Cada implementación adicional añade un punto más donde la frontera de consenso puede divergir; los vectores de prueba comprimen esa superficie, pero no la eliminan. El valor de redundancia que aportan varias implementaciones y ese coste son los dos extremos de una misma disyuntiva.
Fuentes
- Documentación de revm, Introduction: https://bluealloy.github.io/revm/
- Documentación de revm, Architecture: https://bluealloy.github.io/revm/architecture.html
- revm, trait
Database: https://docs.rs/revm/latest/revm/context/trait.Database.html - revm, trait
Host: https://docs.rs/revm-interpreter/latest/revm_interpreter/trait.Host.html - Repositorio de revm y lista de usuarios: https://github.com/bluealloy/revm
- README del repositorio de evmone (intérpretes baseline y advanced, disyuntivas de los precompilados): https://github.com/ethereum/evmone
- evmone, Efficient gas calculation algorithm for EVM: https://github.com/ethereum/evmone/blob/master/docs/efficient_gas_calculation_algorithm.md
- EVMC, Ethereum Client-VM Connector API: https://github.com/ethereum/evmc
- Retrospectiva del origen del proyecto Silkworm (EVMC y coste de la interfaz con CGo): https://erigon.substack.com/p/staged-sync-and-short-history-of
- Bucle del intérprete de go-ethereum: https://github.com/ethereum/go-ethereum/blob/master/core/vm/interpreter.go
- Definición de la tabla de saltos de go-ethereum: https://github.com/ethereum/go-ethereum/blob/master/core/vm/jump_table.go
- go-ethereum PR #35638 (switch generado e inlining con PGO, incluidas las condiciones del benchmark): https://github.com/ethereum/go-ethereum/pull/35638
- Repositorio de go-ethereum y nota de licencia: https://github.com/ethereum/go-ethereum
- Capa de transición de estado de go-ethereum (gas intrínseco, validación de nonce, reembolsos): https://github.com/ethereum/go-ethereum/blob/master/core/state_transition.go
- Yellow Paper de Ethereum (función de coste de ampliación de memoria): https://ethereum.github.io/yellowpaper/paper.pdf
- EIP-2929, Gas cost increases for state access opcodes: https://eips.ethereum.org/EIPS/eip-2929
- Repositorio execution-specs (especificación y vectores de prueba): https://github.com/ethereum/execution-specs
- The Weld is Complete (anuncio de la fusión de execution-spec-tests): https://steel.ethereum.foundation/blog/2025-11-04_weld_final/
- Descripción del formato de state test: https://steel.ethereum.foundation/docs/execution-specs/running_tests/test_formats/state_test/
- Ejecución directa de vectores de prueba y puntos de entrada de cada cliente: https://steel.ethereum.foundation/docs/execution-specs/running_tests/consume/direct/
- ethpandaops/hive-tests (pruebas de consistencia multicliente diarias): https://github.com/ethpandaops/hive-tests
- Aviso de seguridad de la Ethereum Foundation, defecto de consenso del 24-11-2016: https://blog.ethereum.org/2016/11/25/security-alert-11242016-consensus-bug-geth-v1-4-19-v1-5-2
- Notas de la versión v1.5.3 de go-ethereum: https://github.com/ethereum/go-ethereum/releases/tag/v1.5.3
- Repositorio ethereum/evmjit (archivado): https://github.com/ethereum/evmjit
- EIP-7692, EVM Object Format (EOFv1) Meta: https://eips.ethereum.org/EIPS/eip-7692
- Explicación de la retirada de EOF en la EIP-7607 (EIPs PR #9703): https://github.com/ethereum/EIPs/pull/9703
- EIP-7773, Hardfork Meta - Glamsterdam (lista oficial de alcance, sin EOF): https://eips.ethereum.org/EIPS/eip-7773
- Documentación de Optimism, anuncio del fin de soporte de op-geth: https://docs.optimism.io/notices/op-geth-deprecation
- Sitio de seguimiento externo eipsinsight, alcance de la actualización Glamsterdam y registro de cambios: https://eipsinsight.com/upgrade/glamsterdam