The Checkpoint: How a Memory Crisis Reshaped a SNARK Proving Engine's Architecture

Introduction

In the middle of a months-long optimization campaign for a Filecoin SNARK proving pipeline, an AI assistant paused to write a message. Not a tool call, not a code edit, not a benchmark command — but a comprehensive, 2,000-word status document that would serve as the intellectual bridge between a failed experiment and a successful redesign. This message, indexed as message 3131 in the conversation, is a remarkable artifact of the engineering process: a moment of deliberate reflection in the middle of furious optimization work.

The message is addressed to the assistant itself (or perhaps to the user), written in the imperative voice of a technical specification. It begins with "## Goal" and proceeds through "Instructions," "Discoveries," "Accomplished," "Current State," and "What Needs To Be Done Next." It reads like a project manager's status report fused with a kernel developer's debugging notes. But beneath its structured surface lies a story of discovery, failure, and the kind of systems-level insight that only emerges after watching a 668 GiB process OOM on a machine with 755 GiB of RAM.

This article examines that single message in depth: why it was written, what it reveals about the engineering process, the assumptions it makes, the mistakes it documents, and the thinking it exposes. For someone unfamiliar with the conversation, this message offers a window into the moment when a team (or in this case, an AI-human pair) realizes that their intuitive solution to a performance problem is wrong, and that the real solution requires a more nuanced understanding of their system's dynamics.

The Context: Phase 12 and the Split GPU Proving API

To understand message 3131, one must first understand what Phase 12 is and why it matters. The broader project is an optimization campaign for cuzk, a pipelined SNARK proving engine used in Filecoin's Proof-of-Replication (PoRep) protocol. The proving pipeline is a complex chain of CPU-bound synthesis (building arithmetic circuits from witness data) followed by GPU-bound proving (computing the actual Groth16 proof using elliptic curve operations). The bottleneck shifts between CPU and GPU depending on configuration, and the entire system is constrained by a 755 GiB RAM budget on a high-end workstation with an AMD Threadripper PRO 7995WX and an RTX 5070 Ti.

Phase 12 introduced a "split" GPU proving API. The key insight was that the GPU worker's critical path included a CPU-bound computation called b_g2_msm (a multi-scalar multiplication on the G2 curve) that took about 1.7 seconds per partition. During those 1.7 seconds, the GPU was idle, holding the GPU lock, unable to pick up the next synthesized partition. The split API decoupled this: generate_groth16_proofs_start_c would release the GPU lock immediately after the GPU work was done, returning a "pending handle" while b_g2_msm continued in a background thread. The GPU worker could then immediately loop back and pick up the next partition, overlapping the CPU-bound b_g2_msm of one partition with the GPU work of the next.

This was a significant architectural change. It required modifications across four layers: the CUDA C++ code in groth16_cuda.cu, the Rust FFI layer in lib.rs, the bellperson library's supraseal.rs, and the engine's engine.rs. It also introduced a use-after-free bug (the C++ prep_msm_thread captured a stack pointer by reference via [&, num_circuits], which became dangling after the C function returned) that had to be fixed by copying the Assignment<fr_t> array into a heap-allocated struct.

The initial results were promising: with pw=10 (10 partition workers), the system achieved 37.1 seconds per proof — a solid improvement over Phase 11's 36.7 seconds, especially considering the architectural complexity being introduced. But when the team tried to push further with pw=12, they hit a wall: the process OOM'd at 668 GiB of RSS, leaving only 87 GiB of headroom on the 755 GiB machine.

The Memory Pressure Crisis

The OOM at pw=12 was not just a configuration issue — it was a symptom of a fundamental flaw in the pipeline's backpressure architecture. The assistant discovered this through careful instrumentation, adding atomic buffer flight counters (PROVERS_IN_FLIGHT, AUX_IN_FLIGHT, SYNTH_IN_FLIGHT) that logged the state of every buffer allocation at key events. These counters revealed the shocking truth: at peak, there were 28 synthesized ProvingAssignment sets alive simultaneously, each holding approximately 12 GiB of a/b/c evaluation vectors. That's 336 GiB just for the prover data. Additionally, there were 97-99 aux_assignment buffers alive, each ~4 GiB, accounting for another ~388 GiB. The total estimated memory at peak was 727 GiB — perilously close to the 755 GiB ceiling.

How could 28 prover sets accumulate when only 12 partition workers were configured? The answer lies in the pipeline's asynchronous architecture. The partition semaphore (capped at pw=12) controlled how many synthesis tasks could run concurrently. But the semaphore permit was released as soon as synthesis completed, before the synthesized job was sent to the GPU worker via a channel. The channel had a capacity of exactly 1 (the synthesis_lookahead parameter defaulted to 1). So when 12 synthesis tasks completed around the same time, only 1 could be enqueued in the channel; the other 11 would block on synth_tx.send().await, each holding their ~16 GiB SynthesizedJob in memory. Meanwhile, the GPU could only process partitions at about 3.5 seconds each, so the backlog grew at roughly 9 partitions per cycle (12 finishing synthesis in ~5 seconds while the GPU consumed ~3 in the same time).

This was a classic producer-consumer problem where the producer was throttled by a semaphore that released too early. The semaphore limited "concurrent synthesis" but not "synthesized-but-not-yet-consumed-by-GPU." The channel capacity of 1 was supposed to provide backpressure, but it only blocked the next synthesis task — it couldn't prevent the 11 already-completed tasks from sitting on their data while waiting for their turn to send.

The Semaphore Fix Experiment

The assistant's first attempted fix was intuitive and elegant: move the semaphore permit out of the spawn_blocking closure (where it was dropped after synthesis) and keep it in the outer async task until after synth_tx.send() completed. This way, the permit would be held for the entire duration from "start synthesis" to "job accepted by channel," preventing any new synthesis from starting until the channel had room.

The fix was implemented and tested. The results were dramatic — but not in the way the assistant hoped. Peak RSS dropped from 668 GiB to 295 GiB, and the provers counter peaked at exactly 12 (the configured pw value), confirming that the semaphore now correctly capped in-flight synthesis data. But throughput regressed from 37.1 seconds per proof to 40.5 seconds per proof — a 9% slowdown.

The reason is subtle but important. By holding the permit through the channel send, the assistant had effectively serialized the pipeline: synthesis could not start a new partition until the GPU had accepted the previous one. With the GPU taking ~3.5 seconds per partition and synthesis taking ~5 seconds, there was no overlap. The pipeline became purely sequential, losing all the parallelism that the pipelined architecture was designed to provide.

The assistant correctly diagnosed this: "the semaphore holding through channel send is throttling the pipeline. The issue: by keeping the permit until the channel accepts, we're preventing new synthesis from starting during the time the synthesized data sits in the channel queue." The semaphore fix was reverted.

This is a classic engineering tradeoff: memory vs. throughput. The semaphore fix solved the memory problem by eliminating buffering, but in doing so it also eliminated the overlap that made the pipeline fast. The challenge was to find a mechanism that would cap memory without eliminating overlap.

The Message as a Checkpoint

Message 3131 was written at exactly this moment: after the semaphore fix had been tested and reverted, with the early a/b/c free and buffer counters still in place, and with three proposed approaches laid out for the next step. The message serves as a checkpoint — a comprehensive summary of the state of the project that allows the assistant (and the user) to make an informed decision about how to proceed.

The structure of the message reveals its purpose. It begins with a "Goal" section that frames the work as "Phase 12: Split (async) GPU proving API — decoupling the b_g2_msm CPU computation from the GPU worker loop so the GPU worker can pick up the next synthesized partition ~1.7s faster." This is not just a reminder of what Phase 12 is supposed to do; it's a reaffirmation of the design intent. The split API was supposed to increase throughput by overlapping CPU and GPU work. The semaphore fix had inadvertently undermined that goal by serializing the pipeline. The message re-centers the work on its original objective.

