كل المقالات

تسريع توليد براهين المعرفة الصفرية باستخدام GPU

·
7 سبتمبر 2025
·
وقت القراءة: 5 د
·
••• مشاهدة
·
••• إعجاب

مترجمة عن المقالة الأصلية باللغة الإنجليزية. اقرأها بالإنجليزية

تسريع توليد براهين المعرفة الصفرية باستخدام GPU

تتيح براهين المعرفة الصفرية حوسبة قابلة للتحقق دون كشف المعاملات الخاصة، مما يجعلها لبنة أساسية قوية للمعاملات التي تحافظ على الخصوصية وللتحقق القابل للتوسع على السلسلة. لكن الكثافة الحسابية لتوليد البراهين ظلت مع مرور الوقت مشكلة كبيرة. ومن المقايضات المعروفة في أنظمة ZK ما يُسمّى "المُثبِت المتعطش للموارد": فالحصول على برهان صغير وسريع التحقق يتطلب حوسبة عالية، وغالبًا ما يكون بطيئًا جدًا، مما يعيق الاستخدام في الوقت الفعلي.

وحدات GPU الحديثة تغيّر هذه المعادلة جذريًا. فبالاستفادة من تصميمها المتوازي، تحوّل ما كان مشكلة جوهرية إلى مشكلة هندسية قابلة للحل. وتستخدم أنظمة ZK اليوم أساسًا بنيتين: zk-SNARKs و zk-STARKs، وكلتاهما تعتمد على عمليات أساسية تتوافق تمامًا مع التسريع عبر GPU.

ما الذي يستغرق كل هذا الوقت فعلًا؟

يذهب معظم وقت توليد البرهان تقريبًا إلى عمليتين: الضرب متعدد السلّميات (Multi-Scalar Multiplication أو MSM) وتحويل نظرية الأعداد (Number Theoretic Transform أو NTT).

ما هو الضرب متعدد السلّميات؟

تحسب MSM مجموع نقاط المنحنى الإهليلجي بعد ضربها في معاملات سلّمية: S=∑i=1nai⋅PiS = \sum_{i=1}^{n} a_i \cdot P_i. وهذه العملية وحدها تستهلك نسبة هائلة تبلغ 60-80% من وقت توليد البرهان في SNARKs. وهي بحد ذاتها تتطلب عددًا كبيرًا من عمليات الوصول العشوائي إلى الذاكرة لجلب نقاط المنحنى الإهليلجي، مما يجعل عرض نطاق الذاكرة هو مشكلة الأداء الرئيسية هنا. وتوزّع خوارزمية Pippenger هذه المهمة على التوازي عبر فرز المعاملات السلّمية في دلاء (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)
}

إذن، ما يحدث في تنفيذ GPU هو أنه يقسّم هذا العمل على آلاف الخيوط، فيحقق أكثر من 700 million عملية على النقاط في الثانية على RTX 4090.

ما هو تحويل نظرية الأعداد؟

أما NTT فيضرب كثيرات الحدود بسرعة، وعلى عكس قفزات MSM العشوائية في الذاكرة، يتبع NTT نمطًا يمكن التنبؤ به، وهذا بالضبط ما صُمّمت وحدات GPU من أجله.

__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);
}

يتيح هذا النمط التسلسلي لتنفيذات NTT عالية الأداء أن تبلغ 85-95% من عرض نطاق الذاكرة.

دراسة حالة: أداء توليد البراهين على CPU مقابل GPU

لنفكر في Alice و Bob، وكلاهما يشغّل zkEVM Sequencers لسلسلة Layer-2 خاصة به. وهما يعالجان دفعات معاملات متطابقة، لكنهما يستخدمان بنية تحتية مختلفة لتوليد البراهين.

السيناريو: توليد برهان كتلة zkEVM

يجب على كلا المُرتِّبين إثبات كتلة تحتوي على 500 معاملة تتضمن العمليات التالية:

ينتج عن ذلك دائرة تضم نحو 2262^{26} قيدًا، وهو رقم معتاد لكتلة zkEVM متوسطة الحجم.

نهج Alice القائم على CPU

تشغّل Alice المُثبِت الخاص بها على CPU من الفئة العليا - 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)
    }
}

