Questo post esplora l'API WebGPU sperimentale attraverso esempi e aiuta si inizia a eseguire calcoli paralleli ai dati utilizzando la GPU.
Sfondo
Come forse già saprai, la GPU (Graphic Processing Unit) è un'unità un sottosistema di un computer originariamente specializzato per l'elaborazione le immagini. Tuttavia, negli ultimi 10 anni, si è evoluto verso una maggiore flessibilità che consente agli sviluppatori di implementare molti tipi di algoritmi, non solo il rendering di grafica 3D, sfruttando al contempo l'architettura unica GPU. Queste funzionalità sono chiamate computing GPU e utilizzano una GPU come coprocessore per il calcolo scientifico per uso generico è chiamato programmazione GPU (GPGPU).
GPU Compute ha contribuito in modo significativo al recente boom del machine learning, poiché le reti neurali di convoluzione e altri modelli possono sfruttare per l'esecuzione in modo più efficiente sulle GPU. Con l'attuale piattaforma web non dispone di capacità di calcolo GPU, la GPU "GPU for the Web" del W3C Gruppo della community è la progettazione di un'API per esporre le moderne API GPU disponibili dispositivi attuali. Questa API è denominata WebGPU.
WebGPU è un'API di basso livello, come WebGL. È molto potente e piuttosto dettagliato, che vedrai. Ma va bene così. Quello che vogliamo è il rendimento.
In questo articolo mi soffermerò sulla parte di GPU Compute di WebGPU. onestamente, sto solo grattando la superficie, così potrai iniziare a giocare sui tuoi personali. Approfondiremo l'argomento e parlerò del rendering di WebGPU (canvas, texture, e così via) nei prossimi articoli.
Accedi alla GPU
L'accesso alla GPU è facile in WebGPU. Chiamata a navigator.gpu.requestAdapter()
restituisce una promessa JavaScript che si risolverà in modo asincrono con una GPU
dell'adattatore. Considera questo adattatore come la scheda grafica. Può essere integrato
(sullo stesso chip della CPU) o discreto (di solito una scheda PCIe che
prestazioni migliori, ma consumano più energia).
Una volta che hai l'adattatore GPU, chiama adapter.requestDevice()
per ottenere una promessa
che si risolverà con un dispositivo GPU che userai per eseguire alcuni calcoli GPU.
const adapter = await navigator.gpu.requestAdapter();
if (!adapter) { return; }
const device = await adapter.requestDevice();
Entrambe le funzioni accettano opzioni che consentono di specificare l'alimentatore (preferenza alimentazione) e il dispositivo (estensioni, limiti) che desideri. Per Per semplicità, utilizzeremo le opzioni predefinite riportate in questo articolo.
Memoria buffer di scrittura
Vediamo come usare JavaScript per scrivere dati nella memoria della GPU. Questo non è semplice per via del modello di sandbox utilizzato nel web moderno browser.
L'esempio seguente mostra come scrivere 4 byte nella memoria di buffer accessibile
dalla GPU. Chiama device.createBuffer()
, che prende le dimensioni
del buffer e il suo utilizzo. Anche se il flag di utilizzo GPUBufferUsage.MAP_WRITE
è
non sono richiesti per questa specifica chiamata, vogliamo chiarire che vogliamo scrivere
a questo buffer. Risulta in un oggetto buffer GPU mappato al momento della creazione grazie
mappedAtCreation
impostato su true. Il buffer di dati binari non elaborati associato può quindi
recuperabile chiamando il metodo di buffer GPU getMappedRange()
.
È possibile scrivere byte se hai già giocato con ArrayBuffer
. usa un
TypedArray
e copia i valori al suo interno.
// 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]);
A questo punto, il buffer GPU è mappato, ovvero è di proprietà della CPU,
è accessibile in lettura/scrittura da JavaScript. Per consentire alla GPU di accedervi,
deve essere rimosso, il che è semplice come chiamare gpuBuffer.unmap()
.
Il concetto di mappato/non mappato è necessario per impedire le gare in cui le GPU e alla memoria di accesso alla CPU.
Memoria buffer di lettura
Vediamo ora come copiare un buffer GPU in un altro buffer GPU e rileggere il buffer.
Poiché stiamo scrivendo nel primo buffer GPU e vogliamo copiarlo
Buffer GPU, è richiesto un nuovo flag di utilizzo GPUBufferUsage.COPY_SRC
. Il secondo
Il buffer GPU è stato creato in uno stato non mappato questa volta con
device.createBuffer()
. Il suo flag di utilizzo è GPUBufferUsage.COPY_DST |
GPUBufferUsage.MAP_READ
perché verrà usato come destinazione della prima GPU
buffer e leggi in JavaScript dopo l'esecuzione dei comandi di copia della 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
});
Poiché la GPU è un coprocessore indipendente, vengono eseguiti tutti i comandi GPU
in modo asincrono. Ecco perché è presente un elenco di comandi GPU creati e inviati
i batch quando necessario. In WebGPU, l'encoder dei comandi GPU restituito
device.createCommandEncoder()
è l'oggetto JavaScript che crea un batch di
"con buffer" che a un certo punto verranno inviati alla GPU. I metodi su
GPUBuffer
, invece, sono "senza buffer", il che significa che vengono eseguiti atomicamente
nel momento in cui vengono chiamati.
Una volta ottenuto il codificatore dei comandi GPU, chiama copyEncoder.copyBufferToBuffer()
come mostrato di seguito per aggiungere questo comando alla coda dei comandi per un'esecuzione successiva.
Infine, completa i comandi di codifica richiamando copyEncoder.finish()
e invia
alla coda dei comandi del dispositivo GPU. La coda è responsabile della gestione
invii effettuati tramite device.queue.submit()
con i comandi GPU come argomenti.
In questo modo verranno eseguiti a livello atomico tutti i comandi archiviati nell'array in ordine.
// 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]);
A questo punto i comandi della coda GPU sono stati inviati, ma non necessariamente eseguiti.
Per leggere il secondo buffer GPU, chiama gpuReadBuffer.mapAsync()
con
GPUMapMode.READ
. Restituisce una promessa che si risolverà quando il buffer GPU viene
mappato. Quindi ottieni l'intervallo mappato con gpuReadBuffer.getMappedRange()
contiene gli stessi valori del primo buffer GPU una volta che tutti i comandi GPU sono in coda
sono state eseguite.
// Read buffer.
await gpuReadBuffer.mapAsync(GPUMapMode.READ);
const copyArrayBuffer = gpuReadBuffer.getMappedRange();
console.log(new Uint8Array(copyArrayBuffer));
Puoi provare questo esempio.
In breve, ecco cosa bisogna ricordare per quanto riguarda le operazioni di memoria del buffer:
- La mappatura dei buffer GPU deve essere annullata per poter essere utilizzati nell'invio della coda dei dispositivi.
- Una volta mappati, i buffer GPU possono essere letti e scritti in JavaScript.
- I buffer GPU vengono mappati quando
mapAsync()
ecreateBuffer()
con Vengono chiamati i criterimappedAtCreation
impostati su true.
Programmazione Shader
Programmi in esecuzione sulla GPU che eseguono solo calcoli (e non disegnano triangoli) sono chiamati consumatori di computing. vengono eseguite in parallelo di core GPU (che sono più piccoli dei core della CPU) che operano insieme per e i dati di Google Cloud. I rispettivi input e output sono buffer in WebGPU.
Per illustrare l'uso degli shaker di computing in WebGPU, giochiamo con Matrix la moltiplicazione, un algoritmo comune nel machine learning illustrato di seguito.
In breve, ecco cosa faremo:
- Crea tre buffer GPU (due per le matrici da moltiplicare e uno per matrice dei risultati)
- Descrivere l'input e l'output per Compute Skillsr
- Compila il codice Compute Shar
- Configura una pipeline di computing
- Invia in batch i comandi codificati alla GPU
- Leggere il buffer GPU della matrice dei risultati
Creazione di buffer GPU
Per semplicità, le matrici saranno rappresentate come un elenco di variabili mobili numeri in punti. Il primo elemento è il numero di righe, il secondo elemento numero di colonne, mentre il resto sono i numeri effettivi della matrice.
I tre buffer GPU sono buffer di archiviazione perché dobbiamo archiviare e recuperare i dati
Compute Dataflow. Questo spiega perché i flag di utilizzo del buffer GPU includono
GPUBufferUsage.STORAGE
per tutti. Anche il flag di utilizzo della matrice dei risultati
GPUBufferUsage.COPY_SRC
perché verrà copiata in un altro buffer per
dopo l'esecuzione di tutti i comandi
della coda 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
});
Associa layout gruppo e associa gruppo
I concetti di layout del gruppo di associazione e del gruppo di associazione sono specifici di WebGPU. Un vincolo il layout dei gruppi definisce l'interfaccia di input/output prevista da uno Shar, mentre Il gruppo di associazione rappresenta i dati di input/output effettivi per uno shaker.
Nell'esempio riportato di seguito, il layout del gruppo di associazione prevede due buffer di archiviazione di sola lettura
Le voci numerate associano 0
, 1
e un buffer di archiviazione in 2
per il computing shaker.
Il gruppo di associazione, invece, definito per questo layout del gruppo di associazione, associa
Buffer GPU nelle voci: gpuBufferFirstMatrix
all'associazione 0
,
gpuBufferSecondMatrix
all'associazione 1
e resultMatrixBuffer
all'associazione
associazione 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
}
}
]
});
Codice Shar Compute
Il codice dello shaker di computing per moltiplicare le matrici è scritto in WGSL,
WebGPU Shader Language, che è banalmente traducibile in SPIR-V. Senza
più in dettaglio, dovresti trovare più in basso i tre buffer di archiviazione identificati
con var<storage>
. Il programma userà firstMatrix
e secondMatrix
come
e resultMatrix
come output.
Tieni presente che ogni buffer di archiviazione ha una decorazione binding
utilizzata che corrisponde
lo stesso indice definito nei layout dei gruppi di associazione e nei gruppi di associazione dichiarati sopra.
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;
}
`
});
Configurazione della pipeline
La pipeline di computing è l'oggetto che descrive effettivamente l'operazione di computing
che eseguiremo. Per crearlo, chiama device.createComputePipeline()
.
Prende due argomenti: il layout del gruppo di associazione creato in precedenza e
fase che definisce il punto di ingresso del nostro shaker di computing (la funzione WGSL di main
)
e l'effettivo modulo Compute Skills creato con device.createShaderModule()
.
const computePipeline = device.createComputePipeline({
layout: device.createPipelineLayout({
bindGroupLayouts: [bindGroupLayout]
}),
compute: {
module: shaderModule,
entryPoint: "main"
}
});
Invio dei comandi
Dopo aver creato un'istanza per un gruppo di associazione con i nostri tre buffer GPU e con un layout di gruppo di associazione, è il momento di usarli.
Avviamo un codificatore di pass di computing programmabile con
commandEncoder.beginComputePass()
. Lo utilizzeremo per codificare i comandi GPU
che eseguirà la moltiplicazione matriciale. Imposta la sua pipeline con
passEncoder.setPipeline(computePipeline)
e il relativo gruppo di associazione all'indice 0 con
passEncoder.setBindGroup(0, bindGroup)
. L'indice 0 corrisponde alla
Decorazione group(0)
nel codice WGSL.
Ora parliamo di come verrà eseguito questo Shader di computing sulla GPU. Le nostre
l'obiettivo è eseguire questo programma in parallelo per ogni cella della matrice dei risultati,
passo passo. Ad esempio, per una matrice di risultati di dimensione 16 x 32, per codificare
il comando di esecuzione, su un @workgroup_size(8, 8)
, chiamiamo
passEncoder.dispatchWorkgroups(2, 4)
o passEncoder.dispatchWorkgroups(16 / 8, 32 / 8)
.
Il primo argomento "x" è la prima dimensione, la seconda "y" è la seconda dimensione,
e l'ultima "z" è la terza dimensione predefinita di 1, in quanto non è necessaria qui.
Nel mondo del computing GPU, la codifica di un comando per l'esecuzione di una funzione kernel su un set di dati è chiamata invio.
La dimensione della griglia del gruppo di lavoro per lo shaker di computing è (8, 8)
nel nostro WGSL
le API nel tuo codice. Per questo motivo, "x" e "y" che corrispondono rispettivamente al numero di righe
la prima matrice e il numero di colonne della seconda matrice verranno divisi
per 8. A questo punto possiamo inviare una chiamata di computing
passEncoder.dispatchWorkgroups(firstMatrix[0] / 8, secondMatrix[1] / 8)
. La
del numero di griglie del gruppo di lavoro da eseguire sono gli argomenti dispatchWorkgroups()
.
Come si vede nel disegno qui sopra, ogni Shader avrà accesso a un
builtin(global_invocation_id)
oggetto che verrà utilizzato per sapere quale risultato
cella della matrice da calcolare.
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();
Per terminare il codificatore di Compute Pass, chiama passEncoder.end()
. Quindi, crea un'istanza
Buffer GPU da utilizzare come destinazione per copiare il buffer della matrice dei risultati con
copyBufferToBuffer
. Infine, completa i comandi di codifica con
copyEncoder.finish()
e inviale alla coda dei dispositivi GPU chiamando
device.queue.submit()
con i comandi GPU.
// 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]);
Lettura matrice dei risultati
Leggere la matrice dei risultati è facile come chiamare gpuReadBuffer.mapAsync()
con
GPUMapMode.READ
e in attesa della risoluzione della promessa di ritorno, che indica
il buffer GPU è mappato. A questo punto, è possibile ottenere la mappatura
intervallo con gpuReadBuffer.getMappedRange()
.
Nel nostro codice, il risultato collegato alla console JavaScript di DevTools è "2, 2, 50, 60, 114, 140".
// Read buffer.
await gpuReadBuffer.mapAsync(GPUMapMode.READ);
const arrayBuffer = gpuReadBuffer.getMappedRange();
console.log(new Float32Array(arrayBuffer));
Complimenti! Ce l'hai fatta. Puoi riprodurre il Sample.
Un ultimo trucco
Un modo per semplificare la lettura del codice consiste nell'utilizzare la pratica
Metodo getBindGroupLayout
della pipeline di computing per inferire il gruppo di associazione
del layout del modulo
shar. Questo trucco elimina la necessità di creare
layout dei gruppi di associazione personalizzato e specifica un layout della pipeline nelle fasi di computing
come mostrato di seguito.
È disponibile un'illustrazione di getBindGroupLayout
per l'esempio precedente.
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: [
Risultati sul rendimento
Quindi, come si confronta l'esecuzione della moltiplicazione delle matrici su una GPU e l'esecuzione su una di CPU? Per scoprirlo, ho scritto il programma appena descritto per una CPU. E il più possibile guarda nel grafico qui sotto, utilizzare tutta la potenza della GPU sembra una scelta ovvia quando la dimensione delle matrici è maggiore di 256 x 256.
Questo articolo è stato solo l'inizio del mio viaggio all'esplorazione di WebGPU. Ottieni di più a breve con ulteriori approfondimenti sul computing GPU e sul modo in cui (canvas, texture, sampler) funziona in WebGPU.