The "Discoveries" section is the heart of the message. It systematically documents everything learned from the Phase 11 and Phase 12 experiments, from the b_g2_msm timing characteristics to the use-after-free bug to the memory pressure root cause. Each discovery is presented as a numbered finding, creating a chain of reasoning that leads to the key insight: "Channel capacity is the right lever."

The "What Needs To Be Done Next" section presents three approaches, each with its own tradeoffs:

  1. Increase channel capacity from 1 to pw: Simple change, memory capped at ~2×pw synthesis outputs, but risks high memory if pw is large.
  2. Separate queue-depth semaphore: More precise control with a separate semaphore for the channel queue, but more complex to implement.
  3. Just use pw=10: The conservative option, accepting the current throughput and documenting the memory constraint. The message does not make a final decision. It presents the options and lets the next round of work determine which is best. This is a deliberate choice — the assistant has learned that intuitive fixes (like the semaphore change) can have counterintuitive consequences, and that the right approach needs to be tested empirically.

Decision-Making Under Uncertainty

One of the most striking aspects of message 3131 is how it handles uncertainty. The assistant has just spent several rounds testing a fix that seemed correct but turned out to be wrong. Rather than doubling down or abandoning the problem, it steps back and reframes the question.

The key insight is in the "Discoveries" section, point 8: "Channel capacity is the right lever." This is not presented as a proven fact but as a hypothesis, supported by the reasoning that increasing the channel capacity to pw would buffer up to pw completed jobs without blocking synthesis. The semaphore fix was wrong because it throttled the start of synthesis; the channel fix would throttle the delivery of completed jobs, which is a different point in the pipeline with different dynamics.

The assistant's reasoning here is sophisticated. It recognizes that the pipeline has two natural throttling points: the semaphore (which controls how many tasks can be synthesizing) and the channel (which controls how many completed jobs can be buffered). The semaphore fix conflated these two controls, turning the channel's natural buffering into a blocking point for the semaphore. The correct approach is to increase the channel's buffering capacity so that completed jobs flow freely into the channel, and then let the channel's own capacity limit the total number of in-flight jobs.

This is a classic systems design insight: when you have two control points in a pipeline, you need to understand how they interact. The semaphore and the channel were not independent — they were coupled through the permit's lifetime. By decoupling them (increasing channel capacity while keeping the permit released early), the assistant hoped to achieve both memory control and throughput.

The message also reveals a healthy skepticism about whether the optimization is worth pursuing. Point 3 of "What Needs To Be Done Next" suggests: "Just use pw=10 (which works fine at 37.1s/proof) and document the memory constraint. The pw=12 improvement may not be significant anyway (39.9s/proof even when it works)." This is the voice of engineering pragmatism — sometimes the best optimization is knowing when to stop.

Assumptions and Blind Spots

Message 3131 reveals several assumptions, some explicit and some implicit.

Explicit assumption: "The channel capacity increase will work better than the semaphore fix." This is stated as a hypothesis to be tested, not a certainty. The assistant is careful to frame it as "proposed approaches (not yet implemented)."

Implicit assumption: The buffer counters are useful for diagnosis. The message notes that "buf_dealloc_done() is never called from bellperson (cross-crate boundary) so AUX_IN_FLIGHT counter only goes up — this is a counter bug, not a memory bug." The assistant assumes that the counters are still valuable despite this bug, because they reveal the real problem (provers=28) even if the aux counter is misleading.

Implicit assumption: The early a/b/c free is correct. The message states that after prove_start returns, "the GPU is done with prover.a/b/c (NTT evaluation vectors, ~12 GiB per partition). These are cleared to empty Vecs immediately, saving ~12 GiB per pending partition." This assumes that the GPU has fully consumed the a/b/c data by the time prove_start returns. Given the split API design, this is likely correct — the GPU work is done, and only the CPU-side b_g2_msm remains. But it's worth noting that this assumption was validated by the successful benchmark runs.

