Skip to content

Commit b170077

Browse files
committed
B8 measured, 0025 decides: the boxed lane beats JVM boxing, fixnums canonical
Predictions first (0002), and they were wrong in the informative way: the boxed-i64 lane came in 5-15x cheaper than predicted -- 1.38 ns/op on V8, 1.99 on wasmtime, against JVM boxed 2.96 in the same run -- so fib's n = 46..92 domain needs no rescue design. Mixed-representation dispatch is free on V8 and +0.51 on wasmtime; the canonicalization probe is free on V8 and +0.19 on wasmtime, so 0025 decides canonicalization by semantics: a boxed value that fits i31 must not exist, keeping identical? divergence #1 as numbered, and = and hash single-cased on the hot range. B8i answered 0022 C's falsifier: a real allocating slow path costs B3's fast path +~3% on V8 (reproduced across two runs) and nothing wasmtime can measure -- the adversarial review caught the first draft quoting a +5% wasmtime figure whose sign flipped on a rerun, the 0013 failure family, and the honest sentence replaced it in both files. The throw representation stays open with its check now priced; the taken arm is the remaining out-of-contract edge. bench/s0/README also stops claiming B6 is unwritten. Claude-Session: https://claude.ai/code/session_01XF5Hfq4Ca2N2XYEzWQQuHt
1 parent 9b3ddd8 commit b170077

6 files changed

Lines changed: 367 additions & 12 deletions

File tree

bench/s0/README.md

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ first run. Do not read them until you have your own expectation.
1717
| **B3** | `b3_arith.wat` | `i31` fast-path add vs a boxed slow path | Whether boxed arithmetic can be cheap |
1818
| **B4** | `b4_cast.wat` | `ref.cast` by target depth *and* by input variety, against a no-cast floor | How to shape the type graph |
1919
| **B5/B5x/B7** | (in `b2_megamorphic.wat`) | guarded call-site specialisation, and the hit rate at which it starts paying | Whether the server lane is reachable at all |
20+
| **B6** | `b6_boundary.wat` | lowering a 4 KB aggregate across the component boundary, against `memory.copy` | What `0007`'s finding costs, and the representation lever (`0008`) |
21+
| **B8** | `b8_boxed.wat` | the boxed-i64 lane: allocation per op, mixed dispatch, canonicalization probe, and what a real slow path costs B3's fast path | The numeric representation past i31 (`0022` C, `0025`) |
2022

2123
Each runs on **both** `node` (V8: speculative inlining) and `wasmtime`
2224
(no adaptive tier). Both numbers are reported; neither is "the" answer.
@@ -71,15 +73,13 @@ errors.
7173

7274
## Status
7375

74-
**All six measured** — B1–B4 as contracted, plus B5 and B7, which B1's and B2's
75-
findings forced. Numbers, controls and what each means are in
76-
`doc/design/0002-measure-first.md`; the verdict is `doc/design/0010-*`.
76+
**All measured** — B1–B4 as contracted, B5 and B7 which B1's and B2's findings
77+
forced, B6 (the component boundary, measured 2026-07-30), and B8 (the
78+
boxed-i64 lane, S3's numeric-representation benchmark). Numbers, controls and
79+
what each means are in `doc/design/0002-measure-first.md`; the S0 verdict is
80+
`doc/design/0010-*`, the B8 decision `doc/design/0025-*`.
7781

7882
B5 and B7 live inside `b2_megamorphic.wat` rather than their own files so that
7983
their type graph and rings are provably identical to the baselines they are
8084
compared against. That is deliberate: comparing across files is how three of
8185
this project's controls went wrong.
82-
83-
**B6 — the component boundary crossing — is not written**, and is the one
84-
thing the project's own pitch rests on that nothing here measures. See
85-
`doc/status.md`.

bench/s0/b8_boxed.wat

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
;; B8 — the boxed-i64 lane (0022 C): what fib's n = 46…92 domain costs.
2+
;;
3+
;; Predictions are in doc/design/0002-measure-first.md, written before the
4+
;; run. Every variant walks B3's dependency chain — acc = acc + one, n
5+
;; times, result consumed — but on boxed i64 values. The accumulator
6+
;; starts at 2^30, one past i31's maximum, so every value in flight is
7+
;; outside i31: the mixed dispatch always takes the boxed arm and the
8+
;; canonicalization probe never fires, yet both branches are real code
9+
;; the engine must keep. The answer is 2^30 + n, which no empty loop
10+
;; produces.
11+
;;
12+
;; The overflow-check arm is (unreachable) — the throw representation is
13+
;; open (0022 C) — and is never reached: values stay far below 2^63.
14+
15+
(module
16+
(type $Box (struct (field $v i64)))
17+
18+
;; B8k — the floor: operands statically boxed, no dispatch.
19+
;; load, load, add, overflow check, allocate.
20+
(func (export "b8_known") (param $n i32) (result i32)
21+
(local $acc (ref $Box))
22+
(local $one (ref $Box))
23+
(local $i i32)
24+
(local $a i64)
25+
(local $b i64)
26+
(local $t i64)
27+
(local.set $acc (struct.new $Box (i64.const 1073741824)))
28+
(local.set $one (struct.new $Box (i64.const 1)))
29+
(local.set $i (local.get $n))
30+
(block $done
31+
(loop $l
32+
(br_if $done (i32.eqz (local.get $i)))
33+
(local.set $a (struct.get $Box $v (local.get $acc)))
34+
(local.set $b (struct.get $Box $v (local.get $one)))
35+
(local.set $t (i64.add (local.get $a) (local.get $b)))
36+
;; signed-overflow check: ((a^t)&(b^t)) < 0 means the signs prove
37+
;; overflow — the arm is the open throw lane, untaken here.
38+
(if (i64.lt_s (i64.and (i64.xor (local.get $a) (local.get $t))
39+
(i64.xor (local.get $b) (local.get $t)))
40+
(i64.const 0))
41+
(then (unreachable)))
42+
(local.set $acc (struct.new $Box (local.get $t)))
43+
(local.set $i (i32.sub (local.get $i) (i32.const 1)))
44+
(br $l)))
45+
(i32.wrap_i64 (struct.get $Box $v (local.get $acc))))
46+
47+
;; B8b — the lane as a compiler emits it: operands arrive as (ref null
48+
;; eq), representation decided per operand at run time. The i31 arm is
49+
;; real and never taken at these values.
50+
(func (export "b8_mixed") (param $n i32) (result i32)
51+
(local $acc (ref null eq))
52+
(local $one (ref null eq))
53+
(local $i i32)
54+
(local $a i64)
55+
(local $b i64)
56+
(local $t i64)
57+
(local.set $acc (struct.new $Box (i64.const 1073741824)))
58+
(local.set $one (struct.new $Box (i64.const 1)))
59+
(local.set $i (local.get $n))
60+
(block $done
61+
(loop $l
62+
(br_if $done (i32.eqz (local.get $i)))
63+
(local.set $a (if (result i64) (ref.test (ref i31) (local.get $acc))
64+
(then (i64.extend_i32_s (i31.get_s (ref.cast (ref i31) (local.get $acc)))))
65+
(else (struct.get $Box $v (ref.cast (ref $Box) (local.get $acc))))))
66+
(local.set $b (if (result i64) (ref.test (ref i31) (local.get $one))
67+
(then (i64.extend_i32_s (i31.get_s (ref.cast (ref i31) (local.get $one)))))
68+
(else (struct.get $Box $v (ref.cast (ref $Box) (local.get $one))))))
69+
(local.set $t (i64.add (local.get $a) (local.get $b)))
70+
(if (i64.lt_s (i64.and (i64.xor (local.get $a) (local.get $t))
71+
(i64.xor (local.get $b) (local.get $t)))
72+
(i64.const 0))
73+
(then (unreachable)))
74+
(local.set $acc (struct.new $Box (local.get $t)))
75+
(local.set $i (i32.sub (local.get $i) (i32.const 1)))
76+
(br $l)))
77+
(i32.wrap_i64 (struct.get $Box $v (ref.cast (ref $Box) (local.get $acc)))))
78+
79+
;; B8c — B8b plus the canonicalization probe: a result that fits i31
80+
;; would re-box as i31 (0022 C's open question). Untaken at these
81+
;; values, but the branch and both arms are real.
82+
(func (export "b8_canon") (param $n i32) (result i32)
83+
(local $acc (ref null eq))
84+
(local $one (ref null eq))
85+
(local $i i32)
86+
(local $a i64)
87+
(local $b i64)
88+
(local $t i64)
89+
(local.set $acc (struct.new $Box (i64.const 1073741824)))
90+
(local.set $one (struct.new $Box (i64.const 1)))
91+
(local.set $i (local.get $n))
92+
(block $done
93+
(loop $l
94+
(br_if $done (i32.eqz (local.get $i)))
95+
(local.set $a (if (result i64) (ref.test (ref i31) (local.get $acc))
96+
(then (i64.extend_i32_s (i31.get_s (ref.cast (ref i31) (local.get $acc)))))
97+
(else (struct.get $Box $v (ref.cast (ref $Box) (local.get $acc))))))
98+
(local.set $b (if (result i64) (ref.test (ref i31) (local.get $one))
99+
(then (i64.extend_i32_s (i31.get_s (ref.cast (ref i31) (local.get $one)))))
100+
(else (struct.get $Box $v (ref.cast (ref $Box) (local.get $one))))))
101+
(local.set $t (i64.add (local.get $a) (local.get $b)))
102+
(if (i64.lt_s (i64.and (i64.xor (local.get $a) (local.get $t))
103+
(i64.xor (local.get $b) (local.get $t)))
104+
(i64.const 0))
105+
(then (unreachable)))
106+
(local.set $acc
107+
(if (result (ref null eq))
108+
(i64.eq (local.get $t)
109+
(i64.shr_s (i64.shl (local.get $t) (i64.const 33)) (i64.const 33)))
110+
(then (ref.i31 (i32.wrap_i64 (local.get $t))))
111+
(else (struct.new $Box (local.get $t)))))
112+
(local.set $i (i32.sub (local.get $i) (i32.const 1)))
113+
(br $l)))
114+
(i32.wrap_i64 (struct.get $Box $v (ref.cast (ref $Box) (local.get $acc)))))
115+
116+
;; B8i — B3's i31 fast path with a *real* allocating slow path where B3
117+
;; had (unreachable). Inputs are i31 (0..n), the slow path is never
118+
;; taken; the question is what its presence costs the fast path.
119+
(func $slow-add (param $a (ref null eq)) (param $b (ref null eq)) (result (ref null eq))
120+
(local $x i64)
121+
(local $y i64)
122+
(local $t i64)
123+
(local.set $x (if (result i64) (ref.test (ref i31) (local.get $a))
124+
(then (i64.extend_i32_s (i31.get_s (ref.cast (ref i31) (local.get $a)))))
125+
(else (struct.get $Box $v (ref.cast (ref $Box) (local.get $a))))))
126+
(local.set $y (if (result i64) (ref.test (ref i31) (local.get $b))
127+
(then (i64.extend_i32_s (i31.get_s (ref.cast (ref i31) (local.get $b)))))
128+
(else (struct.get $Box $v (ref.cast (ref $Box) (local.get $b))))))
129+
(local.set $t (i64.add (local.get $x) (local.get $y)))
130+
(if (i64.lt_s (i64.and (i64.xor (local.get $x) (local.get $t))
131+
(i64.xor (local.get $y) (local.get $t)))
132+
(i64.const 0))
133+
(then (unreachable)))
134+
(if (result (ref null eq))
135+
(i64.eq (local.get $t)
136+
(i64.shr_s (i64.shl (local.get $t) (i64.const 33)) (i64.const 33)))
137+
(then (ref.i31 (i32.wrap_i64 (local.get $t))))
138+
(else (struct.new $Box (local.get $t)))))
139+
140+
(func (export "b8_slow_real") (param $n i32) (result i32)
141+
(local $acc (ref null eq))
142+
(local $one (ref null eq))
143+
(local $i i32)
144+
(local $x i32)
145+
(local $sum i32)
146+
(local.set $acc (ref.i31 (i32.const 0)))
147+
(local.set $one (ref.i31 (i32.const 1)))
148+
(local.set $i (local.get $n))
149+
(block $done
150+
(loop $l
151+
(br_if $done (i32.eqz (local.get $i)))
152+
(local.set $acc
153+
(block $joined (result (ref null eq))
154+
(drop
155+
(block $slow (result (ref null eq))
156+
(local.set $x
157+
(i31.get_s (br_on_cast_fail $slow (ref null eq) (ref i31) (local.get $acc))))
158+
(local.set $sum
159+
(i32.add (local.get $x)
160+
(i31.get_s (br_on_cast_fail $slow (ref null eq) (ref i31) (local.get $one)))))
161+
(br_if $slow (local.get $acc)
162+
(i32.ne (i32.shr_s (i32.shl (local.get $sum) (i32.const 1))
163+
(i32.const 1))
164+
(local.get $sum)))
165+
(br $joined (ref.i31 (local.get $sum)))))
166+
(call $slow-add (local.get $acc) (local.get $one))))
167+
(local.set $i (i32.sub (local.get $i) (i32.const 1)))
168+
(br $l)))
169+
(i31.get_s (ref.cast (ref i31) (local.get $acc)))))

bench/s0/run.clj

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,27 @@
244244
:what "floor: the same loop unboxed — a raw i32 add"
245245
:wat "bench/s0/b3_arith.wat" :export "bench_unboxed"
246246
:jvm "s0.jvm.b3" :variant "unboxed"
247+
:expect identity}
248+
;; B8 — the boxed-i64 lane (0022 C, predictions in 0002 before the
249+
;; run). Values sit at 2^30..2^30+n, outside i31, so the mixed
250+
;; dispatch always takes the boxed arm and the canonicalization probe
251+
;; never fires — both arms stay real code. No :jvm rows: the boxed
252+
;; baseline is B3's, rerun in the same invocation for comparability.
253+
{:id "B8k"
254+
:what "boxed-i64 floor: known-boxed operands — load, add, check, allocate"
255+
:wat "bench/s0/b8_boxed.wat" :export "b8_known"
256+
:expect #(+ 1073741824 %)}
257+
{:id "B8b"
258+
:what "the lane as compiled: mixed-representation dispatch on both operands"
259+
:wat "bench/s0/b8_boxed.wat" :export "b8_mixed"
260+
:expect #(+ 1073741824 %)}
261+
{:id "B8c"
262+
:what "B8b plus the canonicalization probe on every result (untaken)"
263+
:wat "bench/s0/b8_boxed.wat" :export "b8_canon"
264+
:expect #(+ 1073741824 %)}
265+
{:id "B8i"
266+
:what "B3's i31 fast path with a real allocating slow path present, untaken"
267+
:wat "bench/s0/b8_boxed.wat" :export "b8_slow_real"
247268
:expect identity}])
248269

249270
;; --- shelling out ----------------------------------------------------------

0 commit comments

Comments
 (0)