The Three Trials of Phase 5: Correctness, Performance, and Memory Validation of the Pre-Compiled Constraint Evaluator

Introduction

The development of the Pre-Compiled Constraint Evaluator (PCE) for the cuzk proving engine reached its defining moment in Segment 17. What began as a promising optimization—pre-compiling the constraint system of Filecoin's PoRep circuits to avoid repeated synthesis overhead—faced three distinct trials that tested its viability from every angle. First, a subtle correctness bug threatened the entire approach when ~53% of constraint evaluations mismatched the baseline. Second, a performance regression made the supposedly faster PCE path actually slower than the old path. Third, a reported 375 GB peak memory usage raised existential questions about whether the PCE could scale to multi-GPU deployments.

Each trial was met with systematic engineering: the correctness bug was traced to an inconsistent column offset in RecordingCS::enforce() and fixed with a tagged encoding scheme; the performance regression was diagnosed as a sequential CSR MatVec and resolved with parallel execution via rayon, achieving a 1.42× overall speedup; and the memory phantom was identified as a benchmark artifact, leading to the design and implementation of a new pce-pipeline subcommand with inline RSS tracking that empirically validated the PCE's memory model for concurrent pipelines.

This article synthesizes the full arc of Segment 17, tracing how the PCE was forged through debugging, profiling, and empirical validation into a production-ready component. We will see how the assistant's discipline of documenting before testing, verifying assumptions by reading source code, and building custom tools to answer specific questions combined with the user's sharp systems-level questioning to produce a result that is both faster and better understood than when the phase began.

The First Trial: A Correctness Bug in RecordingCS Column Indexing

The PCE's core idea is elegant: instead of re-synthesizing the constraint system from scratch for every proof (which involves walking the circuit graph, allocating variables, and enforcing constraints one by one), the PCE records the constraint structure once and then evaluates it against each proof's witness values using a fast CSR (Compressed Sparse Row) matrix-vector multiplication. The recording phase captures the shape of the constraint system—which variables appear in which constraints, with which coefficients—while the evaluation phase substitutes the actual witness values.

But recording a constraint system is not as simple as it sounds. The challenge lies in mapping between two different representations of variable indices. During circuit synthesis, variables are allocated sequentially: first the public inputs (via alloc_input()), then the auxiliary variables (via alloc()). The constraint enforce() calls reference these variables by their local indices. But the CSR matrix needs a unified column space where every variable has a single, stable index.

The initial implementation of RecordingCS::enforce() attempted to handle this mapping by using the current num_inputs as an offset for auxiliary variables. When recording a constraint involving an auxiliary variable, it would add num_inputs to the variable's index to produce the column index in the unified space. The reasoning was straightforward: input variables occupy columns 0..num_inputs-1, so auxiliary variables should start at num_inputs.

The flaw in this reasoning was subtle but devastating. During PoRep circuit synthesis, alloc_input() and enforce() calls are interleaved. The circuit graph is built in topological order: as each gadget is processed, it may allocate new input variables, allocate new auxiliary variables, and enforce constraints referencing any combination of previously allocated variables. The num_inputs counter grows throughout synthesis as new inputs are allocated.

This meant that early constraints—recorded when num_inputs was small—used a different offset for auxiliary variables than late constraints, recorded when num_inputs was large. The same auxiliary variable could be mapped to different column indices depending on when its constraint was recorded. The result was chaos: approximately 53% of constraint evaluations produced different results than the old path.

The debugging process that uncovered this bug is a textbook example of systematic fault isolation. The assistant did not guess at the cause or apply random fixes. Instead, the assistant read the source code of RecordingCS, traced the exact flow of variable allocation and constraint enforcement, and identified the precise moment where the inconsistency was introduced. The key insight was that the offset computation must be deferred—it cannot happen at recording time because the final num_inputs is not yet known.

The fix was a tagged encoding scheme. Instead of computing the offset at recording time, the assistant modified RecordingCS to store a tag bit (bit 31 of the column index) that distinguishes auxiliary variables from input variables. The actual offset computation is deferred to into_precompiled(), where the final num_inputs is known and consistent. This separates the concern of recording (which must happen during circuit synthesis) from the concern of remapping (which can happen after all inputs are allocated).

After the fix, all 10 circuits × 130 million constraints validated bit-for-bit correct against the old path. The moment of proof was a turning point: the PCE was no longer a promising idea with a correctness problem—it was a validated implementation.

