Barretenberg
The ZK-SNARK library at the core of Aztec
Loading...
Searching...
No Matches
pippenger_batched.hpp
Go to the documentation of this file.
1#pragma once
2
3// Implementation fragment included from scalar_multiplication_fast.cpp inside
4// bb::scalar_multiplication, after pippenger_round_parallel is defined.
5
6// Multi-MSM_fast driver for `MSM_fast<>::batch_multi_scalar_mul`. The hot path
7// (`CommitmentKey::batch_commit` from `commit_to_wires`) batches K MSMs sharing the same
8// SRS subspan. We do NOT interleave K MSMs inside a single parallel_for body — that
9// K-multiplies the per-thread working set and forces windows_in_batch=1; the single-MSM_fast
10// hot path is tuned to fit ~4 MiB in L2 and we want to preserve that. The loop is just
11// for m in 0..K: run single-MSM_fast dispatch for MSM_fast m.
12// The only cross-MSM_fast amortisation is the GLV-doubled point set: when every member of a
13// shared-SRS-prefix group wants GLV, we double the prefix once into a shared buffer and
14// each per-MSM_fast call aliases its prefix instead of doubling its own.
16
17// One per shared-SRS-prefix group. Membership is keyed on identical
18// `point_arrays[m].data()` pointers — that is the actual sharing relation
19// `commit_to_wires` exposes. Lives for one `pippenger_round_parallel_batched`
20// call: the GLV-doubled buffer is recomputed per batch and freed at return.
21template <typename Curve> struct BatchMsmGlvGroup {
22 const typename Curve::AffineElement* base_ptr = nullptr; // SRS prefix pointer
23 size_t group_max_n = 0; // max n_input across MSMs in this group
24 std::span<typename Curve::AffineElement> doubled; // length 2 * group_max_n; aliases a prefix of
25 // the master-group buffer (computed once for
26 // the largest GLV-using group). Layout
27 // `[P_0, φP_0, P_1, φP_1, …]` — the first 2*n
28 // entries are the per-MSM_fast view for n ≤ Nmax.
29 std::vector<size_t> member_msms; // indices into `scalar_arrays` of MSMs in this group
30};
31
32} // namespace round_parallel_detail
33
34namespace {
35// NOLINTNEXTLINE(readability-function-size, readability-function-cognitive-complexity,
36// google-readability-function-size)
37template <typename Curve>
38void pippenger_round_parallel_batched(std::span<std::span<typename Curve::ScalarField>> scalar_arrays,
41 std::span<const uint32_t> dedup_infos = {}) noexcept
42{
43 using AffineElement = typename Curve::AffineElement;
44 using ScalarField = typename Curve::ScalarField;
45 using BaseField = typename Curve::BaseField;
46
47 BB_BENCH_NAME("MSM_fast::pippenger_round_parallel_batched");
48
49 const size_t K = scalar_arrays.size();
50 BB_ASSERT_EQ(point_arrays.size(), K);
51 out_results.assign(K, Curve::Group::point_at_infinity);
52
53 auto info_for = [&](size_t m) noexcept -> size_t { return m < dedup_infos.size() ? dedup_infos[m] : 0; };
54
55 if (K == 0) {
56 return;
57 }
58 if (K == 1) {
59 const size_t n = std::min(scalar_arrays[0].size(), point_arrays[0].size());
60 if (n == 0) {
61 return;
62 }
63 PolynomialSpan<const ScalarField> sp(0, std::span<const ScalarField>(scalar_arrays[0].data(), n));
64 out_results[0] = pippenger_round_parallel<Curve>(sp, point_arrays[0], info_for(0));
65 return;
66 }
67
68 std::vector<size_t> n_input(K);
69 for (size_t m = 0; m < K; ++m) {
70 n_input[m] = std::min(scalar_arrays[m].size(), point_arrays[m].size());
71 }
72
73 // Group MSMs by shared SRS pointer; one shared GLV-doubled buffer per group, sized to
74 // group_max_n. group_uses_glv is a per-group bool but the per-MSM_fast internal dispatch keeps
75 // each MSM_fast's own GLV decision in case shared doubling is skipped.
77 std::vector<GlvGroup> glv_groups;
78
79 auto find_or_create_group = [&](const AffineElement* base_ptr, size_t n) -> size_t {
80 for (size_t g = 0; g < glv_groups.size(); ++g) {
81 if (glv_groups[g].base_ptr == base_ptr) {
82 glv_groups[g].group_max_n = std::max(glv_groups[g].group_max_n, n);
83 return g;
84 }
85 }
86 GlvGroup g{};
87 g.base_ptr = base_ptr;
88 g.group_max_n = n;
89 glv_groups.push_back(std::move(g));
90 return glv_groups.size() - 1;
91 };
92
93 std::vector<size_t> msm_to_group(K, std::numeric_limits<size_t>::max());
94 for (size_t m = 0; m < K; ++m) {
95 if (n_input[m] == 0) {
96 continue;
97 }
98 const size_t g = find_or_create_group(point_arrays[m].data(), n_input[m]);
99 glv_groups[g].member_msms.push_back(m);
100 msm_to_group[m] = g;
101 }
102
103 std::vector<bool> group_uses_glv(glv_groups.size(), false);
104 for (size_t g = 0; g < glv_groups.size(); ++g) {
105 // GLV decision is per-group on group_max_n. Within a group, every MSM_fast has
106 // n[m] <= group_max_n; if group_max_n is in the small-N regime, every MSM_fast
107 // is too, so they all want GLV. If group_max_n is in the large-N regime,
108 // no MSM_fast in the group wants GLV (they'd be slower with it).
109 group_uses_glv[g] = glv_groups[g].group_max_n <= round_parallel_detail::GLV_SMALL_N_THRESHOLD;
110 }
111
112 // Build ONE shared GLV-doubled buffer covering the union of every GLV-using group's
113 // SRS range, then alias each group's `doubled` into a slice of that buffer.
114 //
115 // Every production / test caller of batch_multi_scalar_mul is `commitment_key.batch_commit`,
116 // which constructs each MSM_fast's point span as `get_monomial_points().subspan(start_index)`
117 // — sub-spans of a single contiguous `std::vector<AffineElement>` SRS. So in every
118 // batch every group's `base_ptr` lives in the same allocation and offsets are
119 // necessarily integer multiples of `sizeof(AffineElement)`. The asserts below
120 // catch a future caller that violates that contract.
121 std::unique_ptr<AffineElement[]> master_doubled_owner; // NOLINT(cppcoreguidelines-avoid-c-arrays)
122 {
123 BB_BENCH_NAME("MSM_fast::pippenger_round_parallel_batched/glv_double_points");
124
125 const AffineElement* min_base = nullptr;
126 for (size_t g = 0; g < glv_groups.size(); ++g) {
127 glv_groups[g].doubled = {};
128 if (!group_uses_glv[g]) {
129 continue;
130 }
131 if (min_base == nullptr || std::less<const AffineElement*>{}(glv_groups[g].base_ptr, min_base)) {
132 min_base = glv_groups[g].base_ptr;
133 }
134 }
135
136 if (min_base != nullptr) {
137 const auto min_addr = reinterpret_cast<uintptr_t>(min_base);
138 size_t max_extent_units = 0;
139 for (size_t g = 0; g < glv_groups.size(); ++g) {
140 if (!group_uses_glv[g]) {
141 continue;
142 }
143 const auto base_addr = reinterpret_cast<uintptr_t>(glv_groups[g].base_ptr);
144 const uintptr_t offset_bytes = base_addr - min_addr;
145 BB_ASSERT_EQ(offset_bytes % sizeof(AffineElement),
146 size_t{ 0 },
147 "GLV group base_ptr not aligned to AffineElement boundary "
148 "(point spans must be subranges of a contiguous AffineElement array)");
149 const size_t offset_units = offset_bytes / sizeof(AffineElement);
150 const size_t end_units = offset_units + glv_groups[g].group_max_n;
151 max_extent_units = std::max(max_extent_units, end_units);
152 }
153
155 2 * max_extent_units); // NOLINT(cppcoreguidelines-avoid-c-arrays)
156 AffineElement* const master_buf = master_doubled_owner.get();
157 const BaseField beta = BaseField::cube_root_of_unity();
158 bb::parallel_for(bb::get_num_cpus(), [&](const ThreadChunk& chunk) {
159 for (size_t i : chunk.range(max_extent_units)) {
160 master_buf[2 * i] = min_base[i];
161 master_buf[(2 * i) + 1].x = min_base[i].x * beta;
162 master_buf[(2 * i) + 1].y = -min_base[i].y;
163 }
164 });
165
166 for (size_t g = 0; g < glv_groups.size(); ++g) {
167 if (!group_uses_glv[g]) {
168 continue;
169 }
170 const auto base_addr = reinterpret_cast<uintptr_t>(glv_groups[g].base_ptr);
171 const size_t offset_units = (base_addr - min_addr) / sizeof(AffineElement);
172 glv_groups[g].doubled =
173 std::span<AffineElement>(master_buf + (2 * offset_units), 2 * glv_groups[g].group_max_n);
174 }
175 }
176 }
177
178 // Batch split by n_input: members >= MSM_MIN_PTS_PER_THREAD * pool_width (large enough to clear the
179 // per-worker floor even when split across all workers) run sequentially, each internally
180 // multithreaded; smaller members run concurrently, one per worker, single-threaded. Keyed on
181 // n_input (span), not the active/non-zero count: the deciding cost is the concurrent working-set
182 // footprint, which tracks the span. A sparse-but-wide MSM (few non-zeros, large domain) must stay
183 // "large" — splitting by active count instead drops it into the concurrent pool and thrashes cache.
184 const size_t pool_width = bb::get_num_cpus();
185#ifdef __wasm__
186 // wasm runs every MSM single-threaded, so the split uses a fixed point-count bound rather than
187 // MSM_MIN_PTS_PER_THREAD (SIZE_MAX here, which would classify every member as small). The loop
188 // below tests `n < mt_threshold` and the intended classification is `n <= SMALL_MSM_BATCH_THRESHOLD`,
189 // so the exclusive bound is one past it.
190 const size_t mt_threshold = SMALL_MSM_BATCH_THRESHOLD + 1;
191#else
192 // overflow-safe MSM_MIN_PTS_PER_THREAD * pool_width
193 const size_t mt_threshold =
194 (pool_width <= 1 || MSM_MIN_PTS_PER_THREAD > std::numeric_limits<size_t>::max() / pool_width)
195 ? std::numeric_limits<size_t>::max()
196 : MSM_MIN_PTS_PER_THREAD * pool_width;
197#endif
198 std::vector<size_t> small_members;
199 std::vector<size_t> large_members;
200 small_members.reserve(K);
201 large_members.reserve(K);
202 for (size_t m = 0; m < K; ++m) {
203 if (n_input[m] == 0) {
204 continue;
205 }
206 if (pool_width > 1 && n_input[m] < mt_threshold) {
207 small_members.push_back(m);
208 } else {
209 large_members.push_back(m);
210 }
211 }
212 if (small_members.size() < 2) {
213 // A lone small member gains nothing from the concurrent path; keep it on the
214 // shared-arena sequential dispatch.
215 large_members.insert(large_members.end(), small_members.begin(), small_members.end());
216 std::sort(large_members.begin(), large_members.end());
217 small_members.clear();
218 }
219
220 auto external_glv_for = [&](size_t m, size_t n) noexcept -> std::span<const AffineElement> {
221 const size_t g = msm_to_group[m];
222 if (g != std::numeric_limits<size_t>::max() && group_uses_glv[g] && !glv_groups[g].doubled.empty()) {
223 // First 2*n entries of the group's interleaved doubled buffer are this MSM's GLV view,
224 // valid for any n <= Nmax (see BatchMsmGlvGroup::doubled for the layout).
225 return { glv_groups[g].doubled.data(), 2 * n };
226 }
227 return {};
228 };
229
230 // Run the large members concurrently (one per worker, single-threaded, below) instead of
231 // sequentially at full width. It keeps a live arena per worker vs the sequential path's single
232 // reused arena, so gate it to a large, balanced, sufficiently-threaded batch:
233 // - size > CONCURRENT_MIN_MEMBERS: only a commitment over a whole wide trace batches this many
234 // columns at once (100s-1000s); every other is <= ~86, so 100 keeps them sequential while
235 // the wide-trace case (385+) qualifies — paying the extra arenas only where it pays off.
236 // - max n <= Σn / pool_width (n is the work proxy): with largest-first ordering (below) the
237 // makespan is max(largest, Σn / pool_width), so a dominant member can't strand one worker.
238 // - pool_width >= CONCURRENT_MIN_POOL_WIDTH: fewer threads make the win too small to justify.
239#ifdef __wasm__
240 // wasm keeps large members on the sequential shared-arena dispatch: with single-threaded MSMs the
241 // concurrent path's per-worker live arenas buy nothing over one reused arena.
242 const bool large_members_concurrent = false;
243#else
244 static constexpr size_t CONCURRENT_MIN_MEMBERS = 100;
245 static constexpr size_t CONCURRENT_MIN_POOL_WIDTH = 4;
246 size_t total_large_n = 0;
247 size_t max_large_n = 0;
248 for (size_t m : large_members) {
249 total_large_n += n_input[m];
250 max_large_n = std::max(max_large_n, n_input[m]);
251 }
252 const bool large_members_concurrent = pool_width >= CONCURRENT_MIN_POOL_WIDTH &&
253 large_members.size() > CONCURRENT_MIN_MEMBERS &&
254 max_large_n <= total_large_n / pool_width;
255#endif
256
257 // Shared dynamically-sized arena for the sequential (large-member) calls. Sized to
258 // the max requirement across those members so each MSM_fast finds enough space; a
259 // single allocation across the batch (vs one per MSM_fast if we passed {} down).
260 // dedup_active varies per MSM_fast (gated by per-MSM_fast hint), so the budget query must
261 // mirror the predicate used inside pippenger_round_parallel.
262 size_t shared_arena_bytes = 0;
263 std::unique_ptr<std::byte[]> shared_arena_owner; // NOLINT(cppcoreguidelines-avoid-c-arrays)
264 std::span<std::byte> shared_arena;
265 if (!large_members_concurrent) {
266 for (size_t m : large_members) {
267 const bool ext_glv = !external_glv_for(m, n_input[m]).empty();
268 // The internal short-circuits to trivial_msm_threaded for tiny MSMs, so the hint
269 // alone is the right arena-sizing predicate (over-sizing for a path that bails
270 // is harmless — under-sizing would crash).
271 const size_t bytes = compute_arena_bytes_for_msm<Curve>(n_input[m], ext_glv, info_for(m));
272 shared_arena_bytes = std::max(shared_arena_bytes, bytes);
273 }
274 if (shared_arena_bytes > 0) {
276 shared_arena_bytes); // NOLINT(cppcoreguidelines-avoid-c-arrays)
277 shared_arena = std::span<std::byte>(shared_arena_owner.get(), shared_arena_bytes);
278 }
279 }
280 // Concurrent small-member dispatch: workers pull members off an atomic cursor and run
281 // each with a thread-capped pipeline (max_threads=1, so the member never re-enters the
282 // pool) out of a per-worker arena sized for the capped layout. The GLV-doubled buffer
283 // is read-only and shared across workers.
284 if (!small_members.empty()) {
285 BB_BENCH_NAME("MSM_fast::pippenger_round_parallel_batched/small_members");
286 size_t small_arena_bytes = 0;
287 for (size_t m : small_members) {
288 const bool ext_glv = !external_glv_for(m, n_input[m]).empty();
289 const size_t bytes =
290 compute_arena_bytes_for_msm<Curve>(n_input[m], ext_glv, info_for(m), /*max_threads=*/1);
291 small_arena_bytes = std::max(small_arena_bytes, bytes);
292 }
293 // The per-worker arenas are one contiguous block of `num_workers * small_arena_bytes`.
294 // Cap it at one MSM's budget so a wide batch doesn't hold many full arenas at once,
295 // matching the large-member path's single reused arena of the same budget.
296 const size_t workers_by_budget =
297 small_arena_bytes > 0 ? std::max<size_t>(1, round_parallel_detail::BATCH_MEM_BUDGET / small_arena_bytes)
298 : std::numeric_limits<size_t>::max();
299 const size_t num_workers = std::min({ pool_width, small_members.size(), workers_by_budget });
300 std::unique_ptr<std::byte[]> small_arena_owner; // NOLINT(cppcoreguidelines-avoid-c-arrays)
301 if (small_arena_bytes > 0) {
303 num_workers * small_arena_bytes); // NOLINT(cppcoreguidelines-avoid-c-arrays)
304 }
305 std::atomic<size_t> next_member{ 0 };
306 bb::parallel_for(num_workers, [&](size_t tid) {
307 std::span<std::byte> worker_arena;
308 if (small_arena_bytes > 0) {
309 worker_arena = { small_arena_owner.get() + (tid * small_arena_bytes), small_arena_bytes };
310 }
311 while (true) {
312 const size_t s = next_member.fetch_add(1, std::memory_order_relaxed);
313 if (s >= small_members.size()) {
314 break;
315 }
316 const size_t m = small_members[s];
317 const size_t n = n_input[m];
318 PolynomialSpan<const ScalarField> sp(0, std::span<const ScalarField>(scalar_arrays[m].data(), n));
319 out_results[m] = pippenger_round_parallel<Curve>(
320 sp, point_arrays[m], info_for(m), external_glv_for(m, n), worker_arena, /*max_threads=*/1);
321 }
322 });
323 }
324
325 if (large_members_concurrent) {
326 // Workers pull members off an atomic cursor. Each member runs single-threaded
327 // (max_threads=1). This skips the member's cross-thread reduction. It also keeps the member
328 // off the pool, so there is no nested parallel_for. The gate above guarantees at least
329 // pool_width members, so every worker stays busy. Each call self-allocates its arena (empty
330 // span), because a shared num_workers × max-member arena would exceed BATCH_MEM_BUDGET and
331 // cap num_workers below pool_width. Members run largest-first (longest-processing-time
332 // order) to bound the tail imbalance.
333 BB_BENCH_NAME("MSM_fast::pippenger_round_parallel_batched/large_members_concurrent");
334 std::sort(
335 large_members.begin(), large_members.end(), [&](size_t a, size_t b) { return n_input[a] > n_input[b]; });
336 const size_t num_workers = std::min(pool_width, large_members.size());
337 std::atomic<size_t> next_large{ 0 };
338 bb::parallel_for(num_workers, [&](size_t) {
339 while (true) {
340 const size_t s = next_large.fetch_add(1, std::memory_order_relaxed);
341 if (s >= large_members.size()) {
342 break;
343 }
344 const size_t m = large_members[s];
345 const size_t n = n_input[m];
346 PolynomialSpan<const ScalarField> sp(0, std::span<const ScalarField>(scalar_arrays[m].data(), n));
347 out_results[m] = pippenger_round_parallel<Curve>(
348 sp, point_arrays[m], info_for(m), external_glv_for(m, n), {}, /*max_threads=*/1);
349 }
350 });
351 } else {
352 // Sequential large-member dispatch: one member at a time, each running the full single-
353 // MSM_fast pipeline (its own from-Mont and to-Mont, schedule, Stage 1-6b) across the whole
354 // pool. Taken when the concurrent gate above does not hold — too few members to fill the
355 // pool, or a member large enough to warrant the full pool on its own. The only batched
356 // amortisation shared is the doubled SRS prefix above.
357 for (size_t m : large_members) {
358 const size_t n = n_input[m];
359 PolynomialSpan<const ScalarField> sp(0, std::span<const ScalarField>(scalar_arrays[m].data(), n));
360 out_results[m] =
361 pippenger_round_parallel<Curve>(sp, point_arrays[m], info_for(m), external_glv_for(m, n), shared_arena);
362 }
363 }
364}
365} // namespace
366
367template <typename Curve>
371 bool handle_edge_cases,
372 std::span<const uint32_t> dedup_infos) noexcept
373{
374 BB_BENCH_NAME("MSM_fast::batch_multi_scalar_mul");
375 const size_t k = scalars.size();
376
377 // Adapt the new (single shared points span + per-MSM_fast PolynomialSpan scalars) API to
378 // the internal dispatcher, which still takes one point sub-span per MSM_fast. Each MSM_fast's
379 // sub-span is `points[start_index .. start_index + size)`; the dispatcher's existing
380 // GLV-doubled-buffer grouping then deduplicates across MSMs that fall in the same
381 // underlying allocation.
383 std::vector<std::span<ScalarField>> scalar_subspans;
384 point_subspans.reserve(k);
385 scalar_subspans.reserve(k);
386 for (size_t i = 0; i < k; ++i) {
387 const size_t start_i = scalars[i].start_index;
388 BB_ASSERT_LTE(start_i, points.size(), "scalars[m].start_index exceeds shared points span");
389 point_subspans.push_back(points.subspan(start_i, points.size() - start_i));
390 scalar_subspans.push_back(scalars[i].span);
391 }
392
393 auto info_for = [&](size_t m) noexcept -> size_t { return m < dedup_infos.size() ? dedup_infos[m] : 0; };
394
395 if (handle_edge_cases) {
396 std::vector<AffineElement> results(k);
397 for (size_t i = 0; i < k; ++i) {
398 const size_t n = std::min(point_subspans[i].size(), scalar_subspans[i].size());
400 std::span<const ScalarField>(scalar_subspans[i].data(), n));
401 results[i] =
402 AffineElement(pippenger_fast<Curve>(scalar_span, point_subspans[i], handle_edge_cases, info_for(i)));
403 }
404 return results;
405 }
406
408 pippenger_round_parallel_batched<Curve>(scalar_subspans, point_subspans, per_msm_jac, dedup_infos);
409
410 std::vector<AffineElement> results(k);
411 for (size_t i = 0; i < k; ++i) {
412 results[i] = AffineElement(per_msm_jac[i]);
413 }
414 return results;
415}
#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
bb::fq BaseField
Definition bn254.hpp:19
typename Group::affine_element AffineElement
Definition bn254.hpp:22
bb::fr ScalarField
Definition bn254.hpp:18
FF a
FF b
size_t get_num_cpus()
Definition thread.cpp:34
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)
STL namespace.
constexpr decltype(auto) get(::tuplet::tuple< T... > &&t) noexcept
Definition tuple.hpp:13
uintptr_t base_addr
std::byte * data
std::span< typename Curve::AffineElement > doubled