The Allocation Hypothesis: When a Quarter-Terabyte of Wasted Copies Changed Nothing

Introduction

In performance engineering, the most dangerous moment is when the theory is perfect. When every piece of evidence points in the same direction, when the numbers align, when the fix is sitting there waiting to be wired up—that is precisely when the gap between intuition and measurement is widest. The opencode session captured in this chunk is a masterclass in that gap: a beautifully reasoned hypothesis about allocation overhead, supported by staggering theoretical numbers (~265 GB of redundant memory copies, ~810 unnecessary system calls), that was completely invalidated by rigorous measurement.

The story begins with a triumph. In Phase 4 of the cuzk proving engine optimization project—a persistent GPU-resident Groth16 proving server for Filecoin—the team had just achieved a dramatic win. By moving synchronous destructor calls to background threads, they eliminated a 10-second GPU wrapper regression, bringing total end-to-end proof time from 88.9 seconds down to 77.2 seconds—a 13.2% improvement. The deallocation bottleneck had been identified, measured, and fixed.

Then came a simple, elegant question from the user at <msg id=1291>: "If there was a win in dealloc, is it possible that alloc can have a similar one?"

This question launched a chain of investigation, implementation, and ultimately, a humbling encounter with reality that would reshape the team's understanding of where the synthesis bottleneck truly lies.

The Context: A 10-Second Deallocation Victory

To understand the allocation hypothesis, we must first understand the deallocation victory that inspired it. The Phase 4 optimization work had been proceeding methodically through a list of "compute quick wins" projected to deliver 2-3x throughput improvement. The reality was proving more stubborn. Two textbook optimizations—SmallVec for the LinearCombination indexer and cudaHostRegister for pinned memory—had been invalidated by real hardware testing on the Zen4 architecture. The SmallVec change caused an IPC regression that made synthesis slower, not faster. The cudaHostRegister change introduced 5.7 seconds of mlock overhead that negated any transfer bandwidth benefit.

But two unplanned discoveries delivered real gains. The first was Boolean::add_to_lc/sub_from_lc methods that eliminated millions of temporary LinearCombination allocations in the circuit gadget hot paths, dropping synthesis time from 55.4s to 50.9s—an 8.3% improvement confirmed by perf stat showing 91 billion fewer instructions (-15.3%) and 18.6 billion fewer branches (-26.7%).

