warriors/trisha/.claude/plans/pr-triton-vm-gpu-hooks.md

PR: triton-vm — GPU acceleration hooks

Target repo: TritonVM/triton-vm
Target branch: main
Title: feat: add GpuAccelerator trait for pluggable GPU backends


Motivation

Triton VM's prover is CPU-bound at three hot paths that map cleanly onto GPU parallelism: Goldilocks NTT/iNTT (polynomial interpolation, ~40-50% of prove time), Tip5 batch hashing (Merkle leaf construction, ~20-30%), and FRI folding. There is currently no extension point that lets an external crate supply GPU implementations of these operations without forking or patching triton-vm at build time.

This PR adds a zero-overhead pluggable dispatch mechanism: a GpuAccelerator trait registered once via a OnceLock, with CPU defaults for every method. If no accelerator is registered the prover behaves identically to today. When one is registered, the seven hot dispatch points delegate to it.

Measured benefit (M1 Max, Metal via wgpu): 4.3× proving speedup on representative programs, 24 MH/s Tip5 mining throughput.


Changes

New file: src/gpu.rs

//! GPU acceleration hooks for triton-vm operations.
//!
//! Register a GPU accelerator via [`set_gpu_accelerator`] before proving.
//! The prover dispatches Tip5 hashing, NTT, iNTT, Merkle tree construction,
//! FRI folding, and GEMV to the GPU when an accelerator is registered.
//!
//! All methods have correct CPU fallback implementations — if no accelerator
//! is registered, the prover behaves identically to the unpatched version.

use std::sync::OnceLock;

use twenty_first::math::ntt::intt;
use twenty_first::prelude::*;
use twenty_first::util_types::merkle_tree::MerkleTree;

/// Trait for GPU-accelerated operations used during STARK proving.
///
/// Implement this trait to dispatch hot prover paths to GPU hardware.
/// Every method has a default CPU implementation — override only the
/// paths your backend accelerates.
///
/// Acceleration targets (in order of prove-time impact):
/// 1. `intt_bfe` / `intt_xfe` — polynomial interpolation, ~40-50%
/// 2. `hash_varlen_batch` — Merkle leaf hashing, ~20-30%
/// 3. `merkle_tree` — internal node hashing, ~10-15%
/// 4. `fri_fold` — FRI query folding
/// 5. `gemv_bfe` / `gemv_xfe` — weighted column sums
/// 6. `ntt_bfe` / `ntt_xfe` — forward NTT (smaller share)
pub trait GpuAccelerator: Send + Sync {
    /// Backend name for diagnostics.
    fn name(&self) -> &str;

    /// Hash multiple variable-length inputs using Tip5.
    fn hash_varlen_batch(&self, inputs: &[&[BFieldElement]]) -> Vec<Digest>;

    /// In-place inverse NTT on a BFieldElement column.
    fn intt_bfe(&self, column: &mut [BFieldElement]) {
        intt(column);
    }

    /// In-place inverse NTT on an XFieldElement column.
    fn intt_xfe(&self, column: &mut [XFieldElement]) {
        intt(column);
    }

    /// In-place forward NTT on a BFieldElement column.
    fn ntt_bfe(&self, column: &mut [BFieldElement]) {
        twenty_first::math::ntt::ntt(column);
    }

    /// In-place forward NTT on an XFieldElement column.
    fn ntt_xfe(&self, column: &mut [XFieldElement]) {
        twenty_first::math::ntt::ntt(column);
    }

    /// Build a Merkle tree from leaf digests.
    fn merkle_tree(&self, leaves: &[Digest]) -> MerkleTree {
        MerkleTree::par_new(leaves).unwrap()
    }

    /// GEMV: BFE matrix (row-major, flat) × XFE weights → XFE result.
    ///
    /// For each row i: result[i] = Σ_j matrix[i*ncols+j] * weights[j]
    fn gemv_bfe(
        &self,
        matrix: &[BFieldElement],
        nrows: usize,
        ncols: usize,
        weights: &[XFieldElement],
    ) -> Vec<XFieldElement> {
        use rayon::prelude::*;
        (0..nrows)
            .into_par_iter()
            .map(|i| {
                let row = &matrix[i * ncols..(i + 1) * ncols];
                row.iter().zip(weights.iter()).map(|(&m, &w)| w * m).sum()
            })
            .collect()
    }

