Todos los artículos

Generación de pruebas de conocimiento cero acelerada por GPU

·
7 sept 2025
·
5 min de lectura
·
••• visitas
·
••• me gusta

Traducido del artículo original en inglés. Leer en inglés

Generación de pruebas de conocimiento cero acelerada por GPU

Las pruebas de conocimiento cero permiten cómputo verificable sin exponer transacciones privadas, y se convierten en una pieza poderosa para transacciones que preservan la privacidad y para la verificación onchain escalable. Sin embargo, la intensidad computacional de la generación de pruebas ha sido, con el tiempo, un problema importante. Un tradeoff muy conocido en los sistemas ZK es el del "prover hambriento de recursos": conseguir una prueba pequeña y rápida de verificar requiere mucho cómputo y a menudo es bastante lento, lo que bloquea el uso en tiempo real.

Las GPUs modernas cambian drásticamente esta ecuación. Aprovechando su diseño paralelo, convierten lo que era un problema de fondo en un problema de ingeniería que se puede resolver. Los sistemas ZK de hoy usan principalmente dos construcciones: zk-SNARKs y zk-STARKs, y ambas dependen de operaciones centrales que encajan perfectamente con la aceleración por GPU.

¿Qué es lo que realmente tarda tanto?

Casi todo el tiempo de generación de pruebas se va en dos operaciones: Multi-Scalar Multiplication (MSM) y Number Theoretic Transform (NTT).

¿Qué es Multi-Scalar Multiplication?

MSM calcula la suma de puntos de curva elíptica escalados: S=∑i=1nai⋅PiS = \sum_{i=1}^{n} a_i \cdot P_i. Esta sola operación consume nada menos que el 60-80% del tiempo de generación de pruebas en los SNARKs. Por sí misma requiere una gran cantidad de accesos aleatorios a memoria para traer los puntos de la curva elíptica, lo que convierte al ancho de banda de memoria en el principal problema de rendimiento aquí. El algoritmo de Pippenger paraleliza esta tarea repartiendo los escalares en buckets.

fn pippenger_msm<G: CurveGroup>(
    bases: &[G::Affine],
    scalars: &[G::ScalarField],
    window_size: usize,
) -> G {
    let num_buckets = 1 << window_size;
    let mut buckets = vec![G::zero(); num_buckets];
    
    for (base, scalar) in bases.iter().zip(scalars.iter()) {
        let bucket_index = extract_window(scalar, window_size);
        buckets[bucket_index] += base;
    }
    
    // bucket aggregation
    buckets.into_iter()
           .enumerate()
           .map(|(i, bucket)| bucket * G::ScalarField::from(i))
           .fold(G::zero(), |acc, term| acc + term)
}

Entonces, lo que hace la implementación en GPU es repartir esto entre miles de hilos, logrando más de 700 million operaciones de puntos por segundo en una RTX 4090.

¿Qué es Number Theoretic Transform?

NTT, por otro lado, multiplica polinomios rápido y, a diferencia de los saltos aleatorios en memoria de MSM, NTT sigue un patrón predecible, justo para lo que están hechas las GPUs.

__global__ void ntt_radix2_kernel(
    uint64_t* coeffs,
    uint64_t* twiddles,
    uint32_t stage,
    uint32_t n
) {
    uint32_t tid = threadIdx.x + blockIdx.x * blockDim.x;
    uint32_t stride = 1 << stage;
    uint32_t m = n >> (stage + 1);
    
    if (tid >= m) return;
    
    uint32_t k = tid % stride;
    uint32_t j = tid / stride;
    uint32_t idx1 = j * (stride << 1) + k;
    uint32_t idx2 = idx1 + stride;
    
    uint64_t u = coeffs[idx1];
    uint64_t v = mod_mul(coeffs[idx2], twiddles[k], MODULUS);
    
    coeffs[idx1] = mod_add(u, v, MODULUS);
    coeffs[idx2] = mod_sub(u, v, MODULUS);
}

Este patrón secuencial permite que las implementaciones de NTT de alto rendimiento alcancen un 85-95% del ancho de banda de memoria.

Caso de estudio: rendimiento de pruebas en CPU vs GPU

Piensa en Alice y Bob, que operan zkEVM Sequencers para sus respectivas cadenas Layer-2. Procesan lotes de transacciones idénticos, pero usan infraestructura de pruebas distinta.

Escenario: generación de pruebas de bloques en un zkEVM

Ambos sequencers deben probar un bloque que contiene 500 transacciones con las siguientes operaciones:

Esto genera un circuito con aproximadamente 2262^{26} restricciones, algo típico para un bloque de zkEVM de tamaño medio.

El enfoque de Alice con CPU

Alice corre su prover en una CPU de gama alta - AMD EPYC 7763, 64 cores:

impl CpuZkEvmProver {
    fn prove_block(&self, block: &Block) -> ProofResult {
        let start = Instant::now();
        
        // circuit compilation
        let circuit = self.compile_block_circuit(block); // 45s
        
        // msm operations
        let witness_commitment = self.cpu_msm(&circuit.witnesses); // 180s
        
        // ntt operations
        let constraint_proof = self.cpu_ntt(&circuit.constraints); // 90s
        
        // final proof
        let proof = self.assemble_proof(witness_commitment, constraint_proof);
        
        ProofResult {
            proof,
            proving_time: start.elapsed(), // ~315s total
            memory_usage: 45_000_000_000, // 45GB peak
        }
    }
    
    fn cpu_msm(&self, witnesses: &[Fr]) -> G1Projective {
        witnesses.iter()
                .zip(self.setup.iter())
                .map(|(scalar, base)| base.mul(scalar))
                .fold(G1Projective::identity(), |acc, point| acc + point)
    }
}

Resultados de Alice:

El enfoque de Bob acelerado por GPU

Bob usa una RTX 4090 con aceleración de ICICLE:

use icicle_bls12_381::{curve::G1Projective, msm};
use icicle_core::msm::MSMConfig;
 
impl GpuZkEvmProver {
    fn prove_block(&self, block: &Block) -> ProofResult {
        let start = Instant::now();
        
        // circuit compilation
        let circuit = self.compile_block_circuit_gpu(block); // 12s
        
        // gpu msm - 16,384 cores parallel
        let witness_commitment = self.gpu_msm(&circuit.witnesses); // 8s
        
        // gpu ntt
        let constraint_proof = self.gpu_ntt(&circuit.constraints); // 3 seconds
        
        // final proof
        let proof = self.assemble_proof(witness_commitment, constraint_proof);
        
        ProofResult {
            proof,
            proving_time: start.elapsed(), // ~23s total
            memory_usage: 18_000_000_000, // 18GB peak
        }
    }
    
    fn gpu_msm(&self, witnesses: &[Fr]) -> Result<G1Projective> {
        let cfg = MSMConfig {
            precompute_factor: 8,
            c: 16,
            ..Default::default()
        };
        
        msm::msm(witnesses, &self.gpu_setup, &cfg)
    }
}

Resultados de Bob:

Comparando los resultados

OperaciónAlice (CPU)Bob (GPU)Aceleración
Compilación del circuito45s12s3.8×
Operaciones MSM180s8s22.5×
Operaciones NTT90s3s30×
Prueba total del bloque315s23s13.7×

Por qué esto lo cambia todo

Finalidad de la red:

Costo por transacción

// one every 12s
const BLOCKS_PER_DAY: u32 = 7200;
 
impl ProvingEconomics {
    fn daily_capacity(&self, proving_time_seconds: u32) -> u32 {
        let daily_seconds = 24 * 60 * 60;
        daily_seconds / proving_time_seconds
    }
}

Capacidad diaria de generación de pruebas (Alice vs Bob):

Lo que Alice necesita para competir

Alice necesitaría 14 CPUs para igualar la configuración de una sola GPU de Bob, lo que implica:

Cómo se lee la memoria

La diferencia de rendimiento nace, en el fondo, de cómo cada servidor accede a la memoria:

// alice's cpu msm - sequential, cache-bound
for (scalar, base) in witnesses.iter().zip(setup.iter()) {
    result += base * scalar; // cache miss every iteration
}
 
// bob's gpu msm - parallel, lightweight
__global__ void msm_kernel(scalar_t* scalars, point_t* bases, point_t* buckets) {
    int tid = blockIdx.x * blockDim.x + threadIdx.x;
    int bucket_idx = extract_window(scalars[tid], WINDOW_SIZE);
    atomicAdd(&buckets[bucket_idx], bases[tid]); // coalesced memory access
}

Este caso de estudio, por ejemplo, simplemente muestra por qué la aceleración por GPU se ha vuelto esencial para los despliegues de zkEVM en producción. La infraestructura de Bob puede atender a 13.7× más usuarios mientras mantiene una finalidad más rápida y costos operativos más bajos.

Patrones de aceleración en SNARK vs. STARK

Sistemas SNARK

