Skip to content

perf(protocol): per-ProtocolFn dispatch cache + skip ReleaseSafe locals fill (ADR-0206) - #86

Merged
BuddhiLW merged 8 commits into
stagingfrom
swarm/perf-protocol
Oct 22, 2026
Merged

BuddhiLW merged 8 commits into
stagingfrom
swarm/perf-protocol

Conversation

@BuddhiLW

Copy link
Copy Markdown
Owner

Card CLJW-PERF-PROTOCOL-DISPATCH, ADR-0206. User direction: cljw faster than bb on every bench.

What

  • O-081 dispatch cache: each ProtocolFn holds one seqlock slot mapping (receiver descriptor, protocol_generation) to the resolved method. It covers the whole chain: per-type impl, ADR-0187 target, IRecord, Object. Before this, callProtocolFn built a fresh CallSite on every call, which re-scanned the method table with string compares. Every writer bumps the generation: extend-type, extend-protocol, extend, ADR-0187 targets, addProtocolImpl (new bump, after the membership write), and type redefine.
  • O-080: callMethodImpl is now a @setRuntimeSafety(false) wrapper that owns the undefined 2 KB locals buffer. ReleaseSafe no longer 0xAA-fills it on every call; that fill was 52% of cycles in this bench. callMethodBody keeps safety on.
  • Tests: new suite test/clj/suites/protocol_dispatch_cache_test.clj, 8 tests and 21 assertions. Each one calls first, then changes what the call must resolve to: extend after a failed first call, re-extend, an Object default displaced by a type-specific extend, alternating receivers, record redefine per vintage, defprotocol re-eval then re-extend, native Long, reify. The JVM passes 21/21, and cljw --compare agrees on tree_walk and vm.
  • DA fork: the invariants it asked for are now documented beside DispatchCache. Debt D-600 records the non-atomic generation counter (a lost bump under concurrent extends, F-015) and the monomorphic slot. Its finished form is a polymorphic call-site IC in the VM.

Before / after vs bb (bench/vs_bb/workloads/protocol_dispatch.clj, ReleaseSafe -Dwasm)

Host load was 16-28 because it is shared with other lings, so the instruction counts are the stable comparison.

instructions (atom+core) in-process ms
cljw base dd41919 22.6 G 1598-1720
cljw this PR 8.06 G 521-1082 (min of 8: 627)
bb 1.13.224 11.57 G 379-765 (min of 8: 383)
  • cljw now executes 0.70x bb's instructions, down from 1.95x.
  • On wall-clock, the min-of-8 ratio is 1.64x, down from 4.56x in the baseline table.
  • The remaining profile is vm.eval 50%, treeWalkCall 17%, resolveDescriptor 4%: the generic VM call path, which the VM call-path card owns.

Checks: check_core_surface, check_doc_coverage, check_placement_status and check_epipe_head pass. The smoke result is below.

Smoke: first run failed one unit test, which pinned exactly one generation bump; fixed in c243bf8. The rerun result will be added as a comment.

…uffer

ProtocolFn now carries a DispatchCache (ADR-0206): a seqlock slot memoising
the whole resolveProtocolMethod chain (per-type impl, ADR-0187 target,
IRecord, Object) per receiver descriptor, keyed on protocol_generation.
callProtocolFn used a fresh CallSite per call, so every protocol call
re-ran the method-table scan. addProtocolImpl bumps the generation after
the membership write so a fill between the two bumps cannot cache stale
state.

callMethodImpl's 2 KB locals buffer moves into a @setRuntimeSafety(false)
wrapper so ReleaseSafe no longer 0xAA-fills it on every call (O-080).

Smell-audited: 2: interior mutability via @constcast on a const ProtocolFn handle, documented as a pure memo
New suite protocol_dispatch_cache_test calls each protocol fn first, so
the ProtocolFn cache is filled, then extends, re-extends, redefines the
record or re-evaluates defprotocol and calls again. Expected values are
the JVM's (21/21 on clojure -M); cljw --compare agrees on both backends.

Smell-audited: 1: test and ledger only
…bt D-600

The DA review asked why the ~40 host-surface method_table writers need no
generation bump (their rows carry protocol_name "" and never match a
protocol lookup), why class_descriptors inserts cannot stale a memo
(failures are never filled), and what roots the untraced cached value.
These are now written beside DispatchCache. D-600 records the non-atomic
generation counter (lost bump under concurrent extends, F-015) and the
monomorphic per-fn slot, with the polymorphic call-site IC as its finished
form.

Smell-audited: 1: comments and a debt row only
addProtocolImpl bumps after the membership write (ADR-0206), so the unit
test that pinned exactly one bump expects two.

Smell-audited: 1: test assertion only
@BuddhiLW

Copy link
Copy Markdown
Owner Author

Smoke (run 1, on f301c8e + the test fix in the working tree): zig_build_test_tree_walk, entrypoint_surface, repr_decode, test_reach and zig_fmt_check pass. zig_build_test_vm failed on one unit test, which pinned exactly ONE generation bump per __extend-type!. ADR-0206 adds a second bump after the membership write, so this was expected. Fixed in c243bf8; the tree_walk run, which already had the fix, passes 1415+ tests. Remaining steps are still running.

tree_walk.zig: #83 (O-064 uninitScratch) and this branch (O-080 safety-off
wrapper) both removed the ReleaseSafe 0xAA fill of the callMethod locals
buffer. Kept this branch's callMethodImpl/callMethodBody split, with the
buffer now taken from uninitScratch: one mechanism for per-call scratch.
O-080 ledger row updated to match. data/placement.yaml regenerated.

Smell-audited: 2: duplicate mechanism removed at merge, ledger amended
Append-only ledgers resolved as the union of both sides: O-080/O-081 and
O-090 in .dev/optimizations.md, D-600 and D-610 in .dev/debt.yaml.

Smell-audited: 0: ledger union only
BuddhiLW added a commit that referenced this pull request Oct 22, 2026
.dev/optimizations.md and .dev/debt.yaml conflicted on every parallel PR
on 2026-10-22 (#85, #86 against #87), always as two sides appending rows.
git's union driver resolves exactly that; check_yaml and the ledger gates
still validate the result.

Smell-audited: 1: merge driver for a recurring mechanical conflict
…ngle-threaded wasm32

CI's zig build -Dtarget=wasm32-wasi failed: @atomicLoad/@AtomicStore on a
64-bit Value is not available on wasm32. The single-threaded targets
(ADR-0193) have no concurrent reader, so loadMethodVal/storeMethodVal use a
plain access there; 64-bit hosts keep the seqlock's atomics unchanged.
Verified: zig build -Dtarget=wasm32-wasi builds.

Smell-audited: 1: comptime single_threaded branch, the repo's existing idiom
.dev/optimizations.md resolved as the union of both sides (this branch's
O-080/O-081 and #85's O-070..O-077); from now on .gitattributes merges it
by union.

Smell-audited: 0: ledger union only
@BuddhiLW
BuddhiLW merged commit 89ae78b into staging Oct 22, 2026
2 of 3 checks passed
@BuddhiLW
BuddhiLW deleted the swarm/perf-protocol branch October 22, 2026 23:56
BuddhiLW added a commit that referenced this pull request Oct 23, 2026
tree_walk.zig: #86 split callMethodImpl/callMethodBody with a 2 KB
uninitScratch locals buffer on the native stack; this branch moved the
window into the threadlocal tw_scratch for recursion depth. Kept one
mechanism: callMethodImpl is a plain call and callMethodBody borrows an
exact-size tw_scratch window, which neither 0xaa-fills nor costs native
stack. O-080 ledger row amended. data/placement.yaml regenerated.

Smell-audited: 2: duplicate per-call buffer removed at merge, ledger amended
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