    /// GEMV: XFE matrix (row-major, flat) × XFE weights → XFE result.
    fn gemv_xfe(
        &self,
        matrix: &[XFieldElement],
        nrows: usize,
        ncols: usize,
        weights: &[XFieldElement],
    ) -> Vec<XFieldElement> {
        use rayon::prelude::*;
        (0..nrows)
            .into_par_iter()
            .map(|i| {
                let row = &matrix[i * ncols..(i + 1) * ncols];
                row.iter().zip(weights.iter()).map(|(&m, &w)| m * w).sum()
            })
            .collect()
    }

    /// FRI split-and-fold: fold a codeword with the given challenge.
    ///
    /// `domain_point_inverses`: batch-inverted domain points (first half
    /// of the evaluation domain). Length = codeword.len() / 2.
    /// Returns the folded codeword (half the input length).
    fn fri_fold(
        &self,
        codeword: &[XFieldElement],
        domain_point_inverses: &[BFieldElement],
        folding_challenge: XFieldElement,
    ) -> Vec<XFieldElement> {
        let one = XFieldElement::new([
            BFieldElement::new(1),
            BFieldElement::new(0),
            BFieldElement::new(0),
        ]);
        let two_inverse = XFieldElement::new([
            BFieldElement::new(2),
            BFieldElement::new(0),
            BFieldElement::new(0),
        ])
        .inverse();
        let n = codeword.len();
        (0..n / 2)
            .map(|i| {
                let scaled_offset_inv = folding_challenge * domain_point_inverses[i];
                let left_summand = (one + scaled_offset_inv) * codeword[i];
                let right_summand = (one - scaled_offset_inv) * codeword[n / 2 + i];
                (left_summand + right_summand) * two_inverse
            })
            .collect()
    }
}

static GPU_ACCELERATOR: OnceLock<Box<dyn GpuAccelerator>> = OnceLock::new();

/// Register a GPU accelerator for use during proving.
///
/// Must be called before the first `prove()` invocation. Returns `Err`
/// containing the accelerator if one was already registered.
pub fn set_gpu_accelerator(
    accelerator: Box<dyn GpuAccelerator>,
) -> Result<(), Box<dyn GpuAccelerator>> {
    GPU_ACCELERATOR.set(accelerator)
}

/// Get the registered GPU accelerator, if any.
#[inline]
pub fn gpu_accelerator() -> Option<&'static dyn GpuAccelerator> {
    GPU_ACCELERATOR.get().map(|b| b.as_ref())
}

src/lib.rs — export the module

 pub mod fri;
+pub mod gpu;
 pub mod instruction;

twenty-first: MerkleTree::from_nodes constructor

GPU backends build the tree node array on the GPU and need to wrap it without re-hashing on CPU. Add to MerkleTree:

/// Construct a MerkleTree from a pre-computed flat node array.
///
/// The caller guarantees the nodes are valid: root at index 1,
/// leaves at indices [n..2n), all internal nodes correctly hashed.
/// Used by GPU backends that compute the full tree on device.
pub fn from_nodes(nodes: Vec<Digest>) -> Self {
    MerkleTree { nodes }
}

src/stark.rs — 4 dispatch points

Import (top of file):

+use crate::gpu;

Tip5 hash dispatch (inside fn prove, profiler block "hash rows of quotient segments"):

