{"schemaVersion":1,"id":"https://lucioduran.com/es/blog/webgpu-compute-shaders-ml-inference","url":"https://lucioduran.com/es/blog/webgpu-compute-shaders-ml-inference","title":"Compute Shaders con WebGPU para Inferencia ML: Ejecución de Modelos en el Cliente","description":"Ejecución de modelos ML directamente en el navegador con compute shaders de WebGPU. Implementación en WGSL, benchmarks contra WebGL e inferencia server-side, y limitaciones prácticas de la ejecución de modelos en el cliente.","language":"es","datePublished":"2025-10-01","dateModified":null,"contentFormat":"text/markdown","content":"WebGPU\n\n# Compute Shaders con WebGPU para Inferencia ML: Ejecución de Modelos en el Cliente\n\n2025-10-0110 min lectura\n\nLucio Durán\nEngineering Manager & AI Solutions Architect\n\nTambién disponible en: [English](<https://lucioduran.com/blog/webgpu-compute-shaders-ml-inference>)\n\n## ¿Por Qué No Usar ONNX Runtime Web Directamente?\n\n[ONNX Runtime Web](<https://onnxruntime.ai/>) es una herramienta sólida para producción. Sin embargo, es una abstracción sobre WebGPU (o WebGL, o WASM), y las abstracciones tienen costos. Al perfilar una sesión de ONNX Runtime Web corriendo un modelo DistilBERT, aproximadamente el 18% del tiempo total de inferencia se iba en overhead de dispatch de operadores, bookkeeping de alocación de memoria, y pases de optimización de grafo que ya habían sido aplicados al modelo.\n\nEscribir compute shaders directamente te da control sobre:\n\n- Las dimensiones exactas de workgroup y la geometría de dispatch\n- El layout de memoria en storage y uniform buffers\n- La sincronización entre pases\n- Aritmética cuantizada sin dequantización en runtime\n\nLa contra es que se está escribiendo código de GPU. No hay almuerzo gratis.\n\n## Armando el Pipeline WebGPU\n\nPrimero, el boilerplate. El código de negociación de adapter/device es boilerplate estándar; las partes interesantes vienen a continuación.\n\n```javascript\nconst adapter = await navigator.gpu.requestAdapter({\n powerPreference: 'high-performance'\n});\n\nconst device = await adapter.requestDevice({\n requiredFeatures: ['timestamp-query'],\n requiredLimits: {\n maxStorageBufferBindingSize: 256 * 1024 * 1024,\n maxBufferSize: 256 * 1024 * 1024,\n maxComputeWorkgroupSizeX: 256,\n maxComputeInvocationsPerWorkgroup: 256,\n }\n});\n```\n\nEse ` maxStorageBufferBindingSize ` es importante, el default es 128MB en muchas implementaciones, y un modelo cuantizado de 125M parámetros puede excederlo fácilmente cuando incluís los buffers de KV cache.\n\n## El Núcleo: Multiplicación de Matrices en WGSL\n\nLa columna vertebral de la inferencia de transformers es la multiplicación de matrices. A continuación, va un compute shader WGSL que hace matmul con tiles y memoria compartida, que es el approach mínimo viable para rendimiento aceptable:\n\n```wgsl\n@group(0) @binding(0) var<storage, read> a: array<f32>;\n@group(0) @binding(1) var<storage, read> b: array<f32>;\n@group(0) @binding(2) var<storage, read_write> result: array<f32>;\n\nstruct Dimensions {\n M: u32,\n N: u32,\n K: u32,\n}\n@group(0) @binding(3) var<uniform> dims: Dimensions;\n\nconst TILE_SIZE: u32 = 16u;\n\nvar<workgroup> tileA: array<array<f32, 16>, 16>;\nvar<workgroup> tileB: array<array<f32, 16>, 16>;\n\n@compute @workgroup_size(16, 16, 1)\nfn main(\n @builtin(global_invocation_id) gid: vec3<u32>,\n @builtin(local_invocation_id) lid: vec3<u32>,\n) {\n let row = gid.x;\n let col = gid.y;\n var sum: f32 = 0.0;\n\n let numTiles = (dims.K + TILE_SIZE - 1u) / TILE_SIZE;\n\n for (var t: u32 = 0u; t < numTiles; t = t + 1u) {\n let tiledCol = t * TILE_SIZE + lid.y;\n let tiledRow = t * TILE_SIZE + lid.x;\n\n if (row < dims.M && tiledCol < dims.K) {\n tileA[lid.x][lid.y] = a[row * dims.K + tiledCol];\n } else {\n tileA[lid.x][lid.y] = 0.0;\n }\n\n if (tiledRow < dims.K && col < dims.N) {\n tileB[lid.x][lid.y] = b[tiledRow * dims.N + col];\n } else {\n tileB[lid.x][lid.y] = 0.0;\n }\n\n workgroupBarrier();\n\n for (var k: u32 = 0u; k < TILE_SIZE; k = k + 1u) {\n sum = sum + tileA[lid.x][k] * tileB[k][lid.y];\n }\n\n workgroupBarrier();\n }\n\n if (row < dims.M && col < dims.N) {\n result[row * dims.N + col] = sum;\n }\n}\n```\n\nEste es el approach tileado estándar. El detalle clave que la mayoría de los tutoriales se pierden: esas dos llamadas a ` workgroupBarrier() ` son innegociables. La primera asegura que el tile esté completamente cargado antes de computar; la segunda asegura que la computación esté completa antes de reusar la memoria del tile. Saquí cualquiera de las dos y se va a tener resultados incorrectos intermitentes que son un infierno para debuggear.\n\n## Yendo a Cuantizado: Matmul INT4\n\nCorrer matmul en FP32 funciona, pero es un desperdicio para inferencia. La mayoría de los modelos de producción usan cuantización INT4 o INT8. A continuación, resulta interesante, WGSL no tiene tipos int4 nativos, así que empaquetás dos valores int4 en un solo u8 (bueno, u32, ya que el tipo más pequeño de WGSL es 32-bit).\n\n```wgsl\nfn unpack_int4(packed: u32, index: u32) -> f32 {\n let shift = (index % 8u) * 4u;\n let nibble = (packed >> shift) & 0xFu;\n // Sign-extend desde 4-bit\n let signed = i32(nibble) - select(0, 16, nibble >= 8u);\n return f32(signed);\n}\n\n@compute @workgroup_size(256, 1, 1)\nfn quantized_matmul(\n @builtin(global_invocation_id) gid: vec3<u32>,\n) {\n let row = gid.x / dims.N;\n let col = gid.x % dims.N;\n\n if (row >= dims.M) { return; }\n\n var sum: f32 = 0.0;\n let groupSize: u32 = 32u;\n\n for (var k: u32 = 0u; k < dims.K; k = k + 1u) {\n let group_idx = k / groupSize;\n let scale = scales[row * (dims.K / groupSize) + group_idx];\n let zero = zeros[row * (dims.K / groupSize) + group_idx];\n\n let packed_idx = (row * dims.K + k) / 8u;\n let w = unpack_int4(weights_packed[packed_idx], k);\n let dequant = (w - zero) * scale;\n\n sum = sum + input[col * dims.K + k] * dequant;\n }\n\n result[row * dims.N + col] = sum;\n}\n```\n\nLa función ` unpack_int4 ` hace el trabajo pesado. Guardamos 8 valores int4 por u32, desempaquetamos on the fly, y aplicamos dequantización por grupo. El tamaño de grupo de 32 coincide con lo que usan GPTQ y AWQ, así que se puede cargar modelos cuantizados directamente.\n\n## El Mecanismo de Atención\n\nSelf-attention es la otra pieza crítica para rendimiento. Lo que la mayoría de la gente hace mal: implementan atención como tres dispatches separados (Q*K^T, softmax, attn*V). Pero se puede fusionar el softmax en el mismo pase que la multiplicación Q\\*K^T si usás el truco de softmax online:\n\n```wgsl\n@compute @workgroup_size(256, 1, 1)\nfn fused_attention(\n @builtin(global_invocation_id) gid: vec3<u32>,\n @builtin(local_invocation_id) lid: vec3<u32>,\n) {\n let head = gid.x / seq_len;\n let query_pos = gid.x % seq_len;\n\n if (head >= num_heads) { return; }\n\n var max_score: f32 = -1e10;\n var sum_exp: f32 = 0.0;\n var acc: array<f32, 64>; // head_dim\n\n // Online softmax: pase único sobre K\n for (var k: u32 = 0u; k < seq_len; k = k + 1u) {\n var score: f32 = 0.0;\n for (var d: u32 = 0u; d < head_dim; d = d + 1u) {\n score += q[head * seq_len * head_dim + query_pos * head_dim + d]\n * keys[head * seq_len * head_dim + k * head_dim + d];\n }\n score = score / sqrt(f32(head_dim));\n\n let new_max = max(max_score, score);\n let correction = exp(max_score - new_max);\n sum_exp = sum_exp * correction + exp(score - new_max);\n\n for (var d: u32 = 0u; d < head_dim; d = d + 1u) {\n acc[d] = acc[d] * correction\n + exp(score - new_max)\n * values[head * seq_len * head_dim + k * head_dim + d];\n }\n max_score = new_max;\n }\n\n // Normalizar\n for (var d: u32 = 0u; d < head_dim; d = d + 1u) {\n output[head * seq_len * head_dim + query_pos * head_dim + d]\n = acc[d] / sum_exp;\n }\n}\n```\n\nEl softmax online evita un segundo pase sobre los scores de atención para normalizar. En una secuencia de 512 tokens con 12 heads, esta fusión ahorró ~4ms por capa en una RTX 3060, que se acumula rápido a lo largo de 6 o 12 capas.\n\n## La Orquestación de Dispatches\n\nAtando todo junto en JavaScript. A continuación, es donde coordinás la inferencia multi-pase:\n\n```javascript\nclass WebGPUInferenceEngine {\n constructor(device, model) {\n this.device = device;\n this.pipelines = {};\n this.buffers = {};\n this.model = model;\n }\n\n async buildPipelines() {\n this.pipelines.embed = await this.createComputePipeline(embeddingShader);\n this.pipelines.layerNorm = await this.createComputePipeline(layerNormShader);\n this.pipelines.attention = await this.createComputePipeline(fusedAttentionShader);\n this.pipelines.ffn = await this.createComputePipeline(quantizedMatmulShader);\n this.pipelines.softmax = await this.createComputePipeline(finalSoftmaxShader);\n }\n\n runInference(inputTokens) {\n const encoder = this.device.createCommandEncoder();\n\n // Embedding lookup\n this.dispatchEmbedding(encoder, inputTokens);\n\n // Capas del transformer\n for (let layer = 0; layer < this.model.numLayers; layer++) {\n this.dispatchLayerNorm(encoder, layer, 'pre_attn');\n this.dispatchAttention(encoder, layer);\n this.dispatchLayerNorm(encoder, layer, 'pre_ffn');\n this.dispatchFFN(encoder, layer);\n }\n\n // Norm final + logits\n this.dispatchLayerNorm(encoder, -1, 'final');\n this.dispatchLogits(encoder);\n\n this.device.queue.submit([encoder.finish()]);\n }\n}\n```\n\nTodos los dispatches van a un solo command encoder. El driver de la GPU maneja el tracking de dependencias entre dispatches basándose en el uso de buffers, esta es un área donde WebGPU realmente brilla comparado con hacer esto en CUDA, donde tendrías que manejar streams manualmente.\n\n## Benchmarks: WebGPU vs WebGL vs Server\n\nLos corrí en una MacBook Pro 2024 (M3 Pro) y un desktop con RTX 3060, usando un DistilBERT cuantizado (66M params, INT4):\n\n| Método | M3 Pro | RTX 3060 | Notas |\n| --- | --- | --- | --- |\n| WebGPU Compute (nuestro) | 19ms | 23ms | Shaders WGSL custom |\n| ONNX Runtime Web (WebGPU) | 31ms | 38ms | Mismo modelo, config default |\n| ONNX Runtime Web (WebGL) | 67ms | 54ms | Backend WebGL2 |\n| Server (Lambda + ONNX) | 45ms\\* | 45ms\\* | \\*Incluye ~30ms red |\n| Server (Lambda cold start) | 2.8s | 2.8s | Penalidad de warm-up |\n\nLos números del M3 Pro son notables. La implementación de WebGPU de Apple es genuinamente excelente, su backend de Metal para WebGPU está maduro y bien optimizado. Los números de la RTX 3060 son ligeramente más altos porque el backend Dawn de Chrome agrega una capa fina de traducción a Vulkan.\n\nLa victoria real no es la latencia cruda, es la eliminación de cold starts y variabilidad de red. Un P99 de 25ms en el navegador supera a un P99 de 3 segundos de una función serverless que puede necesitar cold-start.\n\n## Trampas de Layout de Memoria\n\nUn obstáculo común que vale la pena destacar: las reglas de alineación de storage buffers en WGSL son estrictas y están mal documentadas. Cada miembro del struct debe estar alineado a un múltiplo de su tamaño, y el struct en sí debe estar alineado al mayor alineamiento de sus miembros. Para un struct mixto f32/u32:\n\n```wgsl\nstruct LayerParams {\n weight_offset: u32, // offset 0, align 4\n bias_offset: u32, // offset 4, align 4\n scale: f32, // offset 8, align 4\n num_elements: u32, // offset 12, align 4\n // Total: 16 bytes, align 4, todo bien\n}\n\n// Pero si agregars un vec4:\nstruct LayerParamsV2 {\n weight_offset: u32, // offset 0\n // 12 bytes de padding!\n params: vec4<f32>, // offset 16, align 16\n num_elements: u32, // offset 32\n // 12 bytes de padding!\n // Total: 48 bytes\n}\n```\n\nEse padding es silencioso y brutal. Tus escrituras de buffer del lado JavaScript van a estar mal si no lo se tiene en cuenta. Utilizar anotaciones ` offset_of ` o directamente evitar tipos vec en structs de uniform.\n\n## Cuándo NO Usar Este Approach\n\nHay limitaciones importantes a considerar. Este approach no es adecuado cuando:\n\n- **Tu modelo tiene más de ~500M parámetros.** La memoria GPU en contextos de browser es limitada, y la se comparte con el renderer.\n- **Necesitás resultados determinísticos entre dispositivos.** El comportamiento de punto flotante varía entre vendors de GPU de maneras que importan para algunas aplicaciones.\n- **Tus usuarios están en gráficos integrados.** Intel UHD 630 puede correr estos shaders, pero a 10x la latencia. Detectar features y tener un fallback.\n- **Necesitás mantener los pese es del modelo privados.** Todo lo que se envía al browser es inspeccionable. Si tu modelo es propietario, esto no es para vos.\n\n## Qué Viene Después\n\nUn área activa de exploración es la implementación WebGPU de flash attention para ventanas de contexto más largas. El desafío es que WGSL no te da el control fino de shared memory que te da CUDA, así que la estrategia de tiling tiene que ser diferente. Esto valdrá la pena examinar cuando haya benchmarks significativos disponibles.\n\nEl punto más amplio es este: la industria pasó una década asumiendo que la inferencia ML pertenece a los servidores. Para una categoría creciente de modelos y case es de uso, la GPU del cliente no solo es adecuada, es superior. Cero varianza de latencia, cero cold starts, cero costos de servidor. El tooling finalmente se está poniendo a la altura para hacer esto práctico.\n\n## Preguntas frecuentes\n\n### ¿WebGPU realmente puede competir con inferencia server-side para ML en producción?\n\nPara modelos de menos de ~500M parámetros con cuantización apropiada, absolutamente. Medimos 23ms de inferencia en un DistilBERT cuantizado con compute shaders de WebGPU en una laptop de gama media, que es competitivo con una función serverless en cold start cuando sumás latencia de red.\n\n### ¿Qué navegadores soportan compute shaders de WebGPU ahora mismo?\n\nA principios de 2026, Chrome 121+, Edge 121+, y Firefox 130+ vienen con soporte completo de WebGPU compute. Safari tiene soporte parcial detrás de un flag. Para producción, deberías detectar features con navigator.gpu y hacer fallback a ONNX Runtime Web con WebGL.\n\n### ¿Cómo se compara WGSL con GLSL para workloads de compute?\n\nWGSL es más explícito sobre el layout de memoria y tiene mejores garantías de seguridad. Perdés algo del azúcar sintáctico de los compute shaders de GLSL, pero ganás comportamiento predecible entre vendors. El sistema de tipos detecta desajustes de layout de buffers al crear el pipeline en vez de en runtime.\n\n## Tips\n\n1. **El tamaño del workgroup importa más de lo que considerars:** En la mayoría de las GPUs, ` @workgroup_size(256, 1, 1) ` rinde mejor que ` (64, 4, 1) ` para reducciones 1D aunque el total de threads sea igual. El patrón de coalescing de memoria cambia drásticamente con la dimensión X.\n2. **Evitar indexado dinámico en loops internos de WGSL:** Utilizar ` let cached = weights[idx]; ` fuera del loop interno. WGSL no garantiza que el compilador haga hoist de lecturas de storage buffers, y se va a pagar la latencia en cada iteración.\n3. **Doble buffer en tus storage bindings:** En vez de un solo buffer de output con barrier, alterná entre ` @group(0) @binding(0) ` y ` @group(0) @binding(1) ` entre pases de dispatch. Esto deja que la GPU solape compute y operaciones de memoria en arquitecturas que lo soportan.\n4. **Perfilar con ` GPUCommandEncoder.writeTimestamp() `:** Envolvé tus dispatch calls con timestamp queries en un ` GPUQuerySet ` dedicado de tipo ` 'timestamp' `. Resolvé a un buffer mapeable y leé, esto te da timings reales del lado GPU en microsegundos, no ruido del CPU.\n\n- webgpu\n- wgsl\n- machine-learning\n- inferencia-browser\n- compute-shaders\n\n[Volver al Blog](<https://lucioduran.com/es/blog>)","headings":[{"level":1,"text":"Compute Shaders con WebGPU para Inferencia ML: Ejecución de Modelos en el Cliente","url":"https://lucioduran.com/es/blog/webgpu-compute-shaders-ml-inference#title"},{"level":2,"text":"¿Por Qué No Usar ONNX Runtime Web Directamente?","url":"https://lucioduran.com/es/blog/webgpu-compute-shaders-ml-inference#por-qu-no-usar-onnx-runtime-web-directamente"},{"level":2,"text":"Armando el Pipeline WebGPU","url":"https://lucioduran.com/es/blog/webgpu-compute-shaders-ml-inference#armando-el-pipeline-webgpu"},{"level":2,"text":"El Núcleo: Multiplicación de Matrices en WGSL","url":"https://lucioduran.com/es/blog/webgpu-compute-shaders-ml-inference#el-ncleo-multiplicacin-de-matrices-en-wgsl"},{"level":2,"text":"Yendo a Cuantizado: Matmul INT4","url":"https://lucioduran.com/es/blog/webgpu-compute-shaders-ml-inference#yendo-a-cuantizado-matmul-int4"},{"level":2,"text":"El Mecanismo de Atención","url":"https://lucioduran.com/es/blog/webgpu-compute-shaders-ml-inference#el-mecanismo-de-atencin"},{"level":2,"text":"La Orquestación de Dispatches","url":"https://lucioduran.com/es/blog/webgpu-compute-shaders-ml-inference#la-orquestacin-de-dispatches"},{"level":2,"text":"Benchmarks: WebGPU vs WebGL vs Server","url":"https://lucioduran.com/es/blog/webgpu-compute-shaders-ml-inference#benchmarks-webgpu-vs-webgl-vs-server"},{"level":2,"text":"Trampas de Layout de Memoria","url":"https://lucioduran.com/es/blog/webgpu-compute-shaders-ml-inference#trampas-de-layout-de-memoria"},{"level":2,"text":"Cuándo NO Usar Este Approach","url":"https://lucioduran.com/es/blog/webgpu-compute-shaders-ml-inference#cundo-no-usar-este-approach"},{"level":2,"text":"Qué Viene Después","url":"https://lucioduran.com/es/blog/webgpu-compute-shaders-ml-inference#qu-viene-despus"},{"level":2,"text":"Preguntas frecuentes","url":"https://lucioduran.com/es/blog/webgpu-compute-shaders-ml-inference#questions"},{"level":3,"text":"¿WebGPU realmente puede competir con inferencia server-side para ML en producción?","url":"https://lucioduran.com/es/blog/webgpu-compute-shaders-ml-inference#question-1"},{"level":3,"text":"¿Qué navegadores soportan compute shaders de WebGPU ahora mismo?","url":"https://lucioduran.com/es/blog/webgpu-compute-shaders-ml-inference#question-2"},{"level":3,"text":"¿Cómo se compara WGSL con GLSL para workloads de compute?","url":"https://lucioduran.com/es/blog/webgpu-compute-shaders-ml-inference#question-3"},{"level":2,"text":"Tips","url":"https://lucioduran.com/es/blog/webgpu-compute-shaders-ml-inference#article-tips"}],"sections":[{"heading":"Compute Shaders con WebGPU para Inferencia ML: Ejecución de Modelos en el Cliente","level":1,"url":"https://lucioduran.com/es/blog/webgpu-compute-shaders-ml-inference","content":"WebGPU"},{"heading":"Compute Shaders con WebGPU para Inferencia ML: Ejecución de Modelos en el Cliente","level":1,"url":"https://lucioduran.com/es/blog/webgpu-compute-shaders-ml-inference#title","content":"2025-10-0110 min lectura\n\nLucio Durán\nEngineering Manager & AI Solutions Architect\n\nTambién disponible en: [English](<https://lucioduran.com/blog/webgpu-compute-shaders-ml-inference>)"},{"heading":"¿Por Qué No Usar ONNX Runtime Web Directamente?","level":2,"url":"https://lucioduran.com/es/blog/webgpu-compute-shaders-ml-inference#por-qu-no-usar-onnx-runtime-web-directamente","content":"[ONNX Runtime Web](<https://onnxruntime.ai/>) es una herramienta sólida para producción. Sin embargo, es una abstracción sobre WebGPU (o WebGL, o WASM), y las abstracciones tienen costos. Al perfilar una sesión de ONNX Runtime Web corriendo un modelo DistilBERT, aproximadamente el 18% del tiempo total de inferencia se iba en overhead de dispatch de operadores, bookkeeping de alocación de memoria, y pases de optimización de grafo que ya habían sido aplicados al modelo.\n\nEscribir compute shaders directamente te da control sobre:\n\n- Las dimensiones exactas de workgroup y la geometría de dispatch\n- El layout de memoria en storage y uniform buffers\n- La sincronización entre pases\n- Aritmética cuantizada sin dequantización en runtime\n\nLa contra es que se está escribiendo código de GPU. No hay almuerzo gratis."},{"heading":"Armando el Pipeline WebGPU","level":2,"url":"https://lucioduran.com/es/blog/webgpu-compute-shaders-ml-inference#armando-el-pipeline-webgpu","content":"Primero, el boilerplate. El código de negociación de adapter/device es boilerplate estándar; las partes interesantes vienen a continuación.\n\n```javascript\nconst adapter = await navigator.gpu.requestAdapter({\n powerPreference: 'high-performance'\n});\n\nconst device = await adapter.requestDevice({\n requiredFeatures: ['timestamp-query'],\n requiredLimits: {\n maxStorageBufferBindingSize: 256 * 1024 * 1024,\n maxBufferSize: 256 * 1024 * 1024,\n maxComputeWorkgroupSizeX: 256,\n maxComputeInvocationsPerWorkgroup: 256,\n }\n});\n```\n\nEse ` maxStorageBufferBindingSize ` es importante, el default es 128MB en muchas implementaciones, y un modelo cuantizado de 125M parámetros puede excederlo fácilmente cuando incluís los buffers de KV cache."},{"heading":"El Núcleo: Multiplicación de Matrices en WGSL","level":2,"url":"https://lucioduran.com/es/blog/webgpu-compute-shaders-ml-inference#el-ncleo-multiplicacin-de-matrices-en-wgsl","content":"La columna vertebral de la inferencia de transformers es la multiplicación de matrices. A continuación, va un compute shader WGSL que hace matmul con tiles y memoria compartida, que es el approach mínimo viable para rendimiento aceptable:\n\n```wgsl\n@group(0) @binding(0) var<storage, read> a: array<f32>;\n@group(0) @binding(1) var<storage, read> b: array<f32>;\n@group(0) @binding(2) var<storage, read_write> result: array<f32>;\n\nstruct Dimensions {\n M: u32,\n N: u32,\n K: u32,\n}\n@group(0) @binding(3) var<uniform> dims: Dimensions;\n\nconst TILE_SIZE: u32 = 16u;\n\nvar<workgroup> tileA: array<array<f32, 16>, 16>;\nvar<workgroup> tileB: array<array<f32, 16>, 16>;\n\n@compute @workgroup_size(16, 16, 1)\nfn main(\n @builtin(global_invocation_id) gid: vec3<u32>,\n @builtin(local_invocation_id) lid: vec3<u32>,\n) {\n let row = gid.x;\n let col = gid.y;\n var sum: f32 = 0.0;\n\n let numTiles = (dims.K + TILE_SIZE - 1u) / TILE_SIZE;\n\n for (var t: u32 = 0u; t < numTiles; t = t + 1u) {\n let tiledCol = t * TILE_SIZE + lid.y;\n let tiledRow = t * TILE_SIZE + lid.x;\n\n if (row < dims.M && tiledCol < dims.K) {\n tileA[lid.x][lid.y] = a[row * dims.K + tiledCol];\n } else {\n tileA[lid.x][lid.y] = 0.0;\n }\n\n if (tiledRow < dims.K && col < dims.N) {\n tileB[lid.x][lid.y] = b[tiledRow * dims.N + col];\n } else {\n tileB[lid.x][lid.y] = 0.0;\n }\n\n workgroupBarrier();\n\n for (var k: u32 = 0u; k < TILE_SIZE; k = k + 1u) {\n sum = sum + tileA[lid.x][k] * tileB[k][lid.y];\n }\n\n workgroupBarrier();\n }\n\n if (row < dims.M && col < dims.N) {\n result[row * dims.N + col] = sum;\n }\n}\n```\n\nEste es el approach tileado estándar. El detalle clave que la mayoría de los tutoriales se pierden: esas dos llamadas a ` workgroupBarrier() ` son innegociables. La primera asegura que el tile esté completamente cargado antes de computar; la segunda asegura que la computación esté completa antes de reusar la memoria del tile. Saquí cualquiera de las dos y se va a tener resultados incorrectos intermitentes que son un infierno para debuggear."},{"heading":"Yendo a Cuantizado: Matmul INT4","level":2,"url":"https://lucioduran.com/es/blog/webgpu-compute-shaders-ml-inference#yendo-a-cuantizado-matmul-int4","content":"Correr matmul en FP32 funciona, pero es un desperdicio para inferencia. La mayoría de los modelos de producción usan cuantización INT4 o INT8. A continuación, resulta interesante, WGSL no tiene tipos int4 nativos, así que empaquetás dos valores int4 en un solo u8 (bueno, u32, ya que el tipo más pequeño de WGSL es 32-bit).\n\n```wgsl\nfn unpack_int4(packed: u32, index: u32) -> f32 {\n let shift = (index % 8u) * 4u;\n let nibble = (packed >> shift) & 0xFu;\n // Sign-extend desde 4-bit\n let signed = i32(nibble) - select(0, 16, nibble >= 8u);\n return f32(signed);\n}\n\n@compute @workgroup_size(256, 1, 1)\nfn quantized_matmul(\n @builtin(global_invocation_id) gid: vec3<u32>,\n) {\n let row = gid.x / dims.N;\n let col = gid.x % dims.N;\n\n if (row >= dims.M) { return; }\n\n var sum: f32 = 0.0;\n let groupSize: u32 = 32u;\n\n for (var k: u32 = 0u; k < dims.K; k = k + 1u) {\n let group_idx = k / groupSize;\n let scale = scales[row * (dims.K / groupSize) + group_idx];\n let zero = zeros[row * (dims.K / groupSize) + group_idx];\n\n let packed_idx = (row * dims.K + k) / 8u;\n let w = unpack_int4(weights_packed[packed_idx], k);\n let dequant = (w - zero) * scale;\n\n sum = sum + input[col * dims.K + k] * dequant;\n }\n\n result[row * dims.N + col] = sum;\n}\n```\n\nLa función ` unpack_int4 ` hace el trabajo pesado. Guardamos 8 valores int4 por u32, desempaquetamos on the fly, y aplicamos dequantización por grupo. El tamaño de grupo de 32 coincide con lo que usan GPTQ y AWQ, así que se puede cargar modelos cuantizados directamente."},{"heading":"El Mecanismo de Atención","level":2,"url":"https://lucioduran.com/es/blog/webgpu-compute-shaders-ml-inference#el-mecanismo-de-atencin","content":"Self-attention es la otra pieza crítica para rendimiento. Lo que la mayoría de la gente hace mal: implementan atención como tres dispatches separados (Q*K^T, softmax, attn*V). Pero se puede fusionar el softmax en el mismo pase que la multiplicación Q\\*K^T si usás el truco de softmax online:\n\n```wgsl\n@compute @workgroup_size(256, 1, 1)\nfn fused_attention(\n @builtin(global_invocation_id) gid: vec3<u32>,\n @builtin(local_invocation_id) lid: vec3<u32>,\n) {\n let head = gid.x / seq_len;\n let query_pos = gid.x % seq_len;\n\n if (head >= num_heads) { return; }\n\n var max_score: f32 = -1e10;\n var sum_exp: f32 = 0.0;\n var acc: array<f32, 64>; // head_dim\n\n // Online softmax: pase único sobre K\n for (var k: u32 = 0u; k < seq_len; k = k + 1u) {\n var score: f32 = 0.0;\n for (var d: u32 = 0u; d < head_dim; d = d + 1u) {\n score += q[head * seq_len * head_dim + query_pos * head_dim + d]\n * keys[head * seq_len * head_dim + k * head_dim + d];\n }\n score = score / sqrt(f32(head_dim));\n\n let new_max = max(max_score, score);\n let correction = exp(max_score - new_max);\n sum_exp = sum_exp * correction + exp(score - new_max);\n\n for (var d: u32 = 0u; d < head_dim; d = d + 1u) {\n acc[d] = acc[d] * correction\n + exp(score - new_max)\n * values[head * seq_len * head_dim + k * head_dim + d];\n }\n max_score = new_max;\n }\n\n // Normalizar\n for (var d: u32 = 0u; d < head_dim; d = d + 1u) {\n output[head * seq_len * head_dim + query_pos * head_dim + d]\n = acc[d] / sum_exp;\n }\n}\n```\n\nEl softmax online evita un segundo pase sobre los scores de atención para normalizar. En una secuencia de 512 tokens con 12 heads, esta fusión ahorró ~4ms por capa en una RTX 3060, que se acumula rápido a lo largo de 6 o 12 capas."},{"heading":"La Orquestación de Dispatches","level":2,"url":"https://lucioduran.com/es/blog/webgpu-compute-shaders-ml-inference#la-orquestacin-de-dispatches","content":"Atando todo junto en JavaScript. A continuación, es donde coordinás la inferencia multi-pase:\n\n```javascript\nclass WebGPUInferenceEngine {\n constructor(device, model) {\n this.device = device;\n this.pipelines = {};\n this.buffers = {};\n this.model = model;\n }\n\n async buildPipelines() {\n this.pipelines.embed = await this.createComputePipeline(embeddingShader);\n this.pipelines.layerNorm = await this.createComputePipeline(layerNormShader);\n this.pipelines.attention = await this.createComputePipeline(fusedAttentionShader);\n this.pipelines.ffn = await this.createComputePipeline(quantizedMatmulShader);\n this.pipelines.softmax = await this.createComputePipeline(finalSoftmaxShader);\n }\n\n runInference(inputTokens) {\n const encoder = this.device.createCommandEncoder();\n\n // Embedding lookup\n this.dispatchEmbedding(encoder, inputTokens);\n\n // Capas del transformer\n for (let layer = 0; layer < this.model.numLayers; layer++) {\n this.dispatchLayerNorm(encoder, layer, 'pre_attn');\n this.dispatchAttention(encoder, layer);\n this.dispatchLayerNorm(encoder, layer, 'pre_ffn');\n this.dispatchFFN(encoder, layer);\n }\n\n // Norm final + logits\n this.dispatchLayerNorm(encoder, -1, 'final');\n this.dispatchLogits(encoder);\n\n this.device.queue.submit([encoder.finish()]);\n }\n}\n```\n\nTodos los dispatches van a un solo command encoder. El driver de la GPU maneja el tracking de dependencias entre dispatches basándose en el uso de buffers, esta es un área donde WebGPU realmente brilla comparado con hacer esto en CUDA, donde tendrías que manejar streams manualmente."},{"heading":"Benchmarks: WebGPU vs WebGL vs Server","level":2,"url":"https://lucioduran.com/es/blog/webgpu-compute-shaders-ml-inference#benchmarks-webgpu-vs-webgl-vs-server","content":"Los corrí en una MacBook Pro 2024 (M3 Pro) y un desktop con RTX 3060, usando un DistilBERT cuantizado (66M params, INT4):\n\n| Método | M3 Pro | RTX 3060 | Notas |\n| --- | --- | --- | --- |\n| WebGPU Compute (nuestro) | 19ms | 23ms | Shaders WGSL custom |\n| ONNX Runtime Web (WebGPU) | 31ms | 38ms | Mismo modelo, config default |\n| ONNX Runtime Web (WebGL) | 67ms | 54ms | Backend WebGL2 |\n| Server (Lambda + ONNX) | 45ms\\* | 45ms\\* | \\*Incluye ~30ms red |\n| Server (Lambda cold start) | 2.8s | 2.8s | Penalidad de warm-up |\n\nLos números del M3 Pro son notables. La implementación de WebGPU de Apple es genuinamente excelente, su backend de Metal para WebGPU está maduro y bien optimizado. Los números de la RTX 3060 son ligeramente más altos porque el backend Dawn de Chrome agrega una capa fina de traducción a Vulkan.\n\nLa victoria real no es la latencia cruda, es la eliminación de cold starts y variabilidad de red. Un P99 de 25ms en el navegador supera a un P99 de 3 segundos de una función serverless que puede necesitar cold-start."},{"heading":"Trampas de Layout de Memoria","level":2,"url":"https://lucioduran.com/es/blog/webgpu-compute-shaders-ml-inference#trampas-de-layout-de-memoria","content":"Un obstáculo común que vale la pena destacar: las reglas de alineación de storage buffers en WGSL son estrictas y están mal documentadas. Cada miembro del struct debe estar alineado a un múltiplo de su tamaño, y el struct en sí debe estar alineado al mayor alineamiento de sus miembros. Para un struct mixto f32/u32:\n\n```wgsl\nstruct LayerParams {\n weight_offset: u32, // offset 0, align 4\n bias_offset: u32, // offset 4, align 4\n scale: f32, // offset 8, align 4\n num_elements: u32, // offset 12, align 4\n // Total: 16 bytes, align 4, todo bien\n}\n\n// Pero si agregars un vec4:\nstruct LayerParamsV2 {\n weight_offset: u32, // offset 0\n // 12 bytes de padding!\n params: vec4<f32>, // offset 16, align 16\n num_elements: u32, // offset 32\n // 12 bytes de padding!\n // Total: 48 bytes\n}\n```\n\nEse padding es silencioso y brutal. Tus escrituras de buffer del lado JavaScript van a estar mal si no lo se tiene en cuenta. Utilizar anotaciones ` offset_of ` o directamente evitar tipos vec en structs de uniform."},{"heading":"Cuándo NO Usar Este Approach","level":2,"url":"https://lucioduran.com/es/blog/webgpu-compute-shaders-ml-inference#cundo-no-usar-este-approach","content":"Hay limitaciones importantes a considerar. Este approach no es adecuado cuando:\n\n- **Tu modelo tiene más de ~500M parámetros.** La memoria GPU en contextos de browser es limitada, y la se comparte con el renderer.\n- **Necesitás resultados determinísticos entre dispositivos.** El comportamiento de punto flotante varía entre vendors de GPU de maneras que importan para algunas aplicaciones.\n- **Tus usuarios están en gráficos integrados.** Intel UHD 630 puede correr estos shaders, pero a 10x la latencia. Detectar features y tener un fallback.\n- **Necesitás mantener los pese es del modelo privados.** Todo lo que se envía al browser es inspeccionable. Si tu modelo es propietario, esto no es para vos."},{"heading":"Qué Viene Después","level":2,"url":"https://lucioduran.com/es/blog/webgpu-compute-shaders-ml-inference#qu-viene-despus","content":"Un área activa de exploración es la implementación WebGPU de flash attention para ventanas de contexto más largas. El desafío es que WGSL no te da el control fino de shared memory que te da CUDA, así que la estrategia de tiling tiene que ser diferente. Esto valdrá la pena examinar cuando haya benchmarks significativos disponibles.\n\nEl punto más amplio es este: la industria pasó una década asumiendo que la inferencia ML pertenece a los servidores. Para una categoría creciente de modelos y case es de uso, la GPU del cliente no solo es adecuada, es superior. Cero varianza de latencia, cero cold starts, cero costos de servidor. El tooling finalmente se está poniendo a la altura para hacer esto práctico."},{"heading":"Preguntas frecuentes","level":2,"url":"https://lucioduran.com/es/blog/webgpu-compute-shaders-ml-inference#questions","content":""},{"heading":"¿WebGPU realmente puede competir con inferencia server-side para ML en producción?","level":3,"url":"https://lucioduran.com/es/blog/webgpu-compute-shaders-ml-inference#question-1","content":"Para modelos de menos de ~500M parámetros con cuantización apropiada, absolutamente. Medimos 23ms de inferencia en un DistilBERT cuantizado con compute shaders de WebGPU en una laptop de gama media, que es competitivo con una función serverless en cold start cuando sumás latencia de red."},{"heading":"¿Qué navegadores soportan compute shaders de WebGPU ahora mismo?","level":3,"url":"https://lucioduran.com/es/blog/webgpu-compute-shaders-ml-inference#question-2","content":"A principios de 2026, Chrome 121+, Edge 121+, y Firefox 130+ vienen con soporte completo de WebGPU compute. Safari tiene soporte parcial detrás de un flag. Para producción, deberías detectar features con navigator.gpu y hacer fallback a ONNX Runtime Web con WebGL."},{"heading":"¿Cómo se compara WGSL con GLSL para workloads de compute?","level":3,"url":"https://lucioduran.com/es/blog/webgpu-compute-shaders-ml-inference#question-3","content":"WGSL es más explícito sobre el layout de memoria y tiene mejores garantías de seguridad. Perdés algo del azúcar sintáctico de los compute shaders de GLSL, pero ganás comportamiento predecible entre vendors. El sistema de tipos detecta desajustes de layout de buffers al crear el pipeline en vez de en runtime."},{"heading":"Tips","level":2,"url":"https://lucioduran.com/es/blog/webgpu-compute-shaders-ml-inference#article-tips","content":"1. **El tamaño del workgroup importa más de lo que considerars:** En la mayoría de las GPUs, ` @workgroup_size(256, 1, 1) ` rinde mejor que ` (64, 4, 1) ` para reducciones 1D aunque el total de threads sea igual. El patrón de coalescing de memoria cambia drásticamente con la dimensión X.\n2. **Evitar indexado dinámico en loops internos de WGSL:** Utilizar ` let cached = weights[idx]; ` fuera del loop interno. WGSL no garantiza que el compilador haga hoist de lecturas de storage buffers, y se va a pagar la latencia en cada iteración.\n3. **Doble buffer en tus storage bindings:** En vez de un solo buffer de output con barrier, alterná entre ` @group(0) @binding(0) ` y ` @group(0) @binding(1) ` entre pases de dispatch. Esto deja que la GPU solape compute y operaciones de memoria en arquitecturas que lo soportan.\n4. **Perfilar con ` GPUCommandEncoder.writeTimestamp() `:** Envolvé tus dispatch calls con timestamp queries en un ` GPUQuerySet ` dedicado de tipo ` 'timestamp' `. Resolvé a un buffer mapeable y leé, esto te da timings reales del lado GPU en microsegundos, no ruido del CPU.\n\n- webgpu\n- wgsl\n- machine-learning\n- inferencia-browser\n- compute-shaders\n\n[Volver al Blog](<https://lucioduran.com/es/blog>)"}],"links":[{"text":"English","url":"https://lucioduran.com/blog/webgpu-compute-shaders-ml-inference"},{"text":"ONNX Runtime Web","url":"https://onnxruntime.ai/"},{"text":"Volver al Blog","url":"https://lucioduran.com/es/blog"}],"structuredData":[{"@context":"https://schema.org","@graph":[{"@type":"BlogPosting","@id":"https://lucioduran.com/es/blog/webgpu-compute-shaders-ml-inference#article","mainEntityOfPage":{"@type":"WebPage","@id":"https://lucioduran.com/es/blog/webgpu-compute-shaders-ml-inference"},"headline":"Compute Shaders con WebGPU para Inferencia ML: Ejecución de Modelos en el Cliente","description":"Ejecución de modelos ML directamente en el navegador con compute shaders de WebGPU. Implementación en WGSL, benchmarks contra WebGL e inferencia server-side, y limitaciones prácticas de la ejecución de modelos en el cliente.","datePublished":"2025-10-01","author":{"@type":"Person","@id":"https://lucioduran.com/#person","name":"Lucio Durán","url":"https://lucioduran.com"},"publisher":{"@type":"Person","@id":"https://lucioduran.com/#person","name":"Lucio Durán","url":"https://lucioduran.com"},"image":"https://lucioduran.com/favicons/og-default.png","url":"https://lucioduran.com/es/blog/webgpu-compute-shaders-ml-inference","inLanguage":"es","isPartOf":{"@id":"https://lucioduran.com/blog#blog"},"articleSection":"WebGPU","keywords":"webgpu, wgsl, machine-learning, inferencia-browser, compute-shaders","wordCount":1855,"timeRequired":"PT10M","workTranslation":[{"@type":"BlogPosting","inLanguage":"en","url":"https://lucioduran.com/blog/webgpu-compute-shaders-ml-inference"}]},{"@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"Inicio","item":"https://lucioduran.com/"},{"@type":"ListItem","position":2,"name":"Blog","item":"https://lucioduran.com/es/blog"},{"@type":"ListItem","position":3,"name":"Compute Shaders con WebGPU para Inferencia ML: Ejecución de Modelos en el Cliente","item":"https://lucioduran.com/es/blog/webgpu-compute-shaders-ml-inference"}]},{"@type":"FAQPage","mainEntity":[{"@type":"Question","name":"¿WebGPU realmente puede competir con inferencia server-side para ML en producción?","acceptedAnswer":{"@type":"Answer","text":"Para modelos de menos de ~500M parámetros con cuantización apropiada, absolutamente. Medimos 23ms de inferencia en un DistilBERT cuantizado con compute shaders de WebGPU en una laptop de gama media, que es competitivo con una función serverless en cold start cuando sumás latencia de red."}},{"@type":"Question","name":"¿Qué navegadores soportan compute shaders de WebGPU ahora mismo?","acceptedAnswer":{"@type":"Answer","text":"A principios de 2026, Chrome 121+, Edge 121+, y Firefox 130+ vienen con soporte completo de WebGPU compute. Safari tiene soporte parcial detrás de un flag. Para producción, deberías detectar features con navigator.gpu y hacer fallback a ONNX Runtime Web con WebGL."}},{"@type":"Question","name":"¿Cómo se compara WGSL con GLSL para workloads de compute?","acceptedAnswer":{"@type":"Answer","text":"WGSL es más explícito sobre el layout de memoria y tiene mejores garantías de seguridad. Perdés algo del azúcar sintáctico de los compute shaders de GLSL, pero ganás comportamiento predecible entre vendors. El sistema de tipos detecta desajustes de layout de buffers al crear el pipeline en vez de en runtime."}}]}]}],"contentHash":"19726b37ef65c5f7a37808532d1be3252640d6a6a60904a758382114f4a8be6a","provenance":{"method":"Deterministic serialization of the server-rendered main element","source":"https://lucioduran.com/es/blog/webgpu-compute-shaders-ml-inference","hashAlgorithm":"sha256","hashScope":"JSON document fields before contentHash and provenance","sourceHTMLBytes":68680,"contentBytes":14884}}
