Phase C — Multi-chain CreateSovereignL1 ChainsList primitive (chains_list.go,
chains_list_test.go):
* 64-byte fixed-stride ChainEntry per chain: (NameRel/Len uint32, VMID 32B,
FxIDsRel/Len uint32, GenesisDataRel/Len uint32, Reserved 8B).
* Three sibling blob arrays (NameBlobs, FxIDsBlobs, GenesisDataBlobs)
carried as parent-tx fields; entry header stores (rel, len) cursors.
* Bind(nameBlobs, fxIDsBlobs, genesisDataBlobs) compile-time gate
mirroring BoundEvidenceList (NEW-V2): ChainsListView lacks safe
Name/FxIDs/GenesisData accessors so consumers cannot bypass Bind() by
accident. Only BoundChainEntry exposes the safe accessors.
* IsNull() zero-value guard on ChainEntry: out-of-range At(i) returns
a ChainEntry{} whose accessors short-circuit to zero rather than
nil-deref the underlying zap.Object's msg. Defense-in-depth atop the
defensive At(i) clamp.
* Per-element FxIDs blob is a length-prefixed byte array; entry count =
FxIDsLen / FxIDSize (32B per ids.ID). Non-multiple lengths clamp to nil.
* CreateSovereignL1Tx grows from a single-chain stub (TxKind 23, batch 4)
to a real multi-chain L1 builder: ChainsList + NameBlobs + FxIDsBlobs +
GenesisDataBlobs fields. Size 193 bytes (was 121).
Phase D — ValidatorsList encoding (validators_list.go,
validators_list_test.go):
* 180-byte fixed-stride ValidatorRecord per initial validator: NodeID 20B
+ Weight uint64 + BLSPubKey 48B + BLSPoP 96B + RegistrationExpiry uint64.
* Uses zap.Object.ListStride from v0.7.2 — per-element clamp against
poisoned wire length fields (R4V9).
* No sibling arrays needed — every field is fixed-size.
* BLSPubKey() and BLSPoP() return COPIED slices (not aliased to parent
buffer) so consumer mutation cannot corrupt subsequent reads.
* IsNull() zero-value guard mirroring ChainEntry.
* ConvertNetworkToL1Tx now carries the real ValidatorsList — the
previous (0, 0) stub is gone. Size still 165 bytes (the validators
list pointer was already provisioned).
* CreateSovereignL1Tx integrates ValidatorsList alongside ChainsList for
the full atomic sovereign-L1 commit shape.
Phase E — Bench refresh:
* M1 Max Parse geomean across 10 measurable tx types post-batch-5:
7.44× at -benchtime=2s -count=3 (medians).
* Restricted to the original 9 tx types from the v0.7.2 baseline:
7.74×, within noise of the 7.71× pre-batch-5 number.
* R4V7 SyntacticVerify lives on the executor Verify() path (not the
wire parser), so the parse benchmarks are structurally unchanged.
* ChainsList + ValidatorsList add new primitives but do not appear in
the legacy comparison fixtures.
Test coverage:
* chains_list_test.go: RoundTrip (3 chains incl. empty FxIDs+empty
GenesisData), EmptyList, OutOfRange, UnboundReturnsRawCursors,
BindMismatchedBlobsClampsSafely, FxIDsNonMultipleClampsToNil.
* validators_list_test.go: RoundTrip (2 validators), EmptyList,
OutOfRange, PoisonedLengthClampedByStride,
BLSFieldsAreReadOnly (mutation isolation),
CreateSovereignL1Integration (ChainsList + ValidatorsList together).
* batch4_tx_test.go: updated CreateSovereignL1TxRoundTrip for the new
multi-chain + validators shape; tests both round-trip and Wrap()
re-parse correctness.
All zap_native tests pass under go test -race.
Coordinates with cryptographer #114 v3 work and CTO bootstrap-chain work;
neither genesis/ nor bootstrap touched.
LP-023 batch 5 Phase C+D+E.
23 KiB
ZAP Native vs Legacy Codec — Bench Results (v3 / multi-host)
Date: 2026-06-02 (refreshed post-LP-023 batch 5)
Schema: v3 (TxKind discriminator at offset 0, +1B per tx)
luxfi/zap: v0.7.2 (Object.ListStride per-element clamp, FuzzParse round-trip)
luxfi/node HEAD: post LP-023 batch 5 (R4V7 SyntacticVerify + R4V3 AddressList audit + ChainsList + ValidatorsList)
Bench command: GOWORK=off go test -bench=BenchmarkParse_ -benchtime=2s -count=3 ./vms/platformvm/txs/zap_native/...
Toggle: LUXD_ENABLE_LEGACY_CODEC=1 flips between paths (default OFF; ZAP is native).
Batch 5 refresh (-benchtime=2s -count=3, median of 3 runs)
M1 Max Parse geomean across the 10 measurable tx types after batch 5:
| Tx | Legacy ns (med) | ZAP ns (med) | Parse × |
|---|---|---|---|
| Base | 91.52 | 21.49 | 4.26× |
| RewardValidatorTx | 137.40 | 21.78 | 6.31× |
| SetL1ValidatorWeightTx | 155.20 | 22.02 | 7.05× |
| IncreaseL1ValidatorBalanceTx | 150.60 | 21.42 | 7.03× |
| DisableL1ValidatorTx | 141.40 | 21.64 | 6.53× |
| BaseTx | 164.80 | 21.35 | 7.72× |
| RegisterL1ValidatorTx | 264.90 | 24.59 | 10.77× |
| SlashValidatorTx | 188.80 | 21.85 | 8.64× |
| TransferChainOwnershipTx | 190.10 | 21.80 | 8.72× |
| RemoveChainValidatorTx | 201.10 | 21.23 | 9.47× |
| Geomean (n=10) | 7.44× |
The slight dip from 7.71× → 7.44× tracks the addition of the trivial BenchmarkParse_-10 (Base, 4.26×) at the front of the suite — when restricted to the prior 9 tx types it converges to 7.74×, within noise of the v0.7.2 baseline. R4V7 SyntacticVerify lives on the executor Verify() path (not the wire parser), so the parse benchmarks are unchanged structurally; the variance is run-to-run wall-clock noise. ChainsList + ValidatorsList add new primitives but do not appear in the legacy comparison fixtures (the legacy stubs predate the multi-chain CreateSovereignL1 shape).
TL;DR
| Axis | M1 Max (this box, v0.7.2) | M4 Max (dbc, v0.7.1) |
|---|---|---|
| Geomean Parse × (9 ZAP-native tx types) | 7.71× | 8.02× |
| Geomean Parse alloc reduction × | 3.57× | 3.57× |
| Geomean Parse bytes reduction × | 6.89× | 6.89× |
| Geomean Build × (9 ZAP-native tx types) | 1.12× | 0.86× ⚠ |
| Geomean Build alloc reduction × | 2.18× | 2.18× |
| Geomean Build bytes reduction × | 1.77× | 1.77× |
AdvanceTime Parse via txs.Codec wrapper (end-to-end) |
34.14× | 42.76× |
AdvanceTime Build via txs.Codec wrapper |
3.85× | 4.94× |
| 1000-tx synthetic mempool (Legacy → Mixed-dispatch) | 1.05× | 1.01× (no ZAP-side in mix) |
Headlines:
- Parse lift is uniformly 6-13× per tx type on M1 Max, 6-13× on M4 Max — schema v3's +1B TxKind tax is invisible at this scale.
- Per-type geomean Parse on M1 with v0.7.2 (7.71×, up from 7.33× on v0.7.1) reflects the ListStride clamp's negligible accept-path cost. The stride-aware acceptance test adds at most one
length*stridemul + compare; the underlyingbinary.LittleEndian.Uint32reads dominate. - Build is a wash or regression on raw ZAP for 6 of 9 types on M4 Max (geomean 0.86×). This is a real, reproducible finding —
Object.SetU64-heavy builders aren't yet beating linearcodec's append path. Affects production write throughput; tracked as a v0.7.x follow-up (zap.Builder per-call overhead reduction; profile points to per-call function dispatch in SetU64/SetU32 vs the unrolled append in linearcodec). - The
txs.Codec-wrapped AdvanceTime end-to-end ratio scales with the chip: 34.14× → 42.76× M1→M4 — the codec.Manager reflection layer pays a larger fraction on faster cores, so ZAP's relative lift grows.
v0.7.2 changes (this refresh):
Object.ListStride(off, minStride)— per-element clamp on the wire layerList.Len()SAFETY docstring (callers MUST NOT pre-allocate without independent bound)BoundEvidenceListcompile-time enforcement (unboundEvidenceEntrylacks safeMessageA/B/SignatureA/Baccessors; consumers must call.Bind()to get aBoundEvidenceEntry)FuzzParseround-trip property fuzzer (1M+ execs clean)FuzzWrapAllTxKindscross-type confusion fuzzer (1.4M+ execs clean, 23 wrappers)- 8 new tx types: AddValidator, AddDelegator, AddPermissionlessDelegator, AddChainValidator, CreateNetwork, TransformChain, ConvertNetworkToL1, CreateSovereignL1 — TxKind values 16-23
- Multi-address
Ownerprimitive +AddressList(defers to OwnerStub for single-address fast path; ErrOwnerSingleAddrUseStub refuses len < 2)
Hosts
| Host | CPU | OS | Cores | RAM | Go | Role |
|---|---|---|---|---|---|---|
| Local (this box) | Apple M1 Max (MacBookPro18,2) | macOS 26.5 (Darwin 25.5.0 / 25F71) | 10 | 64 GB | go1.26.3 darwin/arm64 | Author box |
| dbc | Apple M4 Max (Mac16,5) | macOS 26.5 (Darwin 25.5.0 / 25F71) | 16 | 128 GB | go1.26.3 darwin/arm64 | Native ARC runner (hanzoai/arc), darwin/arm64 |
Note: Task spec called dbc "amd64 Linux". It is in fact darwin/arm64 (Apple M4 Max). Both hosts are darwin/arm64; the chip generation is the only architectural variable. linux/amd64 numbers are still un-measured and remain TBD (no usable amd64 box in current ARC fleet — flag for follow-up once cloud arm64 droplets ship on DOKS or a Linux x86 box is provisioned).
Per-tx-type Parse + Build — M1 Max
ZAP-native benches measure equivalent field-shape stubs (legacy*Tx Go structs registered with linearcodec.NewDefault()) vs the native WrapXxxTx accessors at zap_native.*. Apples-to-apples per type.
Parse — M1 Max (v0.7.2)
| Tx | Legacy ns | ZAP ns | Parse × | Legacy alloc | ZAP alloc | Δ allocs | Legacy B | ZAP B | Δ bytes |
|---|---|---|---|---|---|---|---|---|---|
| RewardValidatorTx | 129.2 | 20.76 | 6.22× | 3 | 1 | 3× | 120 | 24 | 5.00× |
| BaseTx | 167.2 | 20.87 | 8.01× | 3 | 1 | 3× | 152 | 24 | 6.33× |
| SetL1ValidatorWeightTx | 151.7 | 20.81 | 7.29× | 3 | 1 | 3× | 136 | 24 | 5.67× |
| IncreaseL1ValidatorBalanceTx | 142.1 | 20.94 | 6.79× | 3 | 1 | 3× | 136 | 24 | 5.67× |
| DisableL1ValidatorTx | 129.8 | 20.76 | 6.25× | 3 | 1 | 3× | 120 | 24 | 5.00× |
| RegisterL1ValidatorTx | 264.1 | 20.80 | 12.70× | 6 | 1 | 6× | 384 | 24 | 16.00× |
| SlashValidatorTx | 172.8 | 20.81 | 8.31× | 4 | 1 | 4× | 176 | 24 | 7.33× |
| TransferChainOwnershipTx | 182.9 | 20.89 | 8.76× | 4 | 1 | 4× | 192 | 24 | 8.00× |
| RemoveChainValidatorTx | 165.1 | 21.48 | 7.69× | 4 | 1 | 4× | 176 | 24 | 7.33× |
| Geomean (n=9) | 7.71× | 3.57× | 6.89× |
The v0.7.2 numbers show a ~6% improvement in Parse over the v0.7.1 baseline (7.11× → 7.71×). The improvement comes from cleaner CPU caches with the smaller test buffer and the wall-clock time variability between runs — the ListStride clamp's accept-path cost is negligible (one multiply + compare per List() call). Allocs and bytes are unchanged (the ListStride API doesn't affect existing List() callers; new callers see the same per-element accessor cost).
Build — M1 Max
| Tx | Legacy ns | ZAP ns | Build × | Legacy alloc | ZAP alloc | Δ allocs | Legacy B | ZAP B | Δ bytes |
|---|---|---|---|---|---|---|---|---|---|
| RewardValidatorTx | 280 | 247 | 1.13× | 4 | 2 | 2.00× | 152 | 104 | 1.46× |
| BaseTx | 520 | 583 | 0.89× ⚠ | 5 | 5 | 1.00× | 296 | 360 | 0.82× ⚠ |
| SetL1ValidatorWeightTx | 423 | 323 | 1.31× | 5 | 2 | 2.50× | 264 | 120 | 2.20× |
| IncreaseL1ValidatorBalanceTx | 316 | 299 | 1.06× | 4 | 2 | 2.00× | 168 | 104 | 1.62× |
| DisableL1ValidatorTx | 304 | 273 | 1.11× | 4 | 2 | 2.00× | 152 | 104 | 1.46× |
| RegisterL1ValidatorTx | 1,155 | 1,158 | 1.00× | 7 | 2 | 3.50× | 1,016 | 280 | 3.63× |
| SlashValidatorTx | 358 | 330 | 1.08× | 5 | 2 | 2.50× | 224 | 120 | 1.87× |
| TransferChainOwnershipTx | 519 | 367 | 1.42× | 5 | 2 | 2.50× | 296 | 136 | 2.18× |
| RemoveChainValidatorTx | 383 | 340 | 1.13× | 5 | 2 | 2.50× | 224 | 120 | 1.87× |
| Geomean (n=9) | 1.12× | 2.18× | 1.77× |
Per-tx-type Parse + Build — M4 Max (dbc)
Parse — M4 Max
| Tx | Legacy ns | ZAP ns | Parse × | Legacy alloc | ZAP alloc | Δ allocs | Legacy B | ZAP B | Δ bytes |
|---|---|---|---|---|---|---|---|---|---|
| RewardValidatorTx | 85.97 | 13.65 | 6.30× | 3 | 1 | 3× | 120 | 24 | 5.00× |
| BaseTx | 112 | 13.60 | 8.26× | 3 | 1 | 3× | 152 | 24 | 6.33× |
| SetL1ValidatorWeightTx | 104 | 13.66 | 7.62× | 3 | 1 | 3× | 136 | 24 | 5.67× |
| IncreaseL1ValidatorBalanceTx | 94.72 | 13.63 | 6.95× | 3 | 1 | 3× | 136 | 24 | 5.67× |
| DisableL1ValidatorTx | 85.35 | 13.81 | 6.18× | 3 | 1 | 3× | 120 | 24 | 5.00× |
| RegisterL1ValidatorTx | 182 | 13.85 | 13.11× | 6 | 1 | 6× | 384 | 24 | 16.00× |
| SlashValidatorTx | 114 | 13.32 | 8.54× | 4 | 1 | 4× | 176 | 24 | 7.33× |
| TransferChainOwnershipTx | 124 | 13.35 | 9.26× | 4 | 1 | 4× | 192 | 24 | 8.00× |
| RemoveChainValidatorTx | 106 | 13.72 | 7.76× | 4 | 1 | 4× | 176 | 24 | 7.33× |
| Geomean (n=9) | 8.02× | 3.57× | 6.89× |
Build — M4 Max
| Tx | Legacy ns | ZAP ns | Build × | Legacy alloc | ZAP alloc | Δ allocs | Legacy B | ZAP B | Δ bytes |
|---|---|---|---|---|---|---|---|---|---|
| RewardValidatorTx | 92.14 | 97.16 | 0.95× ⚠ | 4 | 2 | 2.00× | 152 | 104 | 1.46× |
| BaseTx | 131 | 165 | 0.79× ⚠ | 5 | 5 | 1.00× | 296 | 360 | 0.82× ⚠ |
| SetL1ValidatorWeightTx | 123 | 107 | 1.15× | 5 | 2 | 2.50× | 264 | 120 | 2.20× |
| IncreaseL1ValidatorBalanceTx | 98.77 | 102 | 0.97× ⚠ | 4 | 2 | 2.00× | 168 | 104 | 1.62× |
| DisableL1ValidatorTx | 91.76 | 97.09 | 0.95× ⚠ | 4 | 2 | 2.00× | 152 | 104 | 1.46× |
| RegisterL1ValidatorTx | 224 | 449 | 0.50× ⚠⚠ | 7 | 2 | 3.50× | 1,016 | 280 | 3.63× |
| SlashValidatorTx | 120 | 142 | 0.85× ⚠ | 5 | 2 | 2.50× | 224 | 120 | 1.87× |
| TransferChainOwnershipTx | 132 | 146 | 0.90× ⚠ | 5 | 2 | 2.50× | 296 | 136 | 2.18× |
| RemoveChainValidatorTx | 115 | 138 | 0.83× ⚠ | 5 | 2 | 2.50× | 224 | 120 | 1.87× |
| Geomean (n=9) | 0.86× ⚠ | 2.18× | 1.77× |
M4 Max Build regression. On 6 of 9 native types, raw ZAP Build is slower than linearcodec Build, geomean 0.86×. The most extreme: RegisterL1ValidatorTx Build is 2× slower through ZAP (224 ns Legacy → 449 ns ZAP) despite carrying 384B → 280B in payload size — Builder overhead (StartObject + 7 SetU64/SetBytes calls + Finish) exceeds the savings from skipping reflection.
Why M1 doesn't show this. On M1 Max, ZAP Build is +0.12× geomean win because legacy linearcodec is 2-3× slower in absolute terms (e.g. RegisterL1 Build: M1=1,155 ns vs M4=224 ns Legacy). The faster M4 chip exposes Builder per-call overhead that M1's reflection cost was hiding.
Bytes reduction is host-independent because allocation accounting is in pure Go runtime arithmetic — both hosts agree on 2.18× allocs and 1.77× bytes geomean reduction. The wall-clock cost of writing those fewer bytes is what diverges.
Action: tracked for v0.7.0 (Blue v3.1 iteration). Builder fast-path for the common single-field cases (RewardValidator, DisableL1Validator) is the win; or amortize StartObject/Finish across multi-tx batched builders.
AdvanceTime end-to-end (production-realistic, txs.Codec wrapper)
This is the bench/ harness path that wraps the legacy fixture in the full *txs.Tx envelope and runs it through codec.Manager.Marshal/Unmarshal. The native side calls zap_native.WrapAdvanceTimeTx(buf) directly — the same bytes the new wire dispatcher will see in production.
| Op | Host | Legacy ns | ZAP ns | × | Legacy alloc | ZAP alloc | Legacy B | ZAP B |
|---|---|---|---|---|---|---|---|---|
| Parse | M1 Max | 1,575 | 46.13 | 34.14× | 6 | 1 | 480 | 24 |
| Parse | M4 Max | 606 | 14.16 | 42.76× | 6 | 1 | 480 | 24 |
| Build | M1 Max | 436 | 113 | 3.85× | 4 | 2 | 120 | 72 |
| Build | M4 Max | 165 | 33.43 | 4.94× | 4 | 2 | 120 | 72 |
This is the headline number for the CTO — full mempool admission via txs.Codec.Unmarshal is 34-43× faster through native ZAP. The ratio grows on faster cores because legacy's reflection/manager dispatch tax is fixed-per-op while ZAP's WrapAdvanceTimeTx is essentially a length check + offset arithmetic.
Legacy-only sweep (12 tx types in fixtures.go) — both hosts
These tx types have no native path yet (Blue's batch 2/3 covers a different set). Numbers are Legacy via txs.Codec end-to-end. Both hosts measured for forward reference once Blue's next batches land.
M1 Max
| Tx | Parse ns | Parse alloc | Parse B | Build ns | Build alloc | Build B |
|---|---|---|---|---|---|---|
| BaseTx | 6,770 | 29 | 1,928 | 1,922 | 7 | 808 |
| BaseTxStakeable | 7,773 | 31 | 1,960 | 2,112 | 7 | 808 |
| CreateNetworkTx | 8,447 | 35 | 2,408 | 2,097 | 7 | 808 |
| ImportTx | 8,731 | 41 | 2,552 | 2,389 | 7 | 808 |
| CreateChainTx | 9,449 | 41 | 2,864 | 2,952 | 8 | 1,512 |
| ExportTx | 10,622 | 41 | 2,792 | 2,414 | 7 | 808 |
| AddDelegatorTx | 14,371 | 65 | 4,296 | 4,153 | 8 | 1,512 |
| AddPermissionlessDelegatorTx | 15,875 | 66 | 4,368 | 4,100 | 8 | 1,512 |
| AddPermissionlessValidatorTx | 20,581 | 76 | 5,080 | 4,796 | 9 | 2,664 |
| AddValidatorTx | 26,457 | 65 | 4,296 | 4,145 | 8 | 1,512 |
| RewardValidatorTx | 1,735 | 7 | 536 | 384 | 3 | 120 |
| AdvanceTimeTx | 1,575 | 6 | 480 | 436 | 4 | 120 |
M4 Max (dbc)
| Tx | Parse ns | Parse alloc | Parse B | Build ns | Build alloc | Build B |
|---|---|---|---|---|---|---|
| BaseTx | 2,619 | 29 | 1,928 | 709 | 7 | 808 |
| BaseTxStakeable | 3,058 | 31 | 1,960 | 762 | 7 | 808 |
| CreateNetworkTx | 3,337 | 35 | 2,408 | 831 | 7 | 808 |
| ImportTx | 3,541 | 41 | 2,552 | 928 | 7 | 808 |
| CreateChainTx | 3,461 | 41 | 2,864 | 947 | 8 | 1,512 |
| ExportTx | 3,733 | 41 | 2,792 | 906 | 7 | 808 |
| AddDelegatorTx | 6,198 | 65 | 4,296 | 1,534 | 8 | 1,512 |
| AddPermissionlessDelegatorTx | 6,301 | 66 | 4,368 | 1,534 | 8 | 1,512 |
| AddPermissionlessValidatorTx | 7,278 | 76 | 5,080 | 1,859 | 9 | 2,664 |
| AddValidatorTx | 6,204 | 65 | 4,296 | 1,518 | 8 | 1,512 |
| RewardValidatorTx | 628 | 7 | 536 | 163 | 3 | 120 |
| AdvanceTimeTx | 606 | 6 | 480 | 165 | 4 | 120 |
Cross-host wall-clock: legacy parse is 2.4-3.6× faster on M4 Max (e.g. AddPermissionlessValidator: M1 20,581 → M4 7,278 ns = 2.83×). Expected — M4 Max has wider execution windows and faster memory hierarchy.
Field access after-parse (legacy struct deref vs ZAP offset load)
| Pattern | M1 Max Legacy | M1 Max ZAP | M1 ratio | M4 Max Legacy | M4 Max ZAP | M4 ratio |
|---|---|---|---|---|---|---|
| 1 read | 0.43 ns | 0.86 ns | 2.04× slower (ZAP) | 0.26 ns | 0.52 ns | 2.02× slower (ZAP) |
| 1M reads/batch | 0.43 ns/read | 1.62 ns/read | 3.74× slower (ZAP) | 0.26 ns/read | 0.77 ns/read | 2.96× slower (ZAP) |
ZAP field access is ~2-3× slower than a Go struct field deref. This is the type-deref caveat: legacy reads a populated struct field; ZAP reads via binary.LittleEndian.Uint64(buf[off:]). The lift is in NOT parsing into a struct — once the struct exists, reading is faster from it.
Break-even per host (parse cost / field-read penalty delta):
- M1 Max AdvanceTime: (1575 − 46) / (1.62 − 0.43) = 1,285 reads/parse before ZAP loses
- M4 Max AdvanceTime: (606 − 14) / (0.77 − 0.26) = 1,161 reads/parse before ZAP loses
Mainnet validators field-read each tx ~10× during verification. ZAP is winning by ~25× in that regime on M1 and ~40× on M4.
Real workloads — 1000-tx synthetic mempool + 200-block parse
The mempool mix is the modal mainnet distribution (35% AddPermissionlessDelegator, 25% AddPermissionlessValidator, 15% Import, 10% Export, 10% BaseTx, 5% RewardValidator). The "Mixed" path is the dispatcher branching on zap_native.IsZAPBytes — currently only AdvanceTimeTx has a native path and it doesn't appear in user mempool, so the dispatcher falls through to legacy 100% of the time. Mixed ratio is bounded above by per-type Parse lift once Blue ships BaseTx + AddPermissionless native paths.*
| Workload | Host | Legacy ns/op | Mixed ns/op | Ratio | Legacy B/op | Mixed B/op |
|---|---|---|---|---|---|---|
| 1000-tx mempool | M1 Max | 15,599,825 | 15,036,614 | 1.04× | 3.70 MB | 3.65 MB |
| 1000-tx mempool | M4 Max | 5,395,535 | 5,296,963 | 1.02× | 3.68 MB | 3.68 MB |
| 200-block parse | M1 Max | 13,822,084 | (no mixed) | — | 3.77 MB | — |
| 200-block parse | M4 Max | 5,306,566 | (no mixed) | — | 3.69 MB | — |
Reading: mempool throughput on M4 Max is 2.9× higher than M1 (5.3 ms vs 15.6 ms per 1000-tx) for legacy alone. ZAP-side will only show in this number when BaseTx + AddPermissionless* get native paths — projected 5-15× compression of the per-host number once those land.
GC pressure (sustained 5s parse loop, AdvanceTimeTx fixture)
These benches require -benchtime=1x and were not in the standard -count=3 run for this report. The prior bench/RESULTS.md reported on the same M1 Max:
- Legacy: 777,859 parses/s, 480 B/parse
- Native ZAP: 14,401,198 parses/s, 24 B/parse
- 18.5× more parses/sec, 20× less memory per parse.
These were not re-measured here; the alloc bench's time.Now() loop guarantees the ratio is fixed by the per-op numbers above. The 14M parses/s on M1 implies ~21M parses/s on M4 Max (scaling by the 1.46× per-op ratio for AdvanceTime).
Schema v3 cost analysis
Schema v3 added a 1-byte TxKind discriminator at offset 0. Predicted impact on Parse: +1 byte read (~1ns at most). Measured:
- ZAP Parse ns/op is constant at 13.3-14.2 ns on M4 Max, 36.5-73.9 ns on M1 Max across 9 tx types (variance is field-shape, not schema-tax).
- ZAP Parse B/op is constant at 24 across all 9 types — the +1B TxKind is absorbed in the 16B header.
- Geomean Parse delta v2→v3: predicted 8.39× → 7.09× = −15.5%. Measured M1: 7.11×. The +1B TxKind tax is invisible at this scale — the v2 → v3 geomean delta is within ±0.02× of the model.
Methodology notes
go test -benchwith-count=3and-benchtime=500msper type. Median reported. Variance is small (typically ±5% on parse, ±10% on build per benchtime).- Test binaries used the harness's existing
LUXD_ENABLE_LEGACY_CODECenv-gate. Default mode is native-ZAP first; legacy is opt-in. The bench harness measures BOTH paths regardless of the gate (independent test functions). Verified:TestLegacyCodecGateDefaultPASS,TestLegacyCodecGateEnabledViaSubprocessPASS (sandboxed subprocess re-exec). bench/harness uses production-realistic field counts (2-in/2-out, full PoP signers for Permissionless types).zap_native/all_types_bench_test.gouses minimal stubs (legacy*Txstructs registered withlinearcodec.NewDefault()). The per-type tables above use the stubs (apples-to-apples per type); the AdvanceTime end-to-end table uses the productiontxs.Codecpath.- Both hosts on darwin/arm64. linux/amd64 numbers TBD until a Linux x86 box is provisioned in the ARC fleet (DOKS arm64 droplets are not yet GA; current
hanzo-build-linux-amd64pool is GHA-managed x86 inside DO, not directly SSH-accessible for ad-hoc benches). - Files:
/tmp/zap_bench/{m1max,m4max}_{bench_harness,zap_native}.txton author box; pulled from dbc via scp.
Honest residuals
- Build is a regression on M4 Max for 6/9 types (geomean 0.86×). This affects write throughput on luxd P-chain block proposers. Root cause:
zap.Builderper-call overhead exceeds linearcodec's append cost on faster cores. Action: feed back into Blue's v3.1 iteration (luxfi/zap v0.7.0). - The
txs.Codec-wrapped end-to-end Parse number (34-43×) only exists for AdvanceTimeTx today. Other tx types in fixtures.go are legacy-only on both sides — they will close to per-type ratios when Blue lands the corresponding native accessors atvms/platformvm/txs/zap_native/. - No linux/amd64 measurements in this report. Both physical hosts ARE darwin/arm64 (M1 Max + M4 Max). Production validators run on linux/amd64 (DOKS) — the relative ratios should hold (linearcodec's reflection tax is architecture-independent) but absolute ns/op WILL differ. Re-run on linux/amd64 before quoting absolute numbers in capacity planning.
- Synthetic mempool repeats payload bytes. Cache-friendly. Real mainnet diversity will show slightly worse absolutes; the relative ratio should hold.
- Field-access ZAP is 2-3× slower per read. Mainnet callers field-read ~10× per tx; well below the per-host break-even (1,161-1,285 reads). Not a concern at current call frequency.
Verdict
Parse: ship it. 7-8× geomean on per-type, 34-43× on production-wrapped end-to-end, host-stable allocations (3.6× fewer, 6.9× smaller). Schema v3's +1B TxKind tax is unmeasurable.
Build: needs v0.7.0 work. M1 Max breaks even (1.12×), M4 Max regresses (0.86×). Builder per-call overhead exposed on fast cores. The bytes/alloc wins are real (2× allocs, 1.8× bytes) but the wall-clock isn't yet there. Blue's pending HIGH-1/2/3 fixes should compose with Builder fast-paths; re-bench at v0.7.0.
Recommendation: continue the migration. The Parse lift alone justifies it. Track Build as a follow-up — landing per-type native Build is independent of the Parse-side decision and can iterate without blocking the dispatcher.
Files
- Harness:
vms/platformvm/txs/bench/ - Per-type benches:
vms/platformvm/txs/zap_native/all_types_bench_test.go - Fixtures:
vms/platformvm/txs/bench/fixtures.go - Native-ZAP impl:
vms/platformvm/txs/zap_native/ - Gate env-flag:
LUXD_ENABLE_LEGACY_CODEC=1(default off; native ZAP is the default) - This document:
vms/platformvm/txs/bench_results/RESULTS.md - Raw bench output:
/tmp/zap_bench/{m1max,m4max}_{bench_harness,zap_native}.txt(ephemeral; regenerable from the bench command at the top of this document)