Skip to content

Commit d3fdb6b

Browse files
feat(bb): BY field inversion + tree-reduce MSM design
Phase 1 LANDED — BY safegcd inversion (fr_inv_by_a, Option A: 20×13-bit, BATCH=26, carry-free apply_matrix): - Production swap-in: wgsl/cuzk/batch_inverse{,_parallel}.template.wgsl call fr_inv_by_a - 1.5× faster than legacy fr_inv (Pornin K=12) at chained-inverse bench - ~8% MSM wall reduction at logN=16 sanity check - TS port (cuzk/bernstein_yang.ts, bernstein_yang_a.ts) + Jest tests (24 passing) - WGSL impls: wgsl/field/by_inverse{,_a}.template.wgsl + wgsl/bigint/bigint_by.template.wgsl Phase 2 EXPLORATORY — multi-window pooled batch_inverse + multi-window BPR: - WPB plumbing in batch_inverse_parallel + dispatch_args + batch_affine.ts - Default WPB=1 (= legacy behavior, no perf change) - BPR_WINDOWS_PER_BATCH knob in bpr_bn254.template.wgsl - Empirical: pooling without growing WG count gives 0% gain — design needs restructure Standalone bench infrastructure: - bench-divsteps, bench-apply-matrix, bench-fr-inv, bench-batch-affine - Each with HTML page + TS dispatcher + Playwright runner under dev/msm-webgpu/scripts/ - profile-sanity.mjs for per-pass GPU time breakdown on the Quick Sanity Check Tree-reduce design (Stage B) for autonomous remote execution: - .claude/plans/msm-tree-reduce.md — full design (adaptive batch sizing, analytical slice partition, 2 distinct phase kernels) - .claude/plans/remote-agent-brief.md — remote agent execution brief Co-authored with Claude.
1 parent 400d0c1 commit d3fdb6b

40 files changed

Lines changed: 12234 additions & 356 deletions

