WEBGPU GPU-COMPUTING COMPUTE-SHADERS WGSL DATA-PROCESSING PERFORMANCE-OPTIMIZATION BROWSER-API WEB-DEVELOPMENT PARALLEL-COMPUTING JAVASCRIPT TYPESCRIPT

WebGPU Compute Shaders: Memaksimalkan Kekuatan GPU untuk Pemrosesan Data di Aplikasi Web Anda

⏱️ 21 menit baca
👨‍💻

WebGPU Compute Shaders: Memaksimalkan Kekuatan GPU untuk Pemrosesan Data di Aplikasi Web Anda

1. Pendahuluan

Pernahkah Anda menghadapi skenario di mana aplikasi web Anda perlu melakukan komputasi berat pada sejumlah besar data? Mungkin analisis data real-time, simulasi fisika sederhana, atau bahkan pemrosesan gambar yang kompleks di sisi klien? Seringkali, tugas-tugas ini membebani main thread JavaScript, menyebabkan UI menjadi lambat atau bahkan freeze.

Secara tradisional, kita mengandalkan Web Workers untuk memindahkan komputasi ke thread terpisah. Namun, ada satu sumber daya komputasi di setiap perangkat pengguna yang sering terlupakan oleh developer web: GPU (Graphics Processing Unit). GPU dirancang khusus untuk melakukan banyak operasi secara paralel, menjadikannya sangat efisien untuk tugas-tugas yang dapat dipecah menjadi banyak bagian kecil yang independen.

Di sinilah WebGPU hadir sebagai game-changer. WebGPU bukan hanya tentang grafis 3D yang indah. Ia juga menyediakan akses ke kemampuan komputasi umum GPU melalui Compute Shaders. Ini berarti Anda bisa memanfaatkan kekuatan paralel GPU untuk pemrosesan data non-grafis yang intensif, langsung di browser Anda!

Artikel ini akan membawa Anda menyelami dunia WebGPU Compute Shaders, menjelaskan konsep dasarnya, dan menunjukkan contoh konkret bagaimana Anda bisa menggunakannya untuk mempercepat pemrosesan data di aplikasi web Anda. Bersiaplah untuk membuka potensi tersembunyi perangkat pengguna Anda!

2. Mengapa GPU untuk Pemrosesan Data?

Bayangkan Anda memiliki sebuah array berisi jutaan angka, dan Anda ingin menjumlahkan setiap elemennya. Jika Anda melakukannya dengan CPU secara sekuensial (satu per satu), ini akan memakan waktu. CPU memang cepat, tetapi ia lebih unggul dalam tugas sekuensial dan logika bercabang.

GPU, di sisi lain, memiliki arsitektur yang sangat berbeda. Ia memiliki ribuan core kecil yang dirancang untuk melakukan operasi yang sama pada banyak piece data secara bersamaan. Ini disebut komputasi paralel.

💡 Analogi: Anggaplah CPU adalah seorang koki ahli yang bisa membuat masakan kompleks dengan urutan langkah yang presisi. GPU adalah ribuan asisten koki yang masing-masing hanya tahu cara memotong satu jenis sayuran, tetapi mereka bisa melakukannya secara bersamaan pada ribuan sayuran berbeda dalam waktu yang sangat singkat.

Untuk tugas-tugas seperti:

GPU bisa memberikan peningkatan performa yang dramatis dibandingkan CPU, terutama untuk set data yang besar.

3. Memahami Compute Shaders dan WGSL

Jadi, bagaimana kita “memberi tahu” GPU untuk melakukan komputasi ini? Jawabannya adalah Compute Shaders.

📌 Compute Shader: Sebuah program kecil yang berjalan di GPU, dirancang untuk melakukan tugas komputasi umum pada data. Berbeda dengan vertex atau fragment shaders yang fokus pada rendering grafis, compute shaders beroperasi pada data arbitrer yang tersimpan dalam buffer di memori GPU.

Untuk menulis compute shaders, kita menggunakan WGSL (WebGPU Shading Language). WGSL adalah bahasa shading modern yang mirip dengan Rust, dirancang khusus untuk WebGPU.

Berikut adalah beberapa konsep penting dalam WGSL untuk compute shaders:

4. Langkah-langkah Membangun Pipeline Komputasi WebGPU

Mari kita buat contoh praktis: menjumlahkan semua elemen dalam sebuah array menggunakan WebGPU Compute Shader.

