openvm_static_verifier/tracegen/
graph_executor.rs

1#![allow(rustdoc::private_intra_doc_links)]
2//! Parallel executor for the graph IR recorded by [`Halo2IRBuilder`].
3//!
4//! Lowering flattens each graph node — using its
5//! [`NodeMeta`](super::ir_builder::NodeMeta) — into a [`GraphCoreInst`] whose
6//! operands are absolute tape offsets. The tape layout is
7//! `[advice | lookups | consts]` (dedup'd fixed-column constants live in the
8//! trailing region), so operand gathering is uniform.
9//!
10//! Execution has two phases:
11//! 1. `load_proof_wire` streams proof witnesses into the tape via [`PopulateInputs`], replaying
12//!    recorded `LoadWitness` instructions.
13//! 2. [`GraphExecutor::run`] claims level-sorted compute instructions off a shared atomic cursor;
14//!    each worker spin-waits on its parents' done flags (Release/Acquire) — no barriers.
15
16use std::{
17    collections::HashMap,
18    sync::atomic::{AtomicBool, AtomicU8, AtomicUsize, Ordering},
19};
20
21#[cfg(feature = "halo2-gpu")]
22use halo2_base::halo2_proofs::cuda::utils::HALO2_GPU_CTX;
23use halo2_base::halo2_proofs::{
24    arithmetic::Field as _, halo2curves::bn256::Fr, plonk::AdviceColumns,
25};
26#[cfg(feature = "halo2-gpu")]
27use openvm_cuda_common::d_buffer::DeviceBuffer;
28use openvm_stark_sdk::{
29    config::baby_bear_bn254_poseidon2::BabyBearBn254Poseidon2Config as RootConfig,
30    openvm_stark_backend::{
31        p3_field::{BasedVectorSpace, PrimeField64},
32        proof::Proof,
33    },
34    p3_baby_bear::BabyBear,
35};
36use serde::{Deserialize, Serialize};
37
38use crate::{
39    chip_traits::{ChipBase, PopulateInputs},
40    circuit::StaticVerifierCircuit,
41    field::baby_bear::{
42        BabyBearExt4, BabyBearWire, ReducedBabyBearExt4Wire, ReducedBabyBearWire, BABYBEAR_MAX_BITS,
43    },
44    tracegen::{
45        ir_builder::{GraphCell, Halo2IRBuilder, Halo2Opcode},
46        opcode_impl::{interpret_op, UNMATERIALIZED},
47    },
48};
49
50/// One lowered graph node, replayed via
51/// [`interpret_op`](super::opcode_impl::interpret_op).
52#[derive(Copy, Clone, Debug, Serialize, Deserialize)]
53struct GraphCoreInst {
54    opcode: Halo2Opcode,
55    /// Slice of `operand_offsets` / `operand_bits`.
56    args: (u32, u32),
57    /// Slice of `const_inds` (the node's constant-skip indices).
58    const_inds: (u32, u32),
59    /// Absolute advice-tape offset the node writes at.
60    ctx_offset: u32,
61    ctx_len: u32,
62    /// Absolute lookup-tape offset the node writes at.
63    lookup_offset: u32,
64    lookups_len: u32,
65    /// Slice of `dep_inds` (parent instruction indices).
66    dep_list: (u32, u32),
67}
68
69/// One compute writer in the emission-order release schedule. Ends are
70/// precomputed at lowering so the release walk scans this array sequentially
71/// instead of random-accessing the (level-sorted) `insts` per writer.
72#[derive(Copy, Clone, Serialize, Deserialize)]
73struct ReleaseEntry {
74    /// Index into `insts`/`flags` (level-sorted position).
75    inst: u32,
76    /// Exclusive end of this writer's advice write range.
77    advice_end: u32,
78    /// Same, for the range-check tape.
79    lookup_end: u32,
80}
81
82/// Shared mutable witness tape.
83#[derive(Clone, Copy)]
84struct TapePtr(*mut Fr);
85#[allow(unsafe_code)]
86unsafe impl Send for TapePtr {}
87#[allow(unsafe_code)]
88unsafe impl Sync for TapePtr {}
89
90/// The immutable output of lowering a graph IR: everything needed to replay
91/// the populate trace that is a pure function of the static circuit shape.
92///
93/// Serialized alongside the proving key; on decode a fresh
94/// [`GraphExecutorState`] is paired with it (via [`GraphExecutor::new`]) to
95/// replay the populate trace without re-recording it.
96#[derive(Serialize, Deserialize, Clone)]
97pub struct GraphProgram {
98    advice_cells: usize,
99    lookup_cells: usize,
100    lookup_bits: usize,
101    /// Dedup'd fixed-column constants; the trailing region of the tape.
102    consts: Vec<Fr>,
103    /// Flattened operand tape offsets across all instructions.
104    operand_offsets: Vec<u32>,
105    /// Bit bound per operand, parallel to `operand_offsets`.
106    operand_bits: Vec<u16>,
107    /// Flattened constant-skip indices across all instructions.
108    const_inds: Vec<u32>,
109    /// Compute instructions, level-sorted.
110    insts: Vec<GraphCoreInst>,
111    /// `LoadWitness` instructions, in emission order.
112    input_insts: Vec<GraphCoreInst>,
113    /// Compute writers in emission order (ascending write offsets on both
114    /// tapes). The release walk in [`GraphExecutor::run`] polls writers in
115    /// this order, folding the input-populated gap cells before each writer
116    /// into the pending range.
117    release_order: Vec<ReleaseEntry>,
118    /// Flattened parent-instruction indices, sliced by `insts[i].dep_list`.
119    dep_inds: Vec<u32>,
120    /// Advice offsets of the circuit's public values, in instance order.
121    /// Empty when lowered from a constraints-only trace.
122    pv_offsets: Vec<usize>,
123}
124
125/// Mutable state executing a [`GraphProgram`]: the witness tape, the
126/// input cursor, and the workers' done flags. Reusable across proofs — `run`
127/// stamps `flags` with a fresh `phase` instead of zeroing them, and the tape's
128/// compute cells are simply overwritten.
129///
130/// Sized against a specific [`GraphProgram`]; pairing this with the wrong
131/// program is a bug (asserted in [`GraphExecutor::new`]).
132pub struct GraphExecutorState {
133    /// Layout: `[advice | lookups | consts]`.
134    tape: Vec<Fr>,
135    input_cursor: usize,
136    /// Per-instruction done flag; stamped with the current `phase` on
137    /// completion. Workers spin-wait on parent flags before executing.
138    flags: Vec<AtomicU8>,
139    /// Wrapping run counter that stamps `flags`. `run` is synchronous
140    /// (workers join before return), so wrapping is safe.
141    phase: u8,
142}
143
144/// Ephemeral binding of a [`GraphProgram`] with a [`GraphExecutorState`],
145/// implementing [`PopulateInputs`] so `load_proof_wire` can stream witnesses
146/// into the tape and driving [`Self::run`] for the compute phase.
147pub struct GraphExecutor<'a> {
148    pub program: &'a GraphProgram,
149    pub state: &'a mut GraphExecutorState,
150}
151
152impl GraphProgram {
153    /// Records `circuit`'s full populate trace (constraints + onion-commit
154    /// pin + PV extraction) into a graph IR and lowers it. Any valid proof
155    /// for the static shape works as `representative_proof`.
156    pub fn new(
157        circuit: &StaticVerifierCircuit,
158        lookup_bits: usize,
159        representative_proof: &Proof<RootConfig>,
160    ) -> Self {
161        let mut ir = Halo2IRBuilder::new(lookup_bits);
162        let pvs_wire = circuit.populate_pvs(&mut ir, representative_proof);
163        let pv_offsets = pvs_wire
164            .to_vec()
165            .iter()
166            .map(|cell| match cell {
167                GraphCell::Cell(_, offset, _) => *offset,
168                GraphCell::Const(_) => unreachable!("public value must be an advice cell"),
169            })
170            .collect();
171        let mut program = Self::lower(ir);
172        program.pv_offsets = pv_offsets;
173        program
174    }
175
176    /// Lowers a recorded IR into executable form (with no public values).
177    fn lower(ir: Halo2IRBuilder) -> Self {
178        let advice_cells = ir.total_ctx_len();
179        let lookup_cells = ir.total_lookups_len();
180        let lookup_bits = ir.lookup_bits();
181        let const_base = advice_cells + lookup_cells;
182
183        let mut consts: Vec<Fr> = Vec::new();
184        let mut const_map: HashMap<[u8; 32], u32> = HashMap::new();
185        let mut operand_offsets: Vec<u32> = Vec::new();
186        let mut operand_bits: Vec<u16> = Vec::new();
187        let mut const_inds: Vec<u32> = Vec::new();
188        let mut input_insts: Vec<GraphCoreInst> = Vec::new();
189        let mut compute: Vec<(u32, GraphCoreInst)> = Vec::with_capacity(ir.nodes.len());
190
191        for (node, meta) in ir.nodes.iter().zip(&ir.node_meta) {
192            let arg_lo = operand_offsets.len() as u32;
193            for (cell, &arg_offset) in node.operands.iter().zip(&meta.arg_offsets) {
194                let offset = match cell {
195                    GraphCell::Cell(..) => {
196                        assert_ne!(
197                            arg_offset, UNMATERIALIZED,
198                            "cell operand must be materialized"
199                        );
200                        arg_offset
201                    }
202                    GraphCell::Const(value) => {
203                        let idx = *const_map.entry(value.to_bytes()).or_insert_with(|| {
204                            consts.push(*value);
205                            (consts.len() - 1) as u32
206                        });
207                        const_base + idx as usize
208                    }
209                };
210                operand_offsets.push(u32::try_from(offset).expect("tape offset exceeds u32"));
211                operand_bits.push(cell.bits() as u16);
212            }
213            let ci_lo = const_inds.len() as u32;
214            const_inds.extend_from_slice(&meta.constant_skip_inds);
215            let inst = GraphCoreInst {
216                opcode: node.opcode,
217                args: (arg_lo, operand_offsets.len() as u32),
218                const_inds: (ci_lo, const_inds.len() as u32),
219                ctx_offset: meta.ctx_offset as u32,
220                ctx_len: meta.ctx_len as u32,
221                lookup_offset: meta.lookup_offset as u32,
222                lookups_len: meta.lookups_len as u32,
223                // Filled in later, after `insts` is level-sorted and cell → inst
224                // resolution is available.
225                dep_list: (0, 0),
226            };
227            match node.opcode {
228                Halo2Opcode::LoadWitness => input_insts.push(inst),
229                _ => compute.push((meta.level, inst)),
230            }
231        }
232
233        // Two orders coexist from here on:
234        // - `insts` (and the parallel `flags`) is LEVEL-sorted: workers claim instructions off the
235        //   atomic cursor in this order, so claims are roughly topological and parent spin-waits
236        //   stay short. The sort is stable, keeping emission order within a level (write locality).
237        // - `release_order` (built below) stays in EMISSION order, which is what the release walk
238        //   needs.
239        // `order[sorted]` is the emission index of the instruction placed at
240        // sorted position `sorted`; `emission_to_sorted` is its inverse,
241        // translating an emission index into the `insts`/`flags` slot.
242        let mut order: Vec<u32> = (0..compute.len() as u32).collect();
243        order.sort_by_key(|&i| compute[i as usize].0);
244        let mut insts: Vec<GraphCoreInst> = order.iter().map(|&i| compute[i as usize].1).collect();
245        let mut emission_to_sorted = vec![0u32; compute.len()];
246        for (sorted, &emission) in order.iter().enumerate() {
247            emission_to_sorted[emission as usize] = sorted as u32;
248        }
249
250        // In emission order both write offsets are monotone (bump-cursor
251        // allocation), so writers' disjoint write ranges tile each tape in
252        // order; the advice tape additionally has input-populated gaps
253        // between them. Walking writers in emission order lets the release
254        // walk fold each gap into the pending range before it. Each entry
255        // still needs `emission_to_sorted` to name the writer's flag slot,
256        // since flags are stamped at level-sorted positions.
257        let mut release_order: Vec<ReleaseEntry> = Vec::with_capacity(insts.len());
258        let (mut prev_a, mut prev_l) = (0u32, 0u32);
259        for (i, (_, inst)) in compute.iter().enumerate() {
260            if inst.ctx_len == 0 && inst.lookups_len == 0 {
261                continue;
262            }
263            debug_assert!(inst.ctx_offset >= prev_a && inst.lookup_offset >= prev_l);
264            prev_a = inst.ctx_offset + inst.ctx_len;
265            prev_l = inst.lookup_offset + inst.lookups_len;
266            release_order.push(ReleaseEntry {
267                inst: emission_to_sorted[i],
268                advice_end: prev_a,
269                lookup_end: prev_l,
270            });
271        }
272        drop(compute);
273
274        // Build the dependency graph: for each compute inst, list the indices
275        // of parent compute insts (whose output cells this inst reads as
276        // operands). Input-inst parents are dropped — their cells are already
277        // populated before `run` starts. Since `insts` is level-sorted and
278        // dependencies point to strictly-lower levels, parent indices are
279        // always strictly less than the child's index.
280        let mut cell_to_compute_inst: Vec<i32> = vec![-1; advice_cells];
281        for (idx, inst) in insts.iter().enumerate() {
282            let ctx_lo = inst.ctx_offset as usize;
283            let ctx_hi = ctx_lo + inst.ctx_len as usize;
284            cell_to_compute_inst[ctx_lo..ctx_hi].fill(idx as i32);
285        }
286        let mut dep_inds: Vec<u32> = Vec::new();
287        let mut local_deps: Vec<u32> = Vec::new();
288        for inst in &mut insts {
289            local_deps.clear();
290            let (arg_lo, arg_hi) = inst.args;
291            for &arg_offset in &operand_offsets[arg_lo as usize..arg_hi as usize] {
292                let offset = arg_offset as usize;
293                if offset < advice_cells {
294                    let parent = cell_to_compute_inst[offset];
295                    if parent >= 0 {
296                        let parent = parent as u32;
297                        if !local_deps.contains(&parent) {
298                            local_deps.push(parent);
299                        }
300                    }
301                }
302            }
303            let dep_lo = dep_inds.len() as u32;
304            dep_inds.extend_from_slice(&local_deps);
305            let dep_hi = dep_inds.len() as u32;
306            inst.dep_list = (dep_lo, dep_hi);
307        }
308        drop(cell_to_compute_inst);
309
310        GraphProgram {
311            advice_cells,
312            lookup_cells,
313            lookup_bits,
314            consts,
315            operand_offsets,
316            operand_bits,
317            const_inds,
318            insts,
319            input_insts,
320            release_order,
321            dep_inds,
322            pv_offsets: Vec::new(),
323        }
324    }
325
326    /// Total number of advice-tape cells written per run.
327    pub fn advice_cells(&self) -> usize {
328        self.advice_cells
329    }
330
331    /// Total number of range-check tape cells written per run.
332    pub fn lookup_cells(&self) -> usize {
333        self.lookup_cells
334    }
335
336    /// Advice offsets of the circuit's public values, in instance order.
337    pub fn pv_offsets(&self) -> &[usize] {
338        &self.pv_offsets
339    }
340}
341
342impl GraphExecutorState {
343    /// Allocates a state buffer sized against `program`. Reusable for any
344    /// proof of `program`'s static shape.
345    pub fn new(program: &GraphProgram) -> Self {
346        let const_base = program.advice_cells + program.lookup_cells;
347        let mut tape = vec![Fr::ZERO; const_base + program.consts.len()];
348        tape[const_base..].copy_from_slice(&program.consts);
349        let flags: Vec<AtomicU8> = (0..program.insts.len()).map(|_| AtomicU8::new(0)).collect();
350        Self {
351            tape,
352            input_cursor: 0,
353            flags,
354            phase: 0,
355        }
356    }
357
358    /// Rewinds the input cursor for a new proof; compute cells are overwritten
359    /// by the next [`GraphExecutor::run`], so no other state needs clearing.
360    pub fn reset(&mut self) {
361        self.input_cursor = 0;
362    }
363
364    /// The advice (context) tape; matches `Context::advice_cells()` of the halo2
365    /// backend after [`GraphExecutor::run`].
366    pub fn advice(&self, program: &GraphProgram) -> &[Fr] {
367        &self.tape[..program.advice_cells]
368    }
369
370    /// The range-check tape; matches the values sent to `add_cell_to_lookup`.
371    pub fn lookups(&self, program: &GraphProgram) -> &[Fr] {
372        &self.tape[program.advice_cells..program.advice_cells + program.lookup_cells]
373    }
374}
375
376impl<'a> GraphExecutor<'a> {
377    pub fn new(program: &'a GraphProgram, state: &'a mut GraphExecutorState) -> Self {
378        assert_eq!(
379            state.tape.len(),
380            program.advice_cells + program.lookup_cells + program.consts.len(),
381            "state was allocated against a different program"
382        );
383        assert_eq!(
384            state.flags.len(),
385            program.insts.len(),
386            "state was allocated against a different program"
387        );
388        Self { program, state }
389    }
390
391    pub fn program(&self) -> &GraphProgram {
392        self.program
393    }
394
395    /// See [`GraphExecutorState::advice`].
396    pub fn advice(&self) -> &[Fr] {
397        self.state.advice(self.program)
398    }
399
400    /// See [`GraphExecutorState::lookups`].
401    pub fn lookups(&self) -> &[Fr] {
402        self.state.lookups(self.program)
403    }
404
405    /// Replays the next `LoadWitness` with `value` and returns its advice offset.
406    fn populate_input(&mut self, expected: Halo2Opcode, value: Fr) -> usize {
407        debug_assert!(matches!(expected, Halo2Opcode::LoadWitness));
408        let inst = *self
409            .program
410            .input_insts
411            .get(self.state.input_cursor)
412            .expect("more input loads than recorded input instructions");
413        debug_assert_eq!(
414            inst.opcode, expected,
415            "input load {} kind mismatch",
416            self.state.input_cursor
417        );
418        self.state.input_cursor += 1;
419        let advice = &mut self.state.tape[..self.program.advice_cells];
420        advice[inst.ctx_offset as usize] = value;
421        debug_assert!(inst.ctx_len == 1);
422        debug_assert!(inst.lookups_len == 0);
423        inst.ctx_offset as usize
424    }
425
426    /// Evaluates the compute schedule with `num_threads` workers, barrier-free:
427    /// workers claim instructions off an atomic cursor, spin-wait on parents'
428    /// done flags, execute, and Release-store their own flag. Flags are
429    /// stamped with `phase` (not zeroed between runs).
430    ///
431    /// Meanwhile the calling thread streams newly-materialized tape ranges
432    /// through `on_delta(advice_offset, advice_delta, lookup_offset,
433    /// lookup_delta)`: writers are polled in the program's release order
434    /// (emission order, ascending tape offsets) with a bounded spin, so
435    /// ready writers extend one pending block that also absorbs the
436    /// input-populated gap cells between them. The block flushes once its
437    /// advice span reaches `MAX_FLUSH_CELLS` or when a slow writer defers to
438    /// the retry passes; sub-`MIN_FLUSH_CELLS` blocks are deferred for
439    /// re-merging instead of flushed as tiny H2D copies.
440    #[allow(unsafe_code)]
441    pub fn run<F>(&mut self, num_threads: usize, mut on_delta: F)
442    where
443        F: FnMut(usize, &[Fr], usize, &[Fr]),
444    {
445        assert!(num_threads > 0);
446        assert_eq!(
447            self.state.input_cursor,
448            self.program.input_insts.len(),
449            "all proof inputs must be populated before run"
450        );
451
452        // Bump phase; skip 0 so a fresh executor never reports zeroed flags as
453        // ready.
454        self.state.phase = self.state.phase.wrapping_add(1);
455        if self.state.phase == 0 {
456            self.state.phase = 1;
457        }
458        let phase = self.state.phase;
459
460        // Detach the tape so worker threads share the program and flags
461        // without aliasing the buffer.
462        let mut tape = std::mem::take(&mut self.state.tape);
463        let tape_ptr = TapePtr(tape.as_mut_ptr());
464        let program: &GraphProgram = self.program;
465        let flags: &[AtomicU8] = &self.state.flags;
466        let advice_cells = program.advice_cells;
467        let lookup_cells = program.lookup_cells;
468        let n_insts = program.insts.len();
469        // Level-sorted claims are roughly topological, so parent spin-waits stay short.
470        let claim_cursor = AtomicUsize::new(0);
471
472        // A panicked worker would leave its flag unset forever, livelocking
473        // peer spin-waits. Every unbounded wait checks `poisoned` and panics.
474        let poisoned = AtomicBool::new(false);
475        struct PoisonOnPanic<'a>(&'a AtomicBool);
476        impl Drop for PoisonOnPanic<'_> {
477            fn drop(&mut self) {
478                if std::thread::panicking() {
479                    self.0.store(true, Ordering::Relaxed);
480                }
481            }
482        }
483
484        std::thread::scope(|s| {
485            for _ in 0..num_threads {
486                let claim_cursor = &claim_cursor;
487                let poisoned = &poisoned;
488                s.spawn(move || {
489                    let _poison = PoisonOnPanic(poisoned);
490                    let mut args: Vec<Fr> = Vec::new();
491                    let mut bits: Vec<u16> = Vec::new();
492                    loop {
493                        let idx = claim_cursor.fetch_add(1, Ordering::Relaxed);
494                        if idx >= n_insts {
495                            break;
496                        }
497                        let inst = &program.insts[idx];
498                        // Spin-wait until every parent's flag matches `phase`.
499                        let (dep_lo, dep_hi) = inst.dep_list;
500                        for d in dep_lo as usize..dep_hi as usize {
501                            let parent = program.dep_inds[d] as usize;
502                            while flags[parent].load(Ordering::Acquire) != phase {
503                                assert!(
504                                    !poisoned.load(Ordering::Relaxed),
505                                    "graph executor worker panicked"
506                                );
507                                std::hint::spin_loop();
508                            }
509                        }
510                        program.eval_inst(inst, tape_ptr, &mut args, &mut bits);
511                        // Release publishes tape writes to Acquire loaders of the flag.
512                        flags[idx].store(phase, Ordering::Release);
513                    }
514                });
515            }
516
517            // Emission-order release walk on the calling thread (see the `run` doc).
518            {
519                /// Flag polls before deferring a writer to the retry list.
520                const MAX_SPIN_TRIES: usize = 32;
521                /// Ranges smaller than this are deferred for re-merging
522                /// instead of flushed as tiny H2D copies.
523                const MIN_FLUSH_CELLS: u32 = 8 * 1024;
524                /// Pending ranges flush once their advice span reaches this
525                /// size so long runs stream incrementally.
526                const MAX_FLUSH_CELLS: u32 = 1 << 20;
527                /// `inst` marker for an already-materialized deferred range.
528                const READY_SENTINEL: u32 = u32::MAX;
529
530                /// Contiguous spans on the advice and lookup tapes.
531                #[derive(Clone, Copy)]
532                struct Range {
533                    a_start: u32,
534                    a_end: u32,
535                    l_start: u32,
536                    l_end: u32,
537                }
538                impl Range {
539                    /// An empty range positioned at (`a`, `l`).
540                    fn empty_at(a: u32, l: u32) -> Self {
541                        Range {
542                            a_start: a,
543                            a_end: a,
544                            l_start: l,
545                            l_end: l,
546                        }
547                    }
548                    fn cells(&self) -> u32 {
549                        (self.a_end - self.a_start) + (self.l_end - self.l_start)
550                    }
551                }
552                /// A `Range` gated on `flags[inst]` (or `READY_SENTINEL`).
553                type Entry = (u32, Range);
554
555                let try_wait = |inst: u32| {
556                    if inst == READY_SENTINEL {
557                        return true;
558                    }
559                    let flag = &flags[inst as usize];
560                    for _ in 0..MAX_SPIN_TRIES {
561                        if flag.load(Ordering::Acquire) == phase {
562                            return true;
563                        }
564                        std::hint::spin_loop();
565                    }
566                    false
567                };
568                // Fires `on_delta`, unless the range is sub-`MIN_FLUSH_CELLS`
569                // and `defer_to` is given (then queued as a ready entry).
570                //
571                // Safety: flushed ranges are unions of disjoint release
572                // ranges whose flags passed `try_wait`; the Acquire load
573                // pairs with each worker's Release store, publishing the
574                // writes. Gap cells folded into a release range are
575                // input-populated before `run` by this thread, so program
576                // order suffices for them.
577                let mut flush_or_defer = |r: Range, defer_to: Option<&mut Vec<Entry>>| {
578                    if r.cells() == 0 {
579                        return;
580                    }
581                    if let Some(defer) = defer_to {
582                        if r.cells() < MIN_FLUSH_CELLS {
583                            defer.push((READY_SENTINEL, r));
584                            return;
585                        }
586                    }
587                    let advice_delta: &[Fr] = unsafe {
588                        std::slice::from_raw_parts(
589                            (tape_ptr.0 as *const Fr).add(r.a_start as usize),
590                            (r.a_end - r.a_start) as usize,
591                        )
592                    };
593                    let lookup_delta: &[Fr] = unsafe {
594                        std::slice::from_raw_parts(
595                            (tape_ptr.0 as *const Fr).add(advice_cells + r.l_start as usize),
596                            (r.l_end - r.l_start) as usize,
597                        )
598                    };
599                    on_delta(
600                        r.a_start as usize,
601                        advice_delta,
602                        r.l_start as usize,
603                        lookup_delta,
604                    );
605                };
606
607                // Pass 0: poll writers in emission order, so each ready
608                // writer extends `pend` (absorbing the input-populated gap
609                // cells before its write range). A slow writer flushes the
610                // block and queues a gap-covering range for retry.
611                let mut failed: Vec<Entry> = Vec::new();
612                let mut pend = Range::empty_at(0, 0);
613                for &ReleaseEntry {
614                    inst,
615                    advice_end,
616                    lookup_end,
617                } in &program.release_order
618                {
619                    if try_wait(inst) {
620                        pend.a_end = advice_end;
621                        pend.l_end = lookup_end;
622                        if pend.a_end - pend.a_start >= MAX_FLUSH_CELLS {
623                            flush_or_defer(pend, None);
624                            pend = Range::empty_at(advice_end, lookup_end);
625                        }
626                    } else {
627                        flush_or_defer(pend, Some(&mut failed));
628                        failed.push((
629                            inst,
630                            Range {
631                                a_start: pend.a_end,
632                                a_end: advice_end,
633                                l_start: pend.l_end,
634                                l_end: lookup_end,
635                            },
636                        ));
637                        pend = Range::empty_at(advice_end, lookup_end);
638                    }
639                }
640                // Trailing input-populated cells belong to the final range.
641                pend.a_end = advice_cells as u32;
642                pend.l_end = lookup_cells as u32;
643                if failed.is_empty() {
644                    flush_or_defer(pend, None);
645                } else {
646                    flush_or_defer(pend, Some(&mut failed));
647                }
648
649                // Retry passes: re-poll deferred writers (still in emission
650                // order) with the same merge rule until all have landed.
651                // Deferral stops once every entry is ready, so the final
652                // pass flushes everything (termination).
653                while !failed.is_empty() {
654                    assert!(
655                        !poisoned.load(Ordering::Relaxed),
656                        "graph executor worker panicked"
657                    );
658                    let allow_defer = failed.iter().any(|&(idx, _)| idx != READY_SENTINEL);
659                    let mut still: Vec<Entry> = Vec::new();
660                    let mut pend = Range::empty_at(0, 0);
661                    for &(idx, r) in &failed {
662                        if try_wait(idx) {
663                            if pend.a_end == r.a_start && pend.l_end == r.l_start {
664                                pend.a_end = r.a_end;
665                                pend.l_end = r.l_end;
666                            } else {
667                                flush_or_defer(
668                                    pend,
669                                    if allow_defer { Some(&mut still) } else { None },
670                                );
671                                pend = r;
672                            }
673                            if pend.a_end - pend.a_start >= MAX_FLUSH_CELLS {
674                                flush_or_defer(pend, None);
675                                pend = Range::empty_at(pend.a_end, pend.l_end);
676                            }
677                        } else {
678                            flush_or_defer(pend, Some(&mut still));
679                            still.push((idx, r));
680                            pend = Range::empty_at(r.a_end, r.l_end);
681                        }
682                    }
683                    if allow_defer && !still.is_empty() {
684                        flush_or_defer(pend, Some(&mut still));
685                    } else {
686                        flush_or_defer(pend, None);
687                    }
688                    failed = still;
689                }
690            }
691        });
692
693        self.state.tape = tape;
694    }
695}
696
697impl GraphProgram {
698    #[allow(unsafe_code)]
699    fn eval_inst(
700        &self,
701        inst: &GraphCoreInst,
702        tape: TapePtr,
703        args: &mut Vec<Fr>,
704        bits: &mut Vec<u16>,
705    ) {
706        args.clear();
707        bits.clear();
708        let (lo, hi) = inst.args;
709        for i in lo as usize..hi as usize {
710            let offset = self.operand_offsets[i] as usize;
711            // Safety: operands are input/const (prefilled) or parent outputs
712            // published via the parent-flag Acquire before this call.
713            args.push(unsafe { *tape.0.add(offset) });
714            bits.push(self.operand_bits[i]);
715        }
716        // Safety: each instruction's ctx/lookup ranges are disjoint from all
717        // others', so these exclusive slices never overlap across threads.
718        let ctx = unsafe {
719            std::slice::from_raw_parts_mut(
720                tape.0.add(inst.ctx_offset as usize),
721                inst.ctx_len as usize,
722            )
723        };
724        let lookups = unsafe {
725            std::slice::from_raw_parts_mut(
726                tape.0.add(self.advice_cells + inst.lookup_offset as usize),
727                inst.lookups_len as usize,
728            )
729        };
730        let (ci_lo, ci_hi) = inst.const_inds;
731        interpret_op(
732            &inst.opcode,
733            args,
734            bits,
735            ctx,
736            lookups,
737            self.lookup_bits,
738            &self.const_inds[ci_lo as usize..ci_hi as usize],
739        );
740    }
741}
742
743impl<'a> ChipBase for GraphExecutor<'a> {
744    /// Wires are absolute offsets into the executor's tape.
745    type F = usize;
746}
747
748impl<'a> PopulateInputs for GraphExecutor<'a> {
749    fn load_witness(&mut self, value: Fr) -> usize {
750        self.populate_input(Halo2Opcode::LoadWitness, value)
751    }
752
753    fn bb_load_reduced_witness(&mut self, value: BabyBear) -> ReducedBabyBearWire<usize> {
754        let offset =
755            self.populate_input(Halo2Opcode::LoadWitness, Fr::from(value.as_canonical_u64()));
756        ReducedBabyBearWire::assume_reduced(BabyBearWire {
757            value: offset,
758            max_bits: BABYBEAR_MAX_BITS,
759        })
760    }
761
762    fn ext_load_reduced_witness(&mut self, value: BabyBearExt4) -> ReducedBabyBearExt4Wire<usize> {
763        let coeffs = value.as_basis_coefficients_slice();
764        ReducedBabyBearExt4Wire::assume_reduced(core::array::from_fn(|i| {
765            self.bb_load_reduced_witness(coeffs[i])
766        }))
767    }
768}
769
770/// Streams graph-executor tape deltas into advice columns.
771///
772/// Column storage is [`AdviceColumns<Fr>`] (`DeviceBuffer`s under `halo2-gpu`,
773/// `Vec`s otherwise); placement is a pure function of the tape offset so
774/// disjoint deltas may arrive in any order. Layout mirrors
775/// `PagedWitnessContext::push_advice` (gate columns split at pinned break
776/// points, break value duplicated at row 0 of the next column) and
777/// `BaseCircuitBuilder::assign_lookups_in_phase` (lookup columns round-robin:
778/// value `i` at column `i % L`, row `i / L`).
779///
780/// Columns are zero-filled and allocated on the first [`Self::append`], then
781/// segments write directly into their row range — no intermediate host buffer.
782pub struct FusedColumnBuilder {
783    // ---- Config (immutable after `new`) ------------------------------------
784    n: usize,
785    num_advice_columns: usize,
786    /// Pinned break rows, indexed by gate column.
787    break_points: Vec<usize>,
788    /// Absolute advice offset of row 0 of each gate column;
789    /// `col_starts[c + 1] = col_starts[c] + break_points[c]` (row 0 of column
790    /// `c+1` duplicates column `c`'s break-row value).
791    col_starts: Vec<usize>,
792    /// Physical column indices of the range-check lookup advice columns.
793    lookup_col_indices: Vec<usize>,
794
795    /// Advice columns (lazily allocated on first `append`).
796    columns: AdviceColumns<Fr>,
797}
798
799impl FusedColumnBuilder {
800    pub fn new(
801        n: usize,
802        num_advice_columns: usize,
803        break_points: Vec<usize>,
804        lookup_col_indices: Vec<usize>,
805    ) -> Self {
806        let mut col_starts = Vec::with_capacity(break_points.len() + 1);
807        col_starts.push(0usize);
808        for &bp in &break_points {
809            col_starts.push(col_starts.last().unwrap() + bp);
810        }
811        Self {
812            n,
813            num_advice_columns,
814            break_points,
815            col_starts,
816            lookup_col_indices,
817            columns: AdviceColumns::<Fr>::new(),
818        }
819    }
820
821    fn ensure_allocated(&mut self) {
822        if !self.columns.is_empty() {
823            return;
824        }
825        self.columns.reserve_exact(self.num_advice_columns);
826        for _ in 0..self.num_advice_columns {
827            #[cfg(feature = "halo2-gpu")]
828            {
829                let buf: DeviceBuffer<Fr> =
830                    DeviceBuffer::<Fr>::with_capacity_on(self.n, &HALO2_GPU_CTX);
831                buf.fill_zero_on(&HALO2_GPU_CTX)
832                    .expect("zero-fill advice column");
833                self.columns.push(buf);
834            }
835            #[cfg(not(feature = "halo2-gpu"))]
836            self.columns.push(vec![Fr::ZERO; self.n]);
837        }
838    }
839
840    /// Writes `advice_delta` (starting at `advice_offset`) and `lookup_delta`
841    /// (starting at `lookup_offset`) into the advice columns. Placement is a
842    /// pure function of the offsets, so disjoint deltas may arrive in any order.
843    pub fn append(
844        &mut self,
845        advice_offset: usize,
846        advice_delta: &[Fr],
847        lookup_offset: usize,
848        lookup_delta: &[Fr],
849    ) {
850        self.ensure_allocated();
851
852        // --- Gate stream: contiguous write per (column, row-range) segment.
853        //
854        // Column `c` covers `[col_starts[c], col_starts[c+1]]` inclusive; the
855        // shared endpoint (break value) is duplicated at `(c, break_points[c])`
856        // and `(c+1, 0)`. On crossing a break, `delta_pos -= 1` re-emits the
857        // break value as row 0 of the next column.
858        if !advice_delta.is_empty() {
859            let c = self.col_starts.partition_point(|&s| s <= advice_offset) - 1;
860            let (mut col, mut row) = if c > 0 && advice_offset == self.col_starts[c] {
861                (c - 1, self.break_points[c - 1])
862            } else {
863                (c, advice_offset - self.col_starts[c])
864            };
865            let mut delta_pos = 0usize;
866            while delta_pos < advice_delta.len() {
867                let cur_break_point = self.break_points.get(col).copied();
868                let rows_until_break = match cur_break_point {
869                    Some(bp) => {
870                        debug_assert!(bp >= row);
871                        bp - row + 1
872                    }
873                    None => usize::MAX,
874                };
875                let delta_remaining = advice_delta.len() - delta_pos;
876                let take = rows_until_break.min(delta_remaining);
877                let src = &advice_delta[delta_pos..delta_pos + take];
878                #[cfg(feature = "halo2-gpu")]
879                self.columns[col]
880                    .mut_slice(row..row + take)
881                    .copy_from_host(src, &HALO2_GPU_CTX)
882                    .expect("H2D advice gate segment");
883                #[cfg(not(feature = "halo2-gpu"))]
884                self.columns[col][row..row + take].copy_from_slice(src);
885                delta_pos += take;
886                if cur_break_point.is_some() && take == rows_until_break {
887                    col += 1;
888                    row = 0;
889                    delta_pos -= 1; // Re-emit the break value as row 0 of the new column.
890                } else {
891                    row += take;
892                }
893            }
894        }
895
896        // --- Lookup stream: the value at global lookup index `g`
897        // (`lookup_offset` + its position in the delta) lands at column
898        // `lookup_col_indices[g % l]`, row `g / l`. On GPU, a column's values
899        // sit at delta positions `first_pos, first_pos + l, ...` and their
900        // rows are consecutive (each stride step advances `g` by exactly
901        // `l`), so gather the stride into a host buffer and issue one
902        // contiguous H2D copy per column. On host, just scatter.
903        let l = self.lookup_col_indices.len();
904        #[cfg(feature = "halo2-gpu")]
905        if !lookup_delta.is_empty() {
906            for col in 0..l {
907                // First delta position `i` with `(lookup_offset + i) % l == col`.
908                let first_pos = (col + l - lookup_offset % l) % l;
909                if first_pos >= lookup_delta.len() {
910                    continue;
911                }
912                let num_values = (lookup_delta.len() - first_pos).div_ceil(l);
913                let first_row = (lookup_offset + first_pos) / l;
914                let host_buf: Vec<Fr> = (0..num_values)
915                    .map(|i| lookup_delta[first_pos + i * l])
916                    .collect();
917                self.columns[self.lookup_col_indices[col]]
918                    .mut_slice(first_row..first_row + num_values)
919                    .copy_from_host(&host_buf, &HALO2_GPU_CTX)
920                    .expect("H2D lookup column gather");
921            }
922        }
923        #[cfg(not(feature = "halo2-gpu"))]
924        for (i, v) in lookup_delta.iter().enumerate() {
925            let global = lookup_offset + i;
926            let col = self.lookup_col_indices[global % l];
927            let row = global / l;
928            self.columns[col][row] = *v;
929        }
930    }
931
932    /// Consumes the advice columns, leaving the builder empty.
933    pub fn take_columns(&mut self) -> AdviceColumns<Fr> {
934        assert!(
935            !self.columns.is_empty(),
936            "take_columns: no data was ever appended",
937        );
938        std::mem::take(&mut self.columns)
939    }
940
941    /// Diagnostic-only D2H copy of every device column, for byte-comparing
942    /// against the legacy `BaseCircuitBuilder` path. Not on the hot path.
943    #[cfg(all(feature = "halo2-gpu", test))]
944    pub fn snapshot_columns_to_host(&self) -> Vec<Vec<Fr>> {
945        use openvm_cuda_common::copy::MemCopyD2H;
946        self.columns
947            .iter()
948            .map(|d| d.to_host_on(&HALO2_GPU_CTX).expect("D2H advice column"))
949            .collect()
950    }
951}
952
953#[cfg(test)]
954mod tests {
955    use std::sync::Arc;
956    #[cfg(feature = "halo2-gpu")]
957    use std::time::{Duration, Instant};
958
959    use halo2_base::gates::{
960        circuit::{builder::BaseCircuitBuilder, CircuitBuilderStage},
961        RangeChip,
962    };
963    use openvm_stark_sdk::{
964        config::baby_bear_bn254_poseidon2::{
965            BabyBearBn254Poseidon2Config as RootConfig, BabyBearBn254Poseidon2CpuEngine,
966        },
967        openvm_stark_backend::{
968            proof::Proof,
969            test_utils::{test_system_params_small, InteractionsFixture11, TestFixture},
970            StarkEngine,
971        },
972    };
973    #[cfg(feature = "halo2-gpu")]
974    use rand_chacha::{rand_core::SeedableRng, ChaCha20Rng};
975
976    use super::*;
977    use crate::{
978        backend::Halo2Backend,
979        stages::{full_pipeline::load_proof_wire, proof_shape::log_heights_per_air_from_proof},
980        StaticVerifierCircuit,
981    };
982    #[cfg(feature = "halo2-gpu")]
983    use crate::{
984        test_fixtures::{fixture_circuit_and_proof, FIXTURE_K},
985        Halo2Params, StaticVerifierProvingKey, StaticVerifierShape,
986    };
987
988    const K: usize = 22;
989    const LOOKUP_BITS: usize = K - 1;
990
991    /// Flattens the range tape: every value sent to `add_cell_to_lookup`, in order.
992    fn lookup_tape(range: &RangeChip<Fr>) -> Vec<Fr> {
993        let map = range.lookup_manager()[0].cells_to_lookup.lock().unwrap();
994        assert!(map.len() <= 1, "expected a single context tag");
995        map.values()
996            .flat_map(|cells| cells.iter().map(|c| c[0].value.evaluate()))
997            .collect()
998    }
999
1000    fn build_and_run(
1001        circuit: &StaticVerifierCircuit,
1002        proof: &Proof<RootConfig>,
1003        log_heights_per_air: &[usize],
1004        num_threads: usize,
1005    ) -> (GraphProgram, GraphExecutorState) {
1006        let mut ir = Halo2IRBuilder::new(LOOKUP_BITS);
1007        circuit.populate_verify_stark_constraints(&mut ir, proof);
1008        let program = GraphProgram::lower(ir);
1009        let mut state = GraphExecutorState::new(&program);
1010        {
1011            let mut executor = GraphExecutor::new(&program, &mut state);
1012            load_proof_wire(&mut executor, proof, log_heights_per_air);
1013
1014            // Rebuild both tapes from the streamed deltas: every cell must be
1015            // covered and end at the tape's final value.
1016            let mut shadow_advice = vec![Fr::ZERO; executor.advice().len()];
1017            let mut covered_advice = vec![false; executor.advice().len()];
1018            let mut shadow_lookups = vec![Fr::ZERO; executor.lookups().len()];
1019            let mut covered_lookups = vec![false; executor.lookups().len()];
1020            executor.run(num_threads, |a_off, advice, l_off, lookups| {
1021                shadow_advice[a_off..a_off + advice.len()].copy_from_slice(advice);
1022                covered_advice[a_off..a_off + advice.len()].fill(true);
1023                shadow_lookups[l_off..l_off + lookups.len()].copy_from_slice(lookups);
1024                covered_lookups[l_off..l_off + lookups.len()].fill(true);
1025            });
1026            assert!(covered_advice.iter().all(|&c| c), "uncovered advice cells");
1027            assert!(covered_lookups.iter().all(|&c| c), "uncovered lookup cells");
1028            assert_eq!(shadow_advice, executor.advice(), "advice deltas");
1029            assert_eq!(shadow_lookups, executor.lookups(), "lookup deltas");
1030        }
1031        (program, state)
1032    }
1033
1034    #[test]
1035    fn graph_executor_matches_halo2_backend() {
1036        let engine: BabyBearBn254Poseidon2CpuEngine =
1037            BabyBearBn254Poseidon2CpuEngine::new(test_system_params_small(2, 8, 3));
1038        let (vk, proof) = InteractionsFixture11.keygen_and_prove(&engine);
1039        let log_heights_per_air = log_heights_per_air_from_proof(&proof);
1040        let circuit = StaticVerifierCircuit::try_new(vk, Default::default(), &log_heights_per_air)
1041            .expect("static circuit params");
1042
1043        // Reference: the real halo2 population.
1044        let mut builder = BaseCircuitBuilder::from_stage(CircuitBuilderStage::Mock)
1045            .use_k(K)
1046            .use_lookup_bits(LOOKUP_BITS);
1047        let range = Arc::new(builder.range_chip());
1048        let ctx = builder.main(0);
1049        let mut backend = Halo2Backend::new(range.clone(), ctx);
1050        circuit.populate_verify_stark_constraints(&mut backend, &proof);
1051        let real_advice: Vec<Fr> = backend
1052            .ctx_mut()
1053            .advice
1054            .iter()
1055            .map(|a| a.evaluate())
1056            .collect();
1057        let real_lookups = lookup_tape(&range);
1058
1059        let (program, state) = build_and_run(&circuit, &proof, &log_heights_per_air, 4);
1060        assert_eq!(
1061            state.advice(&program).len(),
1062            real_advice.len(),
1063            "advice len"
1064        );
1065        assert_eq!(state.advice(&program), &real_advice[..], "advice tape");
1066        assert_eq!(state.lookups(&program), &real_lookups[..], "range tape");
1067
1068        // Determinism across schedules: single-threaded run matches.
1069        let (seq_program, seq_state) = build_and_run(&circuit, &proof, &log_heights_per_air, 1);
1070        assert_eq!(seq_state.advice(&seq_program), state.advice(&program));
1071        assert_eq!(seq_state.lookups(&seq_program), state.lookups(&program));
1072    }
1073
1074    /// Runs the executor + fused H2D copies; returns wall time.
1075    #[cfg(feature = "halo2-gpu")]
1076    fn timed_run(
1077        executor: &mut GraphExecutor<'_>,
1078        builder: &mut FusedColumnBuilder,
1079        num_threads: usize,
1080    ) -> Duration {
1081        let start = Instant::now();
1082        executor.run(
1083            num_threads,
1084            |advice_offset, advice, lookup_offset, lookups| {
1085                builder.append(advice_offset, advice, lookup_offset, lookups)
1086            },
1087        );
1088        start.elapsed()
1089    }
1090
1091    /// Production-path setup: STARK-prove the root-shaped fixture, then
1092    /// `keygen` against an in-memory SRS to get a real pinning.
1093    #[cfg(feature = "halo2-gpu")]
1094    fn keygen_fixture_static_verifier() -> (StaticVerifierProvingKey, Proof<RootConfig>) {
1095        let (circuit, proof) = fixture_circuit_and_proof();
1096        let shape = StaticVerifierShape {
1097            k: FIXTURE_K,
1098            lookup_bits: FIXTURE_K - 1,
1099            minimum_rows: 20,
1100            instance_columns: 1,
1101        };
1102
1103        let start = Instant::now();
1104        let params = Halo2Params::setup(FIXTURE_K as u32, ChaCha20Rng::seed_from_u64(42));
1105        println!("SRS setup (k={FIXTURE_K}): {:?}", start.elapsed());
1106
1107        let start = Instant::now();
1108        let pk = StaticVerifierProvingKey::keygen(&params, shape, circuit, &proof);
1109        println!("static verifier keygen: {:?}", start.elapsed());
1110        (pk, proof)
1111    }
1112
1113    #[test]
1114    #[cfg(feature = "halo2-gpu")]
1115    #[ignore = "requires CUDA GPU; slow (fixture STARK prove + halo2 keygen)"]
1116    fn graph_executor_root_proof() {
1117        use halo2_base::{
1118            gates::circuit::MaybeRangeConfig,
1119            halo2_proofs::{halo2curves::bn256::G1Affine, plonk::create_constraint_system},
1120        };
1121
1122        let (pk, proof) = keygen_fixture_static_verifier();
1123        let metadata = &pk.pinning.metadata;
1124        let log_heights_per_air = log_heights_per_air_from_proof(&proof);
1125
1126        // Physical column layout for the FusedColumnBuilder
1127        // (mirrors `StaticVerifierProvingKey::generate_witness`).
1128        let n = 1usize << metadata.config_params.k;
1129        let (cs, config) = create_constraint_system::<G1Affine, BaseCircuitBuilder<Fr>>(
1130            metadata.config_params.clone(),
1131        );
1132        let num_advice_columns = cs.num_advice_columns();
1133        let MaybeRangeConfig::WithRange(range_config) = &config.base else {
1134            panic!("static verifier requires lookup advice columns");
1135        };
1136        let lookup_col_indices: Vec<usize> = range_config.lookup_advice[0]
1137            .iter()
1138            .map(|c| c.index())
1139            .collect();
1140        let break_points = metadata.break_points[0].clone();
1141        let fused_builder = || {
1142            FusedColumnBuilder::new(
1143                n,
1144                num_advice_columns,
1145                break_points.clone(),
1146                lookup_col_indices.clone(),
1147            )
1148        };
1149
1150        let start = Instant::now();
1151        let mut ir = Halo2IRBuilder::new(pk.shape.lookup_bits);
1152        pk.circuit.populate_pvs(&mut ir, &proof);
1153        println!("IR build: {:?}", start.elapsed());
1154
1155        let start = Instant::now();
1156        let program = GraphProgram::lower(ir);
1157        let mut state = GraphExecutorState::new(&program);
1158        println!(
1159            "lowering: {:?} ({} insts)",
1160            start.elapsed(),
1161            program.insts.len()
1162        );
1163
1164        let start = Instant::now();
1165        {
1166            let mut executor = GraphExecutor::new(&program, &mut state);
1167            load_proof_wire(&mut executor, &proof, &log_heights_per_air);
1168        }
1169        println!("input population: {:?}", start.elapsed());
1170
1171        let mut builder = fused_builder();
1172        let total = timed_run(
1173            &mut GraphExecutor::new(&program, &mut state),
1174            &mut builder,
1175            1,
1176        );
1177        let reference_columns = builder.snapshot_columns_to_host();
1178        drop(builder.take_columns());
1179        println!("run + fused H2D (1 thread): {total:?}");
1180        let reference_advice = state.advice(&program).to_vec();
1181        let reference_lookups = state.lookups(&program).to_vec();
1182
1183        // Warm-tape reruns: timing + a consistency check that column
1184        // placement is independent of thread-count chunking. (Fresh-tape
1185        // correctness is covered by `graph_executor_matches_halo2_backend`.)
1186        for num_threads in [4, 8, 12] {
1187            let mut builder = fused_builder();
1188            let total = timed_run(
1189                &mut GraphExecutor::new(&program, &mut state),
1190                &mut builder,
1191                num_threads,
1192            );
1193            let columns = builder.snapshot_columns_to_host();
1194            drop(builder.take_columns());
1195            println!("run + fused H2D ({num_threads} threads): {total:?}");
1196            assert_eq!(state.advice(&program), &reference_advice[..]);
1197            assert_eq!(state.lookups(&program), &reference_lookups[..]);
1198            assert_eq!(columns.len(), reference_columns.len());
1199            for (i, (col, reference)) in columns.iter().zip(&reference_columns).enumerate() {
1200                assert!(
1201                    col == reference,
1202                    "device column {i} mismatch vs 1-thread reference ({num_threads} threads)"
1203                );
1204            }
1205        }
1206    }
1207
1208    /// Benchmarks the witness-gen pipeline `prove_wrapped` runs before SNARK
1209    /// generation: `generate_witness` + `FusedColumnBuilder` H2D copies. SNARK
1210    /// generation itself is excluded.
1211    ///
1212    /// Runs the pipeline three times per thread count to surface cold/warm
1213    /// timings. Run with:
1214    /// ```text
1215    /// cargo test --profile fast -p openvm-static-verifier \
1216    ///     --features evm-prove,halo2-gpu \
1217    ///     -- --ignored --nocapture \
1218    ///        graph_executor_prove_wrapped_pipeline
1219    /// ```
1220    #[test]
1221    #[cfg(all(feature = "evm-prove", feature = "halo2-gpu"))]
1222    #[ignore = "requires CUDA GPU; slow (fixture STARK prove + halo2 keygen)"]
1223    fn graph_executor_prove_wrapped_pipeline() {
1224        let (pk, proof) = keygen_fixture_static_verifier();
1225        let mut state = GraphExecutorState::new(&pk.graph_program);
1226
1227        // Warm-up pays one-time costs (device init, first column allocation)
1228        // so the timed loop measures per-proof work only.
1229        let start = Instant::now();
1230        let (warmup_advice, _) = pk.generate_witness(&proof, 1, &mut state);
1231        println!("pipeline warm-up (1 thread): {:?}", start.elapsed());
1232        drop(warmup_advice);
1233
1234        for num_threads in [4, 8, 12] {
1235            for iter in 0..3 {
1236                let start = Instant::now();
1237                let (gpu_advice, _instances) = pk.generate_witness(&proof, num_threads, &mut state);
1238                println!(
1239                    "pipeline (threads={num_threads}, iter={iter}): {:?}",
1240                    start.elapsed()
1241                );
1242                drop(gpu_advice);
1243            }
1244        }
1245    }
1246}