wandres.dev
WGSL II · Tipos y espacios de dirección

Escalares, literales abstractos y el tipo f16

Los cuatro tipos escalares de WGSL, por qué un literal sin sufijo no tiene tipo todavía, las conversiones explícitas frente a bitcast, y qué hace falta para usar medios flotantes.

⏱ 16 min

El sistema de tipos de WGSL rechaza sumar un entero y un flotante, y sin embargo acepta let x : f32 = 1; sin protestar. La aparente contradicción tiene una explicación exacta y es el mecanismo de tipos numéricos abstractos, que es lo que hace que un lenguaje sin conversiones implícitas siga siendo escribible a mano.

🎯 Al terminar esta lección sabrás
  • Enumerar los tipos escalares de WGSL y qué se puede hacer con cada uno.
  • Predecir el tipo concreto de un literal según su contexto y sus sufijos.
  • Convertir entre tipos con constructores y distinguirlo de reinterpretar con bitcast.
  • Activar f16 correctamente y decidir cuándo compensa.

Los cuatro escalares, y medio

WGSL tiene cuatro tipos escalares en el lenguaje base: i32, u32, f32 y bool. Un quinto, f16, existe solo si lo pides.

i32 es un entero con signo de 32 bits en complemento a dos, y su aritmética envuelve al desbordar en vez de ser comportamiento indefinido. u32 es su versión sin signo, y es el tipo natural de todos los índices e identificadores del lenguaje: vertex_index, instance_index, los componentes de global_invocation_id y el resultado de arrayLength son todos u32. Esa elección obliga a escribir conversiones al mezclarlos con i32, y es deliberada.

f32 es un flotante IEEE-754 de precisión simple, con una salvedad importante para la portabilidad: la especificación deja margen a las implementaciones en el tratamiento de los subnormales y en el redondeo de algunas operaciones, y las funciones trascendentes tienen un error permitido en unidades del último lugar en vez de un resultado exacto. Dos GPUs pueden dar bits distintos para el mismo sin, y las dos cumplen.

bool es el tipo lógico, y tiene una propiedad que hay que saber desde el primer día: no es host-shareable. No tiene tamaño ni alineación definidos, y por tanto no puede aparecer dentro de una struct que viva en un uniform buffer o en un storage buffer, ni en un @location entre etapas. Sirve dentro de la lógica del shader y en ningún sitio más.

struct Opciones {
  // activo : bool,     // ERROR: bool no puede cruzar la frontera con el host
  activo : u32,         // asi se hace: 0 o 1
  intensidad : f32,
};

El idioma es un u32 con valor 0 o 1 y una conversión donde haga falta: if (opciones.activo != 0u) o bool(opciones.activo), que da lo mismo.

El literal no tiene tipo hasta que lo necesita

Un literal numérico sin sufijo no es un i32 ni un f32: es un entero abstracto o un flotante abstracto. Son tipos que solo existen durante la compilación, con precisión suficiente para no perder nada, y que se materializan al tipo concreto que exija el contexto.

let a : f32 = 1;        // el 1 abstracto se materializa como f32
let b : u32 = 7;        // el mismo 1 abstracto vale como u32
let c = 1;              // sin contexto: se materializa a i32
let d = 1.5;            // sin contexto: f32
let e = vec3f(0);       // vec3f(0.0, 0.0, 0.0)
let g = 4 / 3;          // aritmetica entera abstracta: vale 1, tipo i32
let h : f32 = 4.0 / 3;  // el 3 se materializa como flotante: 1.3333333

Los sufijos fuerzan el tipo desde el principio: 1i es i32, 1u es u32, 1f es f32 y 1h es f16. También existen los literales hexadecimales enteros, 0xff, y los hexadecimales de coma flotante con exponente binario, 0x1p3, que valen para escribir constantes con bits exactos.

La regla que resume todo esto: los literales se adaptan al contexto, las variables no. En cuanto un valor tiene tipo concreto, mezclarlo con otro tipo concreto es un error y hace falta una conversión escrita:

let n : u32 = 10u;
// let mal = n * 0.5;      // ERROR: u32 por f32
let bien = f32(n) * 0.5;   // f32
⚠️
La división entera abstracta se evalúa como entera

let mitad : f32 = 1 / 2; vale 0.0, no 0.5. Los dos operandos son enteros abstractos, la división es entera, el resultado es el entero abstracto cero y solo entonces se materializa como f32. Es exactamente la trampa de C y de Java, y aquí no hay aviso. Escribe 1.0 / 2.0 o al menos uno de los dos con punto.

Convertir frente a reinterpretar

Hay dos operaciones distintas y confundirlas produce números que no se parecen a nada.

