← लैब पर वापस

500 गुना तेज़ी जिसने कुछ भी नहीं गिना: WebGPU सिमुलेशन की छह चुप विफलताएँ

webgpusimulationdebugging

रन 0.04 ms प्रति स्टेप पर पूरा हुआ, उस आकार पर सिमुलेशन को जितना समय लगना चाहिए था, उससे लगभग 500 गुना तेज़। हेल्थ रिपोर्ट साफ़ थी: न NaN, न अनंत, सबसे बड़ा बल शून्य था, और हर कण के पड़ोसी शून्य थे। वह साफ़ इसलिए थी क्योंकि कुछ भी गिना ही नहीं गया था।

यह protocell-genesis में हुआ। यह एक WebGPU सिमुलेशन है जो मैंने यह जाँचने के लिए बनाया कि क्या मोटे स्तर का (coarse-grained) “आदिम सूप” खुद जुड़कर एक बंद वेसिकल बना सकता है, यानी भीतर पानी को घेरे हुए एक द्वि-परत झिल्ली। इस मॉडल में ऐसा नहीं हो पाता, और रिपॉज़िटरी में इसके मापे गए कारण हैं। सबसे बड़े रन में 483,268 कण थे। पूरे प्रोजेक्ट में छह बग ऊपर वाले की तरह बर्ताव करते रहे: कोई एक्सेप्शन नहीं, पेज पर कोई एरर नहीं, और नंबर जो भरोसेमंद या अच्छे भी लगते थे। इनमें से तीन WebGPU के लिए ख़ास हैं। ये रहे वे, हर एक कैसा दिखता था और क्या बदला गया।

1. डिवाइस की अनुमति से बड़ा बफ़र

85 σ के बॉक्स साइज़ पर Verlet पड़ोसी सूची को 5,484,474,528 बाइट चाहिए थे। डिवाइस का maxStorageBufferBindingSize 4,294,967,292 था।

जब createBuffer वैलिडेशन में फ़ेल होता है, तो WebGPU एक्सेप्शन नहीं फेंकता। वह कंसोल में एक चेतावनी छापता है और एक अमान्य बफ़र लौटा देता है, और उसके बाद उस बफ़र को इस्तेमाल करने वाले bind group पर हर dispatch छोड़ दिया जाता है। सिमुलेशन लूप स्टेप करता रहा और शून्य पढ़ता रहा: max|F| = 0, 0 अपरिमित (non-finite) मान, 0 पड़ोसी, 0.0405 ms प्रति स्टेप।

सुधार है मेमोरी देने से पहले एक जाँच। createSoup हर उस बफ़र का आकार गिनता है जो कणों की संख्या के साथ बढ़ता है, और उसकी तुलना डिवाइस की अपनी सीमाओं से करता है:

const bindingLimit = device.limits.maxStorageBufferBindingSize
const bufferLimit = device.limits.maxBufferSize
for (const [what, bytes] of candidates) {
  if (bytes > bindingLimit || bytes > bufferLimit) {
    throw new Error(
      `buffer ${what} needs ${bytes} bytes at N=${capacityN}; ` +
        `maxStorageBufferBindingSize=${bindingLimit}, ` +
        `maxBufferSize=${bufferLimit}`,
    )
  }
}

2. COPY_SRC के बिना बफ़र वापस पढ़ना

GPU से डेटा पढ़ने का मतलब है उसे एक मैप किए जा सकने वाले स्टेजिंग बफ़र में कॉपी करना। अगर स्रोत बफ़र GPUBufferUsage.COPY_SRC के बिना बना था, तो यह कॉपी एक वैलिडेशन एरर है। इससे कमांड बफ़र अमान्य हो जाता है, इसलिए submit कुछ नहीं करता। स्टेजिंग बफ़र अभी-अभी बना है, WebGPU नए बफ़र को शून्य से भरता है, और mapAsync सामान्य रूप से resolve हो जाता है। आपको ऐसे शून्य मिलते हैं जो डेटा जैसे दिखते हैं।