The Second Trial: A Performance Regression and the Parallel MatVec

With correctness established, the assistant turned to the performance numbers. The initial benchmark showed the PCE path at 61.1s versus 50.4s for the old path—a 21% slowdown. The PCE, which was supposed to be faster, was actually slower. This was the second trial.

The culprit, identified through profiling and analysis, was the CSR MatVec (sparse matrix-vector multiplication). The PCE records three matrices per circuit—A, B, and C—each representing one of the three linear combinations in a Rank-1 Constraint System (R1CS). During evaluation, each matrix must be multiplied by the witness vector to produce the constraint outputs. With 10 circuits and 130 million constraints each, this is a substantial computation.

The initial implementation ran the MatVec sequentially: loop over 10 circuits, for each circuit multiply A, B, and C by the witness vector. This sequential execution consumed 34 seconds—the majority of the PCE path's total time. The old path, by contrast, performed constraint enforcement in parallel across circuits as part of the synthesis process, which naturally distributed the work.

The fix was straightforward in concept but required careful implementation: switch the MatVec from sequential to parallel execution using rayon::par_iter. This reduced the MatVec time from 34s to 8.8s—a 3.86× speedup on that single operation. The overall PCE synthesis time dropped from 61.1s to 35.5s, achieving a 1.42× speedup over the old path's 50.4s.

The 1.42× figure, while respectable, fell short of the 3-5× target that had been set during Phase 5's design. The reason, as the assistant analyzed, was that the PCE only optimizes the constraint enforcement phase of synthesis. Witness generation—which must still be done from scratch for every proof because it depends on the circuit's inputs—remained at 26.5s. With witness generation now the dominant cost, even a perfectly optimized MatVec (zero time) would only achieve at most a 1.9× speedup. The 3-5× target assumed that enforcement was the dominant cost, but Phase 4's earlier optimizations had already trimmed much of that overhead.

This is a crucial lesson in optimization: the returns from optimizing one component are bounded by the costs of the other components. The PCE's 1.42× speedup is real and valuable, but it revealed that the next bottleneck is witness generation, not constraint evaluation. Future optimization work should target witness generation—perhaps through parallelization, pre-computation, or algorithmic improvements.

The Third Trial: The 375 GB Phantom

With correctness validated and performance measured, the assistant prepared to close Phase 5 Wave 1. The summary was positive: 1.42× speedup, 35.5s PCE synthesis vs 50.4s baseline, all circuits correct. But one number caught the user's attention: 375 GB peak RAM, up from 340 GB in the baseline.

The user's response was a masterclass in productive skepticism. Rather than accepting the number at face value, the user asked a precisely formed question that revealed a deep understanding of the system's architecture: "In previous run why was peak mem 375G? Did we copy PCE for each partition and not dedupe?"

This question was not casual. It was a hypothesis about the most likely failure mode of the PCE's memory model. The PCE stores three CSR matrices per circuit—A, B, and C—totaling approximately 25.7 GiB of static data. If this data was being duplicated per circuit or per partition, the economics of multi-GPU deployment would collapse. An 8-GPU system with 2 pipelines per GPU would need 16 copies of the PCE—over 400 GiB of overhead before any actual proof work.

The assistant's response was immediate and methodical: rather than theorizing, the assistant went directly to the source code, reading the pipeline and benchmark files to trace the exact memory allocation pattern.

What the assistant found was a classic benchmark artifact. The pce-bench subcommand was designed to validate correctness by running both the old-path baseline and the PCE path and comparing results. But it held both result sets simultaneously in memory. The old-path contributed ~163 GiB (10 circuits × ~16.3 GiB each), the PCE path contributed ~125 GiB (10 circuits × ~12.5 GiB each), plus the PCE static data at ~25.7 GiB and miscellaneous overhead. Summed together, they reached ~375 GiB.

The PCE itself was stored in a single static OnceLock—a Rust synchronization primitive guaranteeing exactly one initialization. It was never duplicated. The 375 GB peak was a measurement artifact of the validation methodology, not a production concern.

The real production memory model, as the assistant calculated, was far more benign:

Building the Benchmark: The pce-pipeline Subcommand

But the user was not satisfied with theoretical accounting. The user issued a concise directive that would reshape the entire investigation: "Run a benchmark which demonstrates lower memory use + heavier pipelining (maximizing gpu use)."

This message was a turning point. It moved the conversation from analysis to action, from "what should happen" to "what actually happens." The user wanted empirical evidence—not code traces, not memory calculations, but real RSS measurements from a running process that showed the PCE's memory behavior under realistic conditions.

The assistant recognized the challenge immediately. The existing pce-bench subcommand was designed for validation, not memory profiling. A new tool was needed—one that could:

  1. Drop baseline results before running the PCE path (avoiding the artifact)
  2. Run multiple sequential proofs to show PCE amortization
  3. Track RSS at each pipeline stage via /proc/self/status
  4. Use malloc_trim to aggressively release memory between phases
  5. Optionally simulate concurrent pipeline execution for multi-GPU validation The design and implementation of the pce-pipeline subcommand was a rapid, focused engineering effort. The assistant's design philosophy was elegant in its simplicity: run N sequential proofs via the PCE path, logging RSS at each stage. The first proof extracts the PCE (one-time cost) and synthesizes via PCE. Subsequent proofs reuse the cached PCE—just WitnessCS + MatVec. Each proof drops its results before starting the next (no accumulation). RSS snapshots are captured at startup, post-extraction, post-synthesis, and post-drop. The key insight was that memory measurement must be staged. Rather than reporting a single peak number, the benchmark would capture RSS at multiple points in the pipeline's lifecycle, allowing observers to attribute memory usage to specific phases. This staged approach is what made the benchmark convincing—it told a story of allocation and deallocation that matched the system's architecture. Several critical design decisions shaped the benchmark: In-process RSS tracking. Rather than relying on an external monitoring script (which, as the assistant discovered, already existed at /tmp/cuzk-memmon.sh but monitored the daemon process, not the benchmark), the assistant built RSS tracking directly into the benchmark code. This allowed precise correlation between benchmark phases and memory snapshots. malloc_trim integration. The assistant added calls to libc::malloc_trim(0) after dropping large data structures to force the glibc allocator to return freed memory to the OS. Without this, the allocator's internal caching could mask true memory release, making it appear that memory was not being freed. A --compare-old flag. This allowed the benchmark to optionally run the old-path baseline for comparison, then drop it before running the PCE path—directly addressing the artifact that caused the 375 GB peak. A --parallel (-j) flag. Added later in response to the user's question, this enabled concurrent pipeline simulation, directly testing the multi-GPU memory model.## The Sequential Validation: A Sawtooth Pattern of Clean Drops The first run of the pce-pipeline benchmark produced clean, convincing data that told a clear story of memory allocation and deallocation: | Stage | RSS | Notes | |---|---|---| | After c1 load | 0.1 GiB | Baseline process overhead | | Old-path synthesis (held) | 155.7 GiB | 10 circuits in memory | | Old-path DROPPED | 0.1 GiB | malloc_trim works effectively | | After PCE extraction | 25.8 GiB | Static CSR matrices cached in OnceLock | | PCE proof 1 synthesis (held) | 181.6 GiB | 25.8 PCE + ~156 GiB working set | | PCE proof 1 DROPPED | 25.9 GiB | Back to PCE baseline | | Proof 2: same pattern | 181.6 → 25.9 GiB | No memory leak | | Proof 3: same pattern | 181.6 → 25.9 GiB | Stable, predictable | The sawtooth pattern was exactly what the memory model predicted. RSS rose during synthesis as the working set was allocated, then fell back to the PCE baseline when results were dropped. The consistency across all three proofs confirmed that there was no memory leak, no accumulation, and no unexpected duplication. However, the run also revealed a failure in the external monitoring infrastructure. The /tmp/cuzk-benchmon.sh script, which was supposed to track the benchmark's RSS externally, had been monitoring its own shell process (5912 KB) instead of the cuzk-bench process—a classic pgrep self-matching problem. The inline RSS tracking saved the day, providing reliable data despite the external monitor's failure. This was a valuable lesson in defensive instrumentation: always measure from within the process when possible. The sequential benchmark proved that the PCE's memory behavior was clean and predictable for a single pipeline. But the user immediately identified its limitation: "Couldn't this run parallel?"

The Pivot to Parallelism

This question revealed a deep understanding of production deployment. In a real proving system, the CPU does not sit idle while the GPU works. The typical double-buffering pattern is: while the GPU processes proof N's MSM and NTT operations, the CPU synthesizes proof N+1. The sequential benchmark, while useful for establishing baseline memory behavior, did not model this overlap. The peak memory in production would be higher than the sequential numbers because multiple synthesis pipelines could be active simultaneously.

