Начните работу с вычислениями на графическом процессоре в Интернете

В этой статье на примерах рассматривается экспериментальный API WebGPU, который поможет вам приступить к выполнению параллельных вычислений с использованием графического процессора.

Франсуа Бофор
François Beaufort

Фон

Как вы, возможно, уже знаете, графический процессор (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, мы поэкспериментируем с умножением матриц, распространенным алгоритмом в машинном обучении, показанным ниже.

Схема умножения матриц
Схема умножения матриц

Короче говоря, вот что мы собираемся сделать:

  1. Создайте три буфера GPU (два для матриц для умножения и один для матрицы результата)
  2. Опишите входные и выходные данные для вычислительного шейдера
  3. Скомпилируйте код вычислительного шейдера
  4. Настройка вычислительного конвейера
  5. Пакетная отправка закодированных команд в графический процессор
  6. Прочитать результирующую матрицу буфера графического процессора

Создание буферов GPU

Для простоты матрицы будут представлены в виде списка чисел с плавающей точкой. Первый элемент — это количество строк, второй — количество столбцов, а остальные — сами числа матрицы.

Простое представление матрицы в JavaScript и ее эквивалент в математической нотации
Простое представление матрицы в JavaScript и ее эквивалент в математической нотации

Три буфера 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.

Тест производительности GPU и CPU
Тест производительности GPU и CPU

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