-        let hash_row = |row: ArrayView1<_>| {
-            let row_as_bfes = row.iter().map(interpret_xfe_as_bfes).concat();
-            Tip5::hash_varlen(&row_as_bfes)
-        };
-        let quotient_segments_rows = fri_domain_quotient_segment_codewords
-            .axis_iter(ROW_AXIS)
-            .into_par_iter();
-        let fri_domain_quotient_segment_codewords_digests =
-            quotient_segments_rows.map(hash_row).collect::<Vec<_>>();
+        let fri_domain_quotient_segment_codewords_digests =
+            if let Some(gpu) = gpu::gpu_accelerator() {
+                let rows: Vec<Vec<BFieldElement>> = fri_domain_quotient_segment_codewords
+                    .axis_iter(ROW_AXIS)
+                    .map(|row| row.iter().flat_map(interpret_xfe_as_bfes).collect())
+                    .collect();
+                let row_refs: Vec<&[BFieldElement]> =
+                    rows.iter().map(|r| r.as_slice()).collect();
+                gpu.hash_varlen_batch(&row_refs)
+            } else {
+                let hash_row = |row: ArrayView1<_>| {
+                    let row_as_bfes = row.iter().map(interpret_xfe_as_bfes).concat();
+                    Tip5::hash_varlen(&row_as_bfes)
+                };
+                fri_domain_quotient_segment_codewords
+                    .axis_iter(ROW_AXIS)
+                    .into_par_iter()
+                    .map(hash_row)
+                    .collect::<Vec<_>>()
+            };

Quotient Merkle tree dispatch:

-        let quot_merkle_tree =
-            MerkleTree::par_new(&fri_domain_quotient_segment_codewords_digests)?;
+        let quot_merkle_tree = if let Some(gpu) = gpu::gpu_accelerator() {
+            gpu.merkle_tree(&fri_domain_quotient_segment_codewords_digests)
+        } else {
+            MerkleTree::par_new(&fri_domain_quotient_segment_codewords_digests)?
+        };

iNTT dispatch (profiler block "poly interpolate"):

-        main_table.trace_table_mut().axis_iter_mut(COL_AXIS)
-            .into_par_iter()
-            .for_each(|mut col| intt(col.as_slice_mut().unwrap()));
-        aux_table.trace_table_mut().axis_iter_mut(COL_AXIS)
-            .into_par_iter()
-            .for_each(|mut col| intt(col.as_slice_mut().unwrap()));
+        if let Some(gpu) = gpu::gpu_accelerator() {
+            let mut trace = main_table.trace_table_mut();
+            (0..trace.ncols()).for_each(|c| {
+                gpu.intt_bfe(trace.column_mut(c).into_slice_memory_order().unwrap());
+            });
+            let mut trace = aux_table.trace_table_mut();
+            (0..trace.ncols()).for_each(|c| {
+                gpu.intt_xfe(trace.column_mut(c).into_slice_memory_order().unwrap());
+            });
+        } else {
+            main_table.trace_table_mut().axis_iter_mut(COL_AXIS)
+                .into_par_iter()
+                .for_each(|mut col| intt(col.as_slice_mut().unwrap()));
+            aux_table.trace_table_mut().axis_iter_mut(COL_AXIS)
+                .into_par_iter()
+                .for_each(|mut col| intt(col.as_slice_mut().unwrap()));
+        }

Forward NTT dispatch (profiler block "restore original trace"):

-        main_table.trace_table_mut().axis_iter_mut(COL_AXIS)
-            .into_par_iter()
-            .for_each(|mut col| ntt(col.as_slice_mut().unwrap()));
-        aux_table.trace_table_mut().axis_iter_mut(COL_AXIS)
-            .into_par_iter()
-            .for_each(|mut col| ntt(col.as_slice_mut().unwrap()));
+        if let Some(gpu) = gpu::gpu_accelerator() {
+            let mut trace = main_table.trace_table_mut();
+            (0..trace.ncols()).for_each(|c| {
+                gpu.ntt_bfe(trace.column_mut(c).into_slice_memory_order().unwrap());
+            });
+            let mut trace = aux_table.trace_table_mut();
+            (0..trace.ncols()).for_each(|c| {
+                gpu.ntt_xfe(trace.column_mut(c).into_slice_memory_order().unwrap());
+            });
+        } else {
+            main_table.trace_table_mut().axis_iter_mut(COL_AXIS)
+                .into_par_iter()
+                .for_each(|mut col| ntt(col.as_slice_mut().unwrap()));
+            aux_table.trace_table_mut().axis_iter_mut(COL_AXIS)
+                .into_par_iter()
+                .for_each(|mut col| ntt(col.as_slice_mut().unwrap()));
+        }

src/table/master_table.rs — 3 dispatch points

Import:

+use crate::gpu;

Tip5 hash dispatch (Merkle leaf hashing):

