refactor(bb): give cycle_group its own offset generator store - #25137
Conversation
|
⏳ Claude Review — starting review session... |
|
✅ Adversarial verdict: PASS ✅ Claude Review — completed VERDICT: PASS The new head Fresh verification at the exact head (clean reconfigure after workspace reset, CRS re-downloaded):
No new findings; the two cosmetic nits from the first review remain non-blocking. |
|
Claude Review: Starting automated code review... workflow run |
|
Claude Review: Starting automated code review... workflow run |
## The coupling `cycle_group` took its MSM offset generators from `crypto::generator_data` — the pedersen generator store — via a `GeneratorContext` parameter threaded down `batch_mul` / `fixed_batch_mul`. That store is shared, mutable, and grows in place. Its own header documents it as not thread-safe: > This is not thread-safe. Each process that uses a `generator_data` object may extend `generator_data` if more generators are required ... either each process must use an independent `generator_data` object or the author must KNOW that `generator_data` will not be extended by any process. It also returns a `std::span` into a `std::vector` that a later call for the same domain reallocates, so a view handed to one caller can be freed underneath it. Both halves of that contract are violated in practice. `cycle_group::batch_mul` holds one of those spans across an entire MSM build, and `bb aztec_process` derives verification keys for several circuits on concurrent `std::thread`s against the shared singleton. The result is a rare, unreproducible `cycle_group: Point is not on curve` abort during `noir-contracts` bootstrap — [an example run](http://ci.aztec-labs.com/1786023406574272), [the failing contract](http://ci.aztec-labs.com/44c19c0d4f4041c4). The `GeneratorContext` parameter carrying this dependency was never once supplied. Every call site in `stdlib/` and `dsl/` defaulted it, and `ipa.hpp` passed a literal `{}` purely to reach the `table_bits` argument sitting behind it. ## What this does Adds `stdlib/primitives/group/cycle_group_offset_generators.hpp`, a store that owns exactly this one concern: ```cpp template <typename Curve> class cycle_group_offset_generators { public: static constexpr std::string_view DOMAIN_SEPARATOR = "cycle_group_offset_generator"; static const cycle_group_offset_generators& default_generators(); std::span<const AffineElement> get(size_t num_generators) const; }; ``` The design aims at removing the brittleness rather than guarding it: - **Prefix-only.** No offset or slice parameter. Generator `i` depends on nothing but the domain separator and `i`, so no request can change what another request sees. That is all cycle_group ever needed from the general store. - **Append-only blocks.** Growth allocates a new block and retains the superseded one, so a span already handed out stays valid however many times other threads call `get` afterwards. Growth doubles, so the retained set stays at a handful of entries. - **An instance, not a hidden global.** Circuit construction shares `default_generators()`, but the store is an ordinary object. This is what lets the tests below be non-vacuous — see the note on that. - **Locked.** A plain `std::mutex`; the critical section is a size check plus a rare derivation. `GeneratorContext` disappears from cycle_group's API, `fixed_batch_mul(points, scalars, {}, 8)` in `ipa.hpp` becomes `fixed_batch_mul(points, scalars, 8)`, and the now-unused `crypto/pedersen_commitment/pedersen.hpp` include comes out of both cycle_group files. **`crypto::generator_data` and the pedersen path are untouched.** ## Why memoize Measured here: `derive_generators` costs roughly 65us per generator — 550us for 8, 4.3ms for 65, 84ms for 1024. Deriving per `batch_mul` would be a real regression, so the store memoizes, and doubling blocks keep a circuit made of many differently sized MSMs to a handful of derivations rather than one per MSM. ## Verification keys are unaffected Offset generator `i` is the same point as before: same domain separator, same derivation, same prefix semantics — the previous path also requested offset 0. `MatchesDerivation` and `PrefixIsStableAcrossRequestSizes` pin exactly that, and the circuit tests below carry fixed expected values. ## Tests `stdlib/primitives/group/cycle_group_offset_generators.test.cpp`: | Test | What it pins | | --- | --- | | `MatchesDerivation` | Values equal `derive_generators` for the domain | | `PrefixIsStableAcrossRequestSizes` | Generator `i` does not depend on the request size | | `GetKeepsPreviouslyReturnedGeneratorsValid` | A span stays valid after a larger request grows the store | | `ConcurrentGetReturnsOnCurvePoints` | Concurrent `get` never yields an off-curve point | These were checked against a deliberately reintroduced bug — replacing the block on growth instead of retaining it. `PrefixIsStableAcrossRequestSizes` and `GetKeepsPreviouslyReturnedGeneratorsValid` go red deterministically, and green again once the retain is restored. Two details are worth knowing before editing these tests, because both produced a silently passing suite first time round: - The store has to be an **instance**. With function-local statics the cache is process-global, the growth path is live only for the first few assertions, and everything afterwards passes trivially — the first draft passed even with the bug present. - The deterministic test **sprays the freed size range** before re-reading. Without that, a stale span reads its own old contents back and passes while pointing at released memory. `ConcurrentGetReturnsOnCurvePoints` is the weakest of the four and is not the primary regression net: with the map race gone the residual failure is a dangling read, and freed-but-unreused memory usually still reads back correct. It is kept as the symptom-level reproduction and as an ASan/TSan target, with the two deterministic tests doing the real work. ## Run Full `cmake --build build` clean, including `bb`, `bb-avm` and `bb-avm-sim`. ``` stdlib_primitives_tests 1349 passed, 0 failed (128 cycle_group) dsl_tests 551 passed, 0 failed commitment_schemes_tests (IPA) 30 passed, 0 failed crypto_pedersen_commitment_tests 2 passed, 0 failed crypto_pedersen_hash_tests 6 passed, 0 failed ``` ## Note on scope This removes cycle_group's dependence on the shared store; it does not fix the store itself. `generator_data::get` still hands out spans it can later invalidate, which remains a hazard for the pedersen path and for any future caller, and is worth addressing separately. --- *Created by [claudebox](https://claudebox.work/v2/sessions/877d88cba221b585/jobs/8) · group: `slackbot` · requested by Sergei · [Slack thread](https://aztecfoundation.slack.com/archives/C04ALP0UQHH/p1786024347064329?thread_ts=1786024347.064329&cid=C04ALP0UQHH)*
06506af to
115d643
Compare
The coupling
cycle_grouptook its MSM offset generators fromcrypto::generator_data— the pedersen generator store — via aGeneratorContextparameter threaded downbatch_mul/fixed_batch_mul.That store is shared, mutable, and grows in place. Its own header documents it as not thread-safe:
It also returns a
std::spaninto astd::vectorthat a later call for the same domain reallocates, so a view handed to one caller can be freed underneath it.Both halves of that contract are violated in practice.
cycle_group::batch_mulholds one of those spans across an entire MSM build, andbb aztec_processderives verification keys for several circuits on concurrentstd::threads against the shared singleton. The result is a rare, unreproduciblecycle_group: Point is not on curveabort duringnoir-contractsbootstrap — an example run, the failing contract.The
GeneratorContextparameter carrying this dependency was never once supplied. Every call site instdlib/anddsl/defaulted it, andipa.hpppassed a literal{}purely to reach thetable_bitsargument sitting behind it.What this does
Adds
stdlib/primitives/group/cycle_group_offset_generators.hpp, a store that owns exactly this one concern:The design aims at removing the brittleness rather than guarding it:
idepends on nothing but the domain separator andi, so no request can change what another request sees. That is all cycle_group ever needed from the general store.getafterwards. Growth doubles, so the retained set stays at a handful of entries.default_generators(), but the store is an ordinary object. This is what lets the tests below be non-vacuous — see the note on that.std::mutex; the critical section is a size check plus a rare derivation.GeneratorContextdisappears from cycle_group's API,fixed_batch_mul(points, scalars, {}, 8)inipa.hppbecomesfixed_batch_mul(points, scalars, 8), and the now-unusedcrypto/pedersen_commitment/pedersen.hppinclude comes out of both cycle_group files.crypto::generator_dataand the pedersen path are untouched.Why memoize
Measured here:
derive_generatorscosts roughly 65us per generator — 550us for 8, 4.3ms for 65, 84ms for 1024. Deriving perbatch_mulwould be a real regression, so the store memoizes, and doubling blocks keep a circuit made of many differently sized MSMs to a handful of derivations rather than one per MSM.Verification keys are unaffected
Offset generator
iis the same point as before: same domain separator, same derivation, same prefix semantics — the previous path also requested offset 0.MatchesDerivationandPrefixIsStableAcrossRequestSizespin exactly that, and the circuit tests below carry fixed expected values.Tests
stdlib/primitives/group/cycle_group_offset_generators.test.cpp:MatchesDerivationderive_generatorsfor the domainPrefixIsStableAcrossRequestSizesidoes not depend on the request sizeGetKeepsPreviouslyReturnedGeneratorsValidConcurrentGetReturnsOnCurvePointsgetnever yields an off-curve pointThese were checked against a deliberately reintroduced bug — replacing the block on growth instead of retaining it.
PrefixIsStableAcrossRequestSizesandGetKeepsPreviouslyReturnedGeneratorsValidgo red deterministically, and green again once the retain is restored.Two details are worth knowing before editing these tests, because both produced a silently passing suite first time round:
ConcurrentGetReturnsOnCurvePointsis the weakest of the four and is not the primary regression net: with the map race gone the residual failure is a dangling read, and freed-but-unreused memory usually still reads back correct. It is kept as the symptom-level reproduction and as an ASan/TSan target, with the two deterministic tests doing the real work.Run
Full
cmake --build buildclean, includingbb,bb-avmandbb-avm-sim.Note on scope
This removes cycle_group's dependence on the shared store; it does not fix the store itself.
generator_data::getstill hands out spans it can later invalidate, which remains a hazard for the pedersen path and for any future caller, and is worth addressing separately.Created by claudebox · group:
slackbot· requested by Sergei · Slack thread