В этой статье на примерах рассматривается экспериментальный API WebGPU, который поможет вам приступить к выполнению параллельных вычислений с использованием графического процессора.
Опубликовано: 28 августа 2019 г., Последнее обновление: 12 августа 2025 г.
Фон
Как вы, возможно, уже знаете, графический процессор (GPU) — это электронная подсистема компьютера, изначально предназначенная для обработки графики. Однако за последние 10 лет он эволюционировал в сторону более гибкой архитектуры, позволяющей разработчикам реализовывать множество типов алгоритмов, а не только рендеринг 3D-графики, используя при этом уникальные возможности графического процессора. Эти возможности называются вычислениями на GPU, а использование GPU в качестве сопроцессора для научных вычислений общего назначения называется программированием на GPU общего назначения (GPGPU).
Вычисления на GPU внесли значительный вклад в недавний бум машинного обучения, поскольку сверточные нейронные сети и другие модели могут использовать преимущества этой архитектуры для более эффективной работы на графических процессорах. В связи с отсутствием возможностей вычислений на GPU на текущей веб-платформе, группа сообщества W3C «GPU для веба» разрабатывает API для использования современных API графических процессоров, доступных на большинстве современных устройств. Этот API называется WebGPU .
WebGPU — это низкоуровневый API, такой как WebGL. Он очень мощный и довольно многословный, как вы увидите. Но это нормально. Нам нужна производительность.
В этой статье я сосредоточусь на вычислительной части WebGPU, связанной с GPU, и, честно говоря, я лишь поверхностно коснулся темы, чтобы вы могли начать работать самостоятельно. В следующих статьях я постараюсь углубиться в тему рендеринга WebGPU (холст, текстуры и т. д.).
Доступ к графическому процессору
 Доступ к графическому процессору в WebGPU прост. Вызов navigator.gpu.requestAdapter() возвращает JavaScript-обещание, которое будет асинхронно выполнено с помощью графического адаптера. Этот адаптер можно представить как видеокарту. Он может быть интегрированным (на том же чипе, что и процессор) или дискретным (обычно это карта PCIe, которая более производительна, но и потребляет больше энергии).
 Получив адаптер GPU, вызовите adapter.requestDevice() чтобы получить обещание, которое будет разрешено с помощью устройства GPU, которое вы будете использовать для выполнения некоторых вычислений GPU.
const adapter = await navigator.gpu.requestAdapter();
if (!adapter) { return; }
const device = await adapter.requestDevice();
Обе функции принимают параметры, позволяющие вам точно указать тип адаптера (предпочтения по питанию) и устройства (расширения, ограничения). Для простоты в этой статье мы будем использовать параметры по умолчанию.
Запись буферной памяти
Давайте посмотрим, как использовать JavaScript для записи данных в память для графического процессора. Этот процесс непрост из-за модели «песочницы», используемой в современных веб-браузерах.
 В примере ниже показано, как записать четыре байта в буферную память, доступную из графического процессора. Вызывается device.createBuffer() , который принимает размер буфера и его использование. Хотя флаг использования GPUBufferUsage.MAP_WRITE не требуется для этого вызова, давайте явно укажем, что мы хотим записать данные в этот буфер. В результате объект буфера графического процессора будет отображен при создании благодаря mappedAtCreation , установленному в значение true. После этого связанный буфер необработанных двоичных данных можно получить, вызвав метод getMappedRange() буфера графического процессора.
 Запись байтов вам знакома, если вы уже работали с ArrayBuffer ; используйте TypedArray и скопируйте в него значения.
// Get a GPU buffer in a mapped state and an arrayBuffer for writing.
const gpuBuffer = device.createBuffer({
  mappedAtCreation: true,
  size: 4,
  usage: GPUBufferUsage.MAP_WRITE
});
const arrayBuffer = gpuBuffer.getMappedRange();
// Write bytes to buffer.
new Uint8Array(arrayBuffer).set([0, 1, 2, 3]);
 На этом этапе буфер графического процессора отображается, то есть принадлежит центральному процессору и доступен для чтения и записи из JavaScript. Чтобы графический процессор мог получить к нему доступ, его необходимо отключить, что выполняется простым вызовом gpuBuffer.unmap() .