-            let all_digests = fri_domain_table
-                .axis_iter(ROW_AXIS)
-                .into_par_iter()
-                .map(|row| row.to_slice().unwrap())
-                .map(Self::Field::bfe_slice)
-                .map(Tip5::hash_varlen)
-                .collect();
+            let all_digests = if let Some(gpu) = gpu::gpu_accelerator() {
+                let rows: Vec<Vec<BFieldElement>> = fri_domain_table
+                    .axis_iter(ROW_AXIS)
+                    .map(|row| Self::Field::bfe_slice(row.to_slice().unwrap()).to_vec())
+                    .collect();
+                let row_refs: Vec<&[BFieldElement]> =
+                    rows.iter().map(|r| r.as_slice()).collect();
+                gpu.hash_varlen_batch(&row_refs)
+            } else {
+                fri_domain_table
+                    .axis_iter(ROW_AXIS)
+                    .into_par_iter()
+                    .map(|row| row.to_slice().unwrap())
+                    .map(Self::Field::bfe_slice)
+                    .map(Tip5::hash_varlen)
+                    .collect()
+            };

Merkle tree dispatch:

-        let merkle_tree = MerkleTree::par_new(&hashed_rows).unwrap();
+        let merkle_tree = if let Some(gpu) = gpu::gpu_accelerator() {
+            gpu.merkle_tree(&hashed_rows)
+        } else {
+            MerkleTree::par_new(&hashed_rows).unwrap()
+        };

GEMV dispatch (weighted_sum_of_columns):

-        let weighted_sum = self
-            .trace_table()
-            .axis_iter(ROW_AXIS)
-            .into_par_iter()
-            .map(|row| row.iter().zip_eq(&weights).map(|(&r, &w)| r * w).sum())
-            .collect::<Vec<_>>();
+        let weighted_sum = if let Some(gpu) = gpu::gpu_accelerator() {
+            // Transpose column-major trace into row-major flat buffer for GPU.
+            let trace = self.trace_table();
+            let (nrows, ncols) = (trace.nrows(), trace.ncols());
+            let weights_slice = weights.as_slice().unwrap();
+            // dispatch based on field type via monomorphisation
+            gpu_weighted_sum(gpu, &trace, nrows, ncols, weights_slice)
+        } else {
+            self.trace_table()
+                .axis_iter(ROW_AXIS)
+                .into_par_iter()
+                .map(|row| row.iter().zip_eq(&weights).map(|(&r, &w)| r * w).sum())
+                .collect::<Vec<_>>()
+        };

src/fri.rs — 1 dispatch point

Import:

+use crate::gpu;

FRI fold dispatch (in split_and_fold):

 fn split_and_fold(&self, folding_challenge: XFieldElement) -> Vec<XFieldElement> {
+    let domain_points = self.domain.values();
+    let domain_point_inverses = BFieldElement::batch_inversion(domain_points);
+
+    if let Some(gpu) = gpu::gpu_accelerator() {
+        return gpu.fri_fold(&self.codeword, &domain_point_inverses, folding_challenge);
+    }
+
     let one = xfe!(1);
     let two_inverse = xfe!(2).inverse();
-    let domain_points = self.domain.values();
-    let domain_point_inverses = BFieldElement::batch_inversion(domain_points);
     ...

Properties preserved

  • Zero cost when unused: all dispatch points are if let Some(gpu) = gpu_accelerator() — no overhead when no accelerator is registered. OnceLock reads are a single atomic load after the first call.
  • Correct CPU fallback: every else branch is identical to the current code path. Behaviour without a registered accelerator is bit-for-bit unchanged.
  • No new required dependencies: the GpuAccelerator trait uses only types already in scope (BFieldElement, XFieldElement, Digest, MerkleTree).
  • Thread safe: OnceLock<Box<dyn GpuAccelerator>> with Send + Sync bound.

Testing

The existing test suite exercises all patched code paths with no accelerator registered (CPU fallback). A GPU accelerator that returns wrong results would cause proof verification failure — the prover is self-checking.

A reference wgpu implementation exists at https://github.com/cyberia-to/trisha and passes all 15 integration tests with Metal on macOS.

Graph