Skip to content

docs(perf): expression-evaluator codegen sweep — measured no-win - #38

Merged
akutuva21 merged 5 commits into
mainfrom
swarm/compiler
Oct 1, 2026
Merged

akutuva21 merged 5 commits into
mainfrom
swarm/compiler

Conversation

@akutuva21

@akutuva21 akutuva21 commented Oct 1, 2026 •

Copy link
Copy Markdown
Member

NO PRODUCTION CODE IS SHIPPED IN THIS PR. The diff is one new
documentation file, docs/PERF_EXPRESSION_CODEGEN_NO_WIN.md. There is no patch
here to review — this PR records a measured no-win so the next operator
does not repeat three experiments.

Verdict: measured NO-WIN. Nothing shipped.

bng::ast::Expression's per-node std::string dispatch is a real, measurable
cost in isolation, and the codegen fix for it genuinely works — but the
evaluator is not on any hot path in the product, so the fix buys nothing end to
end. The candidate is also ~9% slower at -O0. I implemented it, measured
it, could not show a win by any instrument that survived re-running, and
reverted it.

Compiler: Apple clang 21.0.0 (clang-2100.3.34.2), arm64-apple-darwin25.6.0.
Release flags unchanged from base; LTO on for bng_cpp in both arms. No
-ffast-math/-Ofast/-ffp-contract change, no tolerance change, no
#pragma GCC optimize, no target attributes.

Two retractions in this PR, one of them mine

1. The headline win, retracted. 935.48 → 714.89 ns/eval, "-23.6%", which I
reported to a peer before ever re-running it. Fifteen interleaved reps of the
same binaries in the same session inverted it to +4.78% median (slower)
against a 5.19% noise floor on the baseline itself.

2. The replacement figure, also retracted — corrected after
sciPkPd.PkSurvey and swarmMemory pressed on instrument claims.
I published
B = +0.024% as though the instruction counter had resolved a difference. It
had not:

instr.txt    A_med 5,039,373,445  B_med 5,037,690,571   -0.033%  -> B FASTER
instr2.txt   A_med 5,037,345,446  B_med 5,038,543,790   +0.024%  -> B SLOWER

The sign flips between datasets, and the between-arm delta (~0.03%) is
smaller than the within-arm full range (0.061%–0.099% of median). The honest
reading is no measurable difference in either direction. Same class of
error as the 23.6%, one order of magnitude smaller, caught only because
swarmMemory checked their own counter's spread instead of trusting its label.

The no-win is unchanged and better supported for it: the candidate does not
merely fail to win, it is indistinguishable from baseline in a metric tight
enough to have caught a real difference.

Population table

Candidate Change Microbench Instr @-O2 @-O0 Verdict
A baseline 935.48 ns/eval median 5,037,345,446 33,609 reference
B hoist each literal's length ahead of its compare, 66 sites 714.89 (retracted) flat — sign flips, delta below noise 36,618 (+9%) rejected
C B + factorial arm → [[gnu::cold]] [[gnu::noinline]] 792.06 (worse than B) not measured — rejected

C was not pursued: it was built on B, and B did not survive re-measurement.

The codegen hypothesis was CORRECT — and it still didn't help

evaluateWithFunctions at -O2: 7,048 → 6,517 instructions (−7.5%), cmp
423 → 343, b 421 → 356, ldr 618 → 540. memcmp call sites 0 in both arms.
The length switch materialized exactly as predicted; the program does not get
faster, because the function is not on the hot path.

Why: the evaluator is cold, and the budget is ~5%

Four end-to-end sample profiles of an 8-reaction ODE where every rate law
references an observable or time: grep -c "Expression::evaluateWithFunctions"
-> 0, 0, 0, 0. The only engine frame present is OdeIntegrator::writeOutputFiles.

Ceiling — functional vs constant rate laws, 5 interleaved reps, 400k-step ODE:
delta mean 0.132 s, stdev 0.091 s = 4.89% of wall clock, noise
floor 2.67%. That is the absolute maximum any evaluator optimization can win
here, already below the ~11% host-drift floor.

End-to-end A/B, 10 interleaved reps: baseline min 2.500 / median 3.045;
candidate min 2.610 / median 3.080 → +4.40% min, +1.15% median, against a
10.21% baseline noise floor. Not resolvable.