दो रैंडम-नंबर बफ़र में यह फ़्लैग नहीं था। डिस्क पर सभी 72 चेकपॉइंट ने रैंडम-नंबर की स्थिति सिर्फ़ शून्य के रूप में सहेजी थी, इसलिए हर फिर से शुरू किया गया रन हर कण के लिए थर्मोस्टेट को 0 से सीड करता था। हर कण पर एक जैसे Langevin शोर के साथ पूरा सिस्टम एक ही पिंड की तरह बहता है: सभी सात प्रजातियों का माध्य वर्ग विस्थापन बराबर, 62.32 σ², था, और सापेक्ष विसरण, जिससे एग्रीगेट एक-दूसरे को ढूँढ पाते हैं, ग़ायब था। सुधार के बाद स्टेप 90,000 पर सबसे बड़ा एग्रीगेट 268 से बढ़कर 588 हो गया।

usage किसी GPUBuffer की पढ़ी जा सकने वाली प्रॉपर्टी है, इसलिए इस पूरी तरह के बग की कीमत बस एक बिटवाइज़ जाँच है:

if ((src.usage & GPUBufferUsage.COPY_SRC) === 0) {
  throw new Error(
    'readBack: the buffer was created without ' +
      `GPUBufferUsage.COPY_SRC (usage=${src.usage})`,
  )
}

3. requiredLimits के बिना requestDevice()

requiredLimits के बिना माँगे गए डिवाइस को डिफ़ॉल्ट सीमाएँ मिलती हैं, वे नहीं जो एडॉप्टर सपोर्ट करता है। डिफ़ॉल्ट में हर शेडर स्टेज पर 8 स्टोरेज बफ़र की अनुमति है। बॉन्ड के कर्नल इससे आगे बढ़ गए, 'auto' लेआउट के साथ पाइपलाइन बनाना कंसोल में एक चेतावनी के साथ फ़ेल हुआ, और हर प्रभावित पास ने कुछ नहीं किया। एक भी बॉन्ड नहीं बना।

आकार की सीमाएँ भी इसी तरह फ़ेल होती हैं, बस कोड बदलने पर नहीं, बल्कि कणों की किसी संख्या पर। डिफ़ॉल्ट maxStorageBufferBindingSize 128 MiB है। हर कण के लिए 1,000 पड़ोसी स्लॉट के साथ, सूची 28,400 कणों (113.6 MB) पर समा गई और 36,400 (145.6 MB) पर चुपचाप कुछ नहीं किया।

अब डिवाइस एडॉप्टर की अपनी अधिकतम सीमाएँ माँगता है:

const { limits } = adapter
const device = await adapter.requestDevice({
  requiredLimits: {
    maxStorageBuffersPerShaderStage:
      limits.maxStorageBuffersPerShaderStage,
    maxStorageBufferBindingSize: limits.maxStorageBufferBindingSize,
    maxBufferSize: limits.maxBufferSize,
  },
})

4. NaN से बचाने वाला गार्ड जो कभी चल ही नहीं सकता था

एक सुरक्षा जाँच तब एक्सेप्शन फेंकती थी जब कण Verlet स्किन के आधे से ज़्यादा खिसक जाते थे, इसके लिए sqrt(maxDriftSq) > skin / 2 का इस्तेमाल होता था। NaN से हर तुलना false होती है। जैसे ही स्थितियाँ NaN बनीं, खिसकाव भी NaN हुआ और जाँच हर टिक पर पास होती रही। गार्ड पूरे समय चलता रहा और उस विफलता को नहीं देख सका जिसके लिए उसे लिखा गया था।

सुधार उसी टिक पर, पुरानी तुलना से पहले, हर स्थिति के IEEE-754 एक्सपोनेंट बिट को स्कैन करता है। इसकी कीमत 1,000 स्टेप के उस हिस्से पर 0.212 ms है जो 331.68 ms लेता है, यानी 0.064%। जिस मिश्रण पर रन चुपचाप बिखर रहा था, उस पर अब रन स्टेप 1,000 पर एक्सेप्शन फेंकता है और 60,750 में से 6,957 अपरिमित घटक बताता है।

5. बॉक्स का आकार बदलने से पहले के बल

