The Linker's Crossroads: Verifying Symbol Visibility Across Rust, C++, and CUDA Boundaries
In the middle of an intense optimization session targeting Filecoin's Groth16 proof generation pipeline, the assistant pauses. It has just implemented Intervention 3 of Phase 11 — a global atomic throttle flag designed to reduce DDR5 memory bandwidth contention between CPU-side synthesis (SpMV) and GPU post-processing (b_g2_msm). Three edits have been made across two languages and three crates. The implementation is complete. The natural next step is to build and benchmark. But instead of reaching for the compiler, the assistant reaches for grep:
Now I need to make sure the Rust symbol is visible to the C++ linker. Theset_membw_throttleis#[no_mangle] pub extern "C"in thecuzk-pcecrate. Sincecuzk-daemonlinks bothsupraseal-c2(which compiles the CUDA code) andcuzk-pce, the symbol should be available at link time. Let me verify by checking the dependency chain:
>
[bash] grep -n "supraseal-c2\|cuzk-pce\|bellperson" /home/theuser/curio/extern/cuzk/cuzk-core/Cargo.toml 23:# Without it, bellperson's native prover is used. 27:cuzk-pce = { workspace = true } 37:bellperson = { workspace = true } 57: "bellperson/cuda", 59:# cuda-supraseal: bellperson and storage-proofs-core get cuda-supraseal, 61:# they don't call bellperson directly, only through storage-proofs-core). 69: "bellperson/cuda-supraseal",
This is message [msg 2793] in the conversation, and it is a masterclass in the kind of careful, systems-level thinking that separates robust engineering from fragile hacking. On its surface, it is a simple verification: a developer checks that a symbol will resolve at link time before attempting a build. But beneath that surface lies a rich tapestry of cross-language FFI semantics, build system architecture, and the often-invisible assumptions that underpin multi-language software projects.
The Optimization That Demanded Cross-Language Coordination
To understand why this verification step matters, we must first understand what the assistant is building. Phase 11 of the cuzk optimization project targets a specific bottleneck identified through waterfall timing analysis: DDR5 memory bandwidth contention between CPU threads performing sparse matrix-vector multiplication (SpMV) during circuit synthesis and the CPU threads executing the b_g2_msm (G2 multi-scalar multiplication) post-processing step. Both operations compete for the same memory bus, causing cache thrashing and TLB shootdowns that degrade overall throughput.
Intervention 3 proposes a throttle mechanism: when the C++ code enters the b_g2_msm critical section, it sets a global atomic flag. The Rust SpMV code, running in parallel on other CPU cores, periodically checks this flag and yields its execution slot (via yield_now) if the flag is set. This gives b_g2_msm preferential access to memory bandwidth during its brief but critical window, reducing the total time-to-completion for the proof.
The implementation spans three files:
cuzk-pce/src/eval.rs— A globalstatic AtomicI32flag and a#[no_mangle] pub extern "C" fn set_membw_throttle(val: i32)function that sets it.cuzk-pce/src/eval.rs(second edit) — Thespmv_parallelfunction modified to check the flag and yield.supraseal-c2/cuda/groth16_cuda.cu— C++ code that callsset_membw_throttle(1)before b_g2_msm andset_membw_throttle(0)after. This is a textbook cross-language FFI pattern: Rust defines a symbol with C calling conventions, C++ declares it as anexternfunction and calls it. The linker resolves the symbol at build time. Simple in concept, but fraught with failure modes in practice.
Why the Assistant Pauses
The assistant's decision to verify the dependency chain before building reveals a deep understanding of how Rust's build system resolves symbols across crate boundaries. In a pure Rust project, #[no_mangle] pub extern "C" guarantees that the symbol is exported from the crate's object files. But whether the linker can find that symbol depends on the entire dependency graph of the final binary.
The key insight is that the CUDA code in supraseal-c2 is compiled by nvcc into object files that are linked into the final cuzk-daemon binary via Rust's FFI integration. The set_membw_throttle symbol lives in the cuzk-pce crate. For the linker to resolve a reference from supraseal-c2's object files to cuzk-pce's symbol, both crates must be linked into the same final binary, and the linker must be able to traverse the dependency graph.
The assistant's grep of cuzk-core/Cargo.toml reveals the dependency chain:
cuzk-pce = { workspace = true }— direct dependency, symbol will be presentbellperson = { workspace = true }— this is the crate that pulls insupraseal-c2via thebellperson/cuda-suprasealfeature flag- The
bellperson/cuda-suprasealfeature enables CUDA-accelerated proving, which compiles thesupraseal-c2CUDA code The critical observation is thatsupraseal-c2is not a direct dependency ofcuzk-corein the Cargo.toml. It is pulled in transitively throughbellperson's feature flag. The assistant's reasoning — "Sincecuzk-daemonlinks bothsupraseal-c2(which compiles the CUDA code) andcuzk-pce, the symbol should be available at link time" — is correct, but it relies on understanding this transitive dependency chain.
Assumptions and Their Hidden Risks
The assistant makes several assumptions in this verification step, each worth examining:
Assumption 1: The linker will resolve symbols across crate boundaries. In Rust, each crate produces one or more object files (or static libraries). When the final binary is linked, the linker collects all object files from all direct and transitive dependencies. A symbol defined in cuzk-pce and referenced from supraseal-c2's object files should resolve — provided both sets of object files are presented to the linker. This is the normal case for Rust binaries, but it can fail if link-time optimization (LTO) aggressively inlines or discards symbols, or if the symbol is in a crate that is only conditionally compiled.
Assumption 2: The #[no_mangle] attribute is sufficient. Rust's name mangling would produce a symbol like _ZN23cuzk_pce4eval19set_membw_throttleE (or similar). The #[no_mangle] attribute suppresses this, producing the exact symbol set_membw_throttle. The C++ code must declare this with the correct signature. Any mismatch (e.g., C++ expecting a different calling convention, or the Rust function using a different ABI on the target platform) would cause undefined behavior at runtime rather than a link error — a far more dangerous failure mode.
Assumption 3: The feature flag bellperson/cuda-supraseal is enabled. The grep output shows this feature is listed under cuzk-core's features. But whether it is enabled when building cuzk-daemon depends on the build configuration. If the feature is disabled (e.g., building without CUDA support), the supraseal-c2 crate might not be compiled at all, and the reference from the CUDA code would not exist — but neither would the code that calls set_membw_throttle. This is safe, but it means the optimization silently does nothing on non-CUDA builds.
Assumption 4: The C++ compiler (nvcc) and Rust compiler use compatible symbol visibility defaults. On Linux, Rust's extern "C" functions default to Visibility::Default (visible to the dynamic linker). C++ functions also default to external visibility. But on some platforms or with certain compiler flags, symbols can be hidden by default, requiring explicit __attribute__((visibility("default"))) annotations. The assistant does not check this, and it could cause a link error on some toolchain configurations.
The Deeper Significance
What makes this message remarkable is not the technical detail of the grep command, but the engineering discipline it reveals. The assistant has just completed three edits across a complex multi-language codebase. The natural human impulse is to run the build and see if it compiles. But the assistant instead pauses to trace the dependency chain, reasoning about whether the linker will be able to find the symbol before the compiler even runs.
This is the hallmark of an engineer who has been burned by linker errors before. In cross-language FFI projects, linker errors are among the most confusing failure modes: the error message often points to a missing symbol, but the root cause can be a missing dependency, a disabled feature flag, a name-mangling mismatch, or a visibility attribute. By verifying the dependency chain before building, the assistant narrows the failure space dramatically. If the build fails, it knows the problem is not a missing crate — it must be something else (a type mismatch, a calling convention issue, or a toolchain quirk).
The message also reveals the assistant's mental model of the build system. The grep output shows a commented-out line (# Without it, bellperson's native prover is used.) and a comment explaining the cuda-supraseal feature. The assistant reads these comments and understands the architecture: there are two paths through the proving pipeline — a native (CPU-only) path and a CUDA-accelerated path. The throttle optimization only applies to the CUDA path. The assistant is verifying that the CUDA path is indeed the one being built.
The Output Knowledge Created
This single message produces one piece of concrete output knowledge: confirmation that cuzk-pce is a direct dependency of cuzk-core, and that bellperson (with its cuda-supraseal feature) is also present. The assistant can proceed to build with confidence that the linker should resolve the set_membw_throttle symbol.
But the message also produces tacit knowledge: a documented trace of the dependency chain that future readers (or the assistant itself in a later session) can refer to. The grep output in the conversation log serves as a snapshot of the build configuration at this point in time. If the dependency chain changes in a future refactor, this message provides a reference point for what worked.
Conclusion
Message [msg 2793] is a brief pause in a fast-paced optimization session — a moment of verification before the critical build step. But it encapsulates the essence of systems engineering: the ability to reason about the entire toolchain, from source code to linker invocation, and to verify assumptions before committing to a build. In a project spanning Rust, C++, CUDA, and multiple crate boundaries, this kind of cross-layer reasoning is not optional — it is essential. The assistant's careful verification of symbol visibility across the dependency graph is a small but telling example of the discipline required to build robust, high-performance multi-language systems.