Cómo crear un intérprete rápido para un lenguaje dinámico
(zef-lang.dev)- Incluso un intérprete que recorre el AST directamente puede lograr grandes mejoras de rendimiento solo con representación de valores, cachés en línea, modelo de objetos, watchpoints y optimizaciones iterativas de detalle
- La línea base de Zef, casi sin considerar el rendimiento, era 35 veces más lenta que CPython 3.10, 80 veces más lenta que Lua 5.4.7 y 23 veces más lenta que QuickJS-ng 0.14.0, pero tras 21 etapas de optimización logró una aceleración de 16.646 veces
- El mayor salto se produjo al rediseñar el modelo de objetos y combinarlo con cachés en línea; luego, el acceso basado en Storage y Offsets, la especialización cacheada del AST y la aplicación de watchpoints para vigilar sobreescrituras de nombres aportaron una mejora adicional de 4.55 veces
- Otras mejoras acumuladas incluyeron eliminar el dispatch basado en cadenas, introducir Symbol, cambiar la estructura de paso de argumentos, especializar getter y setter, usar rutas cortas en la tabla hash, y especializar literales de arreglo junto con
sqrtytoString - Incluyendo el port a Yolo-C++, registró una velocidad 66.962 veces mayor que la línea base; es 1.889 veces más rápido que CPython 3.10 y 2.968 veces más rápido que QuickJS-ng 0.14.0, pero no es apto para cargas de trabajo de larga duración porque no libera memoria
Introducción y metodología de evaluación
- El objetivo de la optimización es un intérprete que recorre el AST directamente, con la meta de llevar el lenguaje dinámico Zef, creado por diversión, a un nivel capaz de competir con Lua, QuickJS y CPython
- Más que centrarse en compiladores JIT o en el ajuste fino de un GC maduro, se enfoca en optimizaciones aplicables incluso partiendo sin bases previas
- Las técnicas tratadas son representación de valores, caché en línea, modelo de objetos, watchpoint y aplicación repetida de optimizaciones sensatas
- Solo con las técnicas del artículo se logró una gran mejora de rendimiento sin SSA, GC, bytecode ni código máquina
- 16 veces de aceleración según el contenido principal
- 67 veces de aceleración al incluir el port incompleto a Yolo-C++
- La evaluación de rendimiento usa la suite de benchmarks ScriptBench1
- Los benchmarks incluidos son Richards OS scheduler, DeltaBlue constraint solver, N-Body physics simulation y Splay binary tree test
- Se usaron ports existentes para JavaScript, Python y Lua
- Los ports de Python y Lua de Splay fueron generados con Claude
- El entorno experimental es Ubuntu 22.04.5, Intel Core Ultra 5 135U, 32GB RAM, Fil-C++ 0.677
- Lua 5.4.7 fue compilado con GCC 11.4.0
- QuickJS-ng 0.14.0 usó el binario de GitHub releases
- CPython 3.10 usó la versión provista por Ubuntu
- En todos los experimentos se usó el promedio de 30 ejecuciones mezcladas aleatoriamente
- La mayoría de las comparaciones se realizó entre el intérprete Zef compilado con Fil-C++ y otros intérpretes construidos con el compilador Yolo-C
El intérprete original de Zef
- Se indica que fue escrito casi sin considerar el rendimiento, y que solo hubo dos decisiones tomadas conscientemente con ese fin
-
Representación de valores
- Usa tagged value de 64 bits
- Los valores que puede contener son double, entero de 32 bits y
Object*
- Los valores que puede contener son double, entero de 32 bits y
- Los double se representan con un desplazamiento de
0x1000000000000- Se presenta como una técnica aprendida de JavaScriptCore
- En la literatura se la denomina NuN tagging
- Los enteros y punteros usan su representación nativa
- Depende de la suposición de que el valor del puntero no será menor que
0x100000000 - Se indica explícitamente que es una elección arriesgada
- Se menciona que una alternativa habría sido poner a los enteros una etiqueta en bits altos de
0xffff000000000000
- Depende de la suposición de que el valor del puntero no será menor que
- Esta representación permite implementar una ruta rápida basada en pruebas de bits para operaciones numéricas
- Una ventaja aún más importante es evitar la asignación en heap para números
- Al crear un intérprete nuevo, es importante elegir bien desde el inicio la representación básica de valores, ya que cambiarla después es muy difícil
- Se propone como punto de partida para implementar un lenguaje de tipado dinámico un tagged value de 32 o 64 bits
- Usa tagged value de 64 bits
-
Elección del lenguaje de implementación
- Se eligió una variante de C++ como lenguaje capaz de incorporar suficiente optimización
- Se indica explícitamente que no elegiría Java por su límite en optimizaciones de bajo nivel
- También explica que no elegiría Rust debido al estado global mutable y a la representación de heap con referencias cíclicas necesarias para implementar un lenguaje con GC
- Menciona que podría usarse Rust en parte o en todo si se acepta una configuración multilenguaje o mucho código
unsafe
- Menciona que podría usarse Rust en parte o en todo si se acepta una configuración multilenguaje o mucho código
-
Decisiones equivocadas desde la perspectiva de ingeniería de rendimiento
- Uso de Fil-C++
- Permitía desarrollar rápido y ofrecía GC gratis
- Reporta violaciones de seguridad de memoria con información de diagnóstico y stack trace
- No hay comportamiento indefinido
- El costo en rendimiento suele ser de alrededor de 4 veces
- Intérprete recursivo que recorre el AST
- Estructura basada en el método virtual
Node::evaluatesobrescrito en múltiples lugares
- Estructura basada en el método virtual
- Abuso de cadenas
- El nodo AST
Getguarda unstd::stringque describe el nombre de la variable - Esa cadena se usa en cada acceso a variable
- El nodo AST
- Abuso de tablas hash
- Al ejecutar
Get, se consulta unstd::unordered_mapcon una clave de tipo cadena
- Al ejecutar
- Búsqueda de scopes basada en cadenas de llamadas recursivas
- Permite anidamiento y closures en casi cualquier estructura
- En un anidamiento como función F dentro de la clase A y función G dentro de la clase B, un método de A puede ver campos de A, variables locales de F, campos de B y variables locales de G
- La implementación original resolvía esto con funciones recursivas de C++ que consultaban distintos objetos de scope
- Uso de Fil-C++
-
Características de la implementación original
- A pesar de las malas decisiones, fue posible implementar con poco código un intérprete de lenguaje bastante complejo
- El módulo más grande es el parser
- El resto es relativamente simple y claro
-
Rendimiento inicial
- El intérprete original era 35 veces más lento que CPython 3.10
-
80 veces más lento que Lua 5.4.7
- 23 veces más lento que QuickJS-ng 0.14.0
Tabla general del progreso de optimización
- La tabla resume los cambios de rendimiento desde Zef Baseline hasta Zef Change #21: No Asserts, y también Zef in Yolo-C++
- Las columnas de comparación son vs Zef Baseline, vs Python 3.10, vs Lua 5.4.7, vs QuickJS-ng 0.14.0
- En la fila final, Zef Change #21: No Asserts es 16.646 veces más rápido que la línea base
-
2.13 veces más lento que Python 3.10
-
4.781 veces más lento que Lua 5.4.7
- 1.355 veces más lento que QuickJS-ng 0.14.0
-
-
Zef in Yolo-C++** es 66.962 veces más rápido que la línea base
-
1.889 veces más rápido que Python 3.10
-
1.189 veces más lento que Lua 5.4.7
- 2.968 veces más rápido que QuickJS-ng 0.14.0
-
Etapa inicial de optimización
-
Optimización #1: llamada directa de operadores
- El parser ya no crea operadores como nodos
DotCallcon nombre de operador, sino que genera nodos AST separados para cada operador - En Zef,
a + bya.add(b)son lo mismo- Originalmente,
a + bse parseaba comoDotCall(a, "add")con el argumentob - En cada operación aritmética ocurría una búsqueda del string con el nombre del método del operador
DotCallpasaba el string aValue::callMethodValue::callMethodhacía múltiples comparaciones de strings
- Originalmente,
- Después del cambio, el parser genera nodos
Binary<>yUnary<>- Usando templates y lambdas, se proveen overrides distintos de
Node::evaluatepara cada operador - Cada nodo llama directamente a la ruta rápida de
Valuepara ese operador - Por ejemplo,
a + bllama aBinary<lambda for add>::evaluatey luego aValue::add
- Usando templates y lambdas, se proveen overrides distintos de
- El efecto en rendimiento fue una mejora de 17.5%
- En este punto, el rendimiento era 30 veces más lento que CPython 3.10
- 67 veces más lento que Lua 5.4.7
- 19 veces más lento que QuickJS-ng 0.14.0
- El parser ya no crea operadores como nodos
-
Optimización #2: llamada directa de operadores RMW
- Los operadores normales se aceleraron, pero las formas RMW como
a += bseguían usando dispatch basado en strings - Se cambió el parser para que generara nodos separados para cada caso RMW
- El parser pide que el nodo LValue se reemplace a sí mismo como RMW mediante una llamada virtual
makeRMW - Los LValue que se convierten en RMW son Get, Dot y Subscript
- Get corresponde a lectura de variable
id - Dot corresponde a
expr.id - Subscript corresponde a
expr[index]
- Get corresponde a lectura de variable
- Cada llamada virtual usa el macro
SPECIALIZE_NEW_RMW- SetRMW es
id += value - DotSetRMW es
expr.id += value - SubscriptRMW es
expr[index] += value
- SetRMW es
- La especialización de operadores del cambio #1 usa dispatch con lambdas
- Para RMW se usa un enum
- Se eligió así porque había que manejar las tres rutas
get,dotysubscript, y pasar el enum por varios lugares - Al final, la función template
Value::callRMW<>hace el dispatch real de la llamada al operador RMW
- Se eligió así porque había que manejar las tres rutas
- El efecto en rendimiento fue una mejora de 3.7%
- En este punto, el rendimiento era 29 veces más lento que CPython 3.10
- 65 veces más lento que Lua 5.4.7
- 18.5 veces más lento que QuickJS-ng 0.14.0
- 1.22 veces más rápido que el punto de partida
- Los operadores normales se aceleraron, pero las formas RMW como
-
Optimización #3: evitar la verificación de IntObject
- Un cuello de botella era que la ruta rápida de
ValueusabaisInt(), y suisIntSlow()interno hacía una llamada virtual aObject::isInt() - La representación original de valores tenía cuatro casos
- tagged int32
- tagged double
- IntObject para int64 que no pueden representarse como int32
- todos los demás objetos
- Incluso en el caso de IntObject, el dispatch de métodos enteros estaba a cargo de
Value- Esto era para mantener todas las implementaciones de operaciones aritméticas en un solo lugar:
Value
- Esto era para mantener todas las implementaciones de operaciones aritméticas en un solo lugar:
- Después de la optimización, la ruta rápida de
Valuesolo considera int32 y double- La lógica de manejo de IntObject se movió al propio
IntObject - Se evitó la llamada a
isInt()que ocurría en cada dispatch de método
- La lógica de manejo de IntObject se movió al propio
- El efecto en rendimiento fue una mejora de 1%
- En este punto, el rendimiento era 29 veces más lento que CPython 3.10
- 65 veces más lento que Lua 5.4.7
- 18 veces más lento que QuickJS-ng 0.14.0
- 1.23 veces más rápido que el punto de partida
- Un cuello de botella era que la ruta rápida de
-
Optimización #4: Symbol
- Originalmente, el intérprete usaba
std::stringen casi todas partes - Los lugares donde usar strings resultaba costoso eran
Context::get,Context::set,Context::callFunction,Value::callMethod,Value::dot,Value::setDot,Value::callOperator<>, y la familia deObject::callMethod - Con esta estructura, en vez de hacer una simple búsqueda en hash table, se hacía una búsqueda en hash table con claves string, repitiendo durante la ejecución hashing y comparación de strings
- La optimización reemplazó las búsquedas basadas en strings por punteros a objetos
Symbolcon hash-consing - Se agregó una nueva clase
Symbol- Implementada en
symbol.hysymbol.cpp - Symbol y string pueden convertirse mutuamente
- Al convertir un string a Symbol, se hace hash consing con una hash table global
- Como resultado, basta con comparar la identidad de punteros
Symbol*para determinar si es el mismo símbolo
- Implementada en
- En lugar de literales string, se usan símbolos preparados de antemano
- Por ejemplo, en lugar de
"subscript", se usaSymbol::subscript
- Por ejemplo, en lugar de
- Muchas firmas de funciones cambiaron de
const std::string&aSymbol* - El efecto en rendimiento fue una mejora de 18%
- En este punto, el rendimiento era 24 veces más lento que CPython 3.10
- 54 veces más lento que Lua 5.4.7
- 15 veces más lento que QuickJS-ng 0.14.0
- 1.46 veces más rápido que el punto de partida
- Originalmente, el intérprete usaba
-
Optimización #5: inlining de Value
- La clave fue permitir el inlining de funciones importantes
- Casi todos los cambios giran en torno a la introducción del nuevo header
valueinlines.h - La razón de separarlo de
value.hen otro header es que usa headers que necesitan incluirvalue.h - El efecto en rendimiento fue una mejora de 2.8%
- En este punto, el rendimiento era 24 veces más lento que CPython 3.10
- 53 veces más lento que Lua 5.4.7
- 15 veces más lento que QuickJS-ng 0.14.0
- 1.5 veces más rápido que el punto de partida
Rediseño del modelo de objetos y la estructura de caché
-
Optimización #6: modelo de objetos, caché inline y watchpoint
- Se reorganizó a gran escala cómo funcionan
Object,ClassObjectyContextpara reducir el costo de asignación de objetos y evitar búsquedas en tablas hash durante el acceso - Este cambio combina tres funciones: modelo de objetos, caché inline y watchpoint
- Se reorganizó a gran escala cómo funcionan
-
Modelo de objetos
- Antes se asignaba un objeto
Contextpara cada ámbito léxico- Cada
Contexttenía una tabla hash con las variables de ese ámbito
- Cada
- Los objetos tenían una estructura más compleja
- Cada objeto tenía una tabla hash que mapeaba las clases de las que es instancia a
Context
- Cada objeto tenía una tabla hash que mapeaba las clases de las que es instancia a
- Esta estructura era necesaria por la herencia y los ámbitos anidados
- Cuando
Barhereda deFoo,BaryFoohacen closure sobre ámbitos distintos - También pueden tener campos privados distintos con el mismo nombre
- Cuando
- La nueva estructura introduce el concepto de
Storage- Los datos se almacenan según Offsets
- El offset lo determina algún
Context
Contextsigue existiendo, pero se crea de antemano en el paseresolvedel AST en lugar de hacerlo al crear el objeto o el ámbito- Cuando realmente se crea un objeto o ámbito, solo se asigna Storage según el tamaño calculado por ese
Context
- Antes se asignaba un objeto
-
Caché inline
- Técnica que recuerda el tipo dinámico de
exprvisto por última vez y el último offset al que se resolviónameen una ubicación de código comoexpr.name - Es una técnica clásica que suele explicarse en el contexto de JIT, pero aquí se aplica al intérprete
- La información recordada se implementa construyendo con placement construct nodos AST especializados sobre el nodo AST normal
- Técnica que recuerda el tipo dinámico de
-
Componentes de la caché inline
CacheRecipe- Rastrea qué hizo un acceso específico y si se puede cachear
- Se insertaron llamadas a
CacheRecipeen varios puntos deContext,ClassObjectyPackage- Recolectan información del proceso de acceso
- Funciones de evaluación de AST como
Dot::evaluatepasan elCacheRecipeobtenido de la operación polimórfica que realizaron, junto conthis, aconstructCache<> constructCache- Compila una nueva especialización de nodo AST según
CacheRecipe - Genera varios nodos AST especializados con maquinaria de plantillas
- Si es acceso a una variable local, hace una carga directa desde el
storagerecibido - Verifica con un class check si es la misma clase vista por última vez
- Después hace una llamada directa a función a la última función vista
- Si hace falta, combina chain step y watchpoint
- Compila una nueva especialización de nodo AST según
- Cada nodo AST que puede cachearse tiene su propia variante cacheada
- Primero intenta una llamada rápida mediante el objeto
cache - El tipo del objeto
cachelo determinaconstructCache<>
- Primero intenta una llamada rápida mediante el objeto
-
watchpoint
- Se muestra un ejemplo con una variable
xen un ámbito léxico, una claseFoodentro de ese ámbito y un método deFooque accede ax - Si dentro de
Foono existe una función o variable llamadax, parecería que puede leer directamente laxexterior - Sin embargo, una subclase podría agregar un getter
x - En ese caso, el resultado del acceso debería ser ese getter, no la
xexterior - Para manejar esta posibilidad de cambio, la caché inline instala un
Watchpointen tiempo de ejecución - En el ejemplo se usa un watchpoint para vigilar si ese nombre fue sobreescrito
- Se muestra un ejemplo con una variable
-
Por qué se implementaron las tres funciones al mismo tiempo
- Solo con el nuevo modelo de objetos es difícil lograr una mejora significativa si la caché inline no funciona bien
- La caché inline tampoco aporta mucho sin watchpoint, porque es difícil manejar de forma segura muchas condiciones de caché
- El nuevo modelo de objetos y watchpoint deben funcionar bien en conjunto
-
Progreso de implementación y partes difíciles
- Se comenzó escribiendo una versión simple de
CacheRecipey diseñando Storage y Offsets cercanos a la forma final - Una de las tareas más difíciles fue reemplazar la forma de implementar las clases intrínsecas
- Ejemplo con arreglos
- Antes,
ArrayObject::tryCallMethodimplementaba todos los métodos interceptando la llamada virtual aObject::tryCallMethod - En el nuevo modelo de objetos,
Objectya no tiene vtable ni métodos virtuales - En su lugar,
Object::tryCallMethoddelega aobject->classObject()->tryCallMethod(object, ...) - Por lo tanto, para ofrecer métodos de
Array, fue necesario crear la propia clase de Array con esos métodos
- Antes,
- Como resultado, gran parte de la funcionalidad intrínseca pasó de estar dispersa por toda la implementación a concentrarse en
makerootcontext.cpp - Se consideró un resultado positivo porque la caché inline también se aplica sin cambios a las funciones nativas/intrínsecas de los objetos
- El efecto en rendimiento fue de una mejora de 4.55x
- En este punto el rendimiento era 5.2 veces más lento que CPython 3.10
- 11.7 veces más lento que Lua 5.4.7
- 3.3 veces más lento que QuickJS-ng 0.14.0
- 6.8 veces más rápido que el punto de partida
- Se evaluó que la desventaja de Fil-C++ frente a otros intérpretes se redujo, en general, hasta un nivel equivalente al costo propio de Fil-C
- Se comenzó escribiendo una versión simple de
Optimización de llamadas y rutas de acceso
-
Optimización #7: Mejora de la estructura de paso de argumentos
- Antes del cambio, el intérprete de Zef pasaba los argumentos de función como
const std::optional<std::vector<Value>>& - La razón por la que se necesitaba
optionalera que en algunos casos límite había que distinguir entre estas dos cosaso.gettero.function()
- En Zef, por lo general ambas son llamadas de función, pero existe esta excepción
o.NestedClasso.NestedClass()
- La primera devuelve el objeto
NestedClassen sí - La segunda crea una instancia
- Por lo tanto, hay que distinguir entre una llamada de función sin argumentos y una llamada tipo getter en la que el arreglo de argumentos está vacío
- Sin embargo, la estructura anterior era ineficiente
- El llamador hacía una asignación de
vector - El llamado volvía a asignar un arguments scope que era una copia de ese vector
- El llamador hacía una asignación de
- El cambio introduce el tipo
Arguments- Su forma es exactamente igual al arguments scope que antes creaba el llamado
- Ahora el llamador lo asigna directamente en esa forma
- También en Yolo-C++ se redujo la cantidad de asignaciones al eliminar el
mallocdel backing store del vector - En Fil-C++,
std::optionalen sí se asigna en el heap- Incluso sin
std::optional, pasarconst std::vector<>&también implica asignación - Lo que se asigna en la pila se marca como si se asignara en el heap
- También se menciona que, del lado del llamador, el vector no se dimensionaba previamente y por eso se reasignaba varias veces
- Incluso sin
- Gran parte del cambio consistió en reemplazar firmas de función por
Arguments* - El efecto en rendimiento fue una mejora de 1.33x
- En este punto, el rendimiento era 3.9 veces más lento que CPython 3.10
- 8.8 veces más lento que Lua 5.4.7
- 2.5 veces más lento que QuickJS-ng 0.14.0
- 9.05 veces más rápido que el punto de partida
- Antes del cambio, el intérprete de Zef pasaba los argumentos de función como
-
Optimización #8: Especialización de getters
- Zef, al igual que Ruby, tiene los campos de instancia privados por defecto
- Ejemplo:
class Foo { my f fn (inF) f = inF }- Guarda el valor recibido en el constructor en la variable local
f, visible solo para la instancia
- Guarda el valor recibido en el constructor en la variable local
- Incluso instancias del mismo tipo no pueden acceder a la
fde otro objeto- Ejemplo:
fn nope(o) o.f println(Foo(42).nope(Foo(666)))- La
o.fdentro denopeno puede acceder a lafdeo
- Ejemplo:
- La razón es que los campos funcionan según la forma en que aparecen en la cadena de scopes de los miembros de la clase
o.fno es una lectura de campo, sino una solicitud de llamada a un método llamadof
- Por eso aparece con frecuencia este patrón
my ffn f f- Es decir, un método llamado
fque devuelve la variable localf
- Existe una sintaxis más corta:
readable f- Es la forma abreviada de
my fyfn f f
- Es la forma abreviada de
- Muchas llamadas de método son en la práctica llamadas a getters
- Es un desperdicio que todos los getters funcionen evaluando el AST
- La optimización es la especialización de getters
- Su centro es
UserFunction - Con el nuevo método
Node::inferGetter, se infiere si el cuerpo de la función es un getter simple
- Su centro es
- Reglas de inferencia
Block::inferGetterse infiere como getter si todo lo que contiene también puede inferirse como getterGet::inferGetterse infiere a sí mismo como getter y devuelve el offset que debe cargarseContext::tryGetFieldOffsetsdevuelve unOffsetsno vacío solo cuando ese campo definitivamente existe en el scope léxico donde se ejecutará el getterUserFunction, si el cuerpo de la función puede inferirse como getter, se resuelve como una subclase especial deFunctionque solo lee directamente desde un offset conocido
- El efecto en rendimiento fue una mejora de 5.6%
- En este punto, el rendimiento era 3.7 veces más lento que CPython 3.10
- 8.3 veces más lento que Lua 5.4.7
- 2.4 veces más lento que QuickJS-ng 0.14.0
- 9.55 veces más rápido que el punto de partida
-
Optimización #9: Especialización de setters
- En la inferencia de setters se necesita hacer pattern matching del patrón
fn set_fieldName(newValue) fieldName = newValue - En la etapa de inferencia de
UserFunction, hay que pasar el nombre del parámetro del setter - En la etapa de inferencia de
Set, hay que verificar que no sea una escritura sobre ClassObject y también confirmar que el parámetro del setter se use como fuente del set - El efecto en rendimiento fue una mejora de 3.4%
- En este punto, Zef era 3.6 veces más lento que CPython 3.10
- 8 veces más lento que Lua 5.4.7
- 2.3 veces más lento que QuickJS-ng 0.14.0
- 9.87 veces más rápido que el punto de partida
- En la inferencia de setters se necesita hacer pattern matching del patrón
-
Optimización #10: Inlining de
callMethod- Se hizo inline una función importante con un cambio de una sola línea
- El efecto en rendimiento fue una mejora de 3.2%
- En este punto, Zef era 3.5 veces más lento que CPython 3.10
- 7.8 veces más lento que Lua 5.4.7
- 2.2 veces más lento que QuickJS-ng 0.14.0
- 10.2 veces más rápido que el punto de partida
-
Optimización #11: Tabla hash
- Cuando ocurría un inline cache miss en una llamada de método, había que bajar por
ClassObject::tryCallMethodyClassObject::TryCallMethodDirect, y ambas rutas eran grandes y complejas - El costo de búsqueda anterior era O(profundidad de la jerarquía)
- En cada clase de la jerarquía se hacía una búsqueda en tabla hash para comprobar si la llamada se resolvía como función miembro
- En cada clase de la jerarquía también se hacía una búsqueda en tabla hash para comprobar si la llamada se resolvía como clase anidada
- El nuevo cambio introduce una tabla hash global que usa como clave la clase receptora y el símbolo
- Con una sola búsqueda devuelve directamente el callee
- En
classobject.h, antes de bajar hastatryCallMethodSlow, primero se consulta esta tabla global - En
classobject.cpp, los resultados de búsquedas exitosas se registran en la tabla global - La implementación de la tabla hash global en sí es relativamente simple
- El efecto en rendimiento fue una mejora de 15%
- En este punto, Zef era 3 veces más lento que CPython 3.10
- 6.8 veces más lento que Lua 5.4.7
- 1.9 veces más lento que QuickJS-ng 0.14.0
- 11.8 veces más rápido que el punto de partida
- Cuando ocurría un inline cache miss en una llamada de método, había que bajar por
-
Optimización #12: Evitar
std::optional- En Fil-C++,
std::optionalnecesita asignación en heap debido a una patología del compilador relacionada con union - En general LLVM maneja con flexibilidad los tipos de acceso a memoria de union, pero esto choca con invisicaps
- Ocurre que los punteros dentro de un union pierden capacidades de manera difícil de predecir desde la perspectiva del programador
- Como resultado, en Fil-C puede ocurrir un pánico por dereferenciar un objeto con null capability incluso sin error del programador
- Para mitigar esto, el compilador de Fil-C++ inserta intrinsics para que LLVM actúe de forma conservadora al manejar variables locales de tipo union
- Después, el pase
FilPizlonatorintenta hacer su propio escape analysis para volver posibles las asignaciones en registros de variables locales de tipo union- Sin embargo, este análisis no es tan completo como el análisis SROA general de LLVM
- Como resultado, pasar clases que incluyen union, como
std::optional, con frecuencia termina provocando asignaciones de memoria en Fil-C++ - Este cambio evita las rutas de código que en el hot path llevan a
std::optional - El efecto en rendimiento fue una mejora de 1.7%
- En este punto, Zef era 3 veces más lento que CPython 3.10
- 6.65 veces más lento que Lua 5.4.7
- 1.9 veces más lento que QuickJS-ng 0.14.0
- En Fil-C++,
-
12 veces más rápido que el punto de partida
-
Optimización #13: argumentos especializados
- Todas las funciones built-in de Zef reciben 1 o 2 argumentos, y en la implementación nativa no hace falta asignar un objeto
Argumentspara contenerlos - Los setter también reciben siempre un solo argumento y, cuando se ha realizado la inferencia del setter, una implementación de setter especializada también basta con recibir directamente el argumento de valor sin un objeto
Arguments - Con este cambio se introducen los tipos de argumentos especializados
ZeroArguments,OneArgumentyTwoArguments- Si el callee no lo necesita, el caller puede evitar asignar un objeto
Arguments
- Si el callee no lo necesita, el caller puede evitar asignar un objeto
- Se necesita
ZeroArgumentspara distinguirlo de(Arguments*)nullptr- Antes se usaba
(Arguments*)nullptrcon el significado de llamada a getter, y se mantiene esa lógica - Ahora
ZeroArgumentssignifica llamada a función sin argumentos
- Antes se usaba
- Muchos de los cambios consisten en convertir en plantillas las funciones que reciben argumentos
- Se realiza una instanciación explícita para
ZeroArguments,OneArgument,TwoArgumentsyArguments* - Gran parte del código existente usaba
Value::getArgcomo helper para extraer argumentos, y aquí se añaden overloads de argumentos especializados - Los cambios en el código nativo que usa argumentos fueron modificaciones relativamente directas
- Se realiza una instanciación explícita para
- El efecto en rendimiento es una mejora del 3.8%
- En este punto, Zef es 2.9 veces más lento que CPython 3.10
- 6.4 veces más lento que Lua 5.4.7
- 1.8 veces más lento que QuickJS-ng 0.14.0
- 12.4 veces más rápido que el punto de partida
- Todas las funciones built-in de Zef reciben 1 o 2 argumentos, y en la implementación nativa no hace falta asignar un objeto
Evasión de patologías de Fil-C y especialización detallada
-
Optimización #14: mejora de la slow path de
Value- Se logró una gran mejora de velocidad con otra evasión de una patología de Fil-C
- Antes del cambio, la slow path out-of-line de
Valueera una función miembro deValuey requería un argumento implícitoconst Value* - En esta estructura, el caller tenía que asignar
Valueen la pila - En Fil-C++, toda asignación en la pila es asignación en el heap
- Por lo tanto, el código que llamaba a la slow path asignaba
Valueen el heap
- Por lo tanto, el código que llamaba a la slow path asignaba
- Después del cambio, estos métodos se volvieron
staticyValuese pasa por valor- Como resultado, ya no se necesita una asignación separada
- El efecto en rendimiento fue una mejora de 10%
- En este punto, Zef era 2.6 veces más lento que CPython 3.10
- 5.8 veces más lento que Lua 5.4.7
- 1.65 veces más lento que QuickJS-ng 0.14.0
- 13.6 veces más rápido que el punto de partida
-
Optimización #15: eliminación de duplicación en
DotSetRMW- Se eliminó algo de código duplicado
- Se esperaba que la reducción de código máquina ayudara en las funciones plantilla especializadas por
constructCache<> - El resultado real fue ningún impacto en el rendimiento
-
Optimización #16: especialización de
sqrt- El inline cache enruta bien las llamadas hacia la función deseada, pero solo funciona con objetos
- En los no-objetos, las fast path de
Binary<>,Unary<>yValue::callRMW<>dependen de verificar si el receiver esintodouble - Este enfoque solo se aplica a operadores reconocidos por el parser
- No se aplica a formas como
value.sqrt
- No se aplica a formas como
- Con este cambio,
Dotpuede especializarse paravalue.sqrt - El efecto en rendimiento fue una mejora de 1.6%
- En este punto, Zef era 2.6 veces más lento que CPython 3.10
- 5.75 veces más lento que Lua 5.4.7
- 1.6 veces más lento que QuickJS-ng 0.14.0
- 13.8 veces más rápido que el punto de partida
-
Optimización #17: especialización de
toString- Se aplicó la especialización de
toStringcasi del mismo modo que la optimización anterior - Este cambio incluye lógica para reducir la cantidad de asignaciones al convertir
inta cadena - El efecto en rendimiento fue una mejora de 2.7%
- En este punto, Zef era 2.5 veces más lento que CPython 3.10
- 5.6 veces más lento que Lua 5.4.7
- 1.6 veces más lento que QuickJS-ng 0.14.0
- 14.2 veces más rápido que el punto de partida
- Se aplicó la especialización de
-
Optimización #18: especialización de literales de arreglo
- Código como
my whatever = [1, 2, 3]requiere asignar un nuevo arreglo en Zef porque los arreglos pueden tener alias y ser mutables - Antes del cambio, en cada ejecución se recorría el AST hacia abajo y se evaluaban recursivamente
1,2,3cada vez - Este cambio especializa el nodo
ArrayLiteralpara el caso de asignación de un arreglo constante - El efecto en rendimiento fue una mejora de 8.1%
- En este punto, Zef era 2.3 veces más lento que CPython 3.10
- 5.2 veces más lento que Lua 5.4.7
- 1.5 veces más lento que QuickJS-ng 0.14.0
- 15.35 veces más rápido que el punto de partida
- Código como
-
Optimización #19: mejora de
Value::callOperator- La misma optimización que antes mejoró la velocidad al no pasar
Valuepor referencia también se aplicó a la slow path decallOperator - El efecto en rendimiento fue una mejora de 6.5%
- En este punto, Zef era 2.2 veces más lento que CPython 3.10
- 4.9 veces más lento que Lua 5.4.7
- 1.4 veces más lento que QuickJS-ng 0.14.0
- 16.3 veces más rápido que el punto de partida
- La misma optimización que antes mejoró la velocidad al no pasar
-
Optimización #20: mejores opciones de C++
- En Fil-C++ se desactivaron RTTI y el hardening de libc++ que no eran necesarios
- No hubo cambios en el código C++ en sí; solo cambios de configuración del sistema de build
- El efecto en rendimiento fue una mejora de 1.8%
- En este punto, Zef era 2.1 veces más lento que CPython 3.10
- 4.8 veces más lento que Lua 5.4.7
- 1.35 veces más lento que QuickJS-ng 0.14.0
- 16.6 veces más rápido que el punto de partida
-
Optimización #21: desactivación de assert
- Como última optimización, se aplicó la desactivación predeterminada de assertions
- El código existente usaba la macro
ZASSERTespecífica de Fil-C- Una estructura que siempre ejecutaba asserts
- Después del cambio se usa la macro interna
ASSERT- Los asserts solo se ejecutan cuando
ASSERTS_ENABLEDestá configurado
- Los asserts solo se ejecutan cuando
- Este cambio también incluyó otras modificaciones para que el código pudiera compilarse con Yolo-C++
- Contra lo esperado, no hubo mejora de velocidad
Resultados y límites de Yolo-C++
- Al compilar el código con Yolo-C++, se obtuvo una mejora de velocidad de 4 veces
- Sin embargo, este enfoque no es sound y es suboptimal
- No es sound porque las llamadas al GC de Fil-C++ existentes se convierten en llamadas a
calloc - Como resultado, la memoria no se libera y, en cargas de trabajo que se ejecutan durante suficiente tiempo, el intérprete termina agotando la memoria
- En ScriptBench1, el tiempo de prueba es corto, así que no ocurre agotamiento de memoria
- No es sound porque las llamadas al GC de Fil-C++ existentes se convierten en llamadas a
- Es suboptimal porque el asignador real del GC es más rápido que
callocde glibc 2.35 - Por lo tanto, se menciona que si se agrega el GC real al port de Yolo-C++, podría lograrse una mejora aún mayor que 4 veces
- En este experimento se usó GCC 11.4.0
- En este punto, Zef era
-
1.9 veces más rápido que CPython 3.10
-
1.2 veces más lento que Lua 5.4.7
-
3 veces más rápido que QuickJS-ng 0.14.0
- 67 veces más rápido que el punto de partida
-
Datos de referencia sin procesar
- La unidad del tiempo de ejecución de los benchmarks es segundos
- La tabla incluye
nbody,splay,richards,deltablueygeomeanpara cada intérprete -
Python 3.10
nbody0.0364splay0.8326richards0.0822deltablue0.1135geomean0.1296
-
Lua 5.4.7
nbody0.0142splay0.4393richards0.0217deltablue0.0832geomean0.0577
-
QuickJS-ng 0.14.0
nbody0.0214splay0.7090richards0.7193deltablue0.1585geomean0.2036
-
Zef Baseline
nbody2.9573splay13.0286richards1.9251deltablue5.9997geomean4.5927
-
Zef Cambio #1: Operadores directos
nbody2.1891splay12.0233richards1.6935deltablue5.2331geomean3.9076
-
Zef Cambio #2: RMW directos
nbody2.0130splay11.9987richards1.6367deltablue5.0994geomean3.7677
-
Zef Cambio #3: Evitar IntObject
nbody1.9922splay11.8824richards1.6220deltablue5.0646geomean3.7339
-
Zef Cambio #4: Símbolos
nbody1.5782splay9.9577richards1.4116deltablue4.4593geomean3.1533
-
Zef Cambio #5: Valor inline
nbody1.4982splay9.7723richards1.3890deltablue4.3536geomean3.0671
-
Zef Cambio #6: Modelo de objetos y cachés inline
nbody0.3884splay3.3609richards0.2321deltablue0.6805geomean0.6736
-
Zef Cambio #7: Argumentos
nbody0.3160splay2.6890richards0.1653deltablue0.4738geomean0.5077
-
Zef Cambio #8: Getters
nbody0.2988splay2.6919richards0.1564deltablue0.4260geomean0.4809
-
Zef Cambio #9: Setters
nbody0.2850splay2.6690richards0.1514deltablue0.4072geomean0.4651
-
Zef Cambio #10:
callMethodinlinenbody0.2533splay2.6711richards0.1513deltablue0.4032geomean0.4506
-
Zef Cambio #11: Tabla hash
nbody0.1796splay2.6528richards0.1379deltablue0.3551geomean0.3906
-
Zef Cambio #12: Evitar
std::optionalnbody0.1689splay2.6563richards0.1379deltablue0.3518geomean0.3839
-
Zef Cambio #13: Argumentos especializados
nbody0.1610splay2.5823richards0.1350deltablue0.3372geomean0.3707
-
Zef Cambio #14: Rutas lentas de Value mejoradas
nbody0.1348splay2.5062richards0.1241deltablue0.3076geomean0.3367
-
Zef Cambio #15:
DotSetRMW::evaluatededuplicadonbody0.1342splay2.5047richards0.1256deltablue0.3079geomean0.3375
-
Zef Cambio #16:
sqrtrápidonbody0.1274splay2.5045richards0.1251deltablue0.3060geomean0.3322
-
Zef Cambio #17:
toStringrápidonbody0.1282splay2.2664richards0.1275deltablue0.2964geomean0.3235
-
Zef Cambio #18: Especialización de literales de arreglo
nbody0.1295splay1.6661richards0.1250deltablue0.2979geomean0.2992
-
Zef Cambio #19: Optimización de
callOperatorde Valuenbody0.1208splay1.6698richards0.1143deltablue0.2713geomean0.2810
-
Zef Cambio #20: Mejor configuración de C++
nbody0.1186splay1.6521richards0.1127deltablue0.2635geomean0.2760
-
Zef Cambio #21: Sin asserts
nbody0.1194splay1.6504richards0.1127deltablue0.2619geomean0.2759
-
Zef en Yolo-C++
nbody0.0233splay0.3992richards0.0309deltablue0.0784geomean0.0686
1 comentarios
Comentarios en Hacker News
En una línea similar, me pareció bastante interesante esta página sobre el rendimiento del intérprete de Wren
Si el texto de Zef se centra en las técnicas de implementación, sentí que el lado de Wren también muestra cómo el diseño del lenguaje en sí contribuye al rendimiento
En particular, me pareció bien que Wren renuncie a los dynamic object shapes, lo que permite copy-down inheritance y hace que la resolución de métodos sea mucho más simple
Personalmente, me parece un tradeoff bastante razonable. Me hace pensar en qué tan seguido realmente hace falta agregar métodos después de que una clase ya fue creada
Hay muchas VM muy optimizadas para lenguajes dinámicos, pero siento que LuaJIT es fuerte porque Lua es un lenguaje muy pequeño y muy adecuado para optimización
Tiene algunas funciones que son difíciles de optimizar, pero son pocas, y eso hace que valga la pena invertir esfuerzo en ellas
En cambio, Python me parece completamente distinto. Exagerando un poco, está diseñado casi para minimizar la posibilidad de un JIT rápido, y sus múltiples capas de dinamismo hacen que optimizarlo se vea realmente difícil
El hecho de que, incluso después de tanto trabajo durante tanto tiempo, el JIT de CPython 3.15 en x86_64 apenas sea alrededor de un 5% más rápido que el intérprete por defecto me parece una buena muestra de eso
Claro, también me viene a la mente que Ruby no es precisamente un lenguaje conocido por priorizar la velocidad por encima de todo
Por otro lado, la idea de que cierto tipo tenga en forma cerrada el conjunto de funciones aplicables también me resulta un poco cuestionable
Hay bastantes lenguajes donde puedes definir funciones arbitrarias y luego usarlas con sintaxis de punto como si fueran métodos sobre variables cuyo primer argumento tenga el tipo correcto
Por ejemplo, con macros en Nim, implicit classes y type classes en Scala, extension functions en Kotlin, o traits en Rust
Los lenguajes dinámicos complejos tienden a romper activamente esa posibilidad de muchas maneras, así que se vuelven más difíciles de optimizar
Viéndolo en retrospectiva, suena bastante obvio
Al pasar del cambio #5 al #6, que la mayor parte de la mejora de rendimiento venga de los inline caches y del modelo de objetos con hidden classes se sintió muy parecido, históricamente, a cómo se volvieron rápidos V8 o JSC
El punto donde muere un intérprete ingenuo al final es el despacho dinámico en el acceso a propiedades, y el resto da la impresión de ser relativamente un rounding error
También me gustó que estuviera organizado de forma que se pudiera ver cuánto aportó cada etapa por separado. Muchas veces los textos sobre rendimiento solo tiran la cifra final y ya
En un intérprete de bytecode, puedes parchear el offset estable dentro del flujo de bytecode, así que el lugar donde reescribir el IC sale de manera natural
Pero aquí la ubicación del caché es el nodo del AST, así que me impresionó que @pizlonator usara
constructCache<>para montar en el mismo lugar un nodo AST especializado encima de uno genéricoAl final se veía como código automodificable a nivel de AST
A cambio, este enfoque requiere mutable AST nodes, así que choca con la suposición de AST inmutables que muchos compiladores esperan para cosas como compartir subárboles o compilar en paralelo
Para un intérprete de un solo hilo suena limpio, pero si el intérprete cambia nodos mientras el mismo AST se compila con JIT en un hilo de fondo, parecería problemático
En mi opinión, puede que no represente tan bien a la mayoría del código real de producción
Lo pensé porque la parte donde la optimización de sqrt da una mejora de 1.6% me llamó mucho la atención
Para que salga una mejora así, el benchmark ya tenía que estar gastando por lo menos ese 1.6% del tiempo ahí, y eso me sorprendió bastante
Viendo el repo, parece que efectivamente eso pasa en la simulación de nbody
Esto me resultó aún más interesante porque yo también publiqué hace poco la primera versión de mi propio AST-walking interpreter
Mi objetivo era entender, a nivel básico, qué hace falta para construir un lenguaje interpretado
No quería meter complejidad de optimización; solo quería enfocarme en hacer que mi propio código en Rust me resultara comprensible
Pero me sorprendió que, solo por haber usado Rust, el rendimiento saliera bastante bien
Además, como Rust se encarga del ownership y los lifetimes, sentí como un bonus no necesitar un garbage collector aparte
Claro, ahora mismo todavía dependo de
clonede manera bastante conservadora en cosas como closures para evitar el infierno de lifetimes, pero aun así siento que el perfil de velocidad y memoria es suficientemente buenoSi a alguien le interesa un tree-walking interpreter simple y fácil de entender basado en Rust, puede ver mi intérprete gluonscript
El texto estuvo realmente muy bueno
En especial, el arco de Arguments, o sea el flujo de #7 a #13, conectó muchísimo con mi experiencia
Hace tiempo, al construir en Rust un async step evaluator, me metí bastante a fondo con
Cow<'_, Input>porque normalmente creía que borrow me iba a dar ventajasEn microbenchmarks se veía bien, pero en cargas reales la complejidad del discriminant de Cow y todo lo relacionado con lifetimes se propagó a cada combinator después del primer
await, el inlining se vino abajo, y dejó de existir la razón por la que había usado Cow en primer lugarAl final cambié en el borde del evaluator a
NoInput / OneInput / MultiInput(Vec), y aunque se veía tosco, terminé llegando casi exactamente al mismo punto que la separación ZeroArguments / OneArgument / TwoArguments de aquíUna cosa que me sigue dando curiosidad es si probaron apilar type specialization encima de la especialización por aridad en la ruta nativa
Por ejemplo, en un estilo binario parecería posible eliminar incluso la verificación de
isIntMi sospecha es que tal vez el cálculo de tamaño de código no cerraba, o que del lado de objetos el IC ya se estaba comiendo suficientemente bien las rutas calientes, así que el fast path nativo no movía tanto la aguja
Me quedé con la duda de cuál de las dos fue
Esto me pareció realmente interesante y muy bien logrado
Yo también hice algo parecido, pero del lado de Scheme, un lenguaje un poco más funcional
Aquí la mayor ganancia vino de optimizar objetos, pero en mi caso la batalla principal fue optimizar closures
Curiosamente, la forma de optimizar fue bastante parecida
Creo que casi toda la respuesta para hacer Scheme lo bastante rápido está en Three implementation models for scheme
Eso sí, en ese caso hay cierto paso de compilación de por medio, así que no es un modelo de interpretar directamente el AST original tal cual
Me pareció interesante y gracias por compartirlo
A mí también me dieron ganas de explorar este tema a fondo algún día
Y además me causó bastante gracia e impresión que, según Github, el repo sea 99.7% HTML y 0.3% C++
Parecía una buena prueba de que el intérprete realmente es muy pequeño
Por cómo generan el código para el navegador, la parte del sitio quedó innecesariamente grande
Aun así, el intérprete en sí de verdad es muy pequeño
Me dio curiosidad si al hacer este trabajo aprendieron algo que pudiera servir para mejorar fil c en sí
Y también aprendí que el costo de manejar métodos de value objects como outline calls es bastante alto
Vi que incluyeron Lua, pero me quedé con la idea de que también habría estado bien ver LuaJIT
No, de hecho, considerando la ingeniería que tiene encima, hasta esperaría que así fuera
Había muchos runtimes que podría haber incluido, pero no los puse todos
Y también fue bastante impresionante ver que PUC Lua fuera bastante más rápido que QuickJS o Python
Me dio curiosidad cómo es realmente la experiencia de usar Fil-C, y si tiene una utilidad práctica real en producción
Aun así, en este proyecto sí ayudó de forma bastante práctica
Me detectó de forma determinista varios problemas de seguridad de memoria, y eso hizo que diseñar el modelo de objetos fuera mucho más fácil de lo que habría sido sin eso
Además, C++ con GC preciso se sintió como un modelo de programación realmente bueno
Me daba la impresión de ser unas 1.5 veces más productivo que con C++ normal, e incluso frente a otros lenguajes con GC sentía que desarrollaba alrededor de 1.2 veces más rápido
Creo que la razón es que el ecosistema de APIs de C++ es rico, y sus lambdas, templates y sistema de clases están muy maduros
Claro, reconozco que estoy sesgado en varios sentidos
Yo mismo hice Fil-C++, y además llevo usando C++ como unos 35 años
Me dio curiosidad qué es el compilador YOLO-C/C++ que se menciona en el texto
Aunque lo busqué, no aparece mucho y parece que ni chatgpt lo conoce