Skip to content

perf(signing): encode wide safe integers without BigInt in msgpack - #167

Merged
joeblau merged 1 commit into
mainfrom
perf/msgpack-wide-int
Oct 6, 2026
Merged

joeblau merged 1 commit into
mainfrom
perf/msgpack-wide-int

Conversation

@joeblau

@joeblau joeblau commented Oct 6, 2026

Copy link
Copy Markdown

Summary

MsgpackWriter.numberL1 sent every safe integer outside the int32/uint32 forms (>= 2^32 or < -2^31) through BigInt to get the 9-byte int64/uint64 form. Every live order ID is wider than 32 bits, so a cancel or modify batch allocated one BigInt per entry.

The wide arm now splits the double at 2^32 and writes the two halves with setUint32. The bytes are identical:

  • Exact split: dividing a safe integer by 2^32 only shifts the exponent, so Math.floor(value / 2 ** 32) gives the exact high word, rounded toward -∞ for negative values.
  • Two's complement: setUint32 reduces its argument mod 2^32 (ToUint32). A negative high word such as -1 becomes 0xffffffff, and the low word value % 2 ** 32 (which carries the sign) becomes value - floor(value / 2^32) * 2^32. Both halves land in two's complement, and -0 becomes 0.
  • Tag and byte order: the tag is 0xd3 for negative values and 0xcf otherwise, which matches the old bigint() path. DataView defaults to big-endian, which matches setBigInt64/setBigUint64 and the existing uint64() fast arm.
  • Classification: which values take the wide arm is unchanged, so int32/uint32 values and integral doubles beyond 2^53 encode exactly as before.

Main's new immutable-action cache (#166) skips re-encoding only for SDK-owned payloads hashed a second time. The first encode of every action still goes through this arm.

Measurements

All numbers are from an Apple M3 Max. Another workload was running on the machine at the same time, so every comparison is paired.

Existing suite (bun run perf, signing + transaction)

I ran 3 paired rounds of base d3126ae against head, alternating order (head/base, base/head, head/base), and compared them with .dev/perf/compare.ts:

  • signing: 26 compared, 0 faster, 0 regressed, 26 unchanged
  • transaction: 7 compared, 0 faster, 0 regressed, 7 unchanged

This is expected. No scenario in tests/perf has an integer wider than 32 bits: orders carry none, and the nonce already used the uint64() fast arm. I did not add a scenario there, because editing tests/perf changes the suite fingerprint and makes the Performance gate fail closed on purpose.

Wide-integer micro-benchmark (.dev/perf/msgpack_wide_int.ts, new)

The script builds the pre-change BigInt writer from the current _msgpack.ts source in a temp directory. It asserts identical preimage bytes, then times both writers in 15 paired rounds, rotating their order each round. Cancel and modify workloads use realistic order IDs (about 4.1e10). Each cell is the median per-action time from one representative run. The range column covers all repeated runs: 5 on Bun 1.4.0 and 4 on Node 24.10.0.

workload Bun BigInt → split Bun change (range) Node BigInt → split Node change (range)
1000 bare wide ints, encode 17.9 µs → 4.85 µs −71.6% … −73.5% 21.3 µs → 9.33 µs −56.0% … −57.4%
cancel ×100, encode 5.92 µs → 4.56 µs −23.3% … −31.4% 7.18 µs → 5.83 µs −17.5% … −18.7%
cancel ×100, full L1 hash (noble keccak) 28.0 µs → 26.4 µs −5.8% … −6.8% 44.5 µs → 42.9 µs −1.9% … −4.0%
cancel ×1000, encode 59.0 µs → 43.9 µs −23.8% … −32.6% 71.5 µs → 56.9 µs −15.9% … −18.5%
cancel ×1000, full L1 hash 257 µs → 242 µs −5.4% … −7.0% 415 µs → 400 µs −2.5% … −3.7%
batchModify ×100, encode 19.9 µs → 19.9 µs −5.0% … +3.7% 30.5 µs → 28.4 µs −5.0% … −6.5%
cancel ×1, encode 146 ns → 132 ns −5.6% … −13.5% 178 ns → 170 ns −1.0% … −5.9%
order ×100 (control, no wide ints) −2.7% … +0.5% −1.1% … +2.6%
  • Per-value saving: about 13 ns per wide integer on Bun and 11–12 ns on Node.
  • Effect on hashing: the win is large on encoding and shows up as 3–7% of a full L1 hash for cancel batches. That end-to-end gain is modest because keccak dominates.
  • Mixed payloads: batchModify, where a wide int is 1 of about 8 scalars per entry, is flat on Bun and about 5% faster on Node.
  • Outlier: one earlier Bun run, taken during a load spike on the shared machine (load average 16), showed no win on cancel ×100. Seven later runs on both runtimes were consistent.

Tests

tests/signing/msgpackWideInt.test.ts (new, 17 tests, about 40k assertions) pins the wide arm three independent ways:

  1. Differential: compares against @std/msgpack encoding BigInt(value) and against the in-house strict bigint path the arm replaced. Inputs cover:
    • every power of two from 2^31 to 2^53, ±2, in both signs
    • each 32-bit half at its edge values (low half 0, 1, 2^31−1, 2^31, 2^32−1, under high halves ±1, ±2, ±0x8000, ±0x1fffff/0x200000)
    • MAX_SAFE_INTEGER and MIN_SAFE_INTEGER
    • 20,000 seeded random wide integers
    • a 5,000+ entry cancel action, which forces unaligned offsets and buffer growth
  2. Classification: pins that 2^32−1 → ce, 2^32 → cf, −2^31 → d2, −2^31−1 → d3, ±2^53 → cb.
  3. Literals: checks hand-derived big-endian two's-complement bytes, for example −(2^53−1) → d3 ffe0000000000001 and −2^31−1 → d3 ffffffff7fffffff.
  4. Hashes and signatures: pins hashes (plain, and with vault + expiry) and mainnet/testnet ECDSA signatures for cancel, batchModify, modify, scheduleCancel, twapCancel and a negative-int64 action. The literals were captured from the pre-change code.

Mutation check: replacing floor with trunc, taking abs of the low half, always tagging cf, or writing little-endian each fails 6–16 of the new tests. A no-op control passes.

Test plan

  • bun run check
  • bun run test:offline: 2039 pass, 0 fail
  • bun run build
  • 3 paired bun run perf rounds (signing, transaction), with no regressions
  • .dev/perf/msgpack_wide_int.ts run 5× on Bun and 4× on Node

🤖 Generated with Claude Code

numberL1 widened every safe integer outside the int32/uint32 forms through
BigInt before writing the int64/uint64 form. Every live order ID is wider
than 32 bits, so cancel and modify batches allocated one BigInt per entry.

Split the double at 2^32 instead and write the two halves with setUint32.
The division by a power of two is exact for safe integers, and the unsigned
writes wrap negative halves to two's complement, so the bytes are identical
to the BigInt path.

Adds tests/signing/msgpackWideInt.test.ts, which pins the arm against
@std/msgpack and the previous BigInt path across every width boundary and
20000 random wide integers, against hand-derived literals, and through
hashes and signatures of representative L1 actions captured before the
change. Adds .dev/perf/msgpack_wide_int.ts, a paired micro-benchmark that
the fingerprinted suite cannot provide (it has no wide-integer workload).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@joeblau
joeblau merged commit 4b12ea4 into main Oct 6, 2026
4 checks passed
@joeblau
joeblau deleted the perf/msgpack-wide-int branch October 6, 2026 23:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant