The Architecture Pivot: How One Design Insight Saved Phase 5 of the Cuzk Proving Engine
In the middle of implementing Phase 5 of the cuzk pipelined SNARK proving engine—a project targeting Filecoin's Groth16 proof generation for 32 GiB PoRep sectors—the assistant made a critical architectural decision that reshaped the entire integration strategy. The message, brief and technical, reads in full:
Instead of modifying bellperson further, the PCE path should be entirely incuzk-coreandcuzk-pce. The key insight is: we don't need to add a new function to bellperson — we can constructProvingAssignmentobjects directly from PCE output, then call the existingprove_from_assignments(). Let me design this properly.
>
The flow is: 1.WitnessCS::synthesize()-> getinput_assignmentandaux_assignment2.evaluate_pce()-> geta,b,cvectors 3. BuildProvingAssignmentwith the a/b/c + density trackers from PCE 4. Call existingprove_from_assignments()unchanged
>
This means we need to construct ProvingAssignment from PCE data. Let me add a constructor in bellperson for that:
At first glance, this reads as a simple implementation note. But in the context of the broader project, it represents a fundamental rethinking of how to integrate a new constraint evaluation engine into an existing, heavily optimized proving pipeline. This article unpacks the reasoning, assumptions, and consequences embedded in those few lines.
The Context: Phase 5's Ambitious Goal
To understand why this message matters, one must understand what Phase 5 was trying to accomplish. The preceding Phase 4 had been a grueling, empirically-driven optimization campaign targeting the CPU-bound synthesis phase of Groth16 proof generation. After weeks of microbenchmarking, perf stat analysis, and carefully controlled experiments, Phase 4 had delivered a 13.4% improvement in total proof time—from 88.9 seconds to 77.0 seconds. This was a respectable gain, but it fell far short of the projected 2–3× speedup the team had hoped for.
The root cause was clear from the perf profiles: the synthesis bottleneck (~50.8 seconds) was purely computational, dominated by field arithmetic and linear combination construction. It was not memory-bound, not allocation-bound, and not cache-bound. The only way to achieve the desired throughput was to fundamentally change how constraint evaluation worked—replacing the traditional circuit synthesis approach with a sparse matrix-vector multiply (MatVec) that could be pre-computed and efficiently evaluated.
This was the mandate of Phase 5: the Pre-Compiled Constraint Evaluator, or PCE.
The Initial Approach: Extending Bellperson
The assistant's initial plan, visible in the preceding messages, was to add a new synthesis function directly to bellperson—the forked bellperson crate that provides the Groth16 proving implementation. The idea was to create something like synthesize_circuits_batch_with_pce, a parallel to the existing synthesize_circuits_batch_with_hint function. This would have been a natural extension: bellperson already owned the synthesis interface, so adding a new variant there seemed logical.
The assistant had already explored the bellperson codebase thoroughly, reading supraseal.rs, mod.rs, generator.rs, and witness_cs.rs. They understood the ProvingAssignment structure, the DensityTracker mechanism, and how KeypairAssembly captured R1CS constraints during circuit generation. The plan was coherent and well-researched.
The Pivot: Why "Don't Modify Bellperson Further"
The message begins with a decisive statement: "Instead of modifying bellperson further, the PCE path should be entirely in cuzk-core and cuzk-pce." This is not a casual preference—it is an architectural commitment. The reasoning, while not spelled out explicitly in the message, can be reconstructed from the surrounding context.
First, there is a question of ownership and maintenance burden. Bellperson is a fork of the upstream bellperson crate, itself a Rust implementation of the BLS12-381 Groth16 proving system. Every modification to bellperson creates a divergence from upstream, increasing the cost of future merges and complicating dependency management. The cuzk-pce crate, by contrast, is a new crate within the cuzk workspace—entirely owned by the project, with no upstream to track.
Second, there is a question of abstraction boundaries. The bellperson crate's supraseal.rs module is specifically designed for the C++ SupraSeal GPU prover integration. Adding PCE logic there would mix two different optimization strategies—one based on GPU acceleration of MSM operations, the other based on pre-compiled sparse matrix evaluation—into the same module. This would create a confusing API surface and make it harder to reason about which path was being used.
Third, and most importantly, there is the insight that the existing API already supports the PCE flow. The prove_from_assignments() function takes a Vec<ProvingAssignment<Scalar>> and runs the GPU proving pipeline. It does not care how those ProvingAssignment objects were constructed—whether through traditional synthesis, pre-compiled evaluation, or any other method. The PCE's job is simply to produce ProvingAssignment objects more efficiently. If that can be done without touching bellperson's synthesis interface, then the integration cost drops to nearly zero.
The Design Decision: Constructing ProvingAssignment from PCE Output
The message lays out a clean four-step flow:
- Witness generation via
WitnessCS::synthesize()— this produces the witness assignments (input and auxiliary variable values) using the existing, well-tested mechanism. The PCE does not replace witness generation; it replaces constraint evaluation. - Constraint evaluation via
evaluate_pce()— this computes thea,b, andcvectors (the evaluations of each constraint's linear combinations against the witness) using the pre-compiled CSR sparse matrix representation. This is the performance-critical step that the PCE is designed to accelerate. - ProvingAssignment construction — the
a,b,cvectors are combined with the witness assignments and density trackers (derived from the PCE's pre-computed density bitmaps) to form completeProvingAssignmentobjects. - Existing proving pipeline — the
ProvingAssignmentobjects are passed toprove_from_assignments(), which runs the GPU MSM operations unchanged. The elegance of this design is that it treats the PCE as a drop-in replacement for the constraint evaluation portion of synthesis, leaving everything else untouched. The proving pipeline—GPU MSM, multi-scalar multiplication, FFTs, and all the complex C++/CUDA machinery in SupraSeal—continues to work exactly as before.
The Assumptions Embedded in the Design
Like any architectural decision, this one rests on several assumptions that deserve scrutiny.
Assumption 1: The PCE output is semantically identical to traditional synthesis output. The a, b, and c vectors produced by the PCE's sparse MatVec must match, bit-for-bit, the vectors produced by the traditional ProvingAssignment synthesis path. If there is any discrepancy, the proofs will be invalid. This assumption is testable—the assistant planned to add a PceExtract benchmark command specifically for validation—but it is not trivial. The CSR matrix representation must capture the exact same R1CS constraints, in the same order, with the same coefficients.
Assumption 2: The density trackers can be derived from the PCE's pre-computed data. The DensityTracker is used by the GPU MSM to determine which scalar values are dense (non-zero) and which are sparse (zero), enabling optimization of the multi-scalar multiplication. In the traditional path, density trackers are built during synthesis by observing which variables are actually used. In the PCE path, the density information is embedded in the CSR matrix structure itself—each row's non-zero entries correspond to the variables that participate in that constraint. The assistant's plan to "build density trackers from PCE" assumes this information can be extracted efficiently.
Assumption 3: The constructor for ProvingAssignment from PCE data is straightforward. The message says "Let me add a constructor in bellperson for that," implying a small, localized change. This is the one modification to bellperson that the design requires—a from_pce constructor or similar. The assumption is that ProvingAssignment's internal fields (the a, b, c evaluation vectors, the input_assignment and aux_assignment witness values, and the density trackers) are sufficiently accessible to construct from external data. If the fields are private or the construction logic is complex, this assumption could prove costly.
Assumption 4: The WitnessCS synthesis path is compatible with the PCE's circuit structure. The PCE records constraints during a single synthesis run via RecordingCS, which implements the ConstraintSystem trait. The resulting CSR matrices encode the structure of the circuit. When the PCE evaluates these matrices against a witness, it assumes the witness has the same number of variables and the same ordering as the recording run. If the circuit structure changes between recording and evaluation (e.g., due to conditional constraints or variable-length circuits), the PCE output will be incorrect. For Filecoin's PoRep circuits, which are fixed-structure, this assumption is safe—but it is an assumption nonetheless.
The Input Knowledge Required
To understand this message fully, one needs substantial domain knowledge spanning several layers of abstraction:
Groth16 proving systems: The message assumes familiarity with the structure of a Groth16 proof, including the role of the a, b, and c evaluation vectors (which represent the constraint system's evaluation at a given witness), the ProvingAssignment data structure (which bundles these evaluations with witness assignments for the GPU prover), and the separation between witness generation (computing variable values) and constraint evaluation (computing the constraint polynomials).
R1CS and CSR matrix formats: The PCE replaces the traditional constraint system evaluation (which walks the circuit graph and evaluates each linear combination) with a sparse matrix-vector multiply. This requires understanding that R1CS constraints can be represented as sparse matrices (A, B, C) where each row is a constraint and each column is a variable. The CSR (Compressed Sparse Row) format is a standard representation for such matrices, storing row pointers, column indices, and coefficient values.
The cuzk project architecture: The message references cuzk-core, cuzk-pce, and bellperson as distinct components with specific responsibilities. Understanding the workspace structure, the dependency graph, and the existing proving pipeline (prove_from_assignments()) is essential to appreciating why the architectural pivot matters.
The Phase 4 post-mortem: The message is informed by the hard-won lessons of Phase 4, where several promising optimizations (SmallVec, cudaHostRegister, pre-allocation hints) were rejected after empirical testing revealed regressions or zero impact. The decision to minimize changes to bellperson reflects a growing appreciation for surgical, well-contained modifications over broad refactoring.
The Output Knowledge Created
This message created several forms of knowledge that shaped the subsequent implementation:
A clear architectural boundary: The PCE is now defined as a component that lives entirely within the cuzk workspace, communicating with the proving pipeline through the ProvingAssignment interface. This boundary simplifies reasoning about the system: the PCE produces ProvingAssignment objects, and the pipeline consumes them. Neither needs to know about the other's internals.
A concrete implementation plan: The four-step flow (witness generation → PCE evaluation → ProvingAssignment construction → existing pipeline) provides a clear roadmap for the implementation. Each step has a well-defined input and output, and the dependencies between steps are explicit.
A validation strategy: By keeping the existing prove_from_assignments() function unchanged, the design enables a direct comparison between the traditional synthesis path and the PCE path. The same proving pipeline processes both, so any difference in proof time or proof validity can be attributed solely to the constraint evaluation step.
A minimal bellperson footprint: The only change required to bellperson is the addition of a constructor (or factory function) for ProvingAssignment from PCE data. This is a small, self-contained modification that does not alter any existing behavior and can be easily maintained.
The Thinking Process Revealed
The message reveals a specific mode of engineering thinking: the search for the minimal viable integration point. The assistant had been exploring the codebase, reading files, and planning a more invasive change (adding a new synthesis function to bellperson). At some point during this exploration, the insight crystallized: the existing prove_from_assignments() function is the natural integration point, not the synthesis interface.
This is a classic "inversion of control" insight. Instead of asking "how do I add PCE support to bellperson's synthesis layer?", the assistant asked "what does the proving pipeline actually need from synthesis?" The answer—ProvingAssignment objects—led directly to the realization that the PCE could produce those objects independently.
The message also shows a preference for composition over modification. Rather than modifying bellperson's synthesis function to optionally use PCE, the design composes two independent components: WitnessCS (for witness generation) and the PCE evaluator (for constraint evaluation), with a thin adapter layer to produce ProvingAssignment objects. This compositional approach is more flexible, easier to test, and less likely to introduce regressions in the existing synthesis path.
The Broader Significance
In the larger narrative of the cuzk project, this message represents a turning point. Phase 4 had been about optimizing the existing synthesis path—tweaking data structures, reducing allocations, and squeezing cycles out of hot loops. Phase 5, with the PCE, is about replacing the synthesis path entirely. The decision to keep the PCE separate from bellperson ensures that the old synthesis path remains intact as a reference and fallback, while the new path can be developed, tested, and benchmarked independently.
The message also demonstrates a key principle of systems engineering: when integrating a new component into an existing system, the best integration point is often the one that requires the least modification to existing code. By identifying prove_from_assignments() as the natural boundary, the assistant avoided a cascade of changes to bellperson's internals and kept the PCE implementation focused and self-contained.
This is the kind of decision that looks obvious in retrospect but requires deep understanding of the system to recognize in the moment. The message, for all its brevity, captures that moment of recognition—and the subsequent implementation would prove the design sound.