Potential blind spot: The message does not consider the interaction between channel capacity and the b_g2_msm finalization. With a larger channel, more partitions can be buffered, but each buffered partition still holds ~4 GiB of aux data (after a/b/c free). If the GPU worker falls behind, these buffered partitions could still accumulate significant memory. The message's estimate of "~2×pw synthesis outputs max (pw synthesizing + pw in channel)" may be optimistic if the GPU worker's processing rate varies.

Another blind spot: The message assumes that the optimal configuration is pw=12 or pw=10, but the benchmark data shows only a small difference (37.1s vs 39.9s). The assistant does not consider whether intermediate values like pw=11 might offer a better tradeoff. This is a minor oversight, but it reflects the binary thinking ("pw=10 works, pw=12 OOMs") that the message is trying to move beyond.

Input Knowledge Required

To fully understand message 3131, a reader needs knowledge spanning several domains:

SNARK proving pipeline architecture: Understanding what synthesis is (building arithmetic circuits from witness data), what GPU proving is (computing Groth16 proofs using elliptic curve operations), and how these phases interact. The message assumes familiarity with terms like "ProvingAssignment," "aux_assignment," "b_g2_msm," and "NTT evaluation vectors."

Async Rust and tokio: The message discusses tokio::sync::mpsc::channel, tokio::spawn, spawn_blocking, semaphores, and async send/receive patterns. The distinction between blocking in a spawn_blocking closure and blocking in an async task is crucial to understanding why the semaphore fix had the effect it did.

CUDA and GPU programming: The split API involves CUDA kernel launches, GPU lock management, and asynchronous deallocation. The use-after-free bug (capturing a stack pointer by reference in a C++ lambda) is a classic CUDA/C++ pitfall.

Memory accounting: The message tracks memory in GiB units across multiple buffer types (a/b/c vectors, aux assignments, SRS/PCE baseline). Understanding the memory budget (755 GiB total, ~70 GiB baseline, ~16 GiB per partition) is essential to appreciating why the OOM occurred.

Filecoin PoRep: The specific proof type (Proof-of-Replication for 32 GiB sectors) determines the circuit size and thus the memory per partition. The message references "C1 output" (the first phase of PoRep) and "c1.json" (a golden test file).

A reader without this background might still grasp the high-level narrative — a memory crisis, a failed fix, a new approach — but the technical depth of the message rewards domain expertise.

Output Knowledge Created

Message 3131 creates several kinds of knowledge:

Documented history: The message serves as a permanent record of what was tried, what was learned, and what remains to be done. This is invaluable for anyone returning to the project after a hiatus, or for onboarding new contributors. The message captures not just the code changes but the reasoning behind them.

Refined problem understanding: Before this message, the problem was "pw=12 OOMs at 668 GiB." After this message, the problem is "the channel capacity of 1 causes synthesized jobs to pile up in the send queue, and the semaphore fix that would prevent this also kills throughput." This is a much more precise formulation that points directly to the solution space.

Three concrete proposals: The message doesn't just identify the problem — it proposes three specific approaches, each with its own tradeoffs. This transforms the problem from "we need to fix this" to "we need to choose which fix to implement and test."

Empirical validation of a principle: The semaphore fix experiment validates an important principle in pipeline design: throttling the start of a producer is not the same as throttling the delivery of completed work. The former can eliminate the overlap that makes pipelining beneficial; the latter preserves it. This principle is generalizable beyond this specific system.

A template for engineering communication: The message's structure — Goal, Instructions, Discoveries, Accomplished, Current State, What Needs To Be Done Next, Relevant Files — is a reusable template for communicating the state of a complex engineering project. It balances technical depth with narrative clarity, and it explicitly separates facts (Discoveries) from decisions (What Needs To Be Done Next).

The Thinking Process

The assistant's thinking process is visible throughout the message, but it's most evident in the "Discoveries" section, which reads like a detective's case notes:

Point 5: "Early a/b/c free: After prove_start returns, the GPU is done with prover.a/b/c (NTT evaluation vectors, ~12 GiB per partition). These are cleared to empty Vecs immediately, saving ~12 GiB per pending partition." This is a straightforward optimization, but it reveals a key insight: the GPU only needs the a/b/c vectors during the GPU kernel execution, not during the CPU-side b_g2_msm. This is the kind of domain-specific knowledge that only comes from deep understanding of the proving pipeline.

Point 6: "Memory pressure root cause identified via buffer counters: With pw=12 j=15, up to 28 synthesized ProvingAssignment sets piled up waiting in the channel (provers=28 at peak)." The buffer counters were added precisely to diagnose this problem, and they succeeded. The number 28 is shocking — more than double the configured pw=12 — and it immediately tells the engineer that something is fundamentally wrong with the backpressure mechanism.

Point 7: "Semaphore fix tested: Holding the permit until after synth_tx.send() capped memory (peak RSS 295 GiB vs 668 GiB) but killed throughput (40.5s/proof vs 37.1s) because synthesis couldn't overlap with GPU." This is the crucial experiment. The assistant didn't just implement the fix and declare victory — it benchmarked it, found the regression, and correctly diagnosed the cause.

Point 8: "Channel capacity is the right lever: The synth_tx/synth_rx channel has capacity=1 (lookahead=1). Increasing it to pw would buffer up to pw completed jobs without blocking synthesis. The semaphore fix was reverted." This is the synthesis of everything learned. The assistant has identified the correct control point and formulated a hypothesis about why it will work better.

The thinking process here is iterative and empirical. The assistant doesn't rely on theory alone — it builds instrumentation, runs experiments, analyzes results, and adjusts its understanding. The semaphore fix was a reasonable hypothesis that turned out to be wrong. The channel capacity hypothesis is the next iteration.

What's remarkable is the assistant's willingness to revert a fix that partially worked. The semaphore fix did solve the memory problem — 295 GiB peak vs 668 GiB is a dramatic improvement. But it introduced a throughput regression that was unacceptable. Many engineers would have accepted the tradeoff, especially if they had invested significant effort in the fix. The assistant's decision to revert and look for a better solution reflects a commitment to the original design goal (improving throughput) over the intermediate goal (fixing memory).

Conclusion: The Value of the Checkpoint

Message 3131 is more than just a status update. It is a moment of deliberate reflection in the middle of a complex engineering campaign. The assistant has just spent several rounds implementing, testing, and reverting a fix. Rather than rushing into the next implementation, it pauses to document everything it has learned and to lay out the options for the next step.

This kind of checkpoint is invaluable in any engineering project, but especially in AI-assisted coding where the "engineer" has no persistent memory beyond the conversation context. The message serves as an external memory — a place where the assistant can store what it has learned so it can pick up where it left off in the next round. It also serves as a communication tool, allowing the user to understand the state of the project and to provide guidance on the next steps.

The message's structure — with its clear separation of discoveries, accomplishments, and proposed next steps — is a model for how to communicate complex technical information under uncertainty. It doesn't pretend to have all the answers. It acknowledges what worked, what didn't, and what remains to be tested. It presents options rather than prescriptions, and it grounds every claim in empirical data.

For the reader unfamiliar with the conversation, this message offers a window into a specific moment in the engineering process: the moment after a failed experiment, when the engineer must decide whether to try a different approach, accept the current state, or abandon the optimization entirely. The assistant's response — to document, reflect, and propose — is a masterclass in how to handle this moment.

The channel capacity fix that the assistant proposed would go on to succeed, achieving 37.7 seconds per proof at pw=12 with 400 GiB peak RSS — essentially matching the throughput of pw=10 while using more workers, and doing so without OOM. But that success was built on the foundation laid in this message: the clear-eyed assessment of what went wrong, the willingness to revert a partially successful fix, and the disciplined formulation of a new hypothesis. Message 3131 is the quiet moment of clarity between two storms of optimization, and it deserves to be studied as an example of engineering at its best.