Barretenberg
The ZK-SNARK library at the core of Aztec
Loading...
Searching...
No Matches
scalar_multiplication_fast.cpp
Go to the documentation of this file.
2
14
15#include <algorithm>
16#include <atomic>
17#include <bit>
18#include <cstddef>
19#include <cstdint>
20#include <limits>
21#include <memory>
22#include <span>
23#include <vector>
24
25#ifdef __wasm_simd128__
26#include <wasm_simd128.h>
27#endif
28
30
32{
33#ifdef __wasm__
34 if (n_input <= (size_t{ 1 } << 11)) {
35 return 1;
36 }
37 if (n_input <= (size_t{ 1 } << 15)) {
38 return 2;
39 }
40 return 4;
41#else
42 static_cast<void>(n_input);
43 return 4;
44#endif
45}
46
47namespace round_parallel_detail {
48
49// Anonymous namespace gives all TU-private helpers in `round_parallel_detail` internal
50// linkage (clang-tidy `misc-use-anonymous-namespace`). It is briefly closed and reopened
51// around `pippenger_round_parallel_jacobian_fast`, which has external linkage via
52// `extern template` declarations in the header.
53namespace {
54
55// Bulk-copy a 64-byte affine point (BN254 / Grumpkin layout: 8 × uint64_t).
56// On wasm, V8 TurboFan compiles the default struct copy to 8 i64 loads/stores; explicit
57// v128 loads/stores halve that and roughly double throughput on random-gather access.
58// On native, std::memcpy of a constant-size struct already lowers to 4 × movdqu.
59template <typename AffineElement>
60[[gnu::always_inline]] inline void copy_affine64(AffineElement& dst, const AffineElement& src) noexcept
61{
62 static_assert(sizeof(AffineElement) == 64, "copy_affine64 requires 64-byte affine point");
64 "AffineElement must be trivially copyable for memcpy / SIMD bulk copy");
65#ifdef __wasm_simd128__
66 const auto* s = reinterpret_cast<const v128_t*>(&src);
67 auto* d = reinterpret_cast<v128_t*>(&dst);
68 const v128_t a = wasm_v128_load(s + 0);
69 const v128_t b = wasm_v128_load(s + 1);
70 const v128_t c = wasm_v128_load(s + 2);
71 const v128_t e = wasm_v128_load(s + 3);
72 wasm_v128_store(d + 0, a);
73 wasm_v128_store(d + 1, b);
74 wasm_v128_store(d + 2, c);
75 wasm_v128_store(d + 3, e);
76#else
77 std::memcpy(&dst, &src, sizeof(AffineElement));
78#endif
79}
80
81// Constantine signed-Booth window recoder (scalar + SIMD x4 paths) lives in
82// pippenger_constantine.hpp.
83
84// `choose_window_bits` and `build_window_schedule` are defined inline in
85// `pippenger_arena_layout.hpp` so the test suite can build identical schedules.
86// `MAX_SCHEDULE_WINDOWS` and `WindowSchedule` likewise live there.
87
88// Sentinel value for `msb_per_scalar[i]` when scalar i is zero. uint8_t fits the 254 valid msb
89// positions (0..253) plus this sentinel; matching `msb_hist` bin layout uses bin 0 = zero count
90// so callers index via `msb + 1` (with -1 → bin 0 for the zero case).
91inline constexpr uint8_t MSB_ZERO_SENTINEL = 255;
92
93// Batched-affine drain trigger. `tree_reduce_in_place` accumulates same-bucket pair
94// candidates into the per-thread `points_to_add` / `pair_dest` scratch and drains via a
95// single inversion + N-pair add when the queue hits this size. Sizing trade-off:
96// - higher = larger inversion amortisation = lower per-pair cost,
97// - lower = smaller scratch / less L1 pressure but more drain calls.
98// 256 was chosen empirically: keeps `points_to_add` (256 × 64 B = 16 KB) inside L1, is
99// well above the ~32-pair amortisation breakeven, and is the value the per-OS-thread
100// scratch buffers (`points_to_add`, `inversion_scratch`, `pair_dest`) are sized for.
101//
102// Deliberately a compile-time constant rather than a per-call parameter: the only sites
103// that ever passed a different value were chunks shorter than 256, where the early-drain
104// branch never fires anyway (the end-of-loop drain catches the residue). Keeping it
105// constexpr lets the compiler turn the per-iter `if (pair_count >= BATCH_CAPACITY)` into
106// a compare-against-immediate and fold the drain-trigger condition into the loop shape.
107// `BATCH_CAPACITY` is defined in `pippenger_arena_layout.hpp` so the layout struct can
108// reference it without depending on this TU.
109
110inline int msb_of_2limb(uint64_t lo, uint64_t hi) noexcept
111{
112 if (hi != 0) {
113 return 64 + 63 - __builtin_clzll(hi);
114 }
115 if (lo != 0) {
116 return 63 - __builtin_clzll(lo);
117 }
118 return -1;
119}
120
121// Accepts the raw `uint64_t[4]` `.data` of `uint256_t` / field elements directly.
122inline int msb_of_4limb(const uint64_t (&d)[4]) noexcept // NOLINT(cppcoreguidelines-avoid-c-arrays)
123{
124 if (d[3] != 0) {
125 return 192 + 63 - __builtin_clzll(d[3]);
126 }
127 if (d[2] != 0) {
128 return 128 + 63 - __builtin_clzll(d[2]);
129 }
130 if (d[1] != 0) {
131 return 64 + 63 - __builtin_clzll(d[1]);
132 }
133 if (d[0] != 0) {
134 return 63 - __builtin_clzll(d[0]);
135 }
136 return -1;
137}
138
139inline void record_msb(int msb, uint8_t& dst, std::array<uint32_t, 256>& th_hist) noexcept
140{
141 dst = (msb < 0) ? MSB_ZERO_SENTINEL : static_cast<uint8_t>(msb);
142 ++th_hist[static_cast<size_t>(msb) + 1];
143}
144
145// `AffineBucketChunkInfo` is defined in `pippenger_arena_layout.hpp` (included above).
146
155template <typename Curve> struct ThreadScratch {
156 using AffineElement = typename Curve::AffineElement;
157 using Element = typename Curve::Element;
158 using BaseField = typename Curve::BaseField;
159 using BaseParams = typename BaseField::Params;
160
161 // reduce_chunk's tree-reduce buffer. Per level the inner loop walks with a read cursor
162 // `i` and a write cursor `next_len ≤ i`, compacting in-place; the next level re-enters
163 // the same buffer without a swap.
164 // curr_pts is kept in AoS (not SIMD-packed): tree_reduce_in_place walks it alongside curr_buckets,
165 // pairing entries conditionally when their digits match — which a packed layout can't index cheaply.
167 std::span<uint32_t> curr_buckets;
168
169 // reduce_chunk's batch-affine drain, held packed in SIMD form (VectorField groups). For each
170 // same-bucket pair, tree_reduce pushes one point into `lhs` and the other into `rhs`; drain_batch
171 // runs the packed affine add and scatters each sum back to curr_pts. `out` shares `lhs`'s backing
172 // (the add runs in place), `add_scratch` holds the dx/dy/xsum/inv working buffers, and
173 // `pair_dest[k]` is the curr_pts slot the k-th sum is written to.
178 std::span<uint32_t> pair_dest;
179
180 size_t result_len = 0;
181
182 // Stage 6a seam-overflow buffer: when a sub-chunk emits a partial for a slot whose
183 // dense bucket entry is already populated (i.e. the digit's run was split across two
184 // sub-chunks), the partial is deferred here and merged at end-of-window via a single
185 // Montgomery-batched tree reduce. Reset to length 0 between windows.
186 std::span<uint32_t> overflow_slots;
188 size_t overflow_len = 0;
189
190 // Recursive affine bucket reduction scratch (cross-window batched, sparse-aware).
191 // `dense_buckets` holds W chunks worth of dense bucket points back-to-back, in column (SoA) form
192 // so the Stage 6b reduction gathers/scatters coordinates with VectorField::gather/scatter.
193 // Layout: dense_buckets.{x,y}[w * affine_bucket_stride + i] for window w and 0-indexed slot i.
194 // `is_present` is a parallel uint8_t array marking non-identity slots (0 = empty, 1 = present).
195 // `affine_bucket_pairs` is the scratch buffer for the real-pairs list (single pass: filtered
196 // inline as candidates are generated, no intermediate candidate buffer).
197 // `affine_bucket_indices` is the scratch index buffer for the doubling kernel.
198 // `affine_bucket_inversion_scratch` is reused for the indexed batch-affine kernels.
202 std::span<uint32_t> affine_bucket_indices;
205 // Per-window metadata consumed by recursive_affine_bucket_reduce_strided (lo, hi, buckets_padded,
206 // empty per window). Filled in the lambda before the call.
208};
209
210struct MsmArena {
211 std::unique_ptr<std::byte[]> local_owner; // NOLINT(cppcoreguidelines-avoid-c-arrays)
212 std::byte* data = nullptr;
213 uintptr_t base_addr = 0;
214 size_t capacity = 0;
215 size_t cursor = 0;
216
217 MsmArena(size_t required_bytes, std::span<std::byte> external_arena)
218 {
219 if (!external_arena.empty() && required_bytes <= external_arena.size()) {
220 data = external_arena.data();
221 capacity = external_arena.size();
222 } else {
223 // NOLINTNEXTLINE(cppcoreguidelines-avoid-c-arrays)
225 data = local_owner.get();
226 capacity = required_bytes;
227 }
228 // NOLINTNEXTLINE(cppcoreguidelines-pro-type-reinterpret-cast)
229 base_addr = reinterpret_cast<uintptr_t>(data);
230 }
231
232 template <typename T> std::span<T> alloc(size_t count) { return bump_alloc<T>(count, cursor, capacity, 0); }
233
234 template <typename T> std::span<T> bump_alloc(size_t count, size_t& local_cursor, size_t bound, size_t base_offset)
235 {
236 const size_t align = alignof(T);
237 const uintptr_t cur_addr = base_addr + base_offset + local_cursor;
238 const uintptr_t aligned_addr = (cur_addr + align - 1) & ~(uintptr_t{ align } - 1);
239 const size_t aligned_local = static_cast<size_t>(aligned_addr - (base_addr + base_offset));
240 const size_t bytes = count * sizeof(T);
241 BB_ASSERT_LTE(aligned_local + bytes, bound);
242 // NOLINTNEXTLINE(cppcoreguidelines-pro-type-reinterpret-cast)
243 T* p = reinterpret_cast<T*>(data + base_offset + aligned_local);
244 local_cursor = aligned_local + bytes;
245 return std::span<T>{ p, count };
246 }
247};
248
249template <typename Curve> inline void drain_batch(ThreadScratch<Curve>& s, size_t pair_count) noexcept
250{
251 if (pair_count == 0) {
252 return;
253 }
255
256 // Add every queued pair at once: out[k] = lhs[k] + rhs[k], in place (out shares lhs's backing).
257 bb::group_elements::batch_affine_add(s.lhs, s.rhs, s.out, s.add_scratch);
258
259 // Scatter each sum back to curr_pts, group-major so to_array() unpacks each VectorField once.
260 // pair_dest[k] is the `next_len` value recorded when the k-th pair was queued, which is < the
261 // read cursor at that level — so the write lands on an already-read slot (see tree_reduce_in_place).
262 size_t k = 0;
263 for (size_t g = 0; g < s.out.num_full_vectors(); ++g) {
264 const auto xs = s.out.x[g].to_array();
265 const auto ys = s.out.y[g].to_array();
266 for (size_t l = 0; l < W; ++l, ++k) {
267 s.curr_pts[s.pair_dest[k]].x = xs[l];
268 s.curr_pts[s.pair_dest[k]].y = ys[l];
269 }
270 }
271 const auto* xt = s.out.x.tail_data();
272 const auto* yt = s.out.y.tail_data();
273 for (size_t t = 0; t < s.out.tail(); ++t, ++k) {
274 s.curr_pts[s.pair_dest[k]].x = xt[t];
275 s.curr_pts[s.pair_dest[k]].y = yt[t];
276 }
277
278 s.lhs.reset();
279 s.rhs.reset();
280}
281
297template <typename Curve> void tree_reduce_in_place(ThreadScratch<Curve>& s, size_t initial_len) noexcept
298{
299 size_t curr_len = initial_len;
300
301 // The drain spans (lhs/rhs) are scratch shared with the Stage-6b reduction, which leaves a non-zero
302 // cursor behind. The drain trigger below tracks a local pair_count, not the span cursor, so without
303 // this reset a prior 6b fill would make push() accumulate past capacity (and batch-add stale points).
304 s.lhs.reset();
305 s.rhs.reset();
306
307 while (true) {
308 size_t i = 0;
309 size_t next_len = 0;
310 size_t pair_count = 0;
311 bool made_pair = false;
312
313 while (i < curr_len) {
314 if (i + 1 < curr_len && s.curr_buckets[i] == s.curr_buckets[i + 1]) {
315 s.lhs.push_point(s.curr_pts[i].x, s.curr_pts[i].y);
316 s.rhs.push_point(s.curr_pts[i + 1].x, s.curr_pts[i + 1].y);
317 s.curr_buckets[next_len] = s.curr_buckets[i];
318 s.pair_dest[pair_count] = static_cast<uint32_t>(next_len);
319 ++next_len;
320 ++pair_count;
321 i += 2;
322 made_pair = true;
323
324 if (pair_count >= BATCH_CAPACITY) {
325 drain_batch<Curve>(s, pair_count);
326 pair_count = 0;
327 }
328 } else {
329 s.curr_pts[next_len] = s.curr_pts[i];
330 s.curr_buckets[next_len] = s.curr_buckets[i];
331 ++next_len;
332 ++i;
333 }
334 }
335
336 drain_batch<Curve>(s, pair_count);
337
338 if (!made_pair) {
339 break;
340 }
341
342 curr_len = next_len;
343 }
344
345 s.result_len = curr_len;
346}
347
364template <typename Curve>
365void merge_overflow(ThreadScratch<Curve>& s, typename Curve::AffineElement* dst_dense) noexcept
366{
367 if (s.overflow_len == 0) {
368 return;
369 }
370
371 size_t merge_len = 0;
372 size_t i = 0;
373 while (i < s.overflow_len) {
374 const uint32_t slot = s.overflow_slots[i];
375 s.curr_buckets[merge_len] = slot;
376 s.curr_pts[merge_len] = dst_dense[slot];
377 ++merge_len;
378 while (i < s.overflow_len && s.overflow_slots[i] == slot) {
379 s.curr_buckets[merge_len] = slot;
380 s.curr_pts[merge_len] = s.overflow_pts[i];
381 ++merge_len;
382 ++i;
383 }
384 }
385
386 tree_reduce_in_place<Curve>(s, merge_len);
387
388 for (size_t k = 0; k < s.result_len; ++k) {
389 dst_dense[s.curr_buckets[k]] = s.curr_pts[k];
390 }
391
392 s.overflow_len = 0;
393}
394
400template <typename Curve>
401void reduce_chunk(ThreadScratch<Curve>& s,
402 const uint32_t* schedule,
403 const size_t* bucket_start,
404 size_t chunk_lo,
405 size_t chunk_hi,
406 size_t& bucket_cursor,
407 size_t chunk_bucket_hi,
409 std::span<const typename Curve::AffineElement> dedup_extra_points = {}) noexcept
410{
411 const size_t chunk_len = chunk_hi - chunk_lo;
412 if (chunk_len == 0) {
413 s.result_len = 0;
414 return;
415 }
416
417 BB_ASSERT_LTE(chunk_len, s.curr_pts.size());
418 static_assert(BATCH_CAPACITY <= 4096, "BATCH_CAPACITY must fit in pair_dest scratch");
419
420 // Compact entries while loading: dedup non-rep entries (DEDUP_SKIP_BIT set in the
421 // schedule entry) carry no contribution — their points are already accumulated
422 // into the cluster's combined `extra_points[cid]` emitted at the rep's slot. Skip
423 // them to avoid double-counting and to shrink the tree-reduce input.
424 size_t valid_len = 0;
425 size_t bucket = bucket_cursor;
426 size_t pos = chunk_lo;
427 while (bucket <= chunk_bucket_hi && pos < chunk_hi) {
428 const size_t run_lo = std::max(pos, bucket_start[bucket]);
429 const size_t run_hi = std::min(chunk_hi, bucket_start[bucket + 1]);
430 if (run_lo >= run_hi) {
431 ++bucket;
432 continue;
433 }
434
435 const uint32_t bucket_u32 = static_cast<uint32_t>(bucket);
436 for (size_t i = run_lo; i < run_hi; ++i) {
437 // The schedule read is sequential but the point fetch below is a
438 // data-dependent random 64-byte load over the SRS span; the loop is branchy
439 // enough that hardware runahead alone does not sustain full memory-level
440 // parallelism. Prefetching 16 entries ahead recovers 4-10% of MSM wall at
441 // n >= 2^18 (EC2 bencher, 16 threads), neutral below. The clamp keeps the
442 // address inside `points` for dedup-redirect entries whose payload indexes
443 // the smaller extra_points array (harmless wrong-but-mapped line).
445 const size_t pf_idx = std::min<size_t>(
446 schedule[i + round_parallel_detail::GATHER_PREFETCH_DIST] & SCHEDULE_INDEX_MASK, points.size() - 1);
447 __builtin_prefetch(points.data() + pf_idx, 0, 3);
448 }
449 const uint32_t e = schedule[i];
450 if ((e & DEDUP_SKIP_BIT) != 0) {
451 continue; // non-rep: skip, don't consume a curr_pts slot
452 }
453 const uint32_t raw_idx = e & SCHEDULE_INDEX_MASK;
454 const bool neg = (e & SCHEDULE_SIGN_BIT) != 0;
455 s.curr_buckets[valid_len] = bucket_u32;
456 // Gather via copy_affine64. The conditional negation runs after the copy because
457 // Fq::operator-() is a modular subtract, not a bit flip, so it can't fold into the load.
458 auto& dst_pt = s.curr_pts[valid_len];
459 // Dedup redirect: if the redirect bit is set, fetch from the dedup
460 // extra-points buffer (combined point for a cluster of duplicate scalars)
461 // instead of the original points span. The branch is always-not-taken when
462 // dedup is inactive (`dedup_extra_points` empty) and predictably-mostly-taken-or-not
463 // when active, since cluster-rep scheduling is uniform per MSM_fast.
464 if ((e & DEDUP_REDIRECT_BIT) != 0) {
465 copy_affine64(dst_pt, dedup_extra_points[raw_idx]);
466 } else {
467 copy_affine64(dst_pt, points[raw_idx]);
468 }
469 if (neg) {
470 dst_pt.y = -dst_pt.y;
471 }
472 ++valid_len;
473 }
474 pos = run_hi;
475 if (pos < chunk_hi) {
476 ++bucket;
477 }
478 }
479 bucket_cursor = bucket;
480
481 tree_reduce_in_place<Curve>(s, valid_len);
482}
483
484// `ChunkOutput<Curve>` (Stage 6 per-chunk bucket-reduce output) is defined in
485// `pippenger_arena_layout.hpp` so the test suite can size the Zone S slot the
486// same way the live allocator does.
487
488// `AffineBucketChunkInfo` is defined in `pippenger_arena_layout.hpp` (forward declared
489// above at line ~674 for ThreadScratch). It describes one chunk's contribution to the
490// cross-window recursive affine bucket reduction (lo/hi digit bounds, buckets_padded,
491// empty flag).
492
509template <typename Curve>
510[[gnu::always_inline]] inline void try_filter_pair(typename Curve::BaseField* dense_x,
511 typename Curve::BaseField* dense_y,
512 uint8_t* is_present,
513 uint32_t dst_idx,
514 uint32_t src_idx,
516 size_t& real_count) noexcept
517{
518 using Element = typename Curve::Element;
519 using AffineElement = typename Curve::AffineElement;
520
521 if (is_present[src_idx] == 0) {
522 return; // src is identity → no-op
523 }
524 if (is_present[dst_idx] == 0) {
525 dense_x[dst_idx] = dense_x[src_idx]; // dst was identity → just copy
526 dense_y[dst_idx] = dense_y[src_idx];
527 is_present[dst_idx] = 1;
528 return;
529 }
530 // Edge case: dst.x == src.x. Since both points are on-curve, this means either
531 // dst == src (doubling case) or dst == -src (inverse case, result is identity).
532 // The batch add would invert zero here, so handle out-of-band.
533 if (dense_x[dst_idx] == dense_x[src_idx]) {
534 if (dense_y[dst_idx] == dense_y[src_idx]) {
535 // dst == src → result is 2 * dst.
536 Element doubled = Element(AffineElement(dense_x[dst_idx], dense_y[dst_idx]));
537 doubled.self_dbl();
538 const AffineElement r{ doubled };
539 dense_x[dst_idx] = r.x;
540 dense_y[dst_idx] = r.y;
541 } else {
542 // dst == -src → result is identity. is_present[dst]=0 makes the slot skipped; an absent
543 // slot's coordinates are never read, so they need not be cleared.
544 is_present[dst_idx] = 0;
545 }
546 return;
547 }
548 real_pairs[real_count++] = { dst_idx, src_idx };
549}
550
556[[gnu::always_inline]] inline void try_filter_idx(const uint8_t* is_present,
557 uint32_t idx,
558 uint32_t* real_indices,
559 size_t& real_count) noexcept
560{
561 if (is_present[idx] != 0) {
562 real_indices[real_count++] = idx;
563 }
564}
565
602template <typename Curve>
603void recursive_affine_bucket_reduce_strided(ThreadScratch<Curve>& s,
604 const AffineBucketChunkInfo* chunk_infos,
605 size_t windows_in_batch,
606 ChunkOutput<Curve>* outputs_base,
607 size_t output_stride,
608 bool single_threaded) noexcept
609{
610 using AffineElement = typename Curve::AffineElement;
611 using Element = typename Curve::Element;
612 using BaseField = typename Curve::BaseField;
613
614 auto out_at = [outputs_base, output_stride](size_t w) -> ChunkOutput<Curve>& {
615 return outputs_base[w * output_stride];
616 };
617
618 if (windows_in_batch == 0) {
619 return;
620 }
621
622 // Stride is the caller's pre-sized layout width (`s.affine_bucket_stride`, set by
623 // pippenger_round_parallel when carving the arena). The densification step in the caller scattered buckets at
624 // `w * s.affine_bucket_stride + i`, so we MUST use the same value for our own indexing — any
625 // re-derivation that disagrees with the layout would index neighbouring windows. The
626 // pre-size already enforces `stride ≥ max_w(buckets_padded_w)` AND `stride ≥ 2` AND
627 // `stride is a power of two`, so the trivial-stride fast path and the 4-phase math
628 // both stay valid here. Per-window buckets_padded controls how many slots each window walks
629 // and is bounded by `stride` — verified below in debug.
630 const size_t stride = s.affine_bucket_stride;
631 bool any_nonempty = false;
632 for (size_t w = 0; w < windows_in_batch; ++w) {
633 if (chunk_infos[w].empty == 0) {
634 any_nonempty = true;
635 BB_ASSERT_LTE(chunk_infos[w].buckets_padded, stride);
636 }
637 }
638 if (!any_nonempty) {
639 for (size_t w = 0; w < windows_in_batch; ++w) {
640 out_at(w).R = Curve::Group::point_at_infinity;
641 out_at(w).L = Curve::Group::point_at_infinity;
642 }
643 return;
644 }
645
646 BaseField* const dense_x = s.dense_buckets.x.data();
647 BaseField* const dense_y = s.dense_buckets.y.data();
648 uint8_t* const is_present = s.is_present.data();
649
650 // Pick L0 (the leaf-partition size). c0 = floor(log2(stride) / 2)
651 // gives L0 ≈ sqrt(stride) — balances Phase A batch size (W·D) vs Phase A iter count
652 // (L0 - 1). Both L0 and D = stride / L0 must be powers of two.
653 BB_ASSERT_GT(stride, size_t{ 0 });
654 const size_t c_log = static_cast<size_t>(std::countr_zero(stride));
655 BB_ASSERT_EQ(static_cast<size_t>(1) << c_log, stride);
656 // Trivial-stride fast paths. The 4-phase algorithm requires c_log ≥ 2 (so we can pick
657 // c0 ∈ [1, c_log - 1]) — fall back to direct computation for stride ∈ {1, 2}.
658 if (stride <= 2) {
659 for (size_t w = 0; w < windows_in_batch; ++w) {
660 if (chunk_infos[w].empty != 0) {
661 out_at(w).R = Curve::Group::point_at_infinity;
662 out_at(w).L = Curve::Group::point_at_infinity;
663 continue;
664 }
665 // Walk the (up to two) populated slots directly.
666 const size_t base = w * stride;
667 Element R = Curve::Group::point_at_infinity;
668 Element L = Curve::Group::point_at_infinity;
669 for (size_t i = 0; i < chunk_infos[w].buckets_padded; ++i) {
670 if (is_present[base + i] == 0) {
671 continue;
672 }
673 const Element pt = Element(AffineElement(dense_x[base + i], dense_y[base + i]));
674 R += pt;
675 L += pt; // weight 1
676 if (i == 1) {
677 L += pt; // weight 2 for i=1
678 }
679 }
680 out_at(w).R = R;
681 out_at(w).L = L;
682 }
683 return;
684 }
685
686 // Choose c0 = floor(c_log / 2), clamped so that 1 ≤ c0 ≤ c_log - 1.
687 size_t c0 = c_log / 2;
688 if (c0 == 0) {
689 c0 = 1;
690 }
691 if (c0 >= c_log) {
692 c0 = c_log - 1;
693 }
694 const size_t L0 = static_cast<size_t>(1) << c0;
695 const size_t D = stride >> c0; // == stride / L0
696 BB_ASSERT_EQ(L0 * D, stride);
697 BB_ASSERT_GTE(L0, size_t{ 2 });
698 BB_ASSERT_GTE(D, size_t{ 2 });
699
700 auto* const reals = s.affine_bucket_pairs.data();
701 auto* const dbl_reals = s.affine_bucket_indices.data();
702 [[maybe_unused]] auto* const inv_scratch = s.affine_bucket_inversion_scratch.data();
703
704 // SIMD dispatch for the batched affine kernels. On a WASM-SIMD build the Phase A/B/D adds and
705 // Phase C doublings run W lanes at a time via the packed wrappers (gathering SoA columns into the
706 // now-free Stage-6a drain scratch); otherwise they run the scalar twins. Packed Stage-6b is gated by
707 // the explicit `single_threaded` flag (set by the caller from num_threads), not inferred from
708 // output_stride: SIMD-6b only pays when the reduce dominates the MSM (single-threaded), whereas under
709 // MT the reduce is a memory-bound sliver and the packed gather/scatter bridge outweighs the arithmetic
710 // saving. See stage6_simd_findings.md.
711 using BaseParams = typename BaseField::Params;
712 constexpr bool simd_compiled = bb::simd_available_v<BaseParams>;
713 [[maybe_unused]] const bool use_packed = single_threaded;
714 auto batched_add = [&](size_t count) {
715 if constexpr (simd_compiled) {
716 if (use_packed) {
718 s.dense_buckets, reals, count, s.lhs, s.rhs, s.out, s.add_scratch);
719 return;
720 }
721 }
722 bb::group_elements::batch_affine_add_indexed_scalar(s.dense_buckets, reals, count, inv_scratch);
723 };
724 auto batched_double = [&](size_t count) {
725 if constexpr (simd_compiled) {
726 if (use_packed) {
727 // Reuse the Stage-6a packed-drain backing: `in` over lhs, `out` over rhs, double scratch
728 // over the add scratch's dx / dy / inv runs (xsum is unused by doubling).
729 bb::VectorAffineElementPushSpan<BaseParams> in{ s.lhs.x.vector_fields, s.lhs.y.vector_fields };
730 bb::VectorAffineElementPushSpan<BaseParams> out{ s.rhs.x.vector_fields, s.rhs.y.vector_fields };
732 bb::VectorFieldPushSpan<BaseParams>(s.add_scratch.dx.vector_fields),
733 bb::VectorFieldPushSpan<BaseParams>(s.add_scratch.dy.vector_fields),
734 bb::VectorFieldPushSpan<BaseParams>(s.add_scratch.inv.vector_fields)
735 };
736 bb::group_elements::batch_affine_double_indexed_packed(s.dense_buckets, dbl_reals, count, in, out, dsc);
737 return;
738 }
739 }
740 bb::group_elements::batch_affine_double_indexed_scalar(s.dense_buckets, dbl_reals, count, inv_scratch);
741 };
742
743 // Phase A: per-sub-partition running-sum (suffix sums).
744 // For each window w and each sub-partition d, walk slots from L0-1 down to 1 within the
745 // sub-partition, accumulating buckets[w*stride + d*L0 + l - 1] += buckets[... l]. All
746 // (w, d, l) triples for a fixed l share one batch-affine inversion (up to windows_in_batch
747 // · D pairs). Short windows (my_M_w < L0) are treated as a single sub-partition of length
748 // my_M_w to skip dead candidates; effective per-(w, d) length is min(L0, my_M_w - d·L0).
749 {
750 for (size_t l = L0 - 1; l >= 1; --l) {
751 size_t real_count = 0;
752 for (size_t w = 0; w < windows_in_batch; ++w) {
753 if (chunk_infos[w].empty != 0) {
754 continue;
755 }
756 const size_t my_M_w = chunk_infos[w].buckets_padded;
757 const size_t base = w * stride;
758 if (my_M_w < L0) {
759 // Short window: single sub-partition of effective length `my_M_w`.
760 if (l >= my_M_w) {
761 continue; // l is in the empty-padding region, skip
762 }
763 const uint32_t src = static_cast<uint32_t>(base + l);
764 const uint32_t dst = static_cast<uint32_t>(base + l - 1);
765 try_filter_pair<Curve>(dense_x, dense_y, is_present, dst, src, reals, real_count);
766 } else {
767 const size_t my_D = my_M_w >> c0; // ≥ 1
768 for (size_t d = 0; d < my_D; ++d) {
769 const uint32_t src = static_cast<uint32_t>(base + (d * L0) + l);
770 const uint32_t dst = static_cast<uint32_t>(base + (d * L0) + l - 1);
771 try_filter_pair<Curve>(dense_x, dense_y, is_present, dst, src, reals, real_count);
772 }
773 }
774 }
775 if (real_count > 0) {
776 batched_add(real_count);
777 }
778 }
779 }
780
781 // After Phase A, each window's slot 0 holds the simple sum of its sub-partition 0,
782 // and slot d*L0 (d ≥ 1) holds the simple sum of sub-partition d. The other slots within
783 // each sub-partition hold suffix sums that Phase D will combine.
784
785 // Phase B: log-recombine sub-partition simple sums into slot 0.
786 // For L1 = L0, 2*L0, 4*L0, ..., stride/2: pair (slot 2d*L1, slot (2d+1)*L1).
787 {
788 size_t L1 = L0;
789 while (L1 < stride) {
790 size_t real_count = 0;
791 const size_t step = 2 * L1;
792 for (size_t w = 0; w < windows_in_batch; ++w) {
793 if (chunk_infos[w].empty != 0) {
794 continue;
795 }
796 const size_t my_M = chunk_infos[w].buckets_padded;
797 if (step > my_M) {
798 continue;
799 }
800 const size_t base = w * stride;
801 const size_t num_pairs_w = my_M / step;
802 for (size_t d = 0; d < num_pairs_w; ++d) {
803 const uint32_t dst = static_cast<uint32_t>(base + ((2 * d) * L1));
804 const uint32_t src = static_cast<uint32_t>(base + (((2 * d) + 1) * L1));
805 try_filter_pair<Curve>(dense_x, dense_y, is_present, dst, src, reals, real_count);
806 }
807 }
808 if (real_count > 0) {
809 batched_add(real_count);
810 }
811 L1 *= 2;
812 }
813 }
814
815 // After Phase B, each window's slot 0 holds Σ_d B_{c,d} = R_c. Save R_c into outputs
816 // before Phase D's tree-add overwrites slot 0.
817 for (size_t w = 0; w < windows_in_batch; ++w) {
818 if (chunk_infos[w].empty != 0) {
819 out_at(w).R = Curve::Group::point_at_infinity;
820 continue;
821 }
822 if (is_present[w * stride] == 0) {
823 out_at(w).R = Curve::Group::point_at_infinity;
824 } else {
825 out_at(w).R = Element(AffineElement(dense_x[w * stride], dense_y[w * stride]));
826 }
827 }
828
829 // Phase C: doublings.
830 // The candidate index list for the initial pass is constant across all c0 iters —
831 // every slot d*L0 for d ∈ [1, my_D - 1] in every non-empty window. Build the empty-
832 // filtered list once and chain c0 doublings on it instead of filtering c0 times.
833 // Subsequent levels (L1 = 2*L0, 4*L0, ...) do one doubling per level on level-specific
834 // index sets handled separately below.
835 {
836 size_t real_count = 0;
837 for (size_t w = 0; w < windows_in_batch; ++w) {
838 if (chunk_infos[w].empty != 0) {
839 continue;
840 }
841 const size_t my_M_w = chunk_infos[w].buckets_padded;
842 const size_t my_D = (my_M_w >= L0) ? (my_M_w >> c0) : size_t{ 0 };
843 const size_t base = w * stride;
844 for (size_t d = 1; d < my_D; ++d) {
845 try_filter_idx(is_present, static_cast<uint32_t>(base + (d * L0)), dbl_reals, real_count);
846 }
847 }
848 // c0 chained doublings on the same real list.
849 if (real_count > 0) {
850 for (size_t j = 0; j < c0; ++j) {
851 batched_double(real_count);
852 }
853 }
854 }
855 // Successive: at L1 = 2*L0, 4*L0, ..., stride/2: every d ≥ 1 in the sub-partition
856 // grid of size `stride / L1` gets one more doubling.
857 {
858 size_t L1 = 2 * L0;
859 while (L1 < stride) {
860 size_t real_count = 0;
861 for (size_t w = 0; w < windows_in_batch; ++w) {
862 if (chunk_infos[w].empty != 0) {
863 continue;
864 }
865 const size_t my_M = chunk_infos[w].buckets_padded;
866 if (L1 >= my_M) {
867 continue; // this window has no sub-partitions at this hierarchy
868 }
869 const size_t my_D1 = my_M / L1;
870 const size_t base = w * stride;
871 for (size_t d = 1; d < my_D1; ++d) {
872 try_filter_idx(is_present, static_cast<uint32_t>(base + (d * L1)), dbl_reals, real_count);
873 }
874 }
875 if (real_count > 0) {
876 batched_double(real_count);
877 }
878 L1 *= 2;
879 }
880 }
881
882 // Phase D: flat tree-add over the buckets_padded slots. For m = 1, 2, 4, ...,
883 // buckets_padded/2: pair (slot pos, slot pos+m) for pos = 0, 2m, 4m, ...
884 // Once the level's candidate count drops below BATCH_AFFINE_BREAKEVEN, the per-batch
885 // inversion overhead exceeds the projective per-add cost; bail and finish in Jacobian.
886 constexpr size_t BATCH_AFFINE_BREAKEVEN = 32;
887 size_t m = 1;
888 while (m < stride) {
889 // Live-slot count after this iter: stride / (2m) per window worst-case.
890 // Decision: would this iter's batch be too small? Estimate as
891 // `windows_in_batch * stride / (2m)` (upper bound on candidates).
892 const size_t est_cands_this_iter = windows_in_batch * (stride / (2 * m));
893 if (est_cands_this_iter < BATCH_AFFINE_BREAKEVEN) {
894 break;
895 }
896 size_t real_count = 0;
897 const size_t step = 2 * m;
898 for (size_t w = 0; w < windows_in_batch; ++w) {
899 if (chunk_infos[w].empty != 0) {
900 continue;
901 }
902 const size_t my_M = chunk_infos[w].buckets_padded;
903 if (m >= my_M) {
904 continue;
905 }
906 const size_t base = w * stride;
907 for (size_t pos = 0; pos + m < my_M; pos += step) {
908 try_filter_pair<Curve>(dense_x,
909 dense_y,
911 static_cast<uint32_t>(base + pos),
912 static_cast<uint32_t>(base + pos + m),
913 reals,
914 real_count);
915 }
916 }
917 if (real_count > 0) {
918 batched_add(real_count);
919 }
920 m *= 2;
921 }
922
923 // Write L_c. After Phase D's loop, `m` is the level NOT performed (or `stride` if all
924 // levels ran). The "live" slots — those holding cumulative tree-sums of consecutive m
925 // original buckets each — are {0, m, 2m, 3m, ...} ∩ [0, my_M):
926 // - loop completed (m == stride): only slot 0 is live; it holds the final L.
927 // - loop broke at level m: sum the live slots in Jacobian (live_step = m).
928 // - loop broke at m == 1: every original bucket is still live, sum them all.
929 // The Jacobian sum recovers what the unfinished levels would have computed in the
930 // batch-affine inner loop.
931 for (size_t w = 0; w < windows_in_batch; ++w) {
932 if (chunk_infos[w].empty != 0) {
933 out_at(w).L = Curve::Group::point_at_infinity;
934 continue;
935 }
936 const size_t base = w * stride;
937 const size_t my_M = chunk_infos[w].buckets_padded;
938 Element L = Curve::Group::point_at_infinity;
939 const size_t live_step = m; // distance between live slots after the affine phase
940 for (size_t pos = 0; pos < my_M; pos += live_step) {
941 if (is_present[base + pos] != 0) {
942 L += Element(AffineElement(dense_x[base + pos], dense_y[base + pos]));
943 }
944 }
945 out_at(w).L = L;
946 }
947}
948
964template <typename Curve>
965[[gnu::always_inline]] inline typename Curve::Element chunk_contribution(const ChunkOutput<Curve>& chunk) noexcept
966{
967 using Element = typename Curve::Element;
968 if (chunk.empty != 0) {
969 return Curve::Group::point_at_infinity;
970 }
971 const uint32_t k = chunk.lo - 1;
972 Element acc = chunk.L;
973 if (k != 0) {
974 Element p = chunk.R;
975 uint32_t kk = k;
976 while (kk != 0) {
977 if ((kk & 1U) != 0) {
978 acc += p;
979 }
980 kk >>= 1;
981 if (kk != 0) {
982 p.self_dbl();
983 }
984 }
985 }
986 return acc;
987}
988
989} // namespace
990// `pippenger_round_parallel_jacobian_fast` has external linkage via the `extern template`
991// declarations in the header (used by the batched driver). Defined at namespace scope.
992
1012// Dispatch `body` across `num_threads` pool workers, running inline when
1013// single-threaded. The inline branch is what makes a thread-capped
1014// (max_threads=1) pipeline safe to call from inside a pool worker: the pool is
1015// not re-entrant (a worker that re-enters bb::parallel_for lazily constructs
1016// its own thread-local pool), so the capped path must never reach it.
1017template <typename F> inline void msm_parallel_for(size_t num_threads, F&& body) noexcept
1018{
1019 if (num_threads <= 1) {
1021 body(ThreadChunk{ 0, 1 });
1022 } else {
1023 body(size_t{ 0 });
1024 }
1025 return;
1026 }
1027 bb::parallel_for(num_threads, std::forward<F>(body));
1028}
1029
1030template <typename Curve>
1034 size_t min_pts_per_thread_override,
1035 size_t max_threads) noexcept
1036{
1037 using Element = typename Curve::Element;
1038 using ScalarField = typename Curve::ScalarField;
1039 using BaseField = typename Curve::BaseField;
1040
1041 const size_t n = scalars.size();
1042 if (n == 0) {
1043 return Curve::Group::point_at_infinity;
1044 }
1045
1046 constexpr size_t NUM_BITS = ScalarField::modulus.get_msb() + 1;
1047
1048 // Cost-model window-size selection (mirrors MSM_fast<Curve>::get_optimal_log_num_buckets,
1049 // with BUCKET_ACCUMULATION_COST = 5 = J-J-add-equiv-muls / J-A-add-equiv-muls ≈ 16/11
1050 // rounded up). We do NOT delegate to the public method — keeping it self-contained
1051 // avoids dragging the AffineAddition / AFFINE_TRICK_THRESHOLD machinery in here.
1052 constexpr size_t BUCKET_ACCUMULATION_COST = 5;
1053 constexpr uint32_t MAX_C = 18;
1054 auto cost = [n](uint32_t bits) -> size_t {
1055 size_t rounds = (NUM_BITS + bits - 1) / bits;
1056 size_t buckets = size_t{ 1 } << bits;
1057 return rounds * (n + buckets * BUCKET_ACCUMULATION_COST);
1058 };
1059 uint32_t window_bits = 1;
1060 size_t best_cost = cost(1);
1061 for (uint32_t b = 2; b <= MAX_C; ++b) {
1062 const size_t this_cost = cost(b);
1063 if (this_cost < best_cost) {
1064 best_cost = this_cost;
1065 window_bits = b;
1066 }
1067 }
1068 const size_t num_buckets = size_t{ 1 } << window_bits;
1069 const uint32_t num_rounds = static_cast<uint32_t>((NUM_BITS + window_bits - 1) / window_bits);
1070 const uint32_t last_round_bits =
1071 static_cast<uint32_t>(NUM_BITS - (static_cast<size_t>(num_rounds - 1) * window_bits));
1072
1073 // Cap the worker count so each gets at least MSM_MIN_PTS_PER_THREAD points.
1074 const size_t MIN_PTS_PER_THREAD =
1075 (min_pts_per_thread_override == 0) ? MSM_MIN_PTS_PER_THREAD : min_pts_per_thread_override;
1076 const size_t hw_threads = max_threads == 0 ? get_num_cpus() : std::min(max_threads, get_num_cpus());
1077 size_t num_threads = std::min(std::max<size_t>(1, n / MIN_PTS_PER_THREAD), hw_threads);
1078 if (num_threads == 0) {
1079 num_threads = 1;
1080 }
1081
1082 // Allocate the per-thread bucket + presence scratch ONCE, indexed by tid inside the
1083 // parallel_for. Allocating inside the lambda body would re-malloc on every call (and
1084 // on WASM the malloc cost is non-trivial relative to the arithmetic work at small n).
1085 std::vector<Element> per_thread_results(num_threads);
1086 std::vector<Element> all_buckets(num_threads * num_buckets);
1087 std::vector<uint8_t> all_present(num_threads * num_buckets);
1088
1089 auto thread_body = [&](size_t tid) {
1090 const size_t lo = (tid * n) / num_threads;
1091 const size_t hi = ((tid + 1) * n) / num_threads;
1092
1093 Element* const buckets = all_buckets.data() + (tid * num_buckets);
1094 uint8_t* const present = all_present.data() + (tid * num_buckets);
1095
1096 Element result = Curve::Group::point_at_infinity;
1097
1098 for (uint32_t round = 0; round < num_rounds; ++round) {
1099 std::memset(present, 0, num_buckets);
1100
1101 const size_t hi_bit = NUM_BITS - (static_cast<size_t>(round) * window_bits);
1102 const size_t lo_bit = (hi_bit < window_bits) ? size_t{ 0 } : (hi_bit - window_bits);
1103 const size_t actual_size = hi_bit - lo_bit;
1104 const size_t start_limb = lo_bit >> 6;
1105 const size_t end_limb = hi_bit >> 6;
1106 const size_t lo_off = lo_bit & 63;
1107 const size_t lo_bits = (64 - lo_off < actual_size) ? (64 - lo_off) : actual_size;
1108 const size_t hi_bits = actual_size - lo_bits;
1109 const uint64_t lo_mask = (lo_bits == 64) ? ~uint64_t{ 0 } : ((uint64_t{ 1 } << lo_bits) - 1);
1110 const uint64_t hi_mask = (hi_bits == 0) ? uint64_t{ 0 } : ((uint64_t{ 1 } << hi_bits) - 1);
1111
1112 for (size_t i = lo; i < hi; ++i) {
1113 const uint64_t s_lo = (scalars[i].data[start_limb] >> lo_off) & lo_mask;
1114 const uint64_t s_hi = (start_limb != end_limb) ? (scalars[i].data[end_limb] & hi_mask) : uint64_t{ 0 };
1115 const uint32_t slice = static_cast<uint32_t>(s_lo | (s_hi << lo_bits));
1116 if (slice == 0) {
1117 continue;
1118 }
1119 if (present[slice] == 0) {
1120 buckets[slice].x = points[i].x;
1121 buckets[slice].y = points[i].y;
1122 buckets[slice].z = BaseField::one();
1123 present[slice] = 1;
1124 } else {
1125 buckets[slice] += points[i];
1126 }
1127 }
1128
1129 // Running suffix sum over populated buckets only.
1130 // acc = Σ_{j ≥ i, present[j]} bucket[j]
1131 // bucket_sum = Σ_{i in [first_pop_low, top]} acc(i) = Σ_k k * bucket[k]
1132 // Bucket 0 carries no contribution and is never added.
1133 std::ptrdiff_t top = static_cast<std::ptrdiff_t>(num_buckets) - 1;
1134 while (top >= 1 && present[static_cast<size_t>(top)] == 0) {
1135 --top;
1136 }
1137 Element bucket_sum = Curve::Group::point_at_infinity;
1138 if (top >= 1) {
1139 Element acc = buckets[static_cast<size_t>(top)];
1140 bucket_sum = acc;
1141 for (std::ptrdiff_t i = top - 1; i >= 1; --i) {
1142 if (present[static_cast<size_t>(i)] != 0) {
1143 acc += buckets[static_cast<size_t>(i)];
1144 }
1145 bucket_sum += acc;
1146 }
1147 }
1148
1149 const uint32_t doublings = (round == num_rounds - 1) ? last_round_bits : window_bits;
1150 for (uint32_t d = 0; d < doublings; ++d) {
1151 result.self_dbl();
1152 }
1153 result += bucket_sum;
1154 }
1155
1156 per_thread_results[tid] = result;
1157 };
1158
1159 if (num_threads == 1) {
1160 thread_body(0);
1161 } else {
1162 round_parallel_detail::msm_parallel_for(num_threads, thread_body);
1163 }
1164
1165 Element total = per_thread_results[0];
1166 for (size_t t = 1; t < num_threads; ++t) {
1167 total += per_thread_results[t];
1168 }
1169 return total;
1170}
1171
1172// PerWorkerArenaLayout (and its dependencies BATCH_CAPACITY, DEDUP_MAX_CHUNK_MEMBERS,
1173// AffineBucketChunkInfo) lives in `pippenger_arena_layout.hpp`. Used by the sizer
1174// below, the live allocator in `pippenger_round_parallel`, and the arena-layout
1175// regression test.
1176} // namespace round_parallel_detail
1177
1196
1197// Compute the exact arena bytes a single MSM_fast of `n_input` points will need.
1198// Mirrors the inline budget calculation inside `pippenger_round_parallel`.
1199// Returns 0 when N is small enough that we'll fall back to the Jacobian fast path
1200// (no affine arena needed). Exposed (declared in `scalar_multiplication_fast.hpp`)
1201// so the test suite can exercise the same sizer the live allocator uses.
1202template <typename Curve>
1203size_t compute_arena_bytes_for_msm(size_t n_input,
1204 bool external_glv_provided,
1205 bool dedup_active,
1206 size_t max_threads) noexcept
1207{
1208 using ScalarField = typename Curve::ScalarField;
1209 constexpr size_t FULL_NUM_BITS = ScalarField::modulus.get_msb() + 1;
1210 const size_t hw_threads = max_threads == 0 ? bb::get_num_cpus() : std::min(max_threads, bb::get_num_cpus());
1211
1212 if (n_input < 4) {
1213 return 0; // trivial path
1214 }
1215
1216 const bool use_glv = external_glv_provided || (n_input <= round_parallel_detail::GLV_SMALL_N_THRESHOLD);
1217 const size_t n = use_glv ? 2 * n_input : n_input;
1218 const size_t NUM_BITS = use_glv ? size_t{ 128 } : FULL_NUM_BITS;
1219 BB_ASSERT_LTE(n,
1221 "working scalar indices must fit in the 29-bit schedule payload");
1222
1227
1228 // window-bits selection uses the ideal per-window oversubscription factor (not the dispatch lmul).
1229 const size_t num_logical_threads_for_c = hw_threads * window_bits_tuning_oversub_factor(n_input);
1230 const size_t window_bits =
1231 round_parallel_detail::choose_window_bits(n, NUM_BITS, n_input, num_logical_threads_for_c);
1232 const size_t num_windows = (NUM_BITS + 2 + window_bits - 1) / window_bits;
1233 const size_t num_buckets = (size_t{ 1 } << (window_bits - 1)) + 1;
1234
1235 const size_t desired_threads = std::max<size_t>(1, hw_threads);
1236 const size_t max_threads_for_min_batch = n / MIN_BATCH_CAPACITY;
1237 const size_t min_threads_allowed =
1238 std::max<size_t>(1, (desired_threads + MIN_AFFINE_THREAD_RATIO - 1) / MIN_AFFINE_THREAD_RATIO);
1239
1240 if (max_threads_for_min_batch < min_threads_allowed) {
1241 return 0; // jacobian-fast fallback, no affine arena
1242 }
1243
1244 const size_t num_threads = std::min(desired_threads, std::max<size_t>(1, max_threads_for_min_batch));
1245
1246 // num_threads sizes the per-task arrays; worker_total sizes the per-OS-thread scratch
1247 // (FIFO-shared by every task that lands on that OS thread).
1248 const size_t worker_total_for_budget = num_threads;
1249 const size_t dense_stride_est = round_parallel_detail::compute_dense_stride(num_buckets, num_threads);
1250
1251 // Pre-schedule conservative per-window cost: uses `num_buckets` (= 2^(c-1)+1) as the
1252 // B upper bound. The lambda below recomputes once the actual schedule is built.
1253 const size_t per_window_bytes = round_parallel_detail::compute_per_window_bytes<Curve>(
1254 num_threads, num_buckets, n, dense_stride_est, worker_total_for_budget);
1255
1256 const size_t global_max_overflow_per_window =
1257 round_parallel_detail::compute_global_max_overflow_per_window(n, num_threads, SUBCHUNK_ENTRIES_CAP);
1258
1259 const bool inline_glv_double = use_glv && !external_glv_provided;
1260 const size_t profile_threads = std::max<size_t>(1, hw_threads);
1261 const size_t phase_one_prologue_bytes =
1262 round_parallel_detail::compute_phase_one_prologue_bytes(n, use_glv, inline_glv_double, profile_threads);
1263
1264 const auto phase_a_caps = round_parallel_detail::compute_phase_a_caps(n, num_threads);
1265 const size_t phase_a_cluster_members_cap = phase_a_caps.members_cap;
1266 const size_t phase_a_cluster_offsets_cap = phase_a_caps.offsets_cap;
1267
1268 // Zone W per-worker UNION via the canonical layout walk. Stage 6a, Stage 6b, and
1269 // Phase A overlay the same per-worker bytes; the struct returns the max-of-layouts
1270 // (the Stage 6 wpb-dependent tail is added below once `windows_per_batch` is known).
1271 // Passing `windows_per_batch = 0` here skips the tail — we only need the union bytes
1272 // for the fixed_overhead → wpb solve.
1273 const round_parallel_detail::PerWorkerArenaLayout<Curve> union_layout(/*chunk_capacity=*/SUBCHUNK_ENTRIES_CAP,
1274 global_max_overflow_per_window,
1275 dedup_active,
1276 phase_a_cluster_members_cap,
1277 phase_a_cluster_offsets_cap,
1278 /*windows_per_batch=*/0,
1279 /*dense_stride_est=*/0);
1280 const size_t worker_union_bytes = union_layout.per_worker_union_bytes;
1281
1282 const size_t fixed_overhead = (worker_union_bytes * worker_total_for_budget) +
1283 round_parallel_detail::window_sums_storage_bytes<Curve>() +
1284 (size_t{ 8 } * (num_threads + 1)) // rebalanced_bucket_lo_partition
1285 + phase_one_prologue_bytes;
1286
1287 // wpb fallback when fixed_overhead has eaten the BATCH_MEM_BUDGET headroom: the inline
1288 // `solve_wpb` in `pippenger_round_parallel` returns `W_R` (the whole region) — running
1289 // every window in a single batch — when `available_budget == 0`. This keeps the sizer correct
1290 // for large num_threads, where fixed_overhead alone can exceed the budget.
1291 const size_t available_budget_outer =
1292 (BATCH_MEM_BUDGET > fixed_overhead) ? (BATCH_MEM_BUDGET - fixed_overhead) : size_t{ 0 };
1293 const size_t windows_per_batch =
1294 round_parallel_detail::solve_wpb(per_window_bytes, available_budget_outer, num_windows);
1295 // Dedup state lives in the arena (allocated post-Phase-1, retained through Stage 6a).
1296 // Worst-case sizes: redirect_lookup is one uint32 per working scalar (4n bytes);
1297 // extra_points is the fixed DEDUP_MAX_CLUSTERS cap (≈1 MB) regardless of n.
1298 const size_t dedup_bytes = dedup_active ? ((size_t{ 4 } * n) + (size_t{ sizeof(typename Curve::AffineElement) } *
1300 : size_t{ 0 };
1301 auto arena_bytes_for_window_layout = [&](size_t bit_budget, size_t wb) {
1302 const auto layout_sched = round_parallel_detail::build_window_schedule(bit_budget, wb);
1303 // Uniform schedule: the widest window's bucket count is the per-window cap.
1304 const size_t B_eff_layout = (size_t{ 1 } << (wb - 1)) + 1;
1305 const size_t dense_stride_layout = round_parallel_detail::compute_dense_stride(B_eff_layout, num_threads);
1306 const size_t per_window_bytes_layout = round_parallel_detail::compute_per_window_bytes<Curve>(
1307 num_threads, B_eff_layout, n, dense_stride_layout, worker_total_for_budget);
1308
1309 const size_t available_budget =
1310 (BATCH_MEM_BUDGET > fixed_overhead) ? (BATCH_MEM_BUDGET - fixed_overhead) : size_t{ 0 };
1311 const size_t wpb = round_parallel_detail::solve_wpb(
1312 per_window_bytes_layout, available_budget, static_cast<size_t>(layout_sched.num_windows));
1313 return fixed_overhead + (wpb * per_window_bytes_layout) + 32768 + dedup_bytes;
1314 };
1315
1316 // Tight return: the arena holds `fixed_overhead + wpb · per_window_bytes` of typed
1317 // buffers plus a 32 KiB alignment pad and the dedup state (when active). Sizing
1318 // tightly — rather than padding up to BATCH_MEM_BUDGET — matters for many-MSM_fast flows
1319 // (e.g. PerMsmChonk's 256 separate per-circuit MSMs) where every per-MSM_fast
1320 // `make_unique_for_overwrite<std::byte[]>` mmap/munmaps the buffer above glibc's
1321 // M_MMAP_THRESHOLD; a 32 MiB floor here would tax every MSM_fast with the page-fault
1322 // first-touch cost regardless of how much of the arena the small MSM_fast actually uses.
1323 size_t arena_bytes = fixed_overhead + (windows_per_batch * per_window_bytes) + 32768 + dedup_bytes;
1324
1325 // The live pipeline chooses window_bits from the *effective* (nonzero) scalar count and the
1326 // observed bit budget after Phase 1: c = choose_window_bits(n_active, effective_num_bits) with
1327 // n_active <= n and effective_num_bits <= NUM_BITS. Fewer active points => smaller c => more
1328 // windows => a larger arena (most sharply once fixed_overhead has eaten the batch budget and
1329 // every window runs in a single batch). Size for the worst reachable c so the bound holds for
1330 // any scalar density, with no extra scalar scan.
1331 //
1332 // For a fixed c, bit_budget = NUM_BITS maximizes the window count (effective_num_bits <=
1333 // NUM_BITS) and 2^(c-1)+1 caps B_eff, so arena_bytes_for_window_layout(NUM_BITS, c) dominates
1334 // every live (effective_num_bits, c) layout. The reachable c span is [2, c_max]: choose is
1335 // non-decreasing in the point count (n_active <= n bounds it above), but the ceil() in the round
1336 // count makes it non-monotonic in the bit budget by ±1, so c_max is the max over bit budgets,
1337 // not simply choose(n, NUM_BITS).
1338 size_t c_max_reachable = window_bits;
1339 for (size_t bit_budget = 1; bit_budget <= NUM_BITS; ++bit_budget) {
1340 c_max_reachable = std::max(c_max_reachable,
1342 n, bit_budget, n_input, num_logical_threads_for_c)));
1343 }
1344 for (size_t wb = 2; wb <= c_max_reachable; ++wb) {
1345 arena_bytes = std::max(arena_bytes, arena_bytes_for_window_layout(NUM_BITS, wb));
1346 }
1347 return arena_bytes;
1348}
1349
1350// Round-parallel Pippenger MSM_fast.
1351// `external_glv_doubled` — optional caller-supplied [P_0, φP_0, …, P_{n-1}, φP_{n-1}]
1352// buffer (length 2·n_input). When non-empty, forces use_glv=true and skips the
1353// internal doubling pass. The interleaved layout means longer-prefix aliasing
1354// (length 2·Nmax) is valid for any n ≤ Nmax with no copy.
1355// `external_arena` — optional caller-supplied scratch buffer ≥ this MSM_fast's required
1356// bytes. When empty, allocate per-MSM_fast via make_unique_for_overwrite and free at
1357// return. The batched driver supplies a single arena sized to the largest member.
1358template <typename Curve>
1359// NOLINTNEXTLINE(readability-function-size, readability-function-cognitive-complexity,
1360// google-readability-function-size)
1363 size_t dedup_info,
1365 std::span<std::byte> external_arena,
1366 size_t max_threads) noexcept
1367{
1368 using Element = typename Curve::Element;
1369 using AffineElement = typename Curve::AffineElement;
1370 using ScalarField = typename Curve::ScalarField;
1371 using BaseField = typename Curve::BaseField;
1372
1373 const size_t n_input = scalars_span.size();
1374 if (n_input == 0) {
1375 return Curve::Group::point_at_infinity;
1376 }
1377
1378 // Bail to trivial_msm_threaded when each worker would own fewer than
1379 // MIN_PTS_PER_THREAD_FOR_PIPPENGER points — pippenger_fast's per-window scaffolding loses
1380 // to straus_msm at this density. Caller-supplied GLV doubling is wasted at this size,
1381 // but the overhead is negligible.
1382 const size_t hw_threads = max_threads == 0 ? bb::get_num_cpus() : std::min(max_threads, bb::get_num_cpus());
1383 {
1384 const size_t num_threads_dispatch = std::max<size_t>(1, std::min(n_input, hw_threads));
1385 const size_t pts_per_thread = (n_input + num_threads_dispatch - 1) / num_threads_dispatch;
1386 if (pts_per_thread < MIN_PTS_PER_THREAD_FOR_PIPPENGER) {
1387 return trivial_msm_threaded<Curve>(scalars_span, all_points, hw_threads);
1388 }
1389 }
1390
1391 BB_ASSERT_GTE(all_points.size(), scalars_span.start_index + n_input);
1392 std::span<const AffineElement> input_points(&all_points[scalars_span.start_index], n_input);
1393
1394 constexpr size_t FULL_NUM_BITS = ScalarField::modulus.get_msb() + 1;
1395
1396 // NOLINTNEXTLINE(cppcoreguidelines-pro-type-const-cast)
1397 ScalarField* scalar_ptr = const_cast<ScalarField*>(&scalars_span[scalars_span.start_index]);
1398 std::span<ScalarField> input_scalars(scalar_ptr, n_input);
1399
1400 // GLV: split k ≡ k1 − k2·λ (mod r), giving 2n pairs at NUM_BITS=128. Halves num_windows;
1401 // costs an extra n point doubles. Applied only below GLV_SMALL_N_THRESHOLD where the
1402 // win-on-windows beats the lose-on-doubled-scan, OR forced on by the batched dispatcher
1403 // supplying `external_glv_doubled` (it amortises the doubling across the whole batch).
1404 // Empirical crossover (best-of-3 sweep at HC=16, P ∈ {4, 8, 16}): wasmtime keeps GLV up
1405 // to n=2^16; native to n=2^13 (clang's branchless bias-decode is fast enough that the 2×
1406 // point-count cost dominates above that). Threshold is platform-conditional in the
1407 // hoisted GLV_SMALL_N_THRESHOLD declaration.
1408 const bool external_glv_provided = !external_glv_doubled.empty();
1409 const bool use_glv = external_glv_provided || n_input <= round_parallel_detail::GLV_SMALL_N_THRESHOLD;
1410
1411 // Stage 6 splits into 6a (per-thread bucket partials over the contiguous-by-schedule-
1412 // index partition) and 6b (cross-thread bucket reduction over a uniform-width digit
1413 // slice). Small MSMs short-circuit to trivial_msm_threaded above this point.
1414
1415 // n is the working scalar/point count (GLV doubles it); NUM_BITS is the post-recoding
1416 // window-bit budget (128 for GLV, FULL_NUM_BITS otherwise).
1417 const size_t n = use_glv ? (2 * n_input) : n_input;
1418 const size_t NUM_BITS = use_glv ? size_t{ 128 } : FULL_NUM_BITS;
1419 BB_ASSERT_LTE(n,
1421 "working scalar indices must fit in the 29-bit schedule payload");
1422 std::span<ScalarField> scalars;
1423 std::span<const AffineElement> points;
1424 const bool inline_glv_double = use_glv && !external_glv_provided;
1425
1426 // Activation gate: caller-supplied hint opts this MSM_fast into the dedup pre-pass.
1427 // Hint-driven so polynomials with low duplicate density (PC counters, range checks)
1428 // skip the O(n) tagging cost. The small-n bail above (pts_per_thread <
1429 // MIN_PTS_PER_THREAD_FOR_PIPPENGER) already shed every case where dedup wouldn't fit
1430 // — n ≥ MIN_PTS_PER_THREAD_FOR_PIPPENGER * 1 = 24 here.
1431 const bool dedup_active = dedup_info != 0;
1432 // dedup_info >= 2 carries a caller-measured duplicate count (e.g. an adjacent-run
1433 // count from grand-product construction); 1 means hinted with no estimate.
1434 const size_t dedup_count_estimate = dedup_info >= 2 ? dedup_info : 0;
1435
1436 // ---------------------------------------------------------------------------------------
1437 // Arena setup (pre-Phase-1).
1438 //
1439 // The per-MSM_fast arena is allocated BEFORE Phase 1 so the Phase 1 prologue (msb_per_scalar,
1440 // glv_*_storage, per_thread_msb_hist) lives inside the arena instead of on the heap.
1441 // Once Phase 1 finishes and the window schedule is known (T, B_eff, dense_stride, wpb),
1442 // we partition the remaining capacity into three named zones
1443 // (Zone P / Zone W / Zone S) — see the "Arena zone layout" block after the wpb solve.
1444 //
1445 // We size the buffer using `compute_arena_bytes_for_msm`, whose conservative bound
1446 // dominates the inline-tight (P + W + S) sum for any wpb we choose below.
1447 // ---------------------------------------------------------------------------------------
1448 const size_t arena_total_bytes =
1449 compute_arena_bytes_for_msm<Curve>(n_input, external_glv_provided, dedup_active, hw_threads);
1450 round_parallel_detail::MsmArena arena(arena_total_bytes, external_arena);
1451
1452 // ---------------------------------------------------------------------------------------
1453 // Phase 1 — convert scalars from Montgomery, optionally GLV-split, populate msb buffer.
1454 // The msb_per_scalar buffer feeds max-msb num_windows selection;
1455 // per-thread msb_hist counts (bin 0 = zero, bin k+1 = msb == k) feed the n_active gate
1456 // and the active-scalar gate.
1457 //
1458 // When dedup is active the per-scalar dedup work (hash + linear-probe shared atomic
1459 // table, per-thread dup_pair recording) is fused into the same per-thread loop so
1460 // scalars stay hot in L1 between from-Mont and the hash. The post-pass (sort, cluster
1461 // build, chunked tree-reduce, redirect_lookup) runs sequentially after the parallel_for
1462 // — see `dedup_finalize_parallel`.
1463 // ---------------------------------------------------------------------------------------
1464 using round_parallel_detail::MSB_ZERO_SENTINEL;
1465 const size_t profile_threads = std::max<size_t>(1, hw_threads);
1466 auto msb_per_scalar = arena.template alloc<uint8_t>(n);
1467 auto per_thread_msb_hist = arena.template alloc<std::array<uint32_t, 256>>(profile_threads);
1468 // MsmArena::alloc returns uninitialised memory; the histograms must be zero-initialised so
1469 // record_msb's increments land on a clean slate.
1470 std::fill_n(per_thread_msb_hist.data(), profile_threads, std::array<uint32_t, 256>{});
1471
1472 // GLV storage (optional). `glv_scalars_storage` is the GLV-split working scalar buffer;
1473 // `glv_points_storage` is the inline-doubled point buffer (skipped when the caller
1474 // supplied an external doubled buffer). Both span empty when `use_glv` is false.
1475 std::span<ScalarField> glv_scalars_storage;
1476 std::span<AffineElement> glv_points_storage;
1477 if (use_glv) {
1478 glv_scalars_storage = arena.template alloc<ScalarField>(n);
1479 if (inline_glv_double) {
1480 glv_points_storage = arena.template alloc<AffineElement>(n);
1481 } else {
1482 BB_ASSERT_EQ(external_glv_doubled.size(), n);
1483 }
1484 }
1485
1486 {
1487 BB_BENCH_NAME("MSM_fast::Phase1_from_montgomery");
1488 if (use_glv) {
1489 // Convert each input scalar from-Mont into a stack local, GLV-split it, store both
1490 // 128-bit halves and their msb into the profile buffer. input_scalars is read-only on
1491 // this path so the user's buffer is preserved (no Montgomery restore needed). Inline
1492 // path additionally GLV-doubles the points in the same parallel pass; external path
1493 // aliases the caller-supplied doubled buffer.
1494 const BaseField beta = inline_glv_double ? BaseField::cube_root_of_unity() : BaseField{};
1495 round_parallel_detail::msm_parallel_for(hw_threads, [&](const ThreadChunk& chunk) {
1496 auto& th_hist = per_thread_msb_hist[chunk.thread_index];
1497 for (size_t i : chunk.range(n_input)) {
1498 const ScalarField canonical = input_scalars[i].from_montgomery_form_reduced();
1499 const auto split = ScalarField::split_into_endomorphism_scalars(canonical);
1500 const auto& k1 = split.first;
1501 const auto& k2 = split.second;
1502 glv_scalars_storage[2 * i].data[0] = k1[0];
1503 glv_scalars_storage[2 * i].data[1] = k1[1];
1504 glv_scalars_storage[2 * i].data[2] = 0;
1505 glv_scalars_storage[2 * i].data[3] = 0;
1506 glv_scalars_storage[(2 * i) + 1].data[0] = k2[0];
1507 glv_scalars_storage[(2 * i) + 1].data[1] = k2[1];
1508 glv_scalars_storage[(2 * i) + 1].data[2] = 0;
1509 glv_scalars_storage[(2 * i) + 1].data[3] = 0;
1510 if (inline_glv_double) {
1511 glv_points_storage[2 * i] = input_points[i];
1512 glv_points_storage[(2 * i) + 1].x = input_points[i].x * beta;
1513 glv_points_storage[(2 * i) + 1].y = -input_points[i].y;
1514 }
1515 round_parallel_detail::record_msb(
1516 round_parallel_detail::msb_of_2limb(k1[0], k1[1]), msb_per_scalar[2 * i], th_hist);
1517 round_parallel_detail::record_msb(
1518 round_parallel_detail::msb_of_2limb(k2[0], k2[1]), msb_per_scalar[(2 * i) + 1], th_hist);
1519 }
1520 });
1521 points =
1522 inline_glv_double ? std::span<const AffineElement>(glv_points_storage.data(), n) : external_glv_doubled;
1523 scalars = glv_scalars_storage;
1524 } else {
1525 // Non-GLV path: in-place from-Mont (later restored in the Stage-7 epilogue).
1526 round_parallel_detail::msm_parallel_for(hw_threads, [&](const ThreadChunk& chunk) {
1527 auto& th_hist = per_thread_msb_hist[chunk.thread_index];
1528 for (size_t i : chunk.range(n_input)) {
1529 input_scalars[i].self_from_montgomery_form_reduced();
1530 round_parallel_detail::record_msb(
1531 round_parallel_detail::msb_of_4limb(input_scalars[i].data), msb_per_scalar[i], th_hist);
1532 }
1533 });
1534 scalars = input_scalars;
1535 points = input_points;
1536 }
1537 }
1538
1539 std::array<uint64_t, 256> msb_hist{};
1540 for (size_t t = 0; t < profile_threads; ++t) {
1541 for (size_t b = 0; b < 256; ++b) {
1542 msb_hist[b] += per_thread_msb_hist[t][b];
1543 }
1544 }
1545 const size_t n_active_early = n - static_cast<size_t>(msb_hist[0]);
1546
1547 // ---------------------------------------------------------------------------------------
1548 // Phase 2 — bail to trivial_msm_threaded when n_active is too small to amortise pippenger_fast's
1549 // per-window scaffolding. trivial_msm_threaded -> straus_msm wants Montgomery scalars, so
1550 // re-Mont-form them in parallel before dispatching.
1551 // ---------------------------------------------------------------------------------------
1552 {
1553 const size_t threads_for_dispatch = std::max<size_t>(1, std::min(n_active_early, hw_threads));
1554 const size_t pts_per_thread = (n_active_early + threads_for_dispatch - 1) / threads_for_dispatch;
1555 if (pts_per_thread < MIN_PTS_PER_THREAD_FOR_PIPPENGER) {
1556 round_parallel_detail::msm_parallel_for(hw_threads, [&](const ThreadChunk& chunk) {
1557 for (size_t i : chunk.range(n)) {
1558 scalars[i].self_to_montgomery_form();
1559 }
1560 });
1561 std::span<const ScalarField> scalars_const(scalars.data(), n);
1562 PolynomialSpan<const ScalarField> ps(0, scalars_const);
1563 return trivial_msm_threaded<Curve>(ps, points, hw_threads);
1564 }
1565 }
1566
1567 // ---------------------------------------------------------------------------------------
1568 // Phase 3 — pick the window layout, build the schedule, run the pipeline, sum into the result.
1569 // ---------------------------------------------------------------------------------------
1570 const size_t num_logical_threads_for_c = hw_threads * window_bits_tuning_oversub_factor(n_input);
1571
1572 // Shrink the bit budget to the highest non-empty msb_hist bin so num_windows is determined
1573 // by the actual data, not the conservative GLV / FULL_NUM_BITS bound.
1574 size_t effective_num_bits = 0;
1575 for (size_t bin = 256; bin > 1;) {
1576 --bin;
1577 if (msb_hist[bin] != 0) {
1578 effective_num_bits = bin;
1579 break;
1580 }
1581 }
1582 if (effective_num_bits == 0 || effective_num_bits > NUM_BITS) {
1583 effective_num_bits = NUM_BITS;
1584 }
1585 // Drive window selection by the count of *nonzero* working scalars (n_active_early), not the
1586 // nominal size n. The native cost model rounds*(num_points + BUCKET_ACC_COST*buckets) charges
1587 // num_points for bucket accumulation, which only touches nonzero scalars; feeding nominal n for
1588 // a sparse MSM (e.g. a chonk commit at ~20% density) inflates that term ~5x and overshoots c.
1589 // Using the effective count picks the right window for sparse and dense MSMs alike.
1590 // Dedup-hinted MSMs lose roughly another half of their active points to the duplicate
1591 // pre-pass (measured on Honk wires: ~48% of nonzero coefficients are cluster non-reps),
1592 // but Phase A runs after the window is chosen — discount up front so the pick matches
1593 // the points that actually reach the buckets.
1594 // Estimate the post-dedup count from the zero fraction: on wire-style polynomials
1595 // the duplicate mass roughly tracks the zero mass (both come from trace padding and
1596 // value reuse), while dense hinted polynomials (z_perm: no zeros, ~16% real repeats)
1597 // lose far less. A blanket halving over-discounts those and drops their window 2-3
1598 // bits below optimum at n ~ 2^19. Clamp the estimate to [15%, 50%] of the active set.
1599 size_t n_for_window = n_active_early;
1600 if (dedup_active) {
1601 // Prefer a caller-measured duplicate count (dedup_info >= 2, e.g. the adjacent-run
1602 // count recorded when the polynomial was built); otherwise estimate from the zero
1603 // fraction, clamped to [15%, 50%] of the active set.
1604 const size_t zeros = n - n_active_early;
1605 size_t dup_est = std::clamp(zeros, (n_active_early * 3) / 20, n_active_early / 2);
1606 if (dedup_count_estimate != 0) {
1607 // Cap a caller-measured count at 90% of the active set: the count is only an estimate
1608 // of what the dedup pre-pass will actually fold (short scalars are skipped), so keep a
1609 // 10% floor of effective points to avoid choosing a window for a near-empty MSM.
1610 dup_est = std::min(dedup_count_estimate, (n_active_early * 9) / 10);
1611 }
1612 n_for_window = std::max<size_t>(1, n_active_early - dup_est);
1613 }
1614 const size_t window_bits =
1615 round_parallel_detail::choose_window_bits(n_for_window, effective_num_bits, n_input, num_logical_threads_for_c);
1616 const size_t num_buckets = (size_t{ 1 } << (window_bits - 1)) + 1;
1617
1618 // Schedule-based dedup state, allocated from the per-MSM arena after Phase 1.
1619 // Until then, both spans are empty.
1620 // Lifetimes:
1621 // redirect_lookup — written by Phase A; read by Stage 4b's dedup_patch_schedule per batch
1622 // extra_points — written by Phase A; read by Stage 6a's reduce_chunk per batch
1623 // Both must survive until the last Stage 6a, so they sit in the arena (which is freed
1624 // when this function returns).
1626
1627 // The schedule is uniform: one region over all non-zero scalars. (The variable-window split
1628 // codepath remains as scaffolding but is not split here.)
1629 const auto sched = round_parallel_detail::build_window_schedule(effective_num_bits, window_bits);
1630 BB_ASSERT_LTE(sched.num_windows,
1632 "window schedule exceeds compile-time max window count");
1633
1638
1639 // Thread count: one parallel_for task per available core, capped at
1640 // `n / MIN_BATCH_CAPACITY` so each task's chunk stays large enough to saturate the
1641 // batched-affine drains. `bb::get_num_cpus() <= 1` is the chonk batch-verifier's
1642 // signal that outer parallelism owns all cores — run sequentially.
1643 const size_t desired_threads = std::max<size_t>(1, hw_threads);
1644 const size_t max_threads_for_min_batch = std::max<size_t>(1, n / MIN_BATCH_CAPACITY);
1645 const size_t num_threads = std::min(desired_threads, max_threads_for_min_batch);
1646
1647 // Stage 6's tree-reduce splits each thread's chunk into sub-chunks of at most
1648 // SUBCHUNK_ENTRIES_CAP entries before calling reduce_chunk, bounding per-thread scratch
1649 // independent of n. 2048 keeps level-0 saturated (≥ 4 BATCH_CAPACITY drains at typical
1650 // c=16) while the deepest level still hits BATCH_AFFINE_BREAKEVEN (~32 pairs); halving
1651 // breaks the deep levels and doubling wastes memory.
1652 // Pick windows_in_batch so per-MSM_fast working set fits in ~32 MB. Empirically 32 MB
1653 // performs as well as 128 MB on the WASM grid (the recursive affine bucket reduction
1654 // recovers most of the small-batch loss).
1655 // The per_window_bytes / fixed_overhead formulas below mirror this enum of allocations
1656 // exactly. Anyone adding an arena buffer must update both the alloc and the corresponding
1657 // term in those formulas, otherwise windows_per_batch drifts off the BATCH_MEM_BUDGET.
1658
1659 // Per-(w, t) slot stride must fit the widest schedule window. The schedule is uniform, so the
1660 // widest window's bucket count is the per-window cap computed above.
1661 const size_t B_eff = num_buckets;
1662
1663 const size_t worker_total_for_budget = num_threads;
1664 const size_t dense_stride_est = round_parallel_detail::compute_dense_stride(B_eff, num_threads);
1665 const size_t bucket_partials_per_window_max =
1667 const size_t per_window_bytes_lo = round_parallel_detail::compute_per_window_bytes<Curve>(
1668 num_threads, B_eff, n, dense_stride_est, worker_total_for_budget);
1669
1670 const size_t global_max_overflow_per_window_for_budget =
1671 round_parallel_detail::compute_global_max_overflow_per_window(n, num_threads, SUBCHUNK_ENTRIES_CAP);
1672
1673 const size_t phase_one_prologue_bytes =
1674 round_parallel_detail::compute_phase_one_prologue_bytes(n, use_glv, inline_glv_double, profile_threads);
1675
1676 const auto phase_a_caps = round_parallel_detail::compute_phase_a_caps(n, num_threads);
1677 const size_t phase_a_cluster_members_cap = phase_a_caps.members_cap;
1678 const size_t phase_a_cluster_offsets_cap = phase_a_caps.offsets_cap;
1679
1680 // Zone W per-worker UNION via the canonical layout walk. The wpb-dependent Stage 6
1681 // tail is added separately after `windows_per_batch` is solved; here we only need
1682 // the union bytes for the fixed_overhead → wpb budget.
1684 /*chunk_capacity=*/SUBCHUNK_ENTRIES_CAP,
1685 global_max_overflow_per_window_for_budget,
1686 dedup_active,
1687 phase_a_cluster_members_cap,
1688 phase_a_cluster_offsets_cap,
1689 /*windows_per_batch=*/0,
1690 /*dense_stride_est=*/0);
1691 const size_t worker_union_bytes_for_budget = budget_layout.per_worker_union_bytes;
1692
1693 const size_t fixed_overhead = (worker_union_bytes_for_budget * worker_total_for_budget) +
1694 round_parallel_detail::window_sums_storage_bytes<Curve>() +
1695 (size_t{ 8 } * (num_threads + 1)) // rebalanced_bucket_lo_partition
1696 + phase_one_prologue_bytes;
1697
1698 // Solve `wpb · per_window_bytes ≤ BATCH_MEM_BUDGET − fixed_overhead`.
1699 const size_t available_budget =
1700 (BATCH_MEM_BUDGET > fixed_overhead) ? (BATCH_MEM_BUDGET - fixed_overhead) : size_t{ 0 };
1701 const size_t windows_per_batch =
1702 round_parallel_detail::solve_wpb(per_window_bytes_lo, available_budget, sched.num_windows);
1703
1704 // Per-thread chunk-capacity scratch sizing. A thread's per-window slice is split into
1705 // sub-chunks of at most SUBCHUNK_ENTRIES_CAP entries. Worst-case overflow per
1706 // (thread, window) is one partial per sub-chunk boundary that lands mid-run, bounded
1707 // above by `ceil(max_chunk_len / SUBCHUNK_ENTRIES_CAP)` where max_chunk_len ≤ n/T.
1708 // The Stage 6a end-of-window overflow merge runs tree_reduce on `2 × overflow` entries
1709 // (each affected slot contributes a dense head + ≥1 overflow entry). Tree-reduce
1710 // scratch must fit either a sub-chunk's reduce_chunk input (up to SUBCHUNK_ENTRIES_CAP)
1711 // or a full overflow merge — take the max.
1712 const size_t global_max_chunk_len = (n + num_threads - 1) / num_threads;
1713 const size_t global_max_overflow_per_window =
1714 (global_max_chunk_len + SUBCHUNK_ENTRIES_CAP - 1) / SUBCHUNK_ENTRIES_CAP;
1715 const size_t chunk_capacity = std::max(SUBCHUNK_ENTRIES_CAP, 2 * global_max_overflow_per_window);
1716
1717 // Per-task scratch. Every parallel_for stage below runs `num_threads` tasks, and
1718 // `thread_scratch` holds one slot per task index, so no two concurrent tasks ever
1719 // share a slot. Every field is overwritten fresh at task start; nothing is read
1720 // across tasks. Phase A scratch overlays the same arena bytes (see Zone W below)
1721 // because the Phase A and Stage 6 parallel phases never run concurrently.
1722 const size_t worker_total = num_threads;
1723 std::vector<round_parallel_detail::ThreadScratch<Curve>> thread_scratch(worker_total);
1725 if (dedup_active) {
1726 phase_a_scratch.resize(worker_total);
1727 }
1728
1729 // ---------------------------------------------------------------------------------------
1730 // Arena zone layout — set up after Phase 1 and schedule selection (see
1731 // https://gist.github.com/AztecBot/7c5ef0581350f6fdb9711679552fd86f §1, §4, §5).
1732 //
1733 // [0 .. bytes_P) Zone P — whole-MSM_fast permanent
1734 // msb_per_scalar (already alloc'd above)
1735 // glv_scalars / glv_points (already alloc'd above)
1736 // per_thread_msb_hist (already alloc'd above)
1737 // window_sums (Stage 7 accumulator)
1738 // redirect_lookup, extra_points (dedup, if active)
1739 // [bytes_P .. bytes_P + bytes_W) Zone W — per-worker union slab × T
1740 // Stage 6a/6b ThreadScratch fields and PhaseA
1741 // scratch overlay the same per-worker bytes; the
1742 // wpb-dependent Stage 6 fields sit immediately
1743 // after the union. Stage 6a, Stage 6b, and Phase A
1744 // run in distinct parallel_for invocations and
1745 // never co-exist on a worker.
1746 // [bytes_P + bytes_W .. arena.capacity)
1747 // Zone S — per-batch swing region (schedule, HIST slot,
1748 // DENSE slot, partition metadata).
1749 // HIST slot overlays H ↔ O on one byte slab:
1750 // H (S1-S4): digit_cursors
1751 // O (S6b-S7): chunk_outputs/window_partial_sums
1752 // Slot per-window = max(H, O). At chonk this is
1753 // H-bound (~256 KiB/window).
1754 // DENSE slot is dedicated for D (S6a-S6b):
1755 // bucket_partials_dense / _present
1756 // (~135 KiB/window at chonk). The D-class was
1757 // moved out of the HIST slot to eliminate L1
1758 // cache aliasing on the Stage 6a scatter writes
1759 // (+1.29% regression observed when D was overlaid
1760 // at the HIST offset).
1761 //
1762 // wpb solve: BATCH_MEM_BUDGET - bytes_P - bytes_W_fixed - bytes_S_shared - 32 KiB pad,
1763 // divided by (bytes_S_per_window + bytes_W_per_wpb). per_window_bytes_shared accounts
1764 // for HIST + DENSE as two separate slots.
1765 // ---------------------------------------------------------------------------------------
1766
1767 // Freeze Zone P prefix at the post-Phase-1 cursor — everything allocated so far
1768 // (msb_per_scalar, glv storage, per_thread_msb_hist) is Zone P permanent state.
1769 const size_t bytes_P_prefix = arena.cursor;
1770
1771 // Per-worker fixed-bytes "union": ThreadScratch's wpb-independent fields overlay the
1772 // PhaseAScratch fields. Compute each layout's strict byte requirement (including the
1773 // alignment slop a bump cursor would consume), then take the max.
1774 auto align_up = [](size_t off, size_t align) -> size_t { return (off + align - 1) & ~(align - 1); };
1775 auto layout_add = [&](size_t& off, size_t bytes, size_t align) { off = align_up(off, align) + bytes; };
1776
1777 // Per-worker layout via the canonical walk (single source of truth shared with
1778 // `compute_arena_bytes_for_msm`). Pre-wpb-solve usage there passes wpb=0; here we
1779 // pass the actual windows_per_batch so the Stage 6 wpb-dependent tail is included.
1780 const round_parallel_detail::PerWorkerArenaLayout<Curve> worker_layout(chunk_capacity,
1781 global_max_overflow_per_window,
1782 dedup_active,
1783 phase_a_cluster_members_cap,
1784 phase_a_cluster_offsets_cap,
1785 windows_per_batch,
1786 dense_stride_est);
1788 const size_t per_worker_union_bytes = worker_layout.per_worker_union_bytes;
1789 const size_t per_worker_bytes = worker_layout.per_worker_bytes;
1790
1791 // Zone P extra (post-decision permanent state): window_sums + dedup state. Sized
1792 // with the strict alignment a bump cursor would apply.
1793 constexpr size_t WINDOW_SUMS_CAP = round_parallel_detail::MAX_SCHEDULE_WINDOWS;
1794 size_t bytes_P_extra_layout = 0;
1795 layout_add(bytes_P_extra_layout, round_parallel_detail::window_sums_storage_bytes<Curve>(), alignof(Element));
1796 if (dedup_active) {
1797 layout_add(bytes_P_extra_layout, sizeof(uint32_t) * n, alignof(uint32_t));
1798 layout_add(bytes_P_extra_layout,
1799 sizeof(AffineElement) * round_parallel_detail::DEDUP_MAX_CLUSTERS,
1800 alignof(AffineElement));
1801 }
1802
1803 // Zone sizes. The Zone W slab uses `MsmArena::bump_alloc` which aligns in ABSOLUTE address
1804 // space (the arena buffer base is only `__STDCPP_DEFAULT_NEW_ALIGNMENT__`-aligned, but
1805 // AffineElement is alignas(64)). To make the per-worker layout match the layout-only
1806 // calc (which assumes the slab starts on a 64-byte boundary), bias bytes_P so the
1807 // absolute address `arena.data + bytes_P` is 64-aligned.
1808 const size_t arena_base_misalign = static_cast<size_t>(arena.base_addr & (WORKER_SLAB_ALIGN - 1));
1809 const size_t bytes_P_min = align_up(bytes_P_prefix, alignof(Element)) + bytes_P_extra_layout;
1810 const size_t bytes_P = align_up(bytes_P_min + arena_base_misalign, WORKER_SLAB_ALIGN) - arena_base_misalign;
1811 // bytes_W: per_worker_bytes is already rounded to WORKER_SLAB_ALIGN, so consecutive
1812 // slabs stay aligned once the first slab is aligned.
1813 const size_t bytes_W = per_worker_bytes * worker_total;
1814
1815 // Sanity: zones must fit. The conservative `compute_arena_bytes_for_msm` upper bound
1816 // sized the buffer to `BATCH_MEM_BUDGET + 32K + dedup_bytes` at worst, which dominates
1817 // every reachable (P + W + S) sum at the inline-tight wpb chosen above.
1818 BB_ASSERT_LTE(bytes_P + bytes_W, arena.capacity);
1819 const size_t bytes_S_total = arena.capacity - bytes_P - bytes_W;
1820
1821 // Per-zone bump cursors. Zone P continues from `bytes_P_prefix`; Zones W and S start
1822 // fresh at their zone base. Zone P's bound is `bytes_P` so the bump cursor stays inside
1823 // its slot even if the extra slabs alignment-slop a hair.
1824 size_t zone_P_cursor = bytes_P_prefix;
1825 size_t zone_S_cursor = 0;
1826 auto zone_P_alloc = [&]<typename T>(size_t count) -> std::span<T> {
1827 return arena.template bump_alloc<T>(count, zone_P_cursor, bytes_P, 0);
1828 };
1829 auto zone_S_alloc = [&]<typename T>(size_t count) -> std::span<T> {
1830 return arena.template bump_alloc<T>(count, zone_S_cursor, bytes_S_total, bytes_P + bytes_W);
1831 };
1832 // Zone W is carved into per-worker slabs directly via `MsmArena::bump_alloc` below — each
1833 // worker gets its own (cursor, bound) pair, so a single zone-wide allocator would not
1834 // capture the per-worker discipline.
1835 // The pre-Phase-1 `MsmArena::alloc` cursor is retired here — every subsequent allocation
1836 // routes through `zone_P_alloc`, the per-worker Zone W allocators, or `zone_S_alloc`.
1837
1838 // Zone W: per-worker union slab — Stage6a/6b ThreadScratch and PhaseA fields overlay the
1839 // same per-worker bytes, with the wpb-dependent Stage 6 fields immediately after.
1840 for (size_t t = 0; t < worker_total; ++t) {
1841 // Each worker's slab is a contiguous `per_worker_bytes` window inside Zone W.
1842 const size_t slab_base = t * per_worker_bytes;
1843 auto& s = thread_scratch[t];
1844
1845 // ThreadScratch fixed fields — first view into the union. Bound = union size.
1846 size_t ts_fixed_cur = 0;
1847 auto ts_fixed_alloc = [&]<typename T>(size_t count) -> std::span<T> {
1848 return arena.template bump_alloc<T>(count, ts_fixed_cur, per_worker_union_bytes, bytes_P + slab_base);
1849 };
1850 s.curr_pts = ts_fixed_alloc.template operator()<AffineElement>(chunk_capacity);
1851 s.curr_buckets = ts_fixed_alloc.template operator()<uint32_t>(chunk_capacity);
1852 // Packed batch-affine drain backing, each run sized for BATCH_CAPACITY elements. The run count
1853 // is sourced from PerWorkerArenaLayout so these allocations and the sizer's layout walk cannot
1854 // drift. Index map below; `out` shares `lhs`'s backing (the add runs in place).
1855 using BaseParams = typename BaseField::Params;
1856 using VecField = bb::VectorField<BaseParams>;
1857 constexpr size_t packed_runs =
1859 const size_t pack_cap = (BATCH_CAPACITY / VecField::SIZE) + 1;
1860 std::array<std::span<VecField>, packed_runs> packed;
1861 for (auto& run : packed) {
1862 run = ts_fixed_alloc.template operator()<VecField>(pack_cap);
1863 }
1864 // packed = { lhs.x, lhs.y, rhs.x, rhs.y, dx, dy, xsum, inv }
1865 s.lhs = { packed[0], packed[1] };
1866 s.rhs = { packed[2], packed[3] };
1867 s.out = { packed[0], packed[1] };
1868 s.add_scratch = { bb::VectorFieldPushSpan<BaseParams>{ packed[4] },
1872 s.pair_dest = ts_fixed_alloc.template operator()<uint32_t>(BATCH_CAPACITY);
1873 s.overflow_slots = ts_fixed_alloc.template operator()<uint32_t>(global_max_overflow_per_window);
1874 s.overflow_pts = ts_fixed_alloc.template operator()<AffineElement>(global_max_overflow_per_window);
1875
1876 // PhaseA fields — second view, overlays the SAME per-worker union bytes. PhaseA's
1877 // parallel_for never overlaps Stage 6a/6b on the same worker, so reusing the bytes is
1878 // safe; the union's size is max(ts_fixed_layout, pa_layout) by construction.
1879 if (dedup_active) {
1880 size_t pa_cur = 0;
1881 auto pa_alloc = [&]<typename T>(size_t count) -> std::span<T> {
1882 return arena.template bump_alloc<T>(count, pa_cur, per_worker_union_bytes, bytes_P + slab_base);
1883 };
1884 auto& ps = phase_a_scratch[t];
1886 ps.cluster_members = pa_alloc.template operator()<uint32_t>(phase_a_cluster_members_cap);
1887 ps.cluster_offsets = pa_alloc.template operator()<uint32_t>(phase_a_cluster_offsets_cap);
1888 ps.dirty_slots = pa_alloc.template operator()<uint16_t>(PWAL::PHASE_A_DIRTY_SLOTS_CAP);
1889 ps.bucket_rep = pa_alloc.template operator()<uint32_t>(PWAL::PHASE_A_BUCKET_REP_CAP);
1890 ps.staged = pa_alloc.template operator()<std::pair<uint32_t, uint32_t>>(PWAL::PHASE_A_STAGED_CAP);
1891 ps.chunk_pts = pa_alloc.template operator()<AffineElement>(PWAL::PHASE_A_CHUNK_CAP);
1892 ps.chunk_ids = pa_alloc.template operator()<uint32_t>(PWAL::PHASE_A_CHUNK_CAP);
1893 }
1894
1895 // Stage 6 wpb-dependent fields — tail of the per-worker slab, BEYOND the union. Bound
1896 // = full per-worker slab size; cursor starts at per_worker_union_bytes so we don't
1897 // overwrite the union region.
1898 size_t ts_tail_cur = per_worker_union_bytes;
1899 auto ts_tail_alloc = [&]<typename T>(size_t count) -> std::span<T> {
1900 return arena.template bump_alloc<T>(count, ts_tail_cur, per_worker_bytes, bytes_P + slab_base);
1901 };
1902 const size_t dense_total = windows_per_batch * dense_stride_est;
1903 const size_t dense_pair_max = dense_total / 2;
1904 auto dense_x = ts_tail_alloc.template operator()<BaseField>(dense_total);
1905 auto dense_y = ts_tail_alloc.template operator()<BaseField>(dense_total);
1906 s.dense_buckets = bb::AffineColumnSpan<BaseField>{ dense_x, dense_y };
1907 s.is_present = ts_tail_alloc.template operator()<uint8_t>(dense_total);
1908 s.affine_bucket_pairs = ts_tail_alloc.template operator()<std::pair<uint32_t, uint32_t>>(dense_pair_max);
1909 s.affine_bucket_indices = ts_tail_alloc.template operator()<uint32_t>(dense_pair_max);
1910 s.affine_bucket_inversion_scratch = ts_tail_alloc.template operator()<BaseField>(dense_pair_max);
1911 s.chunk_infos =
1912 ts_tail_alloc.template operator()<round_parallel_detail::AffineBucketChunkInfo>(windows_per_batch);
1913 std::fill_n(s.chunk_infos.begin(), windows_per_batch, round_parallel_detail::AffineBucketChunkInfo{});
1914 s.affine_bucket_stride = dense_stride_est;
1915 }
1916
1917 // Zone S: per-batch swing region — schedule + HIST slot + DENSE slot + partition metadata.
1918 const size_t schedule_total = windows_per_batch * n;
1919 auto schedule = zone_S_alloc.template operator()<uint32_t>(schedule_total);
1920
1921 // ----- HIST slot ------------------------------------------------------------------
1922 // Single byte slab backing two non-coexisting lifetime classes:
1923 // Epoch H (Stages 1-4): digit_cursors.
1924 // Epoch O (Stages 6b-7): chunk_outputs, window_partial_sums.
1925 // H dies before O is born (Stage 4 cursor advance ends before Stage 6b first writes
1926 // chunk_outputs / window_partial_sums).
1927 //
1928 // D-class (bucket_partials_dense + bucket_partials_present) lives in its own dedicated Zone-S
1929 // DENSE slot (below), not overlaid on this HIST slot: overlaying aliases the
1930 // `dense[slot]/present[slot]` scatter writes in L1. See the "DENSE slot" comment block for the
1931 // measured Stage-6a regression.
1932 //
1933 // Phase 4: `digit_cursors` is dual-role within epoch H. After Stage 1 it holds
1934 // per-(w, t) counts of digit d; Stage 2 walks each (w, d) column from t = 0..T-1
1935 // reading the count from slot k and writing back the exclusive prefix-sum offset
1936 // (the count is consumed into `running` BEFORE the slot is overwritten, so the
1937 // in-place transform is correct). Stage 4 then advances each (w, t) slice as a per-thread cursor.
1938 // Strict aliasing: every access goes through a std::span<T> obtained by
1939 // reinterpret_cast<T*>(hist_slot.data() + offset)
1940 // which is well-defined because std::byte is allowed by [basic.lval] to alias any
1941 // POD type. All overlaid types (uint32_t, size_t, Element, ChunkOutput<Curve>) are
1942 // trivially copyable / standard layout so the two epochs do not require construction
1943 // or destruction calls when the role of the bytes changes.
1944 static_assert(alignof(Element) <= 32, "HIST slot O layout assumes alignof(Element) <= 32");
1945 static_assert(alignof(round_parallel_detail::ChunkOutput<Curve>) <= 32,
1946 "HIST slot O layout assumes alignof(ChunkOutput) <= 32");
1947
1948 auto align_up_local = [](size_t off, size_t a) -> size_t { return (off + a - 1) & ~(a - 1); };
1949
1950 // Exact byte requirements for each epoch (matches the budget formula above).
1951 const size_t hist_h_bytes_total = (size_t{ 4 } * windows_per_batch * num_threads * B_eff); // digit_cursors
1952
1953 // O epoch layout — chunk_outputs first, then window_partial_sums. Both are alignof
1954 // <= 32; align each up to its own alignment.
1955 size_t o_layout_cur = 0;
1956 o_layout_cur = align_up_local(o_layout_cur, alignof(round_parallel_detail::ChunkOutput<Curve>));
1957 const size_t off_chunk_outputs = o_layout_cur;
1958 o_layout_cur += sizeof(round_parallel_detail::ChunkOutput<Curve>) * windows_per_batch * num_threads;
1959 o_layout_cur = align_up_local(o_layout_cur, alignof(typename Curve::Element));
1960 const size_t off_window_partial_sums = o_layout_cur;
1961 o_layout_cur += sizeof(typename Curve::Element) * num_threads * windows_per_batch;
1962 const size_t hist_o_bytes_total = o_layout_cur;
1963
1964 const size_t hist_slot_bytes_total = std::max(hist_h_bytes_total, hist_o_bytes_total);
1965 // Round up to AffineElement size so the bump allocator below treats the slot as a
1966 // whole number of 64-byte alignas(64) cells. Allocate via AffineElement to force the
1967 // slot base to be 64-byte aligned in absolute address space — sufficient for the
1968 // H-epoch uint32 digit_cursors span (alignof 4) and the O-epoch ChunkOutput/Element
1969 // spans (alignof ≤ 32).
1970 const size_t hist_slot_cells = (hist_slot_bytes_total + sizeof(AffineElement) - 1) / sizeof(AffineElement);
1971 auto hist_slot_cells_span = zone_S_alloc.template operator()<AffineElement>(hist_slot_cells);
1972 // NOLINTNEXTLINE(cppcoreguidelines-pro-type-reinterpret-cast)
1973 std::byte* const hist_slot_bytes = reinterpret_cast<std::byte*>(hist_slot_cells_span.data());
1974
1975 // H-epoch view — live S1..S4. `digit_cursors[(w*T + t) * stride + d]` holds three
1976 // distinct meanings depending on stage:
1977 // * After Stage 1: per-(w, t) count of digit d's occurrences in thread t's slice.
1978 // * After Stage 2: per-(w, t) exclusive prefix-sum offset (cursor base) for the
1979 // bucket-d run inside that window's schedule slot.
1980 // * After Stage 4: offset + count (final cursor end-state); dead from then on.
1981 // Stage 2 reads each (w, t, d) count from this buffer and writes the running prefix
1982 // sum back to the SAME slot before advancing `running`, so the count is preserved
1983 // long enough to feed the accumulator. Stage 4's `++` post-increment on each
1984 // thread's slice runs without atomics because each thread owns its (w, t, *) row
1985 // exclusively.
1986 // NOLINTNEXTLINE(cppcoreguidelines-pro-type-reinterpret-cast)
1987 auto digit_cursors =
1988 std::span<uint32_t>{ reinterpret_cast<uint32_t*>(hist_slot_bytes), windows_per_batch * num_threads * B_eff };
1989
1990 // O-epoch views — live S6b..S7. Backed by the SAME bytes as above; H contents are
1991 // dead by the time these are touched. ChunkOutput<Curve> and Curve::Element have
1992 // user-defined constructors so are not formally trivially_copyable, but they are
1993 // standard-layout PODs of fixed bytes (Element is alignas(32) over a fixed-width Fq
1994 // field array). The existing arena pre-Phase-3 already aliases them through std::byte
1995 // buffers via `make_unique_for_overwrite<std::byte[]>` + reinterpret_cast; the
1996 // std::byte aliasing rule in [basic.lval] applies regardless of trivial-copyability.
1998 // NOLINTNEXTLINE(cppcoreguidelines-pro-type-reinterpret-cast)
1999 reinterpret_cast<round_parallel_detail::ChunkOutput<Curve>*>(hist_slot_bytes + off_chunk_outputs),
2000 windows_per_batch * num_threads
2001 };
2002 auto window_partial_sums = std::span<typename Curve::Element>{
2003 // NOLINTNEXTLINE(cppcoreguidelines-pro-type-reinterpret-cast)
2004 reinterpret_cast<typename Curve::Element*>(hist_slot_bytes + off_window_partial_sums),
2005 num_threads * windows_per_batch
2006 };
2007 // window_partial_sums is reset to identity at the start of each Stage 6b worker
2008 // (`my_partials[w] = point_at_infinity` loop), so we deliberately do NOT initialise
2009 // it here. chunk_outputs is written unconditionally per (w, tprime) in Stage 6b
2010 // (the empty path sets `out.empty = 1`), so no pre-init is needed either.
2011 // ----- end HIST slot --------------------------------------------------------------
2012
2013 // ----- DENSE slot -----------------------------------------------------------------
2014 // Dedicated Zone-S slot for D-class (bucket_partials_dense + bucket_partials_present).
2015 // Lifetime is Stages 6a-6b only. Isolated from the HIST slot so Stage 6a's tight
2016 // scatter loop
2017 // `dst_dense[slot] = pt; dst_present[slot] = 1;`
2018 // does not L1-alias against the HIST slot's H/O bytes (the previous co-located
2019 // layout caused a +1.29% Stage 6a regression in WASM, t=+58 across 10× interleaved
2020 // runs). The dense ↔ present pair stays packed at fixed aligned offsets within this
2021 // slot — they MUST stay close because Stage 6a reads `present[slot]` then writes
2022 // `dense[slot]` / `present[slot]` in tandem in the inner loop.
2023 static_assert(alignof(AffineElement) == 64, "DENSE slot D layout assumes alignof(AffineElement) == 64");
2024 const size_t bp_total = windows_per_batch * bucket_partials_per_window_max;
2025 size_t d_layout_cur = 0;
2026 const size_t off_dense = d_layout_cur;
2027 d_layout_cur += sizeof(AffineElement) * bp_total; // bucket_partials_dense
2028 const size_t off_present = d_layout_cur;
2029 d_layout_cur += sizeof(uint8_t) * bp_total; // bucket_partials_present
2030 const size_t dense_slot_bytes_total = d_layout_cur;
2031 const size_t dense_slot_cells = (dense_slot_bytes_total + sizeof(AffineElement) - 1) / sizeof(AffineElement);
2032 // Allocate via AffineElement to force 64-byte alignment for the leading
2033 // bucket_partials_dense view.
2034 auto dense_slot_cells_span = zone_S_alloc.template operator()<AffineElement>(dense_slot_cells);
2035 // NOLINTNEXTLINE(cppcoreguidelines-pro-type-reinterpret-cast)
2036 std::byte* const dense_slot_bytes = reinterpret_cast<std::byte*>(dense_slot_cells_span.data());
2037
2038 // NOLINTNEXTLINE(cppcoreguidelines-pro-type-reinterpret-cast)
2039 auto bucket_partials_dense =
2040 std::span<AffineElement>{ reinterpret_cast<AffineElement*>(dense_slot_bytes + off_dense), bp_total };
2041 // NOLINTNEXTLINE(cppcoreguidelines-pro-type-reinterpret-cast)
2042 auto bucket_partials_present =
2043 std::span<uint8_t>{ reinterpret_cast<uint8_t*>(dense_slot_bytes + off_present), bp_total };
2044 // ----- end DENSE slot -------------------------------------------------------------
2045
2046 auto bucket_start_all = zone_S_alloc.template operator()<size_t>(windows_per_batch * (B_eff + 1));
2047 auto chunk_start_all = zone_S_alloc.template operator()<size_t>(windows_per_batch * (num_threads + 1));
2048 // chunk_bucket_lo_all[w*(T+1) + t] = bucket index of the first schedule entry in
2049 // chunk t of window w.
2050 // chunk_bucket_hi_all[w*T + t] = bucket index of the last schedule entry in chunk t.
2051 // Chunks are partitioned by schedule index (uniform t·m/T), not by bucket boundary, so
2052 // a bucket's run can straddle threads — both threads then carry a partial for that
2053 // shared bucket and Stage 7's chunk_contribution sum (Σ_d d · partial_d_in_t over t)
2054 // combines them without an explicit merge step.
2055 auto chunk_bucket_lo_all = zone_S_alloc.template operator()<size_t>(windows_per_batch * (num_threads + 1));
2056 auto chunk_bucket_hi_all = zone_S_alloc.template operator()<size_t>(windows_per_batch * num_threads);
2057
2058 // bucket_partials_offsets is the index table that maps (thread, window) -> slot
2059 // start in bucket_partials_dense/present. Lives S5..S6b alongside chunk_start_all,
2060 // and stays as its own Zone S allocation (separate from the DENSE slot).
2061 auto bucket_partials_offsets = zone_S_alloc.template operator()<size_t>((num_threads * windows_per_batch) + 1);
2062
2063 // Stage 6b rebalanced-task partition. The bucket range [1, num_buckets) is split evenly
2064 // across `num_threads` rebalanced tasks t'. The partition is uniform in num_buckets so
2065 // we store T+1 boundaries (not per-window). For each window we record the half-open
2066 // range of original threads whose chunk range intersects each task t' — usually 1-2
2067 // originals per task.
2068 auto rebalanced_bucket_lo_partition = zone_S_alloc.template operator()<size_t>(num_threads + 1);
2069 auto orig_thread_lo = zone_S_alloc.template operator()<size_t>(windows_per_batch * num_threads);
2070 auto orig_thread_hi = zone_S_alloc.template operator()<size_t>(windows_per_batch * num_threads);
2071
2072 // Zone P: window_sums (Stage 7 accumulator — survives the whole MSM_fast).
2073 auto window_sums = zone_P_alloc.template operator()<typename Curve::Element>(WINDOW_SUMS_CAP);
2074 std::fill_n(window_sums.begin(), WINDOW_SUMS_CAP, Curve::Group::point_at_infinity);
2075
2076 // Zone P: dedup state — written by Phase A and read through Stage 6a of every batch,
2077 // so it must outlive every batch.
2078 // - redirect_lookup: parallel-filled with DEDUP_INVALID_EXTRA below before Phase A reads it.
2079 // - extra_points: no init needed; Phase A writes per-thread cid ranges, and consumers
2080 // only read indices Phase A actually populated.
2081 if (dedup_active) {
2082 dedup_state.redirect_lookup = zone_P_alloc.template operator()<uint32_t>(n);
2083 dedup_state.extra_points =
2084 zone_P_alloc.template operator()<AffineElement>(round_parallel_detail::DEDUP_MAX_CLUSTERS);
2085 BB_BENCH_NAME("MSM_fast::dedup/redirect_invalid_fill");
2086 uint32_t* const rl = dedup_state.redirect_lookup.data();
2087 round_parallel_detail::msm_parallel_for(hw_threads, [&](const ThreadChunk& chunk) {
2088 for (size_t i : chunk.range(n)) {
2090 }
2091 });
2092 }
2093
2094 // BUCKET_MASK strips the sign bit off a packed (sign | bucket) digit produced by
2095 // get_constantine_packed_digit, leaving the unsigned bucket index.
2096 constexpr uint32_t BUCKET_MASK = (uint32_t{ 1 } << 31) - 1;
2097
2098 // Phase A runs at most once per MSM_fast (not per batch). Cluster membership is determined
2099 // by scalar value (memcmp) — independent of which window we walk — and bucket
2100 // adjacency holds in any window's sorted schedule because true duplicates land in the
2101 // same bucket of every window. So we Phase A on the very first batch's window-0
2102 // schedule, populate `dedup_state.{redirect_lookup, extra_points}` once, and reuse the
2103 // result for every subsequent batch.
2104 bool phase_a_done = false;
2105
2106 auto run_batch = [&](size_t batch_start, size_t windows_in_batch, size_t B_R) noexcept {
2107 // Per-(w, t) slot stride uses `B_eff` = max(num_buckets, B_lo, B_hi); each call
2108 // iterates only the region's first B_R entries. The arena was sized for B_eff per slot.
2109 const size_t bucket_stride = B_eff;
2110 // Per-window slice params. The final window can be narrower when the bit budget
2111 // does not divide evenly by the default window size; the Booth recoder must use
2112 // that narrower width or it encroaches on bits beyond the schedule.
2113 constexpr size_t SCALAR_UINT64_LIMBS = sizeof(ScalarField) / sizeof(uint64_t);
2120 std::array<uint8_t, 128> per_window_bits{};
2121 constexpr size_t SCALAR_U32_LIMBS = sizeof(ScalarField) / sizeof(uint32_t);
2122 for (size_t w = 0; w < windows_in_batch; ++w) {
2123 const size_t global_w = batch_start + w;
2124 const size_t window_bits_w = sched.window_bits_per_window[global_w];
2125 per_window_bits[w] = static_cast<uint8_t>(window_bits_w);
2127 sched.bit_base[global_w], window_bits_w, SCALAR_UINT64_LIMBS);
2129 sched.bit_base[global_w], window_bits_w, SCALAR_U32_LIMBS);
2130 slice_paths[w] = round_parallel_detail::classify_slice_path_u32(slice_params_u32[w]);
2131 const uint32_t lo_mask = slice_params_u32[w].lo_mask;
2132 const uint32_t hi_mask = slice_params_u32[w].hi_mask;
2133 const uint32_t val_mask = (uint32_t{ 1 } << static_cast<uint32_t>(window_bits_w)) - 1;
2134 lo_mask_vectors[w] = round_parallel_detail::SimdU32x4{ lo_mask, lo_mask, lo_mask, lo_mask };
2135 hi_mask_vectors[w] = round_parallel_detail::SimdU32x4{ hi_mask, hi_mask, hi_mask, hi_mask };
2136 val_mask_vectors[w] = round_parallel_detail::SimdU32x4{ val_mask, val_mask, val_mask, val_mask };
2137 }
2138
2139 constexpr size_t SIMD_BATCH = 64;
2140 static_assert(SIMD_BATCH % 4 == 0, "SIMD_BATCH must be divisible by 4");
2141 constexpr size_t LIMBS_PER_SCALAR = sizeof(ScalarField) / sizeof(uint32_t);
2142 const auto* scalars_u32 = reinterpret_cast<const uint32_t*>(scalars.data());
2144 auto fill_packed_digit_buffer = [&](size_t w, size_t i, uint32_t* packed_buf) noexcept {
2145 const auto& sp32 = slice_params_u32[w];
2146 const uint32_t window_bits_w = static_cast<uint32_t>(per_window_bits[w]);
2148 for (size_t k = 0; k < SIMD_BATCH; k += 4) {
2150 packed_buf + k,
2151 scalars_u32 + ((i + k + 0) * LIMBS_PER_SCALAR),
2152 scalars_u32 + ((i + k + 1) * LIMBS_PER_SCALAR),
2153 scalars_u32 + ((i + k + 2) * LIMBS_PER_SCALAR),
2154 scalars_u32 + ((i + k + 3) * LIMBS_PER_SCALAR),
2155 sp32.lo_limb,
2156 sp32.lo_off,
2157 lo_mask_vectors[w],
2158 one_v,
2159 val_mask_vectors[w],
2160 window_bits_w);
2161 }
2162 } else if (slice_paths[w] == round_parallel_detail::ConstantineSlicePath::Bottom) {
2163 for (size_t k = 0; k < SIMD_BATCH; k += 4) {
2165 packed_buf + k,
2166 scalars_u32 + ((i + k + 0) * LIMBS_PER_SCALAR),
2167 scalars_u32 + ((i + k + 1) * LIMBS_PER_SCALAR),
2168 scalars_u32 + ((i + k + 2) * LIMBS_PER_SCALAR),
2169 scalars_u32 + ((i + k + 3) * LIMBS_PER_SCALAR),
2170 sp32.hi_limb,
2171 sp32.lo_bits,
2172 hi_mask_vectors[w],
2173 one_v,
2174 val_mask_vectors[w],
2175 window_bits_w);
2176 }
2177 } else {
2178 for (size_t k = 0; k < SIMD_BATCH; k += 4) {
2180 packed_buf + k,
2181 scalars_u32 + ((i + k + 0) * LIMBS_PER_SCALAR),
2182 scalars_u32 + ((i + k + 1) * LIMBS_PER_SCALAR),
2183 scalars_u32 + ((i + k + 2) * LIMBS_PER_SCALAR),
2184 scalars_u32 + ((i + k + 3) * LIMBS_PER_SCALAR),
2185 sp32.lo_limb,
2186 sp32.hi_limb,
2187 sp32.lo_off,
2188 sp32.lo_bits,
2189 lo_mask_vectors[w],
2190 hi_mask_vectors[w],
2191 one_v,
2192 val_mask_vectors[w],
2193 window_bits_w);
2194 }
2195 }
2196 };
2197
2198 // Capture the dedup state before Stage 1. The first batch must build the ordinary
2199 // R14 schedule so Phase A can discover clusters, then patch+compact that batch.
2200 // Later batches can schedule cluster reps directly and omit non-reps up front.
2201 const bool phase_a_done_at_batch_start = phase_a_done;
2202 const bool dedup_known_for_batch =
2203 dedup_active && phase_a_done_at_batch_start && dedup_state.n_dedup_extras != 0;
2204
2205 // Stage 1 (digit extraction): per-thread per-window bucket histograms. Work is
2206 // scalar-blocked across the windows in this batch so scalars/msb/dedup metadata are
2207 // read once per block and reused while still hot.
2208 auto stage1_digit_extract = [&]<bool DedupKnown>(size_t tid) noexcept {
2209 [[maybe_unused]] const uint32_t* const rl_data = dedup_state.redirect_lookup.data();
2210 for (size_t w = 0; w < windows_in_batch; ++w) {
2211 uint32_t* my_counts = digit_cursors.data() + (((w * num_threads) + tid) * bucket_stride);
2212 std::memset(my_counts, 0, B_R * sizeof(uint32_t));
2213 }
2214 const size_t start = tid * n / num_threads;
2215 const size_t end = (tid + 1) * n / num_threads;
2216
2217 alignas(16) std::array<uint32_t, SIMD_BATCH> packed_buf{};
2218 // Pack the per-block filter into a uint64 bitmask. When every scalar in the block
2219 // is active (common in dense workloads), the inner scatter takes an all_included
2220 // fast path that drops the per-element predicate; mixed blocks bit-scan the mask.
2221 auto compute_include_mask = [&](size_t block_start) noexcept -> uint64_t {
2222 uint64_t include_mask = 0;
2223 for (size_t k = 0; k < SIMD_BATCH; ++k) {
2224 const size_t scalar_idx = block_start + k;
2225 const uint8_t m = msb_per_scalar[scalar_idx];
2226 bool include = (m != MSB_ZERO_SENTINEL);
2227 if constexpr (DedupKnown) {
2228 if (include) {
2229 const uint32_t patch = rl_data[scalar_idx];
2230 include = (patch == round_parallel_detail::DEDUP_INVALID_EXTRA ||
2232 }
2233 }
2234 include_mask |= static_cast<uint64_t>(include) << k;
2235 }
2236 return include_mask;
2237 };
2238
2239 size_t i = start;
2240 while (i + SIMD_BATCH <= end) {
2241 const uint64_t include_mask = compute_include_mask(i);
2242 if (include_mask == 0) {
2243 i += SIMD_BATCH;
2244 continue;
2245 }
2246 const bool all_included = include_mask == ~uint64_t{ 0 };
2247 for (size_t w = 0; w < windows_in_batch; ++w) {
2248 fill_packed_digit_buffer(w, i, packed_buf.data());
2249 uint32_t* my_counts = digit_cursors.data() + (((w * num_threads) + tid) * bucket_stride);
2250 if (all_included) {
2251 for (size_t k = 0; k < SIMD_BATCH; ++k) {
2252 ++my_counts[packed_buf[k] & BUCKET_MASK];
2253 }
2254 } else {
2255 uint64_t scatter_mask = include_mask;
2256 for (size_t k = 0; k < SIMD_BATCH; ++k) {
2257 if ((scatter_mask & uint64_t{ 1 }) != 0) {
2258 ++my_counts[packed_buf[k] & BUCKET_MASK];
2259 }
2260 scatter_mask >>= 1;
2261 }
2262 }
2263 }
2264 i += SIMD_BATCH;
2265 }
2266
2267 // Tail (0..SIMD_BATCH-1 scalars). Same scalar-major loop order; per-scalar
2268 // active check inlined since the block is short.
2269 for (; i < end; ++i) {
2270 const uint8_t m = msb_per_scalar[i];
2271 if (m == MSB_ZERO_SENTINEL) {
2272 continue;
2273 }
2274 if constexpr (DedupKnown) {
2275 const uint32_t patch = rl_data[i];
2278 continue;
2279 }
2280 }
2281 for (size_t w = 0; w < windows_in_batch; ++w) {
2282 uint32_t* my_counts = digit_cursors.data() + (((w * num_threads) + tid) * bucket_stride);
2283 const round_parallel_detail::ConstantineSliceParams sp = slice_params[w];
2284 const uint32_t window_bits_w = static_cast<uint32_t>(per_window_bits[w]);
2285 const uint32_t packed =
2287 sp.lo_limb,
2288 sp.hi_limb,
2289 sp.lo_off,
2290 sp.lo_bits,
2291 sp.lo_mask,
2292 sp.hi_mask,
2294 window_bits_w);
2295 ++my_counts[packed & BUCKET_MASK];
2296 }
2297 }
2298 };
2299 {
2300 BB_BENCH_NAME("MSM_fast::Stage1_digit_extract");
2301 if (dedup_known_for_batch) {
2303 num_threads, [&](size_t tid) { stage1_digit_extract.template operator()<true>(tid); });
2304 } else {
2306 num_threads, [&](size_t tid) { stage1_digit_extract.template operator()<false>(tid); });
2307 }
2308 }
2309
2310 // Stage 2 (bucket histogram): per-window per-digit totals + per-thread within-digit
2311 // offsets. Parallelised over digit-chunks; each worker handles its slice of 2^window_bits
2312 // for all windows_in_batch windows. In-place exclusive prefix-sum: each slot
2313 // `digit_cursors[(w*T + t) * stride + d]` is read for its Stage 1 count and then
2314 // overwritten with the running prefix sum (== the cursor base Stage 4 needs). The
2315 // count must be read BEFORE the write or `running` would skip its contribution.
2316 // Phase 5: the per-digit total `running` is written directly into
2317 // `bucket_start_all[w][d+1]` (one cell past where Stage 3 will read), so Stage 3 can
2318 // prefix-sum in place without a separate `bucket_total_counts` buffer. The size_t
2319 // bucket_start cell widens the uint32_t total implicitly.
2320 {
2321 BB_BENCH_NAME("MSM_fast::Stage2_bucket_histogram");
2322 round_parallel_detail::msm_parallel_for(num_threads, [&](size_t tid) {
2323 const size_t d_start = tid * B_R / num_threads;
2324 const size_t d_end = (tid + 1) * B_R / num_threads;
2325 for (size_t w = 0; w < windows_in_batch; ++w) {
2326 size_t* const bucket_start_w = bucket_start_all.data() + (w * (bucket_stride + 1));
2327 for (size_t d = d_start; d < d_end; ++d) {
2328 if (d == 0) {
2329 continue;
2330 }
2331 uint32_t running = 0;
2332 for (size_t t = 0; t < num_threads; ++t) {
2333 const size_t k = (((w * num_threads) + t) * bucket_stride) + d;
2334 const uint32_t cnt = digit_cursors[k];
2335 digit_cursors[k] = running;
2336 running += cnt;
2337 }
2338 bucket_start_w[d + 1] = running;
2339 }
2340 }
2341 });
2342 }
2343
2344 // Stage 3 (bucket offsets / prefix sum): per-window serial prefix sum in place.
2345 // Stage 2 already deposited each digit's per-window total at bucket_start[d+1];
2346 // the loop accumulates the running prefix-sum without a separate counts buffer.
2347 {
2348 BB_BENCH_NAME("MSM_fast::Stage2_3_bucket_offsets");
2349 auto build_bucket_offsets_for_window = [&](size_t w) noexcept {
2350 size_t* bucket_start = bucket_start_all.data() + (w * (bucket_stride + 1));
2351 bucket_start[0] = 0;
2352 bucket_start[1] = 0;
2353 for (size_t d = 1; d < B_R; ++d) {
2354 bucket_start[d + 1] += bucket_start[d];
2355 }
2356 };
2357 const size_t offset_threads = std::min(num_threads, windows_in_batch);
2358 if (offset_threads <= 1) {
2359 for (size_t w = 0; w < windows_in_batch; ++w) {
2360 build_bucket_offsets_for_window(w);
2361 }
2362 } else {
2363 round_parallel_detail::msm_parallel_for(offset_threads, [&](size_t tid) {
2364 for (size_t w = tid; w < windows_in_batch; w += offset_threads) {
2365 build_bucket_offsets_for_window(w);
2366 }
2367 });
2368 }
2369 }
2370
2371 // Stage 4 (digit scatter): scalar-cache-blocked, window-local scatter. Re-decodes each
2372 // (point, window) signed digit via the same Constantine carry-less recoder Stage 1 used.
2373 // Stage 4 stores only `sign | scalar_idx`; bucket magnitude is recovered later from
2374 // bucket_start ranges.
2375 // Stage 1 benefits from full scalar-major order because it only updates compact
2376 // per-window histograms. Stage 4 writes large bucket schedules, so full scalar-major
2377 // order opens too many cold write/cursor streams. Instead, process a scalar tile across
2378 // all windows: scalar/msb/dedup metadata are reused while the tile is cache-hot, but each
2379 // inner loop still scatters to one window's schedule at a time.
2380 //
2381 // First-batch Stage 4 is dedup-unaware: every scalar is emitted as
2382 // `sched_w[idx] = sign | scalar_idx`, then Phase A + patch/compact tags cluster
2383 // reps and removes non-reps. Later batches with known dedup state skip non-reps
2384 // here and emit redirect reps directly.
2385 // Splitting the dedup work out of this hot loop avoids a per-iteration
2386 // closure-indirection chain through `dedup_state.redirect_lookup[i]`
2387 // that the WASM JIT does not hoist (~13 ns/iter penalty observed).
2388 auto stage4_emit = [&]<bool DedupKnown>(size_t tid) noexcept {
2389 [[maybe_unused]] const uint32_t* const rl_data = dedup_state.redirect_lookup.data();
2390 const size_t start = tid * n / num_threads;
2391 const size_t end = (tid + 1) * n / num_threads;
2393 std::array<const size_t*, 128> bucket_starts{};
2394 std::array<uint32_t*, 128> schedules{};
2395 for (size_t w = 0; w < windows_in_batch; ++w) {
2396 cursors[w] = digit_cursors.data() + (((w * num_threads) + tid) * bucket_stride);
2397 bucket_starts[w] = bucket_start_all.data() + (w * (bucket_stride + 1));
2398 schedules[w] = schedule.data() + (w * n);
2399 }
2400
2401 alignas(16) std::array<uint32_t, SIMD_BATCH> packed_buf{};
2402 constexpr size_t STAGE4_SCALAR_TILE = 2048;
2404 [[maybe_unused]] std::array<uint32_t, STAGE4_SCALAR_TILE> out_base_tile{};
2405
2406 for (size_t tile_start = start; tile_start < end; tile_start += STAGE4_SCALAR_TILE) {
2407 const size_t tile_end = std::min(end, tile_start + STAGE4_SCALAR_TILE);
2408 const size_t tile_len = tile_end - tile_start;
2409 for (size_t j = 0; j < tile_len; ++j) {
2410 const size_t scalar_idx = tile_start + j;
2411 const uint8_t m = msb_per_scalar[scalar_idx];
2412 bool include = (m != MSB_ZERO_SENTINEL);
2413 if constexpr (DedupKnown) {
2414 uint32_t out_base = static_cast<uint32_t>(scalar_idx);
2415 if (include) {
2416 const uint32_t patch = rl_data[scalar_idx];
2418 include = (patch & round_parallel_detail::DEDUP_SKIP_BIT) == 0;
2419 out_base = patch;
2420 }
2421 }
2422 out_base_tile[j] = out_base;
2423 }
2424 active_tile[j] = static_cast<uint8_t>(include);
2425 }
2426
2427 for (size_t w = 0; w < windows_in_batch; ++w) {
2428 uint32_t* my_cursor = cursors[w];
2429 const size_t* bucket_start = bucket_starts[w];
2430 uint32_t* sched_w = schedules[w];
2431 size_t i = tile_start;
2432 while (i + SIMD_BATCH <= tile_end) {
2433 const size_t rel = i - tile_start;
2434 uint64_t include_mask = 0;
2435 for (size_t k = 0; k < SIMD_BATCH; ++k) {
2436 include_mask |= static_cast<uint64_t>(active_tile[rel + k]) << k;
2437 }
2438 if (include_mask == 0) {
2439 i += SIMD_BATCH;
2440 continue;
2441 }
2442 fill_packed_digit_buffer(w, i, packed_buf.data());
2443 uint64_t scatter_mask = include_mask;
2444 for (size_t k = 0; k < SIMD_BATCH; ++k) {
2445 if ((scatter_mask & uint64_t{ 1 }) != 0) {
2446 const uint32_t packed = packed_buf[k];
2447 const uint32_t bucket_idx = packed & BUCKET_MASK;
2448 if (bucket_idx != 0) {
2449 const uint32_t idx =
2450 static_cast<uint32_t>(bucket_start[bucket_idx]) + my_cursor[bucket_idx]++;
2452 if constexpr (DedupKnown) {
2453 out |= out_base_tile[rel + k];
2454 } else {
2455 out |= static_cast<uint32_t>(i + k);
2456 }
2457 sched_w[idx] = out;
2458 }
2459 }
2460 scatter_mask >>= 1;
2461 }
2462 i += SIMD_BATCH;
2463 }
2464 for (; i < tile_end; ++i) {
2465 const size_t rel = i - tile_start;
2466 if (active_tile[rel] == 0) {
2467 continue;
2468 }
2469 const round_parallel_detail::ConstantineSliceParams sp = slice_params[w];
2471 scalars[i].data,
2472 sp.lo_limb,
2473 sp.hi_limb,
2474 sp.lo_off,
2475 sp.lo_bits,
2476 sp.lo_mask,
2477 sp.hi_mask,
2479 static_cast<uint32_t>(per_window_bits[w]));
2480 const uint32_t bucket_idx = packed & BUCKET_MASK;
2481 if (bucket_idx != 0) {
2482 const uint32_t idx =
2483 static_cast<uint32_t>(bucket_start[bucket_idx]) + my_cursor[bucket_idx]++;
2485 if constexpr (DedupKnown) {
2486 out |= out_base_tile[rel];
2487 } else {
2488 out |= static_cast<uint32_t>(i);
2489 }
2490 sched_w[idx] = out;
2491 }
2492 }
2493 }
2494 }
2495 };
2496
2497 {
2498 BB_BENCH_NAME("MSM_fast::Stage4_digit_scatter");
2499 if (dedup_known_for_batch) {
2501 num_threads, [&](size_t tid) { stage4_emit.template operator()<true>(tid); });
2502 } else {
2504 num_threads, [&](size_t tid) { stage4_emit.template operator()<false>(tid); });
2505 }
2506 }
2507
2508 // Phase A: schedule-based dedup detection on window 0. Each thread owns a
2509 // contiguous range of window 0's schedule. Detects duplicate clusters via
2510 // consecutive-pair check (same bucket + memcmp on full scalar value), tree-reduces
2511 // members into an aggregate, and publishes results into `dedup_state.extra_points`,
2512 // `dedup_state.redirect_lookup`, and zeroed `msb_per_scalar` entries for non-reps.
2513 // Per-thread cluster-id ranges keep writes disjoint — no atomics needed.
2514 // Phase A: schedule-based dedup detection. Runs at most ONCE per MSM_fast (gated on
2515 // `phase_a_done` from the enclosing function scope). Cluster membership is decided
2516 // by scalar value (memcmp), so any window's bucket-sorted schedule places duplicates
2517 // consecutively — Phase A on this first-batch's window-0 schedule produces the
2518 // correct redirect_lookup + extra_points for all subsequent batches. We deliberately
2519 // do not re-run Phase A per batch: the dedup_state is populated once and reused.
2520 if (dedup_active && windows_in_batch > 0 && !phase_a_done) {
2521 BB_BENCH_NAME("MSM_fast::PhaseA_dedup_detect");
2522 uint32_t* sched_w0 = schedule.data();
2523 // Pre-Phase-A bucket sort: Stage 4 emits each bucket's run in scalar-emit
2524 // order, so different-value scalars that happen to share a window-0 digit
2525 // (bucket collisions are common — c=11 → 2048 buckets vs 60-90k entries)
2526 // interleave with same-value entries and break Phase A's consecutive-pair
2527 // detection. Sorting each bucket's run by scalar value makes same-value
2528 // entries adjacent so the simple consecutive-pair walk finds every cluster.
2529 // Sort cost: per bucket of size K, ~K log K comparisons × 32-byte memcmp;
2530 // for typical K=44 this is ~500 cycles per bucket × 2048 buckets = ~1 ms
2531 // wall (parallelized across threads).
2532 const uint32_t cids_per_thread =
2533 static_cast<uint32_t>(round_parallel_detail::DEDUP_MAX_CLUSTERS / num_threads);
2534 // Hash-based per-bucket dedup detection: every thread owns a
2535 // contiguous bucket range of window-0's schedule and runs an
2536 // open-addressing hash table over that range's long-scalar entries.
2537 // O(K) per bucket, avoids the 32-byte memcmp comparator inside any
2538 // sort, and keeps thread balance uniform because short-scalar
2539 // entries (the source of mega-buckets like digit_0 = 1) are skipped.
2540 // Catches ~99.94 % of long-scalar duplicates against MSM_DUMP's
2541 // theoretical maximum (`dup_input_extras`).
2542 {
2543 BB_BENCH_NAME("MSM_fast::PhaseA_dedup_detect_hash");
2544 const size_t* const w0_bucket_start = bucket_start_all.data();
2545 std::atomic<size_t> dedup_cluster_count{ 0 };
2546 round_parallel_detail::msm_parallel_for(num_threads, [&, w0_bucket_start](size_t tid) noexcept {
2547 const size_t b_lo = 1 + ((tid * (B_R - 1)) / num_threads);
2548 const size_t b_hi = 1 + (((tid + 1) * (B_R - 1)) / num_threads);
2549 const uint32_t cid_lo = static_cast<uint32_t>(tid) * cids_per_thread;
2550 const uint32_t cid_max = cid_lo + cids_per_thread;
2551 const size_t local_clusters = round_parallel_detail::dedup_phase_a_worker_hash<Curve>(
2552 sched_w0,
2553 w0_bucket_start,
2554 b_lo,
2555 b_hi,
2556 std::span<const ScalarField>(scalars.data(), n),
2557 points,
2559 std::span<uint32_t>(dedup_state.redirect_lookup),
2560 msb_per_scalar.data(),
2561 window_bits,
2562 cid_lo,
2563 cid_max,
2564 phase_a_scratch[tid]);
2565 if (local_clusters != 0) {
2566 dedup_cluster_count.fetch_add(local_clusters, std::memory_order_relaxed);
2567 }
2568 });
2569 dedup_state.n_dedup_extras = dedup_cluster_count.load(std::memory_order_relaxed);
2570 }
2571 phase_a_done = true;
2572 }
2573
2574 // Schedule patch post-pass: tags cluster-member entries with SKIP/REDIRECT bits.
2575 // Runs only for the batch that just ran Phase A: later batches with known dedup
2576 // state skip non-reps in Stage 1/4 and emit redirect reps directly.
2577 // Parallel by window (one window per worker) because each window's slice of the
2578 // schedule is disjoint. Hoisting `redirect_lookup.data()` to a raw pointer outside
2579 // the lambda + passing it by value into the inner function avoids the per-iter
2580 // closure-indirection chain that made the inline form 3× slower per iter on WASM.
2581 auto partition_chunks_for_window = [&](size_t w) noexcept {
2582 const size_t* bucket_start = bucket_start_all.data() + (w * (bucket_stride + 1));
2583 const size_t* const bucket_start_end = bucket_start + B_R + 1;
2584 size_t* chunk_start = chunk_start_all.data() + (w * (num_threads + 1));
2585 size_t* chunk_bucket_lo = chunk_bucket_lo_all.data() + (w * (num_threads + 1));
2586 size_t* chunk_bucket_hi = chunk_bucket_hi_all.data() + (w * num_threads);
2587 const size_t m = bucket_start[B_R];
2588 const size_t* search_begin = bucket_start + 1;
2589 size_t lo = 0;
2590 chunk_start[0] = lo;
2591 for (size_t t = 0; t < num_threads; ++t) {
2592 const size_t hi = ((t + 1) == num_threads) ? m : (((t + 1) * m) / num_threads);
2593 chunk_start[t + 1] = hi;
2594 if (lo < hi) {
2595 const size_t* const lo_it = std::upper_bound(search_begin, bucket_start_end, lo);
2596 const size_t lo_bucket = static_cast<size_t>(lo_it - bucket_start - 1);
2597 const size_t* const hi_it = std::upper_bound(lo_it, bucket_start_end, hi - 1);
2598 const size_t hi_bucket = static_cast<size_t>(hi_it - bucket_start - 1);
2599 chunk_bucket_lo[t] = lo_bucket;
2600 chunk_bucket_hi[t] = hi_bucket;
2601 search_begin = hi_it;
2602 } else {
2603 chunk_bucket_lo[t] = B_R;
2604 chunk_bucket_hi[t] = 0;
2605 }
2606 lo = hi;
2607 }
2608 chunk_bucket_lo[num_threads] = B_R;
2609 };
2610
2611 bool chunk_partition_done = false;
2612 if (dedup_active && windows_in_batch > 0 && phase_a_done && !phase_a_done_at_batch_start) {
2613 BB_BENCH_NAME("MSM_fast::dedup_patch_schedule");
2614 const uint32_t* const rl_data = dedup_state.redirect_lookup.data();
2615 const size_t bs_stride = bucket_stride + 1;
2616 const size_t br = B_R;
2617 const size_t cap_R = n;
2619 num_threads, [&, rl_data, bs_stride, br, cap_R](size_t tid) noexcept {
2620 for (size_t w = tid; w < windows_in_batch; w += num_threads) {
2621 uint32_t* sched_w = schedule.data() + (w * cap_R);
2622 size_t* bucket_start_w = bucket_start_all.data() + (w * bs_stride);
2623 round_parallel_detail::dedup_patch_schedule_window<Curve>(sched_w, bucket_start_w, br, rl_data);
2624 partition_chunks_for_window(w);
2625 }
2626 });
2627 chunk_partition_done = true;
2628 }
2629
2630 // Per-window chunk partition at schedule-index granularity (chunk_start[t] = t·m/T).
2631 // Balances across threads regardless of bucket-distribution skew. When the partition
2632 // lands mid-bucket, both adjacent threads build their own partial into the boundary
2633 // bucket; chunk_contribution combines them in Stage 7.
2634 {
2635 BB_BENCH_NAME("MSM_fast::Stage5_chunk_partition");
2636 if (!chunk_partition_done) {
2637 for (size_t w = 0; w < windows_in_batch; ++w) {
2638 partition_chunks_for_window(w);
2639 }
2640 }
2641 }
2642
2643 // Stage 6 bucket accumulation per thread:
2644 // (1) For each window w: reduce_chunk emits a digit-sorted (point, digit) list,
2645 // which we densify into a per-window dense bucket array at
2646 // tid's affine bucket buffer + w * stride. Empty slots stay identity.
2647 // (2) Call recursive_affine_bucket_reduce_strided once across all windows_in_batch
2648 // chunks; it computes (R_w, L_w) for each non-empty chunk via batch-affine
2649 // arithmetic, amortising the inversion across windows at every phase step.
2650 // (3) chunk_contribution(out) folds L_w + (lo_w-1)·R_w into the thread's per-window
2651 // partial.
2652 // The Stage-6 scratch is pre-sized for every thread BEFORE entering the parallel_for
2653 // so the per-thread vector resizes don't race the heap allocator.
2654 auto next_pow2 = [](size_t x) -> size_t {
2655 if (x <= 1) {
2656 return 1;
2657 }
2658 size_t p = 1;
2659 while (p < x) {
2660 p <<= 1;
2661 }
2662 return p;
2663 };
2664 size_t max_chunk_len = 0;
2665 for (size_t t = 0; t < num_threads; ++t) {
2666 for (size_t w = 0; w < windows_in_batch; ++w) {
2667 const size_t* chunk_start = chunk_start_all.data() + (w * (num_threads + 1));
2668 const size_t entries_in_chunk = chunk_start[t + 1] - chunk_start[t];
2669 if (entries_in_chunk == 0) {
2670 continue;
2671 }
2672 max_chunk_len = std::max(max_chunk_len, entries_in_chunk);
2673 }
2674 }
2675
2676 // global_stride drives the per-thread `dense_buckets` layout (assigned to each thread's
2677 // `affine_bucket_stride` below). Stage 6a writes its per-thread bucket
2678 // partials into `bucket_partials_dense` (a separate buffer packed via
2679 // `bucket_partials_offsets`, no power-of-two stride); Stage 6b copies them into
2680 // `s.dense_buckets` keyed by Stage 6b's uniform bucket-index slice of width
2681 // `buckets_per_task ≈ ⌈(num_buckets-1)/T⌉`. The recursive bucket-reduction
2682 // algorithm (phases A-D) operates on `s.dense_buckets` with power-of-two row
2683 // stride — that's where `next_pow2` matters.
2684 size_t global_stride = 0;
2685
2686 {
2687 // Stage 6b's bucket-balanced partition. Uniform across windows: each rebalanced
2688 // task t' owns active digits [d_lo'[t'], d_hi'[t']] where d_lo'[t'] = 1 + t · (B-1) / T.
2689 const size_t active_digits = (B_R > 0) ? (B_R - 1) : 0;
2690 for (size_t t = 0; t <= num_threads; ++t) {
2691 rebalanced_bucket_lo_partition[t] = 1 + (t * active_digits) / num_threads;
2692 }
2693 rebalanced_bucket_lo_partition[num_threads] = B_R;
2694 size_t max_buckets_per_task = 0;
2695 for (size_t t = 0; t + 1 <= num_threads; ++t) {
2696 const size_t hi_d = (t + 1 == num_threads) ? (B_R - 1) : (rebalanced_bucket_lo_partition[t + 1] - 1);
2697 const size_t lo_d = rebalanced_bucket_lo_partition[t];
2698 if (hi_d >= lo_d) {
2699 max_buckets_per_task = std::max(max_buckets_per_task, hi_d - lo_d + 1);
2700 }
2701 }
2702 global_stride = next_pow2(max_buckets_per_task);
2703 global_stride = std::max<size_t>(global_stride, 2);
2704
2705 // Per-window orig-thread contributing ranges (O(W·T·T) total — only paid for
2706 // the rebalance path, where T is small enough that this is sub-µs).
2707 for (size_t w = 0; w < windows_in_batch; ++w) {
2708 const size_t* chunk_bucket_lo = chunk_bucket_lo_all.data() + (w * (num_threads + 1));
2709 const size_t* chunk_bucket_hi = chunk_bucket_hi_all.data() + (w * num_threads);
2710 const size_t* chunk_start_w = chunk_start_all.data() + (w * (num_threads + 1));
2711 for (size_t tprime = 0; tprime < num_threads; ++tprime) {
2712 const size_t lo_d = rebalanced_bucket_lo_partition[tprime];
2713 const size_t hi_d =
2714 (tprime + 1 == num_threads) ? (B_R - 1) : (rebalanced_bucket_lo_partition[tprime + 1] - 1);
2715 size_t lo_orig = num_threads;
2716 size_t hi_orig = 0;
2717 for (size_t t = 0; t < num_threads; ++t) {
2718 const size_t entries = chunk_start_w[t + 1] - chunk_start_w[t];
2719 if (entries == 0) {
2720 continue;
2721 }
2722 const size_t cl = chunk_bucket_lo[t];
2723 const size_t ch = chunk_bucket_hi[t];
2724 if (ch < lo_d || cl > hi_d) {
2725 continue;
2726 }
2727 if (lo_orig == num_threads) {
2728 lo_orig = t;
2729 }
2730 hi_orig = t;
2731 }
2732 orig_thread_lo[(w * num_threads) + tprime] = lo_orig;
2733 orig_thread_hi[(w * num_threads) + tprime] = hi_orig;
2734 }
2735 }
2736
2737 // bucket_partials_dense / _present packed via bucket_partials_offsets — each
2738 // (thread, window) row holds exactly buckets_per_thread[t][w] AffineElements (no
2739 // padding). The arena pre-sized to `windows_per_batch · (num_buckets - 1 + T)`
2740 // (covers the T-1 boundary-bucket shares); only the actual prefix is touched.
2741 size_t bucket_partials_cursor = 0;
2742 for (size_t t = 0; t < num_threads; ++t) {
2743 for (size_t w = 0; w < windows_in_batch; ++w) {
2744 bucket_partials_offsets[(t * windows_in_batch) + w] = bucket_partials_cursor;
2745 const size_t* chunk_bucket_lo_w = chunk_bucket_lo_all.data() + (w * (num_threads + 1));
2746 const size_t* chunk_bucket_hi_w = chunk_bucket_hi_all.data() + (w * num_threads);
2747 const size_t* chunk_start_w = chunk_start_all.data() + (w * (num_threads + 1));
2748 const size_t entries = chunk_start_w[t + 1] - chunk_start_w[t];
2749 if (entries > 0) {
2750 bucket_partials_cursor += chunk_bucket_hi_w[t] - chunk_bucket_lo_w[t] + 1;
2751 }
2752 }
2753 }
2754 bucket_partials_offsets[num_threads * windows_in_batch] = bucket_partials_cursor;
2755 const size_t bucket_partials_total = bucket_partials_cursor;
2756 BB_ASSERT_LTE(bucket_partials_total, bucket_partials_dense.size());
2757 std::memset(bucket_partials_present.data(), 0, bucket_partials_total);
2758 }
2759
2760 // thread_scratch is worker-indexed (one slot per OS thread, FIFO-shared by tasks);
2761 // update the stride on each worker's slot.
2762 for (size_t t = 0; t < worker_total; ++t) {
2763 thread_scratch[t].affine_bucket_stride = global_stride;
2764 }
2765
2766 {
2767 // Stage 6a — per-thread bucket partials. Each thread `tid` reduces its schedule
2768 // slice via reduce_chunk and scatters the (digit, point) output directly into the
2769 // per-thread dense bucket buffer at slot `(digit - chunk_bucket_lo[tid])`. Stage
2770 // 6b then reads this buffer with O(1) slot lookup. `bucket_partials_present` is
2771 // pre-zeroed per batch.
2772 auto bucket_partials_per_thread_lambda = [&](size_t tid) {
2773 auto& s = thread_scratch[tid];
2774 for (size_t w = 0; w < windows_in_batch; ++w) {
2775 const size_t* chunk_start_w = chunk_start_all.data() + (w * (num_threads + 1));
2776 const size_t cs_lo = chunk_start_w[tid];
2777 const size_t cs_hi = chunk_start_w[tid + 1];
2778 if (cs_lo == cs_hi) {
2779 continue;
2780 }
2781 const uint32_t* sched_w = schedule.data() + (w * n);
2782 const size_t* bucket_start = bucket_start_all.data() + (w * (bucket_stride + 1));
2783 AffineElement* dst_dense =
2784 bucket_partials_dense.data() + bucket_partials_offsets[(tid * windows_in_batch) + w];
2785 uint8_t* dst_present =
2786 bucket_partials_present.data() + bucket_partials_offsets[(tid * windows_in_batch) + w];
2787 const size_t* chunk_bucket_lo = chunk_bucket_lo_all.data() + (w * (num_threads + 1));
2788 const uint32_t my_lo = static_cast<uint32_t>(chunk_bucket_lo[tid]);
2789 const size_t my_hi = chunk_bucket_hi_all[(w * num_threads) + tid];
2790 size_t bucket_cursor = my_lo;
2791
2792 for (size_t pos = cs_lo; pos < cs_hi;) {
2793 const size_t end = std::min(pos + SUBCHUNK_ENTRIES_CAP, cs_hi);
2794 reduce_chunk<Curve>(s,
2795 sched_w,
2796 bucket_start,
2797 pos,
2798 end,
2799 bucket_cursor,
2800 my_hi,
2801 points,
2802 std::span<const AffineElement>(dedup_state.extra_points));
2803 const size_t len = s.result_len;
2804 for (size_t k = 0; k < len; ++k) {
2805 const uint32_t d = s.curr_buckets[k];
2806 const size_t slot = d - my_lo;
2807 if (dst_present[slot]) {
2808 s.overflow_slots[s.overflow_len] = static_cast<uint32_t>(slot);
2809 s.overflow_pts[s.overflow_len] = s.curr_pts[k];
2810 ++s.overflow_len;
2811 } else {
2812 dst_dense[slot] = s.curr_pts[k];
2813 dst_present[slot] = 1;
2814 }
2815 }
2816 pos = end;
2817 }
2818 merge_overflow<Curve>(s, dst_dense);
2819 }
2820 };
2821
2822 // Stage 6b (cross-thread bucket reduction): each rebalanced task `tprime` owns a
2823 // uniform-width slice of the bucket-index space [d_lo'(tprime), d_hi'(tprime)].
2824 // For each window in the batch, walk the contributing original threads' Stage 6a
2825 // dense outputs (range [orig_thread_lo, orig_thread_hi]), filter to digits in
2826 // this task's slice, scatter into the task's local dense_buckets (with
2827 // projective-add accumulation on the at-most-2 boundary digits per pair of
2828 // contributing originals), then run recursive_affine_bucket_reduce_strided +
2829 // chunk_contribution on a guaranteed-equal buckets_padded across all tasks.
2830 auto bucket_reduce_cross_thread_lambda = [&](size_t tprime) {
2831 auto& s = thread_scratch[tprime];
2832 Element* my_partials = window_partial_sums.data() + (tprime * windows_per_batch);
2833 for (size_t w = 0; w < windows_in_batch; ++w) {
2834 my_partials[w] = Curve::Group::point_at_infinity;
2835 }
2836
2837 const size_t stride = s.affine_bucket_stride;
2838 std::memset(s.is_present.data(), 0, windows_in_batch * stride);
2839
2840 const size_t lo_d = rebalanced_bucket_lo_partition[tprime];
2841 const size_t hi_d =
2842 (tprime + 1 == num_threads) ? (B_R - 1) : (rebalanced_bucket_lo_partition[tprime + 1] - 1);
2843 const uint32_t lo_d_u = static_cast<uint32_t>(lo_d);
2844 const uint32_t hi_d_u = static_cast<uint32_t>(hi_d);
2845
2846 bool any_nonempty = false;
2847 for (size_t w = 0; w < windows_in_batch; ++w) {
2848 auto& info = s.chunk_infos[w];
2849 auto& out = chunk_outputs[(w * num_threads) + tprime];
2850 if (lo_d > hi_d) {
2851 info.empty = 1;
2852 info.lo = 0;
2853 info.hi = 0;
2854 info.buckets_padded = 0;
2855 out.empty = 1;
2856 continue;
2857 }
2858 const size_t orig_lo = orig_thread_lo[(w * num_threads) + tprime];
2859 const size_t orig_hi = orig_thread_hi[(w * num_threads) + tprime];
2860 if (orig_lo == num_threads) {
2861 info.empty = 1;
2862 info.lo = 0;
2863 info.hi = 0;
2864 info.buckets_padded = 0;
2865 out.empty = 1;
2866 continue;
2867 }
2868 const size_t base = w * stride;
2869 bool has_data = false;
2870
2871 // bucket_partials_dense holds per-(orig_t, w, slot) bucket points with
2872 // bucket_partials_present as the populated-slot bitmap. For each
2873 // contributing orig_t, intersect its [chunk_bucket_lo, chunk_bucket_hi]
2874 // range with this task's [lo_d, hi_d] slice and walk the intersection
2875 // only — no sorted scan, O(1) lookup per slot.
2876 const size_t* chunk_bucket_lo_w = chunk_bucket_lo_all.data() + (w * (num_threads + 1));
2877 const size_t* chunk_bucket_hi_w = chunk_bucket_hi_all.data() + (w * num_threads);
2878 for (size_t t = orig_lo; t <= orig_hi; ++t) {
2879 const size_t cl = chunk_bucket_lo_w[t];
2880 const size_t ch = chunk_bucket_hi_w[t];
2881 const size_t d_lo_clip = std::max<size_t>(lo_d, cl);
2882 const size_t d_hi_clip = std::min<size_t>(hi_d, ch);
2883 if (d_lo_clip > d_hi_clip) {
2884 continue;
2885 }
2886 const AffineElement* src_dense =
2887 bucket_partials_dense.data() + bucket_partials_offsets[(t * windows_in_batch) + w];
2888 const uint8_t* src_present =
2889 bucket_partials_present.data() + bucket_partials_offsets[(t * windows_in_batch) + w];
2890 for (size_t d = d_lo_clip; d <= d_hi_clip; ++d) {
2891 const size_t src_slot = d - cl;
2892 if (src_present[src_slot] == 0) {
2893 continue;
2894 }
2895 const size_t dst_slot = base + (d - lo_d);
2896 if (s.is_present[dst_slot] == 0) {
2897 // src_dense is the AoS Stage-6a partials; transpose into the SoA columns.
2898 s.dense_buckets.x[dst_slot] = src_dense[src_slot].x;
2899 s.dense_buckets.y[dst_slot] = src_dense[src_slot].y;
2900 s.is_present[dst_slot] = 1;
2901 } else {
2902 // Boundary digit shared between two consecutive originals
2903 // — projective add then re-normalise to affine. Under the
2904 // contiguous-by-schedule-index partition there are at most
2905 // W boundary points per task.
2906 Element acc =
2907 Element(AffineElement(s.dense_buckets.x[dst_slot], s.dense_buckets.y[dst_slot]));
2908 acc += Element(src_dense[src_slot]);
2909 const AffineElement merged(acc);
2910 s.dense_buckets.x[dst_slot] = merged.x;
2911 s.dense_buckets.y[dst_slot] = merged.y;
2912 }
2913 has_data = true;
2914 }
2915 }
2916 if (!has_data) {
2917 info.empty = 1;
2918 info.lo = 0;
2919 info.hi = 0;
2920 info.buckets_padded = 0;
2921 out.empty = 1;
2922 continue;
2923 }
2924 any_nonempty = true;
2925 const size_t M = hi_d - lo_d + 1;
2926 const uint32_t buckets_padded =
2927 (M == 1) ? 1 : (uint32_t{ 1 } << (32 - __builtin_clz(static_cast<uint32_t>(M - 1))));
2928 info.empty = 0;
2929 info.lo = lo_d_u;
2930 info.hi = hi_d_u;
2931 info.buckets_padded = buckets_padded;
2932 out.empty = 0;
2933 out.lo = lo_d_u;
2934 out.hi = hi_d_u;
2935 }
2936
2937 if (!any_nonempty) {
2938 return;
2939 }
2940
2941 round_parallel_detail::recursive_affine_bucket_reduce_strided<Curve>(s,
2942 s.chunk_infos.data(),
2943 windows_in_batch,
2944 chunk_outputs.data() + tprime,
2945 num_threads,
2946 /*single_threaded=*/num_threads ==
2947 1);
2948
2949 for (size_t w = 0; w < windows_in_batch; ++w) {
2950 auto& out = chunk_outputs[(w * num_threads) + tprime];
2951 if (out.empty == 0) {
2952 my_partials[w] = round_parallel_detail::chunk_contribution<Curve>(out);
2953 }
2954 }
2955 };
2956
2957 {
2958 BB_BENCH_NAME("MSM_fast::Stage6a_bucket_partials");
2959 round_parallel_detail::msm_parallel_for(num_threads, bucket_partials_per_thread_lambda);
2960 }
2961 {
2962 BB_BENCH_NAME("MSM_fast::Stage6b_reduce_cross_thread");
2963 round_parallel_detail::msm_parallel_for(num_threads, bucket_reduce_cross_thread_lambda);
2964 }
2965 }
2966
2967 // Stage 7 (cross-window combine): per-window reduce of `num_threads` per-thread partials.
2968 // (Algebraic identity: `Σ_t (L_t + (lo_t − 1) · R_t) = window's bucket sum`,
2969 // with the per-chunk contributions already accumulated above.)
2970 {
2971 BB_BENCH_NAME("MSM_fast::Stage7_combine");
2972 const size_t reduce_threads = std::min(num_threads, windows_in_batch);
2973 round_parallel_detail::msm_parallel_for(reduce_threads, [&](size_t rid) {
2974 const size_t lo = rid * windows_in_batch / reduce_threads;
2975 const size_t hi = (rid + 1) * windows_in_batch / reduce_threads;
2976 for (size_t w = lo; w < hi; ++w) {
2977 Element sum = Curve::Group::point_at_infinity;
2978 for (size_t tid = 0; tid < num_threads; ++tid) {
2979 sum += window_partial_sums[(tid * windows_per_batch) + w];
2980 }
2981 window_sums[batch_start + w] = sum;
2982 }
2983 });
2984 }
2985 };
2986
2987 {
2988 const size_t B_R = (size_t{ 1 } << (window_bits - 1)) + 1;
2989 for (size_t batch_start = 0; batch_start < sched.num_windows; batch_start += windows_per_batch) {
2990 const size_t windows_in_batch = std::min(windows_per_batch, sched.num_windows - batch_start);
2991 run_batch(batch_start, windows_in_batch, B_R);
2992 }
2993 }
2994
2995 // Stage 7 horner: walk high-to-low, doubling by `window_bits_per_window[w]` between adjacent windows.
2996 // Init from the top window to skip a wasted doubling on identity.
2997 Element result = (sched.num_windows == 0) ? Curve::Group::point_at_infinity : window_sums[sched.num_windows - 1];
2998 for (size_t w_rev = sched.num_windows - 1; w_rev > 0; --w_rev) {
2999 const size_t window_bits_w = sched.window_bits_per_window[w_rev - 1];
3000 for (size_t d = 0; d < window_bits_w; ++d) {
3001 result.self_dbl();
3002 }
3003 result += window_sums[w_rev - 1];
3004 }
3005
3006 // GLV path leaves input_scalars untouched (it reads via from_montgomery_form_reduced into
3007 // a temporary). Non-GLV path mutated in place above and must restore.
3008 if (!use_glv) {
3009 round_parallel_detail::msm_parallel_for(hw_threads, [&](const ThreadChunk& chunk) {
3010 for (size_t i : chunk.range(n_input)) {
3011 input_scalars[i].self_to_montgomery_form();
3012 }
3013 });
3014 }
3015
3016 return result;
3017}
3018
3019template <typename Curve>
3022 size_t dedup_info) noexcept
3023{
3024 return pippenger_round_parallel<Curve>(scalars, points, dedup_info);
3025}
3026
3027template <typename Curve>
3030 bool handle_edge_cases,
3031 size_t dedup_info) noexcept
3032{
3033 using Element = typename Curve::Element;
3034 using ScalarField = typename Curve::ScalarField;
3035 if (!handle_edge_cases) {
3036 return pippenger_round_parallel<Curve>(scalars, points, dedup_info);
3037 }
3038 // Edge-case-handling path: route through the Jacobian fast-path. It uses
3039 // Jacobian additions throughout, so point-at-infinity and equal-x bucket
3040 // collisions don't trigger the affine-add edge-case bug. We need to convert
3041 // PolynomialSpan to a plain ScalarField span: the jacobian fast-path takes
3042 // a contiguous std::span and ignores `start_index`.
3043 const size_t n = scalars.span.size();
3044 if (n == 0) {
3045 return Curve::Group::point_at_infinity;
3046 }
3047 // Trivially small N: skip Pippenger / Jacobian-fast-path scaffolding entirely.
3048 // Affine operator* + Jacobian sum already handles all edge cases.
3049 if (n < 4) {
3050 return trivial_msm<Curve>(scalars, points);
3051 }
3052 const auto& start = scalars.start_index;
3053 if (start >= points.size()) {
3054 return Curve::Group::point_at_infinity;
3055 }
3056 const size_t n_used = std::min<size_t>(n, points.size() - start);
3057 std::span<const typename Curve::AffineElement> point_slice(points.data() + start, n_used);
3058 std::span<const ScalarField> scalar_slice(scalars.span.data(), n_used);
3059 // Convert scalars to non-Montgomery form for the jacobian path's bit-extraction loop,
3060 // then restore. Mirrors the round-parallel fast-path's scalar lifecycle.
3061 // Use the `_reduced` variant: the bit-extraction loop reads only bits 0..253
3062 // (NUM_BITS = 254). Plain `self_from_montgomery_form` leaves the value in [0, 2p),
3063 // so values in [2^254, 2p) would have bit 254 set and silently drop the contribution
3064 // of that bit. `_reduced` brings the value into [0, p) ⊂ [0, 2^254).
3065 auto* mutable_scalars =
3066 const_cast<ScalarField*>(scalar_slice.data()); // NOLINT(cppcoreguidelines-pro-type-const-cast)
3067 bb::parallel_for(bb::get_num_cpus(), [&](const ThreadChunk& chunk) {
3068 for (size_t i : chunk.range(n_used)) {
3069 mutable_scalars[i].self_from_montgomery_form_reduced();
3070 }
3071 });
3072 const Element result =
3073 round_parallel_detail::pippenger_round_parallel_jacobian_fast<Curve>(scalar_slice, point_slice, 0, 0);
3074 bb::parallel_for(bb::get_num_cpus(), [&](const ThreadChunk& chunk) {
3075 for (size_t i : chunk.range(n_used)) {
3076 mutable_scalars[i].self_to_montgomery_form();
3077 }
3078 });
3079 return result;
3080}
3081
3082template <typename Curve>
3085 bool handle_edge_cases,
3086 size_t dedup_info) noexcept
3087{
3088 return AffineElement(pippenger_fast<Curve>(scalars, points, handle_edge_cases, dedup_info));
3089}
3090
3091#include "./pippenger_batched.hpp"
3092
3093// Explicit instantiations.
3097 size_t dedup_info) noexcept;
3101 size_t dedup_info) noexcept;
3104 bool handle_edge_cases,
3105 size_t dedup_info) noexcept;
3109 bool handle_edge_cases,
3110 size_t dedup_info) noexcept;
3111template class MSM_fast<curve::BN254>;
3112template class MSM_fast<curve::Grumpkin>;
3113
3117 size_t dedup_info,
3119 std::span<std::byte> external_arena,
3120 size_t max_threads) noexcept;
3121
3125 size_t dedup_info,
3127 std::span<std::byte> external_arena,
3128 size_t max_threads) noexcept;
3129
3133
3137
3141 size_t max_threads) noexcept;
3142
3146 size_t max_threads) noexcept;
3147
3148namespace round_parallel_detail {
3149template curve::BN254::Element pippenger_round_parallel_jacobian_fast<curve::BN254>(
3152 size_t min_pts_per_thread_override,
3153 size_t max_threads) noexcept;
3154
3155template curve::Grumpkin::Element pippenger_round_parallel_jacobian_fast<curve::Grumpkin>(
3158 size_t min_pts_per_thread_override,
3159 size_t max_threads) noexcept;
3160} // namespace round_parallel_detail
3161
3162template size_t compute_arena_bytes_for_msm<curve::BN254>(size_t, bool, bool, size_t) noexcept;
3163
3164} // namespace bb::scalar_multiplication
#define BB_ASSERT_GTE(left, right,...)
Definition assert.hpp:128
#define BB_ASSERT_GT(left, right,...)
Definition assert.hpp:113
#define BB_ASSERT_EQ(actual, expected,...)
Definition assert.hpp:83
#define BB_ASSERT_LTE(left, right,...)
Definition assert.hpp:158
#define BB_BENCH_NAME(name)
Definition bb_bench.hpp:264
typename Group::element Element
Definition bn254.hpp:21
typename Group::affine_element AffineElement
Definition bn254.hpp:22
typename Group::element Element
Definition grumpkin.hpp:63
typename Group::affine_element AffineElement
Definition grumpkin.hpp:64
static AffineElement msm(std::span< const AffineElement > points, PolynomialSpan< const ScalarField > scalars, bool handle_edge_cases=false, size_t dedup_info=0) noexcept
Single MSM_fast convenience wrapper — returns the result as an AffineElement.
#define info(...)
Definition log.hpp:93
FF a
FF b
void batch_affine_add_indexed_scalar(AffineColumnSpan< Field > &buckets, const std::pair< uint32_t, uint32_t > *pairs, size_t num_pairs, Field *scratch) noexcept
void batch_affine_add(const VectorAffineElementPushSpan< Params > &lhs, const VectorAffineElementPushSpan< Params > &rhs, VectorAffineElementPushSpan< Params > &out, BatchAffineAddScratch< Params > &s) noexcept
void batch_affine_double_indexed_scalar(AffineColumnSpan< Field > &buckets, const uint32_t *indices, size_t num_points, Field *scratch) noexcept
void batch_affine_double_indexed_packed(AffineColumnSpan< typename VectorField< Params >::Field > &buckets, const uint32_t *indices, size_t num_points, VectorAffineElementPushSpan< Params > &in, VectorAffineElementPushSpan< Params > &out, BatchAffineDoubleScratch< Params > &scratch) noexcept
void batch_affine_add_indexed_packed(AffineColumnSpan< typename VectorField< Params >::Field > &buckets, const std::pair< uint32_t, uint32_t > *pairs, size_t num_pairs, VectorAffineElementPushSpan< Params > &lhs, VectorAffineElementPushSpan< Params > &rhs, VectorAffineElementPushSpan< Params > &out, BatchAffineAddScratch< Params > &scratch) noexcept
uint32_t get_constantine_packed_digit(const uint64_t *scalar_data, uint32_t lo_limb, uint32_t hi_limb, uint32_t lo_off, uint32_t lo_bits, uint32_t lo_mask, uint32_t hi_mask, bool slice_localised_to_one_u64, size_t window_bits) noexcept
Read (window_bits+1) bits from scalar_data (uint64 limbs) using precomputed slice params and apply Co...
WindowSchedule build_window_schedule(size_t num_bits, size_t window_bits) noexcept
void msm_parallel_for(size_t num_threads, F &&body) noexcept
Small-N fast-path: per-thread Jacobian Pippenger over a partition of the input.
ConstantineSlicePath classify_slice_path_u32(const ConstantineSliceParamsU32 &sp) noexcept
size_t compute_global_max_overflow_per_window(size_t n, size_t num_threads, size_t subchunk_entries_cap) noexcept
size_t compute_phase_one_prologue_bytes(size_t n, bool use_glv, bool inline_glv_double, size_t profile_threads) noexcept
void store_constantine_packed_digits_x4_bottom(uint32_t *dst, const uint32_t *scalar_data_0, const uint32_t *scalar_data_1, const uint32_t *scalar_data_2, const uint32_t *scalar_data_3, uint32_t hi_limb, uint32_t lo_bits, SimdU32x4 hi_mask_v, SimdU32x4 one_v, SimdU32x4 val_mask, uint32_t window_bits) noexcept
size_t solve_wpb(size_t per_window_bytes, size_t available_budget, size_t W_R) noexcept
Curve::Element pippenger_round_parallel_jacobian_fast(std::span< const typename Curve::ScalarField > scalars, std::span< const typename Curve::AffineElement > points, size_t min_pts_per_thread_override, size_t max_threads) noexcept
Single-MSM_fast, no-affine-trick Pippenger over window_bits-wide windows.
void store_constantine_packed_digits_x4_boundary(uint32_t *dst, const uint32_t *scalar_data_0, const uint32_t *scalar_data_1, const uint32_t *scalar_data_2, const uint32_t *scalar_data_3, uint32_t lo_limb, uint32_t hi_limb, uint32_t lo_off, uint32_t lo_bits, SimdU32x4 lo_mask_v, SimdU32x4 hi_mask_v, SimdU32x4 one_v, SimdU32x4 val_mask, uint32_t window_bits) noexcept
size_t compute_bucket_partials_max(size_t B_eff, size_t num_threads) noexcept
uint32_t __attribute__((vector_size(16))) SimdU32x4
PhaseACaps compute_phase_a_caps(size_t n, size_t num_threads) noexcept
ConstantineSliceParams compute_constantine_slice_params(size_t bit_offset, size_t window_bits, size_t num_uint64_limbs) noexcept
void store_constantine_packed_digits_x4_localised(uint32_t *dst, const uint32_t *scalar_data_0, const uint32_t *scalar_data_1, const uint32_t *scalar_data_2, const uint32_t *scalar_data_3, uint32_t lo_limb, uint32_t lo_off, SimdU32x4 lo_mask_v, SimdU32x4 one_v, SimdU32x4 val_mask, uint32_t window_bits) noexcept
size_t compute_dense_stride(size_t B_eff, size_t num_threads) noexcept
uint32_t choose_window_bits(size_t num_points, size_t num_bits, size_t n_input, size_t num_logical_threads) noexcept
ConstantineSliceParamsU32 compute_constantine_slice_params_u32(size_t bit_offset, size_t window_bits, size_t num_u32_limbs) noexcept
template curve::Grumpkin::Element trivial_msm_threaded< curve::Grumpkin >(PolynomialSpan< const curve::Grumpkin::ScalarField > scalars_span, std::span< const curve::Grumpkin::AffineElement > all_points, size_t max_threads) noexcept
Curve::Element pippenger_unsafe_fast(PolynomialSpan< const typename Curve::ScalarField > scalars, std::span< const typename Curve::AffineElement > points, size_t dedup_info) noexcept
template curve::BN254::Element pippenger_round_parallel< curve::BN254 >(PolynomialSpan< const curve::BN254::ScalarField > scalars, std::span< const curve::BN254::AffineElement > points, size_t dedup_info, std::span< const curve::BN254::AffineElement > external_glv_doubled, std::span< std::byte > external_arena, size_t max_threads) noexcept
size_t compute_arena_bytes_for_msm(size_t n_input, bool external_glv_provided, bool dedup_active, size_t max_threads) noexcept
Round-parallel Pippenger MSM_fast. Windows process sequentially (high-to-low) but each window is full...
template curve::Grumpkin::Element pippenger_round_parallel< curve::Grumpkin >(PolynomialSpan< const curve::Grumpkin::ScalarField > scalars, std::span< const curve::Grumpkin::AffineElement > points, size_t dedup_info, std::span< const curve::Grumpkin::AffineElement > external_glv_doubled, std::span< std::byte > external_arena, size_t max_threads) noexcept
template size_t compute_arena_bytes_for_msm< curve::BN254 >(size_t, bool, bool, size_t) noexcept
template curve::Grumpkin::Element trivial_msm< curve::Grumpkin >(PolynomialSpan< const curve::Grumpkin::ScalarField > scalars_span, std::span< const curve::Grumpkin::AffineElement > all_points) noexcept
template curve::BN254::Element trivial_msm_threaded< curve::BN254 >(PolynomialSpan< const curve::BN254::ScalarField > scalars_span, std::span< const curve::BN254::AffineElement > all_points, size_t max_threads) noexcept
template curve::BN254::Element trivial_msm< curve::BN254 >(PolynomialSpan< const curve::BN254::ScalarField > scalars_span, std::span< const curve::BN254::AffineElement > all_points) noexcept
template curve::Grumpkin::Element pippenger_unsafe_fast< curve::Grumpkin >(PolynomialSpan< const curve::Grumpkin::ScalarField > scalars, std::span< const curve::Grumpkin::AffineElement > points, size_t dedup_info) noexcept
template curve::BN254::Element pippenger_unsafe_fast< curve::BN254 >(PolynomialSpan< const curve::BN254::ScalarField > scalars, std::span< const curve::BN254::AffineElement > points, size_t dedup_info) noexcept
template curve::BN254::Element pippenger_fast< curve::BN254 >(PolynomialSpan< const curve::BN254::ScalarField > scalars, std::span< const curve::BN254::AffineElement > points, bool handle_edge_cases, size_t dedup_info) noexcept
size_t window_bits_tuning_oversub_factor(size_t n_input)
N-dependent oversubscription factor used ONLY for choose_window_bits' target_load formula (not for ac...
Curve::Element pippenger_fast(PolynomialSpan< const typename Curve::ScalarField > scalars, std::span< const typename Curve::AffineElement > points, bool handle_edge_cases, size_t dedup_info) noexcept
template curve::Grumpkin::Element pippenger_fast< curve::Grumpkin >(PolynomialSpan< const curve::Grumpkin::ScalarField > scalars, std::span< const curve::Grumpkin::AffineElement > points, bool handle_edge_cases, size_t dedup_info) noexcept
Curve::Element pippenger_round_parallel(PolynomialSpan< const typename Curve::ScalarField > scalars_span, std::span< const typename Curve::AffineElement > all_points, size_t dedup_info, std::span< const typename Curve::AffineElement > external_glv_doubled, std::span< std::byte > external_arena, size_t max_threads) noexcept
State of the art pippenger_fast multiscalar multiplication algorithm.
size_t get_num_cpus()
Definition thread.cpp:34
C slice(C const &container, size_t start)
Definition container.hpp:9
Inner sum(Cont< Inner, Args... > const &in)
Definition container.hpp:70
void parallel_for(size_t num_iterations, const std::function< void(size_t)> &func)
Definition thread.cpp:112
constexpr void g(state_array &state, size_t a, size_t b, size_t c, size_t d, uint32_t x, uint32_t y)
constexpr decltype(auto) get(::tuplet::tuple< T... > &&t) noexcept
Definition tuple.hpp:13
uint8_t len
std::span< uint32_t > affine_bucket_indices
uintptr_t base_addr
std::byte * data
std::span< BaseField > affine_bucket_inversion_scratch
std::span< uint32_t > pair_dest
std::span< uint8_t > is_present
std::span< AffineElement > overflow_pts
std::unique_ptr< std::byte[]> local_owner
std::span< std::pair< uint32_t, uint32_t > > affine_bucket_pairs
std::span< uint32_t > overflow_slots
size_t affine_bucket_stride
std::span< AffineElement > curr_pts
bb::VectorAffineElementPushSpan< BaseParams > lhs
std::span< uint32_t > curr_buckets
bb::VectorAffineElementPushSpan< BaseParams > out
bb::AffineColumnSpan< BaseField > dense_buckets
bb::group_elements::BatchAffineAddScratch< BaseParams > add_scratch
std::span< AffineBucketChunkInfo > chunk_infos
Curve::Element Element
size_t thread_index
Definition thread.hpp:150
auto range(size_t size, size_t offset=0) const
Definition thread.hpp:152
Per-window precomputed slice parameters for the carry-less signed-Booth window recoding....
std::span< typename Curve::AffineElement > extra_points
VectorField result