Barretenberg
The ZK-SNARK library at the core of Aztec
Loading...
Searching...
No Matches
add_scaled.bench.cpp
Go to the documentation of this file.
1// Diagnostic benchmarks for Polynomial::add_scaled.
2//
3// Four variants all doing self[i] = self[i] + other[i] * scaling for
4// i in [0, N), same inputs, same pre-randomized reference buffer copied
5// into self at the start of each iteration so accumulating values don't
6// drift across iterations:
7//
8// bench_add_scaled_scalar — raw scalar for-loop, no
9// vectorized_for, no parallel_for.
10// bench_add_scaled_vectorized_inline — single vectorized_for<bb::VECTOR_FIELD_WIDTH> call
11// over [0, N), uses Polynomial's
12// token overloads. Routes through
13// ContiguousVectorIndex<5> /
14// load_contiguous /
15// store_contiguous (NOT gather).
16// bench_add_scaled_vector_field_raw — ceiling: hand-written loop on
17// raw bb::fr* calling
18// load_contiguous /
19// store_contiguous directly,
20// mirroring what
21// ContiguousVectorIndex emits.
22// The delta vs vectorized_inline
23// is the abstraction tax.
24// bench_add_scaled_full — Polynomial::add_scaled, which
25// routes through add_scaled_chunk
26// + parallel_for + vectorized_for<bb::VECTOR_FIELD_WIDTH>.
27//
28// Ratios of interest:
29// scalar / vectorized_inline — real vectorization speedup
30// scalar / vector_field_raw — ceiling speedup
31// vectorized_inline / vector_field_raw — abstraction tax (target: ~1.0x)
32// full / vectorized_inline — parallel_for overhead
33//
34// Last measured (wasmtime 20, BN254 Fr, N = 1<<16):
35// scalar ~ 3.99 ms
36// vectorized_inline ~ 4.37 ms (0.91× scalar)
37// vector_field_raw ~ 4.36 ms (0.91× scalar — ceiling)
38// full ~ 4.65 ms (0.86× scalar — parallel_for ~5% overhead)
39// Abstraction tax: vectorized_inline / vector_field_raw ≈ 1.00× (no tax).
40// The 0.91× ratio vs scalar is the irreducible AoS↔interleaved transpose
41// cost over a 1-op-per-block kernel (mul + add); on V8/Zen3 the underlying
42// primitive speedups are larger so add_scaled vectorized comfortably exceeds
43// scalar there.
44
50
51#include <array>
52#include <benchmark/benchmark.h>
53#include <cstring>
54
55using namespace benchmark;
56using bb::fr;
57using bb::Polynomial;
59using bb::VectorField;
61
62// Polynomial size for each benchmark. 2^16 gives a reasonably sized inner
63// loop while keeping the total benchmark time modest.
64constexpr size_t N = 1 << 16;
65
66namespace {
67
68// Pre-populate once: `self_ref` and `other` hold random data; each bench
69// iteration starts by memcpy'ing `self_ref -> self` so accumulating values
70// don't saturate across iterations.
71struct PolyFixture {
72 Polynomial<fr> self;
73 Polynomial<fr> self_ref;
74 Polynomial<fr> other;
75 fr scaling;
76
77 PolyFixture()
81 , scaling(fr::random_element())
82 {
83 for (size_t i = 0; i < N; ++i) {
84 self_ref.at(i) = fr::random_element();
85 other.at(i) = fr::random_element();
86 }
87 std::memcpy(self.data(), self_ref.data(), N * sizeof(fr));
88 }
89
90 // Reset self to the pre-randomized reference state without timing.
91 void reset_self(State& state)
92 {
93 state.PauseTiming();
94 std::memcpy(self.data(), self_ref.data(), N * sizeof(fr));
95 state.ResumeTiming();
96 }
97};
98
99// Correctness check (NOT timed): compute add_scaled via the scalar loop and
100// via Polynomial::add_scaled (the vectorised production path) on identical
101// inputs, then assert all 65k field outputs match. If the bulk-transpose
102// rewrite has a bit-level error this aborts before any benchmark runs.
103struct CorrectnessGuard {
104 CorrectnessGuard()
105 {
106 constexpr size_t M = N;
110 for (size_t i = 0; i < M; ++i) {
111 a.at(i) = fr::random_element();
112 b.at(i) = fr::random_element();
113 }
114 std::memcpy(a_ref.data(), a.data(), M * sizeof(fr));
115 const fr s = fr::random_element();
116
117 // Production path.
118 a.add_scaled(PolynomialSpan<const fr>{ 0, { b.data(), M } }, s);
119
120 // Reference scalar path.
121 for (size_t i = 0; i < M; ++i) {
122 a_ref.at(i) = a_ref.at(i) + b.at(i) * s;
123 }
124
125 for (size_t i = 0; i < M; ++i) {
126 if (!(a.at(i) == a_ref.at(i))) {
127 std::fprintf(stderr, "[ADD_SCALED CORRECTNESS] mismatch at i=%zu\n", i);
128 std::abort();
129 }
130 }
131 }
132};
133static const CorrectnessGuard correctness_guard;
134} // namespace
135
136static void bench_add_scaled_scalar(State& state)
137{
138 PolyFixture f;
139 auto& self = f.self;
140 auto& other = f.other;
141 auto scaling = f.scaling;
142 for (auto _ : state) {
143 f.reset_self(state);
144 for (size_t i = 0; i < N; ++i) {
145 self.at(i) = self.at(i) + other.at(i) * scaling;
146 }
147 DoNotOptimize(self.at(0));
148 }
149}
150BENCHMARK(bench_add_scaled_scalar);
151
152static void bench_add_scaled_vectorized_inline(State& state)
153{
154 PolyFixture f;
155 auto& self = f.self;
156 auto& other = f.other;
157 auto scaling = f.scaling;
158 // Mirror Polynomial::add_scaled_chunk: hoist the scalar->VectorField
159 // broadcast out of the loop via `Broadcast<Fr>`, so the same
160 // `scaling_b[ctx]` expression yields the right-typed multiplicand for
161 // the bulk and tail iterations.
162 bb::Broadcast<fr> scaling_b(scaling);
163 for (auto _ : state) {
164 f.reset_self(state);
165 vectorized_for<bb::VECTOR_FIELD_WIDTH, fr>(
166 0, N, [&](auto ctx) { self[ctx] = self[ctx] + other[ctx] * scaling_b[ctx]; });
167 DoNotOptimize(self.at(0));
168 }
169}
170BENCHMARK(bench_add_scaled_vectorized_inline);
171
172static void bench_add_scaled_vector_field_raw(State& state)
173{
174 PolyFixture f;
175 auto& self = f.self;
176 auto& other = f.other;
177 auto scaling = f.scaling;
178
180 // Inline 5-wide per-block ceiling: hand-written loop using the
181 // linear-memory VectorField ctor (`Vec(fr*)`) and its matching write
182 // method (`store_to`). Per-iter transpose. The bulk-transposed path
183 // (Polynomial::add_scaled) has a higher ceiling.
184 Vec scaling_v = Vec::broadcast(scaling);
185
186 for (auto _ : state) {
187 f.reset_self(state);
188 fr* s = self.data();
189 const fr* o = other.data();
190 size_t i = 0;
191 for (; i + 5 <= N; i += 5) {
192 Vec sv(s + i);
193 Vec ov(o + i);
194 (sv + ov * scaling_v).store_to(s + i);
195 }
196 // Tail (N % 5 elements). For N = 65536 this is 1 iteration.
197 for (; i < N; ++i) {
198 s[i] = s[i] + o[i] * scaling;
199 }
200 DoNotOptimize(self.at(0));
201 }
202}
203BENCHMARK(bench_add_scaled_vector_field_raw);
204
205// Bulk-transpose variant: lift the AoS↔interleaved transpose out of the
206// inner kernel so each polynomial is converted exactly once per call, and
207// the inner loop runs on already-interleaved VectorField slots.
208//
209// The math identity is unchanged. The point of this variant is to measure
210// what `Polynomial::add_scaled` looks like when transpose cost is paid in
211// streaming bulk passes that V8/TurboFan can vectorize, instead of being
212// folded into each 5-field iteration.
213static void bench_add_scaled_bulk_transpose(State& state)
214{
215 PolyFixture f;
216 auto& self = f.self;
217 auto& other = f.other;
218 auto scaling = f.scaling;
219
221 constexpr size_t bulk_count = N / 5;
222 constexpr size_t tail_count = N % 5;
223
224 // Persistent scratch — one allocation, reused across bench iterations.
225 // Hoist the raw data pointer outside the inner loop so V8/TurboFan
226 // doesn't re-read std::vector's _M_start on every block (matches what
227 // Polynomial::add_scaled_chunk does internally).
228 std::vector<Vec> self_inter(bulk_count);
229 std::vector<Vec> other_inter(bulk_count);
230 Vec* self_buf = self_inter.data();
231 Vec* other_buf = other_inter.data();
232 Vec scaling_v = Vec::broadcast(scaling);
233
234 for (auto _ : state) {
235 f.reset_self(state);
236 fr* s = self.data();
237 const fr* o = other.data();
238
239 // Pass 1: pre-transpose self → interleaved scratch via the
240 // linear-memory ctor.
241 for (size_t i = 0; i < bulk_count; ++i) {
242 self_buf[i] = Vec(s + i * 5);
243 }
244 // Pass 2: pre-transpose other → interleaved scratch.
245 for (size_t i = 0; i < bulk_count; ++i) {
246 other_buf[i] = Vec(o + i * 5);
247 }
248 // Pass 3 (kernel): operate entirely on interleaved data — no
249 // transpose in the inner loop.
250 for (size_t i = 0; i < bulk_count; ++i) {
251 self_buf[i] = self_buf[i] + other_buf[i] * scaling_v;
252 }
253 // Pass 4: post-transpose self ← interleaved scratch via store_to.
254 for (size_t i = 0; i < bulk_count; ++i) {
255 self_buf[i].store_to(s + i * 5);
256 }
257 // Tail.
258 for (size_t i = bulk_count * 5; i < N; ++i) {
259 s[i] = s[i] + o[i] * scaling;
260 }
261 DoNotOptimize(self.at(0));
262 (void)tail_count;
263 }
264}
265BENCHMARK(bench_add_scaled_bulk_transpose);
266
267static void bench_add_scaled_full(State& state)
268{
269 PolyFixture f;
270 auto& self = f.self;
271 auto& other = f.other;
272 auto scaling = f.scaling;
273 for (auto _ : state) {
274 f.reset_self(state);
275 self.add_scaled(PolynomialSpan<const fr>{ 0, { other.data(), N } }, scaling);
276 DoNotOptimize(self.at(0));
277 }
278}
279BENCHMARK(bench_add_scaled_full);
280
281// Kernel-only ceiling: both self and other already in 5-wide interleaved
282// layout, time only the inner mul+add pass. No AoS↔interleaved transpose
283// in the timed window. This is the upper bound `Polynomial::add_scaled`
284// could reach if Polynomial natively stored its data in the 5-wide
285// interleaved layout (the spec's "larger architectural change").
286//
287// self_inter is *not* reset between iterations — the math drifts across
288// iterations, but every iteration performs the exact same SIMD work, so
289// per-iteration timing is meaningful. PauseTiming/ResumeTiming for a
290// per-iter reset added ~17 % overhead in V8 — this bench is a CEILING
291// DIAGNOSTIC only, not a correctness measurement.
292static void bench_add_scaled_kernel_only(State& state)
293{
294 PolyFixture f;
295 auto& other = f.other;
296 auto scaling = f.scaling;
297
299 constexpr size_t bulk_count = N / 5;
300
301 std::vector<Vec> self_inter(bulk_count);
302 std::vector<Vec> other_inter(bulk_count);
303 Vec* self_buf = self_inter.data();
304 Vec* other_buf = other_inter.data();
305 Vec scaling_v = Vec::broadcast(scaling);
306 {
307 const fr* s = f.self_ref.data();
308 const fr* o = other.data();
309 for (size_t i = 0; i < bulk_count; ++i) {
310 self_buf[i] = Vec(s + i * 5);
311 other_buf[i] = Vec(o + i * 5);
312 }
313 }
314
315 for (auto _ : state) {
316 for (size_t i = 0; i < bulk_count; ++i) {
317 self_buf[i] = self_buf[i] + other_buf[i] * scaling_v;
318 }
319 DoNotOptimize(self_buf[0]);
320 }
321}
322BENCHMARK(bench_add_scaled_kernel_only);
323
324// Kernel + write-back ceiling: both self and other already in 5-wide
325// interleaved layout; the timed window contains the kernel pass plus the
326// AoS write-back pass that untransposes self into the Polynomial buffer.
327// This is the upper bound `add_scaled` could reach if `other` is already
328// interleaved (cached across many calls) and only `self` needs to be
329// flushed back to AoS at the end of the call. Same ceiling-diagnostic
330// caveat as `kernel_only` — self_inter drifts but per-iter SIMD work is
331// constant.
332static void bench_add_scaled_kernel_plus_writeback(State& state)
333{
334 PolyFixture f;
335 auto& self = f.self;
336 auto& other = f.other;
337 auto scaling = f.scaling;
338
340 constexpr size_t bulk_count = N / 5;
341
342 std::vector<Vec> self_inter(bulk_count);
343 std::vector<Vec> other_inter(bulk_count);
344 Vec* self_buf = self_inter.data();
345 Vec* other_buf = other_inter.data();
346 Vec scaling_v = Vec::broadcast(scaling);
347 {
348 const fr* s = f.self_ref.data();
349 const fr* o = other.data();
350 for (size_t i = 0; i < bulk_count; ++i) {
351 self_buf[i] = Vec(s + i * 5);
352 other_buf[i] = Vec(o + i * 5);
353 }
354 }
355
356 for (auto _ : state) {
357 fr* s = self.data();
358 // Kernel pass.
359 for (size_t i = 0; i < bulk_count; ++i) {
360 self_buf[i] = self_buf[i] + other_buf[i] * scaling_v;
361 }
362 // Write-back pass.
363 for (size_t i = 0; i < bulk_count; ++i) {
364 self_buf[i].store_to(s + i * 5);
365 }
366 DoNotOptimize(self.at(0));
367 }
368}
369BENCHMARK(bench_add_scaled_kernel_plus_writeback);
370
371// Isolated load: time JUST the AoS → 9×29 interleaved conversion. No math,
372// no kernel, no store. Each iteration runs 13107 invocations of the
373// linear-memory `Vec(const fr*)` ctor over the same self/other buffers.
374// This is the conversion microbench the user asked for.
375static void bench_load_contiguous_only(State& state)
376{
377 PolyFixture f;
378 auto& self = f.self;
379 auto& other = f.other;
380
382 constexpr size_t bulk_count = N / 5;
383
384 std::vector<Vec> sink(bulk_count);
385 Vec* sink_buf = sink.data();
386
387 for (auto _ : state) {
388 const fr* s = self.data();
389 const fr* o = other.data();
390 for (size_t i = 0; i < bulk_count; ++i) {
391 sink_buf[i] = Vec(s + i * 5);
392 }
393 for (size_t i = 0; i < bulk_count; ++i) {
394 sink_buf[i] = Vec(o + i * 5);
395 }
396 DoNotOptimize(sink_buf[0]);
397 }
398}
399BENCHMARK(bench_load_contiguous_only);
400
401// Isolated store: time JUST the 9×29 interleaved → AoS conversion.
402// 13107 invocations of `store_to` per iteration.
403static void bench_store_contiguous_only(State& state)
404{
405 PolyFixture f;
406 auto& self = f.self;
407
409 constexpr size_t bulk_count = N / 5;
410
411 std::vector<Vec> src(bulk_count);
412 Vec* src_buf = src.data();
413 {
414 const fr* s = f.self_ref.data();
415 for (size_t i = 0; i < bulk_count; ++i) {
416 src_buf[i] = Vec(s + i * 5);
417 }
418 }
419
420 for (auto _ : state) {
421 fr* s = self.data();
422 for (size_t i = 0; i < bulk_count; ++i) {
423 src_buf[i].store_to(s + i * 5);
424 }
425 DoNotOptimize(self.at(0));
426 }
427}
428BENCHMARK(bench_store_contiguous_only);
429
430// Memory-bandwidth floor: pure 160-byte (5×fr) → 192-byte (sizeof Vec)
431// memcpy. No bit work at all. Whatever load_contiguous costs ABOVE this is
432// the conversion-compute overhead the user wants reduced.
433static void bench_memcpy_only(State& state)
434{
435 PolyFixture f;
436 auto& self = f.self;
437
439 constexpr size_t bulk_count = N / 5;
440
441 std::vector<Vec> sink(bulk_count);
442 Vec* sink_buf = sink.data();
443
444 for (auto _ : state) {
445 const fr* s = self.data();
446 for (size_t i = 0; i < bulk_count; ++i) {
447 std::memcpy(&sink_buf[i], s + i * 5, sizeof(fr) * 5);
448 }
449 DoNotOptimize(sink_buf[0]);
450 }
451}
452BENCHMARK(bench_memcpy_only);
453
BENCHMARK_MAIN()
constexpr size_t N
BENCHMARK(bench_add_scaled_scalar)
Fr & at(size_t index)
Our mutable accessor, unlike operator[]. We abuse precedent a bit to differentiate at() and operator[...
FF a
FF b
void vectorized_for(size_t start, size_t end, K &&kernel)
constexpr decltype(auto) get(::tuplet::tuple< T... > &&t) noexcept
Definition tuple.hpp:13
static VectorField broadcast(const Field &s) noexcept
void store_to(Field *base) const noexcept
static field random_element(numeric::RNG *engine=nullptr) noexcept
bb::VectorField< bb::Bn254FrParams > Vec