The assistant's response showed immediate recognition: "Now to your question—yes, the 3 sequential proofs could run in parallel to simulate pipelining. The real production scenario is: while GPU processes proof N's results, CPU synthesizes proof N+1."

What followed was a rapid implementation cycle. The assistant:

  1. Designed the parallel mode: "The idea: add a --parallel flag that launches N syntheses concurrently using threads, simulating N GPUs each needing a synthesis result ready."
  2. Added the CLI flag: A --parallel / -j argument to the PcePipeline struct, defaulting to 1 (sequential).
  3. Updated the dispatch: Passed the parallel parameter through the command dispatch.
  4. Implemented the parallel execution: Used std::thread::spawn to launch N concurrent synthesis threads, each running the full PCE pipeline. The PCE extraction happened once before spawning threads, and all threads shared the same static OnceLock data. The key design choice was to keep PCE extraction single-threaded and shared. The PCE is extracted once before any parallel work begins, stored in the static OnceLock, and all concurrent pipelines read from the same shared CSR matrices. This directly tested the memory model: the static 25.7 GiB PCE cost is paid once, while each concurrent pipeline adds its own ~156 GiB working set.

The Parallel Validation: 310.9 GiB and Clean Drops

The parallel benchmark ran with -j 2 (two concurrent pipelines) and --num-proofs 4. The results were definitive:

| Stage | RSS | Notes | |---|---|---| | After c1 load | 0.1 GiB | Baseline | | After PCE extraction | 25.8 GiB | Static PCE data | | 2 concurrent syntheses | 310.9 GiB | 2 × ~156 GiB + 25.7 GiB | | After all proofs dropped | 25.9 GiB | Clean return to PCE baseline |

The peak of 310.9 GiB precisely matched the model of 2 × ~156 GiB working sets + 25.7 GiB static PCE overhead. After all proofs completed, RSS returned cleanly to the PCE baseline, confirming no memory leak and no cross-pipeline interference.

This validated the core thesis: the PCE's memory overhead is a one-time static cost that scales gracefully with the number of concurrent pipelines. For an 8-GPU system with 2 pipelines per GPU, the model predicted ~738 GiB total—only ~3.6% overhead from the PCE static data on top of the ~712 GiB working set.

The parallel validation was the culmination of the memory investigation. What began as a troubling 375 GB data point was traced to a benchmark artifact, analyzed through a theoretical memory model, and empirically validated with a purpose-built benchmarking tool. The PCE's memory model was no longer a matter of speculation—it was a measured, documented, and reproducible fact.

Documentation and Closure

With the empirical validation complete, the assistant turned to documentation. The cuzk-project.md file was updated with the Phase 5 memory analysis, the sequential and parallel benchmark results, and the updated roadmap. The todo list showed all four high-priority items marked completed:

  1. Update cuzk-project.md with Phase 5 results
  2. Add pce-pipeline subcommand with RSS tracking
  3. Add parallel mode: overlap N syntheses to show peak concurrent memory
  4. Run parallel pipeline benchmark and record results The user's response was an empty message—a silence that spoke volumes. In the context of the preceding intense investigation, this silence represented acceptance, satisfaction, and trust. The user's pointed questions had been answered with empirical evidence. The 375 GB phantom had been exorcised. The memory model was validated.

The Partnership That Made It Possible

Throughout Segment 17, a pattern of collaboration is visible. The assistant builds, documents, and tests; the user questions, probes, and redirects. The assistant's architectural documentation provides the map; the user's memory scaling question provides the compass that points toward the most important uncharted territory. The assistant's theoretical memory model provides the hypothesis; the user's skeptical follow-up demands empirical validation.

This partnership is not accidental. It is enabled by the assistant's discipline of externalizing knowledge—writing down architectures, assumptions, and plans in a form that can be examined and challenged. The user's questions are most effective when they can point to a specific claim in the assistant's own documentation and say: "Is that really true? Have you checked?" The assistant's responses are most valuable when they treat such questions not as criticism but as opportunities to deepen understanding.

The user's role as a driver of rigor is particularly noteworthy. The user did not simply accept the assistant's initial analysis of the 375 GB peak. They pushed for empirical validation. They asked "Couldn't this run parallel?"—a question that forced the assistant to build a more realistic benchmark that simulated production deployment conditions. Each user question raised the bar for evidence, forcing the assistant to move from speculation to measurement, from single-run benchmarks to multi-pipeline simulations.

Lessons for Engineering Practice