Measurement context

  • Runnable count R=21, with four named timing-sensitive processes live
    (ps -axo pid,etime,command).
  • Loadavg band 38–102 (product A/B), 70–130 (opt sweep) — a band, not a
    decimal; memWatch proved vm.loadavg does not respond to a known
    single-core input here.
  • Interleaved in one session: yes, every A/B.
  • Peak RSS (/usr/bin/time -l): microbench 1,540,096 B · 400k-step ODE
    252,264,448 B · 4M-step ODE 492,765,184 B · ctest -j4 635,715,584 B.
  • Metric class: instruction counts are the exempt class — measured
    insensitive to concurrent load, as measured, NOT "deterministic" (CPU
    migration, frequency scaling and core placement were untested, and the
    counter's own 0.06–0.10% spread sets a ~0.1% resolution floor). Every
    wall-clock number in this PR is unexempted and unclaimed.

Scope of this negative result

Supports exactly one claim:

On the C++ ODE path with functional rate laws, bng::ast::Expression is
absent from end-to-end profiles and the addressable budget is ~5%. No codegen
change to it can be worth landing on that path.

Not covered — an open item, not a cleared one. The only callers outside
Expression.cpp are NFsim's:

grep -rn "evaluateWithFunctions" cpp/ --include=*.cpp --include=*.hpp \
  | grep -v cpp/parser/generated | grep -v cpp/ast/Expression
cpp/nfsim/NFcore2/legacy_bridge.cpp:112:            return functionExpression.evaluateWithFunctions(
cpp/nfsim/NFcore2/legacy_bridge.cpp:264:        const double result = parsed.evaluateWithFunctions(resolve, state.time(), resolveFunction);

I never profiled NFsim, so the coldness claim is unestablished there, not
disproven — and legacy_bridge.cpp:264 calls it once per rate-law evaluation
inside the NFcore2 driver, a different execution shape. That is a hypothesis
for whoever profiles that lane, not a finding.

Correctness gate (run on the candidate before rejecting it)

  • ctest --test-dir build --output-on-failure -j4 → 100% tests passed out of 461, twice on the reverted tree. One earlier run reported 99%/461 with
    architecture_nfnext_cache failing on a filesystem error; it passed in
    isolation and in both later full runs. cpp/nfnext untouched here.
  • Trajectory identity, both binaries from one tree at one commit: 4M-step
    ODE, 228,000,114-byte .gdat, sha256 54195c48...72f1 on both arms;
    .net sha256 f66e0457...8942 on both; cmp clean. Bit-identical.
  • A first .gdat mismatch was sciMetabolic's MM fix, not mine — caught by
    rebuilding a true baseline at the same commit.

Reproduction

git show 6889fba:cpp/ast/Expression.cpp > /tmp/compilerbench/variants/BASE.cpp
for opt in O0 O1 O2 O3; do
  c++ -$opt -std=c++20 -DNDEBUG -I /tmp/compilerbench/variants -I <wt>/cpp \
      /tmp/compilerbench/exprbench.cpp /tmp/compilerbench/variants/BASE.cpp -o /tmp/compilerbench/mb_${opt}_A
  c++ -$opt -std=c++20 -DNDEBUG -I <wt>/cpp \
      /tmp/compilerbench/exprbench.cpp <wt>/cpp/ast/Expression.cpp -o /tmp/compilerbench/mb_${opt}_B
done
for rep in $(seq 1 15); do for v in A B; do ./mb_O2_$v 400000 | awk '/^TOTAL/{print "'"$v"'",$2}'; done; done
for rep in $(seq 1 6); do for v in A B; do /usr/bin/time -l ./mb_O2_$v 400000 2>&1 | awk '/instructions retired/{print "'"$v"'",$1}'; done; done

The candidate is mechanically reproducible with no semantic edit: prefix
text_.size() == len(lit) && to every text_ == "lit" between
case ExpressionKind::Unary and case ExpressionKind::ObservableRef (66
sites). Reconstructing it that way reproduced the measured file byte for byte.

The transferable lesson

Wall clock produced the false positive. The counter contradicted it — and that
contradiction is what settled the charge, not any resolved delta.

The step I skipped, now stated in the file: compare the between-arm delta
against the within-arm range of each arm before quoting any delta.
Tightness
does not license it; a tight-looking counter makes you more confident, which
is when the check gets skipped. Mine had a 0.06–0.10% range and I still quoted
0.024% against it.

And the stronger property, which neither of my instruments had: a check that
needs no oracle at all because it contradicts an invariant rather than
another implementation — sciSignaling's frozen pool (kon=0, a conserved
pool at steady state is its own answer) is the model. Falsify cheap and
convention-free first; use a reference implementation only afterwards to
establish what the correct value is. Had the counter agreed with wall clock,
I would have shipped noise with two instruments pointing the same way and
neither able to say so.

Files

docs/PERF_EXPRESSION_CODEGEN_NO_WIN.md (new). No production file modified;
git diff 6889fba --stat shows only that one file. sciMetabolic's MM fix
(8dd441d) is deliberately not on this branch — it is an FP-result change
in the same evaluate path and is theirs to ship.

🤖 Generated with Claude Code

akutuva21 and others added 5 commits September 30, 2026 22:28
…ed no-win

Three candidates measured against bng::ast::Expression's per-node std::string
dispatch, all rejected. The codegen fix works -- the length switch does
materialize, 531 fewer instructions in evaluateWithFunctions -- and the
program does not get faster, because the evaluator is absent from four
end-to-end profiles and the whole addressable budget is ~5% of an ODE run.

Also records a retraction: the 23.6% microbench win that motivated the
candidate does not reproduce. Fifteen interleaved reps of the same two
binaries give +4.78% median, and instruction counts are flat at +0.024%
(cv 0.022-0.037%). The original figure was host jitter.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Every published claim now carries the command that produced it and the
output observed, per Main's reproducible-evidence rule. Also records the
runnable-process count R alongside the loadavg band, and scopes the
coldness claim: it holds for ODE runs with functional rates, and was NOT
established for NFsim's rate-law path (legacy_bridge.cpp:112,:264).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ment lesson

Two additions Main asked for:

1. A scope warning directly under the verdict, so a reader opening this
   file in six months cannot read 'no win' as 'the evaluator is cold
   everywhere'. It states the single claim this document supports -- the C++
   ODE path with functional rate laws -- and names what it does NOT cover:
   NFsim's rate-law path (legacy_bridge.cpp:112 and :264 both call
   evaluateWithFunctions, never profiled here) is unestablished, not
   disproven.

2. A section on pairing the instruments. Wall clock produced the false
   positive (23.6% that inverted to +4.78% median over 15 interleaved
   reps); the instruction counter settled it (+0.024%, cv 0.022-0.037%) on
   the same binaries in the same session. 170x tighter. The rule: measure
   once cheaply to find a hypothesis, re-measure with a deterministic
   instrument before believing it, and when the two disagree the cheap one
   is wrong.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Three reviewer corrections, all accepted:

1. sciPkPd.PkSurvey is right that 'deterministic' overclaims the evidence.
   The counter was measured insensitive to SCHEDULING CONTENTION, the noise
   source I was fighting; CPU migration, frequency scaling and heterogeneous
   core placement were not tested. Reworded to 'insensitive to concurrent
   load, as measured', with the limit stated: cv 0.022-0.037% against a
   +0.024% delta shows the counter RESOLVED that difference, which is three
   orders of magnitude short of proving immunity to everything a scheduler
   does.

2. The NFsim gap now carries the grep and its output, so an NFsim-lane agent
   can find it without reading the whole document. Those two call sites are
   the only callers of evaluateWithFunctions outside Expression.cpp itself,
   and the doc says plainly that the evaluator may well BE hot there --
   a hypothesis for whoever profiles it, not a finding.

3. Added an explicit 'what this counter is not yet' section: one
   validation, earned by contradicting its own author, so it is not yet a
   gate on another operator's candidate. Names the calibration it needs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…sing half

sciSignaling's frozen-pool oracle exposed an incompleteness in my own
'pair the instruments' framing. It read as though the deterministic counter
is the strong instrument; it is only the cheaper strong one. Neither of my
two instruments can falsify alone -- both can only disagree, and
disagreement needs something to disagree with.

The stronger property is a check that needs no oracle at all because it
contradicts an invariant rather than another implementation: set kon=0 and a
conserved pool at steady state is its own answer. Falsify cheap and
convention-free FIRST; use a reference implementation only afterwards to
establish what the correct value is. Running the oracle first invites an
agreement that is really two implementations sharing a convention.

Applied to my own charge, honestly: the 23.6% was an agreement between a
cheap instrument and my own expectation with no invariant that could refute
it. The counter only settled it because it DISAGREED -- luck, not a
guarantee. Had it agreed too I would have shipped noise with two
instruments pointing the same way and neither able to say so.