نتائج Alice:

نهج Bob المُسرَّع عبر GPU

يستخدم Bob بطاقة RTX 4090 مع تسريع 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)
    }
}

نتائج Bob:

مقارنة النتائج

العمليةAlice (CPU)Bob (GPU)معامل التسريع
ترجمة الدائرة45s12s3.8×
عمليات MSM180s8s22.5×
عمليات NTT90s3s30×
إجمالي برهان الكتلة315s23s13.7×

لماذا يغيّر هذا كل شيء

النهائية في الشبكة:

التكلفة لكل معاملة

// 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
    }
}

القدرة اليومية على توليد البراهين (Alice مقابل Bob):

ما تحتاج إليه Alice لكي تنافس

ستحتاج Alice إلى 14 وحدة CPU لتضاهي إعداد Bob القائم على GPU واحدة، مما يؤدي إلى:

كيف تُقرأ الذاكرة

يبدأ فرق الأداء من الطريقة الجوهرية التي يصل بها كلا الخادمين إلى الذاكرة:

// 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
}

توضح دراسة الحالة هذه ببساطة لماذا أصبح التسريع عبر GPU ضروريًا لعمليات نشر zkEVM في بيئات الإنتاج. فالبنية التحتية لدى Bob تستطيع خدمة عدد أكبر من المستخدمين بمقدار 13.7×، مع الحفاظ على نهائية أسرع وتكاليف تشغيل أقل.

أنماط تسريع SNARK مقابل STARK

أنظمة SNARK

يعتمد Groth16 و PLONK بكثافة على MSM. فـ Groth16، على سبيل المثال، يقضي نحو 80% من وقت توليد البرهان في عمليات من نوع المنحنى الإهليلجي. أما PLONK فيوزّع الحمل بشكل أكثر توازنًا بين MSM بنسبة ~60% وعمليات كثيرات الحدود بنسبة ~30%، مما يتطلب تحسينًا متوازنًا لكليهما.

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 })
    }
}

أنظمة STARK

تهيمن على STARKs عمليات حساب كثيرات الحدود NTTs والتجزئة، مثل Poseidon أو Blake3. ونظرًا لانتظام أنماط الوصول إلى الذاكرة في STARKs، فإن ذلك يؤدي غالبًا إلى استغلال أفضل لعرض نطاق ذاكرة GPU مقارنة بأنماط الوصول العشوائي الأكثر شيوعًا في عمليات MSM الخاصة بـ 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,
        }
    }
}

تسريع zkEVM

تطرح zkEVMs تحديات فريدة بسبب تعقيد تعليمات EVM وتمثيلات الحالة الكبيرة. وتتضمن عملية توليد البرهان إنشاء قيود لكل رمز عملية (opcode) في EVM ولكل انتقال في الحالة.

إثبات الـ 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)
    }
}

يجب ترجمة كل رمز عملية، من ADD إلى SSTORE، إلى مجموعة من قيود كثيرات الحدود.

إثبات شجرة الحالة

يجب على zkEVMs عمومًا أن تثبت دائمًا صحة أنماط الوصول إلى الذاكرة وتحديثات التخزين. ويتضمن ذلك عادةً عمليات Merkle tree للتحقق من جذر الحالة

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);
}

يصبح التسريع عبر GPU مهمًا عمومًا لعمليات التجزئة المكثفة المطلوبة في التحقق من انتقالات الحالة

الخاتمة

لم تكتفِ وحدات GPU بتسريع توليد براهين ZK، بل غيّرت في الأساس طريقة توليدها عبر حل مشكلة "المُثبِت المتعطش للموارد"، فأصبح لدينا الآن طبقات Layer 2 تصل إلى النهائية خلال ثوانٍ، و DeFi خاص يعمل في بيئات الإنتاج، وهو ما لم يكن ممكنًا تمامًا من قبل. ومع ازدياد تعقيد الدوائر في تطبيقات مثل zkEVMs، لم يعد التسريع عبر GPU مجرد مسألة تحسين، بل أصبح متطلبًا أساسيًا لعمليات النشر في بيئات الإنتاج.

روابط أساسية

•••
شارك هذه المقالة:

ابقَ على اطلاع

احصل على أحدث المقالات في بريدك الوارد.