The second discovery was more dramatic. The assistant noticed that the GPU wrapper function was taking 36.0 seconds to return, but CUDA internal timing showed only ~26 seconds of actual GPU compute. The missing 10 seconds was traced to synchronous destructor overhead: after GPU proving completed, ~37 GB of C++ vectors (split_vectors, tail_msm_bases) and ~130 GB of Rust Vecs (ProvingAssignment's a, b, c) were freed synchronously, blocking the return path while munmap updated page tables. The fix was elegantly simple: move ownership to detached threads on both the C++ and Rust sides, allowing destructors to run in the background while the function returns immediately. GPU wrapper time dropped from 36.0s to 26.2s, now matching CUDA internal timing exactly.

This was the victory that prompted the user's question. If freeing memory was costing 10 seconds, surely allocating that same memory must have a comparable cost.

The Investigation: Tracing the Allocation Pattern

The assistant's response at <msg id=1292> immediately validated the user's intuition: "If freeing ~130 GB of Rust Vecs took ~10s, then allocating them during synthesis must have a similar cost—it's the same mmap/page-fault overhead in reverse." The assistant dispatched a subagent task to trace exactly how ProvingAssignment Vecs grow during synthesis.

The investigation revealed a striking picture. All five main Vecs in ProvingAssignmenta, b, c, input_assignment, and aux_assignment—start at capacity zero and grow organically via push(). For the 130 million constraints in a PoRep C2 proof, each Vec undergoes approximately 27 reallocation cycles as Rust's geometric doubling strategy doubles capacity each time it fills. Each reallocation follows the same pattern: mmap a new buffer of twice the current size, memcpy all existing data into the new buffer, then munmap the old buffer.

The theoretical waste was staggering. Per circuit, the geometric doubling series produces approximately 25 GB of redundant memory copies across all five Vecs. For 10 parallel circuits (the standard batch size), that's ~250 GB of wasted copies and ~810 mmap/munmap syscalls. These numbers are large enough that any engineer would expect them to matter.

But then came the critical discovery at <msg id=1293>: the infrastructure to fix this problem already existed but was never wired up. Bellperson's supraseal module defined a SynthesisCapacityHint struct with fields for num_constraints, num_aux, and num_inputs, and a function synthesize_circuits_batch_with_hint that would pre-allocate all Vecs to their final capacity before synthesis began. The documentation even boasted that "providing accurate hints eliminates ~27 reallocation cycles per vector and avoids ~32 GiB of redundant memory copies for 32 GiB PoRep C2." Yet every call site in pipeline.rs passed None as the hint, leaving the optimization dormant.

The API was a solution in search of a problem that everyone knew existed but nobody had wired up.

The Implementation: Wiring Up Six Call Sites

The assistant's implementation strategy was methodical and carefully designed. Rather than hardcoding known values per circuit type (which would be fragile if circuit parameters changed), the assistant designed a global hint cache: the first synthesis of any circuit type would grow organically and record the actual final capacities; subsequent syntheses would use those cached hints for pre-allocation.

The implementation required several components:

  1. A global hint cache: A Mutex<HashMap<CircuitId, SynthesisCapacityHint>> protected by once_cell::sync::Lazy for thread-safe initialization.
  2. A helper function synthesize_with_hint: This function looks up the cache for the given CircuitId, calls synthesize_circuits_batch_with_hint with the cached hint (or None if not yet available), and after the first synthesis, stores the hint from the resulting ProvingAssignment's actual capacities.
  3. Modifications to all six synthesis call sites: Each call site in pipeline.rs needed to be updated to use the new helper instead of the raw synthesize_circuits_batch function. The six call sites spanned different proof types and pipeline stages: - synthesize_porep_c2_multi: The multi-sector PoRep batch, handling 10 circuits in parallel - synthesize_porep_c2_partition: Single-partition PoRep synthesis - synthesize_porep_c2_batch: Batch-mode PoRep synthesis (Phase 3) - synthesize_winning_post: Winning PoSt circuit synthesis - synthesize_window_post: Window PoSt circuit synthesis - synthesize_snap_deals: SnapDeals circuit synthesis The implementation was not without its hiccups. A mass-edit using find-and-replace accidentally matched the wrong call site—the synth_ms log pattern appeared in multiple places, and the edit tool's pattern matching hit a different instance than intended. This required careful debugging and repair over several subsequent messages ([msg 1327] through [msg 1338]). The assistant had to grep for all occurrences, verify each replacement manually, and fix a mistaken replacement where a SnapDeals32G label ended up on a PoRep function. A compilation error also revealed that the CircuitId enum had unhandled variants (Porep64G, SnapDeals64G) that needed match arms. Through systematic effort, all six call sites were converted, the code compiled successfully, and the infrastructure was committed as 41999e0b with 117 insertions across pipeline.rs.

The Benchmark: When Theory Meets Reality

The assistant ran a rigorous two-phase benchmark. First, a single-partition synth-only microbenchmark ran four iterations of PoRep C2 synthesis. The results were unambiguous:

| Iteration | Hint Status | Synthesis Time | |-----------|-------------|----------------| | Iter 1 | No hint (organic growth) | 50.7s | | Iter 2 | With cached hint | 50.9s | | Iter 3 | With cached hint | 51.0s | | Iter 4 | With cached hint | 50.9s |

Zero measurable impact. The variation between iterations was within measurement noise.

To be thorough, the assistant then ran a full end-to-end test with the daemon, comparing two consecutive proofs: the first with organic growth, the second with pre-allocation from the cached hint. The result was identical: 50.65 seconds of synthesis time in both cases.

The assistant's reaction in the subsequent message was telling: "This is surprising given the ~250 GB of wasted copies we estimated." But the analysis that followed was a masterclass in understanding why.

The Analysis: Why Allocation and Deallocation Are Not Symmetric

The assistant's post-mortem analysis revealed a fundamental asymmetry between allocation and deallocation in this workload. The reasoning is worth examining in detail because it teaches a general lesson about memory management in high-performance computing.

Deallocation was expensive because munmap() of large contiguous regions is a synchronous kernel operation that tears down page tables all at once. After GPU proving completed, all ~130 GB of Rust Vecs and ~37 GB of C++ vectors were freed synchronously in their destructors. The munmap syscall must update page table entries, flush TLB entries across all cores, and return physical pages to the kernel's free list. With 130+ GB of memory, this takes real time—approximately 10 seconds. There was no computation to overlap with, no parallelism to hide the cost. The deallocation was a discrete, concentrated event on the critical path.

Allocation during synthesis, by contrast, happens incrementally through push() with geometric doubling. Each reallocation copies only the data that has been written so far. Early reallocations (when Vecs are small) are trivially cheap—copying 1, 2, 4, 8, 16 elements takes nanoseconds. The first ~20 doublings copy less than 256 MB total. By the time Vecs reach multi-gigabyte sizes, they double infrequently—only 3-4 times from 2 GB to 4 GB. Each of those late-stage reallocations copies 2-4 GB at memory bandwidth speed (~100ms on Zen4 with ~40 GB/s bandwidth), but they happen concurrently across 10 circuits thanks to rayon's parallel iterator.

The assistant quantified this precisely. For a single circuit, the total wasted copy across all reallocations is approximately 26.5 GB. At ~40 GB/s memory bandwidth, that's about 0.66 seconds of pure copy time. Distributed across 50.7 seconds of synthesis and overlapped with field arithmetic (which dominates the CPU profile at 16.98%), the reallocation cost is hidden in the noise.

The key insight is that Rust's geometric doubling strategy amortizes the cost beautifully. The average cost per push() is O(1) with a tiny constant. The mmap/munmap syscalls for the ~27 reallocations per Vec are fast because the kernel's virtual memory system handles them lazily—new pages are not actually faulted in until they're first accessed. And crucially, the reallocations happen during synthesis, interleaved with computational work, not after it like the deallocation.

The Deeper Lesson: Measurement Over Intuition

The allocation hypothesis was compelling. It had a clear mechanism (27 reallocation cycles × 10 circuits = 810 syscalls + 250 GB of copies), a plausible magnitude (if dealloc was 10 seconds, alloc must be comparable), and an existing fix API (just wire up the hint). Any engineer reading the analysis would nod along and say, "Yes, that must be the problem."

But it wasn't. The measurement showed zero impact.

This is the central lesson of this chunk: intuition is not a substitute for instrumentation. The SmallVec optimization (A1) was cancelled because it caused a regression on Zen4. The cudaHostRegister optimization (B1) was cancelled because the mlock overhead dwarfed the transfer benefit. And now the allocation hint optimization (A2) was found to have no effect at all.

Each of these "obvious" wins was invalidated by measurement. And each invalidation taught the team something deeper about the system's behavior—that Zen4's cache hierarchy doesn't benefit from small-vector optimizations, that pinning 130 GB of memory has a cost that exceeds the DMA bandwidth gain, and that allocation during synthesis is effectively free because it's hidden behind computation.

The assistant's response to this zero result was exemplary. The SynthesisCapacityHint infrastructure was committed as a defensive optimization—it prevents pathological fragmentation under memory pressure, costs nothing when not triggered, and documents the expected vector sizes for anyone reading the code. But more importantly, the benchmark results were accepted and used to update the team's mental model of where the synthesis bottleneck truly lies.

The Confirmed Bottleneck: Synthesis Is Purely Computational

The zero-impact result of the allocation hypothesis has a profound implication: the synthesis bottleneck at ~50.8 seconds is now confirmed to be purely computational, not memory-management overhead. The time is dominated by field arithmetic (16.98% of CPU cycles), LC evaluation (13.53%), and other gadget operations—not by mmap/munmap syscalls or memcpy during reallocation.

This conclusion directly motivates Phase 5 of the roadmap: the Pre-Compiled Constraint Evaluator (PCE). PCE replaces circuit synthesis entirely with sparse matrix-vector multiply. Instead of traversing the circuit graph and evaluating each constraint individually, PCE pre-computes the R1CS matrix structure once per circuit topology and then generates witnesses using only alloc() closures, skipping enforce() entirely. The a/b/c vectors are produced by sparse matrix-vector multiply: a = A·w, b = B·w, c = C·w.

The projected impact is 3-5x faster synthesis, which would bring synthesis time from ~50s down to ~10-16s. Combined with the GPU time of ~26s, total proof time would be ~36-42s—approaching the 2-3x throughput target that Phase 4's "quick wins" failed to deliver.

The allocation hypothesis investigation was not wasted effort. It ruled out a plausible bottleneck, confirmed the true nature of the synthesis phase, and strengthened the case for the deeper architectural changes planned in Phase 5. In the high-stakes world of Filecoin proof generation, knowing where not to optimize is just as valuable as knowing where to optimize.

The Philosophical Implications: Why We Measure

The story of this chunk is a microcosm of a much larger phenomenon in engineering: the tension between theory and measurement. Every engineer has experienced the moment when a beautifully reasoned optimization delivers nothing. The temptation is to rationalize—to tweak parameters, run more iterations, look for the effect in a different metric. But the disciplined engineer accepts the null result and updates their mental model.

The allocation hypothesis was wrong, but it was wrong in the most productive way possible. It ruled out a plausible bottleneck, confirmed the true nature of the synthesis phase, and strengthened the case for the deeper architectural changes planned in Phase 5. The investigation was thorough, the implementation was clean, the measurement was definitive, and the conclusion was accepted.

This is the scientific method applied to engineering: form a hypothesis, design an experiment, implement the experiment, measure the result, accept or reject the hypothesis, and commit the infrastructure anyway as a defensive optimization. The process is its own reward, regardless of whether the hypothesis is confirmed.

Conclusion

The user's question at <msg id=1291>—"If there was a win in dealloc, is it possible that alloc can have a similar one?"—was a brilliant piece of analogical reasoning. It recognized a pattern (large memory operations on the critical path are expensive) and asked whether that pattern applied to a different phase of the same pipeline. The investigation that followed was thorough and methodical. The implementation was clean and well-designed. The measurement was definitive.

And the answer was no. Allocation and deallocation are not symmetric in this workload. Deallocation was expensive because it was synchronous, concentrated, and on the critical path after GPU proving. Allocation is cheap because it's incremental, amortized, and overlapped with computation.

The SynthesisCapacityHint infrastructure was committed as defensive code—117 insertions across pipeline.rs, now part of the project's permanent optimization arsenal. But the real output of this investigation is knowledge: the synthesis bottleneck at ~50.8 seconds is purely computational, and the path to 2-3x throughput improvement lies through Phase 5's PCE approach, not through further memory-management optimizations.

Sometimes the most valuable result of an experiment is learning what doesn't work. The allocation hypothesis was wrong, but testing it was exactly the right thing to do.