सुखाना और फिर से पानी देना (रीहाइड्रेशन) बॉक्स का आकार बदलते हैं। कोड ने निर्देशांकों को फिर से स्केल किया, पड़ोसी ग्रिड और Verlet सूची को फिर से बनाया, और फिर अगला स्टेप उन बलों से लिया जो स्केल बदलने से पहले गिने गए थे। नतीजा एक ग़लत नंबर है, जिसे पकड़ना शून्य से ज़्यादा मुश्किल है।

अब एक टेस्ट इसे दोनों तरफ़ से बाँधता है। सुधार के साथ, GPU पर बल बफ़र नई गणना से 513 के बल-पैमाने पर 1.5e-4 के भीतर मेल खाता है। उसके बिना, बफ़र बिट दर बिट पुराने बलों के बराबर होता है और 313.7 से अलग होता है।

इसने एक ऐसे नतीजे को बदल दिया जो मैं पहले ही लिख चुका था। बॉक्स 30 पर, बल सही होते ही सबसे बड़ा एग्रीगेट 2.28 गुना छोटा निकला (हर तरफ़ तीन रन, रेंज एक-दूसरे पर नहीं चढ़तीं), और सुखाने के चक्रों के लिए मैंने जो असर बताया था, वह 12.5–14.5× से घटकर 8.1–8.4× रह गया। जो गार्ड 216 में से 9 बार चला था, वह सुधार के बाद 36 में से 0 बार चला।

6. गेट जो अपने इनपुट को नाम से पहचानते थे

इसका GPU से कोई लेना-देना नहीं है। परकोलेशन गेट, जो मापों को पास या फ़ेल के फ़ैसले में बदलते हैं, अपना इनपुट कोड में लिखे कैंपेन लेबल zfB54 से ढूँढते थे। अलग लेबल वाले पहले ही कैंपेन ने बिना किसी एरर के गेट को “अप्रमाणित” कर दिया। अब पंक्तियों में एक role फ़ील्ड होता है, और लेबल सिर्फ़ एक विकल्प के तौर पर बचा है।

जो पाइपलाइन अपने इनपुट को नाम से पहचानती है, वह तब चुप हो जाती है जब कोई नया प्रयोग चलाता है, और ठीक उसी समय उसके फ़ैसले की सबसे ज़्यादा ज़रूरत होती है।

सातवाँ: उपकरण शून्य पढ़ रहा था

जो टेस्ट यह जाँचता था कि पेज ड्रॉ कर रहा है या नहीं, वह WebGPU कैनवस को drawImage के ज़रिए एक 2D कैनवस में पढ़ता था। उसने 30,000 में से 0 पिक्सल बदले हुए बताए, उस पेज पर जिसने अभी-अभी 661 इंस्टेंस के साथ 119 फ़्रेम ड्रॉ किए थे। अब टेस्ट सीन के अपने इंस्टेंस काउंटर और एक असली page.screenshot() का इस्तेमाल करता है।

अब मैं किसे विफलता मानता हूँ

शून्य बल, शून्य पड़ोसी और शून्य बॉन्ड तब तक विफलता हैं, जब तक कुछ यह न दिखा दे कि भौतिकी उन्हें पैदा कर सकती है। ऐसी तेज़ी भी, जो किसी ने की ही नहीं। यही बात उस गार्ड पर लागू होती है जो कभी चला ही नहीं, जब तक मैं उसे जानबूझकर चलते हुए न देख लूँ।

WebGPU वैलिडेशन एरर कंसोल में और डिवाइस के uncapturederror इवेंट में बताता है, और एरर स्कोप (device.pushErrorScope('validation')) आपको काम के किसी हिस्से के आसपास उनका इंतज़ार करने देते हैं। यह प्रोजेक्ट स्पष्ट जाँचें इस्तेमाल करता है जो एक्सेप्शन फेंकती हैं और बफ़र का नाम और बाइट की संख्या बताती हैं। आप जो भी चुनें, डिफ़ॉल्ट एक कंसोल चेतावनी और ऐसा नतीजा है जो ठीक दिखता है।

कोड, गेट रिपोर्ट और पूरा निष्कर्ष रिपॉज़िटरी में हैं, और स्नैपशॉट गैलरी किसी भी मौजूदा ब्राउज़र में असली चेकपॉइंट दिखाती है।