Still-current note 2026-08-27. This document profiles the historical bulk rebuild, which remains closed (below). The 08-26/08-27 feature work (RPC surface, mempool eviction, indexes, wallet encryption, regtest) touched none of the verify/storage hot paths profiled here; the levers in §4 are unchanged and still un-landed pending an explicit perf pass.
Closure note 2026-08-25. The rebuild this document profiles is finished:
genesis to the live tip, ending in the MuHash parity proof (README.md).
The final stretch was dominated by ghost-healing (rollback + fresh re-apply
per block after a crashed run, ~10 blk/s through the modern era), which is a
recovery mode, not a representative replay rate — the height-banded numbers
below remain the citable ones. Two artifacts of that stretch became
incidents #45 and #46 in LOG.md.
Written 2026-08-21, during Stage D's full-chain replay (see
PLAN_SCRIPT_VERIFY.md). This is a profile-grounded inventory of where the
replay's cycles actually go and what the real levers are — not a roadmap
commitment. Every number below comes from a perf record of the live
production process at the stated height, symbol-resolved against a copy of
the exact running binary; nothing is estimated from general knowledge.
Deployment rule, stated up front: every lever in §4 touches the live verify or storage path. Per this project's established discipline, none of them gets built into the running daemon until the current replay reaches tip clean. Scoping now, landing later.
"Match Bitcoin Core" only means something if the two are doing the same work, and by default they are not.
- This node runs full signature verification on every historical block,
unconditionally.
assumevalidis deliberately ignored (daemon/node_config.c:272) because the whole point of Stage D's replay is to prove the verifier against real chain data. - Real Core, by default, uses
assumevalidto skip script verification for every block below a hard-coded known-good hash. Its advertised IBD time reflects that shortcut. - The only fair target is therefore Core with
-assumevalid=0(full verification). That mode is far slower than Core's typical sync and is the number this project should be measured against. - Running Core for benchmarking and as an oracle is authorized as of
2026-08-21 — from the separate source build
(
/storage/bitcoin-core-source/build/bin/), never the production install./storage/bitcoinandbitcoind.serviceremain off-limits.
Built libsecp256k1 from Core's vendored source in isolation, with Core's own
shipped configuration (x86-64 asm, ECMULT_WINDOW_SIZE=15, comb gen table),
and ran its bench ecdsa_verify back-to-back with tests/bench_ecdsa, both
under the same load (the live replay was consuming ~6 cores):
cmake -S /storage/bitcoin-core-source/src/secp256k1 -B <scratch> \
-DCMAKE_BUILD_TYPE=Release -DSECP256K1_BUILD_BENCHMARK=ON \
-DSECP256K1_BUILD_TESTS=OFF -DSECP256K1_BUILD_EXHAUSTIVE_TESTS=OFF \
-DSECP256K1_BUILD_CTIME_TESTS=OFF -DSECP256K1_BUILD_EXAMPLES=OFF
cmake --build <scratch> --target bench
SECP256K1_BENCH_ITERS=20000 <scratch>/bin/bench ecdsa_verify
| µs / verify | verifies / s / core | |
|---|---|---|
| libsecp256k1 (Core v31.99 config) | 21.8 (min 21.4, max 23.1) | ≈ 45,900 |
bitcoinmachinecode ecdsa_verify |
120.9 | 8,271 |
The crypto gap is 5.5×, not the ~1.5–2.5× assumed earlier from public figures. That single measurement reframes §4: the secp256k1 side is a far bigger lever than the first GLV scoping estimated, and libsecp256k1's advantage is the stack of techniques (GLV split, wNAF with a 15-bit window, 5×52 field representation, lazy reduction, batch inversion), not GLV alone.
Sub-conclusion: the gap is real but it is a two-front problem — crypto and storage I/O — not the single crypto optimisation the first GLV scoping (§4.3) implied.
Method: perf record -p <download-worker> -g --call-graph dwarf, 10 s
sample, 94,607 events. The on-disk daemon/bitcoind had been rebuilt several
times since the process loaded it, so perf report printed raw offsets
against bitcoind (deleted). Ground truth was recovered by copying
/proc/<pid>/exe and bisecting samples against its nm table after
correcting for the fact that perf printed file offsets, not virtual
addresses (VA = offset + 0x400000; validated — offset 0x368d8 rebases
to exactly the fe_mul.zero label). 100 % of the previously-unresolved
userspace samples resolved.
DSO split: bitcoind 60.2 % · kernel 37.9 % · libc 3.9 %.
Top bitcoind self-time (share of bitcoind's own 60 %):
| share | symbol | note |
|---|---|---|
| 42.0 % | fe_mul.zero |
secp256k1 field multiply, secp256k1_fe.asm |
| ~38 % | ..@28.zt … ..@35.zt (8 labels) |
sc_mul's internal loop, secp256k1_scalar.asm |
| 3.4 % | sc_mul.zero_cur |
|
| 2.2 % | fe_mul (prologue) |
|
| 1.8 % | fe_sub |
|
| 1.7 % | mac_read_exact2.re |
LSM run-file read() wrapper, bitcoin_utxo_lsm.asm:259 |
| 1.7 % | fe_add |
|
| 1.0 % | point_double |
|
| 0.6 % | sha256_block_shani |
|
| 0.3 % | mac_run_lookup.ml_scan_loop |
LSM multi-run scan |
| 0.2 % | sc_inv.inv_loop |
Fermat inversion driver |
Kernel self-time is almost entirely the file-read path:
_copy_to_iter (24.2 % of all cycles) → copy_page_to_iter →
filemap_read → ext4_file_read_iter → vfs_read ← read() syscall.
Rolled up as a share of all cycles:
- secp256k1 field/scalar arithmetic ≈ 52–55 % (
fe_*+sc_*+point_*). - kernel file-read path ≈ 31 %.
- everything else ≈ 15 %.
Two things this profile corrects from earlier in the same session:
- The
..@2X.ztlabels are not a naive double-and-addsc_mul. That implementation was replaced with a constant-time schoolbook multiply on 2026-08-16 (5e39cc5,71985ca); a stale header comment misled an earlier diagnosis (fixed in5e9f8bc). What the profile shows is the genuine cost of the optimised multiply, amplified bysc_invcalling it ~255 times per verify. This is near a floor, not a bug. - Actual EC point arithmetic (
point_double,point_scalar_mul_fixed) is only ≈ 1.2 % of cycles. The crypto cost is field-multiply volume, not doubling count — which matters for how much GLV can deliver (§4.3).
The 31 % read-path cost was not visible in a profile taken hours earlier at a lower height; it is real and growing with the UTXO set.
Two code paths issue read() on this node:
mac_read_exact2(bitcoin_utxo_lsm.asm:259) — rawread()in a retry loop, used byutxo_lsm_getto pull records from on-disk run files.store_read_at(bitcoin_store_fast.asm:334) — "two syscalls total on a warm fd cache (pread index, pread body)", one call per block.
Verdict: UTXO LSM lookups dominate, not block-archive reads. Evidence:
LSM-path symbols (mac_read_exact2.re, mac_run_lookup.*) appear in the
resolved top 25; no store_read_at-family symbol does. Call frequency
agrees — an LSM lookup fires once per transaction input, an archive read
once per block. (Not 100 % certain without tracing past the syscall
boundary, which perf's caller graph did not cleanly do; well-supported.)
This rules out a hypothesis raised the same day: the archive's
non-monotonic layout (PLAN_SCRIPT_VERIFY.md, "Related known issues") is
not the I/O cost — sequential-height reads against scattered blk*.dat
files would show up under store_read_at, and they don't.
Why LSM reads are expensive: utxo_lsm_get scans on-disk runs newest →
oldest until it finds the key, so read volume per lookup scales with
manifest_n — the number of unmerged runs sitting between compactions
(threshold UTXO_LIVE_COMPACT_THRESHOLD = 12). The same mechanism was
observed directly at the 21:24 restart the same day: with 11 unmerged runs
after reload, throughput was visibly degraded until the first compaction
collapsed them to 1.
Status 2026-08-22 — BUILT on branch lsm-mmap, not merged. The root
cause turned out to be sharper than "too many runs are scanned": for every
lookup, for every run, mac_run_lookup (bitcoin_utxo_lsm.asm:1197)
opened the run file and read the entire bloom filter
(BLOOM_MAX_BYTES = 4 MiB at bulk scale) into TLS scratch in order to test
exactly 3 bits, then lseek/read its way through the sparse index and
records, then closed. That copy is the _copy_to_iter the profile put at
24 % of all cycles.
asm/utxo_lsm_mm.c adds a per-thread cache of read-only mmaps: a run is
opened and mapped once, and every later lookup against it is pure memory
access. mac_run_lookup calls it first and falls back to its original,
untouched code on -2 (open/mmap failure, malformed run, any out-of-bounds
offset), so the audited asm remains the correctness anchor. Cache is
__thread (no locks; MAP_SHARED PROT_READ pages ARE the page cache, so
per-thread mappings cost page-table entries, not memory), direct-mapped over
64 slots keyed by run_no % 64, entries tagged with the run's generation.
Measured on identical work (tests/test_utxo_lsm under strace -c -f):
| syscall | before | after | |
|---|---|---|---|
read |
480,714 | 28,723 | −94 % |
lseek |
193,039 | 2,304 | −99 % |
open |
24,982 | 1,820 | −93 % |
close |
25,016 | 2,270 | −91 % |
mmap/munmap |
141 | 975 | (the cache) |
| total syscalls | 781,882 | 94,918 | −88 % |
| time in syscalls | 0.778 s | 0.125 s | −84 % |
tests/bench_lsm_get (10 runs, lookups skewed to misses — the bloom-reject
path): 44,664 → 2,111,432 lookups/s, 47×, with fallbacks=0 confirming
the fast path serves every lookup rather than quietly degrading. That
microbench has small blooms and a warm page cache, so it is an upper bound;
the syscall table above is the honest structural number.
UTXO_LIVE_COMPACT_THRESHOLD (12): leave it alone. Lowering it was the
other half of the original proposal, and it is now the wrong lever. Its
purpose was to bound how many runs a lookup scans, because each run cost an
open + a multi-MiB read. Post-fix a scanned run costs three memory loads
against an already-resident mapping, so scan width barely registers, while
lowering the threshold would buy that irrelevance with strictly more
compaction I/O — the expensive direction. Revisit only if a profile taken
after this lands still shows run-scan cost.
- What: compact more often (lower the run threshold from 12), and/or
replace the newest→oldest linear run scan in
utxo_lsm_getwith a per-key index (e.g. a generation map or a single merged bloom over all runs) so a lookup touches far fewer run files. - Evidence: ≈ 31 % of all cycles, measured; LSM symbols present in the resolved profile; read-amplification mechanism confirmed empirically at the 21:24 restart.
- Impact: large — currently the single biggest cost, roughly 1.5–2× the crypto-side gap the original GLV scoping targeted.
- Risk / effort: threshold tuning is small; a lookup-structure change is
medium. Consensus-adjacent: a wrong lookup is a wrong verify result, so
it needs the same differential testing bar as the verifier itself, plus
the "no test ever exercises this scale" caution recorded in
project_utxo_flush_compact_bug's history — the compaction path has hidden a scale-only bug before. - Timing: wait for tip.
Measured with callgrind on a single fixture verify (scratch harness linking
the repo's .o files; verify ≈ 2.72 M instructions). There are exactly
two inversions per ecdsa_verify (grep 'call fe_inv\|call sc_inv':
secp256k1_ecdsa.asm:138 and :204, nothing else on the path):
| callee | calls / verify | cost | share of verify |
|---|---|---|---|
sc_inv (:138, Fermat s^(n−2)) |
1 → 450 sc_mul (255 sq + 195 mul, secp256k1_scalar.asm:450) |
1.22 M Ir | 45 % |
fe_inv (:204, Jacobian→affine x) |
1 → 503 fe_mul (255 sq + 248 mul, secp256k1_fe.asm:700) |
0.15 M Ir | 5.4 % |
point arithmetic (point_double 253×, point_add 58×, point_add_mixed 73×) |
≈ 3,750 fe_mul + 4,700 fe_add/sub |
1.29 M Ir | ≈ 47 % |
| everything else | 2 sc_mul, 2 fe_mul, parsing |
— | ≈ 2 % |
Two facts the live profile could not show: sc_mul costs ≈ 9.4× a
fe_mul per call (2,719 vs 290 Ir — the fold-based mod-n reduction), so
450 of them cost as much as all ~4,250 field multiplies; and the point
formulas are already standard (dbl 2M+5S, add 11M+5S, madd 7M+4S) — the
remaining point cost is op count (no GLV/wNAF), not formula choice. At
120.9 µs/verify: sc_inv ≈ 54 µs, point arithmetic ≈ 57 µs, fe_inv
≈ 6.5 µs.
What Core does (ecdsa_impl.h:195 secp256k1_ecdsa_sig_verify):
secp256k1_scalar_inverse_var — a variable-time safegcd inverse, neither
Fermat nor batched — then ecmult, then no field inversion at all:
secp256k1_gej_eq_x_var (:257) checks r·Z² == X; if that fails and
r < p − n (secp256k1_ecdsa_const_p_minus_order, :261) it retries with
r + n (:266). Valid because 2n > p. Core never batches ECDSA
inversions.
Why batching is the wrong first move. Legacy-script inputs — the whole
current replay range and nearly all pre-2017 history — reach
ecdsa_verify from inside the interpreter (script_eval →
checksig_fn → sv_checksig, bitcoin_scriptverify.c:217),
synchronously, because OP_CHECKSIG's result can steer script control
flow. Batching there needs a deferred-verify restructure; only the
witness-v0 direct calls (bitcoin_segwit.c:375/:388/:448) and the worker
slices in txvb_verify_all (tx_verify.c:891-909) offer a clean batch
point. So the levers, in order:
A — inversion-free x-compare (drop fe_inv). Replace
ecdsa.asm:196-244 with Core's projective test; Z² is already computed at
:200. Pure algebra, identical verdict on every input. −5.4 % ≈ −6.5 µs.
Small (~40–60 asm lines). The r + n branch fires with probability
≈ 2⁻¹²⁷ for a real signature, so it must be tested at function level with
a constructed (X, Z) where X = (r+n)·Z², plus the r ≥ p − n negative.
B — variable-time scalar inverse (the real prize). sc_inv_var:
binary extended GCD over 256-bit limbs (~10–15 k Ir, ≈ 4–6 µs) or
libsecp's safegcd (~1–2 µs, harder, ~600 lines). Dispatch it at
ecdsa.asm:138 only; keep Fermat sc_inv for secret-scalar paths. Within
policy: secp256k1_point_ct.asm's header and ENGINEERING_RULES.md
confine constant-time to secret scalars, and s is public. ecdsa_in_range
(:126-135) already rejects s = 0 / s ≥ n before :138 — keep that
ordering. −45 % ≈ −50 µs → ~70 µs/verify (1.7×); with A, ~63 µs —
gap to libsecp256k1 5.5× → ~2.9×. Also makes sc_mul's per-call cost
irrelevant to verify (2 calls left). Medium (~200–300 asm lines);
exactness provable by differential test vs Fermat over ≥ 10⁶ random a
plus 1, 2^k, n−1, n−2^k, and a·inv(a) ≡ 1.
B′ — Montgomery batching, after B, witness-v0 only. Per worker slice:
range-check every s first (one zero poisons the prefix-product chain),
one sc_inv_var + 3(k−1) sc_mul. After B this saves ≤ 5 %; not worth
the txvb_verify_one restructure until a post-B profile says otherwise.
A′ — Schnorr. secp256k1_schnorr.asm:302/:313 do two fe_inv per
verify; the same projective trick applies. Taproot-era only
(> 709,632) — flagged, not scoped.
Effort (calibrated: CLTV/CSV ≈ 310 lines/session, taproot script-path
≈ 933 lines/long session): A ≈ ⅓ session, B ≈ 1 session incl. harness,
B′ ≈ 1 session deferred. Timing: all change ecdsa_verify's instruction
stream — branch now, merge after the replay reaches tip, and require the
second full-chain replay to match the first's accept/reject decisions
before deploy.
Status 2026-08-22 — A and B MERGED to main and deployed.
The post-tip gate (§4 preamble: merge only after the running replay reaches
tip and a second replay reproduces the first's accept/reject decisions) was
resolved differently than planned, and deliberately: an unrelated
store-height off-by-one (genesis absent from the archive, so record index ==
real height − 1, shifting every height-gated activation by one block)
invalidated the in-flight replay anyway, forcing a restart from zero. That
fresh full-chain replay — on the fixed archive, with A+B in — IS the
gate: it must validate every block to tip. Verdict-equivalence is separately
covered by the 113,315-case differential against a frozen copy of the
pre-change verifier (below), which is the stronger of the two checks.
ecdsa_x_eq_mod_n (projective compare, exported for the r+n branch test)
and sc_inv_var (binary xgcd, dispatched only at ecdsa.asm's
w = s^{-1}; Fermat sc_inv untouched for signing) are in, with
tests/test_ecdsa_inverse.c: 1,001,023 sc_inv_var vs sc_inv cases
(10⁶ random + every edge listed above), 6,008 ecdsa_x_eq_mod_n cases
including the constructed r+n branch and the r == p−n boundary, and
113,315 ecdsa_verify new-vs-frozen-reference cases (1,024
libsecp256k1-signed fixtures × 13 variants + 10⁵ random tuples) — 0
mismatches. Out-of-tree: 5,000 sc_inv_var outputs vs Python
pow(a,−1,n) and 10⁵ further libsecp256k1-signed signatures through new
and reference — 0 mismatches.
| same moment, replay + Core sync loading the box | µs / verify | /s/core |
|---|---|---|
before (main, min of 3) |
115.4 | 8,662 |
| after (branch, min of 3) | 56.8 | 17,599 |
| libsecp256k1 bench | 22.0 | ~45,500 |
2.03× on ecdsa_verify; gap to libsecp256k1 5.2× → 2.6×.
Building this found two pre-existing lost-carry bugs in the multiplies
themselves, both fixed as the branch's first commit with exact-vector
regression tests (tests/test_mul_carry_regression.c):
sc_mul's MULACC propagated carries only two limbs (lost 2^256 ≡ DELTA
when the third limb was 0xFFFF…; sc_inv(6), sc_inv(n−2), sc_inv(n−k)
were wrong), and fe_mul's fold 2 dropped the carry out of limb 3 (lost
2^256 ≡ C; fe_inv(p−k) wrong). Random-operand probability ~2⁻⁶⁴ and
~2⁻¹⁹⁰ per product — invisible to every random differential, deterministic
on structured chains. Those fixes are independent of A/B and
cherry-pickable, and landed as the first of the two commits.
What: replace the single point_scalar_mul(P2, Q, u2) at
secp256k1_ecdsa.asm:319-322 with a GLV-split, dual-stream wNAF
Strauss/Shamir ladder: u2 = r1 + λ·r2 (mod n) with both halves ≤ 128 bits
(scalar_impl.h:142-178), one odd-multiples table of Q at window w=5
(ecmult_impl.h:32,:73-123) with the λ-table derived for free as (β·x, y)
(group_impl.h:917-923, ecmult_impl.h:315-317), and one ladder over both
digit streams (ecmult_impl.h:252-365): ~128 doublings instead of 252.
The G side (comb table, zero doublings) and the 4.2-A projective compare are
untouched.
Constants (4×64 LE limbs, limb0 first — transcribed from Core and
numerically verified: λ³≡1 mod n, β³≡1 mod p, and λ·G == (β·Gx, Gy)
on the real curve, so this is the matching pair, not the conjugate):
| name | source | limbs |
|---|---|---|
LAMBDA (mod n) |
scalar_impl.h:83 |
DF02967C1B23BD72 122E22EA20816678 A5261C028812645A 5363AD4CC05C30E0 |
BETA (mod p) |
field.h:69 |
C1396C28719501EE 9CF0497512F58995 6E64479EAC3434E9 7AE96A2B657C0710 |
MINUS_B1 |
scalar_impl.h:144 |
6F547FA90ABFE4C3 E4437ED6010E8828 0 0 |
MINUS_B2 |
:148 |
D765CDA83DB1562C 8A280AC50774346D FFFFFFFFFFFFFFFE FFFFFFFFFFFFFFFF |
G1 |
:152 |
E893209A45DBB031 3DAA8A1471E8CA7F E86C90E49284EB15 3086D221A7D46BCD |
G2 |
:156 |
1571B4AE8AC47F71 221208AC9DF506C6 6F547FA90ABFE4C4 E4437ED6010E8828 |
Algorithm: split — c1 = round((k·G1) >> 384), c2 = round((k·G2) >> 384)
via a 512-bit product taking limbs [6,7] plus the rounding bit (bit 383,
scalar_4x64_impl.h:893-915); r2 = c1·MINUS_B1 + c2·MINUS_B2;
r1 = k − r2·λ (all mod n). Sign rule: a half is negative iff bit 255 is
set (ecmult_impl.h:176-179, valid because n > 2²⁵⁵); negative → magnitude
n − r and negate every wNAF digit. wNAF (:162-222): length 129 (bit
128 can carry), w=5, odd digits |d| ≤ 15, index pre[(|d|−1)/2], negate y
for d < 0. Table (:73-123): d = 2Q, isomorphism C = d.z, 7 mixed adds
recording z-ratios, then ge_table_set_globalz (group_impl.h:289-310) so
every entry shares one Z — no inversion anywhere; λ-table aux[i] = pre[i].x · β. Ladder: per bit, double, then up to two mixed adds (one per
stream); finish with R.z *= Z (:359-361) before P = P1 + R so the
4.2-A compare sees a real-curve point.
New vs existing in this codebase: sc_mul_512 (~40 lines, extract
sc_mul's Phase-1 product at secp256k1_scalar.asm:262-299 — sc_mul is a
reduced product and cannot be reused); sc_split_lambda (~120 lines);
glv_wnaf (~80 lines C, precedent secp256k1_scalar_c.o/utxo_lsm_mm.c);
fe_neg is absent — use fe_sub(r, ZERO, y); point_add_mixed_zr (+10
lines, the z-ratio is H at secp256k1_point.asm:205-212);
point_scalar_mul_glv (~350-450 lines, model on point_scalar_mul
:422-614); kill switch BMC_ECDSA_GLV=0 like BMC_LSM_MMAP.
Latent bug to close first: point_add/point_add_mixed do not handle a
Z=0 operand (return infinity instead of the other point; :54-90 only
handles equal-X). Unreachable via point_scalar_mul today (it seeds R from
the top digit), reachable in a dual ladder — guard inside the primitives.
Expected gain, derived from §4.2's measured costs (dbl 2,320 Ir, add 4,640, madd 3,162, fe_mul 290; verify ≈ 1.35 M Ir ≈ 56.3 µs):
| component | today | GLV w=5 + globalz |
|---|---|---|
| split + wNAF | — | ≈ 15.5 k |
| table | 14 madd = 44.3 k | 37.2 k |
| doublings | 252 × 2,320 = 584.6 k | 128 × 2,320 = 297.0 k |
| window adds | 58 add = 269.1 k | ≈ 43 madd = 136.9 k |
u2·Q |
898 k ≈ 37.4 µs | ≈ 487 k ≈ 20.3 µs |
ecdsa_verify |
56.3 µs | ≈ 39 µs |
| gap to libsecp256k1 (21.8 µs) | 2.58× | 1.8× |
A Jacobian-table variant without globalz gets ≈ 85 % of this (≈ 41.8 µs).
What remains afterwards is field representation — libsecp's 5×52
lazy-reduction fe_mul is ~4-5× cheaper per call than our 4×64 — and the
G-side comb (≈ 7.9 µs; libsecp's WINDOW_G=15 tables would be a "4.5").
Risk: sign handling in three places (half negativity, negative digit,
λ-table shares y); lattice rounding off-by-one (halves still satisfy the
identity but exceed 128 bits — caught only by exact comparison against
Python's split, not by the identity); wNAF needs 129 digit slots; the
globalz direction and the forgotten R.z *= Z are silent (only the
point-level differential catches them); Z=0 mid-ladder (~2⁻¹²⁸/step, guard
anyway). Permanent runtime check: after every split assert
r1 + λ·r2 == k (≈ 0.1 µs; libsecp does it in VERIFY builds) and on failure
fall back to point_scalar_mul — never abort a verify.
Tests: sc_mul_512 vs Python 10⁶; sc_split_lambda vs Python exact
10⁶ + {0,1,n−1,n−2,2¹²⁸,2¹²⁸−1,2²⁵⁵,n>>1,λ,n−λ}; glv_wnaf 10⁶ reconstruct;
point_scalar_mul_glv vs point_scalar_mul ≥ 10⁵ (k,Q) compared
projectively incl. Q ∈ {G, 2G, −G, λG}; then the existing 113,315-case
tests/test_ecdsa_inverse.c campaign vs the frozen ecdsa_verify_ref.asm
unchanged — it is already the right gate.
Staging: branch glv, each commit suite-green: (a) Z=0 guards in
point_add/point_add_mixed (cherry-pickable bug fix); (b) sc_mul_512 +
sc_split_lambda; (c) glv_wnaf; (d) point_add_mixed_zr +
point_scalar_mul_glv standalone + differential; (e) wire into
ecdsa_verify behind BMC_ECDSA_GLV. Deploy gate as 4.2, plus tonight's
lesson: watch the first checkpoint resume. Effort ≈ 1,100-1,400 lines
incl. tests, 2-3 long sessions, the risk concentrated in (d).
Status 2026-08-22 — BUILT on branch glv, (a)–(e) all landed, not
merged. Exactly the staging above: 3853c3f Z=0 guards (the old code
failed all 10 infinity cases of tests/test_point_inf.c); efa0606
sc_mul_512 + sc_split_lambda with the permanent identity check
(1,000,016 product cases vs a C schoolbook, 2,018 exact vectors vs the
Python oracle — which re-derives g1/g2/−b1/−b2 from Core's basis and
asserts they match the asm — 10⁶ identity+bounds, and 1,002,018 exact
out-of-tree); 82371b1 glv_wnaf (10⁶ reconstructed, both signs);
45d6c65 point_add_mixed_zr + point_scalar_mul_glv with globalz
(102,056 projective cases vs point_scalar_mul, incl. λG == (βGx, Gy),
the k×Q grid, every sign combination of the halves); (e) wired behind
BMC_ECDSA_GLV — the 113,315-case test_ecdsa_inverse campaign vs the
frozen reference passes with GLV on and with the switch off, and
tests/test_ecdsa_glv_switch.c pins glv/plain/reference to identical
verdicts on 30,240 cases. The globalz variant shipped, not the Jacobian
fallback.
| same moment, replay + oracle loading the box, min of 3 | µs / verify | /s/core |
|---|---|---|
main (4.2 A+B) |
54.7 | 18,271 |
glv, BMC_ECDSA_GLV=0 |
54.8 | 18,238 |
glv, GLV on |
35.3 | 28,350 |
| libsecp256k1 bench | 22.0 | ~45,500 |
Standalone u2·Q: 46.2 → 25.7 µs (1.8×). ecdsa_verify 1.55×; gap to
libsecp256k1 2.5× → 1.6× (the model above said 1.8×; the table build and
the adds came in cheaper than the derivation assumed). What remains is
field representation and the G side, as noted above.
- What:
store_rd_advise(bitcoin_store_fast.asm) already does fadvise-based prefetch for block-archive reads. - Impact: low — archive reads are not the I/O bottleneck (§3).
- Timing: orthogonal to the verify path, could be built now; not worth the attention yet.
sc_mulalgorithm — already optimised (see §2); nothing to fix.- Downloader write ordering / monotonic archive — not the I/O cost
(§3); the reorg and pruning problems it blocked were solved without it
(
9269a86,a051f21). - CUDA offload — a validated batch ECDSA kernel exists
(
asm/cuda/,1e648af), but per-launch overhead (~170 ms measured for the SHA sibling) makes per-block offload a net loss, and the host GPU is currently saturated by an unrelated process (header audit measured the CPU path 1.46× faster than GPU on 2026-08-21). Shelved until the node is stable at tip and GPU capacity is actually available.
All of 4.1, 4.2 A+B and 4.3 deployed. Clean self-time perf (no call
graph), symbol-resolved against /proc/<pid>/exe, 204 k samples:
| share of all cycles | what |
|---|---|
| 74.0 % | secp256k1 / hashing (fe_*, sc_*, point_*, sha256) |
| 14.2 % | libc (memcpy/memmove in block parsing and the verify pool) |
| 5.1 % | kernel — was 31–38 %; the LSM I/O wall is gone |
| 1.1 % | script interpreter |
| 0.9 % | UTXO apply / LSM |
| 0.1 % | verify-pool dispatch |
Top symbols: fe_mul 55.9 %, fe_add 4.1 %, sc_inv_var 3.6 %,
fe_sub 3.3 %, sha256_block_shani 1.4 %, point_scalar_mul_glv 1.2 %,
point_double 1.1 %. point_scalar_mul (the non-GLV path) is 0.00 % —
GLV is executing, no fallback; sc_inv/fe_inv are 0.00 %;
mac_read_exact2 is 0.00 % — the mmap path serves every lookup.
Reading: the replay is now almost purely bound by the field multiply.
GLV cut the number of point operations (≈ 252 → 128 doublings) but each
still costs our ~290-instruction 4×64-limb fe_mul with full reduction.
libsecp256k1's 5×52-limb lazy-reduction fe_mul is ~4–5× cheaper per call;
that representation change is the next lever and the last large one on the
crypto side (then the G-side WINDOW_G=15 tables, ≈ 5–6 µs).
Session result, all deployed and running on the live rebuild:
| before (08-21 AM) | after (08-22 04:45) | |
|---|---|---|
ecdsa_verify |
115–121 µs | ~39 µs (loaded: 44) |
| vs libsecp256k1 on this CPU (21.8 µs) | 5.2× slower | 1.65× slower |
| kernel (I/O) share of cycles | 31–38 % | 5 % |
| replay, identical heights 343087→363086 (before GLV) | 7.8 blk/s | 34.1 blk/s (4.39×) |
| replay, identical heights 378845→398844 (with GLV) | 3.9 blk/s | 22.2 blk/s (5.68×) |
The two walls the first profile found — inversions and bloom copies — are
both gone. What remains is fe_mul at 56 % of cycles: field
representation (5×52 lazy reduction) is the next scope, projected to take
fe_mul from ~290 to ~70 Ir and the verify from ~39 µs to the low 20s —
i.e. parity with libsecp256k1 is now one representation change away, not a
stack of them. Order after that: G-side tables (4.5), then whatever the next
clean profile shows.
The multipliers above are baseline-matched: same heights, old code vs new. They are the honest way to measure a code change, but they are not the number you want for "how long to tip", because block density keeps rising. One clean uninterrupted run, measured end to end on the deployed binary:
| span | wall clock | rate |
|---|---|---|
| 537,616 → 575,833 (38,217 blocks) | 16:35:23 → 17:38:59 (3,816 s) | 10.0 blk/s |
Full signature verification, no assumevalid, 16 verify threads, mmap run
cache on. That is ~3.4× slower than the 34.1 blk/s measured at 343k–363k —
almost entirely block density (inputs per block), not a regression: the
343k-era blocks predate segwit and carry a fraction of the signature work.
Treat 10 blk/s as the current realistic planning figure for the 500k–600k
band and expect it to keep falling through the taproot/inscription era.
A caveat for anyone projecting from this: raising stopatheight to extend
the download ceiling pauses the replay entirely for the duration of the
download leg (they do not interleave), so wall-clock to tip includes those
pauses — roughly 20 minutes per 58k blocks fetched from the local oracle at
~30 blk/s.
Scoped in §5 as "5×52 lazy reduction", targeting fe_mul from ~290 to ~70 Ir
and the verify from ~39 µs to the low 20s. What shipped is not 5×52. The
first measurement of the session invalidated the premise, so the plan changed;
the numbers that forced that are below, and they are the main deliverable of
this section along with the code.
§4.6 recorded, from public knowledge rather than measurement, that
"libsecp256k1's 5×52-limb lazy-reduction fe_mul is ~4–5× cheaper per call".
Built libsecp256k1's own bench_internal and, separately, a single process
linking libsecp's field code and this repo's secp256k1_fe.o so both run
in one harness under one load (scratch hh.c, CPU-time, min-of-25):
| ns per call, min-of-25 | this repo (main cb20051) |
libsecp256k1 5×52 | ratio |
|---|---|---|---|
fe_mul, serial dependency chain |
12.87 | 9.36 | 1.38× |
fe_mul, 4 independent chains |
10.46 | 4.33 | 2.42× |
fe_sqr, serial chain |
13.12 | 8.20 | 1.60× |
So the representation gap was 2.4×, not 4–5×, and only in the throughput-bound regime. Two further facts settled the direction:
- libsecp256k1 ships no x86-64 field assembly at all (
src/asm/contains onlyfield_10x26_arm.s); its 5×52fe_mulis__int128C. It was beating hand-written assembly. - Probing this CPU directly (scratch
uarch.asm, serial-addclock reference):mulx0.664 cyc/op throughput,adcx+adoxon two interleaved chains 0.501 cyc/op, achieved clock 5.48 GHz. A 4×64fe_mulneeds 21 multiplies; libsecp's 4.33 ns is 23.7 cycles, i.e. libsecp's 25-multiply 5×52 kernel was already sitting near the multiplier port limit. A 4×64 kernel that also sat near that limit would be at parity or better — with no representation change, no lazy-reduction magnitude bookkeeping, and no edit tosecp256k1_point.asm(84fe_*sites),secp256k1_point_ct.asm(59),bitcoin_pubkey.asm,bitcoin_keys.asm,secp256k1_ecdsa.asm,secp256k1_schnorr.asm,secp256k1_taproot.asmorwallet_msgsign.c.
Diagnosis of the real problem: main's fe_mul was 290 instructions for
work that needs ~110. It materialised each row a_i*B into its own 40-byte
stack buffer, merged the four buffers with memory-to-memory add/adc, then
reduced through memory using mul (which forces rax/rdx) with three-
instruction xor edx,edx / add / adc rdx,0 carry captures. The
representation was never the cost; the constant factor was.
Choice made: keep 4×64 canonical, rewrite the kernels. Stated plainly because it is a deviation from the scope: this trades ~20 % of the remaining theoretical field gain for a change that touches ONE file, keeps every existing test applicable unchanged, and is provable limb-for-limb against the implementation that has been validating the chain. On consensus code that trade is not close.
fe_mul— same algorithm, same two-fold reduction, zero memory traffic. The 512-bit product and both folds live in registers (15 of the 15 usable GPRs); every carry is captured by ADCX/ADOX on two interleaved chains instead of by anadd/adcpair. 21 multiplies, ~36 ADX ops.fe_sqr— wasjmp fe_mul. Now a dedicated square: 10 multiplies instead of 16 (six off-diagonal products accumulated once, doubled with one shift-left chain, then the four diagonal squares on a single ripple carry). Squaring is ~40 % of the field multiplies on the verify path.fe_add/fe_sub— rewritten around the identityp = 2^256 - C, so "subtract p" is "add C" and the carry out ofv + Cis the predicatev >= p. NoP_LIMBSload, no four-limb comparison, and no callee-saved spills at all. Bit-identical to the previous code on every 512-bit input pair, in range or not — "add p" and "subtract C" are the same operation on wrapping 4-limb arithmetic — which is why this was safe to do without first proving no caller ever passes a non-canonical value.- The reduction has exactly one implementation:
fe_sqrjumps tofe_mul.reduce(documented entry contract) rather than carrying a copy. That code has already produced one consensus bug; two copies could drift.
| instructions in the function | before | after |
|---|---|---|
fe_mul |
290 | 113 |
fe_sqr |
291 (jmp fe_mul) |
105 (56 + the 49-instruction shared reduce) |
fe_add |
48 | 31 |
fe_sub |
36 | 19 |
Callgrind on a 100,000-call fe_mul chain: 296 → 112 Ir/call.
tests/bench_fe — new in-tree microbenchmark. CPU time
(CLOCK_THREAD_CPUTIME_ID), min-of-25 rounds of 200,000 calls, spread
printed; the same discipline commit 879554b applied to
test_scalarmul_ct, and mandatory here because the live replay was consuming
~10 of 32 threads throughout. Two regimes are reported because they bracket
the real cost: a serial dependency chain (latency) and four independent chains
(throughput — closer to what point arithmetic achieves).
| ns per call | before | after | speedup | libsecp256k1 |
|---|---|---|---|---|
fe_mul latency chain |
12.87 | 8.53 | 1.51× | 9.36 |
fe_mul 4 indep. chains |
10.46 | 5.52 | 1.90× | 4.33 |
fe_sqr latency chain |
13.12 | 7.67 | 1.71× | 8.20 |
fe_sqr 4 indep. chains |
10.73 | 4.68 | 2.29× | — |
fe_add latency chain |
2.20 | 2.20 | 1.00× (48→31 instructions) | ~0 (inlined) |
fe_sub latency chain |
1.68 | 1.28 | 1.31× | — |
Head-to-head again afterwards, both implementations in ONE process under one
load (the same hh.c harness that produced the "premise was wrong" table):
| ns per call, min-of-25 | this branch | libsecp256k1 5×52 | |
|---|---|---|---|
fe_mul, serial chain |
8.92 | 9.65 | 1.08× faster |
fe_sqr, serial chain |
7.79 | 8.21 | 1.05× faster |
fe_mul, 4 indep. chains |
5.76 | 4.48 | 1.28× slower |
So the 4×64 kernel now beats libsecp's 5×52 on latency and remains 1.28×
behind on throughput. That residual is precisely what lazy reduction buys and
this change deliberately did not take: libsecp's fe_mul returns a
non-canonical result of bounded magnitude, skipping the conditional subtract,
and its fe_add is five adds with no reduction at all (measured at 0.000 ns
because it inlines to nothing) against our 2.31 ns canonical add. Closing
that last 1.28× is what a real 5×52 lazy-reduction conversion would be for —
and it is now worth ~11 % of a verify, not the 2× the original scope assumed.
Spreads were tight: fe_mul after, 25 rounds, min 5.515 / median 5.646 /
max 7.700 ns. fe_add's latency is unchanged — it is a serial
load→add→cmov→store chain and always was — but it costs 35 % fewer
instructions, which is what its share of a throughput-bound verify responds to.
tests/bench_ecdsa, upgraded to CPU-time min-of-N in the same commit,
alternating old and new binaries in one session so drift lands on both:
| µs / verify, min-of-7 rounds x 20,000 | /s/core | |
|---|---|---|
main cb20051 field, best of 3 runs |
35.28 | 28,342 |
| this branch, best of 3 runs | 25.66 | 38,964 |
libsecp256k1 bench ecdsa_verify, best of 3 |
20.6 | ~48,500 |
ecdsa_verify 1.37×; gap to libsecp256k1 1.71× → 1.25×. The §5 target was
"low 20s"; 25.7 µs is close but short of it, and the honest reading is that
the remaining gap is no longer mostly field representation — after this
change, a callgrind attribution of one verify puts fe_mul(+fe_sqr) at
61.7 % of retired instructions, fe_add at 9.3 %, sc_mul 6.7 %, fe_sub
6.6 %, and the whole verify at 415 k Ir / 140 k cycles (IPC 2.95). The next
lever is the G-side comb table (§4.5), not another field pass.
tests/test_fe_repr.c (new, in the test target) plus
validation/fe_oracle.py (new) and tests/fe_ref.asm (new: a FROZEN copy of
main's field, symbols suffixed _ref — the implementation that replayed the
chain to height ~575,000, including the incident #7 fixes). 39,266,927
checks, 0 failures, 1.3 s.
- Python ground truth, explicit. 1,600
(a, b, a+b, a-b, a*b, a², a⁻¹)tuples from Python big integers. Shares nothing with the assembly. - Python ground truth, exhaustive over the structured space. A 1,547-value
structured family — every
2^i,2^i±1,p-2^i,p-2^i±1for all 256 bit positions,0..7,p-1..p-8,C-1/C/C+1/2C/p-C/p-2C, and per-limb saturation patterns — crossed with itself: 2,393,209 ordered pairs,a*b,a+banda-bfor each, folded into one digest that the harness recomputes from the assembly and compares to Python's. Every limb boundary against every other limb boundary. - Differential vs the frozen
fe_ref, limb-exact: the same 2,393,209 structured pairs, plus 4,000,000 random in-range pairs (mul/add/sub/sqr) — 0 differences. - Non-canonical input. 4,000,000 full-range
[0, 2^256)pairs plus a structured sweep withpdeliberately added to one operand, proving thefe_add/fe_subbit-identity claim on inputs neither implementation defines a result for. 0 differences. - Algebraic identities independent of both implementations: 800,000 of
(a+b)-b == a,a*(b+c) == a*b+a*c,a*a⁻¹ == 1,fe_sqr(a) == fe_mul(a,a), and canonicality of every result. - Incident #7's exact operand is pinned in this file as well as in
test_mul_carry_regression:(p-2^31)² == 2^62.
Sensitivity proven by mutation, both ways. Eight deliberate carry bugs
were injected and every one is caught: dropping the pending CF at the end of
phase-1 row 3; deleting the fold-2 carry correction (literally re-introducing
incident #7); dropping the fold-1 carry absorb; deleting the bit that
fe_sqr's doubling chain carries into T7; removing fe_add's carry fold;
flipping fe_sub's correction sign; removing fe_mul's canonicalisation;
and moving one fe_sqr diagonal term a limb too low. The pre-existing
tests/test_fe misses the fold-2 mutation, and
test_mul_carry_regression misses both fe_sqr mutations — which is the
argument for the new file existing.
Whole-stack equivalence, out of tree: a digest harness ran the 1,024
libsecp256k1-signed fixtures × 13 mutated variants, 200,000 random verify
tuples, and a 200,000-step fe_inv/fe_mul/fe_sqr chain digesting every
intermediate, built twice — once against the new field, once against
main's. All three digests identical (717fb4cf77f2d725,
a04995f0966e4025, 48249430d5520df1). In tree, the existing
tests/test_ecdsa_inverse campaign (1,001,023 sc_inv_var cases, 6,008
ecdsa_x_eq_mod_n, 113,315 verify-vs-frozen-reference) and
test_ecdsa_glv_switch (30,240 three-way) pass unchanged, as does
test_scalarmul_ct — the constant-time guard, which matters because
fe_mul's canonicalisation stayed branch-free cmov rather than becoming a
conditional jump. Nothing on the signing path became variable-time; no
variable-time optimisation was introduced at all in this change.
One hazard this change created, and how it was closed. The new fe_mul
reuses rsi as the fold's fifth limb and the new fe_add reuses rsi/rdx
as canonicalisation scratch. Both are caller-saved under SysV so this is
legal, but the OLD fe_add/fe_sub/fe_mul happened to leave rsi intact,
and 163 hand-written call sites across secp256k1_point.asm (78),
secp256k1_point_ct.asm (54), bitcoin_pubkey.asm (10), bitcoin_keys.asm
(6), secp256k1_schnorr.asm (6), secp256k1_taproot.asm (6) and
secp256k1_ecdsa.asm (3) could have been relying on that accident. A static
scan found no site reading rsi/rdx after an fe_* call before writing it,
and then the point was settled dynamically: a build with EVERY caller-saved
register (rsi, rdx, rcx, rdi, r8-r11) deliberately poisoned with 0xDEAD…
at every ret in secp256k1_fe.asm passes test_fe, test_point,
test_point_inf, test_addm, test_add, test_scalarmul,
test_glv_pointmul, test_ecdsa, diff_add_ct_homog, test_scalarmul_ct,
test_mul_carry_regression, test_schnorr, test_pubkey and test_keys
unchanged. Nothing depends on the old register-preservation accident.
Not verified: no replay of real chain data was run against this branch (the live daemon is mid-replay and is not to be touched), so the deploy gate of §4's preamble still applies.
libsecp256k1 ships no IFMA field backend, so this is where this project could exceed it rather than approach it. Per the scoping rule, the CPU was measured before anything large was built.
Instruction-level probes (scratch uarch.asm/uarch.c, min-of-15,
CPU-time, clock established by a serial add rax,1 chain = exactly 1 cyc/op):
| cyc/op | note | |
|---|---|---|
| achieved clock | — | 5.478 GHz under the live replay |
mulx, independent |
0.664 | ~1.5/cycle |
adcx+adox, two interleaved chains |
0.501 | 2/cycle — the fe_mul design target |
vpmadd52luq zmm, 8 independent accumulators |
0.550 | ~1.8/cycle × 8 lanes |
vpmadd52luq zmm, serial chain |
4.008 | latency 4 cycles |
vpmadd52luq ymm |
0.557 | identical to zmm — no 512-bit penalty |
Downclocking: there is none worth modelling on this part. Immediately
after a sustained AVX-512 burst the scalar clock read 5.469 GHz against a
5.478 GHz baseline. Under eight concurrently running sustained IFMA hogs
(on top of the replay) the scalar clock moved 5.429 → 5.305 GHz (−2.3 %,
explicable by the added load alone) and scalar fe_mul was unchanged:
8.686 → 8.677 ns latency, 5.675 → 5.791 ns throughput. This is the Zen 5
answer, and it is not Intel's.
A real 8-way kernel was then built and validated, not extrapolated:
5×52 limbs, one field element per lane, 25 lane-parallel products as 50
VPMADD52{LU,HU}Q, a ten-limb carry normalisation, and the 2^260 ≡ C·2^4
fold — 63 IFMA and ~91 other vector instructions. Checked against this repo's
scalar fe_mul over 20,000 chained rounds × 8 lanes = 160,000 products, with
the structured shapes (0, 1, p-1, p-2^31, 2^255, 2^64-1, a
limb-2-all-ones value, p-2) pinned into specific lanes: identical.
| ns per field multiply | |
|---|---|
scalar fe_mul after §5.2, 4 indep. chains |
5.52 |
| IFMA 8-way, 4 indep. chains | 1.343 (min-of-15; med 1.446, max 1.722) |
| IFMA 8-way, single chain | 2.619 |
4.11× on the field multiply, and the hardware objection does not apply.
One trap worth recording: with acc[10] written as a loop over an array, gcc
kept the accumulators on the stack and the kernel measured 4.28 ns — 3.2×
worse than the unrolled version, and a number that would have produced the
wrong verdict. The measurement is only valid because the disassembly was
checked (27 vmovdqa64 and 6 vpmadd52 in a loop, versus 63 vpmadd52 and
no spills after unrolling).
Verdict: a genuine win at the kernel, correctly deferred as a project. Not because of the silicon — the silicon is willing — but because of what lane-parallelism costs above the kernel:
- Every consumer must be rewritten 8-wide.
fe_add/fe_subbecome nearly free, but the point formulas, the GLV ladder, the wNAF digit handling and the scalar side are a second complete implementation of the EC stack, in vector form, on the consensus path — beside the scalar one, which cannot be retired because odd-sized batches still need it. - The per-lane table read is solvable but not free: each lane has its own
Q, so the eight w=5 table entries for a lane cannot be packed into one zmm and selected with a singlevpermq. A table read becomes ~8 blends per limb (or avpgatherqq, which is slow on Zen) — with the globalz table's two coordinates × 5 limbs that is ~80 vector ops per windowed add, on top of the add itself. Estimated, not measured. - The batching point does not exist yet for most of the remaining work.
§4.2 already recorded this: legacy-script inputs — nearly all pre-2017
history — reach
ecdsa_verifysynchronously from inside the interpreter becauseOP_CHECKSIG's result steers script control flow. Only the witness-v0 direct calls and thetxvb_verify_allworker slices offer a clean place to collect eight independent verifications.
So the honest ordering is: the deferred-verify restructure is the prerequisite, and it is worth doing on its own merits (it also unlocks §4.2 B′ Montgomery batching). IFMA is what you build after that exists, and only if a profile at that point still says the field multiply is the wall. Recorded here with the numbers so the next scoping does not have to re-measure the CPU.
Ceiling, for whoever picks this up: if the whole 74 % crypto share went 4×, Amdahl gives 1/(1 − 0.74·0.75) = 2.3× on replay — real, but a second EC implementation's worth of consensus risk for it.
From §4.6's post-deploy profile: fe_mul (which then included every
fe_sqr, since fe_sqr was jmp fe_mul) was 55.9 % of all replay
cycles, fe_add 4.1 %, fe_sub 3.3 %; crypto+hashing together 74.0 %.
State the bound first: even an infinitely fast fe_mul only removes its own
55.9 %, capping the whole lever at 1/(1 − 0.559) = 2.27× on replay. Every
number below is a fraction of that ceiling, not of the runtime.
Applying the measured throughput-regime speedups to their measured shares:
| component | share of all cycles | speedup | cycles removed |
|---|---|---|---|
fe_mul + fe_sqr |
55.9 % | 1.90× (sqr better, 2.29×) | 26.5 % |
fe_add + fe_sub |
7.4 % | ~1.4× (instruction count 84→50) | 2.1 % |
| everything else | 36.7 % | 1.00× | 0 |
→ 1/(1 − 0.286) = ≈ 1.40× on replay block throughput, i.e. ~47 % of the
theoretical fe_mul headroom captured. That projection is corroborated
rather than assumed: the independently measured end-to-end ecdsa_verify
improvement is 1.37×, and ecdsa_verify is the dominant consumer of
those same cycles.
Against §5.1's measured 10.0 blk/s in the 537,616 → 575,833 band, that projects ≈ 14 blk/s in the same band. Caveats, stated because this number will be quoted: the 55.9 % share was measured at height ~390 k and block composition keeps shifting toward more signature work per block (which, if anything, makes the crypto share larger and the projection conservative); the projection assumes the replay is CPU-bound, which §4.6 supports (kernel 5.1 %); and no replay was actually run on this branch, so this is a projection from two measurements, not an observation.
Scoped 2026-08-22 evening. Sections 1-5 aim at parity with libsecp256k1. This section is about exceeding it, and starts with the uncomfortable part.
Every multiplier in this document is this code against itself, old vs
new, at matched heights. That is the right way to measure a change and it
says nothing about Core. The only like-for-like comparison is Core doing a
full-verification IBD (-assumevalid=0 -stopatheight) in a scratch datadir
on this machine, and it has never been run. Until it is, any claim to beat
Core is unsupported.
It is deliberately not run while the replay is live: it would contend for CPU and disk with the thing being measured, giving Core an artificially bad number and slowing our own replay. Queue it for a quiet window, pin cores, and state the pinning.
Note also the asymmetry that makes the honest comparison harder: Core
defaults to assumevalid, skipping historical signature verification
entirely. Our replay always verifies in full. A fair contest must force
-assumevalid=0; a useful framing also records what Core's default does,
because that is what users actually run.
AMD Ryzen 9 9950X3D (Zen 5), 16C/32T, 128 MiB L3, plus:
avx512f avx512dq avx512vl avx512ifma adx bmi2 sha_ni gfni vaes avx512_vnni.
GPU: RTX 5090, 32 GB, compute 12.0 — already driving the CUDA sha256d
path (~17-18× CPU at N=1,000,000).
Two of these matter a great deal and were not being exploited:
avx512ifma and the 128 MiB L3.
fe_mul is 55.9 % of all replay cycles at ~290 Ir/call against
libsecp256k1's 5×52. Closing this is worth ~39 µs → low 20s on
ecdsa_verify and is the whole of the remaining primitive gap. It is
parity work: it makes us as fast as Core's crypto, not faster.
VPMADD52LUQ/VPMADD52HUQ perform 8 lanes of 52-bit multiply-accumulate.
libsecp256k1 ships no IFMA field backend, so unlike §6.2 this is not a
gap to close but an instruction set Core does not use.
The shape that fits: block validation verifies many independent signatures. Eight independent verifications in flight across lanes suits IFMA; vectorising inside one verification mostly does not, because the carry chain serialises. Caveats to measure rather than assume: Zen 5's IFMA throughput differs from Intel's, and AVX-512 downclocking can eat the win. A measured "not worth it" is a valid and useful outcome here.
Core verifies Schnorr signatures individually in consensus. BIP340
supports batch verification at roughly 2-3× individual, and a block is
almost always valid, so the fallback-to-individual path on failure is rare
and cheap. This gets more valuable exactly where the replay is heading:
script-path taproot spends are heavy from ~775,000 (one sampled block held
44,933 script-path inputs — see CHAIN_AHEAD_CENSUS.md).
Not started yet, deliberately: it touches bitcoin_taproot_sighash.c,
which had in-flight work at the time of writing. Sequence it after that
lands to avoid a merge conflict in consensus code.
The 5090 already does sha256d. Batch ECDSA/Schnorr on GPU is the largest
theoretical win and by far the largest correctness risk — consensus
verification returning a wrong verdict is a chain split. It should come
last, behind a byte-exact differential against the asm path over millions
of real chain signatures, and behind a bit-exact CPU fallback on any CUDA
error (the pattern sha256d already uses).
Crypto is 74 % of replay cycles, fe_mul 55.9 %. Even an infinitely fast
fe_mul leaves 44 % of the work, i.e. a ceiling of about 2.3× end-to-end
from field arithmetic alone. Getting past that requires the UTXO/apply path
and the archive read path to come down too — which is why §4.1's mmap work
(kernel 31 % → 5 %) mattered so much and why the next profile after §6.2
lands should be taken before choosing anything here.
§6.6 said to re-profile before choosing the next lever. Doing so immediately after deploying the field rewrite (§5.2) invalidated the ranking in §6.
Method: perf record -p <worker> -F 999 for 20 s, no call-graph (self
time only — an inclusive -g profile misled a diagnosis earlier the same
day), 132,785 samples, live replay at height ~617,000.
DSO split: bitcoind 92.3 % · libc 6.0 % · kernel 1.8 %.
| self time | symbol | where |
|---|---|---|
| 22.44 % | read_cs |
bitcoin_segwit.c compactsize reader |
| 17.10 % | fe_mul.reduce |
field |
| 15.89 % | fe_mul |
field |
| 5.92 % | sw_seq |
bitcoin_segwit.c |
| 5.71 % | sw_prevout |
bitcoin_segwit.c |
| 3.85 % | __memmove_avx512 |
libc |
| 3.69 % | fe_add |
field |
| 2.36 % | fe_sub |
field |
| 1.66 % | point_double |
EC |
| 1.45 % | sha256_block_shani |
hashing |
| 1.37 % | fe_sqr |
field |
Two shifts, both decisive.
1. Crypto is no longer dominant. It was 74 % of cycles at height ~390 k
(§2/§4.6); it is now roughly 45 %, and fe_mul+reduce is 33 % where
fe_mul alone was 55.9 %. Part of that is §5.2's own doing; the rest is
that the UTXO set has grown to ~106 M entries, so the storage and
serialisation sides have grown around the crypto. This is why the deployed
field rewrite delivered ~1.15 × end-to-end (10.31 vs 8.95 blk/s at
comparable depth) against a ~1.40 × projection computed from the old
shares. The projection arithmetic was right; its input was stale. Amdahl
inputs age — re-measure them, never carry them forward.
2. read_cs + sw_seq + sw_prevout = 34 %, and it is an O(n²) bug,
not a constant factor.
sw_prevout(t,i) and sw_seq(t,i) each walk the input list from the
start to reach input i, parsing a compactsize per step — and BIP143's
hashPrevouts/hashSequence call them once per input. That is O(nin²)
varint reads. sw_ser_txout(t,i) is worse: it re-walks every input and
the first i outputs on each call, so hashOutputs is O(nin·nout + nout²).
For the 1,372-input transaction in CHAIN_AHEAD_CENSUS.md that is ~1.9 M
redundant reads for hashPrevouts alone.
Core does not have this cost at all: PrecomputedTransactionData walks the
transaction once and hashes the three vectors in a single pass.
The fix is a single-pass precompute — walk the tx once recording each input's and output's offset, then index. It removes a super-linear term whose weight grows with transaction size, which is exactly the direction the remaining chain is heading (segwit-era blocks carry far larger transactions than the ~390 k blocks the original profile sampled).
Revised lever order, replacing §6's:
- Single-pass BIP143 precompute — ~34 % of cycles, removes an O(n²) term, contained to one file, no new instruction set, no consensus-format change. Biggest and safest win available.
- Re-profile again. The shares will have moved again.
- G-side comb table (§4.5) —
fe_mulis still ~33 %, so the field path still matters, just less than it did. - §6.3 IFMA / §6.4 BIP340 batch — both still real, both still gated on the deferred-verify restructure that §5.2(b) identified as the actual prerequisite.
The general lesson, twice over in one session: a performance plan built on a profile is only as current as the profile. This one was ~227,000 blocks stale and it inverted the top of the list.
Amendment (incident #21): these three functions just got slower, on purpose, and the single-pass rewrite must not undo the reason.
read_cs was unbounded — a compactsize's width comes from its own first
byte, so every walk in bitcoin_segwit.c could read up to 8 bytes past the
end of the transaction — and sw_ser_txout wrote an output's scriptPubKey
into a 600-byte stack buffer with no check at all, which real mainnet
transactions from height ~927,500 onward overflow. Both are fixed;
read_cs now takes the buffer end, and sw_prevout/sw_seq bound each
step of their walk.
That is not free, and it lands exactly on the 34% this section is about.
Measured on segwit_v0_sighash alone (min of 12–15 runs, -O2, the
census's 1,372-input shape, 100 outputs):
| build | ms/call | vs main |
|---|---|---|
| before the fix | 2.44 | — |
bounded read_cs only |
2.54 | +4% |
| as shipped (walk steps bounded too) | 2.84 | +16% |
Most of the cost is not the bounded reader: it is the per-iteration bound
in sw_prevout/sw_seq, which is redundant in the sense that swtx_parse
has already validated the identical byte range before either is called. It
was kept anyway, because "a distant function already checked this" is how
incidents #13 and #21 both happened, and because the fix below deletes these
loops entirely.
So lever 1 is now worth more, not less. A single-pass precompute walks
the transaction once with one bounded pass and indexes afterwards — it
removes the O(n²) and the repeated bound checks, which together are the
whole of this 34%. When it is written: the single walk must keep the
end-bounded reader and must bound each q += sl against the remaining
length rather than forming q + sl first (a wire-derived length near 2^64
overflows the pointer into a comparison that passes). The cap on
sw_ser_txout is a consensus-safety bound, not a policy one, and must
survive in whatever replaces it.
Amendment (2026-08-22, see §10): item 3 of that order changes. "5×52 lazy
reduction" was tested and rejected — measured at 1.07 × on ecdsa_verify in
its cheapest correct form, against 49 field-element value-inspection sites
it would have to normalise, four of which decide a doubling branch by limb
equality and would silently return infinity where they should return 2P.
The same measurement pass found that fe_sqr was being called zero times
per verification (this table's fe_sqr 1.37 % was the symptom, read at the
time as "squaring is rare") and that point_double carried a dead fe_mul
and a dead fe_sub. Fixing those is worth ≈ 1.11 × on ecdsa_verify
(nine alternating pairs over three sessions, median 1.107) with no change to
any field kernel. The remaining field lever is a small-constant
multiply and inlining the field ops into the EC formulas — not the
representation. §10 has the numbers.
§7's lever 1 is implemented (asm/bitcoin_segwit.c, branch
bip143-precompute). This section is the measurement; the design is in the
file's own comments and in LOG.md.
What it does. One bounded pass over the transaction records the offset of
every input and every output; sw_prevout/sw_seq become array indexes, and
the CTxOut serialization disappears entirely because a CTxOut's BIP143
encoding is its wire encoding — so hashOutputs is a sha256d over a
contiguous slice of the transaction, in place, with no copy. The walks
read_cs was called from are gone, and so are the per-iteration bounds §7's
amendment measured at +16%: the loop they guarded no longer exists.
New and permanent (make tests/bench_segwit_sighash), -O2, CPU time
(CLOCK_PROCESS_CPUTIME_ID), min of 15 runs, both builds back to back.
| shape | tx size | before | after | factor |
|---|---|---|---|---|
| 1 in / 2 out | 189 B | 0.3960 µs | 0.3718 µs | 1.07× |
| 2 in / 2 out | 304 B | 0.4364 µs | 0.4052 µs | 1.08× |
| 100 in / 5 out | 11,667 B | 16.830 µs | 2.510 µs | 6.7× |
| 1,372 in / 100 out | 160,894 B | 2.633 ms | 30.57 µs | 86× |
| 2 in / 3,000 out | 93,244 B | 5.115 ms | 42.14 µs | 121× |
Spread (min / median / max) on the two extremes: before, 2.633 / 2.640 / 2.652 ms and 5.115 / 5.134 / 5.297 ms; after, 30.57 / 30.72 / 30.87 µs and 42.14 / 42.19 / 42.40 µs. The 1,372-input row is §7's shape; its "before" min of 2.63 ms is consistent with incident #21's 2.84 ms on a busier machine.
The common case is not pessimised — that was the thing to check, because an asymptotic win that cost the 1- and 2-input shapes would be a bad trade on this chain. Both are slightly faster: even at two inputs the old code walked the input list twice per aggregate hash.
The microbenchmark says the asymptote moved; it does not say what the replay
will feel. So: 20 real mainnet blocks, heights 616,980–617,018 — the exact
window §7 profiled — pulled from the Core oracle, with every one of their
53,400 witness inputs driven through segwit_v0_sighash (taproot
activates at 709,632, so at these heights every witness input is segwit v0).
CPU time, min of 9:
| total | per witness input | |
|---|---|---|
| before | 5,735.90 ms (med 5,765.87, max 6,913.13) | 107.41 µs |
| after | 209.00 ms (med 212.54, max 218.50) | 3.91 µs |
27.4× on the real workload.
read_cs call counts over the same 20 blocks, from an instrumented build —
this is what apportions §7's 22.44%:
| BIP143 sighash path | strip_witness (1× per tx) |
total | |
|---|---|---|---|
| before | 4,339,573,177 | 437,615 | 4,340,010,792 |
| after | 32,451,774 | 437,615 | 32,889,389 |
131.9× fewer read_cs calls. The middle column is why it is here:
strip_witness also calls read_cs, runs once per transaction, and is not
changed by this work — and it is 0.010% of the old total. So essentially all
of §7's read_cs share is the BIP143 aggregate hashes, and essentially all of
it goes.
§7's shares at height ≈617,000: read_cs 22.44% + sw_seq 5.92% +
sw_prevout 5.71% = 34.07% of all replay cycles.
Amdahl ceiling: 1 / (1 − 0.3407) = 1.517×. Nothing in this change can beat that, because 65.93% of the profiled cycles are untouched by it.
Residual, two ways:
- from the whole-function measurement (27.4×): 34.07 / 27.4 = 1.24% → 1.489×;
- from the
read_cscounts (131.9×, withsw_seq/sw_prevoutdeleted outright rather than reduced): ≈0.2% → 1.512×.
So the projection is 1.49–1.51× — within a couple of points of the ceiling, and the gap between the two methods is smaller than the uncertainty in the share itself.
Independent corroboration that the benchmark drives the profiled work.
The old path costs 107.41 µs per witness input and §1 measured ecdsa_verify
at 120.9 µs, so sighashing cost about as much as the signature check. 96.4% of
that sighash cost is removed here, which predicts the three symbols at
45.3% of that pair's cycles. §7's profile puts them at 34.07% against
42.07% for the field/EC symbols (fe_mul.reduce, fe_mul, fe_add,
fe_sub, point_double, fe_sqr), i.e. 44.75%. Two independent routes,
0.6 points apart.
What this projection is not. §7 exists because a projection built on a 227,000-block-stale profile said 1.40× and delivered 1.15×. The share above is measured at height ≈617,000 and the replay has moved on; blocks further up the chain carry different transaction shapes, and past 709,632 they carry taproot inputs that do not use this code at all. Re-profile at the height the replay is actually at before believing 1.49×. The number that does not depend on the share, and will hold at any height, is the 27.4× on the component.
Also excluded: this is a userspace-cycles projection. §7's DSO split was bitcoind 92.3% / libc 6.0% / kernel 1.8%, so there is little else in the sample, but storage I/O behaves differently under different cache states.
No post-change profile of the daemon was taken. The change is not deployed —
the deployment rule at the top of this file says nothing lands until the
replay reaches tip clean — and perf_event_paranoid is 4 on this host, which
blocks perf for a non-root user; changing a system setting to take a profile
was out of scope.
Hashing each CTxOut in place instead of re-serializing it is only equivalent
if the transaction's compactsizes are minimally encoded. The old
sw_ser_txout wrote put_cs(len), i.e. the canonical form, so a padded
length (fd 00 00 for 0, say) was silently rewritten before hashing; the raw
bytes are not rewritten, and the two answers differ. Neither answer is Core's:
Core's ReadCompactSize() throws "non-canonical ReadCompactSize()" and
refuses to deserialize such a transaction at all, so it cannot appear in any
block Core accepts.
So read_cs now enforces minimality — Core's exact rule — and refuses instead
of hashing. That makes in-place hashing provably identical to canonical
re-serialization for every transaction not refused, rather than merely
identical on the transactions that happen to exist. It costs the hot path
nothing (the single-byte encoding returns before the test).
Found while proving this, and not fixed here: nothing else in the tree
enforces minimality — bitcoin_tx.asm's compactsize readers do not — so a
peer's non-canonical transaction is still mis-parsed everywhere else, which is
a pre-existing divergence from Core at the block-acceptance level, not
something this path introduced.
Two things, in order of confidence.
bitcoin_taproot_sighash.cis the same bug, unfixed, and it goes hot at height 709,632.tx_seq,tx_outpoint,ser_txoutandser_txout_leneach walk the input (and output) list from the start on every call, and BIP341's aggregate hashes call them once per input — the identical O(n²).ser_txout_lenwalks the output list twice per call. Worse, that file'sread_csis unbounded (it takes noend) and its bound tests are written in theq + sl > endpointer form that incident #21 had to remove frombitcoin_segwit.cbecause a wire-derived length near 2^64 overflows it. The taproot path is script-path-heavy from ~775,000 (one sampled block inCHAIN_AHEAD_CENSUS.mdhad 44,933 script-path inputs). Same fix, same bounds class; not done here because a performance restructure and a consensus-path bounds fix should not land in one commit.- Re-profile. With 34% removed, everything else's share rises by ~1.5×
and the ranking will move again — the standing lesson of §7. The field
kernels (
fe_mul+reduce, 33%) become ~49% of what is left, which puts §4.5's G-side comb table back at the top on share alone.
A per-transaction cache of the three hashes across the inputs of one
transaction — Core's PrecomputedTransactionData proper — was scoped and
rejected on measurement. segwit_v0_sighash is called once per executed
OP_CHECKSIG from deep inside the interpreter (sv_checksig_witness_v0),
which has no transaction-scoped context to hang a cache on, so it would have
to be a thread-local keyed on the transaction's address and length. That key
is not sound on its own — a different transaction can land at the same address
with the same length — and making it sound needs a full byte-compare against a
retained copy on every call. Priced out: on the 1,372-input shape the compare
costs about half of what the rebuild now costs, so the cache would buy ~2× on
the rarest shape and ~0 on the common one, against a wrong-sighash failure
mode if the key ever aliased. After this change the sighash is 3.91 µs against
ecdsa_verify's 120.9 µs — 3.1% of a witness input's cost — so there is
little left to win. Revisit only if a re-profile puts this path back near the
top.
Both of the day's performance changes are live: the field-kernel rewrite (§5.2, deployed 19:40) and the single-pass BIP143 precompute (§8, deployed 21:35).
| height band | rate | |
|---|---|---|
| before both | 537,616 → 575,833 (38,217 blk / 3,816 s) | 10.01 blk/s |
| after both | 656,104 → 666,855 (10,751 blk / 790 s) | 13.61 blk/s |
1.36×, and that is a floor rather than the figure. The two bands are not the same work: the second sits ~120,000 blocks deeper, where transactions carry far more inputs and outputs. Measuring the true same-depth factor would mean reverting and re-replaying identical heights — hours of wall clock for a number that would only revise an already-known-conservative result upward. It was not done, and the figure is quoted as a floor accordingly.
A warning about every other rate number from this session: they are
contaminated. Full make -k test suites and up to four verification agents
ran on this same 32-thread box throughout the evening, competing with the
replay for CPU. One window containing a change that cost a measured +16 % on
one function shows a 43 % end-to-end drop — that is contention, not code. The
two rows above were both taken during comparatively quiet periods, and the
component benchmarks in §5.2 and §8 (CPU-time, min-of-N, both builds back to
back) are the numbers to trust. If a future session wants a clean
end-to-end figure, take it with nothing else running.
Do not compose the component factors. §8's sighash path got 27.4× on real
blocks, and §5.2's ecdsa_verify 1.37×. The end-to-end result is neither,
nor their product: §8 removed ~34 % of cycles, which caps it at 1.52× by
Amdahl regardless of how fast that component became, and §5.2's share was
smaller than the profile it was planned against. Component speedups multiply
against their own share only.
Scoped 2026-08-22 as the largest remaining field-layer lever, on the strength
of §7's profile (fe_mul.reduce 17.10 % self time, fe_mul 15.89 % — the
reduction costing more than the multiply it reduces) and §5.2's closing note
that the residual 1.28 × throughput gap to libsecp256k1 "is precisely what
lazy reduction buys and this change deliberately did not take".
The hypothesis was tested directly and it is wrong. The measurements are below; they are the deliverable of this section. What shipped is a different change, found while measuring, that is worth more than the correct-and-safe version of lazy reduction would have been and costs no representation change at all.
4×64 has no headroom, so every product must be reduced immediately. 5×52 exists to create 12 spare bits per limb, and that is what makes lazy reduction possible — accumulate several results, reduce once. libsecp256k1's speed is the representation plus lazy reduction as a system. §5.2 measured 5×52's multiply and correctly found no advantage; it may have been right about the multiply and wrong about the system.
The premise is sound. The conclusion does not follow, for a reason that only
shows up when you measure the whole field API rather than fe_mul.
tests/bench_fe methodology throughout (CPU time, CLOCK_THREAD_CPUTIME_ID,
min-of-25 rounds × 200,000 calls, spread printed, live replay consuming ~5-10
of 32 threads). Four cut-down variants of the shipped kernel were assembled,
each stopping one stage earlier. p1 and p1+fold1 do not compute a
correct result — they exist only to attribute cost.
| ns/call, min-of-25 | LAT (serial chain) | THRU (4 indep. chains) |
|---|---|---|
phase 1 only — the 512-bit product, 16 mulx |
3.213 | 3.133 |
+ fold 1 (4 mulx) |
4.642 | 4.009 |
+ fold 2 and its carry re-fold (1 mulx) |
7.421 | 4.714 |
+ canonicalisation (the shipped fe_mul) |
8.307 | 5.390 |
So the reduction is 43 % of fe_mul in the throughput regime and 62 % in
the latency regime — §7's 17.10 % is real, not a symbol-attribution
artifact. Of that, the final conditional subtract of p — the one step
lazy reduction actually skips — is 0.68 ns THRU / 0.89 ns LAT, i.e. 12.6 % /
10.6 % of a whole fe_mul.
Build the field with the canonicalisation deleted from fe_mul/fe_sqr and
from fe_add. This is not correct code — nothing normalises anywhere —
but it is an honest upper bound on what any lazy scheme can return in this
representation, and tests/bench_ecdsa still accepts its fixture, so the
timing is real work.
| min-of-25 / min-of-7×20,000 | shipped | lazy ceiling |
|---|---|---|
fe_mul LAT / THRU |
8.53 / 5.50 | 7.56 / 4.77 |
fe_sqr LAT / THRU |
7.60 / 4.62 | 6.60 / 4.10 |
fe_add LAT |
2.18 | 1.29 |
ecdsa_verify, best of 3 alternating runs |
27.77 µs | 24.91 µs |
Ceiling: 1.115 × on ecdsa_verify. And note where that lands fe_mul:
4.77 ns THRU, against libsecp256k1's 5×52 at 4.33-4.48 ns and 9.36-9.65 ns
LAT. Those libsecp figures are §5.2's, from a different session under
different load — the one-process head-to-head harness was not rebuilt here,
so treat the cross-comparison as ±5 % and the same-build comparisons above as
tight. On that basis laziness closes the throughput gap from ≈ 1.25 × to
≈ 1.08 ×, i.e. most of §5.2's 1.28 × was the canonicalisation, not the
representation — and on latency 4×64 is already 1.24-1.28 × faster than
5×52.
That is already most of the answer: after laziness, the representation itself
is worth ≈ 8 % of fe_mul throughput and is a ~25 % latency regression.
The ceiling build is wrong in a specific, instructive way. Once fe_mul may
return a value in [0, 2^256) rather than [0, p), fe_add and fe_sub both
need a second correction round:
fe_add: with a, b < 2^256 the sum can reach 2^257 − 2. The fold of the 257th bit adds C; that add can itself carry out, and today's single fold would silently drop a 2^256. Reachable (a = b = p + C − 1), not theoretical.fe_sub: on borrow the wrapped value can be < C, so the single "subtract C" correction borrows again and lands a full C low.
Both are provably terminating after one more round (when the second carry
fires the survivor is < C, so limbs 1..3 are zero and a bare add r8, C
cannot carry), so the fix is 3 instructions each. Measured with those
3-instruction fixes in place — the cheapest correct lazy 4×64 field:
| min-of-25 | shipped | lazy, correct |
|---|---|---|
fe_mul LAT / THRU |
8.46 / 5.44 | 7.55 / 4.99 |
fe_sqr LAT / THRU |
7.93 / 4.82 | 6.99 / 3.95 |
fe_add LAT |
2.18 | 2.31 — no better |
fe_sub LAT |
1.27 | 2.32 — 1.8 × worse |
ecdsa_verify, best of 3 alternating |
27.09 µs | 25.31 µs |
1.070 ×, not 1.115 ×. The second fold in fe_add gives back exactly what
deleting the canonicalisation won, and fe_sub — which needed no
canonicalisation in the first place — becomes a straight regression. There
are 1,243 fe_add and 1,436 fe_sub per verification against 2,262
fe_mul (counted, not estimated: instrumented entry counters, below), so
add+sub outnumber mul and their regression eats most of the gain.
This is precisely the headroom argument, and it is what kills 4×64
laziness — but it does not save 5×52. In 5×52 the add is five bare adds
with no carry propagation and no correction, which is why libsecp's fe_add
benched at 0.000 ns in §5.2: it inlines to nothing. Behind a function-call
boundary with memory-resident operands — which is what this codebase has, 163
hand-written fe_* call sites across seven .asm files — a 5×52 add is
still 5 loads, 5 adds and 5 stores. Measured here: a 4×64 add with no
correction at all is 0.97 ns THRU. The one structural advantage of 5×52 is
realisable only by inlining the field ops into the EC formulas, which is a
different lever and independent of the representation.
A full read of the non-test tree found 49 places that inspect a field
element's value rather than feeding it back into another fe_*: 8
zero/infinity tests, 8 limb-by-limb field equalities, 6 parity tests, 6
serialisations, and 15 range checks or hand-rolled p − x corrections
(secp256k1_point.asm 7, secp256k1_point_ct.asm 1, secp256k1_ecdsa.asm
5, secp256k1_schnorr.asm 7, secp256k1_taproot.asm 5, bitcoin_pubkey.asm
5, bitcoin_keys.asm 2, wallet_core.c 2, wallet_msgsign.c 15).
Four of them are the dangerous kind. point_add's U1 == U2 and
point_add_mixed's U2 == X1 decide the doubling branch by limb equality.
A non-canonical operand makes two field-equal values compare unequal, so the
routine takes .distinct, computes H = 0, and returns infinity where it
should return 2P — a wrong consensus verdict, silently, on an
input-dependent basis. That is the incident-#7 failure class made worse, and
it is the class this project has already been bitten by twice.
Verdict: 1.07 × on ecdsa_verify (≈ 1.03 × end-to-end) in exchange for
non-canonical field elements flowing through 49 inspection sites and 163 call
sites in consensus code. Rejected. Not built. 5×52 is rejected a fortiori:
it buys ≈ 8 % of fe_mul throughput over lazy 4×64, loses ~25 % of its
latency, and requires all of the above plus a representation change.
Counting fe_* entries per ecdsa_verify (counters at each function's first
instruction) produced the finding that made this section worth writing:
before fe_add 1243 fe_sub 1436 fe_mul 2262 fe_sqr 0 fe_inv 0
fe_sqr was called zero times. The dedicated squaring kernel added in
§5.2 — 10 multiplies instead of 16, measured at 4.62 ns THRU against
fe_mul's 5.50 — was dead on the verification path, because the EC layer
spelled every squaring fe_mul(r, a, a). Its 1.37 % in §7's table is
pubkey_parse (2 calls per input, bitcoin_pubkey.asm) and
scalar_to_pubkey — the only fe_sqr callers in the tree. The signal was
there and was read as "squaring is rare" rather than "the EC layer never
dispatches it". Two changes, both contract-preserving:
(a) 20 fe_mul(a, a) call sites routed through fe_sqr — 14 in
secp256k1_point.asm, 3 in secp256k1_point_ct.asm, 1 each in
secp256k1_ecdsa.asm, secp256k1_schnorr.asm, secp256k1_taproot.asm.
Sites were identified by abstract-interpreting the rdi/rsi/rdx setup
before every call fe_mul and keeping those where rsi and rdx resolve to
the same effective address; the now-dead rdx setup was removed with the
call. fe_sqr is branch-free, so point_scalar_mul_ct and the rest of the
signing path stay constant-time.
(b) A dead fe_mul and a dead fe_sub deleted from point_double. It
computed E*(D − X3) into slot S6, then unconditionally overwrote S6 with
the Y1*Z1 stash before anything read it, then recomputed E*(D − X3) from
scratch. The comment left in place ("was clobbered by S6 above; recompute")
shows the recompute was added deliberately and the now-dead original was
never removed. point_double runs 126 times per verification — it is the GLV
ladder's inner step — so this is 126 wasted fe_mul and 126 wasted fe_sub
per signature.
after fe_add 1243 fe_sub 1310 fe_mul 1169 fe_sqr 967 fe_inv 0
252 field operations per verification deleted outright, and 967 of the remaining 2,136 multiplications now dispatch to the cheaper kernel.
No field kernel changed. asm/secp256k1_fe.asm is byte-identical to
main; fe_mul, fe_sqr, fe_add and fe_sub cost exactly what §5.2
measured. The win is entirely in which kernel is called and how often.
tests/bench_ecdsa, CPU-time min-of-7 rounds × 20,000, old and new binaries
alternated so drift lands on both. Three independent sessions, three
pairs each — because the first session's spread was wide enough that a single
best-of-3 would have overstated the result. Session 2 was run after the full
suite; session 3 after rebasing onto §8:
| µs / verify | before | after | pair ratio |
|---|---|---|---|
| session 1, pair 1 | 27.43 | 24.77 | 1.107 |
| session 1, pair 2 | 27.52 | 24.54 | 1.122 |
| session 1, pair 3 | 28.50 | 24.67 | 1.155 |
| session 2, pair 1 | 28.96 | 25.92 | 1.117 |
| session 2, pair 2 | 27.44 | 25.15 | 1.091 |
| session 2, pair 3 | 28.28 | 25.87 | 1.093 |
| session 3, pair 1 | 29.66 | 27.11 | 1.094 |
| session 3, pair 2 | 28.92 | 25.57 | 1.131 |
| session 3, pair 3 | 29.20 | 26.46 | 1.104 |
| best-of-3 per session | 27.43 / 27.44 / 28.92 | 24.54 / 25.15 / 25.57 | 1.118 / 1.091 / 1.131 |
ecdsa_verify ≈ 1.11 × — nine pairwise ratios spanning 1.091-1.155,
median 1.107, mean 1.113, and three independent best-of-3 estimates of
1.118, 1.091 and 1.131. Everything below uses 1.11 ×. Quoting the single
best pair (1.155 ×) would be exactly the mistake §7 was written about, and so
would quoting one session. Per-core rate at the best-of-3 points: 36,462 →
40,756 /s, 36,447 → 39,768 /s, 34,578 → 39,106 /s. Absolute µs drifts by ~8 %
between sessions with the replay's load; the ratio is stable to ±3 %, which
is the argument for alternating rather than comparing across sessions.
For calibration, measured the same alternating way in the same conditions, this is at or above the correct lazy-reduction build (1.070 ×) and near the incorrect lazy ceiling (1.115 ×) — with no change to the field representation, no magnitude bookkeeping, and no new value domain anywhere. Those two lazy figures are single-session best-of-3 and carry the same ±3 % uncertainty this table exposes; the ordering is what the comparison supports, not the third digit.
A caution that cost half a day here: bench_fe is sensitive to code
alignment at the ±0.5 ns level. A variant that changed only fe_add moved
fe_mul LAT from 8.46 to 9.14 ns. Any field micro-optimisation claiming less
than ~1 ns must be confirmed at the ecdsa_verify level, where the effect is
averaged over 2,000+ calls at many alignments. Three contract-preserving
restructurings of the reduction (fusing the carry re-fold with the
canonicalisation into ADCX/ADOX chains committed by CMOVO; a short
AND-tree canonicalisation predicate; the same for fe_add) were built and
proved bit-identical to the shipped kernels — a digest over 1,695,204
structured pairs (mul, add and sub each) plus 400,000 full-range
(deliberately non-canonical) random pairs, 6,686,918 results folded into one
64-bit digest, identical for all four builds — and all three measured
inside alignment noise. None is worth taking.
asm/tests/point_ref.asm and asm/tests/point_ct_ref.asm are new: FROZEN
copies of main's EC layer with every exported symbol suffixed _ref, the
point-layer analogue of tests/fe_ref.asm. They link against the live
(unchanged) field, so any difference they report is a difference in the point
layer. asm/tests/test_point_repr.c drives both implementations over the
same operands and compares limb for limb:
point_double,point_add,point_add_mixed,point_add_mixed_zr(result and thezrout-parameter),pointh_double,pointh_add— every iteration, out-of-place and in-place (r == p), because the deleted code sat inside an in-place-aliasing workaround.point_scalar_mul,point_scalar_mul_glv,point_scalar_mul_fixed,point_scalar_mul_ct— sampled every 64th iteration.- The degenerate shapes the formulas branch on, forced one per iteration mod 7: p = infinity, q = infinity, q == p, mixed-add with x2 == X1 and Z1 == 1, Y1 == 0, affine (0,0). Random operands never reach these.
- Operands are canonical field elements drawn 2-in-3 from a structured family (0, 1, p−1, p−k, every 2^i, 2^i^1, C, per-limb saturation) and 1-in-3 uniform. Off-curve inputs are deliberate: both sides evaluate the same straight-line formula, so agreement must hold for any 12-limb input, and the off-curve space is enormously larger than the curve.
fe_sqr(a) == fe_mul(a, a)over 2,000,000 operands from the same distribution — the identity all 20 rewritten call sites rest on.
8,037,500 checks, 0 failures, 2.8 s. An out-of-tree build of the same
harness that additionally differentials ecdsa_verify against a frozen
ecdsa_verify_ref ran 8,075,000 checks with the same result.
Mutation-tested, on real instructions. Six bugs were injected into the new code and every one is caught:
| mutation | caught |
|---|---|
point_double's A = X1^2 fed Y1 instead (lea rsi operand changed) |
yes |
Z3 = 2*Y1*Z1 doubling dropped (call fe_add → nop) |
yes |
point_add_mixed's Z1Z1 = Z1^2 fed X1 |
yes |
a converted site left as call fe_mul after its rdx setup was removed — the exact failure mode of the mechanical rewrite |
yes (SIGSEGV: rdx holds a stale non-pointer) |
pointh_double's t0 = Y*Y fed Z (constant-time path) |
yes |
Y3 built from the stale F slot instead of E*(D − X3) — i.e. the overwrite bug the dead code was working around |
yes |
All six are caught at 4,000 iterations, 1/150th of the committed size, so the harness has a wide sensitivity margin rather than only just catching them. The unmutated control passes at that size (60,252 checks, 0 failures).
The rest of the campaign passes unchanged, and two existing tests are
independent cross-checks of exactly this change rather than incidental
coverage: test_ecdsa_inverse differentially compares ecdsa_verify (which
now squares via fe_sqr inside ecdsa_x_eq_mod_n) against the frozen
ecdsa_verify_ref, which reaches the same verdict by a completely different
route (fe_inv + Fermat sc_inv); and test_ecdsa_glv_switch compares the
GLV path (point_double + point_add_mixed) against the plain path
(point_double + point_add) three ways. test_scalarmul_ct passes,
which is the constant-time guard for change (a): fe_sqr introduces no
branch and no variable-time step on the signing path.
Full make -k test: MAKE_RC=0, zero failures. make abi-check passes
(1,119 reachable external call sites, RSP ≡ 0 mod 16).
This is a projection, not a measurement. The change is not deployed; §4's deploy gate still applies (the replay must reach tip clean first), and §7 exists because a projection from a stale profile overstated a result 1.40 × against 1.15 × actual. The inputs below are §7's profile, taken 2026-08-22 at height ≈ 617,000 — hours old, but the same day.
From §7's self-time table the field and point rows are
fe_mul 15.89 + fe_mul.reduce 17.10 + fe_add 3.69 + fe_sub 2.36 +
fe_sqr 1.37 + point_double 1.66 = 42.07 % of replay cycles. Taking
that as the share this change accelerates by 1.11 ×:
1 / (0.579 + 0.421/1.11) = 1.044x end-to-end
Amdahl, stated: even an infinitely fast crypto path leaves 58 % of the replay, a ceiling of 1.73 × from this direction at today's shares. 1.044 × is 6 % of that headroom. Carried through the 1.091-1.155 × measurement band the projection is 1.036-1.058 ×.
§8's BIP143 precompute has since landed, and this branch is rebased onto
it, so the second calculation is the live one. §8 removes read_cs +
sw_seq + sw_prevout — 34.07 % of §7's cycles — down to a measured
residual of 0.2-1.24 %. The field and point rows are untouched in absolute
terms, so their share of the new, smaller total rises to
42.07 / 0.662-0.672 ≈ 63 %, and this change becomes worth
1 / (0.37 + 0.63/1.11) = 1.067x end-to-end
while the Amdahl ceiling from the crypto direction rises to 1/0.37 = 2.70 ×.
Both figures are still arithmetic on a profile taken before §8 landed, and §9 now measures the deployed reality: 10.01 → 13.61 blk/s for §5.2 and §8 together, quoted there as a floor because the two bands sit ~120,000 blocks apart. §7's lesson applies to this section as much as to any other: the shares have moved twice in one day, so the next lever must be chosen from a fresh profile, not from this table. §8's own §8.5 and this section's §10.11 both reason from the same pre-§8 denominator.
§9's contamination warning does not apply to §10.7's table: those are
CLOCK_THREAD_CPUTIME_ID, min-of-7, with the two binaries alternated inside
each session, which is exactly the component-benchmark discipline §9 says to
trust. It does apply to the end-to-end projection above, which is why it is
labelled a projection and why the deployed figure must be taken with nothing
else running.
- No replay of real chain data ran against this branch. The live daemon is mid-replay and is not to be touched, so the 1.044 × / 1.067 × figures above are arithmetic on §7's profile, not observed throughput.
- The full suite and the benchmarks in §10.7 were run at the pre-rebase
base (
91b7c9d) and again after rebasing onto §8's merge. The two changes touch disjoint sources (bitcoin_segwit.cagainst thesecp256k1_*.asmlayer); onlyasm/Makefile,.gitignoreand this file conflicted, all textually. - No new profile was taken. §7's shares are reused as-is.
bench_ecdsameasures one fixture signature; the call counts in §10.6 are from that same fixture. GLV wNAF digit density varies slightly with the scalar, so 126 doublings/verify is representative, not invariant.- The three restructurings dismissed in §10.7 were dismissed on
bench_ecdsa-level noise, not proven neutral by a cycle-accurate model.
Not the representation. The remaining structure is in the call pattern:
point_doublenow makes 21 field-op calls (5fe_sqr, 2fe_mul, 9fe_add, 5fe_sub), and 5 of the 9 adds are small-constant multiplies:E = 3A(2 adds) and8C(3 adds). Onefe_mul_int(r, a, k)for k ≤ 8 — fourmulx, one single-limb fold, and no second fold needed because the top limb is < 8 — replaces both chains. At the measured 2.18 ns perfe_addagainst an estimated ~2.5-3 ns for the new primitive, five adds (≈ 11 ns) become two calls (≈ 5-6 ns): ~5.6 ns perpoint_double, ×126 doublings ≈ 0.7 µs, or ~3 % of a verification. That is a new field primitive, so it needs its own Python oracle and mutation campaign; it is sized here so the decision is informed, and deliberately not taken in this change. (Consistency check on the counts: 126 × 9 = 1,134 of the 1,243fe_addper verification arepoint_double's.)- Inlining
fe_add/fe_subinto the EC formulas (§10.4) is the larger and much more invasive one:fe_add+fe_subare 2,553 calls per verification at ~2.2 and ~1.3 ns, ≈ 4.4 µs or 18 % of a verify, most of it loads, stores and call overhead rather than arithmetic. This — not 5×52 — is where libsecp256k1's remaining advantage lives.
§8.5 named bitcoin_taproot_sighash.c as the next lever and as the same bug:
tx_seq, tx_outpoint, ser_txout and ser_txout_len each walked from the
start on every call, ser_txout_len walked the outputs twice, and BIP341's
aggregates call them once per input/output while taproot_sighash() is itself
called once per input — O(nin³) per transaction. All of that is confirmed and
fixed (branch taproot-precompute); the design is in the file's comments and
in LOG.md, and the two consensus divergences found while proving it are in
LOG.md too, not here, because they are not performance.
One design point that had to be checked rather than assumed, since BIP341 is
not BIP143: a CTxOut's BIP341 serialization is also byte for byte its wire
encoding, so sha_outputs is one sha256 over a contiguous slice of the
transaction, in place. BIP341's separate sha_amounts and sha_scriptpubkeys
are over the spent outputs, which are not in this transaction at all, so
they are not a counterexample — and they turn out to need no copying either,
because the caller already supplies them contiguously.
New and permanent (make tests/bench_taproot_sighash), -O2, CPU time
(CLOCK_PROCESS_CPUTIME_ID), min of 15, both builds back to back. Same five
shapes as §8.1 so the two tables are directly comparable. Key-path;
script-path is +0.03–0.04 µs on both builds and otherwise identical.
| shape | before | after | factor |
|---|---|---|---|
| 1 in / 2 out | 0.4001 µs | 0.3875 µs | 1.03× |
| 2 in / 2 out | 0.4699 µs | 0.4487 µs | 1.05× |
| 100 in / 5 out | 9.7203 µs | 4.3148 µs | 2.25× |
| 1,372 in / 100 out | 1.1254 ms | 54.360 µs | 20.70× |
| 2 in / 3,000 out | 18.177 ms | 43.318 µs | 419× |
The common case is not pessimised, checked the same way §8.1 checked it and then three more times: across four interleaved min-of-15 repeats the new build is at or below the old in every repeat of the 1- and 2-input shapes, key-path and script-path, by 2–5%, with non-overlapping distributions on the 2-input rows. Per-thread resident scratch also falls, 4 MiB → 2.29 MiB.
19,870 taproot inputs across 12,014 transactions from heights 825,000 / 830,000 / 842,000 / 865,000 / 910,000 — the five largest taproot input counts out of 38 blocks scanned in 709,700–963,000, capturing both the inscription-era script-path regime and the large-consolidation regime. Real witness-stripped serializations (real input/output counts and script sizes); spent outputs synthetic 34-byte P2TR, which is the correct size and does not affect cost. CPU time, min of 15:
| total | µs / taproot input | |
|---|---|---|
| before | 4,577.85 ms | 230.39 |
| after | 243.35 ms | 12.25 |
| 18.81× |
| height | taproot inputs | before | after | factor |
|---|---|---|---|---|
| 825,000 | 3,379 | 2.25 ms | 1.68 ms | 1.33× |
| 830,000 | 3,661 | 3,833.15 ms | 195.65 ms | 19.59× |
| 842,000 | 4,274 | 68.00 ms | 5.39 ms | 12.61× |
| 865,000 | 4,295 | 2.87 ms | 2.15 ms | 1.33× |
| 910,000 | 4,261 | 766.24 ms | 65.72 ms | 11.66× |
Ordinary 1–3 input taproot blocks gain a flat ~1.33×; the block carrying a 1,500-input consolidation goes from 3.83 s of sighashing to 196 ms.
§7 exists because a projection off a stale share said 1.40× and delivered 1.15×. This path is worse than stale: it has never been profiled live, because the replay has not reached 709,632. Its share of taproot-era cycles is unknown, so no Amdahl bound can honestly be stated here. The figure that does not depend on a share is the 18.81× on the component. Re-profile after the replay passes activation — that is the measurement that would let this be turned into an end-to-end number.
taproot_sighash() still runs the single pass and re-hashes the O(nin)
prevouts/amounts/spks arrays on every call, once per input. Only the inner
re-walk was removed. That is why the 1,372-input shape is still 54 µs/call and
why block 830,000 still costs 53 µs per taproot input afterwards.
Core hoists exactly this to once per transaction. §8.6 priced the equivalent
for BIP143 and rejected it — no transaction-scoped context to hang a cache on,
and a thread-local keyed on the transaction's address and length is not sound.
The same objection applies, but the prize is larger here: BIP341 has four
aggregate hashes rather than three, and one of them (sha_scriptpubkeys) is
over variable-length data. §8.6's arithmetic said the BIP143 cache would buy
~2× on the rarest shape and ~0 on the common one; here the 1,372-input shape
would go from 74.6 ms per transaction to roughly one call plus 1,372 cheap
tail assemblies. If this path is ever re-profiled hot, this is the lever —
and the sound way to do it is to thread a real per-transaction context down
from taproot_verify_input(), which already exists per input and would only
need to be created one level up, rather than to key a cache on an address.
Taken with everything from §5.2, §8, §10 and the taproot sighash rewrite
deployed. Method as §7: perf record -F 999, no call graph, 25 s,
117,989 samples, live worker. DSO: bitcoind 91.1 % · libc 6.1 % · kernel 2.8 %.
| self time | symbol | note |
|---|---|---|
| 22.50 % | fe_mul.reduce |
modular reduction |
| 14.31 % | fe_mul |
|
| 9.03 % | fe_sqr |
was 1.37 % — §10 made it actually get called |
| 5.48 % | fe_add |
|
| 5.14 % | sha256_block_shani |
already SHA-NI accelerated |
| 3.82 % | __memmove_avx512 |
libc |
| 3.32 % | fe_sub |
|
| 2.25 % | point_double |
|
| 1.78 % | __memset_avx512 |
libc |
| 1.53 % | swtx_parse |
§8's single pass — cheap, as designed |
| 1.45 % | lsm_run_lookup_mm |
the entire UTXO store |
| 1.31 % | copy_bytes.cb_loop |
|
| 1.27 % | read_cs |
was 22.44 % in §7 |
§8 is confirmed in production. read_cs was the single largest symbol in
the whole profile at 22.44 %; it is now 1.27 % and thirteenth. The benchmark
predicted 27.4× on the component and the live profile agrees.
The shape of the problem has changed completely. Serialization and
storage are effectively finished: sighash parsing is ~3 % (swtx_parse +
read_cs + w32le), the UTXO store is 1.45 %, the kernel 2.8 %. Field
and EC arithmetic is now ~64 % of all cycles, and hashing another ~5.8 %.
There is no longer a "two-front problem" (§1): it is one front.
- Lazy reduction / 5×52 (§10a): the correct implementation measures
1.07×, because
fe_add/fe_subeach need an extra correction round oncefe_mulmay return [0, 2²⁵⁶), and there are more of them (1,243 + 1,436) than multiplies (2,262). §10 also found four sites wherepoint_add/point_add_mixedselect the doubling branch by limb equality — a non-canonical operand there returns infinity instead of 2P, silently. - Micro-optimising
reduce: three contract-preserving restructurings (ADCX/ADOX-fused carry refold,CMOVOcanonicalisation, AND-tree predicate) were built and proved bit-identical over 6,686,918 results. All measured inside the ±0.5 ns alignment noise.bench_feis alignment-sensitive enough that a change touching onlyfe_addmovedfe_mulLAT by 0.68 ns — do not trust sub-ns deltas from it. - G-side comb table (§4.5, and ranked next by §6):
point_scalar_mul_fixedis 0.73 %. This lever is now worth almost nothing and should be dropped from the plan.
- Inline
fe_add/fe_subinto the EC formulas — §10.11's conclusion, now corroborated by this profile: the two are 8.8 % of all cycles across ~2,553 calls per verify that are mostly loads, stores and call overhead, and inlining additionally lets a multiply feeding an add skip an intermediate canonicalisation. The only remaining lever with double-digit headroom, and it is contained to the EC layer. fe_mul_int(r, a, k)for small k replacing the3A/8Cpatterns (~3 % of a verify).- Attribute the 6.9 % in
memmove/memset/copy_bytes— source unknown without a call-graph profile; worth one targeted run. - Re-profile again afterwards. The shares have now moved three times in one day, and each time they inverted the ranking.
§11.2's item 1, and the last lever with double-digit headroom. 1.10× on
ecdsa_verify, 1.22× on point_double. Branch ec-inline.
fe_add (5.48 %) + fe_sub (3.32 %) were 8.8 % of all replay cycles
across 2,553 calls per verification (§11). Both are tiny — 31 and 19
instructions — so most of that is not arithmetic. It is three leas of
argument setup, a call/ret, and above all four stores and four loads
through the caller's stack frame on every result, including results the
very next operation consumes.
§10.4 had already located this precisely: 5×52's one structural advantage over 4×64 is not the representation, it is that libsecp256k1's field ops are inlined into the EC formulas, so consecutive values stay in registers. This change gives 4×64 the same property, and it is independent of the representation — which is why §10 could reject 5×52 and this could still pay.
asm/secp256k1_fe_inline.inc holds the macros; secp256k1_point.asm and
secp256k1_point_ct.asm include it. The two files had 58 call fe_add /
call fe_sub sites (30 + 28); 57 are gone, replaced by 57 inline field
operations grouped into 40 register chains (19 + 21). One call
survives, in point_scalar_mul_glv's wNAF y-negation, because rbx holds
the digit there and rbx is the macros' home for C. It runs ~25× per verify,
not 2,553×.
The operation count is deliberately unchanged -- this banks no algebraic
shortcut. What disappears is 57 call/ret pairs, ~150 argument leas, and
17 whole field elements' worth of store-then-reload (57 results now cost
40 stores and 40 loads instead of 57 of each, because the value the next
operation consumes never leaves the register file).
A field element is four registers, so the design question is whether an accumulator fits without displacing anything.
- Accumulator
V=r8:r9:r10:r11; fold constant C = 2³²+977 pinned inrbx, loaded once per function (FE_C_INIT); scratchrax, rcx, rdx, rsi, rdi. - In
point_double,point_add,point_add_mixed,pointh_addandpointh_doublethose nine registers are already dead between calls —rax/rcx/rdx/rsi/rdiexist only to pass arguments andr8..r11are caller-saved and unused. Every live pointer (r12out,r13p,r14q,r15zr,rbpframe) is callee-saved and untouched by the macros. rbxwas pushed by all five prologues and used by none of them, so C is free real estate.fe_mul,fe_sqrandpoint_doubleall preserve it, so it survives every nested call and is never reloaded.- Zero spills, zero added stack slots, no frame-size change — which is
also why
make abi-checkis unaffected (1,026 call sites, still all 16-byte aligned).
A full inline — expanding fe_mul/fe_sqr too — was deliberately not
attempted. Those need all 15 GPRs for the 512-bit product and would spill
immediately, they are 21 multiplies each so the call is noise against them,
and §10 established that the reduction is the one part of this codebase that
has produced a lost-carry consensus bug, so it keeps having exactly one copy.
Chains that now cost one load, N register operations and one store:
| site | was | now |
|---|---|---|
point_double D |
T-A, T-C, T+T — 3 calls, 3 slot round trips |
one chain |
point_double E = 3A |
A+A, +A |
one chain |
point_double 8C |
3 chained fe_add |
3 FE_DBL |
point_double X3 |
S6 = D+D; F - S6 |
F - D - D, slot S6 never written |
point_add / point_add_mixed X3 |
2V into a slot; R²-2V; -HHH |
R² - V - V - HHH |
pointh_add 7+8, 12+13, 17+18 |
t4 = ta+tb; t = t - t4 |
t - ta - tb, temp never written |
pointh_add 19+20, pointh_double 11+12 |
X3 = 2t0; t0 = X3+t0 |
t0 = 3t0 in registers |
pointh_double 2-4 |
3 chained fe_add |
3 FE_DBL |
Each dropped temp was verified dead by inspection and by the differential:
in every case the slot is overwritten by a later step before anything reads
it. Note that these fusions do not remove reductions -- F - 2D becomes
F - D - D, still two -- they remove the slot the intermediate was parked
in, and with it the store and the reload.
FE_ADD_TAIL merges fe_add's two reduction steps — fold the 257th bit,
then conditionally subtract p — into one conditional select:
s = a + b = CF1·2²⁵⁶ + s0
t = s0 + C (with carry CF2) ; t == s - p (mod 2²⁵⁶)
result = (CF1 | CF2) ? t : s0
It is the same 256 bits fe_add returns, not merely congruent:
- CF1 = 0:
tand CF2 arefe_add's own shadow sum and predicate, and its fold added nothing. Identical by inspection. - CF1 = 1: with a, b < p, s ≤ 2p−2, so s0 ≤ 2²⁵⁶−2C−2 and
t = s0+C ≤ 2²⁵⁶−C−2 < p.
fe_add's second step therefore keepstunchanged — which is what this returns.
The two forms can only differ when a+b ≥ 2²⁵⁷−2C, which is impossible for
a, b < p = 2²⁵⁶−C. tests/test_fe_inline constructs that boundary
explicitly and demonstrates the divergence on an out-of-contract pair
(a = 2²⁵⁶−1), so the claim is shown rather than asserted.
No non-canonical intermediate is introduced anywhere. Every macro takes
canonical operands and leaves the accumulator canonical, so §10.5's hazard —
point_add's U1 == U2 and point_add_mixed's U2 == X1 deciding the
doubling branch by limb equality, returning infinity instead of 2P — is
untouched. That hazard is the reason lazy reduction was rejected and it is
the reason this change deliberately banks none of the same headroom.
Methodology throughout: CPU time (CLOCK_THREAD_CPUTIME_ID), min-of-N,
spread printed, taskset -c 20, both builds run alternately in the same
session (the before build is the shipped secp256k1_point{,_ct}.asm from
6df20c7, assembled and linked identically). A full-chain replay was
consuming ~5-10 of 32 threads throughout, which is why the absolute numbers
drift between repeats and only the paired comparison is quoted.
New permanent harnesses: tests/bench_point (point_double, point_add,
point_add_mixed as serial chains over real curve points, plus the field
additive ops both ways in the same binary — deliberately, because bench_fe
is alignment-sensitive at ±0.5 ns and a cross-binary comparison of a 1 ns
operation would not survive it).
tests/bench_point, 200,000 (50,000 for the adds) calls/round, min-of-15,
4 alternating repeats. Each cell is the min over the 4 repeat minima; the
4 repeat minima are listed so the spread is visible.
| ns/call | before | after | factor |
|---|---|---|---|
point_double |
71.75 (72.11, 71.75, 71.83, 73.16) | 58.72 (58.86, 58.72, 60.11, 59.91) | 1.222× |
point_add_mixed |
97.44 (97.44, 97.79, 100.67, 98.97) | 93.62 (93.62, 93.81, 95.53, 95.40) | 1.041× |
point_add |
134.68 (135.29, 134.68, 136.72, 137.72) | 131.52 (131.52, 132.12, 134.48, 134.02) | 1.024× |
point_double's distributions do not overlap. point_add_mixed's do not
overlap. point_add's overlap by 2 ns across repeats, but the paired
comparison is in the same direction in all four.
The field ops, measured in one binary at one moment (fe_*_inl are the same
macro bodies wrapped as SysV functions, so they still pay three push/pop
pairs and a ret that the in-situ expansion does not — these are an upper
bound on the inline cost):
| ns/call, LAT | call | inline (wrapped) | factor |
|---|---|---|---|
fe_add |
2.225 | 1.467 | 1.517× |
fe_sub |
1.302 | 1.301 | 1.00× — no change |
That fe_sub row is the honest part of the result: standalone, fe_sub is
already latency-bound on its own four-limb sbb chain and the call/ret hides
inside it. All of fe_sub's gain comes from chaining — not paying the
store and the reload when the next operation consumes the value — which is
invisible to a single-operation benchmark and is exactly what point_double
measures.
tests/bench_ecdsa, 20,000 verifications × min-of-9, 8 alternating
repeats, taskset -c 20:
| µs / verify | before | after |
|---|---|---|
| repeat minima | 23.49, 23.41, 23.66, 23.50, 25.14, 25.44, 25.91, 26.13 | 21.38, 21.29, 21.28, 21.41, 23.77, 23.61, 23.60, 21.44 |
| best | 23.41 | 21.28 |
1.100×. Paired ratios: 1.099, 1.100, 1.112, 1.098, 1.058, 1.078, 1.098,
1.219 — median 1.099. Unpinned, 3 alternating repeats: 22.75 → 20.76 µs,
1.096×. tests/bench_fe (the field file is untouched — control) is
unchanged within noise: fe_mul 8.39/5.36, fe_sqr 7.37/4.50, fe_add
2.13, fe_sub 1.24.
The saving reconciles with the component numbers. Instrumented entry
counters on the benchmark fixture: 126 point_double, 109
point_add_mixed, 1 point_add per ecdsa_verify. Predicted saving
126×13.03 + 109×3.82 + 1×3.16 = 2.06 µs; measured 23.41 − 21.28 =
2.13 µs. Agreement to 3.3 %, so the gain is where the microbenchmarks say
it is and not somewhere else. (Counts are for one fixed fixture signature;
real digit streams vary by a few counts either way.)
.text bytes in the NASM objects:
| before | after | |
|---|---|---|
point_double |
481 | 1,336 |
point_add.distinct |
344 | 762 |
point_add_mixed.distinct |
360 | 748 |
pointh_add |
850 | 2,186 |
pointh_double |
483 | 1,140 |
secp256k1_point.o .text |
4,764 | 6,530 (+37 %) |
secp256k1_point_ct.o .text |
2,153 | 4,146 (+93 %) |
point_double nearly triples and runs 126× per verify, so this was the real
risk. The verification hot loop is now point_double (1.3 K) +
point_add_mixed (~1.2 K) + point_scalar_mul_glv (~1.2 K) + fe_mul /
fe_sqr (~0.9 K) ≈ 4.6 KB against a 32 KB L1I, and the measurement above
is the answer: it got faster, so on this core the I-cache did not care. On a
machine with a smaller L1I this trade could invert, and that is not tested.
Not measured end to end. Projected. §7 and §9 exist because projections off stale shares overstated results, so the arithmetic is spelled out.
§11's live profile (height ~700,000, 117,989 samples) puts field + EC
arithmetic at ~64 % of all replay cycles, essentially all of it under
ecdsa_verify. A 1.100× on that component removes 9.1 % of its cycles:
end-to-end ≈ 1 / (1 − 0.091 × 0.64) ≈ 1.062×
The Amdahl bound for this lever specifically is set by the two symbols it
attacks: fe_add + fe_sub were 8.8 % of all cycles, so making field
addition entirely free would give 1/(1−0.088) = 1.096× end-to-end. This
change realises about two thirds of that (5.8 of the 8.8 points). The
remaining third is the arithmetic itself, which no amount of inlining removes.
The wider bound is unchanged: even a free ecdsa_verify is 1/(1−0.64) =
2.78×.
This projection is only as good as §11's share, which is hours old and was taken at one height. Re-profile before quoting an end-to-end number.
§11.2's item 2 — fe_mul_int(r, a, k) for the 3A / 8C patterns — has
been mostly consumed by this change and should be re-sized before it is
built. §10.11 priced it at ~3 % of a verify against fe_add at 2.18 ns per
call. Those five calls are now five inline operations costing ~0.87 ns each
(derived: point_double lost 13.03 ns across 14 inlined operations), so the
whole 3A + 8C chain is now ~4.4 ns, not ~11 ns. A shift-based small
multiply might halve that: ~2 ns per point_double, 126× = 0.25 µs ≈ 1.2 %
of a verify, ~0.8 % end-to-end — for a new field primitive needing its own
Python oracle and mutation campaign. That is now below the bar.
What is left after this:
- Re-profile. The shares have moved four times in two days and the EC layer's internal composition just changed again.
- Attribute the 6.9 % in
memmove/memset/copy_bytes(§11.2 item 3) — still unexplored, still needs one call-graph run. fe_mul/fe_sqrare now ~46 % of all cycles between them and there is no known lever on them: §10 measured lazy reduction (1.07×, rejected on the consensus hazard), 5×52 (worse), AVX-512 IFMA (§5.3, rejected), and three contract-preservingreducerestructurings (all inside ±0.5 ns).
tests/test_point_repr— 8,037,500 checks, 0 failures: every point routine against the frozentests/point_ref.asm/point_ct_ref.asm, out of place and in place, with the degenerate shapes forced (infinity, q == p, mixed-add doubling, Y = 0, (0,0)).tests/test_fe_inline— NEW, 24,000,720 checks, 0 failures: the macros driven directly (tests/fe_inline_probe.asm) againstfe_add/fe_suband an independent C big-integer oracle, over 3,000,000 structured and random canonical pairs plus 90 constructed carry-boundary pairs around p, 2²⁵⁶, 2²⁵⁶+C, 2p, 0 and C. Wired intomake test.tests/test_ecdsa_inverse(113,315 cases vs a frozen reference reaching the verdict by a different route) andtests/test_ecdsa_glv_switch(30,240, three-way): pass.tests/test_scalarmul_ct: passes, including its timing check — CT ratio 1.000× against a variable-time control that leaks 14.6×. The macros are straight-lineadc/sbb/cmov; no branch and no data-dependent address was introduced on the signing path.make abi-check: OK, 1,026 call sites. No frame size changed.make -k test: MAKE_RC=0, zero failures, 108 harnesses.
Mutation campaign: 18 mutants, 18 caught, 0 survivors. Every mutant is a
real instruction edit (never a comment), applied to the shipped source and
rebuilt: broken adc→add carry chains in FE_ADDM and FE_DBL; inverted
cmovnz→cmovz; dropping CF1 from FE_ADD_TAIL's predicate (caught only
by the constructed a+b ≥ 2²⁵⁶ boundary — the case §12.4's proof turns on);
fold constant C → C−1; dropping FE_SUBM's borrow correction; wrong limb
offsets in FE_LD and FE_ST; and ten formula-level edits — X3 = F−D,
8C → 4C, E = 3A → 2A, swapped D−X3, swapped H = U2−X1, a dropped V
in point_add, zr = Y1 instead of 2Y1, a wrong slot in pointh_add's
fused 7+8, Z3 = 4t0, t2 = 2t2.
- No live profile of the new code. The replay is running the previously deployed binary; nothing here was deployed. §12.7 is arithmetic, not measurement.
- The 8.8 % / 64 % shares are §11's, from one 25-second window at one height.
- Whether the code-size growth costs anything on a core with a smaller L1I.
fe_mul/fe_sqrinlining was reasoned about (§12.2) and not measured.tests/bench_point's operands are one fixed pair of curve points; the routines are straight-line over the field so this is not a correctness concern, but the digit-dependent call counts in §12.5 are fixture-specific.
BENCHMARKS.md (2026-08-22) measured this project against Bitcoin Core
operation by operation and named the two biggest gaps: BIP340 Schnorr verify
at 3.35× and the SHA-256d/merkle pair at 2.24×. Both diagnoses in that
document were correct. This section records what they cost, what was done, and
— for the second one — the measurement that says it was worth far less than
the ratio suggests.
Every number below is CPU time (CLOCK_THREAD_CPUTIME_ID), min-of-15 rounds,
taskset -c 25, taken on 2026-08-23 with the live replay (~470 % CPU), the
Core oracle (~46 %) and another agent on the box. Before and after were
measured in the same sitting on the same core, which is why the "before"
column does not always match BENCHMARKS.md's absolute numbers — that run was
a different night with different contention. Compare the columns, not the
documents.
BENCHMARKS.md guessed one cause. There were three, and they are worth
separating because two of them are not about Schnorr at all.
Cause 1 — Schnorr never received the ECDSA multiply work.
secp256k1_ecdsa.asm has used point_scalar_mul_fixed for the G-side and
point_scalar_mul_glv for the point-side since §4.3. secp256k1_schnorr.asm
called plain point_scalar_mul twice. BIP340's R = s·G − e·P is
structurally the same shape — one fixed base, one variable — so both
substitutions apply directly.
The sign. s·G − e·P is not u1·G + u2·Q, and the two obvious ways to
handle that are not equivalent. Negating the scalar (e' = n − e) costs a
subtraction and then makes e' = 0 mean "infinity" for what was e = n, which
never happens but has to be reasoned about. Negating the Jacobian Y
((X, Y, Z) → (X, p−Y, Z)) is four sub/sbb, is exact for every input, and
leaves Z alone so the infinity case stays infinity. The code already did the
second and it was kept unchanged; only the multiply above it moved.
The x-only lift. lift_x goes through pubkey_parse on 0x02 || pk, which
is the even-Y root by construction, and is untouched. The even-Y check on R
is untouched. What changed underneath them is only how s·G and e·P are
computed.
Cause 2 — two field inversions where one was needed, and neither was
needed for x. PERF_SCOPE.md §4.2's item A′ flagged this a day early and
nobody had done it: the tail did fe_inv(Z²) and fe_inv(Z³). The
x(R) == r test needs no inversion at all — r is a field element and the
caller has already rejected r ≥ p, so
x(R) == r ⟺ r · Z² == X (mod p)
exactly as ecdsa_x_eq_mod_n does for ECDSA, minus the r + n retry that
only exists because ECDSA's r is a scalar mod n. The even-Y test genuinely
does need the affine y — parity is not a projective invariant, and no
Legendre-symbol trick recovers it (that trick works for the square-y variant
BIP340 abandoned) — but it needs Z⁻¹ once, not Z⁻² and Z⁻³ separately.
The x compare is now done first, so a rejected signature never reaches the
inversion at all.
Cause 3 — fe_inv itself was doing 248 multiplies it did not need.
Once the count was down to one inversion, that one inversion measured
4,712 ns — 17 % of a whole verify. The reason is that fe_inv walked the
256 bits of EXP = p−2 with fe_mul(R,R,R) for the squarings: p−2 has 247
set bits, so 255 squarings plus 248 multiplies, 503 fe_mul calls in all
(the count §4.2 already recorded, without noticing it was the fixable half).
p − 2 = 2²⁵⁶ − 2³² − 979 is a long run of 1-bits, and the classical
x_k = a^(2^k − 1) ladder reaches it in 255 squarings and 15 multiplies.
Fermat is unchanged; only the addition chain and the use of fe_sqr for the
squares are new.
Deliberately not done: a variable-time inversion. The obvious candidate is
a binary xgcd like sc_inv_var, which is right there and measures 3,590 ns
for the scalar field — slower than the 2,091 ns the addition chain now costs.
libsecp256k1's safegcd would land near 1,000 ns, saving a further ~1.1 µs
(4 % of a verify) for ~600 lines of new consensus-critical primitive. That
trade is not worth taking today and is written down here so the next session
does not rediscover it.
it is small
BENCHMARKS.md attributed the 2.24× entirely to Core's 2-way SHA-NI batch.
That is exactly right, and it is now measured on our own kernel rather than
inferred. A scratch harness alternating N independent sha256_block_shani
chains on this CPU (Zen 5):
| independent chains in flight | ns per compression | vs 1 |
|---|---|---|
| 1 | 26.88 | 1.000× |
| 2 | 16.91 | 1.590× |
| 3 | 16.72 | 1.608× |
| 4 | 16.75 | 1.605× |
| 5 | 16.78 | 1.602× |
| 6 | 16.67 | 1.612× |
Two is the right width on this CPU and three is not better — measured, not
assumed. The whole 2.24× is that 1.59× plus per-call overhead: sha256d(x, 64)
cost 100.84 ns per pair, of which 3 × 26.88 = 80.6 ns is the three serial
compressions and 20.1 ns (19.9 %) is sha256_init, a rep movsb of the 64
input bytes, and building a padding block whose contents are a compile-time
constant.
But the end-to-end value of fixing it is ~0.2 %, and that is the finding
that matters. merkle_root has exactly one caller in the node
(cons_verify, bitcoin_cons.asm:175) — there is no other 64-byte
double-hash on any path. Counting SHA-256 compressions over three real mainnet
blocks pulled from the Core oracle:
| height | txs | inputs | merkle compressions | share of the block's SHA-256 |
|---|---|---|---|---|
| 772,000 | 515 | 1,055 | 1,566 | 6.7 % |
| 850,000 | 3,163 | 6,087 | 9,504 | 11.6 % |
| 963,000 | 4,321 | 8,541 | 12,987 | 10.2 % |
§11's live profile puts all of sha256_block_shani at 5.14 % of replay
cycles, so merkle is 0.34 – 0.60 % of all cycles. The Amdahl bound is
therefore blunt: making merkle_root infinitely fast is worth at most
1.006× end to end, and the 1.90× actually achieved is worth ≈1.002×.
Put the other way: at height 963,000 merkle_root costs 0.43 ms against
191.8 ms of signature verification for the same block — 0.22 %.
It was built anyway, and the reason is the risk side rather than the reward
side: it needed no new cryptographic kernel. sha256d64 calls the
existing, already-validated sha256_block twice per step with two independent
states, and both padding blocks are .rodata constants because the two
message lengths are always 512 and 256 bits. Nothing in it computes a hash
round. A translation of Core's Transform_2way — 16 live __m128i and ~430
lines of intrinsics — would have been the wrong trade for 0.2 %; this was not.
merkle_root stages a batch of nodes before calling sha256d64, because
one pair per call leaves consecutive pairs unable to overlap across the call
boundary. Measured, at 9,001 leaves: 1 pair/call 76.8 ns/leaf, 2 → 60.0,
4 → 54.9, 8 → 53.2, 16 → 53.2. The curve is flat from 8; 16 is what
shipped (1 KB of stack).
Methodology, and why it is not the one at the top of this section. The box
got much busier partway through this session (load average went from ~5 to
~40 — another agent started a 30-way parallel job). PERF_SCOPE.md §9 and
BENCHMARKS.md both warn that a "before" from one window and an "after" from
another is worthless, and this run proved it: the same untouched
ecdsa_verify measured 22.46 µs at load 5 and 31.63 µs at load 42.
So the before/after table below was taken by building the baseline commit
(cffe48b) into a second worktree and ALTERNATING the two binaries on the
same core, base/new, base/new, three passes — whatever the box is doing hits
both sides inside the same few seconds. Each figure is the minimum over the
three passes of a min-of-15 CPU-time (CLOCK_THREAD_CPUTIME_ID) measurement,
2,000 operations per round, taskset -c 25. Load average was 39.9 at the
start and 32.1 at the end, which is why the absolute numbers are worse than
§13.1's — the ratios are what this table is for.
baseline cffe48b |
this branch | factor | |
|---|---|---|---|
schnorr_verify |
60.65 µs | 25.45 µs | 2.38× |
merkle_root, 9,001 leaves |
100.50 ns/leaf | 53.89 ns/leaf | 1.86× |
| SHA256D64 shape (1024 × 64 B) | 1.5733 ns/B | 0.8084 ns/B | 1.95× |
ecdsa_verify — untouched control |
21.20 µs | 21.23 µs | 1.00× |
The control is the point of the table: ecdsa_verify shares fe_mul,
fe_sqr, point_add, point_double, the comb and the GLV ladder with
everything that changed, and it did not move — 21.20 vs 21.23 µs, a 0.1 %
difference against a 3-pass spread of 21.20–24.94. Whatever made Schnorr 2.38×
faster did not come from a measurement artefact that would have moved ECDSA
too. (ecdsa_verify does not call fe_inv at all since §4.2's item A, which
is why the addition chain does not show up here.)
Per-pass, so the spread is visible rather than described:
| pass | base schnorr | new schnorr | base merkle | new merkle | base ecdsa | new ecdsa |
|---|---|---|---|---|---|---|
| 1 | 64.82 µs | 27.95 µs | 111.36 ns | 58.81 ns | 23.24 µs | 23.25 µs |
| 2 | 60.65 | 25.45 | 100.50 | 53.89 | 21.20 | 21.23 |
| 3 | 63.72 | 31.24 | 119.26 | 62.42 | 24.88 | 24.94 |
Two component measurements were taken earlier in the session, on the quiet box (load ~5), and are quoted with that caveat:
| before | after | factor | |
|---|---|---|---|
fe_inv (scratch harness, 20,000 calls/round, min-of-15) |
4,712.4 ns | 2,091.2 ns | 2.25× |
sha256d64 vs sha256d, per 64-byte pair |
100.74 ns | 51.99 ns | 1.94× |
The Schnorr decomposition. Each step was measured on its own as it was built, in one sitting on the quiet box (so this column's baseline is 58.99 µs rather than the 60.65 µs above — same code, quieter machine):
| step | µs/verify | share of the total gain |
|---|---|---|
baseline (point_scalar_mul twice, two fe_inv) |
58.99 | — |
+ fixed-base comb for s·G, GLV+wNAF for e·P |
31.99 | 82 % |
+ projective x(R) == r, one inversion instead of two |
27.69 | 13 % |
+ fe_inv addition chain |
25.92 | 5 % |
Schnorr is now within ~20 % of this project's own ECDSA verify (25.45 vs
21.23 µs in the same window), which is the right internal sanity check: the
two do the same two multiplies, and what is left between them is Schnorr's one
fe_inv (2.09 µs) and two tagged sha256_full calls against ECDSA's
sc_inv_var (3.59 µs) and two sc_mul.
Reading secp256k1_schnorr.asm closely enough to rewrite it turned up
something that has nothing to do with performance: the function was not
thread-safe, and the node verifies blocks on worker threads.
The BIP340 challenge preimage was built in a process-global .data buffer
(schnorr_preimg). asm/daemon/tx_verify.c spawns verify workers
(bmc_pthread_create, tx_verify.c:451 and :955), so two taproot key-path
inputs verified at the same moment overwrote each other's preimage and each
computed the other's challenge e. A wrong e gives a wrong R, so
x(R) != r and a valid signature is rejected — which, inside a block,
rejects a valid block.
Reproduced rather than inferred: 8 threads × 20,000 verifications of the
official BIP340 vectors — signatures the same binary accepts single-threaded —
gave 1,982 false rejects. With the preimage moved into schnorr_verify's
own stack frame: 0. tests/test_schnorr_thread_stress.c is that harness,
now in make test, and scripts/mutate_check.py puts the global buffer back
to confirm the test fails when it should.
The direction matters: false reject, not false accept — a corrupted preimage would have to hash to the exact challenge that particular signature commits to, a ~2⁻²⁵⁶ accident. So this is a chain-split/liveness bug, not a theft bug. It is also the shape that shows up in a replay as an unexplained "block rejected" and gets attributed to something else.
The same commit adds the message-length bound the buffer never had: an
over-long message used to run off the end of a 320-byte .data buffer, and
now rejects cleanly, exactly as secp256k1_taproot.asm's tap_leaf_hash was
hardened to do on 2026-08-21.
The same bug class is still live one file over and is NOT fixed here.
secp256k1_taproot.asm's tagh_buf and tap_preimg are process-global — its
own header calls them "single-threaded global" — and they are on the taproot
verify path that the same worker threads reach for every script-path spend.
The 2026-08-19 TLS conversion covered the interpreter's scratch and never
reached the secp256k1 layer. That is a separate change with its own tests; it
does not belong in a performance branch. See LOG.md, 2026-08-23.
scripts/bench_vs_core.sh was re-run and its output at load 42 was
discarded — Core's nanobench and libsecp256k1's bench both time with a
WALL clock, so on a loaded box their numbers are inflated by descheduling that
our CLOCK_THREAD_CPUTIME_ID harnesses do not see, which biases every ratio
in our favour. The table below was taken instead by the same alternating
method as §13.3: Core's binary and ours, back to back on taskset -c 25,
three passes, minimum of each. Two such windows were run, at load ~40 and
again at load ~15; the quieter one is reported and the busier one agreed to
within 3 % on every row.
Ratios are ours ÷ theirs, so > 1 means we are slower.
| operation | Core, this window | ours, this window | ratio | published 2026-08-22 |
|---|---|---|---|---|
| SHA-256, 1,000,000 B | 0.3825 ns/B | 0.4376 ns/B | 1.14× | 1.15× |
| SHA-256, 32 B | 39.06 ns | 36.20 ns | 0.93× (we are faster) | 0.94× |
| SHA-256d, 64 B × 1024 | 0.7255 ns/B | 0.8499 ns/B | 1.17× | 2.24× |
— the same window, through the OLD sha256d path |
0.7255 | 1.5839 ns/B | 2.18× | 2.24× |
| Merkle root, 9,001 leaves | 47.88 ns/leaf | 55.94 ns/leaf | 1.17× | 2.24× |
Three of those five rows are controls, and all three land on their published
values. The two SHA-256 rows are code this branch never touched and they
reproduce 2026-08-22's 1.15× and 0.94× to within 1 %. The italic row is
better still: bench_hash_core deliberately still carries the OLD
one-node-at-a-time sha256d shape next to the new sha256d64 one, and in
this window it measures 2.18× Core against the 2.24× published on a quiet
box — 2.5 % apart. So the alternating method is sound here, and the 1.17× next
to it is a real change and not a measurement artefact. Core's own
bench_bitcoin measured CPU/wall = 0.992 across the passes, comfortably
inside BENCHMARKS.md's own rule for keeping the Core column.
The two crypto rows could not be measured this way tonight, and the reason
is worth stating rather than papering over. libsecp256k1's bench reports a
wall clock and carries no CPU/wall check of its own. In the quiet window it
read 31.0 µs for ecdsa_verify and 31.4 µs for schnorrsig_verify,
against its own published quiet-box 21.10 and 21.40 — inflated by ~47 %. A
ratio taken against that would flatter us by half, so by this document's own
rule the Core crypto column is discarded.
What can honestly be said instead uses the untouched ECDSA control to
calibrate our column: ecdsa_verify read 21.98 µs here, against
23.70 µs published for the identical binary on 2026-08-22. Our side is
therefore at or better than quiet-box quality, so comparing it to Core's
published quiet figures is sound in the direction that matters:
| ours | libsecp256k1 (published quiet) | ratio | |
|---|---|---|---|
| ECDSA verify — untouched control | 21.98 µs (best of night 21.20) | 21.10 µs | 1.00–1.04× |
| Schnorr verify (BIP340) | 27.56 µs (best of night 25.45) | 21.40 µs | 1.19–1.29× |
The control reading 1.00–1.04× where 2026-08-22 published 1.12× is the check
that this composition is not quietly generous: it errs slightly against the
published number, not for it. Call Schnorr ~1.2×, down from 3.35×, and
re-run scripts/bench_vs_core.sh end to end on a genuinely idle machine for a
single-sitting figure.
The two gaps this session set out to close are closed to within ~20 %. Schnorr 3.35× → ~1.2×, SHA-256d 2.24× → 1.17×, merkle 2.24× → 1.17×. What that is worth end to end is §13.4, and for merkle the answer is "almost nothing".
These are projections from §11's shares, not measurements. Nothing here has been deployed; the replay is running the previous binary.
§11's profile was taken at height ~700,000, where the verify mix is overwhelmingly ECDSA, so it contains almost no Schnorr at all. That makes it the wrong instrument for this change and the right one for the merkle change:
- Merkle.
sha256_block_shaniis 5.14 % of cycles; §13.2 measures merkle at 6.7–11.6 % of a block's SHA-256 work, i.e. 0.34–0.60 % of cycles. At 1.90× that recovers 0.16–0.28 % → 1.002–1.003× end to end. The ceiling, at infinite speed, is 1.006×. - Schnorr. At §11's height the share is near zero, so the projection there
is ~1.00× and meaningless. The number that matters is per-input:
BENCHMARKS.md's tier-2 lower bound for a P2TR key-path input falls from ≥ 72.04 µs to ≥ 26.30 µs, and Core'sVerifyScriptP2TR_KeyPathis 20.66 µs, so the floor on that input type goes from ≥ 3.49× to ≥ 1.27×. For Core's ownConnectBlockMixedEcdsaSchnorrshape (1 Schnorr : 4 ECDSA, which Core's comment calls representative of blocks 848,000–868,000) the composed lower bound falls from ≥ 1.48× to ≥ 1.09×.
The Schnorr share is going to grow, and §11 cannot see it. Taproot
key-path spends are common from ~713,500; the replay is at ~772,000 and
climbing. A profile taken today would put a materially larger share on
schnorr_verify than the one this projection is built from, and every month
of chain makes that worse. Re-profiling after deploy is the only honest way to
close this — §11's shares have already inverted the ranking three times.
BIP340's official vectors. tests/test_schnorr — 19 checks, 0 failures,
including every published failure case.
Differential against Bitcoin Core, per signature. A new SCHNORR command
in validation/core_verify_oracle.cpp calls XOnlyPubKey::VerifySchnorr
(libsecp256k1's secp256k1_schnorrsig_verify) directly, with no script, no
transaction and no flags — so a malformed signature can be handed to Core and
its verdict taken. validation/gen_schnorr_diff_vectors.py builds the corpus
from a small independent pure-Python BIP340 implementation (not a port of
anything in this repo, so a shared misunderstanding cannot cancel out).
| class | what it is | committed | bulk run |
|---|---|---|---|
valid |
correct signatures | 1,200 | 100,000 |
oddy |
x(R) == r but y(R) ODD — signed without BIP340's k-negation | 450 | 37,500 |
infinity |
s·G − e·P is the point at infinity — r chosen freely, s = e·d | 150 | 12,500 |
nolift |
pk is not any curve point's x (x³+7 not a QR) | 240 | 20,000 |
bitflip |
one bit flipped, spread over r / s / msg / pk | 960 | 80,000 |
s_ge_n, r_ge_p, pk_ge_p, pk_zero, s_zero, r_zero |
every range edge, incl. s == n, r == p, pk == p | 20 | 500 |
3,020 committed cases and 250,500 bulk cases, each run twice (GLV on and
BMC_ECDSA_GLV=0), agree with Core exactly — 501,000 comparisons, 0
mismatches, 0 false accepts. The committed set is tests/test_schnorr_diff,
wired into make test; the bulk set is out of tree.
On "small-order-adjacent": secp256k1 has prime order, so there is no
non-trivial small subgroup — the point at infinity is the entire story and it
is the infinity class above.
fe_inv's chain. asm/validation/fe_inv_chain.py parses the assembly,
evaluates the exponent each FEINV_SQN / FEINV_MUL produces over Python's
integers, and asserts it equals p − 2 exactly (255 squarings, 15 multiplies);
change a rung in the .asm and it fails. tests/test_fe_repr adds 252,555
addition-chain cases against the frozen naive-binary fe_inv_ref — 250,000
random plus structured k, p−k, 2^k, 2^k−1, p−2^k for k = 1..256 and
zero — 0 differences, on top of the 1,600 Python ground-truth vectors and the
1,547-value structured digest it already carried.
Merkle. tests/test_merkle_batch (new, in make test): sha256d64 ==
sha256d for every batch count 0..128 over 200 random fills including the
odd tail and a no-overrun check, and merkle_root == the one-node-at-a-time
algorithm it replaced for every leaf count 1..2050 — which covers every
odd level, every batch boundary, and the in-place overwrite. Real chain data:
cons_verify recomputes every txid and the merkle root and compares it to the
header, and it accepts 989 real mainnet blocks pulled from the Core
oracle: 389 spread across heights 0–963,000 (dense around the segwit
activation at 481,824 and across the taproot era) plus a contiguous run of
600, heights 850,000–850,599. All 989 accepted, i.e. all 989 merkle roots
byte-identical to the header.
ABI. sha256d64, merkle_root, fe_inv, schnorr_verify and
schnorr_x_eq_r all probed with tests/bench_abi_guard.S's sentinel probe:
clean, no callee-saved register destroyed.
Mutation campaign — scripts/mutate_check.py, 11 mutants, 10 caught, 1
documented-unobservable. Every mutant is a real instruction or numeric
constant in the shipped assembly; the script refuses a mutation whose
anchor text appears only inside a comment, because an earlier attempt in this
project was vacuous for exactly that reason. Mutants: drop the even-Y test;
compute s·G + e·P; e·G instead of s·G; drop the top limb from the x
compare; one squaring short in the fe_inv chain; the wrong operand in a
chain rung; a 256-bit length in sha256d64's 512-bit padding block; no state
reset before the second SHA-256; jae→ja on the odd-level duplication; a
64-byte write stride where 32 is right.
The eleventh is worth reading, because finding it changed the code. Dropping
schnorr_verify's infinity check survives the entire 250,500-case Core
corpus — point_add writes the canonical infinity (1, 1, 0), so an infinite
R reaches the compare as X == 1, Z == 0 and r·0 == 1 is false, and the
signature is rejected by the x compare instead. No input can distinguish the
two versions. The check stays because the compare alone would accept a
(0, ·, 0) infinity against r == 0, and tests/test_schnorr_diff now
asserts exactly that hazard (schnorr_x_eq_r(0,0,0) == 1). The mutation is
marked expect_survive with that reasoning in the script rather than deleted.
Finding the other survivor changed the code too: "check only 3 of the 4
limbs of x(R)" is invisible to any corpus of real signatures, because it needs
an (r, X, Z) where r·Z² agrees with X in three limbs and differs in the
fourth — a ~2⁻¹⁹² accident that no signature can be steered into. The compare
was therefore lifted into an exported schnorr_x_eq_r, following the exact
precedent of ecdsa_x_eq_mod_n (exported "ONLY so tests can drive the r+n
branch"), and tests/test_schnorr_diff drives it with 179,377 constructed
triples — matching ones plus every single-limb perturbation of X and of
r. The mutation is now caught.
make -k test: MAKE_RC=0, zero failures, 152 harnesses run (149 onmainplus this branch's three:test_schnorr_diff,test_schnorr_thread_stress,test_merkle_batch). Every "N failures" line in the log reads 0; nomakeerror lines. The count is stated because silent test loss has bitten this project four times.make abi-check: OK, 1,050 reachable call sites scanned, every call site leaving assembly at RSP == 0 mod 16. (The 234 latent asm→asm misalignments it also reports are pre-existing and tracked as LOG.md incident #20; this branch adds none —sha256d64andschnorr_x_eq_rare both 16-aligned at every nested call andmerkle_root's enlarged frame keeps the property it had.)- Out of tree: 250,500 BIP340 cases × 2 GLV settings vs Core — 0 mismatches.
- Out of tree:
cons_verifyover 989 real mainnet blocks from the Core oracle (389 spread over heights 0–963,000 plus a contiguous 850,000–850,599) — all accepted, i.e. every merkle root byte-identical to its header.
- Nothing here has been deployed and no live profile of it exists. §13.4 is arithmetic over §11's shares, and §11 predates the taproot-dense part of the chain the replay is now in.
- No tier-3 end-to-end run.
BENCHMARKS.md's reasons still hold: the box carries a ~470 % replay plus another agent, andbench_tier3.sh checkdoes not pass. - The Schnorr differential uses 32-byte messages only for the Core half,
because
XOnlyPubKey::VerifySchnorrtakes auint256— which is also the only shape consensus ever uses. Variable-length BIP340 messages are covered only by the official vectors (which include 0, 1, 17 and 100-byte messages). e ≥ nas an input edge is unreachable:eis a SHA-256 digest reduced mod n by the verifier itself, and a digest ≥ n happens with probability ~2⁻¹²⁸. The reduction is exercised by every case; the ≥ n branch of it is not.- The merkle share in §13.2 is counted from three blocks, and the compression model for the sighash side is analytic (preimage sizes), not instrumented.
MK_STAGEwas swept at 1, 2, 4, 8 and 16 only; 32 and 64 need the frame offsets recomputed and were not tried. The curve is flat from 8, so this is unlikely to matter.
14. The bottleneck is not arithmetic, it is serialization — MEASURED, 2026-08-23 02:30, height ~797,000
Every projection in §11–§13 assumed the verifier's cost is spread across the worker pool. It is not. Measured on the live daemon, on a quiet box (load 6.9, system 85% idle, iowait 2–3%):
thread states, verification worker (pid 3459575): 32 sleeping, 1 running
effective parallelism: ~1 core of 32
Thirty-three threads exist. The main thread runs; the thirty-two workers sleep.
perf record -F 999 -g -t <main tid>, 24,893 samples, 30s:
| symbol | share of main thread |
|---|---|
fe_mul.reduce |
33.52% |
fe_mul |
14.02% |
fe_sqr |
11.54% |
point_double |
8.24% |
sha256_block_shani |
3.38% |
lsm_run_lookup_mm |
2.13% |
67.3% of the main thread is field/EC arithmetic — i.e. signature verification. The whole-process profile (48,365 samples) agrees: 53.8% field arithmetic, ~63% including the EC layer, and SHA-256 only 5.53%.
Both worker loops in daemon/tx_verify.c skip P2TR outright —
if (g_txv_in[i].shape == TXV_SHAPE_P2TR) { g_txv_results[i].ok = 1; continue; } /* single-tx */
if (w->flat[i].shape == TXV_SHAPE_P2TR) { w->res[i].ok = 1; continue; } /* batch */
— and every taproot input is then verified in a sequential loop after the
threads join, at both entry points (tx_verify.c:593 and :1220). The code
says so: "taproot pass 3 stays sequential/single-tx-at-a-time".
That is not a scheduling choice. It is a workaround for shared mutable state:
secp256k1_taproot.asm keeps tagh_buf in .data and tap_preimg in .bss
as process-global scratch, and its own header still asserts the assumption that
made that safe — "Global scratch (tagh_buf, tap_preimg) used by the small
helpers is single-threaded." Every taproot sighash, key-path included, is
built in that one shared buffer (bitcoin_taproot_sighash.c:576 calls
tagged_hash256(out32, "TapSighash", 10, pre, prelen)).
This is the third time tonight a written-down assumption turned out to be a scheduled outage; incidents #22 and #26 were both preceded by an explicit "known divergence, deliberately left" note. A documented assumption needs a test that fails when it stops being true, or it needs fixing.
§13 took Schnorr from 3.35x to ~1.2x vs Core and SHA-256d/merkle to ~1.17x. That work is real and correctly measured. But it reduced per-signature cost on a path that runs on one core, while Core verifies taproot across its script-check thread pool. The remaining gap to Core is now mostly that we do not.
It also explains the projection §13.4 could not make land: Schnorr's end-to-end effect was computed as "~1.00x and meaningless" against a profile that predates taproot density. The projection assumed a parallel execution that is not happening.
Ordering consequence: parallelising the taproot pass comes before any further
fe_mul work. A 1.15x on 54% of one core is worth far less than moving that
work onto 32. fe_mul stays the right target afterwards, when it multiplies
against a parallel baseline instead of a serial one.
tap_leaf_hash is the only one of the four helpers that needs a large buffer:
BIP342 puts no cap on tapscript size, so TAP_PREIMG_CAP is 4 MiB and the true
bound is the block size itself. Shrinking it would trade a latent bug for a
false reject. tagged_hash256 (64 + msglen) and tap_branch_hash (+64..+128)
are small.
Naive TLS therefore costs 4 MiB x up to TXVB_MAX_WORKERS (64) = 256 MiB of
.tbss. The codebase already has the better idiom: worker threads receive
sv_work, 1<<20 as a parameter rather than reaching for a global. Threading
a caller-provided scratch pointer through the four helpers removes the global,
removes the latent race, and lets the P2TR skip in both worker loops be deleted.
- The end-to-end win is not measured — only the headroom (31 idle cores and a main thread that is 67% signature crypto). Whether the catch-up loop can actually feed 32 workers is a separate question from whether the verifier can use them, and the UTXO apply is sequential regardless.
- An earlier parallelism sample read 1.90 cores rather than ~1.0; that window was contaminated by concurrent agent load. The 32-sleeping/1-running thread census is the reliable measurement, and it was taken on the quiet box.
- The taproot share of total verification at height ~797,000 was not counted directly (no per-shape input census); it is inferred from the main thread being 67% field arithmetic and the workers being the only other consumer.
§13.7 left "is close-core-gap constant-time clean" unverified, because
tests/test_scalarmul_ct was failing and the box was at load 60. Resolved by
alternating both binaries on one pinned core at load 3.3:
| run | baseline (pre-§13) | with §13 |
|---|---|---|
| 1..7 | 1.000, 1.001, 1.001, 1.000, 1.001, 1.001, 1.001 | 1.001, 0.999, 1.000, 1.001, 1.003, 1.002, 1.000 |
Both trees sit inside the +-3% window on every run. §13 is constant-time clean, and the test is sound — at load 3.3 it reproduces its own calibration band (0.986..1.009 at load 4-7) almost exactly.
What it did under load is worth recording, because it nearly caused a wrong call. At load 60 the same binaries produced 0.906..1.129 in both arms. Two of those readings were 0.952 and 0.956 — and the test's own comment documents that an injected 4% data-dependent leak "is caught at 3% (ratio ~0.955)". The noise landed exactly on the signature the tolerance exists to detect. An 8-run sample then showed the branch with ~3x the baseline's variance, which looked like a real asymmetry; four more runs destroyed it, with the baseline producing the two worst readings of the night.
Two lessons, both already paid for elsewhere in this log:
- A calibrated instrument is calibrated for an environment. This test is rigorous at load 4-7 and worthless at load 60, in both directions. Its window is not a safety margin against load; it is a claim about a measurement made under stated conditions. Run it outside them and it manufactures both false alarms and false confidence.
- Do not conclude from the sample you have when the next sample is cheap. The 3x-variance finding was real in its 8 points and wrong about the world.
The prerequisite is done: secp256k1_taproot.asm's two staging buffers are
thread-local as of 2026-08-23, gated by tests/test_taproot_thread_stress
(29,236 wrong digests of 96,000 against the pre-fix globals; 0 after). What
remains is to actually use the worker pool.
Do not parallelise within a transaction. Measured against Core at heights 825,000 and 825,001:
| h825,000 | h825,001 | |
|---|---|---|
| transactions (excl. coinbase) | 4,272 | 3,825 |
| taproot-bearing transactions | 2,988 (70%) | 2,051 (54%) |
| taproot inputs | 3,379 | 2,283 |
| mean taproot inputs per taproot tx | 1.13 | 1.11 |
| txs with exactly one taproot input | 96% | 95% |
Fanning a transaction's own taproot inputs across threads — which would need no
new memory management, since po/am/sp/ns are already built and
read-only by then — finds nothing to fan out 96% of the time. It is the
cheap design and it is worth approximately nothing.
The axis is across transactions, and the cost of that is that the per-transaction aggregate sighash data has to be live for many transactions at once instead of being rebuilt into one reused arena per transaction.
Shape that fits the existing code:
- Phase A, sequential and cheap. For each transaction that has at least one
taproot input, build
po(36B/input),am(8B/input),sp(1+spklen per input, packed — the existing arena is sizednin*(1+TXV_SPK_CAP)worst-case but only ever fills1+spklen) and the witness-strippedns, into a per-BLOCK arena. Record one small descriptor per transaction: offsets plusnin. This is memcpy andstrip_witness, not signature work — it is not what the profile is complaining about. - Phase B, parallel. Fan every taproot input across
g_txvb_pool. Each worker reads its transaction's descriptor and callstaproot_verify_inputwith pointers into the shared arena. The arena is read-only for the whole of phase B, so no per-worker duplication is needed — which is the entire reason phase A is worth doing separately rather than having each worker rebuild its own transaction's arrays.
Arena size is bounded by roughly sum(stripped tx bytes) + 44 * inputs over
the taproot-bearing transactions — a few MB per block, not the 8 MiB-per-worker
that duplicating ns would cost (static u8 ns[8<<20] x 32 workers).
Then, and only then, delete the two TXV_SHAPE_P2TR skips at
tx_verify.c (the single-tx worker loop and txvb_worker_loop) and the two
sequential passes that follow them.
Expected effect, stated as a bound rather than a promise. Section 14 measured the main thread at 67% field arithmetic with 31 cores idle. If that whole 67% moves to the pool, Amdahl gives at most ~3x on the connect path; the replay's other serial work (the UTXO apply, the LSM writes, the archive read) does not move and will become the next ceiling. It has to be re-profiled after, not projected — this section exists because section 13's projections were made against a profile that predated taproot density and were wrong for exactly that reason.
Validation this must pass before deployment, given the failure mode is a corrupted sighash rather than a loud error:
tests/test_taproot_thread_stress(already inmake test).- Whole-block differential against the Core oracle over a run of real taproot-dense blocks at height >= 800,000 — every transaction, both entry points, accept AND reject direction.
- The block that a wrong shared arena would break first is one where two transactions with different input counts verify concurrently; a fixture with that shape specifically, not just "a busy block".
14.7's shape, implemented at both entry points in daemon/tx_verify.c. Two
TXV_SHAPE_P2TR skips and two sequential passes deleted; taproot inputs are
now ordinary entries in the flat verify array.
- Phase A —
tapagg_build(). Sequential, cheap (memcpy +strip_witness, no signature work). For every transaction with at least one taproot input, appendspo(36 B/input),am(8 B/input),sp(packed 1+spklen, notnin*(1+TXV_SPK_CAP)) and the witness-strippednsto ONE per-block arena, and records a descriptor of byte offsets plusnin. Onebytepool_reserveper transaction, so nothing can relocate the arena between reserving a region and filling it. - Phase B —
tapagg_verify(). A pure reader of that arena, called fromtxv_verify_one/txvb_verify_onelike any other shape. The arena is read-only from the moment Phase 2 dispatches. - Phase A exists exactly once (a
tapin_fnadapter per entry point), so the single-transaction and whole-block paths cannot drift apart.
One change 14.7 did not call for, and why. Work claiming in the block pool
went from a fixed contiguous [lo,hi) slice per worker to one shared atomic
counter. Fixed slices were defensible while every entry cost roughly one
ECDSA verify; with taproot in the same round the per-entry cost now spans a
key-path Schnorr verify, a tapscript execution and a zero-work
TXV_SHAPE_WPASS entry, and those are not spread evenly through a block. The
measurement below separates the two changes.
Heights 825,000–825,015, 840,000–840,007, 870,000–870,007, 850,000–850,001, 860,000–860,001. 143,952 non-coinbase transactions, 247,725 inputs, 98,814 of them taproot. Blocks and prevouts from the scratch Core oracle. Box: AMD Ryzen 9 9950X3D, 16 physical cores / 32 threads, load 8–13. Three timed runs per block after one untimed warm-up, A/B alternated; the two rounds of each arm agreed to better than 1.5%. Built at -O2 (the pin came off the same day); an earlier -O0 measurement of the same three arms gave 4,035 / 777 / 563 ms, i.e. the same picture — verification is hand-written asm and the C glue is not where the time goes.
| tree | wall (36 blocks) | CPU | effective cores | inputs/s | vs baseline |
|---|---|---|---|---|---|
main (taproot sequential) |
3,999 ms | 10,848 ms | 2.72 | 61,950 | 1.00x |
| + taproot in the pool, static slices | 715 ms | 13,444 ms | 18.80 | 346,300 | 5.59x |
| + dynamic work claiming (shipped) | 534 ms | 14,225 ms | 26.62 | 463,600 | 7.49x |
| + the sizing/realloc checks below | 537 ms | 14,275 ms | 26.57 | 461,000 | 7.44x |
The last row is the hardening in "Three invariants made enforced rather than documented": two compares per input, 0.6% of wall — inside the run-to-run spread, and a fair price for turning a silent arena overrun into a refusal.
Dynamic claiming is worth 1.34x of that on its own — with static slices a worker that draws a run of tapscript-heavy transactions holds the barrier while the rest idle.
CPU time rises 31% and that is SMT, not extra work. Pinned to 8 logical
CPUs, where no two worker threads share a physical core, the same block costs
294.9 ms of CPU on main and 298.2 ms here — +1.1%, i.e. the arena build and
the atomic counter are noise — while wall time goes 192.9 ms → 38.5 ms (5.01x).
At full width the extra CPU is 26 threads sharing 16 cores, which is a real
cost paid for a much larger wall-time win, not a regression.
The old single-transaction taproot pass declared its arena as file statics
sized for the worst case — sp[TXV_MAX_INPUTS*(1+TXV_SPK_CAP)] alone is
20,000 x 10,001 = 200 MB, plus po, am, spk34, is_tap and an
8 MiB ns; the block path carried a second 8 MiB ns. All of it is gone,
replaced by a bump-reset pool that is a few MB per block. Measured on
tests/test_txvb_wprog_stable, which links the same translation unit:
.bss 428,437,288 -> 210,060,200 bytes (-218 MB)
.bss is demand-paged, so most of that was address space rather than resident
memory — but it was also 200 MB of arena whose entries were spaced 10,001
bytes apart to hold scripts that are almost always 22 to 34 bytes long, which
is why the packed layout is both smaller and better for cache.
The 7.49x is the script-verification phase in isolation, and nothing else. It is not a connect-path number and it is emphatically not a replay-throughput number:
- The bench feeds a fully-resolved in-memory prevout table. The live daemon resolves each prevout through the LSM, sequentially, which is why §14 measured ~1.0 effective cores live where this bench measures 2.72 on the same code. The bench isolates verification; it therefore overstates the end-to-end effect.
- Everything else the connect path does — block parse, BIP141 commitment, BIP30, the in-block index and duplicate-outpoint check, the sequential UTXO apply, the LSM writes, the undo log, the archive read — is untouched.
- Applying §14's live figure (67% of the main thread was field arithmetic) to a 7.49x on that portion gives at most 1/(0.33 + 0.67/7.49) ≈ 2.4x on the connect path, consistent with 14.7's "at most ~3x". The UTXO apply does not move and becomes the next ceiling.
This has to be re-profiled on the live daemon after deployment, not projected. That is the whole reason 14.7 exists.
-
tests/test_taproot_thread_stress— 8 threads x 4,000 iterations = 96,000 concurrent hashes, 0 wrong digests (29,236 of 96,000 against the pre-fix globals). -
Whole-block differential against the Core oracle —
tests/test_taproot_block_diff, new, over the 36 blocks above. Every transaction, both entry points, both directions:count result whole-block ACCEPT runs (block path) 288 (36 blocks x8) 0 rejects per-transaction ACCEPT (single-tx path) 143,952 0 rejects corrupted-witness REJECT (single-tx path) 81,124 all rejected, p2tr*reasoncorrupted-witness REJECT (block path) 1,775 all rejected, fail_tx_indexexactprevout perturbations, per path 3,454 all rejected, blame exact The prevout probes are the ones aimed at the arena specifically: bumping a sibling input's amount by one satoshi can only reject through
sha_amounts, i.e. through the shared array. -
The different-input-counts fixture —
tests/test_taproot_parallel_arena, inmake test, from real block 825,000 transactions spanning 14 distinct input counts (1,2,3,4,5,6,7,8,11,12,14,19,28,36): 488 pairwise two-transaction blocks, one 483-transaction interleaved block of 4,284 inputs x25 runs, 46 in-place corruption rejects with exact blame, the single-transaction path over all 23 fixture transactions in both directions, and the aggregate array's one-byte length limit checked from both sides — refused at a 253-byte prevout script, accepted at 252.
Soaked, because the bug class is scheduling-dependent. With 32 spinners saturating the box: the arena fixture 40 times, 0 failures; and 600 whole-block accepts (100 runs each over blocks 825,000 / 825,010 / 840,003 / 870,004 / 850,000 / 860,000), 0 rejects. A scheduling-dependent corruption that survives that is not one a single run would have found.
Two corpus facts that broke the first version of the probes, recorded because both are easy to get wrong again:
- 5 transactions spend taproot with SIGHASH_ANYONECANPAY, which drops
sha_amounts/sha_scriptpubkeysentirely — perturbing a sibling input's amount is legitimately a no-op for them. - 17 transactions are script-path spends that never compute a sighash at
all (unknown leaf version, an
OP_SUCCESSxleaf, or a tapscript with no CHECKSIG). Their own amount is not committed either. They are covered by breaking the control block's Merkle commitment instead, which every script-path spend must satisfy.
Ten deliberate bugs injected into the arena logic, each caught:
| mutation | caught by |
|---|---|
| descriptor index off by one | A (block accept) |
global flat index passed instead of local_idx |
A |
nin off by one in the descriptor |
A / pairwise |
ns taken from the neighbouring transaction |
pairwise |
| amounts written big-endian | A |
sp strided instead of packed |
A |
TXV_SHAPE_P2TR silently ok (the old skip), block path |
D / reject-in-place |
TXV_SHAPE_P2TR silently ok, single-transaction path |
single-tx reject |
>= 0xfd guard relaxed to > 0xfd, or removed |
arena pass 5 |
>= 0xfd guard tightened to >= 200 (false reject) |
arena pass 5 |
The two false-ACCEPT mutations are the ones that matter: a corrupted sighash never produces a false accept (a wrong sighash fails), but an input that is never checked does.
A review of the diff found no consensus bug but three places where correctness rested on a fact that is no longer checkable at the point of use. All three are now closed, and the first two are the difference between a loud refusal and a silent heap overrun:
tapagg_buildsizes with one traversal ofget()and writes with a second.getis a function pointer, so "both passes see the samespklen" stopped being a local fact. If they ever disagreed, thememcpywould overrunspinto the arena regions other transactions' descriptors point at — a corrupted sighash for an unrelated transaction. The write pass now re-checkssl < 0xfdandw + 1 + sl <= splen. Two compares per input against a Schnorr verify. Removing the>= 0xfdguard entirely, or loosening it by one, now trips this check rather than corrupting memory — which is exactly what mutations M-a and M-b above demonstrate.- Same indirection weakened the realloc argument: a future adapter that
allocated would dangle
po/am/sp/ns. The loop now comparespool->bufagainst the base captured after the single reserve, before the first write of each iteration. g_t1_tap_builtwas only cleared insideif (has_taproot). Safe today via a non-local coupling (has_taprootandTXV_SHAPE_P2TRare set by the same branch); now cleared unconditionally, so it is locally true.
- End-to-end replay throughput. Not measured at all. It needs the live daemon restarted, which this work deliberately did not do. The 2.4x connect-path figure above is arithmetic on §14's live profile, not a measurement.
- The re-profile §14.3 asks for — whether
fe_mulis still the right next target now that it multiplies against a parallel baseline — is not done. - Blocks below 800,000 and above 870,007, and any block whose taproot usage is unlike this corpus's. The differential covers 36 blocks in four clusters, not a chain.
- Constant-time behaviour.
tests/test_scalarmul_ctwas not re-run for this change; nothing here touches the scalar-multiply path. - Taproot spending a SAME-BLOCK output. The differential passes
bx = NULL, so every prevout resolves throughutxo_lsm_get; the live daemon resolves in-block chained spends throughbidx_getfirst. Both write the samevalue/spk_offfields that Phase A then reads, so the exposure is small, but it is reasoned rather than measured.tests/test_cross_tx_verifycoversbidx_getwith non-taproot scripts only. - The
>= 0xfdprevout-script guard on the taproot path. Carried over verbatim, and no real block in the corpus has a prevout script that large on a taproot-bearing transaction, so the reject is untested here as it was before. tests/test_block_481827_pool_stackstill SKIPs: its fixture has never existed in this tree. Generating one exposed a PRE-EXISTING defect, verified on unmodifiedmain— the.prevoutsformat lists prevouts created by an earlier transaction in the SAME block, which is right for a verifier and wrong as a seed for the full apply path, where BIP30 then sees the block's own transaction overwriting an unspent output and rejects ("REJECT h=481827 tx=12: bad-txns-BIP30, output 32 already unspent"). A seeder must drop any prevout whose funding txid appears in the block. Not fixed here — unrelated to taproot, and the fixture is not committed.- Memory under adversarial blocks was reasoned and then checked against
the corpus, but not fuzzed. Measured maximum over the 38 fixture blocks:
2.29 MB (height 840,000). The adversarial ceiling is small for a reason
worth stating:
tapagg_buildrejects any prevout script>= 0xfdon a taproot-bearing transaction (BIP341's aggregate array has a one-byte length field), sospcosts at most 253 B/input, notTXV_SPK_CAP. An input costs at least 41 wire bytes, so a 4,000,000-byte block carries at most ~97,600 of them, giving(36+8+253) * 97,600 + 4 MB ≈ 33 MB— bounded, bump-reset per block, and bounded by data the block-level checks already accepted.
The 3-hour gap to Core on the 2026-09-04 full-sync benchmark
(../audits/UTXO_INLINE_BUILD_PERF_SCOPE.md) decomposed into independent
levers. Eight branches landed today; the numbers below are each branch's own
measurement on one pinned core of this host, from the benchmark named in the
row (asm/tests/), min of three runs.
| lever | before | after | benchmark |
|---|---|---|---|
num3072_mul, generic → BMI2/ADX |
944 ns | 603 ns | bench_muhash |
num3072_mul, → AVX-512 IFMA (52-bit limbs) |
944 ns | 301 ns | bench_muhash |
muhash_insert per element (IFMA) |
1640 ns | 1005 ns | bench_muhash |
| MuHash fold on the bulk connect path | ~1.66 µs × every coin event (~3 h/sync) | none (seed once at caught-up; worker in steady state) | instrumentation, PR #24 |
| memtable flush descriptor sort, N=4M | 541 ms | 91 ms | bench_lsm_flush_sort |
| memtable probe, 2^26 slots, 50% load, dependent hits | 1.50 ns amortised, 1–2 DRAM misses cold | prefetch widened to the six lines a probe walks; no other change paid for | bench_utxo_probe |
| compact-block reconstruction, 50k-entry pool | 10.03 ms/block | 1.45 ms/block | agent measurement in daemon/cmpct_recv.c commit |
| UTXO connect vs download | 4.5 h after a 19.5 h download | under it (8 s budget between reaps, stop at the first hole) | test_dlc_interleave |
Where a row says "no other change paid for": the probe agent measured first and found a probe is one or two cache misses, not a sequence, so the cache-line-aware probe the scope imagined would have bought nothing. Tiered compaction, also in the scope, had already landed on 2026-08-31.
Measured end to end: in progress. A fresh full sync on the dedicated
bench SSD started 2026-09-06 05:50Z from main 0151051
(/mnt/2tbssd/bmc-bench, 16 workers, dbcache=8192,
bmc.bootcatchup=0, 965,215 blocks); its wall clock against Core v31.1's
21.0 h from 2026-09-04 is the number every lever above is waiting on. Two
harness defects had to be fixed first, both from the repo moving under it:
it built daemon/bitcoin_cli (renamed bmc_cli on 2026-09-05) and wrote
no bmc.bootcatchup=0, which would have measured the pre-interleave shape.
Not measured end to end (before that run). No full sync had run on this code. The CC-8
full-verification replay (/storage/bmc-fullverify, assumevalid=0) is
being restarted on it from its 233k-block archive; its wall-clock is the
first whole-run number. Two caveats bound what that run can show: the
boot-time catch-up cannot interleave (only the worker's far-behind run
does; the replay's FIRST download, 02:05–04:55Z, was the boot catch-up —
its shutdown line said so — and its restart on 305c1b4 went straight to
the worker), and the replay syncs from one loopback peer, so the download
is round-trip bound, not bandwidth bound. The restart also found CC-5's
four-page hold abandoning the header phase (fixed, 4c3e8fc); the
interleaved download is first exercised on the restart after that fix.
Next levers, in order: the ChaCha20 keystream inside a MuHash element
(now the larger half); TCP_NODELAY on outbound sockets and a single write
per message; the double input lookup; moving the boot catch-up into the
worker.