El coste real de la memoria
La jerarquía de memoria de una GPU medida en ciclos y en gigabytes por segundo, y por qué recalcular sale casi siempre más barato que guardar y volver a leer.
“La memoria es lenta” no es ingeniería, es un adjetivo. Lo que sirve es el número: una multiplicación-suma fusionada tiene una latencia de unos cuatro ciclos y una lectura de memoria global está entre trescientos y ochocientos. Dos órdenes de magnitud completos, y en el peor caso casi tres. Todas las decisiones de este nivel —el patrón de acceso, los tiles, la ocupación, la elección de formato— salen de interiorizar esa proporción y de entender el único mecanismo que tiene la GPU para sobrevivir a ella.
- Ordenar la jerarquía de memoria de una GPU por latencia en ciclos y en equivalentes de FMA.
- Distinguir la latencia del ancho de banda y determinar cuál de los dos limita un kernel.
- Calcular el presupuesto de bytes por píxel para una resolución y una tasa de refresco dadas.
- Justificar con números cuándo recalcular un valor sale más barato que almacenarlo y releerlo.
Dos órdenes de magnitud entre multiplicar y leer
La operación básica de una GPU es la multiplicación-suma fusionada, fma(a, b, c), que calcula a * b + c con un único redondeo. Su rendimiento es de una por ciclo y por carril: cada carril de cada unidad de ejecución puede retirar una FMA en cada ciclo de reloj, siempre. Su latencia es de unos cuatro ciclos en las arquitecturas recientes: desde que se emite la instrucción hasta que el resultado está disponible para la siguiente pasan cuatro ciclos. Esa distinción entre rendimiento y latencia es la que hace inteligible el resto de la tabla: cuatro FMA independientes se solapan y salen a una por ciclo; cuatro FMA encadenadas, donde cada una necesita el resultado de la anterior, tardan dieciséis.
Con la FMA como unidad de medida, la jerarquía queda así. Los valores son órdenes de magnitud y dependen de la arquitectura, del reloj y de la generación; ninguna GPU publica esta tabla y los rangos vienen de microbenchmarks de terceros repetidos durante años sobre familias distintas.
| operación | latencia aproximada (ciclos) | FMA que caben en ese tiempo, por carril |
|---|---|---|
| leer un registro | 0, forma parte del propio pipeline | — |
fma(a, b, c) |
4 a 6 | 1, es la unidad |
leer var<workgroup> sin conflicto de bancos |
20 a 40 | 20 a 40 |
| acierto en caché L1 o de texturas | 60 a 150 | 60 a 150 |
| acierto en caché L2 | 150 a 250 | unas 200 |
| fallo hasta memoria global (VRAM) | 300 a 800 | 300 a 800 |
| variable privada derramada a memoria | igual que VRAM | 300 a 800 |
sincronizar con la CPU (mapAsync y su await) |
del orden de 10⁶ | — |
Lee la última columna despacio, porque ahí está toda la lección. Una sola lectura de memoria global cuesta el tiempo de varios cientos de multiplicaciones. Y esa cuenta es por carril: si la haces por grupo SIMD de 32 carriles, el mismo intervalo de 500 ciclos habría dado para 16 000 FMA. Cuando dudes entre traer un valor de memoria y recalcularlo con una fórmula de veinte operaciones, la fórmula gana por un factor de veinte, no por un margen discutible.
La otra fila que conviene mirar es la de la variable privada derramada. Un array declarado dentro de una función e indexado con un valor que no se conoce en compilación no cabe en registros, porque los registros no se pueden indexar dinámicamente; el compilador lo coloca en memoria respaldada por VRAM. Un array<f32, 64> local que creías gratis puede estar costándote 400 ciclos por acceso sin que nada en el código lo insinúe.
El otro eje: el ancho de banda como techo de rendimiento
La latencia mide cuánto tarda una lectura. El ancho de banda mide cuántos bytes por segundo puede mover el bus en total. Son problemas distintos y se arreglan de forma distinta: la latencia se esconde con paralelismo, el ancho de banda no se esconde con nada, porque es un techo físico. Si tu frame necesita mover más bytes de los que caben en 16,6 milisegundos, no hay optimización de código que lo salve; hay que mover menos bytes.
Los órdenes de magnitud actuales, otra vez como rangos porque dependen del producto concreto:
- Una GPU integrada comparte la memoria con la CPU y está en el orden de 50 a 100 GB/s, y ese ancho de banda no es todo tuyo: el sistema operativo, el compositor y el propio navegador comen de ahí.
- Una dedicada de gama media con memoria GDDR está en el orden de 300 a 500 GB/s.
- Una de gama alta con memoria apilada supera los 1000 GB/s.
De ahí sale el presupuesto que de verdad importa, que es el de bytes por píxel y por frame. Divide el ancho de banda entre los píxeles por segundo:
| destino | píxeles por segundo | a 80 GB/s | a 400 GB/s | a 1000 GB/s |
|---|---|---|---|---|
| 1080p a 60 fps | 124 millones | 640 B/px | 3200 B/px | 8000 B/px |
| 1440p a 60 fps | 221 millones | 360 B/px | 1800 B/px | 4500 B/px |
| 1080p a 120 fps | 249 millones | 320 B/px | 1600 B/px | 4000 B/px |
| 4K a 60 fps | 498 millones | 160 B/px | 800 B/px | 2000 B/px |
Y ahora la corrección honesta: esas cifras son el pico teórico del bus. En la práctica un kernel bien escrito alcanza entre el 60 y el 85 por ciento, porque la DRAM pierde ciclos en refrescos y en cambios de fila, y porque ningún patrón real es perfecto. Trabaja con el 70 por ciento y no te llevarás sorpresas.
Haz la cuenta con un renderizador diferido a 1080p y 400 GB/s, que te deja unos 2200 bytes por píxel utilizables. El G-buffer son tres destinos RGBA8 más profundidad, 16 bytes escritos por fragmento, y con un solapamiento de 1,5 son 24. La pasada de iluminación lee esos 16 y escribe 8 en RGBA16F. El encadenado de bloom y el mapeado tonal rondan los 30. Los mapas de sombra, otros 10. Suma unos 90 bytes por píxel de destinos de render, cómodo. Lo que se come el presupuesto son las texturas, que según el número de materiales y el nivel de detalle van de 100 a 400 bytes por píxel. Cabe. Repite la misma cuenta a 4K sobre una integrada, con 160 bytes por píxel teóricos y unos 110 reales, y verás que el G-buffer solo ya no cabe. No es que el diferido sea mala arquitectura: es que en ese dispositivo no hay bus para pagarlo.
La GPU no esconde la latencia con caché, la esconde con hilos
Una CPU ataca la latencia con jerarquías de caché enormes, predicción de saltos y ejecución fuera de orden: gasta transistores en que ese hilo espere lo menos posible. Una GPU hace justo lo contrario. Sus cachés son pequeñas en proporción —unos pocos cientos de kilobytes de L2 para miles de hilos— y su planificador es sencillo. Lo que hace cuando un grupo SIMD se bloquea esperando una lectura es cambiar a otro grupo que sí tenga trabajo listo, y el cambio cuesta cero ciclos porque cada grupo residente conserva sus registros: no hay estado que salvar ni que restaurar. Es un cambio de contexto gratuito, y es la razón entera de que una GPU tenga bancos de registros gigantescos.
La consecuencia es exacta y merece quedarse grabada: la latencia solo duele cuando no hay suficientes hilos listos para taparla. Si en una unidad de ejecución hay dieciséis grupos residentes y uno se bloquea 500 ciclos, los otros quince siguen emitiendo instrucciones y la unidad no pierde un solo ciclo. Si solo hay dos grupos residentes y ambos están esperando, la unidad se queda parada y esos 500 ciclos son 500 ciclos tirados. El mismo shader, el mismo acceso a memoria, la misma latencia: la diferencia la marca cuántos hilos hay en vuelo, que es exactamente lo que mide la ocupación.
Esto también explica por qué un kernel puede ir a la mitad de velocidad sin que el perfilador señale ninguna instrucción cara. No hay ninguna instrucción cara; hay una unidad de ejecución sin nada que emitir.
Recalcular es barato, releer no
De todo lo anterior sale una regla que contradice el instinto que traes de la CPU, donde memorizar un resultado casi siempre gana:
Si el valor cuesta menos de un centenar de operaciones aritméticas, recalcúlalo. Guardarlo en memoria global y volver a leerlo cuesta una escritura y una lectura, y cada una de las dos vale por cientos de FMA.
Tres casos concretos donde esto cambia el diseño:
Tablas de consulta. Sustituir una fórmula por una lectura sobre una LUT, en un buffer o en una textura, es un patrón heredado de cuando la aritmética era cara. Hoy solo gana si la fórmula original supera el centenar de instrucciones o si la LUT es tan pequeña y tan coherente que vive permanentemente en caché. La codificación sRGB es el ejemplo típico:
// A: tabla de 256 entradas en un buffer. Una lectura de VRAM por invocacion.
// Si el indice varia de pixel a pixel, es un fallo de cache disfrazado de optimizacion.
let g = tablaGamma[u32(clamp(x, 0.0, 1.0) * 255.0)];
// B: la formula exacta. Unas diez instrucciones, cero lecturas.
let g = select(1.055 * pow(x, 1.0 / 2.4) - 0.055, x * 12.92, x <= 0.0031308);
Una LUT de curva de color de 256 entradas con acceso coherente es razonable; una de 4096 entradas indexada con un valor que varía de píxel a píxel cuesta más que cualquier fórmula que puedas escribir.
Buffers intermedios entre pasadas. Dos dispatch encadenados que se comunican por un buffer de storage pagan la escritura completa del intermedio más su lectura completa, más el coste de la propia pasada. Fusionar los dos kernels en uno, aunque obligue a repetir parte del cálculo en cada invocación, suele ganar. La excepción es cuando el intermedio se lee muchas veces: ahí el factor de reutilización cambia la cuenta, y esa es la materia de la reutilización de datos.
Atributos derivables. Una normal se puede almacenar (12 bytes, o 8 comprimida) o derivar de la geometría con un producto vectorial y una normalización, que son unas diez operaciones. Diez operaciones frente a una lectura de 400 ciclos: no hay debate cuando el dato no está ya en caché por otro motivo.
Hay una forma de convertir la intuición de “hacen falta muchos hilos” en una cifra, y es la ley de Little aplicada al subsistema de memoria: la cantidad de bytes que tienen que estar en vuelo simultáneamente es igual al ancho de banda multiplicado por la latencia. Haz la cuenta con números de gama media. Una latencia de 500 ciclos a 1,5 GHz son 333 nanosegundos. Multiplicado por 400 GB/s da 133 000 bytes: para saturar el bus tiene que haber unos 130 KB de lecturas pedidas y no servidas en todo momento. Si cada grupo SIMD de 32 carriles emite una lectura coalescente de 128 bytes y se para a esperarla, hacen falta más de mil grupos en vuelo, es decir del orden de 33 000 hilos residentes. Una GPU de gama media tiene sitio para unos 50 000, así que el margen existe pero es estrecho, y se evapora en cuanto la ocupación baja del cincuenta por ciento. Ahora la parte que casi nadie deduce: en la fórmula, los bytes en vuelo son el producto de hilos por lecturas independientes por hilo, y esos dos factores son intercambiables. Emitir cuatro lecturas independientes por hilo antes de usar la primera vale exactamente lo mismo que cuadruplicar el número de hilos. Por eso un kernel desenrollado que lanza cuatro cargas seguidas y luego opera con las cuatro puede saturar el bus con una ocupación del 25 por ciento, mientras que un kernel que alterna carga y uso necesita ocupación máxima para el mismo resultado. Y por eso la receta de “sube la ocupación” fracasa tantas veces: el problema no era el número de hilos, era que cada hilo solo tenía una petición en vuelo. Cuando escribas un bucle que lee, opera y vuelve a leer, mira si puedes separar las lecturas de los usos. Es la optimización de memoria más barata que existe y no aparece en ningún perfilador.
- Escribe un kernel que lea un buffer grande de forma perfectamente secuencial y mide los GB/s reales de tu máquina. Compáralos con el pico teórico que anuncia el fabricante.
- Repite el kernel leyendo con un salto de 256 bytes entre invocaciones consecutivas y anota la caída.
- Sustituye una
textureSamplede una LUT por la fórmula equivalente y mide cuál gana en tu GPU. - Calcula el presupuesto de bytes por píxel de tu escena a la resolución y la tasa de refresco a las que quieres ir, y compáralo con lo que de verdad estás moviendo.