Skip to content

Add evaluator fast paths: cached field plans, reflect-space index chains, typed binary ops#26

Merged
myzie merged 1 commit into
mainfrom
claude/serene-einstein-ifmkqd
Jun 10, 2026
Merged

Add evaluator fast paths: cached field plans, reflect-space index chains, typed binary ops#26
myzie merged 1 commit into
mainfrom
claude/serene-einstein-ifmkqd

Conversation

@myzie

@myzie myzie commented Jun 10, 2026

Copy link
Copy Markdown
Contributor

Summary

Four per-Run optimizations to the evaluator hot path, driven by CPU/alloc profiling of the existing bench suite. No language behavior changes; error messages and result values are preserved (pinned by new tests).

1. Cached field-index plans for untagged struct lookups (struct_tags.go)

reflect.Value.FieldByName re-walks the field set (including embedded promotion) on every call and accounted for ~30% of cumulative CPU in struct-env profiles. Untagged lookups now reuse the same VisibleFields-based plan machinery as tagged lookups, cached per type in a package-level sync.Map, so repeated lookups become a map hit + FieldByIndex.

2. Reflect-space fast path for index chains (program.go)

Ident-rooted chains of index/selector hops (a[0][1], a.b[i].c) are now walked in reflect space, materializing only the leaf — mirroring the existing evalSelectorChainRV fast path. The general path boxes every intermediate through any, which copies aggregate data: Data[0][0] on a struct holding a 10 MiB array memcpy'd the array on every access. Single hops on map envs still take the general path (values there are already boxed, so reflect adds only overhead).

3. Typed fast path in applyBinary (program.go)

Exact int64/int/float64/string/bool operand pairs — the overwhelmingly common case — dispatch through typed helpers, skipping the reflect-based coercion funnel (asString called reflect.ValueOf on every non-string operand, twice per binary op). Integer ==/!= intentionally keeps looseEqual's float64 comparison so results are bit-for-bit identical to the general path. Unsupported operators fall through so error messages keep the original operand types.

4. Streaming higher-order forms + minor call-path cleanup (higher_order.go, reflect.go, prepared.go)

iterItems no longer materializes typed slices into a []any before iteration; forms iterate an itemSeq view that boxes elements lazily. Also hoists the error reflect.Type to a package var instead of rebuilding it in finishCall on every call.

Benchmarks

Linux/amd64, go1.26, -benchtime 1s (baseline → this PR):

Benchmark Before After Δ
envStruct 145 ns 84 ns −42%
structTagsNested/go_names 145 ns 84 ns −42%
structTagsWide/go_names 559 ns 202 ns −64%
envMap 101 ns 68 ns −33%
largeStructAccess 310 ns 220 ns −29%
largeNestedStructAccess 512 ns 383 ns −25%
largeNestedArrayAccess 1.52 ms, 10.5 MB/op 167 ns, 65 B/op ~9000×
filter 90.3 µs, 40,840 B 69.6 µs, 24,456 B −23% time, −40% mem
filterMap 10.6 µs, 4,752 B 8.3 µs, 2,960 B −22% time, −38% mem
filterFirst 673 ns 572 ns −15%
callField 328 ns 285 ns −13%
callMethod 645 ns 590 ns −9%
expr 95 ns 91 ns −4%
arrayIndex 49 ns 52 ns +5% (fast-path probe cost on the cheapest op)

Behavior notes

  • Untagged ambiguous promoted field names resolve to "not found", matching reflect.Value.FieldByName (previously also not found, via a different route).
  • Traversing an embedded nil pointer during untagged field lookup now returns an ErrEvaluate ("cannot access … on nil pointer") instead of panicking inside reflect — same message the tagged path already produced.

Testing

  • Full suite passes, including adversarial/boundary/fuzz-seed and -race.
  • New index_chain_test.go pins the index-chain fast path to the general path's values and error messages, including map-env/struct-env parity.

https://claude.ai/code/session_018MkLtLrR1u8FEo1LgxyhrN


Generated by Claude Code

…ins, typed binary ops

Four per-Run optimizations, validated against the existing bench suite
(baseline -> after, Linux/amd64, go1.26):

- Cache Go-name field plans for untagged struct lookups instead of
  calling reflect's FieldByName per access (it re-walks the field set
  every time and dominated struct-env CPU profiles at ~30%).
  envStruct 145ns -> 84ns, structTagsWide/go_names 559ns -> 202ns,
  largeStructAccess 310ns -> 220ns.

- Walk ident-rooted index/selector chains (a[0][1], a.b[i].c) in
  reflect space, materializing only the leaf. The general path boxes
  every intermediate through any, copying aggregate data: indexing a
  struct field holding a 10 MiB array memcpy'd the array per access.
  largeNestedArrayAccess 1.52ms / 10.5MB per op -> 167ns / 65B per op.

- Dispatch exact int64/int/float64/string/bool operand pairs in
  applyBinary through typed helpers, skipping the reflect-based
  coercion funnel (asString called reflect.ValueOf on every non-string
  operand). Integer equality keeps looseEqual's float64 comparison so
  results are bit-for-bit identical. filter 90us -> 70us, envMap
  101ns -> 68ns.

- Stream higher-order forms through an itemSeq view instead of
  materializing typed slices into []any before iteration, and hoist
  the error reflect.Type to a package var in finishCall/prepareFunc.
  filter 40,840B -> 24,456B per op, filterMap 4,752B -> 2,960B per op.

Untagged field lookup now reuses the same VisibleFields-based plan as
tagged lookup: ambiguous promoted names resolve to "not found" (same
as FieldByName), and traversing an embedded nil pointer returns an
ErrEvaluate instead of panicking. New index-chain tests pin the fast
path to the general path's values and error messages.

https://claude.ai/code/session_018MkLtLrR1u8FEo1LgxyhrN
@myzie myzie merged commit 636ea1c into main Jun 10, 2026
1 check passed
@myzie myzie deleted the claude/serene-einstein-ifmkqd branch June 10, 2026 12:51
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