← Kembali ke Lab

Percepatan 500× yang tidak menghitung apa pun: enam kegagalan senyap dalam simulasi WebGPU

webgpusimulationdebugging

Run itu selesai dengan 0,04 ms per langkah, kira-kira 500 kali lebih cepat daripada seharusnya untuk simulasi seukuran itu. Laporan kesehatannya bersih: tidak ada NaN, tidak ada tak hingga, gaya terbesar nol, dan setiap partikel punya nol tetangga. Laporannya bersih karena tidak ada yang dihitung.

Ini terjadi di protocell-genesis, simulasi WebGPU yang saya buat untuk menguji apakah “sup purba” berbutir kasar (coarse-grained) bisa merakit dirinya sendiri menjadi vesikel tertutup, yaitu membran lapis ganda dengan air terperangkap di dalamnya. Dalam model ini hal itu tidak bisa, dan repositorinya memuat alasan-alasan yang terukur. Run terbesar punya 483.268 partikel. Sepanjang proyek, enam bug berperilaku seperti yang di atas: tanpa exception, tanpa error di halaman, dan angkanya tampak masuk akal atau bahkan bagus. Tiga di antaranya khas WebGPU. Inilah mereka, dengan rupa masing-masing dan apa yang diubah.

1. Buffer yang lebih besar daripada yang diizinkan perangkat

Pada ukuran kotak 85 σ, daftar tetangga Verlet membutuhkan 5.484.474.528 byte. maxStorageBufferBindingSize milik perangkat adalah 4.294.967.292.

WebGPU tidak melempar exception ketika createBuffer gagal validasi. Ia mencetak peringatan ke konsol dan mengembalikan buffer yang tidak valid, dan setiap dispatch berikutnya pada bind group yang memakainya dibuang. Loop simulasi terus melangkah dan terus membaca balik nol: max|F| = 0, 0 nilai non-finite, 0 tetangga, 0,0405 ms per langkah.

Perbaikannya adalah pemeriksaan sebelum alokasi. createSoup menghitung setiap buffer yang ukurannya bertambah seiring jumlah partikel dan membandingkannya dengan batas milik perangkat itu sendiri:

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. Membaca balik buffer tanpa COPY_SRC

Membaca data dari GPU berarti menyalinnya ke buffer staging yang bisa di-map. Kalau buffer sumber dibuat tanpa GPUBufferUsage.COPY_SRC, penyalinan itu adalah error validasi. Error itu membatalkan command buffer, sehingga submit tidak melakukan apa-apa. Buffer staging baru saja dibuat, WebGPU mengisi buffer baru dengan nol, dan mapAsync selesai seperti biasa. Anda mendapat nol yang tampak seperti data.

Dua buffer bilangan acak tidak punya flag itu. Ke-72 checkpoint di disk semuanya menyimpan status bilangan acak berupa nol murni, sehingga setiap run yang dilanjutkan memberi termostat seed 0 untuk setiap partikel. Dengan derau Langevin yang identik pada setiap partikel, seluruh sistem melayang sebagai satu benda: perpindahan kuadrat rata-rata sama-sama 62,32 σ² untuk ketujuh spesies, dan difusi relatif, yang memungkinkan agregat saling menemukan, lenyap. Setelah perbaikan, agregat terbesar pada langkah 90.000 tumbuh dari 268 menjadi 588.

usage adalah properti GPUBuffer yang bisa dibaca, jadi seluruh kelas bug ini cukup ditangkal dengan satu uji bitwise:

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

3. requestDevice() tanpa requiredLimits

Perangkat yang diminta tanpa requiredLimits mendapat batas bawaan, bukan batas yang didukung adapter. Bawaannya mengizinkan 8 storage buffer per tahap shader. Kernel ikatan tumbuh melewati angka itu, pembuatan pipeline dengan layout 'auto' gagal hanya dengan peringatan di konsol, dan setiap pass yang terdampak tidak melakukan apa-apa. Tidak ada satu ikatan pun yang terbentuk.

Batas ukuran gagal dengan cara yang sama, hanya saja pemicunya jumlah partikel, bukan perubahan kode. maxStorageBufferBindingSize bawaan adalah 128 MiB. Dengan 1.000 slot tetangga per partikel, daftarnya muat pada 28.400 partikel (113,6 MB) dan diam-diam tidak melakukan apa-apa pada 36.400 (145,6 MB).