Groth16 y PLONK dependen mucho de MSM. Groth16, por ejemplo, dedica alrededor del 80% de su tiempo de generación de pruebas a operaciones de curva elíptica. PLONK, en cambio, reparte la carga de forma más equilibrada entre MSM ~60% y operaciones polinómicas ~30%, por lo que requiere una optimización balanceada para ambas.

impl<E: Pairing> Groth16Prover<E> {
    fn create_proof(&self, circuit: &Circuit<E::ScalarField>) -> Result<Proof<E>> {
        let (a, b, c) = circuit.to_matrices();
        
        let g_a = self.params.alpha_g1.msm(&a);
        let g_b = self.params.beta_g2.msm(&b);  
        let g_c = self.params.gamma_g1.msm(&c);
        
        Ok(Proof { a: g_a, b: g_b, c: g_c })
    }
}

Sistemas STARK

Los STARKs están dominados por la aritmética polinómica NTTs y el hashing, por ejemplo Poseidon o Blake3. Por lo regulares que son los patrones de acceso a memoria en los STARKs, suelen aprovechar mejor el ancho de banda de memoria de la GPU en comparación con los patrones de acceso más aleatorios de las operaciones MSM de los SNARK.

impl<F: PrimeField> StarkProver<F> {
    fn prove(&self, trace: &ExecutionTrace<F>) -> StarkProof<F> {
        // polynomial interpolation via ntt
        let trace_poly = self.interpolate(trace);
        
        let composition_poly = self.evaluate_constraints(&trace_poly);
        
        let fri_proof = self.fri_commit(&composition_poly);
        
        StarkProof {
            trace_commitment: self.commit(&trace_poly),
            composition_commitment: self.commit(&composition_poly),
            fri_proof,
        }
    }
}

Aceleración de zkEVM

Los zkEVMs presentan retos únicos por la complejidad de las instrucciones de la EVM y por las grandes representaciones de estado. El proceso de generación de pruebas implica generar restricciones para cada opcode de la EVM y cada transición de estado.

Probando la EVM

impl ZkEvmProver {
    fn prove_block(&self, block: &Block) -> ZkEvmProof {
        let mut circuit = EvmCircuit::new();
        
        for tx in &block.transactions {
            for (pc, opcode) in tx.trace.iter().enumerate() {
                match opcode {
                    OpCode::ADD => {
                        let (a, b) = self.stack.pop2();
                        let result = a + b;
                        circuit.add_constraint(a + b - result);
                        self.stack.push(result);
                    },
                    OpCode::SSTORE => {
                        let (key, value) = self.stack.pop2();
                        circuit.add_storage_constraint(key, value);
                        self.update_state_root(key, value);
                    },
                    // ...
                }
            }
        }
        
        self.prove_circuit(circuit)
    }
}

Cada opcode, desde ADD hasta SSTORE, debe traducirse en un conjunto de restricciones polinómicas.

Probando el árbol de estado

Por lo general, los zkEVMs siempre deben probar que los patrones de acceso a memoria y las actualizaciones de almacenamiento son correctos. Esto normalmente implica operaciones sobre un Merkle tree para verificar el state root

fn constrain_merkle_update(
    circuit: &mut Circuit,
    old_root: Variable,
    new_root: Variable,
    key: Variable,
    old_value: Variable,
    new_value: Variable,
    path: &[Variable],
) {
    let mut current = old_value;
    let mut updated = new_value;
    
    for (i, &sibling) in path.iter().enumerate() {
        let bit = circuit.extract_bit(key, i);
        
        let hash_input = circuit.select(bit, (sibling, current), (current, sibling));
        current = circuit.poseidon_hash(hash_input.0, hash_input.1);
        
        let updated_input = circuit.select(bit, (sibling, updated), (updated, sibling));
        updated = circuit.poseidon_hash(updated_input.0, updated_input.1);
    }
    
    circuit.assert_equal(current, old_root);
    circuit.assert_equal(updated, new_root);
}

La aceleración por GPU suele volverse importante para las extensas operaciones de hashing que requieren las verificaciones de transiciones de estado

Conclusión

Las GPUs no solo aceleraron la generación de pruebas ZK, básicamente cambiaron la forma en que se hace, al resolver el problema del "prover hambriento de recursos". Ahora tenemos Layer 2s que finalizan en segundos y DeFi privado corriendo en producción, cosas que antes no eran del todo posibles. A medida que la complejidad de los circuitos aumenta con aplicaciones como los zkEVMs, la aceleración por GPU ya no es solo una cuestión de optimización, es un requisito fundamental para los despliegues en producción.

Enlaces esenciales

•••
Comparte este artículo:

Mantente al día

Recibe los últimos artículos en tu bandeja de entrada.