Skip to content

perf: environment scan tier, plus CI that can actually fail - #142

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

perf: environment scan tier, plus CI that can actually fail#142
kaidesu merged 2 commits into
1.0from
claude/tree-walk-interpreter-optimizations-yv2fd9

Conversation

@kaidesu

@kaidesu kaidesu commented Aug 21, 2026

Copy link
Copy Markdown
Member

What does this implement or fix?

Follow-up to #140. Two independent pieces: one performance fix for a workload shape the merged work regressed, and the CI gaps that let related breakage through unnoticed.

Added

  • A middle storage tier in Environment, between the inline array and the map. Bindings are looked up in a fixed inline array, then a slice that only exists for environments that outgrow it, then a map past scanLimit.
  • A TileRender benchmark: many globals in one flat scope read from a tight nested loop, with no function calls. The suite previously had no flat-scope case, which is why the regression below was missed.
  • bench, fmt, vet and check targets in the Makefile, and matching CI steps.

Fixed

  • A flat-scope regression from perf: amortize list.push and intern small integers #140. Inline environment storage made call-heavy code much faster but a tile rendering loop 15% slower. With four inline slots and a map behind them, a scope holding nine globals kept the first four inline and put the rest in the map, so reading any of those five paid a full scan and then a hash — strictly worse than the map it replaced. Raising the inline capacity fixes that scope and penalises every call frame, since the array sits in a struct allocated on every call; the two shapes of scope want opposite sizes, hence the middle tier. Tile rendering is ~8% faster than perf: amortize list.push and intern small integers #140, loop and map benchmarks improve, and the call benchmarks are unchanged.
  • CI could not fail. make test pipes go test through sed for colour. A pipeline reports the exit status of its last command, so sed always succeeded and the target exited 0 no matter how the tests went — and the workflow runs make test. Verified by making a test fail and watching the exit code go from 0 to 1 once the target runs under bash with pipefail.
  • The benchmark suite could not parse. ClassMethods still used Counter.new(), which Adopt JavaScript-style class syntax and fix OOP semantics #141 removed. Benchmark bodies are not compiled into a plain go test run and the Ghost programs inside them are only parsed when they execute, so tests stayed green while the suite was broken. Updated to new Counter(), and make bench now runs each benchmark for a single iteration in CI so this cannot recur silently.
  • Workflow maintenance: actions/checkout and actions/setup-go move from v2 (retired Node runtime) to v4/v5; the Go version comes from go.mod rather than the ^1.17 that had drifted from the declared 1.21.1; pull_request is no longer filtered to the 1.0 base, so a PR is checked wherever it is targeted; the test timeout goes from 5s to 120s, since under -race on a cold runner the existing tests approach the old limit and risked flaking.

Removed

  • The dep / Gopkg.toml bootstrap block in the workflow. There is no Gopkg.toml and modules have handled this since the repository moved to them.

Does this close any currently open issues?

None.

Additional Notes

On the performance number. The ~8% figure was measured against the pre-#141 tree. The class rework touched the evaluator enough that it is worth re-measuring on this base rather than carrying the number over; make bench is the starting point.

Why scanning wins. More than it looks like it should: the name being looked up and the name stored in the environment usually come from the same identifier in the AST, so the string comparison settles on equal pointers without examining any characters. Past scanLimit a scan genuinely stops paying, so everything migrates into the map and the scan tiers are abandoned — splitting names between a scan tier and a map is worse than either alone, which is exactly the bug being fixed. The tier is one slice of name/value pairs rather than two parallel slices; with two, the extra header made call-heavy benchmarks 10% slower even though they never allocate the tier.

Behaviour. Get, Set, Has, Delete, All, HasLocal and GetLocal keep their existing semantics; only the storage behind them changes.

Verification limits. gofmt, go vet, go build, go test -race and the benchmarks pass, but on every package except repl, whose liner dependency could not be fetched in the environment this was prepared in. CI covers the full ./..., and it is now able to report a failure if that is wrong.

Two separable commits. The CI fix is independent of the perf change and matters on its own, since the benchmark suite is broken on 1.0 today and CI is not reporting it. Happy to split it out if you would rather land that first.

Screenshots

N/A — no visual change.

🤖 Generated with Claude Code

https://claude.ai/code/session_01XtRdJPCskcS2ryiQZoEbUx


Generated by Claude Code

claude added 2 commits August 21, 2026 15:42
A tile rendering loop - many globals in one flat scope, read from a tight
nested loop with no function calls - was 15% slower after environments
moved to inline storage, even though call-heavy benchmarks got much
faster. With four inline slots and a map behind them, a scope holding
nine globals kept its first four inline and put the rest in the map, so
reading any of those five paid a full scan and then a hash.

Raising the inline capacity fixes that scope and penalizes every call
frame, because the array sits in a struct that is allocated on every
call. The two shapes of scope want opposite sizes, so there is now a
middle tier: a slice that only exists for environments that outgrow the
inline array, scanned before falling back to the map. Past scanLimit
everything migrates into the map and the scan tiers are abandoned, since
splitting names between a scan tier and a map is worse than either alone.

The tier is one slice of name/value pairs rather than two parallel
slices; with two, the extra header made call-heavy benchmarks 10% slower
even though they never allocate the tier.

Scanning wins here by more than it appears it should: the name being
looked up and the name stored usually come from the same AST identifier,
so the comparison settles on equal pointers without reading characters.

Tile rendering is 8% faster, loop and map benchmarks improve, and the
call benchmarks are unchanged. The workload is now in the benchmark
suite, which previously had no flat-scope case and so missed this.

Also updates the ClassMethods benchmark to the `new Counter()` syntax.
The benchmarks are not compiled by a plain `go test` run, so the class
syntax change missed this file and the suite failed to parse.
`make test` pipes go test through sed for colour. A pipeline reports the
exit status of its last command, so the sed always succeeded and the
target exited 0 no matter how the tests went. The workflow runs
`make test`, so CI reported success for any failing test run. The target
now runs under bash with pipefail; verified by making a test fail and
watching the exit code go from 0 to 1.

Benchmark bodies are not compiled into a plain `go test` run, and the
Ghost programs inside them are only parsed once they execute. That is how
the class syntax change left the benchmark suite unable to parse while
every test still passed. A `bench` target runs each benchmark for a
single iteration, which is enough to catch it.

Also adds `fmt` and `vet` targets, and a `check` target that runs all
four for local use. Vet is worth having in CI: it flags the
`string(obj.Type())` conversions that the integer type change turned into
one-rune strings.

Workflow changes beyond the new steps:

- actions/checkout and actions/setup-go move from v2 to v4/v5. The v2
  releases run on a Node version GitHub has retired.
- The Go version comes from go.mod rather than the `^1.17` in the
  workflow, which had drifted from the 1.21.1 the module declares.
- The dep/Gopkg.toml bootstrap block is removed. There is no Gopkg.toml
  and modules have handled this since the repository moved to them.
- pull_request is no longer filtered to the 1.0 base branch, so a pull
  request is checked wherever it is targeted.
- The test timeout goes from 5s to 120s. Under -race on a cold runner the
  race benchmarks alone approach the old limit, so it risked flaking.
@kaidesu
kaidesu merged commit 51f20b6 into 1.0 Aug 21, 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