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

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.
Casi todo el tiempo de generación de pruebas se va en dos operaciones: Multi-Scalar Multiplication (MSM) y Number Theoretic Transform (NTT).
MSM calcula la suma de puntos de curva elíptica escalados: . 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.
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.
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.
Ambos sequencers deben probar un bloque que contiene 500 transacciones con las siguientes operaciones:
Esto genera un circuito con aproximadamente restricciones, algo típico para un bloque de zkEVM de tamaño medio.
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:
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:
| Operación | Alice (CPU) | Bob (GPU) | Aceleración |
|---|---|---|---|
| Compilación del circuito | 45s | 12s | 3.8× |
| Operaciones MSM | 180s | 8s | 22.5× |
| Operaciones NTT | 90s | 3s | 30× |
| Prueba total del bloque | 315s | 23s | 13.7× |
Finalidad de la red:
// 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):
Alice necesitaría 14 CPUs para igualar la configuración de una sola GPU de Bob, lo que implica:
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.
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 })
}
}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,
}
}
}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.
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.
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
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.
Recibe los últimos artículos en tu bandeja de entrada.