Convertir usa el nombre del tipo como constructor y preserva el valor en la medida en que quepa: f32(3) da 3.0, i32(3.7) da 3 truncando hacia cero, u32(-1.0) no da lo que esperas. Cuando el valor de origen no es representable en el destino —un flotante fuera del rango de i32, un NaN— el resultado es un valor indeterminado del tipo destino: algún valor válido, nunca un fallo ni una lectura de memoria ajena, pero tampoco un número en el que puedas confiar. Comprobar el rango antes de convertir es responsabilidad tuya.

Reinterpretar usa bitcast y preserva los bits, cambiando el valor:

let f : f32 = 1.0;
let bits : u32 = bitcast<u32>(f);      // 0x3f800000
let vuelta : f32 = bitcast<f32>(bits); // 1.0 otra vez

bitcast exige que el tipo de origen y el de destino tengan el mismo tamaño total, así que vale entre f32, i32 y u32, entre vectores del mismo número de componentes y anchura, y entre un vec2<f16> y un u32. Sus usos reales son tres: empaquetar datos heterogéneos en un buffer de enteros, extraer el exponente o la mantisa de un flotante para depurar precisión, y aplicar trucos de bits sobre flotantes como el de ordenar por profundidad usando la representación entera.

f16, y lo que cuesta pedirlo

f16 es un flotante de 16 bits: un bit de signo, cinco de exponente y diez de mantisa, con un rango que llega hasta 65504 y una precisión relativa de unos tres dígitos decimales. No está en el lenguaje base y hacen falta dos cosas para usarlo.

Primero, la feature al crear el dispositivo:

const adaptador = await navigator.gpu.requestAdapter();
if (!adaptador.features.has('shader-f16')) {
  // hay que tener una ruta alternativa con f32
}
const device = await adaptador.requestDevice({ requiredFeatures: ['shader-f16'] });

Y segundo, la directiva en el shader, que va antes de cualquier declaración:

enable f16;

fn mezclaBarata(a : vec4h, b : vec4h, t : f16) -> vec4h {
  return mix(a, b, vec4h(t));
}

Los alias predeclarados son vec2h, vec3h, vec4h y la familia mat2x2h a mat4x4h. Los literales llevan sufijo h.

Dónde compensa de verdad: en el ancho de banda, no en la ALU. La mayoría de las GPUs de escritorio ejecutan operaciones de 16 bits a la misma velocidad que las de 32, así que el ahorro de ciclos es cero o casi. Lo que se ahorra es memoria y tráfico: un buffer de pesos a la mitad, una struct de material que baja de 64 a 32 bytes, variables entre etapas que ocupan la mitad. En GPUs móviles, además, muchas arquitecturas sí ejecutan el doble de operaciones de 16 bits por ciclo, y ahí el ahorro es real en las dos dimensiones.

Dónde no compensa nunca: en posiciones del mundo, en acumuladores de sumas largas y en cualquier cosa que se compare consigo misma tras muchas operaciones. Con diez bits de mantisa, el entero más grande representable de forma exacta es 2048; a partir de ahí x + 1.0 puede valer x.

Si quieres saber cómo se comportará un valor en f16 sin activar la feature, existe quantizeToF16, que redondea un f32 a la precisión de medio flotante y devuelve un f32. Es la forma de simular el error de cuantización en un shader que sigue siendo de 32 bits.

El bool que no cruza la frontera es la primera lección de que WGSL distingue dos mundos

Que bool no sea host-shareable parece un capricho hasta que ves de dónde viene: no existe acuerdo sobre cuánto ocupa un booleano en memoria de GPU. Unos backends lo representan con un byte, otros con una palabra de 32 bits, otros con un bit dentro de una máscara de predicado que ni siquiera vive en memoria direccionable. Definir un tamaño obligaría a las implementaciones a convertir, y convertir en cada lectura de cada struct es exactamente el tipo de coste oculto que WebGPU se niega a introducir.

La consecuencia va mucho más allá del booleano y es la idea que estructura todo el nivel: el conjunto de tipos que un shader puede usar internamente es mayor que el conjunto de tipos que puede compartir con la CPU. Dentro tienes bool, punteros, referencias, arrays de tamaño fijo con cualquier disposición, matrices de cualquier forma. En la frontera solo pasan los tipos host-shareable, con su alineación y su tamaño definidos al bit, y todo lo demás hay que codificarlo.

De ahí salen los idiomas que verás en cualquier shader serio: banderas empaquetadas en un u32 con máscaras en vez de una struct de booleanos, índices en vez de punteros, y vec4 en vez de arrays de escalares. No son manías de programador de bajo nivel: son la traducción obligatoria entre un lenguaje expresivo y un formato de memoria que tiene que significar lo mismo en cuatro arquitecturas distintas. En cuanto interiorizas que la frontera existe y dónde está, la mitad de las reglas raras de WGSL dejan de ser raras.

Con los escalares claros, toca el tipo que de verdad usa una GPU: vectores y matrices.