Концепция сопоставления/не сопоставления необходима для предотвращения состояний гонки, когда графический процессор и центральный процессор одновременно обращаются к памяти.
Чтение буферной памяти
Теперь давайте посмотрим, как скопировать буфер графического процессора в другой буфер графического процессора и прочитать его обратно.
 Поскольку мы записываем данные в первый буфер GPU и хотим скопировать его во второй, требуется новый флаг использования GPUBufferUsage.COPY_SRC . Второй буфер GPU создаётся в несопоставленном состоянии с помощью device.createBuffer() . Его флаг использования — GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ , поскольку он будет использоваться в качестве назначения для первого буфера GPU и считываться в JavaScript после выполнения команд копирования GPU.
// Get a GPU buffer in a mapped state and an arrayBuffer for writing.
const gpuWriteBuffer = device.createBuffer({
  mappedAtCreation: true,
  size: 4,
  usage: GPUBufferUsage.MAP_WRITE | GPUBufferUsage.COPY_SRC
});
const arrayBuffer = gpuWriteBuffer.getMappedRange();
// Write bytes to buffer.
new Uint8Array(arrayBuffer).set([0, 1, 2, 3]);
// Unmap buffer so that it can be used later for copy.
gpuWriteBuffer.unmap();
// Get a GPU buffer for reading in an unmapped state.
const gpuReadBuffer = device.createBuffer({
  size: 4,
  usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ
});
 Поскольку графический процессор является независимым сопроцессором, все его команды выполняются асинхронно. Именно поэтому существует список команд графического процессора, который формируется и отправляется пакетами по мере необходимости. В WebGPU кодировщик команд графического процессора, возвращаемый функцией device.createCommandEncoder() , — это объект JavaScript, который формирует пакет «буферизированных» команд, которые будут отправлены графическому процессору в определённый момент. Методы класса GPUBuffer , с другой стороны, являются «небуферизированными», то есть они выполняются атомарно в момент вызова.
 Получив кодировщик команд графического процессора, вызовите метод copyEncoder.copyBufferToBuffer() как показано ниже, чтобы добавить эту команду в очередь команд для последующего выполнения. Наконец, завершите кодирование команд, вызвав метод copyEncoder.finish() , и отправьте их в очередь команд графического процессора. Очередь отвечает за обработку отправок, выполненных через метод device.queue.submit() с командами графического процессора в качестве аргументов. Это позволит атомарно выполнить все команды, хранящиеся в массиве, по порядку.
// Encode commands for copying buffer to buffer.
const copyEncoder = device.createCommandEncoder();
copyEncoder.copyBufferToBuffer(gpuWriteBuffer, gpuReadBuffer);
// Submit copy commands.
const copyCommands = copyEncoder.finish();
device.queue.submit([copyCommands]);
 На этом этапе команды очереди GPU были отправлены, но не обязательно выполнены. Чтобы прочитать второй буфер GPU, вызовите gpuReadBuffer.mapAsync() с GPUMapMode.READ . Он возвращает обещание, которое будет выполнено после отображения буфера GPU. Затем получите отображенный диапазон с помощью gpuReadBuffer.getMappedRange() , который содержит те же значения, что и первый буфер GPU, после выполнения всех команд GPU в очереди.
// Read buffer.
await gpuReadBuffer.mapAsync(GPUMapMode.READ);
const copyArrayBuffer = gpuReadBuffer.getMappedRange();
console.log(new Uint8Array(copyArrayBuffer));
Вы можете попробовать этот образец .
Короче говоря, вот что вам нужно помнить относительно операций с буферной памятью:
- Буферы графического процессора должны быть отключены для использования при отправке очереди устройств.
- После отображения буферы графического процессора можно читать и записывать в JavaScript.
-  Буферы графического процессора отображаются при вызове mapAsync()иcreateBuffer()сmappedAtCreation, установленным в значение true.
Программирование шейдеров
Программы, работающие на графическом процессоре и выполняющие только вычисления (и не рисующие треугольники), называются вычислительными шейдерами. Они выполняются параллельно сотнями ядер графического процессора (которые меньше ядер центрального процессора), которые совместно обрабатывают данные. Их входные и выходные данные представляют собой буферы в WebGPU.
Чтобы проиллюстрировать использование вычислительных шейдеров в WebGPU, мы поэкспериментируем с умножением матриц, распространенным алгоритмом в машинном обучении, показанным ниже.