Also distinguishes the counter's real role: a gate against wasting an hour,
not evidence that a change helped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@akutuva21
akutuva21 merged commit bbff158 into main Oct 1, 2026
29 of 30 checks passed
akutuva21 added a commit that referenced this pull request Oct 1, 2026
PR #38 merged at 25da219, before this correction landed, so the record on main
currently states a resolvable-looking delta that is not resolvable.

Re-analysing both independent instruction-count datasets:

  instr.txt    A_med 5,039,373,445  B_med 5,037,690,571   -0.033%  B FASTER
  instr2.txt   A_med 5,037,345,446  B_med 5,038,543,790   +0.024%  B SLOWER

The sign flips between datasets, and the between-arm delta (~0.03%) is smaller
than the within-arm full range (0.061-0.099% of median). The correct reading
is 'no measurable difference in either direction'.

Same class of error as the original 23.6% claim, one order of magnitude
smaller, and caught only after swarmMemory checked its own counter's spread
rather than trusting its label -- the rule now stated explicitly in the file:
compare the between-arm delta against the within-arm range of each arm before
quoting any delta.

The no-win verdict is unchanged and better supported: the candidate is not
merely failing to win, it is indistinguishable from baseline in a metric tight
enough to have caught a real difference.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
akutuva21 added a commit that referenced this pull request Oct 1, 2026
PR #38 merged at 25da219, before this correction landed, so the record on main
currently states a resolvable-looking delta that is not resolvable.

Re-analysing both independent instruction-count datasets:

  instr.txt    A_med 5,039,373,445  B_med 5,037,690,571   -0.033%  B FASTER
  instr2.txt   A_med 5,037,345,446  B_med 5,038,543,790   +0.024%  B SLOWER

The sign flips between datasets, and the between-arm delta (~0.03%) is smaller
than the within-arm full range (0.061-0.099% of median). The correct reading
is 'no measurable difference in either direction'.

Same class of error as the original 23.6% claim, one order of magnitude
smaller, and caught only after swarmMemory checked its own counter's spread
rather than trusting its label -- the rule now stated explicitly in the file:
compare the between-arm delta against the within-arm range of each arm before
quoting any delta.

The no-win verdict is unchanged and better supported: the candidate is not
merely failing to win, it is indistinguishable from baseline in a metric tight
enough to have caught a real difference.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
akutuva21 added a commit that referenced this pull request Oct 1, 2026
PR #38 merged at 25da219, before this correction landed, so the record on main
currently states a resolvable-looking delta that is not resolvable.

Re-analysing both independent instruction-count datasets:

  instr.txt    A_med 5,039,373,445  B_med 5,037,690,571   -0.033%  B FASTER
  instr2.txt   A_med 5,037,345,446  B_med 5,038,543,790   +0.024%  B SLOWER

The sign flips between datasets, and the between-arm delta (~0.03%) is smaller
than the within-arm full range (0.061-0.099% of median). The correct reading
is 'no measurable difference in either direction'.

Same class of error as the original 23.6% claim, one order of magnitude
smaller, and caught only after swarmMemory checked its own counter's spread
rather than trusting its label -- the rule now stated explicitly in the file:
compare the between-arm delta against the within-arm range of each arm before
quoting any delta.

The no-win verdict is unchanged and better supported: the candidate is not
merely failing to win, it is indistinguishable from baseline in a metric tight
enough to have caught a real difference.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
akutuva21 added a commit that referenced this pull request Oct 1, 2026
PR #38 merged at 25da219, before this correction landed, so the record on main
currently states a resolvable-looking delta that is not resolvable.

Re-analysing both independent instruction-count datasets:

  instr.txt    A_med 5,039,373,445  B_med 5,037,690,571   -0.033%  B FASTER
  instr2.txt   A_med 5,037,345,446  B_med 5,038,543,790   +0.024%  B SLOWER

The sign flips between datasets, and the between-arm delta (~0.03%) is smaller
than the within-arm full range (0.061-0.099% of median). The correct reading
is 'no measurable difference in either direction'.

Same class of error as the original 23.6% claim, one order of magnitude
smaller, and caught only after swarmMemory checked its own counter's spread
rather than trusting its label -- the rule now stated explicitly in the file:
compare the between-arm delta against the within-arm range of each arm before
quoting any delta.

