Skip to content

perf: amortize list.push and intern small integers - #140

Merged
kaidesu merged 6 commits into
1.0from
claude/tree-walk-interpreter-optimizations-yv2fd9
Aug 20, 2026
Merged

perf: amortize list.push and intern small integers#140
kaidesu merged 6 commits into
1.0from
claude/tree-walk-interpreter-optimizations-yv2fd9

Conversation

@kaidesu

@kaidesu kaidesu commented Aug 20, 2026

Copy link
Copy Markdown
Member

Two independent allocation wins in the object layer, plus a benchmark
suite to measure them.

list.push allocated a fresh backing array and copied every element on
each call, making repeated pushes O(n^2). Switching to append amortizes
growth to O(1). Building a 10k element list drops from 470ms/837MB to
11ms/2.2MB.

Number is immutable (unexported fields, never reassigned after
construction), so instances are safe to share. Preallocating the range
-128..1024 removes a heap allocation from nearly every loop counter,
index, and arithmetic result.

Both changes carry over to a bytecode compiler: the small-integer cache
becomes the runtime side of the constant pool.

claude added 6 commits August 20, 2026 06:51
Two independent allocation wins in the object layer, plus a benchmark
suite to measure them.

list.push allocated a fresh backing array and copied every element on
each call, making repeated pushes O(n^2). Switching to append amortizes
growth to O(1). Building a 10k element list drops from 470ms/837MB to
11ms/2.2MB.

Number is immutable (unexported fields, never reassigned after
construction), so instances are safe to share. Preallocating the range
-128..1024 removes a heap allocation from nearly every loop counter,
index, and arithmetic result.

Both changes carry over to a bytecode compiler: the small-integer cache
becomes the runtime side of the constant pool.
object.Type and token.Type were both string types, so every type check
and every operator dispatch in the evaluator ran a string comparison.
Both are now integers backed by iota, with String() methods that recover
the original names for error messages.

AST operator fields (Infix, Prefix, Postfix, Compound) now hold a
token.Type rather than the lexeme string, so operator switches in the
evaluator compare single words and are eligible for jump tables.
evaluateCompound previously derived its binary operator by slicing the
last character off the lexeme; it now uses an explicit mapping table.

This also fixes latent string(obj.Type()) conversions in the io and ghost
modules and the type() builtin, which after the change would have yielded
a one-rune string instead of the type name. go vet flags these.

Runtime error messages and the type() builtin are unchanged: output over
the example suite is byte-identical to before.

Integer type tags and operator codes are the representation a bytecode
compiler needs, so this work is reused rather than redone.
evaluatePrefix matched the ! operand against the value.TRUE/value.FALSE
singletons with a pointer-identity switch. Booleans that are not those
singletons fell through to the default branch and yielded false whatever
their value, so !(expression) was wrong for every producer of a fresh
boolean object: string comparisons, string.startsWith/endsWith/matches,
and math.isNegative/isPositive/isZero.

    !("a" != "a")            // was false, is now true
    !("abc".startsWith("x")) // was false, is now true

The number path was already correct because it routes through
toBooleanValue, which returns the singletons. Matching on the operand's
type and reading its value fixes every producer at once rather than
patching each call site.

evaluateStringInfix now also returns the singletons via toBooleanValue,
matching the number path and dropping an allocation per comparison.
Non-boolean and non-null operands keep their existing behavior.
A tree-walking evaluator recomputes every node each time control reaches
it, so an expression built only from literals is re-evaluated on every
loop iteration and every call. The new optimizer package folds those
expressions into a single literal once, after parsing and before
evaluation, and is wired into all three places a program is parsed:
ghost.Execute, import evaluation, and ghost.execute().

The pass is conservative. It folds numeric, string and boolean infix
operators and the - and ! prefix operators, mirroring the evaluator's
semantics exactly, including that division always promotes to float.
It deliberately declines to fold anything whose meaning depends on run
time: division and modulo by zero and type mismatches stay unfolded so
the runtime error still occurs with its original position, and ranges
stay unfolded because they build a mutable list object. Nodes it does
not recognize are left alone, so failing to fold is always safe.

Correctness is covered from two directions: optimizer tests assert the
folded AST shape and that unfoldable expressions survive, and a
differential test in the evaluator runs each program with and without
the optimizer and requires identical results, error messages included.
Output over the example suite is byte-identical.

Folding belongs in the compiler pipeline of a bytecode implementation
too, so this pass moves over as-is.
Profiling a call-heavy program showed Environment.Set and NewEnvironment
together accounting for 71% of all bytes allocated. Every call builds an
environment, and each one allocated a map and then grew its buckets as
parameters were bound - for a scope that usually holds two or three
names.

Environments now keep the first few bindings in fixed arrays inside the
struct, so a call frame costs a single allocation and a lookup is a short
scan of adjacent memory rather than a hash. Scopes that exceed that
capacity, mainly module and class scopes, spill the remainder into a map
and behave as before.

The inline capacity is 4, chosen by measurement: 2 makes ordinary calls
spill and is much slower, while 6 and 8 buy no speed and cost memory
linearly.

Recursive fib is 43% faster and a call loop 22% faster, both with roughly
a third fewer allocations.

Get, Set, Has, Delete and All keep their existing semantics, including
that Set binds in the current environment without walking the outer
chain, and that Has consults the outer chain. All now returns a copy
rather than the live map; both of its callers only read it.

This is a step toward the indexed local slots a bytecode compiler would
use, and the storage layout carries over.
evaluateIdentifier consulted the library module and function registries
before the scope chain, so reading any ordinary variable paid two
string-keyed map lookups first. In a loop that hashing dominated
identifier evaluation: mapaccess2_faststr and aeshashbody together
accounted for roughly a quarter of CPU samples.

The registries are fixed once initialization and any embedder
registration have run, so the optimizer now classifies each identifier
ahead of evaluation and the evaluator skips the registries for names
that cannot be globals. Precedence is unchanged - a library name still
wins over a same-named variable - and an AST that was never optimized
stays unclassified and consults the registries as before.

The classification is written during optimization and only read during
evaluation. That ordering matters: http.handle() callbacks are evaluated
per request on their own goroutines, so caching this on the AST at
evaluation time would have introduced a data race.

The optimizer reaches the registries through a resolver hook installed
by the library package rather than importing it, because library/modules
already imports the optimizer and a direct import would close a cycle.

Every benchmark improves; the loop and call benchmarks are 25-30% faster.
@kaidesu
kaidesu merged commit 4a24209 into 1.0 Aug 20, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants