Skip to content

perf: forget cached lookups per name, not per context (0158) - #473

Merged
rmorozov merged 3 commits into
masterfrom
claude/lookup-epoch-0158-8wv38i
Oct 12, 2026
Merged

rmorozov merged 3 commits into
masterfrom
claude/lookup-epoch-0158-8wv38i

Conversation

@rmorozov

@rmorozov rmorozov commented Oct 11, 2026 •

Copy link
Copy Markdown
Member

Requested by Ruslan · project thread

Before: a set, a loop body scope cleared after each pass, or a loop or macro slot bound or unbound started a new lookup epoch for the context. After that, every cached name lookup missed once, including lookups of names the change could not affect. On many_tags every root set did this. On chat_mistral 62 of the 71 misses per render came from these events.

After: those events drop only the cache entries of the names they touch. Other names stay cached. Rendered output does not change.

How:

  • LookupCache groups its 128 entries into 32 buckets of 4, by name hash. NewSlot(hash) places a name expression in its name's bucket, so forgetting a name clears 4 keys. The keys sit in their own array, so this is two stores. The hit path does not change.
  • Each scope records, in 32 bits in ScopeStack, the buckets of the names added to it. Clear and ExitScope forget those buckets without hashing names.
  • A scope entered with names it did not record (a loop's kept body scope) starts a context epoch and forgets every bucket when it goes.
  • BindSlot and UnbindSlots take the slot names' hashes.
  • A static assert keeps InternalValueMap a node map, because the cache keeps pointers to scope values while other names are added.
  • Second round, after the performance track asked for one: the loop and its lazy filter read the target's slot and hash once instead of after every item, and the first scope chunk no longer zero-fills bucket masks that Push sets anyway. This pays back the extra bucket clears per loop run.

Instruction counts after the second round (callgrind, Release, against 39dc889):

Better Worse
many_tags -9.5%, macros -3.1%, chat_mistral -0.6%, for_range -0.4%, dict_ops -0.4%, config_file -0.3%, chat_qwen -0.2%, chat_llama -0.1% inheritance +0.46%, substitute +0.41% (+9 instructions), mitsuhiko_table +0.24%, mitsuhiko_table_wide +0.22%, html_autoescape +0.14%

Allocations are unchanged. Every render case is now within the house bar of +0.5%.

Exceptions to the task's +0.3% bar:

  • Render inheritance +0.46%: each nested context records and forgets buckets for the names it adds.
  • Render substitute: +9 instructions.
  • Load large_static +0.7% and substitute +0.6% (CI, first round): NewSlot computes the name's bucket and uses that bucket's counter. This costs a few instructions once per distinct name in a template, so it is a real cost and not layout.

Nested contexts still take a context epoch. A follow-up note on this is in docs/tasks/0158.

Roles: the verifier ran 1 round on the first commit. It found 5 clang-tidy hits and no correctness issue: the Debug cache cross-check passed on about 10k generated and hand-written templates, and the mutation checks were caught. ASan+UBSan, C++20 and preflight pass. The second-round commit passed 4747 tests in Debug (with the cache cross-check) and preflight. Red pushes so far: 0.

🤖 Generated with Claude Code

https://claude.ai/code/session_01AmFfzfwMghRTWPifzgmANC


Generated by Claude Code

A name added by `set`, a loop body scope cleared after each pass and a loop's
slots bound or unbound started a new lookup epoch, so every cached lookup of the
context missed once afterwards. They now drop only the entries of the names
concerned:

- LookupCache groups its 128 entries in 32 buckets of 4 by name hash, and
  NewSlot(hash) gives a name expression an entry of its name's bucket, so
  forgetting a name clears 4 keys (kept in their own array). The hit path is
  unchanged.
- Each scope records the buckets of the names added to it, so clearing or
  leaving it forgets them without hashing its names.
- BindSlot and UnbindSlots take the slot names' hashes.

Callgrind against 39dc889: many_tags -9.6%, macros -3.8%, chat_mistral -0.6%,
config_file -0.5%; mitsuhiko_table +0.7%, for_filter_if +0.6%, inheritance
+0.6% (details in docs/tasks/0158).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmFfzfwMghRTWPifzgmANC
@rmorozov rmorozov self-assigned this Oct 11, 2026
The loop and its lazy filter reloaded the target's slot and hash after
each item; the first scope chunk zero-filled masks Push sets anyway.
Pays back the bucket clears per loop run: every render case is now
within +0.5% of base, mitsuhiko_table +0.24%.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmFfzfwMghRTWPifzgmANC
@rmorozov
rmorozov marked this pull request as ready for review October 12, 2026 00:16
@rmorozov
rmorozov merged commit 8f13350 into master Oct 12, 2026
38 checks passed
rmorozov pushed a commit that referenced this pull request Oct 12, 2026
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KMcWcpGwhiaEs33YjAgEV4
rmorozov added a commit that referenced this pull request Oct 12, 2026
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