Skip to content

perf(runtime): resolve a feature chain's declarations once per model - #1627

Merged
HuiJun merged 2 commits into
developfrom
perf/chain-declarations-memo
Oct 28, 2026
Merged

HuiJun merged 2 commits into
developfrom
perf/chain-declarations-memo

Conversation

@devin-ai-integration

@devin-ai-integration devin-ai-integration Bot commented Oct 28, 2026 •

Copy link
Copy Markdown
Contributor

What and why

Since feature values are held by feature rather than by name (#1514), every evaluation of a feature chain notes the declaration each of its segments names (EvalContext.noteChainMembers) so the reads of its parts resolve to a feature, not a name. Each note allocated twice per evaluation: chainDeclarations resolved the segments afresh into a new slice, and noteChainMembers returned a restore closure. In a calc whose loop assigns four attributes from a calc usage's outputs, the fixed-step integration pattern, that is two allocations per chain read per step — the AssignmentLoopStep regression against v0.11.0 (+9.5 % allocs/op, +8.5 % B/op).

The declarations a chain's segments name depend only on the model and on the feature or cast type its base names (declaredMember reads the type's shape and the semantic model), so this change:

  • memoizes chainDeclarations per model in Model.chainDeclarations, keyed by the base symbol and the chain expression node (immutable AST). A nested chain such as rig.panel.area is flattened afresh by ast.FeatureChainBase on every evaluation, so the flattened slice is not a stable key — the node is;
  • makes noteChainMembers return the previous chainStatic value, restored by the new restoreChain, instead of a closure — defer ec.restoreChain(ec.noteChainMembers(base, parts)) allocates nothing.

Measured (-benchtime 200x, Go 1.27.3 for every column, same machine; sec/op omitted as noisy on this VM):

internal/exec/runtime BenchmarkAssignmentLoopStep v0.11.0 develop ddfbb9446 develop + this PR
allocs/op 8 541 9 356 8 556
B/op 922 360 1 000 812 971 987

The fix recovers 800 of the 815 allocations per call #1514 added; about 50 KB/op of its bytes remain, which come from the other parts of that change (the per-slot feature resolution) and are not addressed here.

How it was verified

  • New TestChainDeclarationsAreResolvedOncePerModel (internal/exec/runtime/chain_declarations_memo_test.go): over a nested chain flattened afresh on every iteration, testing.AllocsPerRun for 100 vs 50 repeated notes must allocate no more beyond the flattening itself, and the memo must hold one entry afterwards. It fails with the memo bypassed (100 vs 50 allocations, 0 entries) and passes with it (0 vs 0, 1 entry); it also checks the resolved declarations are Rig::panel and Panel::area.
  • go build ./..., go vet ./..., gofmt -l . empty, python3 scripts/changelog.py check.
  • make lint, go test -count=1 ./internal/exec/runtime ./tests/perf ./internal/frontend/grpc and go test -race ./internal/exec/runtime pass on each head; results are posted in a comment per head.

Overlap with open PRs

Dry-merged in a scratch worktree from origin/develop ddfbb9446: this head, then each sibling that touches internal/exec/runtime (#1606 edits eval.go, which this PR changes on one line).

PR head verdict
#1606 fix/sonar-runtime 9ed7ce5e3 clean
#1610 fix/sonar-internal 2dc10e486 clean

Both merge without conflict; the merged tree's runtime tests are running and the result will be posted in a comment. No landing-order constraint. #1626 touches other runtime files (subsetting.go, kernel_collections.go, model.go) and adds its own memo tables to Model; both add fields and constructor lines in model.go, so whichever lands second merges develop first; dry-merged together they are clean.

Checklist

  • make test and make lint pass locally (see above for what is still running)
  • Tests added or updated for the change
  • Documentation extended where it already covers the surface (none covers these memo tables)
  • Changelog entry added as changes/unreleased/chain-declarations-memo.changed.md
  • baselines regenerated and make docs-counts run if a gate count moved (no gate count moved)
  • No internal work-item labels (waves, slices, F4, K5) in the body, docs, or changelog

Co-Authored-By: jason.han <hanhuijun@gmail.com>
@devin-ai-integration

Copy link
Copy Markdown
Contributor Author

I'll fix CI failures and address comments from users with write access. I'll skip comments containing "(aside)".

  • Disable automatic comment, CI, and merge conflict monitoring

@devin-ai-integration

Copy link
Copy Markdown
Contributor Author

Gate results for 1a6e62a (Go 1.27.3, same machine):

  • go vet ./... clean; gofmt -l . empty; make lint passed; python3 scripts/changelog.py check passed.
  • go test -count=1 ./internal/exec/runtime ./tests/perf ./internal/frontend/grpc: all ok (runtime 150 s).
  • go test -count=1 -race ./internal/exec/runtime: ok (830 s).
  • Negative control: TestChainDeclarationsAreResolvedOncePerModel fails with the memo bypassed (100 vs 50 allocations) and passes with it (0 vs 0).

Overlap: origin/develop ddfbb94 + this head + #1606 (9ed7ce5) + #1610 (2dc10e4) merge clean; the merged tree's go build ./..., go vet ./internal/exec/runtime and go test -count=1 ./internal/exec/runtime pass. #1626 (e1093ee) also dry-merges clean with this head.

@devin-ai-integration
devin-ai-integration Bot marked this pull request as ready for review October 28, 2026 11:32
devin-ai-integration[bot]

This comment was marked as resolved.

Co-Authored-By: jason.han <hanhuijun@gmail.com>
@devin-ai-integration

Copy link
Copy Markdown
Contributor Author

Gate results for 155b178 (the memo re-keyed by chain node; Go 1.27.3, same machine):

  • go vet ./internal/exec/runtime clean; gofmt -l . empty; make lint passed.
  • go test -count=1 ./internal/exec/runtime ./tests/perf ./internal/frontend/grpc: all ok (runtime 137 s).
  • go test -count=1 -race ./internal/exec/runtime: ok (794 s).
  • Negative control on the new test: with the memo bypassed it fails (100 vs 50 allocations beyond flattening, 0 memo entries); with it, 0 vs 0 and 1 entry.
  • BenchmarkAssignmentLoopStep at 200x: 8 556 allocs/op, 971 985 B/op — same as the previous head.

Overlap re-checked on this head: origin/develop ddfbb94 + 155b178 + #1606 (9ed7ce5) + #1610 (2dc10e4) + #1626 (e1093ee) merge clean; the merged tree's go build ./... and go vet ./internal/exec/runtime pass.

@HuiJun
HuiJun merged commit b5276f5 into develop Oct 28, 2026
30 checks passed
@HuiJun
HuiJun deleted the perf/chain-declarations-memo branch October 28, 2026 13:12
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.

1 participant