Короче говоря, вот что мы собираемся сделать:
- Создайте три буфера GPU (два для матриц для умножения и один для матрицы результата)
- Опишите входные и выходные данные для вычислительного шейдера
- Скомпилируйте код вычислительного шейдера
- Настройка вычислительного конвейера
- Пакетная отправка закодированных команд в графический процессор
- Прочитать результирующую матрицу буфера графического процессора
Создание буферов GPU
Для простоты матрицы будут представлены в виде списка чисел с плавающей точкой. Первый элемент — это количество строк, второй — количество столбцов, а остальные — сами числа матрицы.

 Три буфера GPU являются буферами хранения, поскольку нам необходимо сохранять и извлекать данные в вычислительном шейдере. Это объясняет, почему флаги использования буферов GPU включают GPUBufferUsage.STORAGE для всех них. Флаг использования матрицы результата также имеет GPUBufferUsage.COPY_SRC , поскольку он будет скопирован в другой буфер для чтения после выполнения всех команд очереди GPU.
const adapter = await navigator.gpu.requestAdapter();
if (!adapter) { return; }
const device = await adapter.requestDevice();
// First Matrix
const firstMatrix = new Float32Array([
  2 /* rows */, 4 /* columns */,
  1, 2, 3, 4,
  5, 6, 7, 8
]);
const gpuBufferFirstMatrix = device.createBuffer({
  mappedAtCreation: true,
  size: firstMatrix.byteLength,
  usage: GPUBufferUsage.STORAGE,
});
const arrayBufferFirstMatrix = gpuBufferFirstMatrix.getMappedRange();
new Float32Array(arrayBufferFirstMatrix).set(firstMatrix);
gpuBufferFirstMatrix.unmap();
// Second Matrix
const secondMatrix = new Float32Array([
  4 /* rows */, 2 /* columns */,
  1, 2,
  3, 4,
  5, 6,
  7, 8
]);
const gpuBufferSecondMatrix = device.createBuffer({
  mappedAtCreation: true,
  size: secondMatrix.byteLength,
  usage: GPUBufferUsage.STORAGE,
});
const arrayBufferSecondMatrix = gpuBufferSecondMatrix.getMappedRange();
new Float32Array(arrayBufferSecondMatrix).set(secondMatrix);
gpuBufferSecondMatrix.unmap();
// Result Matrix
const resultMatrixBufferSize = Float32Array.BYTES_PER_ELEMENT * (2 + firstMatrix[0] * secondMatrix[1]);
const resultMatrixBuffer = device.createBuffer({
  size: resultMatrixBufferSize,
  usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC
});
Макет группы привязки и группа привязки
Концепции макета группы привязки и самой группы привязки специфичны для WebGPU. Макет группы привязки определяет интерфейс ввода/вывода, ожидаемый шейдером, в то время как группа привязки представляет собой фактические входные/выходные данные для шейдера.
 В примере ниже схема группы привязок предполагает наличие двух буферов хранения, доступных только для чтения, с привязками 0 и 1 , а также буфера хранения с привязкой 2 для вычислительного шейдера. С другой стороны, группа привязок, определённая для этой схемы группы привязок, связывает буферы графического процессора с записями: gpuBufferFirstMatrix с привязкой 0 , gpuBufferSecondMatrix с привязкой 1 и resultMatrixBuffer с привязкой 2 .
const bindGroupLayout = device.createBindGroupLayout({
  entries: [
    {
      binding: 0,
      visibility: GPUShaderStage.COMPUTE,
      buffer: {
        type: "read-only-storage"
      }
    },
    {
      binding: 1,
      visibility: GPUShaderStage.COMPUTE,
      buffer: {
        type: "read-only-storage"
      }
    },
    {
      binding: 2,
      visibility: GPUShaderStage.COMPUTE,
      buffer: {
        type: "storage"
      }
    }
  ]
});
const bindGroup = device.createBindGroup({
  layout: bindGroupLayout,
  entries: [
    {
      binding: 0,
      resource: gpuBufferFirstMatrix
    },
    {
      binding: 1,
      resource: gpuBufferSecondMatrix
    },
    {
      binding: 2,
      resource: resultMatrixBuffer
    }
  ]
});
Вычислительный код шейдера
 Код вычислительного шейдера для умножения матриц написан на WGSL (языке шейдеров WebGPU), который легко транслируется в SPIR-V . Не вдаваясь в подробности, ниже вы увидите три буфера хранения, обозначенные как var<storage> . Программа будет использовать firstMatrix и secondMatrix в качестве входных данных, а resultMatrix — в качестве выходных данных.
 Обратите внимание, что для каждого буфера хранения используется декорация binding , соответствующая тому же индексу, который определен в макетах групп привязки и группах привязки, объявленных выше.
const shaderModule = device.createShaderModule({
  code: `
    struct Matrix {
      size : vec2f,
      numbers: array<f32>,
    }
    @group(0) @binding(0) var<storage, read> firstMatrix : Matrix;
    @group(0) @binding(1) var<storage, read> secondMatrix : Matrix;
    @group(0) @binding(2) var<storage, read_write> resultMatrix : Matrix;
    @compute @workgroup_size(8, 8)
    fn main(@builtin(global_invocation_id) global_id : vec3u) {
      // Guard against out-of-bounds work group sizes
      if (global_id.x >= u32(firstMatrix.size.x) || global_id.y >= u32(secondMatrix.size.y)) {
        return;
      }
      resultMatrix.size = vec2(firstMatrix.size.x, secondMatrix.size.y);
      let resultCell = vec2(global_id.x, global_id.y);
      var result = 0.0;
      for (var i = 0u; i < u32(firstMatrix.size.y); i = i + 1u) {
        let a = i + resultCell.x * u32(firstMatrix.size.y);
        let b = resultCell.y + i * u32(secondMatrix.size.y);
        result = result + firstMatrix.numbers[a] * secondMatrix.numbers[b];
      }
      let index = resultCell.y + resultCell.x * u32(secondMatrix.size.y);
      resultMatrix.numbers[index] = result;
    }
  `
});
Настройка трубопровода
 Вычислительный конвейер — это объект, который фактически описывает вычислительную операцию, которую мы собираемся выполнить. Создайте его, вызвав метод device.createComputePipeline() . Он принимает два аргумента: макет группы связывания, созданный ранее, и этап вычислений, определяющий точку входа нашего вычислительного шейдера ( main функцию WGSL), а также сам модуль вычислительного шейдера, созданный с помощью device.createShaderModule() .
const computePipeline = device.createComputePipeline({
  layout: device.createPipelineLayout({
    bindGroupLayouts: [bindGroupLayout]
  }),
  compute: {
    module: shaderModule
  }
});
Подача команд
После создания экземпляра группы привязки с нашими тремя буферами GPU и вычислительного конвейера с макетом группы привязки настало время их использовать.
 Давайте запустим программируемый кодировщик проходов вычислений с помощью commandEncoder.beginComputePass() . Мы будем использовать его для кодирования команд графического процессора, которые будут выполнять умножение матриц. Установите его конвейер с помощью passEncoder.setPipeline(computePipeline) , а его группу связывания — с индексом 0 с помощью passEncoder.setBindGroup(0, bindGroup) . Индекс 0 соответствует декорированию group(0) в коде WGSL.
 Теперь давайте поговорим о том, как этот вычислительный шейдер будет работать на GPU. Наша цель — выполнить эту программу параллельно для каждой ячейки матрицы результата, шаг за шагом. Например, для матрицы результата размером 16 на 32, чтобы закодировать команду выполнения, на @workgroup_size(8, 8) мы бы вызвали passEncoder.dispatchWorkgroups(2, 4) или passEncoder.dispatchWorkgroups(16 / 8, 32 / 8) . Первый аргумент «x» — это первое измерение, второй «y» — второе измерение, а последний «z» — третье измерение, которое по умолчанию равно 1, поскольку оно нам здесь не нужно. В мире вычислений на GPU кодирование команды для выполнения функции ядра над набором данных называется диспетчеризацией. 

 Размер сетки рабочей группы для нашего вычислительного шейдера в нашем коде WGSL равен (8, 8) . Поэтому «x» и «y», которые представляют собой количество строк первой матрицы и количество столбцов второй матрицы соответственно, будут разделены на 8. Таким образом, мы можем отправить вызов функции compute с помощью passEncoder.dispatchWorkgroups(firstMatrix[0] / 8, secondMatrix[1] / 8) . Количество запускаемых сеток рабочей группы — это аргументы функции dispatchWorkgroups() .
 Как показано на рисунке выше, каждый шейдер будет иметь доступ к уникальному builtin(global_invocation_id) , который будет использоваться для определения, какую ячейку матрицы результата следует вычислить. 
