Reutilización de datos y tiling
El único principio que ahorra ancho de banda de verdad, con la multiplicación de matrices por bloques como caso de estudio y la cuenta exacta de los 16384 bytes.
Puedes optimizar el patrón de acceso hasta que cada transacción aproveche el cien por cien del bus y aun así estar moviendo veinte veces más bytes de los necesarios, simplemente porque traes el mismo valor una y otra vez. Solo hay un principio que reduce el tráfico total en lugar de hacerlo más eficiente: traer un dato una vez y usarlo muchas. La memoria compartida del workgroup existe exactamente para eso, y la multiplicación de matrices es el caso donde la aritmética sale tan limpia que se puede seguir con lápiz.
- Contar el tráfico de memoria global de un algoritmo antes y después de aplicar bloques.
- Escribir un kernel con carga cooperativa y las dos barreras que exige la corrección.
- Comprobar contra
maxComputeWorkgroupStorageSizesi un tamaño de bloque cabe. - Aplicar el criterio de reutilización a filtros separables y a convoluciones con halo.
Traer una vez, usar muchas
Multiplica dos matrices de N por N. La versión directa asigna una invocación a cada elemento del resultado, y esa invocación lee una fila completa de A y una columna completa de B: 2N valores para producir un número. Con N² resultados, el total son 2N³ lecturas de memoria global.
Pon números. Con N igual a 1024 y f32:
| magnitud | valor |
|---|---|
| lecturas de memoria global | 2 · 1024³ = 2,15 · 10⁹ |
| bytes movidos | 8,6 GB |
| operaciones de coma flotante | 2 · 1024³ = 2,15 GFLOP |
| tiempo solo de mover datos a 400 GB/s | 21,5 ms |
| tiempo solo de calcular a 10 TFLOP/s | 0,215 ms |
Cien veces más tiempo moviendo bytes que multiplicando. Y el dato interesante es que cada valor de A se lee N veces —una por cada columna del resultado— y cada valor de B también. Las matrices completas ocupan 4 MiB cada una: estás moviendo 8,6 GB para leer 8 MiB de datos. El mil por uno de repetición es todo el problema.
El tiling lo ataca de frente. Divide el resultado en bloques de T por T. Cada bloque necesita una banda de T filas de A y una banda de T columnas de B, que se recorren en trozos de T por T. Cada trozo se carga una sola vez en memoria compartida y desde ahí lo leen las T invocaciones que lo necesitan. El tráfico global pasa de 2N³ a 2N³/T lecturas: el factor de ahorro es exactamente el lado del bloque.
Con T igual a 16, los 8,6 GB se convierten en 537 MB y los 21,5 ms en 1,34 ms. Sigue habiendo seis veces más tiempo de memoria que de cálculo —el kernel continúa limitado por memoria— pero ha ganado un factor de dieciséis sin cambiar una sola operación aritmética.
El kernel completo
const T: u32 = 16u; // tiene que coincidir con @workgroup_size
struct Dims { M: u32, N: u32, K: u32, relleno: u32 };
@group(0) @binding(0) var<uniform> d: Dims;
@group(0) @binding(1) var<storage, read> A: array<f32>; // M por K
@group(0) @binding(2) var<storage, read> B: array<f32>; // K por N
@group(0) @binding(3) var<storage, read_write> C: array<f32>; // M por N
var<workgroup> tileA: array<array<f32, T>, T>;
var<workgroup> tileB: array<array<f32, T>, T>;
@compute @workgroup_size(16, 16)
fn gemm(@builtin(workgroup_id) wid: vec3u,
@builtin(local_invocation_id) lid: vec3u) {
let fila = wid.y * T + lid.y;
let col = wid.x * T + lid.x;
let pasos = (d.K + T - 1u) / T;
var acc = 0.0;
for (var p: u32 = 0u; p < pasos; p = p + 1u) {
let aCol = p * T + lid.x;
let bFila = p * T + lid.y;
// Carga cooperativa: cada invocacion trae un elemento de cada tile.
// Los hilos con lid.x consecutivo leen posiciones consecutivas de A y de B:
// las dos cargas son coalescentes.
if (fila < d.M && aCol < d.K) {
tileA[lid.y][lid.x] = A[fila * d.K + aCol];
} else {
tileA[lid.y][lid.x] = 0.0; // el cero no altera el producto
}
if (bFila < d.K && col < d.N) {
tileB[lid.y][lid.x] = B[bFila * d.N + col];
} else {
tileB[lid.y][lid.x] = 0.0;
}
workgroupBarrier(); // nadie calcula hasta que el tile este completo
for (var k: u32 = 0u; k < T; k = k + 1u) {
acc = fma(tileA[lid.y][k], tileB[k][lid.x], acc);
}
workgroupBarrier(); // nadie sobrescribe el tile hasta que todos hayan leido
}
if (fila < d.M && col < d.N) {
C[fila * d.N + col] = acc;
}
}
Del lado de JavaScript el dispatch es una cuadrícula de bloques, y maxComputeWorkgroupsPerDimension vale 65535, lo que con bloques de 16 da matrices de hasta un millón de columnas: no es el límite que te va a molestar.
pass.dispatchWorkgroups(Math.ceil(N / 16), Math.ceil(M / 16));
Hay cuatro decisiones en ese kernel que conviene que sepas defender.
Las dos barreras no son una duplicada. La primera resuelve el peligro de escritura seguida de lectura: si una invocación empieza a multiplicar antes de que sus compañeras hayan terminado de rellenar el tile, lee basura. La segunda resuelve el de lectura seguida de escritura: si una invocación rápida empieza la iteración siguiente y sobrescribe tileA mientras otra todavía está en el bucle interior, corrompe el cálculo de la otra. Olvidar la segunda es el error clásico, y es traicionero porque un workgroup pequeño que quepa en un solo grupo SIMD avanza en bloqueo de paso y da el resultado correcto por accidente; con 256 invocaciones repartidas en ocho grupos SIMD ya no, y falla en una máquina y no en otra.
La comprobación de límites carga ceros en vez de salir. La tentación es escribir if (fila >= d.M) { return; } al principio. No se puede: workgroupBarrier() exige control de flujo uniforme dentro del workgroup, y si una parte de las invocaciones ha salido, las que quedan esperan en la barrera a compañeras que nunca van a llegar. El comportamiento formal es indefinido y el práctico es un cuelgue o un resultado sucio. Cargar cero en las posiciones fuera de rango es correcto porque el cero es el elemento neutro del producto acumulado, y todas las invocaciones llegan a todas las barreras.
Las dos cargas son coalescentes. En A[fila * d.K + aCol], las invocaciones con lid.x consecutivo leen direcciones consecutivas; en B[bFila * d.N + col], también. Si hubieras escrito el tile de A transpuesto al cargarlo, la carga dejaría de ser coalescente y perderías por un lado lo ganado por el otro.
El bucle interior lee la memoria compartida de dos formas distintas. tileB[k][lid.x] recorre posiciones consecutivas al variar lid.x, que es el caso ideal. tileA[lid.y][k] da la misma dirección para todas las invocaciones que comparten lid.y, y una lectura de la misma dirección por muchos carriles se resuelve con una difusión, que es gratis. Ninguno de los dos accesos serializa.
El presupuesto: 16384 bytes y 256 invocaciones
maxComputeWorkgroupStorageSize vale 16384 bytes por defecto y maxComputeInvocationsPerWorkgroup vale 256. Haz la cuenta explícita antes de elegir el lado del bloque, porque los dos límites muerden a la vez y el que muerde primero no es el que la gente espera:
lado T |
invocaciones si un hilo por elemento | bytes por tile | dos tiles | veredicto |
|---|---|---|---|---|
| 8 | 64 | 256 | 512 | cabe, el 3 % del límite |
| 16 | 256 | 1024 | 2048 | cabe, el 12,5 % del límite, y agota las invocaciones justo |
| 32 | 1024 | 4096 | 8192 | la memoria cabe al 50 %, pero 1024 invocaciones cuadruplican el límite |
| 64 | 4096 | 16384 | 32768 | un solo tile consume el límite entero; los dos son imposibles |
Un bloque de 16 por 16 de f32 ocupa 1024 bytes por matriz y las dos matrices caben de sobra. Un bloque de 64 por 64 ocupa exactamente los 16384 bytes del límite él solo, así que ni siquiera hay sitio para el segundo, y además necesitaría 4096 invocaciones por workgroup, dieciséis veces por encima del máximo. La conclusión es que con un hilo por elemento del bloque, 16 por 16 es el techo, y no por la memoria compartida sino por el número de invocaciones.
Los 2048 bytes del caso bueno tienen otra virtud: son tan pocos que la memoria compartida deja de ser el recurso que limita cuántos workgroups residen a la vez en una unidad de ejecución. Si la unidad dispone de 64 KB de memoria compartida —cifra típica, dependiente del hardware—, con 2048 bytes por grupo caben treinta y dos, y el cuello pasa a estar en los registros o en el número máximo de invocaciones residentes.
El mismo principio fuera de las matrices
El desenfoque separable. Un núcleo gaussiano de k por k cuesta k² muestras por píxel. Como el gaussiano es separable, se puede hacer en dos pasadas de k muestras cada una, una horizontal y otra vertical, con un resultado idéntico salvo redondeo. El ahorro crece con el radio:
k |
muestras directas | separable | factor |
|---|---|---|---|
| 5 | 25 | 10 | 2,5 |
| 9 | 81 | 18 | 4,5 |
| 15 | 225 | 30 | 7,5 |
| 33 | 1089 | 66 | 16,5 |
El precio es una textura intermedia: a 1080p en RGBA16F son 16,6 MB que se escriben y se vuelven a leer, unos 83 microsegundos a 400 GB/s. Por debajo de k igual a 5 no se amortiza; a partir de 9 gana siempre. Y ojo con la condición: la separabilidad es una propiedad del núcleo, no una técnica. El gaussiano y la caja son separables; un filtro bilateral no lo es, y las versiones separables que circulan son aproximaciones que dejan artefactos en los bordes.
El halo compartido. En una convolución con un núcleo de radio R, un bloque de 16 por 16 píxeles necesita leer un rectángulo de (16 + 2R) por (16 + 2R) para producir sus 256 salidas. Con R igual a 4 son 576 lecturas para 256 resultados: 2,25 lecturas por salida, frente a las 81 de la versión directa. El halo tiene un sobrecoste propio que conviene cuantificar, porque decide el tamaño del bloque:
| bloque | rectángulo a cargar con radio 4 | lecturas | por salida | sobrecoste del halo |
|---|---|---|---|---|
| 8 x 8 | 16 x 16 | 256 | 4,0 | 300 % |
| 16 x 16 | 24 x 24 | 576 | 2,25 | 125 % |
| 32 x 32 | 40 x 40 | 1600 | 1,56 | 56 % |
Los bloques grandes amortizan mejor el borde. Es la razón real por la que los kernels de convolución tienden a bloques de 32 por 32 con varias salidas por hilo, y no a 8 por 8.
La regla de decisión, que resume el nivel entero en una frase: si un dato lo va a leer más de un hilo del mismo workgroup, cárgalo a memoria compartida; si no, no. Un factor de reutilización de uno significa que has añadido una escritura, una barrera y una lectura para sustituir una lectura de memoria global que además habría sido coalescente. Con factor dos apenas empatas. A partir de cuatro, gana claro.
El kernel de arriba es el que aparece en todos los tutoriales y se queda a un factor de cuatro del que usan las bibliotecas serias. Lo que le falta no es más memoria compartida: es dejar de usarla tanto. Fíjate en el bucle interior: cada fma necesita dos lecturas de memoria compartida. La memoria compartida tiene 32 bancos de 4 bytes que sirven una palabra por ciclo cada uno, así que a dos lecturas por operación el kernel satura el ancho de banda de la propia memoria compartida antes de acercarse al de las unidades aritméticas. Has cambiado un cuello de botella por otro más pequeño. La solución es el bloqueo en registros: que cada invocación calcule un micro-bloque de 4 por 4 salidas en vez de una sola. Carga 4 valores de tileA y 4 de tileB en variables locales —que viven en registros, latencia cero— y con esas 8 lecturas haces 16 fma. La proporción cae de 2 lecturas por operación a 0,5: cuatro veces menos presión sobre la memoria compartida. Y hay una segunda ganancia, mayor: con 256 invocaciones que producen 16 salidas cada una, el bloque de salida pasa a ser de 64 por 64 sin romper maxComputeInvocationsPerWorkgroup, y los tiles compartidos que hacen falta son de 64 por 16 y 16 por 64, es decir 4096 bytes cada uno, 8192 en total: la mitad justa del presupuesto de 16384. El tráfico global por operación se divide por otros cuatro. El motivo de fondo es que el banco de registros de una unidad de ejecución es del orden de 64 KB, cuatro veces mayor que toda la memoria compartida que un workgroup puede pedir, y es el único nivel de la jerarquía con latencia cero. La memoria compartida no es la cima de la pirámide: es el escalón intermedio que usas para llenar los registros. Cuando veas un kernel que va bien y otro que va cuatro veces mejor, la diferencia casi nunca está en el tile, está en cuántas salidas lleva cada hilo en registros.
- Implementa la versión directa y la de bloques de la multiplicación de matrices y mide las dos con
Nigual a 1024. Comprueba si el cociente se acerca a 16. - Calcula el tráfico teórico de las dos, divídelo por el tiempo medido y compara los GB/s que obtienes con el pico de tu GPU.
- Modifica el kernel para que cada invocación calcule 2 por 2 salidas y vuelve a medir.
- Implementa un desenfoque de radio 8 de las dos formas, directa y separable, y encuentra el radio en el que se cruzan en tu máquina.