Auditoría del runtime antes de optimizar
Estado auditado: commit 9d27239, Go 1.26.2, Windows/amd64. Este documento
describe el runtime anterior a las optimizaciones. Las mediciones reproducibles
están en benchmarks/BASELINE_9D27239.md.
Flujo real
.joss
-> lexer (Token por valor)
-> parser Pratt (nodos AST enlazados por interfaces)
-> analyzer (scopes y símbolos por map[string]*symbol)
-> diagnostics
-> Runtime.Execute
-> executeStatement (type switch)
-> evaluateExpression (type switch)
-> helper especializado
-> valor Go en interface{}
pkg/bytecode no altera este flujo: restaura mediante gob+flate el mismo AST
que vuelve a recorrer el evaluator. No hay IR tipada ni resolución semántica
persistida entre analyzer y runtime.
Capas atravesadas por una expresión
Una expresión top-level normal atraviesa al menos cuatro decisiones:
Executeregistra declaraciones y selecciona el modo script/Main.executeStatementhace un type switch de statements.evaluateExpressionhace otro type switch de expressions.- El helper (
evaluateInfix,evaluateMember,executeCall, etc.) vuelve a discriminar tipos Go, buscar nombres o recorrer AST.
Una llamada añade evaluación y materialización de argumentos, resolución por
nombre, clasificación del callable, creación del frame, binding y comprobación
de parámetros, ejecución del cuerpo, panic/recover de retorno y validación
del tipo de retorno. Un acceso a método añade búsqueda de clase, recorrido de
herencia, recorrido lineal de los statements de cada clase y construcción de
un BoundMethod.
Representaciones y estructuras creadas
| Concepto | Representación anterior | Coste relevante |
|---|---|---|
| Valores | interface{} |
boxing de primitivas cuando escapan; type switches repetidos |
| Variables | Runtime.Variables map[string]interface{} |
hash por lectura/escritura local y global |
| Tipos runtime | VarTypes map[string]string |
parseo repetido del nombre de tipo |
| Constantes | Constants map[string]bool |
lookup separado |
| Frame | sustitución temporal de tres mapas del Runtime |
tres mapas nuevos por llamada y copia de globals |
| Funciones | map[string]*parser.MethodStatement |
resolución por nombre y ejecución del AST original |
| Closures | AST + copia de tres mapas | copia completa al capturar; mutex por entorno capturado |
ref |
tres mapas + nombre en VariableReference |
lookup por nombre y posible referencia anidada |
| Objetos | Instance{Class, Fields map, Constants map} |
propiedad por hash; metadato/método por recorrido del AST |
| Arrays | []interface{} |
elementos boxed; literales crecen con append sin capacidad |
| Maps | map[string]interface{} |
claves evaluadas dinámicamente; literales sin capacidad inicial |
| Strings | string UTF-8 de Go |
indexación anterior por byte, no por carácter |
| Futures | goroutine + runtime clonado + chan bool |
fork copia mapas incluso para un valor trivial |
| Channels | chan interface{} |
frontera dinámica sin contrato de elemento |
El paquete pkg/core contenía 104 archivos Go y 17,623 líneas. La búsqueda
estática encontró 651 menciones de interface{}, 243 de
map[string]interface{}, 17 usos de reflexión, 100 llamadas a panic y 29 a
recover. Estas cifras incluyen stdlib/framework y tests; no todas están en el
hot path, pero cuantifican la concentración de responsabilidades.
Mapas de ejecución
Hot path
while
-> evaluate condition
-> identifier -> Variables[name]
-> infix -> conversión numérica + operador
-> execute block
-> postfix
-> identifier -> Variables[name]
-> box int64
-> Variables[name] = value
El perfil CPU del loop de 10,000 iteraciones sitúa evaluateExpression con
82.13% acumulado, evaluateInfix con 30.88%, evaluatePostfix con 46.71% y
las primitivas de mapas/hash entre los mayores costes planos.
Allocation path
call
-> []interface{} de argumentos
-> frameVariables map
-> frameVarTypes map
-> frameConstants map
-> parameterNames map
-> ReturnPanic heap object
En llamadas anidadas, callMethodEvaluated representa 93.12% plano del espacio
asignado. En el loop, evaluatePostfix representa 86.18% del espacio asignado:
el int64 nuevo escapa al guardarse como interface{}.
Dispatch path
CallExpression
-> evaluar argumentos
-> IsBuiltin map lookup
-> hasta cinco switches de familias builtin
-> Functions[name]
-> Variables[name]
-> applyFunction
-> PluginCallable / CapturedFunction / BoundMethod /
MethodStatement / FunctionLiteral / NativeHandler /
func([]interface{}) / reflect.Func
La resolución de métodos de usuario recorre statements de clase en cada acceso. La resolución de clases/métodos de plugins puede recorrer todos los plugins y sus símbolos. Los métodos estáticos crean además una instancia dummy.
Error path
Conviven cuatro políticas:
panic(*JossError)para algunos errores del lenguaje;panic(string)opanic(error)para otros;fmt.Print*seguido denilpara fallos recuperables;- panics tipados (
ReturnPanic,BreakPanic,ContinuePanic) para flujo normal.
try/catch, llamadas, loops, async y varios componentes framework recuperan
panics con criterios diferentes. El error estructurado previo no tenía código
estable, stack Joss, contexto, sugerencia ni causa.
Type-check path
binding/assignment/call/return/property write
-> typesystem.Parse(typeName string)
-> runtimeTypeOf(interface{})
-> typesystem.Assignable
-> si es instancia: recorrido de herencia
El analyzer ya conoce tipos de símbolos, parámetros, retornos y miembros, pero
esa información no queda anotada en el AST. El runtime vuelve a parsear strings
de tipo, resolver bindings, localizar miembros y validar contratos. mixed,
plugins y bytecode externo obligan a conservar un slow path dinámico; no
obligan a penalizar los call sites verificados.
Semántica auditada
mixedse representa como string de tipo y valorinterface{}; desactiva la incompatibilidad estática/dinámica intencionalmente.refno expone punteros Go; conserva los mapas del binding y el nombre. No puede cruzar plugins, async ni handlers nativos.- Las exceptions y el control
return/break/continueusan panic/recover. asyncclona el runtime antes de lanzar la goroutine. DB y registros inmutables se comparten; variables, tipos, constantes, maps, slices e instancias se copian parcialmente.- Las closures retenidas copian el entorno y serializan su uso con un mutex.
- Arrays y maps son heterogéneos. El analyzer sólo conocía
array/map, por lo que un índice retornabamixed/unknown. - La reflexión de Go aparece principalmente en builtins de arrays y en el fallback de callables host.
- La aritmética anterior convertía enteros a
float64y luego aint64. Esto pierde precisión por encima de 2^53 y el overflow deint64era silencioso. string[index]usabastr[idx]: retornaba un byte UTF-8 convertido a string.
Hotspots clasificados
| Prioridad | Hotspot | Evidencia |
|---|---|---|
| P0 | Variables locales en maps + boxing de postfix | loop: ~2.10 ms, 9,752 allocs; hash/map domina CPU; postfix 86.18% de alloc_space |
| P0 | Construcción de frames por llamada | simple: 760 B/7 allocs; nested: 2,312 B/23; callMethodEvaluated 93.12% de alloc_space |
| P1 | Resolución repetida de métodos/campos | perfil de objetos: evaluateMember 16.18% acumulado y lookupInstanceFieldOwner 6.08% |
| P1 | Retorno por panic/recover | cada llamada que retorna crea ReturnPanic; aparece en 65.54% acumulado del perfil de allocations de nested calls |
| P1 | Carga de AST serializado | ~255 us, 80,208 B y 857 allocs para el programa de startup |
| P1 | Aritmética entera vía float64 | pérdida de precisión demostrable y trabajo duplicado por operación |
| P2 | Dispatch builtin por cascada de switches | lookup inicial seguido por hasta cinco familias |
| P2 | Literales sin capacidad y metadatos por string | arrays/maps pequeños asignan más de lo necesario; tipos se vuelven a parsear |
| P2 | Fork completo por async | ~4.17 us, 1,680 B y 18 allocs para async+await trivial |
| P3 | Reflexión de compatibilidad host | no apareció como hotspot en programas Joss puros; conservar como slow path |
| P3 | Framework DB/HTTP/IO | importante arquitectónicamente, pero no dominó los perfiles del evaluator puro |
Concurrencia y GC
La suite preexistente pasa:
go test -race ./pkg/parser ./pkg/typesystem ./pkg/analyzer ./pkg/core
El perfil de bloqueo de async atribuye el bloqueo esperado a Future.Wait y
runtime.chanrecv1; no mostró contención de mutex de aplicación. El perfil de
mutex fue casi enteramente runtime/GC. Esto no prueba ausencia de riesgos en
plugins/framework: sólo establece la línea base cubierta por tests.
Conclusión anterior a cambios
La primera optimización debe eliminar allocations y hashing dentro del loop y
en llamadas antes de introducir una representación Value nueva. La evidencia
no justifica todavía reemplazar globalmente interface{} ni construir una VM
completa. Sí justifica conservar una ruta AST y añadir metadatos preanalizados,
frames compactos/cachés sencillas y fast paths enteros seguros.