В этой статье на примерах рассматривается экспериментальный API WebGPU, который поможет вам приступить к выполнению параллельных вычислений с использованием графического процессора.
Фон
Как вы, возможно, уже знаете, графический процессор (GPU) — это электронная подсистема компьютера, изначально предназначенная для обработки графики. Однако за последние 10 лет он эволюционировал в сторону более гибкой архитектуры, позволяющей разработчикам реализовывать множество типов алгоритмов, а не только рендеринг 3D-графики, используя при этом уникальные возможности графического процессора. Эти возможности называются вычислениями на GPU, а использование GPU в качестве сопроцессора для научных вычислений общего назначения называется программированием на GPU общего назначения (GPGPU).
Вычисления на GPU внесли значительный вклад в недавний бум машинного обучения, поскольку сверточные нейронные сети и другие модели могут использовать преимущества этой архитектуры для более эффективной работы на графических процессорах. В связи с отсутствием возможностей вычислений на GPU на текущей веб-платформе, группа сообщества «GPU для веба» консорциума W3C разрабатывает 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 /* source buffer */,
0 /* source offset */,
gpuReadBuffer /* destination buffer */,
0 /* destination offset */,
4 /* size */
);
// 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: {
buffer: gpuBufferFirstMatrix
}
},
{
binding: 1,
resource: {
buffer: gpuBufferSecondMatrix
}
},
{
binding: 2,
resource: {
buffer: 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,
entryPoint: "main"
}
});
Подача команд
После создания экземпляра группы привязки с нашими тремя буферами 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 /* source buffer */,
0 /* source offset */,
gpuReadBuffer /* destination buffer */,
0 /* destination offset */,
resultMatrixBufferSize /* size */
);
// 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.