Sekarang perangkat meminta nilai maksimum milik adapter itu sendiri:

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

4. Penjaga NaN yang tidak mungkin terpicu

Sebuah pemeriksaan keamanan melempar exception ketika partikel bergeser lebih jauh dari setengah kulit Verlet (skin), dengan sqrt(maxDriftSq) > skin / 2. Setiap perbandingan dengan NaN bernilai false. Begitu posisi menjadi NaN, pergeserannya NaN dan pemeriksaan lolos di setiap tick. Penjaga itu berjalan sepanjang waktu dan tidak bisa melihat kegagalan yang menjadi alasan ia ditulis.

Perbaikannya memindai bit eksponen IEEE-754 dari setiap posisi pada tick yang sama, sebelum perbandingan lama. Biayanya 0,212 ms per potongan 1.000 langkah yang memakan 331,68 ms, yaitu 0,064%. Pada komposisi yang diam-diam menyimpang, run kini melempar exception pada langkah 1.000 dan menyebut 6.957 komponen non-finite dari 60.750.

5. Gaya dari sebelum ukuran kotak berubah

Pengeringan dan rehidrasi mengubah ukuran kotak. Kodenya menskalakan ulang koordinat, membangun ulang grid tetangga dan daftar Verlet, lalu mengambil langkah berikutnya dengan gaya yang dihitung sebelum penskalaan ulang. Hasilnya angka yang salah, dan itu lebih sulit dikenali daripada nol.

Sekarang sebuah tes mengikatnya dari dua sisi. Dengan perbaikan, buffer gaya di GPU cocok dengan perhitungan baru dalam selisih 1,5e-4 pada skala gaya 513. Tanpa perbaikan, buffer itu sama persis dengan gaya lama, bit demi bit, dan meleset 313,7.

Ini menggeser hasil yang sudah saya tuliskan. Pada kotak 30, agregat terbesar ternyata 2,28 kali lebih kecil setelah gayanya benar (tiga run di masing-masing sisi, rentangnya tidak tumpang tindih), dan efek yang saya laporkan untuk siklus pengeringan menyusut dari 12,5–14,5× menjadi 8,1–8,4×. Penjaga yang sebelumnya terpicu 9 kali dari 216 terpicu 0 kali dari 36 setelah perbaikan.

6. Gerbang yang mengenali masukannya dari nama

Yang ini tidak ada hubungannya dengan GPU. Gerbang perkolasi, yang mengubah hasil pengukuran menjadi vonis lulus atau gagal, menemukan masukannya lewat label kampanye zfB54 yang ditulis langsung di kode. Kampanye pertama dengan label berbeda membalik gerbang menjadi “belum terbukti” tanpa error. Kini setiap baris membawa field role, dan label hanya menjadi cadangan.

Pipeline yang mengenali masukannya dari nama akan diam ketika seseorang menjalankan eksperimen baru, padahal justru saat itulah vonisnya penting.

Yang ketujuh: alat ukurnya membaca nol

Tes yang memeriksa apakah halaman sedang menggambar membaca canvas WebGPU lewat drawImage ke sebuah canvas 2D. Tes itu melaporkan 0 piksel berubah dari 30.000, pada halaman yang baru saja menggambar 119 frame dengan 661 instance. Kini tes memakai penghitung instance milik scene itu sendiri dan page.screenshot() yang sungguhan.

Apa yang sekarang saya anggap kegagalan

Gaya nol, tetangga nol, dan ikatan nol adalah kegagalan sampai ada yang menunjukkan bahwa fisikanya memang bisa menghasilkan itu. Begitu juga percepatan yang tidak dibuat siapa pun. Hal yang sama berlaku untuk penjaga yang belum pernah terpicu, sampai saya melihatnya terpicu dengan sengaja.

WebGPU melaporkan error validasi ke konsol dan ke event uncapturederror milik perangkat, dan error scope (device.pushErrorScope('validation')) memungkinkan Anda menunggunya di sekitar satu blok pekerjaan. Proyek ini memakai pemeriksaan eksplisit yang melempar exception dan menyebutkan buffer serta jumlah byte-nya. Apa pun yang Anda pilih, bawaannya adalah peringatan di konsol dan hasil yang tampak baik-baik saja.

Kode, laporan gerbang, dan vonis lengkapnya ada di repositori, dan galeri snapshot merender checkpoint yang asli di browser modern mana pun.