4.1. Inisialisasi WebGPU

Langkah pertama adalah mendapatkan GPUAdapter dan GPUDevice. GPUAdapter merepresentasikan GPU fisik atau virtual di sistem, sedangkan GPUDevice adalah antarmuka logis untuk berinteraksi dengannya.

async function initWebGPU() {
    if (!navigator.gpu) {
        throw new Error("WebGPU tidak didukung di browser Anda.");
    }

    const adapter = await navigator.gpu.requestAdapter();
    if (!adapter) {
        throw new Error("Tidak dapat menemukan GPU adapter.");
    }

    const device = await adapter.requestDevice();
    return device;
}

4.2. Persiapan Data (Input dan Output)

Kita perlu membuat data input dan buffer untuk menyimpan hasilnya di memori GPU. Data CPU harus ditransfer ke GPU.

async function runComputeShader() {
    const device = await initWebGPU();

    const data = new Float32Array([1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0]);
    const numElements = data.length;

    // Buffer input
    const inputBuffer = device.createBuffer({
        size: data.byteLength,
        usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
        mappedAtCreation: true,
    });
    new Float32Array(inputBuffer.getMappedRange()).set(data);
    inputBuffer.unmap();

    // Buffer output (untuk menyimpan hasil penjumlahan)
    // Karena kita akan menjumlahkan semua elemen, hasilnya hanya satu angka.
    const outputBuffer = device.createBuffer({
        size: Float32Array.BYTES_PER_ELEMENT, // Satu elemen float untuk hasil
        usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC,
    });

    // Buffer untuk membaca hasil kembali ke CPU
    const readBuffer = device.createBuffer({
        size: Float32Array.BYTES_PER_ELEMENT,
        usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ,
    });

4.3. Menulis Compute Shader (WGSL)

Sekarang, mari kita tulis compute shader yang akan menjumlahkan elemen-elemen array. Untuk kesederhanaan, kita akan melakukan penjumlahan secara berpasangan dalam setiap workgroup.

// sum_array.wgsl
@group(0) @binding(0) var<storage, read> input: array<f32>;
@group(0) @binding(1) var<storage, read_write> output: array<f32>;

@compute @workgroup_size(64)
fn main(@builtin(global_invocation_id) global_id: vec3<u32>) {
    // global_id.x adalah indeks unik untuk setiap thread
    let i = global_id.x;

    // Pastikan kita tidak keluar dari batas array
    if (i >= arrayLength(&input)) {
        return;
    }

    // Untuk contoh sederhana ini, kita asumsikan output hanya satu elemen
    // dan thread pertama yang akan menulis hasil akhir.
    // Ini BUKAN cara paling efisien untuk penjumlahan besar,
    // tapi menunjukkan dasar compute shader.
    // Untuk penjumlahan besar, butuh algoritma reduksi yang lebih kompleks.

    // Contoh ini akan menjumlahkan elemen pada indeks 'i' ke output[0]
    // Ini akan menyebabkan race condition jika banyak thread mencoba menulis output[0]
    // secara bersamaan. Untuk demonstrasi, kita akan membiarkan ini
    // dan fokus pada bagaimana data diakses.

    // Yang lebih realistis, setiap thread bisa memproses sekelompok data
    // dan menyimpan hasil parsial.
    // Untuk penjumlahan seluruh array, kita perlu strategi "reduksi" yang lebih canggih.

    // Mari kita ubah contoh ini menjadi sesuatu yang lebih sederhana dan aman:
    // Setiap thread menggandakan elemen pada indeksnya.
    // Atau, kita bisa membuat output array yang ukurannya sama dengan input
    // dan setiap thread menulis ke indeks yang sesuai.

    // Contoh yang lebih baik: Setiap thread mengkalikan elemen input dengan 2
    // dan menyimpannya ke buffer output yang ukurannya sama.
    // Ini menunjukkan pemrosesan paralel yang jelas tanpa race condition.

    // Kita akan ubah outputBuffer menjadi array dengan ukuran yang sama dengan inputBuffer
    // di JavaScript.

    output[i] = input[i] * 2.0; // Setiap elemen dikalikan dua
}

⚠️ Penting: Contoh penjumlahan di atas sangat sederhana dan akan menyebabkan race condition jika banyak thread mencoba menulis ke lokasi memori yang sama (output[0]). Untuk penjumlahan seluruh array yang benar, diperlukan algoritma reduksi yang lebih canggih yang memanfaatkan shared memory dalam workgroup atau beberapa pass komputasi.

Untuk artikel ini, mari kita gunakan contoh yang lebih jelas menunjukkan pemrosesan paralel tanpa race condition: setiap elemen dalam array input akan dikalikan dua dan disimpan ke array output. Ini akan menunjukkan bagaimana setiap thread memproses bagian datanya secara independen.

// multiply_by_two.wgsl
@group(0) @binding(0) var<storage, read> input: array<f32>;
@group(0) @binding(1) var<storage, read_write> output: array<f32>;

@compute @workgroup_size(64) // Setiap workgroup memiliki 64 thread
fn main(@builtin(global_invocation_id) global_id: vec3<u32>) {
    let i = global_id.x; // Indeks global untuk thread ini

    // Pastikan tidak melampaui batas array
    if (i >= arrayLength(&input)) {
        return;
    }

    output[i] = input[i] * 2.0; // Setiap thread mengkalikan elemennya dengan 2
}

Revisi outputBuffer di JavaScript agar memiliki ukuran yang sama dengan inputBuffer:

    // ... (kode sebelumnya) ...

    // Buffer output (untuk menyimpan hasil perkalian)
    const outputBuffer = device.createBuffer({
        size: data.byteLength, // Ukuran sama dengan input
        usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC,
    });

    // Buffer untuk membaca hasil kembali ke CPU
    const readBuffer = device.createBuffer({
        size: data.byteLength, // Ukuran sama dengan input
        usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ,
    });

4.4. Membuat Compute Pipeline dan Binding Group

Sekarang kita perlu mengkompilasi shader dan membuat pipeline yang akan menjalankannya.

    // ... (kode sebelumnya) ...

    const shaderModule = device.createShaderModule({
        code: `
            @group(0) @binding(0) var<storage, read> input: array<f32>;
            @group(0) @binding(1) var<storage, read_write> output: array<f32>;

            @compute @workgroup_size(64)
            fn main(@builtin(global_invocation_id) global_id: vec3<u32>) {
                let i = global_id.x;
                if (i >= arrayLength(&input)) {
                    return;
                }
                output[i] = input[i] * 2.0;
            }
        `,
    });

    const computePipeline = device.createComputePipeline({
        layout: 'auto',
        compute: {
            module: shaderModule,
            entryPoint: 'main',
        },
    });

    const bindGroup = device.createBindGroup({
        layout: computePipeline.getBindGroupLayout(0),
        entries: [
            {
                binding: 0,
                resource: { buffer: inputBuffer },
            },
            {
                binding: 1,
                resource: { buffer: outputBuffer },
            },
        ],
    });

4.5. Encoding Perintah dan Dispatch

Kita akan membuat GPUCommandEncoder untuk merekam perintah yang akan dikirim ke GPU.

    // ... (kode sebelumnya) ...

    const commandEncoder = device.createCommandEncoder();
    const passEncoder = commandEncoder.beginComputePass();
    passEncoder.setPipeline(computePipeline);
    passEncoder.setBindGroup(0, bindGroup);

    // Dispatch workgroups
    // Kita perlu menghitung berapa banyak workgroup yang dibutuhkan.
    // Jumlah elemen / workgroup_size (dibulatkan ke atas)
    const workgroupCount = Math.ceil(numElements / 64);
    passEncoder.dispatchWorkgroups(workgroupCount);
    passEncoder.end();

    // Salin hasil dari outputBuffer ke readBuffer agar bisa dibaca CPU
    commandEncoder.copyBufferToBuffer(
        outputBuffer,
        0, // offset sumber
        readBuffer,
        0, // offset tujuan
        outputBuffer.size // ukuran
    );

    const commandBuffer = commandEncoder.finish();
    device.queue.submit([commandBuffer]);

4.6. Membaca Hasil Kembali ke CPU

Setelah GPU selesai, kita perlu membaca data dari readBuffer kembali ke memori CPU.

    // ... (kode sebelumnya) ...

    await readBuffer.mapAsync(GPUMapMode.READ);
    const result = new Float32Array(readBuffer.getMappedRange());
    console.log("Hasil dari GPU:", result); // Output: [2, 4, 6, 8, 10, 12, 14, 16, 18, 20]
    readBuffer.unmap();

    device.destroy(); // Penting untuk membersihkan resource GPU
}

runComputeShader().catch(console.error);

Contoh Lengkap (HTML + JavaScript):

<!DOCTYPE html>
<html>
<head>
    <title>WebGPU Compute Shader Example</title>
</head>
<body>
    <h1>WebGPU Compute Shader: Perkalian Array</h1>
    <p>Lihat console untuk hasilnya.</p>
    <script type="module">
        async function initWebGPU() {
            if (!navigator.gpu) {
                throw new Error("WebGPU tidak didukung di browser Anda.");
            }

            const adapter = await navigator.gpu.requestAdapter();
            if (!adapter) {
                throw new Error("Tidak dapat menemukan GPU adapter.");
            }

            const device = await adapter.requestDevice();
            return device;
        }

        async function runComputeShader() {
            const device = await initWebGPU();

            const data = new Float32Array([1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0]);
            const numElements = data.length;

            // Buffer input
            const inputBuffer = device.createBuffer({
                size: data.byteLength,
                usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
                mappedAtCreation: true,
            });
            new Float32Array(inputBuffer.getMappedRange()).set(data);
            inputBuffer.unmap();

            // Buffer output (untuk menyimpan hasil perkalian)
            const outputBuffer = device.createBuffer({
                size: data.byteLength, // Ukuran sama dengan input
                usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC,
            });

            // Buffer untuk membaca hasil kembali ke CPU
            const readBuffer = device.createBuffer({
                size: data.byteLength,
                usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ,
            });

            const shaderModule = device.createShaderModule({
                code: `
                    @group(0) @binding(0) var<storage, read> input: array<f32>;
                    @group(0) @binding(1) var<storage, read_write> output: array<f32>;

                    @compute @workgroup_size(64) // Setiap workgroup memiliki 64 thread
                    fn main(@builtin(global_invocation_id) global_id: vec3<u32>) {
                        let i = global_id.x; // Indeks global untuk thread ini

                        // Pastikan tidak melampaui batas array
                        if (i >= arrayLength(&input)) {
                            return;
                        }

                        output[i] = input[i] * 2.0; // Setiap thread mengkalikan elemennya dengan 2
                    }
                `,
            });

            const computePipeline = device.createComputePipeline({
                layout: 'auto',
                compute: {
                    module: shaderModule,
                    entryPoint: 'main',
                },
            });

            const bindGroup = device.createBindGroup({
                layout: computePipeline.getBindGroupLayout(0),
                entries: [
                    {
                        binding: 0,
                        resource: { buffer: inputBuffer },
                    },
                    {
                        binding: 1,
                        resource: { buffer: outputBuffer },
                    },
                ],
            });

            const commandEncoder = device.createCommandEncoder();
            const passEncoder = commandEncoder.beginComputePass();
            passEncoder.setPipeline(computePipeline);
            passEncoder.setBindGroup(0, bindGroup);

            // Dispatch workgroups
            const workgroupCount = Math.ceil(numElements / 64);
            passEncoder.dispatchWorkgroups(workgroupCount);
            passEncoder.end();

            // Salin hasil dari outputBuffer ke readBuffer agar bisa dibaca CPU
            commandEncoder.copyBufferToBuffer(
                outputBuffer,
                0, // offset sumber
                readBuffer,
                0, // offset tujuan
                outputBuffer.size // ukuran
            );

            const commandBuffer = commandEncoder.finish();
            device.queue.submit([commandBuffer]);

            await readBuffer.mapAsync(GPUMapMode.READ);
            const result = new Float32Array(readBuffer.getMappedRange());
            console.log("Data awal:", data);
            console.log("Hasil dari GPU (setiap elemen dikalikan 2):", result);
            readBuffer.unmap();

            device.destroy(); // Penting untuk membersihkan resource GPU
        }

        runComputeShader().catch(error => {
            console.error(error);
            document.body.innerHTML += `<p style="color: red;">Error: ${error.message}. Pastikan browser Anda mendukung WebGPU dan Anda mengaktifkannya (jika perlu).</p>`;
        });
    </script>
</body>
</html>

5. Manfaat dan Kasus Penggunaan Nyata

Mengapa Anda harus repot-repot menggunakan WebGPU Compute Shaders?

🎯 Manfaat Utama:

🌐 Kasus Penggunaan Nyata:

6. Tips, Best Practices, dan Pertimbangan

Menggunakan WebGPU Compute Shaders memang powerful, tetapi ada beberapa hal yang perlu diperhatikan: