Le calcul GPU
Lancer des compute shaders WGSL depuis BASIC : tampons de stockage, pipelines, dispatch dans l'ordre des images, relecture en memblock, et le repli CPU pour les cibles sans calcul GPU.
En un mot#
BASIC peut lancer des compute shaders WebGPU : on dépose des nombres dans un tampon sur le GPU, on fait tourner un petit programme WGSL sur tous à la fois, puis on relit le résultat dans un memblock. Quatorze commandes, dans la catégorie Compute, font tout le travail.
⚠️ Engine uniquement. Le calcul GPU marche quand vous lancez Play dans l'éditeur, sur un GPU et un navigateur qui gèrent le calcul WebGPU. L'export HTML et les compilations Bevy ne l'ont pas. Écrivez le repli CPU avant d'en dépendre.
Quand il est disponible#
Appelez d'abord
ComputeSupported(). Elle répond sur toutes les cibles, et toutes les autres commandes Compute supposent qu'elle a répondu 1.
| Où tourne le programme | ComputeSupported() | Toute autre commande Compute |
|---|---|---|
| Engine, WebGPU avec calcul | 1 | fonctionne |
| Engine, GPU ou navigateur sans calcul | 0 | créer un tampon ou un shader, ou lancer un dispatch, s'arrête sur WebGPU compute is unavailable on this runtime target or GPU |
| Export HTML | 0 | s'arrête sur … is recognized but has no HTML export implementation (Compute) |
| Bevy, natif ou wasm | 0 | le rapport de conversion de l'export liste les commandes Compute que votre programme appelle, avant la compilation ; un appel qui s'exécute quand même s'arrête sur une erreur de capacité |
Il n'y a pas d'émulation silencieuse : une commande Compute tourne sur le GPU
ou s'arrête sur une erreur qui nomme la cible. Le schéma est toujours le même :
tester ComputeSupported(), et prendre un chemin CPU quand elle vaut 0 (voir
le schéma de repli plus bas).
Les pièces#
| Étape | Commande | Ce qu'elle donne |
|---|---|---|
| 1. Mettre les données dans un memblock | CreateMemblock, MemblockFloat, MemblockInt | des octets côté CPU |
| 2. Créer un tampon de stockage | CreateComputeBuffer | un identifiant de tampon |
| 3. Téléverser | ComputeBufferFromMemblock | les octets sur le GPU |
| 4. Compiler un noyau | CreateComputeShader | un identifiant de shader |
| 5. Lier le tampon | ComputeShaderBuffer | |
| 6. Lancer | ComputeDispatchAsync | un identifiant de tâche |
| 7. Relire | ComputeBufferReadAsync | un identifiant de tâche ; son nombre est un identifiant de memblock |
Les étapes 6 et 7 rendent des tâches de plateforme (jobs), les mêmes que
celles des autres commandes asynchrones. On les interroge avec
PlatformJobDone,
PlatformJobError et
PlatformJobResultNumber.
Les tampons de stockage#
buffer = CreateComputeBuffer(<nombreElements>, <type$>)
CreateComputeBuffer(<idTampon>, <nombreElements>, <type$>)
ComputeBufferFromMemblock(<tampon>, <memblock>)
- Trois types d'élément :
"f32","i32","u32". Les trois font 4 octets. Un tampon denéléments faitn * 4octets. - Le memblock doit avoir exactement la taille du tampon. Un tampon de 16 octets prend un memblock de 16 octets, ni plus ni moins. Toute autre taille arrête le programme.
- Les octets sont copiés tels quels. Écrivez les valeurs
f32avecMemblockFloat, les valeursi32etu32avecMemblockInt, aux décalages0, 4, 8…. - Un tampon contient au plus 128 Mio. C'est le minimum courant des GPU mobiles.
- La seconde forme prend l'identifiant que vous choisissez ; il ne doit pas être déjà pris.
| Commande | Rend |
|---|---|
ComputeBufferSize(b) | la taille en octets |
ComputeBufferElementCount(b) | le nombre d'éléments |
ComputeBufferType(b) | "f32", "i32" ou "u32" |
ComputeBufferExists(b) | 1 ou 0 |
DeleteComputeBuffer(b) | le libère |
Les pipelines WGSL#
shader = CreateComputeShader(<wgsl$>, <liaisonsJson$> [, <pointEntree$>])
CreateComputeShader(<idShader>, <wgsl$>, <liaisonsJson$> [, <pointEntree$>])
ComputeShaderBuffer(<shader>, <nom$>, <tampon>)
-
Le noyau est du WGSL brut. Ce n'est pas du GLSL, et il n'est pas traduit. Il doit contenir un point d'entrée
@compute, nommémainsauf si vous en passez un autre. -
Le JSON des liaisons associe chaque variable WGSL à son emplacement, exactement comme le WGSL la déclare :
{"data": {"group": 0, "binding": 0}}Une entrée par variable de stockage, au moins une, aucun emplacement utilisé deux fois.
-
ComputeShaderBufferlie un tampon à une variable par son nom WGSL. -
Les tampons de stockage sont les seules liaisons. Pas d'uniforms, pas de textures. Pour passer un paramètre, comme un pas de temps, mettez-le dans un petit tampon de stockage à lui.
-
CreateComputeShaderne vérifie que la forme : la source contient@compute, le JSON est valide, les emplacements sont distincts. Le WGSL lui-même compile en arrière-plan, et une erreur WGSL revient sur la tâche du dispatch, parPlatformJobError.
Dans une chaîne BASIC, les guillemets du JSON s'écrivent \" :
bindings$ = "{\"data\":{\"group\":0,\"binding\":0}}"
Console(bindings$) ' {"data":{"group":0,"binding":0}}
| Commande | Rend |
|---|---|
ComputeShaderExists(s) | 1 ou 0 |
DeleteComputeShader(s) | le libère |
Le dispatch, et sa place dans l'image#
job = ComputeDispatchAsync(<shader>, <groupesX> [, <groupesY> [, <groupesZ>]])
Les arguments sont des nombres de groupes de travail, pas des nombres
d'éléments. Avec @workgroup_size(64) et n éléments, demandez
(n + 63) DIV 64 groupes, et laissez le noyau ignorer les indices au-delà de
la fin.
Un dispatch ne s'exécute pas quand vous l'appelez. Il est mis en file, et la file part vers le GPU juste avant que l'image suivante soit dessinée.
- La file garde son ordre. Deux dispatchs lancés l'un après l'autre s'exécutent dans cet ordre, même quand le pipeline du second est prêt le premier. Un second noyau peut lire ce que le premier a écrit.
- La tâche est terminée quand le travail est parti. Attendez-la avec
PlatformJobDone, puis vérifiezPlatformJobError(vide en cas de succès). - Un pipeline toujours pas prêt au bout de 10 secondes fait échouer sa tâche.
- Attendre exige un programme qui peut attendre. La boucle habituelle est
WHILE PlatformJobDone(job) = 0 … Sync() … ENDWHILE. SonSync()doit vraiment céder : le programme a besoin d'unDO … LOOPou d'unEND, ou la boucle doit être dans une étiquette. Sinon l'onglet se fige, voir La boucle de jeu.
La relecture dans un memblock#
job = ComputeBufferReadAsync(<tampon>)
job = ComputeBufferReadAsync(<tampon>, <idNouveauMemblock>)
memblock = PlatformJobResultNumber(job)
- La lecture crée un nouveau memblock avec les octets du tampon. Le nombre de la tâche est son identifiant.
- La seconde forme choisit cet identifiant, et il doit être libre. Relire dans un memblock qui existe arrête le programme : supprimez-le d'abord.
- ⚠️ La lecture n'est pas mise en file derrière les dispatchs. Ne la lancez qu'une fois la tâche du dispatch terminée, sinon vous lisez les données d'avant le passage du noyau.
- Un memblock contient au plus 100 000 000 octets : un tampon que vous relisez doit donc rester sous 25 000 000 éléments, en dessous des 128 Mio qu'un tampon peut contenir.
- Supprimez les memblocks dont vous n'avez plus besoin, et libérez les tâches
terminées avec
PlatformJobRelease.
Un exemple complet#
Deux noyaux sur un même tampon : le premier double chaque valeur, le second ajoute un. Les deux sont mis en file d'un coup, et l'ordre de la file garantit le résultat.
' 1, 2, 3, 4 deviennent 3, 5, 7, 9 sur le GPU.
IF ComputeSupported() = 0
Console("Pas de calcul WebGPU sur cette cible")
END
ENDIF
' Les données : quatre valeurs f32, de 4 octets chacune.
input = CreateMemblock(16)
FOR i = 0 TO 3
MemblockFloat(input, i * 4, i + 1)
NEXT
' Un tampon de stockage de même taille en octets, rempli depuis le memblock.
buffer = CreateComputeBuffer(4, "f32")
ComputeBufferFromMemblock(buffer, input)
' Deux noyaux qui partagent leurs premières lignes. Un appel par élément.
head$ = "@group(0) @binding(0) var<storage, read_write> data: array<f32>; "
head$ = head$ + "@compute @workgroup_size(64) "
head$ = head$ + "fn main(@builtin(global_invocation_id) id: vec3<u32>) { "
head$ = head$ + "if (id.x >= arrayLength(&data)) { return; } "
bindings$ = "{\"data\":{\"group\":0,\"binding\":0}}"
doubler = CreateComputeShader(head$ + "data[id.x] = data[id.x] * 2.0; }", bindings$)
adder = CreateComputeShader(head$ + "data[id.x] = data[id.x] + 1.0; }", bindings$)
ComputeShaderBuffer(doubler, "data", buffer)
ComputeShaderBuffer(adder, "data", buffer)
' Les deux en file. Ils passent dans cet ordre, avant la prochaine image.
groups = (4 + 63) DIV 64
job1 = ComputeDispatchAsync(doubler, groups)
job2 = ComputeDispatchAsync(adder, groups)
WHILE PlatformJobDone(job2) = 0
Sync()
ENDWHILE
IF PlatformJobError(job1) <> "" OR PlatformJobError(job2) <> ""
Console("échec du dispatch : " + PlatformJobError(job1) + PlatformJobError(job2))
END
ENDIF
' Relecture. Le nombre de la tâche est l'identifiant d'un nouveau memblock.
readJob = ComputeBufferReadAsync(buffer)
WHILE PlatformJobDone(readJob) = 0
Sync()
ENDWHILE
result = PlatformJobResultNumber(readJob)
FOR i = 0 TO 3
Console(Str(MemblockFloat(result, i * 4))) ' 3, puis 5, 7, 9
NEXT
' Tout libérer.
DeleteMemblock(result)
DeleteMemblock(input)
DeleteComputeShader(doubler)
DeleteComputeShader(adder)
DeleteComputeBuffer(buffer)
END
Ce qui le fait marcher :
- Les lignes
END. Elles permettent àSync()de céder dans les bouclesWHILE. Sans elles, le programme n'a ni boucleDOniEND, et les boucles figeraient l'onglet. - Attendre
job2suffit. La file passe dans l'ordre : quand le second dispatch est parti, le premier l'est aussi. - La lecture commence après la fin des dispatchs.
arrayLength(&data)garde la fin du tableau. Un groupe de 64 lance 64 appels ; seuls les 4 premiers ont un élément.
Le schéma de repli#
Gardez les données dans un memblock sur les deux chemins. Le chemin GPU le téléverse et le relit ; le chemin CPU travaille dessus en place. Tout ce qui suit l'embranchement est le même code.
' Doubler 1024 valeurs : sur le GPU si possible, sinon sur le CPU.
count = 1024
data = CreateMemblock(count * 4)
FOR i = 0 TO count - 1
MemblockFloat(data, i * 4, i)
NEXT
IF ComputeSupported()
GOSUB DoubleOnGpu
ELSE
DoubleOnCpu(data, count)
ENDIF
Console(Str(MemblockFloat(data, 10 * 4))) ' 20, sur les deux chemins
END
FUNCTION DoubleOnCpu(mem, n)
FOR k = 0 TO n - 1
MemblockFloat(mem, k * 4, MemblockFloat(mem, k * 4) * 2)
NEXT
ENDFUNCTION
DoubleOnGpu:
buf = CreateComputeBuffer(count, "f32")
ComputeBufferFromMemblock(buf, data)
wgsl$ = "@group(0) @binding(0) var<storage, read_write> data: array<f32>; "
wgsl$ = wgsl$ + "@compute @workgroup_size(64) "
wgsl$ = wgsl$ + "fn main(@builtin(global_invocation_id) id: vec3<u32>) { "
wgsl$ = wgsl$ + "if (id.x >= arrayLength(&data)) { return; } "
wgsl$ = wgsl$ + "data[id.x] = data[id.x] * 2.0; }"
kernel = CreateComputeShader(wgsl$, "{\"data\":{\"group\":0,\"binding\":0}}")
ComputeShaderBuffer(kernel, "data", buf)
job = ComputeDispatchAsync(kernel, (count + 63) DIV 64)
WHILE PlatformJobDone(job) = 0
Sync()
ENDWHILE
' Relire sous le même identifiant : le libérer, puis laisser la lecture le recréer.
DeleteMemblock(data)
readJob = ComputeBufferReadAsync(buf, data)
WHILE PlatformJobDone(readJob) = 0
Sync()
ENDWHILE
DeleteComputeShader(kernel)
DeleteComputeBuffer(buf)
RETURN
- Le chemin GPU est une étiquette, pas une
FUNCTION: uneFUNCTIONne peut pas attendre. UnGOSUBvers une étiquette qui attend exige une boucleDOou unENDdans le programme. - Le chemin CPU est une
FUNCTIONordinaire : il n'attend pas. - Dans un vrai programme, vérifiez aussi
PlatformJobErroraprès chaque attente, comme dans l'exemple complet.
Pour tenir le code GPU tout à fait hors des exports, placez-le entre
#IF ENGINE et #ENDIF : l'éditeur définit ENGINE, les compilations HTML et
Bevy non. Voir le préprocesseur.
Durée de vie#
- Stop détruit tout. Tampons et shaders sont libérés quand le programme s'arrête, et leurs identifiants repartent de 1 au lancement suivant.
- Les dispatchs encore en file au Stop échouent avec
Compute session ended before queued work was dispatched. - Supprimez ce dont vous n'avez plus besoin pendant l'exécution : la mémoire GPU n'est pas récupérée pour vous tant que le programme tourne.
Ce qui change par rapport aux dialectes classiques#
AGK, DarkBASIC et PureBasic n'ont pas de calcul GPU. C'est un ajout d'aukimi, bâti sur WebGPU. Deux conséquences pour qui vient de ces dialectes :
- Les noyaux sont en WGSL, le langage de shaders de WebGPU. Les compute shaders GLSL ne se chargent pas.
- Tout est asynchrone. Un dispatch et une relecture sont des tâches qu'on attend d'une image à l'autre, jamais des appels qui bloquent jusqu'à ce que le GPU ait fini.
Les commandes Compute#
| Commande | Rôle |
|---|---|
ComputeSupported() | 1 si cette cible sait calculer sur GPU, sinon 0 |
CreateComputeBuffer | crée un tampon de stockage de f32, i32 ou u32 |
DeleteComputeBuffer | libère un tampon |
ComputeBufferExists | 1 si le tampon existe |
ComputeBufferSize | sa taille en octets |
ComputeBufferElementCount | son nombre d'éléments |
ComputeBufferType | son type d'élément |
ComputeBufferFromMemblock | téléverse un memblock de la taille exacte |
ComputeBufferReadAsync | relit le tampon dans un nouveau memblock, en tâche |
CreateComputeShader | compile un noyau WGSL |
DeleteComputeShader | libère un noyau |
ComputeShaderExists | 1 si le noyau existe |
ComputeShaderBuffer | lie un tampon à une variable WGSL |
ComputeDispatchAsync | met une exécution en file, en tâche |
Limites et pannes courantes#
- Testez
ComputeSupported()d'abord, et écrivez le chemin CPU. - Le memblock téléversé a exactement la taille du tampon.
- Les nombres du dispatch sont des groupes. Gardez la fin du tableau dans le WGSL.
- Attendez la tâche du dispatch avant de lancer la lecture.
- Un identifiant de relecture doit être libre. Supprimez d'abord l'ancien memblock.
- Le
Sync()des boucles d'attente doit céder :DO … LOOP,END, ou une étiquette. - Tampons de stockage seulement. Les paramètres vont dans un petit tampon à eux.