|
| 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