.claude/plans/msm-tree-reduce.md

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
# Stage B — Tree-reduce per bucket with adaptive batch sizing
2+
3+
> Replaces the current SMVP round-loop (`smvp_batch_affine_gpu` + 5 shaders) with
4+
> a tree-reduce structure that scales logarithmically in max bucket population
5+
> instead of linearly. Designed for skewed real-world ZK workloads where the
6+
> current round-loop's MAX_ROUNDS bound is dominated by a few heavy buckets.
7+
8+
## Constants
9+
10+
```
11+
SWEET_B = 1024 // peak per-pair throughput (24.4 ns/pair from bench)
12+
MIN_B = 32 // floor: TPB=32, 1 SIMD group, no cross-SIMD barriers, 1.56× sweet cost
13+
TARGET_THREADS = 40_000 // Apple Silicon (M-series Pro/Max) resident thread budget
14+
TPB_DEFAULT = 64
15+
TPB_MIN_B = 32 // matches Apple's SIMD group width
16+
MAX_PHASES = 10 // recursion safety cap; pre-pass usually computes exact depth
17+
```
18+
19+
## Adaptive batch sizing
20+
21+
```
22+
function pickBatch(total_adds):
23+
candidate_B = total_adds / (TARGET_THREADS / TPB_DEFAULT) # = total_adds / 625
24+
25+
if candidate_B >= SWEET_B: # plenty of work
26+
return (SWEET_B, ceil(total_adds / SWEET_B), 64)
27+
elif candidate_B >= 64: # mid: largest pow-2 ≤ candidate
28+
B = floor_pow2(candidate_B)
29+
return (B, ceil(total_adds / B), 64)
30+
else: # tail: floor at MIN_B with TPB=32
31+
return (MIN_B, ceil(total_adds / MIN_B), 32)
32+
```
33+
34+
## Phase structure
35+
36+
### Pre-pass kernel (per phase)
37+
38+
One small dispatch. Inputs: sorted schedule (Phase 1) or partials buffer (Phase ≥2). For each entry:
39+
1. Determine WG slice membership via index-partitioning of `total_adds`.
40+
2. Flag if entry is first of its bucket in its WG slice.
41+
3. Flag if entry pairs with the next entry (same bucket, both in same WG slice).
42+
4. Emit pair (idx_a, idx_b) to per-WG pair-list slot.
43+
44+
Then host-side prefix sums produce:
45+
- `wg_pair_offset[]`, `wg_pair_count[]` — pair-list slice per WG
46+
- `wg_output_offset[]`, `wg_output_count[]` — output partials slice per WG
47+
- `wg_first_bucket[]` — bucket_id of first partial (for cross-WG boundary detection)
48+
- `max_pop_remaining` — if 0, no more dup buckets, terminate
49+
50+
No atomics. Per-entry kernel work is O(1); host prefix-sum is O(num_WGs) = O(1000) = trivial.
51+
52+
### Phase 1: per-WG slice batch-affine
53+
54+
One dispatch, `num_WGs` workgroups of TPB threads (from `pickBatch`). Inputs:
55+
- Bucket-sorted schedule
56+
- Pre-computed pair list
57+
58+
Each WG:
59+
1. Reads its `wg_pair_count` pre-computed pairs from `pair_list[wg_pair_offset[wg_id] : wg_pair_offset[wg_id] + wg_pair_count[wg_id]]`
60+
2. For each pair (a, b): loads `P = points[scalar_idx_a]` (with sign-flip per SCHEDULE_SIGN_BIT), `Q = points[scalar_idx_b]`, computes `delta_x = Q.x - P.x`
61+
3. Cooperative Phase A/B/C/D batch inverse (workgroup-shared scan, 1 fr_inv_by_a per WG)
62+
4. Per-pair: compute slope, R = P + Q
63+
5. Compaction: each pair's result is the partial for some bucket. Adjacent same-bucket pairs in the slice get combined into running sum; final partials written to `output[wg_output_offset[wg_id] + slot]` with `bucket_id` tag.
64+
65+
Single fr_inv per WG amortises over `wg_pair_count` ≈ B pair-adds.
66+
67+
### Phase ≥2: tree-reduce on partials
68+
69+
Re-sort phase 1's output by bucket_id globally (use existing transpose pattern — fast on GPU). Then re-run pre-pass + Phase 1 kernel on the bucket-sorted partials buffer.
70+
71+
Key difference from Phase 1: load is `partials[idx]` (a point) instead of `points[scalar_idx]` (a point with sign). Faster — no negation per load.
72+
73+
### Phase final: BPR / Horner
74+
75+
After all phases collapse buckets to 1 point each, hand off to existing BPR per window + Horner combine across windows. No change to those.
76+
77+
## Memory budget (logN=16, N_entries=1.1M, B_active≈272K)
78+
79+
- `pair_list` (Phase 1): ~825K pairs × 8 bytes = **6.6 MB**
80+
- `wg_*` arrays: 1000 WGs × ~5 × 4 bytes = **20 KB**
81+
- `output partials` (Phase 1): ~325K × 68 bytes = **22 MB**
82+
- `output partials` (Phase 2): ~80K × 68 bytes = **5.5 MB**
83+
- `output partials` (Phase ≥3): rapidly shrinking
84+
- Total scratch: **~35 MB**, well under any device limit
85+
86+
## Phase count (theoretical)
87+
88+
For typical 4-entries-per-bucket: `max_pop ≈ 16`, `log2(16/1024) ≤ 0`**Phase 1 alone resolves it**.
89+
90+
For skewed (heavy bucket pop=10K): `max_pop = 10000`, `log2(10000/1024) ≈ 4`**Phase 1 + 3-4 recursion levels**.
91+
92+
For uniform with sweet B fill: ~5 phases worst case.
93+
94+
vs current: 32 rounds. **6× fewer dispatches in typical case**.
95+
96+
## What we save
97+
98+
- **Dispatch overhead**: 5 phases × 3 dispatches each = 15 vs current 32 rounds × 3 = 96. Saves ~1.6 ms.
99+
- **Late-round amortisation collapse**: gone — adaptive sizing keeps per-WG batch at sweet through phase 5+.
100+
- **Pathological skew**: round count goes from O(max_pop) to O(log max_pop). **The big win for production ZK workloads.**
101+
102+
## Open implementation decisions
103+
104+
### Per-WG slice compaction (within phase 1 / phase ≥2)
105+
106+
Each WG's batch-affine produces `wg_pair_count` result points. These need to be COMPACTED into per-bucket partials (one partial per distinct bucket the WG touched).
107+
108+
Two sub-options:
109+
- **(a) Within-WG sequential merge**: after batch-affine, one thread walks the pair results, merges adjacent same-bucket results, writes final partials. ~B sequential adds (cheap, 63/64 threads idle but only briefly).
110+
- **(b) Within-WG segmented reduce**: parallel reduction grouping by bucket_id. More complex.
111+
112+
Going with **(a)** — simpler, the post-merge work is negligible compared to the batch-affine.
113+
114+
### Re-sort between phases
115+
116+
Phase k output is grouped by WG; Phase k+1 needs bucket-grouped input. Options:
117+
- **Transpose-style**: use existing `transpose_parallel_{count,scan,scatter}` infrastructure on the new layout. Adds ~3 dispatches per phase.
118+
- **Per-WG outputs are SORTED by bucket already** (since schedule was bucket-sorted). Just need a parallel MERGE of K sorted lists. O(N log K). Cheap.
119+
120+
Going with **merge** — fewer dispatches.
121+
122+
### Pair-list pre-pass
123+
124+
Single dispatch, one thread per schedule entry. Per entry:
125+
- Determine WG = `entry_idx * num_WGs / total_adds_density` (uses precomputed running-adds index)
126+
- Check predecessor entry: same bucket + same WG slice → emit pair (predecessor, self) to per-WG slot
127+
128+
Per-WG pair slot allocation: pre-pre-pass counts per-WG pair count, host prefix-sums.
129+
130+
So phase structure is actually:
131+
1. count-pass — count pairs per WG (1 atomic per WG, only num_WGs increments, low contention)
132+
2. host prefix-sum — compute pair_offsets
133+
3. fill-pass — write pairs to per-WG slots (one atomic per WG for local cursor, or use 2-thread cooperation to make atomicLess)
134+
4. phase 1 batch-affine
135+
136+
Actually atomics per-WG are TRIVIAL (one address per WG = no contention). Acceptable.
137+
138+
OR even cleaner: do the count-pass and fill-pass in ONE kernel with per-thread local pair-buffer in registers, flushed at WG boundary. Avoids any global atomics. Complexity vs simplicity tradeoff.
139+
140+
For first implementation: 2-pass pre-pass with per-WG-local atomics. Optimize later.
141+
142+
## Phase count termination
143+
144+
Pre-pass computes per-bucket population at phase 0. `MAX_PHASES = ceil(log2(max_pop / SWEET_B)) + 2`. Hard-coded; no runtime detection.
145+
146+
OR per-phase: if `num_distinct_buckets_output == num_distinct_buckets_input`, no reduction happened → done.
147+
148+
Use the formula-based approach (cleaner; hardcoded loop count). Loop:
149+
```
150+
for phase in 0..MAX_PHASES:
151+
if total_adds_remaining == 0: break
152+
dispatch pre-pass
153+
dispatch phase k
154+
re-sort output → input of next phase
155+
```
156+
157+
## What this does NOT include (per user scope)
158+
159+
- Duplicate stripping
160+
- Two bucket widths
161+
- Adaptive bucket width (c stays constant)
162+
- GLV scalar split
163+
164+
## Estimated impact
165+
166+
For UNIFORM data at logN=16 (current bench case):
167+
- ba_inverse_Σ: 10.8 ms → estimated 6-8 ms. Saving ~3 ms = 4% MSM wall.
168+
- Dispatch overhead saving: ~1.6 ms.
169+
170+
For SKEWED data (typical ZK workloads):
171+
- ba_inverse phase: estimated 3-5× faster due to log_2 vs linear in max_pop.
172+
- Could be ~25-40% MSM wall reduction. Numbers depend heavily on actual workload skew profile.

0 commit comments

Comments
 (0)