The Edit That Changed Everything: Patching SHA-256 Closures to Eliminate Hidden Allocations

In the middle of a deep optimization campaign targeting Groth16 SNARK synthesis for Filecoin PoRep proofs, a single edit confirmation — [edit] /home/theuser/curio/extern/bellpepper-core/src/gadgets/boolean.rs Edit applied successfully. — marks the decisive pivot from a failed generic optimization to a surgically targeted one. This message, [msg 1188], is deceptively brief: it is the culmination of a multi-hour investigation that began with a disappointing ~1% speedup and ended with the discovery that the real bottleneck was hiding inside the circuit code's most frequently called function.

The Failed Promise of Recycling

The story starts with a Vec recycling pool. Earlier in [msg 1161], the assistant had implemented a pool that reused six Vec allocations per enforce() call, expecting to eliminate the ~34% of runtime that perf stat attributed to jemalloc allocation and deallocation. The reasoning was sound: each enforce() call creates three LinearCombination objects (for A, B, and C constraints), each backed by a Vec<(usize, Scalar)>. With ~130 million constraints in a Filecoin PoRep proof, even tiny per-constellation savings should compound into substantial gains. The assistant estimated 15.6 seconds saved from 6 allocs + 6 frees per constraint at ~120ns each.

But the microbenchmark told a different story: only ~1% improvement, from 55.5s to 54.9s. Something was wrong.

perf Record Reveals the Truth

The assistant turned to perf record for answers. In [msg 1167], the profile data arrived:

11.14%  ProvingAssignment::enforce
 8.52%  __mulx_mont_sparse_256
 6.82%  UInt32::addmany
 6.51%  bellpepper_core::gadgets::...  (Boolean::lc)

The recycling pool was invisible in the profile — zero samples for take, give, recycle, or zero_recycled. The pool operations were so cheap they didn't register. But Boolean::lc() at 6.51% was a different story. This function creates a fresh LinearCombination object every time it's called, allocating a new Vec on the heap.

A subagent investigation in [msg 1172] confirmed the pattern: 84% of closures do use the recycled lc argument, but they then call Boolean::lc() inside the closure body to create temporary LCs for individual bits. Each UInt32::addmany calls Boolean::lc() 32 times per operand. For SHA-256, this means thousands of temporary LC allocations inside a single enforce() call. The recycling pool addressed the 6 Vecs per enforce, but the real bottleneck was the dozens of temporary LCs created per constraint inside the closures.

The Strategic Pivot: In-Place Operations

The assistant recognized that the recycling pool was fighting the wrong battle. Instead of recycling the 6 Vecs that enforce() itself manages, the optimization needed to target the temporary LCs created by Boolean::lc() inside the circuit code. The solution: add add_to_lc and sub_from_lc methods to Boolean that directly add or subtract a boolean's term(s) to an existing LinearCombination without creating a temporary object.

In [msg 1175], the assistant added these methods to bellpepper-core/src/gadgets/boolean.rs. Then in [msg 1177], the hottest call site — UInt32::addmany — was patched. In [msg 1183], Num::add_bool_with_coeff was patched. In [msg 1185], the enforce_equal closures in boolean.rs were patched.

Message 1188: Patching SHA-256's Heart

Message [msg 1188] is the edit that patches the sha256_ch and sha256_maj enforce closures. These are among the most frequently executed code paths in the entire synthesis pipeline — SHA-256 is the workhorse of Filecoin's Proof-of-Replication circuit.

The sha256_ch closure at line 665-670 of boolean.rs looked like this before the edit:

cs.enforce(
    || "ch computation",
    |_| b.lc(CS::one(), Scalar::ONE) - &c.lc(CS::one(), Scalar::ONE),
    |_| a.lc(CS::one(), Scalar::ONE),
    |lc| lc + ch - &c.lc(CS::one(), Scalar::ONE),
);

Each of these b.lc(), c.lc(), a.lc(), and ch.lc() calls creates a temporary LinearCombination with a fresh heap allocation. The closure ignores the recycled lc entirely (using |_|), so the recycling pool is completely wasted on these paths. The edit replaces these with calls to add_to_lc and sub_from_lc, eliminating the temporary allocations.

Similarly, the sha256_maj closure at line 803-810:

cs.enforce(
    || "maj computation",
    |_| {
        bc.lc(CS::one(), Scalar::ONE) + &bc.lc(CS::one(), Scalar::ONE)
            - &b.lc(CS::one(), Scalar::ONE)
            - &c.lc(CS::one(), Scalar::ONE)
    },
    ...

This creates four temporary LCs in a single closure. The edit transforms these into in-place operations on a single LC, avoiding four heap allocations per enforce call.

Assumptions and Corrected Understanding

The original optimization rested on a key assumption: that the 6 Vec allocations per enforce() call were the dominant source of allocation overhead. This assumption was reasonable — perf stat had shown ~34% of runtime in jemalloc alloc/dealloc. But the assumption failed to account for where those allocations were happening. The recycling pool targeted the 6 Vecs managed by enforce() itself, but the circuit code's closures were creating dozens of additional LCs that bypassed the pool entirely.

The corrected understanding, revealed by perf record and the subagent analysis, is that allocation overhead in synthesis is dominated by temporary LinearCombination objects created inside closures, particularly through Boolean::lc(). The 6 Vecs per enforce() are a small fraction of total allocation volume. The real savings come from eliminating temporary LC creation at the hottest call sites.

Knowledge Flow

The input knowledge required to understand this message includes: the architecture of Groth16 SNARK synthesis in bellperson/bellpepper-core, the enforce() closure pattern, the Boolean::lc() method's allocation behavior, and the perf profiling results. The output knowledge created by this message is a patched set of SHA-256 closures that avoid temporary LC allocations, plus the broader insight that in-place operations on existing LCs are more effective than recycling pools for this workload.

Conclusion

Message [msg 1188] is a single line in a conversation, but it represents a fundamental shift in optimization strategy. It is the moment when data from perf profiling overruled intuition, when a generic recycling approach was abandoned for targeted in-place operations, and when the assistant learned that in SNARK synthesis, the hottest allocations are not where you expect them — they are hiding inside the circuit code's most innocent-looking function calls. The edit itself is trivial; the thinking behind it is not.