D
P
0
← Semua artikel Read in English

JavaScript, DOM & Animasi Browser

`EXT_disjoint_timer_query_webgl2` di Mac Bilang SMAA Makan 10,7 ms? Waktu GPU per Pass di ANGLE Metal Tak Bisa Dipercaya

· · 9 menit baca
`EXT_disjoint_timer_query_webgl2` di Mac Bilang SMAA Makan 10,7 ms? Waktu GPU per Pass di ANGLE Metal Tak Bisa Dipercaya

Ekstensi EXT_disjoint_timer_query_webgl2 menawarkan sesuatu yang jarang bisa didapat dari JavaScript: lamanya GPU mengerjakan sepotong perintah WebGL, bukan waktu dinding yang ikut tercampur kerja CPU. Kamu membuka kueri TIME_ELAPSED_EXT, menjalankan perintah gambarnya, menutup kuerinya, lalu belakangan membaca hasilnya dalam satuan nanodetik. Di atas kertas, ini alat yang pas untuk menjawab pertanyaan pass postprocessing mana yang paling mahal.

Pada 3 Oktober 2026 saya memakai ekstensi itu untuk menimbang bobot halaman WebGL yang sedang saya bangun. Pengukurnya sebuah rig yang menjalankan Brave headless di Mac M4, dan salah satu yang diukurnya adalah waktu GPU per pass lewat ekstensi tadi. Halamannya memakai three.js 0.186.1 dengan pustaka postprocessing 6.39.5, jadi yang saya sebut pass di tulisan ini adalah pass di dalam composer milik pustaka itu.

Kueri yang membungkus tiap pass memberi angka yang mustahil. Tulisan ini tentang kenapa saya berhenti memercayai angka itu, dan cara mengukur yang akhirnya saya pegang.

Angka-angka yang saling membantah

Rig membungkus metode render milik tiap pass dengan kueri TIME_ELAPSED_EXT, lalu secara terpisah membungkus render milik composer secara utuh dengan label total. Hasil dari dua pembungkus itu tidak masuk akal dari beberapa arah sekaligus.

Pertama, pass SMAA tercatat 10,7 ms, hampir sama dengan pass efek berat yang berjalan sebelum dia. Untuk anti-aliasing pascaproses, angka sebesar itu sudah janggal.

Kedua, kalau angka per pass dijumlahkan, hasilnya melebihi total composer yang diukur tersendiri. Waktu sebuah pass adalah bagian dari waktu seluruh composer, jadi jumlah bagian yang lebih besar dari keseluruhannya berarti setidaknya salah satu angka tidak mengukur apa yang saya kira dia ukur.

Ketiga, total composer bertahan di 13 sampai 14 ms, baik saat kanvas dirender di DPR 1 maupun DPR 1,5, selama kerjanya masih di bawah anggaran 16,7 ms. Anggaran itu jatah satu bingkai di 60 fps, yaitu 1000 ms dibagi 60. Padahal pada ukuran CSS yang sama, menaikkan DPR dari 1 ke 1,5 berarti jumlah piksel kanvas naik 1,5 × 1,5 = 2,25 kali, sementara pass postprocessing bekerja per piksel. Catatan saya merumuskannya pendek: total mentok di sekitar 14 ms walaupun kerjanya jauh lebih ringan.

Keempat, angkanya baru jujur ketika GPU kelebihan beban. Contohnya 43 ms, saat detail terrain per fragmen ikut dirender di DPR 1,5. Artinya kueri ini bisa dipercaya justru ketika bingkainya sudah melewati anggaran, padahal untuk menimbang bobot halaman, yang ingin saya ketahui adalah seberapa jauh di bawah anggaran sebuah adegan berjalan. Di wilayah itu, total dari kueri memberi angka yang hampir sama untuk kerja yang berbeda.

Pembungkusnya tidak ceroboh

Kecurigaan wajar jatuh ke instrumennya lebih dulu, karena ada beberapa kesalahan yang lazim dengan ekstensi ini. Rig tadi sudah menghindari yang paling jelas.

Di WebGL2 hanya boleh ada satu kueri TIME_ELAPSED_EXT yang aktif pada satu waktu, sementara render milik composer memanggil render milik pass-pass di dalamnya. Kalau composer dan pass-nya diukur di bingkai yang sama, kuerinya akan bersarang. Rig menghindarinya dengan menggilir mode ukur per bingkai di antara tiga mode, yaitu per pass, satu unsur adegan, dan total. Pembungkusnya juga tidak membuka kueri baru kalau sudah ada kueri yang aktif, atau kalau mode bingkai itu bukan modenya.

Hasil kueri hanya disimpan kalau GPU_DISJOINT_EXT tidak menyala. Tanda itu menandakan ada kejadian di GPU yang membuat hasil pengukuran waktu tidak bisa dipegang, jadi sampel di rentang itu dibuang. Satu ukuran bawaannya 90 bingkai yang dibagi ke tiga mode tadi, kira-kira 30 bingkai per mode, dan sampel tiap nama diringkas menjadi jumlah, median, dan p90. Bentuknya kira-kira begini, disederhanakan dan dengan nama variabel netral:

// Disederhanakan dari pembungkus di rig ukur; nama variabelnya netral.
const ext = gl.getExtension('EXT_disjoint_timer_query_webgl2');
const modes = ['pass', 'element', 'total']; // tiap bingkai: currentMode = modes[frame % 3]
let active = null, currentMode = 'pass';
const pending = [], samples = {};
 
function wrap(obj, label, mode) {
  const original = obj.render;
  obj.render = function (...args) {
    // jangan buka kueri kedua, dan ukur hanya di bingkai milik mode ini
    if (!ext || active || currentMode !== mode) return original.apply(this, args);
    const q = gl.createQuery();
    gl.beginQuery(ext.TIME_ELAPSED_EXT, q);
    active = label;
    try {
      return original.apply(this, args);
    } finally {
      gl.endQuery(ext.TIME_ELAPSED_EXT);
      active = null;
      pending.push({ q, label });
    }
  };
}
 
composer.passes.forEach((pass, i) => wrap(pass, `${i}-${pass.name}`, 'pass'));
wrap(composer, 'total', 'total');
 
// belakangan, saat hasil satu kueri { q, label } dibaca:
const disjoint = gl.getParameter(ext.GPU_DISJOINT_EXT);
if (!disjoint) (samples[label] ||= []).push(gl.getQueryParameter(q, gl.QUERY_RESULT) / 1e6); // ns ke ms

Angka-angka di bagian sebelumnya datang dari pembungkus seperti ini. Jadi angka yang meleset tadi tidak bisa dijelaskan dengan kueri yang bersarang, sampel disjoint yang ikut tersimpan, atau satu sampel yang kebetulan buruk.

Dugaan penyebabnya tetap dugaan

Brave adalah browser berbasis Chromium, dan di Mac, Chromium menjalankan WebGL lewat ANGLE, lapisan yang menerjemahkan panggilan WebGL ke API grafis milik sistem, dalam hal ini Metal. Dugaan yang saya catat: ANGLE Metal mengukur per command buffer, dan GPU Apple berbasis tile, sehingga batas antara satu pass dan pass berikutnya tidak bisa dicap waktu. Kalau dugaan itu benar, angka per pass tidak benar-benar mengukur pass-nya sendiri, dan itu cocok dengan SMAA yang semahal pass berat serta jumlah bagian yang melampaui keseluruhan.

Tapi itu tetap dugaan, dan catatan saya tidak memuat buktinya. Saya juga tidak punya penjelasan yang terbukti untuk total yang berhenti di sekitar 14 ms. Yang tertulis di kepala rig hanya kesimpulan praktisnya: di GPU Apple (ANGLE Metal), kueri waktu per pass tidak bisa dipercaya, pakai total, yaitu seluruh composer. Paruh kedua catatan itu tidak sejalan dengan angka di atas, karena total dari kueri yang sama juga mentok di 13 sampai 14 ms selama kerjanya di bawah anggaran. Untuk menimbang bobot halaman, saya butuh cara yang tidak bergantung pada kueri GPU sama sekali.

Empat puluh render dan satu readPixels

Cara yang saya catat sebagai terbukti tidak memakai kueri. Di halaman, composer dirender 40 kali berturut-turut tanpa menunggu requestAnimationFrame, lalu satu gl.readPixels(0, 0, 1, 1, ...) dipanggil sebagai sinkron, dan waktu dindingnya dibagi 40. Rig menamai pengukuran ini beban nyata. Sebelum stopwatch dimulai, composer dirender sekali dan readPixels satu piksel dipanggil sekali. Sesudah itu baru 40 render dalam satu loop, satu readPixels lagi, lalu selisih performance.now() dibagi 40. Versi yang disederhanakan:

// Disederhanakan dari pengukuran "beban nyata" di rig ukur; nama variabelnya netral.
const buf = new Uint8Array(4); // satu piksel RGBA
 
composer.render(1 / 60);
gl.readPixels(0, 0, 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, buf); // sinkron sebelum stopwatch
const t0 = performance.now();
 
for (let i = 0; i < 40; i++) composer.render(1 / 60); // tanpa menunggu rAF
gl.readPixels(0, 0, 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, buf); // tunggu GPU menyelesaikan semuanya
const msPerFrame = (performance.now() - t0) / 40;

Kuncinya ada di sifat readPixels. Membaca piksel ke memori JavaScript bersifat sinkron: browser harus menunggu perintah gambar sebelumnya selesai sebelum bisa menyalin hasilnya, jadi satu piksel sudah cukup sebagai titik sinkron dan biaya salinnya bisa diabaikan. readPixels yang pertama memastikan antrean GPU sudah kosong ketika stopwatch mulai, sehingga sisa kerja bingkai sebelumnya tidak ikut terhitung. Empat puluh render tanpa menunggu rAF membuat GPU terus diberi kerja tanpa jeda vsync, dan biaya satu sinkron di akhir terbagi rata ke 40 bingkai. Angka yang dihasilkan tetap waktu dinding, jadi dia ikut mencakup kerja CPU yang menyusun perintah, bukan waktu GPU murni. Untuk menimbang bobot halaman, justru itu yang saya perlukan.

Hasilnya masuk akal dan mengikuti jumlah piksel. Di titik ukur yang di komentar kalibrasi kode tercatat sebagai bingkai terberat, beban nyatanya 10,1 ms di DPR 1 dan 13,8 ms di DPR 1,5. Rasionya, 13,8 dibagi 10,1, kira-kira 1,37, sementara jumlah pikselnya naik 2,25 kali. Kenaikannya tidak sebanding lurus, tapi angkanya bergerak searah dengan jumlah piksel, sesuatu yang tidak dilakukan total dari kueri. Dua angka itu juga sama-sama di bawah anggaran 16,7 ms, rentang yang membuat total dari kueri berhenti di 13 sampai 14 ms.

Supaya angka itu berarti, rig menyiapkan halamannya lebih dulu. Mode hemat saat diam dimatikan selama ukur, sehingga tiap bingkai benar-benar dirender. Resolusi dinamis halaman dimatikan dengan mengganti fungsinya menjadi fungsi kosong, karena resolusi dinamis memang bertugas mengubah resolusi mengikuti beban, dan pengukuran yang berjalan sambil resolusinya berubah tidak lagi mengukur satu kondisi yang sama. Untuk desktop, DPR kanvas secara bawaan diukur di 1 dan 1,5, dan 1,5 adalah batas atas DPR kanvas halaman ini di tingkat kualitas teratas. Sesudah DPR diganti, rig menunggu 700 ms supaya program baru selesai dikompilasi di luar sampel, dan di tiap titik rig menahan 1,8 detik supaya akumulasi temporal efek di adegan dan redaman eksposurnya mapan dulu. Brave-nya sendiri diluncurkan headless dengan flag --enable-gpu dan --ignore-gpu-blocklist.

Biaya per unsur: nyalakan, matikan, bandingkan

Untuk biaya per unsur, aturan yang saya pegang sekarang adalah jangan percaya pembagian per pass. Gantinya, beban total dibandingkan antara saat unsur itu dinyalakan dan saat dimatikan. Halaman ini menaruh instans utamanya sebagai properti di window, dan rig tidak mengubah kode adegannya sama sekali: semua sakelar dipasang dari luar lewat properti itu.

Saya perlu jujur di bagian ini. Aturannya sudah ada, tetapi untuk rig ini belum ada angka hasil sakelar per unsur yang diukur dengan beban nyata.

Yang tercatat di kode adalah angka untuk satu unsur yang akhirnya dibuang. Komentar di kode terrain menyebut detail terrain per fragmen dibuang pada 3 Oktober 2026, karena di DPR 1,5 detail itu menaikkan bingkai dari 14 ke 43 ms, dan hasilnya terlihat seperti tekstur game. Komentar itu tidak menyebut dua angka tersebut datang dari cara ukur yang mana. Catatan saya menjadikan 43 ms itu contoh angka kueri yang jujur, karena sudah jauh di atas anggaran. Angka 14 ms tidak punya keterangan serupa, dan dia jatuh tepat di kisaran mentok tadi, jadi saya tidak memakainya sebagai harga bingkai tanpa detail. Sesudah pengukuran hari itu, composer-nya diringkas menjadi RenderPass, satu EffectPass, lalu SMAA, tanpa pass normal dan tanpa detail terrain.

Angka yang masih saya pakai