/** * attention.ts – Causal multi-head self-attention kernels. * * Implements tiled 16×16 causal attention suitable for WebGPU. * No Flash-Attention dependency — straightforward O(L²) with causal mask. * * Buffer layout: * qkv_in : [B, L, 3*D_model] fused Q,K,V after wQKV projection * out_buf : [B, L, D_model] * scores : [B, H, L, L] intermediate (written then read by kernel) * * Dispatch attention_forward: (ceil(L/16), H, B) * Dispatch softmax_forward: (L, H, B) — one workgroup per row * Dispatch attention_backward: (ceil(L/16), H, B) */ // ── Softmax ─────────────────────────────────────────────────────────────────── export const SOFTMAX_WGSL: string = /* wgsl */` struct SoftmaxParams { rows : u32, // L cols : u32, // L (score matrix is L×L per head) }; @group(0) @binding(0) var params : SoftmaxParams; @group(0) @binding(1) var data : array; // One workgroup per row; each invocation handles one element within the row. // Workgroup size 64 – cooperative reduction for max and sum. var wg_max : array; var wg_sum : array; @compute @workgroup_size(64, 1, 1) fn softmax_forward(@builtin(global_invocation_id) gid: vec3, @builtin(local_invocation_id) lid: vec3, @builtin(workgroup_id) wid: vec3) { let row = wid.x; // L row index let head = wid.y; let bat = wid.z; let cols = params.cols; if (row >= params.rows) { return; } let base = (bat * params.rows * cols * /* nHeads from outer dispatch */ 1u) + row * cols; // Step 1: find row max (with causal mask: positions > row are -inf) var local_max = -1e38; for (var c = lid.x; c < cols; c = c + 64u) { var v = -1e38; if (c <= row) { v = data[base + c]; } if (v > local_max) { local_max = v; } } wg_max[lid.x] = local_max; workgroupBarrier(); for (var s = 32u; s >= 1u; s = s >> 1u) { if (lid.x < s) { if (wg_max[lid.x + s] > wg_max[lid.x]) { wg_max[lid.x] = wg_max[lid.x + s]; } } workgroupBarrier(); } let row_max = wg_max[0u]; // Step 2: exp and sum var local_sum = 0.0; for (var c = lid.x; c < cols; c = c + 64u) { if (c <= row) { let e = exp(data[base + c] - row_max); data[base + c] = e; local_sum = local_sum + e; } else { data[base + c] = 0.0; } } wg_sum[lid.x] = local_sum; workgroupBarrier(); for (var s = 32u; s >= 1u; s = s >> 1u) { if (lid.x < s) { wg_sum[lid.x] = wg_sum[lid.x] + wg_sum[lid.x + s]; } workgroupBarrier(); } let inv_sum = 1.0 / (wg_sum[0u] + 1e-12); // Step 3: normalise for (var c = lid.x; c <= row; c = c + 64u) { data[base + c] = data[base + c] * inv_sum; } } `; // ── Attention forward ───────────────────────────────────────────────────────── export const ATTENTION_FORWARD_WGSL: string = /* wgsl */` struct AttnParams { batch : u32, seq_len : u32, d_model : u32, n_heads : u32, d_head : u32, }; @group(0) @binding(0) var params : AttnParams; // Q, K, V packed: [B, L, 3, H, d_head] (after projection split) @group(0) @binding(1) var Q : array; // [B,L,H,dh] @group(0) @binding(2) var K : array; // [B,L,H,dh] @group(0) @binding(3) var V : array; // [B,L,H,dh] @group(0) @binding(4) var scores : array; // [B,H,L,L] @group(0) @binding(5) var out_buf : array; // [B,L,H,dh] // Tiled 16×16 shared memory for Q row and K col var tile_q : array; // 16 tokens × 16 d_head var tile_k : array; @compute @workgroup_size(16, 16, 1) fn attention_forward(@builtin(global_invocation_id) gid: vec3, @builtin(local_invocation_id) lid: vec3, @builtin(workgroup_id) wid: vec3) { let q_tile = wid.x; // tile index along query (row) dimension let head = wid.y; let batch = wid.z; let B = params.batch; let L = params.seq_len; let H = params.n_heads; let dh = params.d_head; let inv_sqrt = 1.0 / sqrt(f32(dh)); let row = q_tile * 16u + lid.x; // query token index let col = lid.y; // key token index offset within tile if (row >= L) { return; } // ── Phase 1: Compute raw attention scores for all K positions ────────── // scores[batch, head, row, k] = Q[row] · K[k] / sqrt(dh) // We iterate over K tiles let q_base = batch * L * H * dh + row * H * dh + head * dh; for (var k_start: u32 = 0u; k_start <= row; k_start = k_start + 16u) { let k_tok = k_start + lid.y; // Load Q row tile into shared memory (lid.y = 0..15 element index) if (lid.y < dh && lid.y < 16u) { tile_q[lid.x * 16u + lid.y] = Q[q_base + lid.y]; } // Load K col tile if (k_tok < L && lid.x < dh && lid.x < 16u) { let k_base = batch * L * H * dh + k_tok * H * dh + head * dh; tile_k[lid.y * 16u + lid.x] = K[k_base + lid.x]; } else if (lid.x < 16u) { tile_k[lid.y * 16u + lid.x] = 0.0; } workgroupBarrier(); // Dot product: accumulate over dh if (k_tok <= row) { var acc = 0.0; for (var d = 0u; d < min(dh, 16u); d = d + 1u) { acc = acc + tile_q[lid.x * 16u + d] * tile_k[lid.y * 16u + d]; } let score_idx = batch * H * L * L + head * L * L + row * L + k_tok; scores[score_idx] = acc * inv_sqrt; } workgroupBarrier(); } } // Phase 2: softmax is dispatched separately via softmax_forward kernel. // Phase 3: weighted sum of V @compute @workgroup_size(16, 16, 1) fn attention_value(@builtin(global_invocation_id) gid: vec3, @builtin(local_invocation_id) lid: vec3, @builtin(workgroup_id) wid: vec3) { let q_tile = wid.x; let head = wid.y; let batch = wid.z; let L = params.seq_len; let H = params.n_heads; let dh = params.d_head; let row = q_tile * 16u + lid.x; let d = lid.y; // d_head dimension if (row >= L || d >= dh) { return; } var acc = 0.0; for (var k: u32 = 0u; k <= row; k = k + 1u) { let score_idx = batch * H * L * L + head * L * L + row * L + k; let v_idx = batch * L * H * dh + k * H * dh + head * dh + d; acc = acc + scores[score_idx] * V[v_idx]; } let out_idx = batch * L * H * dh + row * H * dh + head * dh + d; out_buf[out_idx] = acc; } `; // ── Attention backward ──────────────────────────────────────────────────────── export const ATTENTION_BACKWARD_WGSL: string = /* wgsl */` struct AttnParams { batch : u32, seq_len : u32, d_model : u32, n_heads : u32, d_head : u32, }; @group(0) @binding(0) var params : AttnParams; @group(0) @binding(1) var Q : array; @group(0) @binding(2) var K : array; @group(0) @binding(3) var V : array; @group(0) @binding(4) var scores : array; // post-softmax @group(0) @binding(5) var dy : array; // [B,L,H,dh] @group(0) @binding(6) var dQ : array; @group(0) @binding(7) var dK : array; @group(0) @binding(8) var dV : array; @group(0) @binding(9) var dscores : array; @compute @workgroup_size(16, 16, 1) fn attention_backward(@builtin(global_invocation_id) gid: vec3, @builtin(local_invocation_id) lid: vec3, @builtin(workgroup_id) wid: vec3) { let q_tile = wid.x; let head = wid.y; let batch = wid.z; let L = params.seq_len; let H = params.n_heads; let dh = params.d_head; let inv_sqrt = 1.0 / sqrt(f32(dh)); let row = q_tile * 16u + lid.x; let d = lid.y; if (row >= L || d >= dh) { return; } // dV[k, d] += score[row, k] * dy[row, d] // dscores[row, k] += dy[row, d] * V[k, d] (before softmax backward) for (var k: u32 = 0u; k <= row; k = k + 1u) { let s_idx = batch * H * L * L + head * L * L + row * L + k; let v_idx = batch * L * H * dh + k * H * dh + head * dh + d; let dy_idx = batch * L * H * dh + row * H * dh + head * dh + d; dV[v_idx] = dV[v_idx] + scores[s_idx] * dy[dy_idx]; dscores[s_idx] = dscores[s_idx] + dy[dy_idx] * V[v_idx]; } // dQ[row, d] += sum_k dscores_post_softmax[row, k] * K[k, d] * inv_sqrt var dq_acc = 0.0; for (var k: u32 = 0u; k <= row; k = k + 1u) { let ds_idx = batch * H * L * L + head * L * L + row * L + k; let k_idx = batch * L * H * dh + k * H * dh + head * dh + d; dq_acc = dq_acc + dscores[ds_idx] * K[k_idx]; } let q_idx = batch * L * H * dh + row * H * dh + head * dh + d; dQ[q_idx] = dQ[q_idx] + dq_acc * inv_sqrt; // dK[k, d] += dscores[row, k] * Q[row, d] * inv_sqrt (for all rows >= k) for (var k: u32 = 0u; k <= row; k = k + 1u) { let ds_idx = batch * H * L * L + head * L * L + row * L + k; let k_idx = batch * L * H * dh + k * H * dh + head * dh + d; dK[k_idx] = dK[k_idx] + dscores[ds_idx] * Q[q_idx] * inv_sqrt; } } `;