Several principles emerge from this segment that are transferable to other high-stakes performance engineering efforts:

  1. Interrogate anomalous numbers immediately. The 375 GB peak could have been dismissed as a glitch or accepted as reality. Instead, it was traced to its source, revealing a benchmark artifact that would have misled future optimization efforts.
  2. Build instrumentation into benchmarks from the start. The inline RSS tracking via /proc/self/status was the key enabler—it worked even when the external monitor failed. Always measure from within the process when possible.
  3. Stage memory measurements. A single peak number is easy to misinterpret. A staged RSS trace, showing memory rising and falling in predictable patterns, is much harder to dismiss.
  4. Test under production-like conditions. The sequential benchmark proved correctness, but the parallel benchmark proved deployability. The user's question revealed that the two are not the same.
  5. Document findings continuously. The cuzk-project.md file was updated throughout the investigation, capturing the memory model, benchmark results, and roadmap. Documentation was not deferred until after the investigation—it was maintained as the work progressed.
  6. Understand the bounds of optimization. The 1.42× speedup, while below the 3-5× target, revealed that witness generation is now the dominant cost. Future optimization work should target witness generation, not constraint evaluation. Understanding these bounds prevents wasted effort on components that are no longer the bottleneck.

The Knowledge Produced

Segment 17 produced several forms of durable knowledge that will serve the project beyond the immediate Phase 5 implementation:

A fixed correctness bug. The tagged encoding scheme (bit 31 flags aux vs input) with deferred remapping in into_precompiled() is now the standard approach for RecordingCS column indexing. The bug's root cause—interleaved alloc_input() and enforce() calls producing inconsistent offsets—is documented for future reference.

An optimized MatVec. The parallel CSR MatVec via rayon::par_iter achieves 8.8s for 10 circuits × 130M constraints, a 3.86× improvement over the sequential version. This optimization is immediately applicable to any future work involving sparse matrix operations in the proving pipeline.

A validated memory model. The PCE's 25.7 GiB static overhead is confirmed to be shared across all pipelines via OnceLock. The per-pipeline working set of ~156 GiB is unchanged from the old path. The 375 GB peak was a benchmark artifact, not a production concern.

A new benchmarking tool. The pce-pipeline subcommand with RSS tracking and malloc_trim provides a reusable framework for measuring memory behavior under realistic pipeline conditions. This tool can be extended for future phases and optimization efforts.

A revised roadmap. The 1.42× speedup, while below the 3-5× target, revealed that witness generation is now the dominant cost. Future optimization work (Wave 2) should target witness generation, not constraint evaluation. This insight prevents wasted effort on components that are no longer the primary bottleneck.

Conclusion

The arc of Phase 5's Pre-Compiled Constraint Evaluator—from architectural documentation through correctness debugging, performance optimization, and memory validation—demonstrates what rigorous engineering looks like in practice. It is not about getting everything right the first time. It is about building systems that can be tested, debugging systematically when tests fail, measuring empirically when models are uncertain, and documenting knowledge so that it can be shared and challenged.

The PCE that emerged from this process is not just faster (1.42×) and correct (130M constraints validated). It is understood. Its memory model is quantified, its bottlenecks are identified, its deployment characteristics are validated. This understanding is the most valuable output of the entire phase—more valuable than the speedup itself, because it enables informed decisions about where to invest optimization effort next and whether the PCE is suitable for the deployment scenarios that matter.

The three trials of Phase 5—correctness, performance, and memory—each tested the PCE from a different angle. The correctness bug tested whether the approach was sound. The performance regression tested whether the optimization was worthwhile. The memory phantom tested whether the implementation was deployable. The PCE passed all three trials, emerging as a validated, optimized, and well-understood component of the cuzk proving engine.

The 375 GB ghost was not a memory leak or a design flaw—it was a benchmark artifact that, once understood and addressed, led to a deeper understanding of the system's memory behavior and a robust benchmarking tool that will serve future optimization efforts. In the end, the question that seemed to threaten the PCE's viability became the catalyst for its most thorough validation.

References

[1] "From Correctness to Deployment: The Full Arc of Phase 5's Pre-Compiled Constraint Evaluator" — Chunk article covering the architectural documentation, correctness debugging, and performance optimization of the PCE.

[2] "From Phantom to Proof: How a 375 GB Benchmark Artifact Led to a Validated Memory Model for Multi-GPU Proving" — Chunk article covering the memory investigation, benchmark design, and empirical validation of the PCE's memory model.