const commandEncoder = device.createCommandEncoder();
const passEncoder = commandEncoder.beginComputePass();
passEncoder.setPipeline(computePipeline);
passEncoder.setBindGroup(0, bindGroup);
const workgroupCountX = Math.ceil(firstMatrix[0] / 8);
const workgroupCountY = Math.ceil(secondMatrix[1] / 8);
passEncoder.dispatchWorkgroups(workgroupCountX, workgroupCountY);
passEncoder.end();
Чтобы завершить кодирование вычислительного прохода, вызовите passEncoder.end() . Затем создайте буфер графического процессора, который будет использоваться в качестве назначения для копирования буфера матрицы результатов с помощью copyBufferToBuffer . Наконец, завершите кодирование команд с помощью copyEncoder.finish() и отправьте их в очередь графического устройства, вызвав device.queue.submit() с командами графического процессора. 
// Get a GPU buffer for reading in an unmapped state.
const gpuReadBuffer = device.createBuffer({
  size: resultMatrixBufferSize,
  usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ
});
// Encode commands for copying buffer to buffer.
commandEncoder.copyBufferToBuffer(resultMatrixBuffer, gpuReadBuffer);
// Submit GPU commands.
const gpuCommands = commandEncoder.finish();
device.queue.submit([gpuCommands]);
Прочитать матрицу результатов
 Чтение матрицы результатов так же просто, как вызов gpuReadBuffer.mapAsync() с GPUMapMode.READ и ожидание разрешения возвращаемого промиса, что означает, что буфер графического процессора теперь отображен. На этом этапе можно получить отображенный диапазон с помощью gpuReadBuffer.getMappedRange() . 

В нашем коде результат, зарегистрированный в консоли JavaScript DevTools, — «2, 2, 50, 60, 114, 140».
// Read buffer.
await gpuReadBuffer.mapAsync(GPUMapMode.READ);
const arrayBuffer = gpuReadBuffer.getMappedRange();
console.log(new Float32Array(arrayBuffer));
Поздравляю! Вы сделали это. Можете поиграть с образцом .
Последний трюк
 Один из способов сделать код более удобным для чтения — использовать удобный метод getBindGroupLayout вычислительного конвейера для определения макета группы привязки из модуля шейдера . Этот приём устраняет необходимость в создании собственного макета группы привязки и указании макета конвейера в вычислительном конвейере, как показано ниже.
 Доступна иллюстрация getBindGroupLayout для предыдущего примера. 
 const computePipeline = device.createComputePipeline({
-  layout: device.createPipelineLayout({
-    bindGroupLayouts: [bindGroupLayout]
-  }),
   compute: {
-// Bind group layout and bind group
- const bindGroupLayout = device.createBindGroupLayout({
-   entries: [
-     {
-       binding: 0,
-       visibility: GPUShaderStage.COMPUTE,
-       buffer: {
-         type: "read-only-storage"
-       }
-     },
-     {
-       binding: 1,
-       visibility: GPUShaderStage.COMPUTE,
-       buffer: {
-         type: "read-only-storage"
-       }
-     },
-     {
-       binding: 2,
-       visibility: GPUShaderStage.COMPUTE,
-       buffer: {
-         type: "storage"
-       }
-     }
-   ]
- });
+// Bind group
  const bindGroup = device.createBindGroup({
-  layout: bindGroupLayout,
+  layout: computePipeline.getBindGroupLayout(0 /* index */),
   entries: [
Результаты оценки эффективности
Итак, чем же отличается выполнение умножения матриц на GPU от выполнения на CPU? Чтобы выяснить это, я написал только что описанную программу для CPU. И, как видно на графике ниже, использование всей мощности GPU кажется очевидным выбором, когда размер матриц превышает 256x256.

Эта статья — только начало моего пути в изучении WebGPU . Скоро появятся новые статьи с более подробным описанием вычислений на GPU и принципов работы рендеринга (холст, текстура, сэмплер) в WebGPU.