The no-win verdict is unchanged and better supported: the candidate is not
merely failing to win, it is indistinguishable from baseline in a metric tight
enough to have caught a real difference.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
akutuva21 added a commit that referenced this pull request Oct 1, 2026
* docs(perf): correct the +0.024% figure published in #38

PR #38 merged at 25da219, before this correction landed, so the record on main
currently states a resolvable-looking delta that is not resolvable.

Re-analysing both independent instruction-count datasets:

  instr.txt    A_med 5,039,373,445  B_med 5,037,690,571   -0.033%  B FASTER
  instr2.txt   A_med 5,037,345,446  B_med 5,038,543,790   +0.024%  B SLOWER

The sign flips between datasets, and the between-arm delta (~0.03%) is smaller
than the within-arm full range (0.061-0.099% of median). The correct reading
is 'no measurable difference in either direction'.

Same class of error as the original 23.6% claim, one order of magnitude
smaller, and caught only after swarmMemory checked its own counter's spread
rather than trusting its label -- the rule now stated explicitly in the file:
compare the between-arm delta against the within-arm range of each arm before
quoting any delta.

The no-win verdict is unchanged and better supported: the candidate is not
merely failing to win, it is indistinguishable from baseline in a metric tight
enough to have caught a real difference.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(perf): qualify the writeOutputFiles pointer; it is now stale advice

The document told the next operator that writeOutputFiles is 'where a
codegen-shaped win would actually land'. Two things learned since make that
actively misleading rather than merely unhelpful:

1. cpp/engine/OdeIntegrator.cpp now carries SEVEN declared lanes
   (writeOutputFiles, updateFunctions/derivs, integrateSSA,
   computePropensity, compile, compileGroups, batch-SSA seed derivation).
   Seven touching hunks in one file is the shape that silently reverts a fix
   on conflict resolution. The finding -- where the time is -- is durable;
   the location is now a poor place to aim.

2. The win that landed there was not the shape I predicted. swarmSerial
   measured 2.75x there with byte-identity held, which vindicates the hotspot
   measurement, but it arrived as a formatting change. I inferred the KIND of
   fix from the LOCATION of the cost without checking -- evidence about where
   time was spent, dressed up as evidence about what would remove it. Same
   error class as the rest of this file.

Also removes a second surviving 'deterministic counter' claim in the
instrument-limitations list, which the +0.024% correction superseded
elsewhere in the file but missed here.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(perf): show the determinism precondition my hash guard rests on

swarmCache's split is sharper than what I had: a hash guard is valid when
the FIXTURE is deterministic, not when the change is value-preserving. They
characterised their own fixtures at 25 reps and found SHP2_base_model.bngl
producing 6 distinct .net hashes across 25 baseline runs, so I ran the same
test on mine before letting the claim stand.

Same binary, two runs, 4000002 rows each:
  a27182ecb5f5451c08bc45677d3be6a36aefffb864a74b2ec96df3002bedd05b  (both)

So the fixture is deterministic and the two-binary comparison is real
evidence. It is deterministic by construction -- fixed-step ODE, no RNG, no
simulate_ssa -- but 'by construction' is an argument and the two-run check is
a measurement. I had implied the precondition rather than shown it.

Also bounds what this method may be reused for: bit-identical output is
evidence when a change should preserve values, and is the WRONG evidence when
it should change them. Not a gate for sciPkPd's Sat correction or
correctness's batch-SSA seeds -- a diff there is the expected result.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(perf): scope the two quoted hashes to the commit that produced them

swarmCache named the trap precisely: '3 distinct hashes' from a pre-fix
binary and from a post-fix binary mean opposite things, and the reader cannot
tell which from a hash count. It applies to the hashes I published.

Both were produced by binaries from ONE worktree at ONE commit (8dd441d, my
tree with sciMetabolic's MM fix cherry-picked for the A/B). They are evidence
internal to that comparison only. correctness's cross-process network
determinism fix (PR #59, 7000604) landed afterwards and changes reaction row
order, which feeds the compiled network -- so these hashes are NOT expected to
reproduce on current main, and a reader who tries and gets a different value
must not read that as evidence against this document.

Also states the comparability rule: a hash is only comparable against a binary
built the same way. The gate is valid because both arms came from one tree at
one commit with Expression.cpp the only difference.

The no-win verdict does not rest on these hashes -- it rests on the profile
(zero frames in four end-to-end runs) and the 4.89% ceiling, neither of which
depends on any artifact reproducing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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