perf: amortize list.push and intern small integers - #140
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.