nx_f32_mha.nx source
↩ module page · 144 lines · 5064 B
1// nx_f32_mha.nx -- bits-up f32 multi-head attention with GQA.
2//
3// L7 / L8 composition brick. Closes the single-head v1 limitation
4// by decomposing hidden_dim into n_heads * head_dim and computing
5// per-head attention in parallel, then concatenating + projecting
6// through W_o. Supports Grouped-Query Attention (Llama-3, Qwen2,
7// Mistral) via separate n_kv_heads.
8//
9// Single-token v1: for autoregressive decode the typical
10// inference pattern is one new token at a time, so n_tokens = 1.
11// Multi-token (prefill) is a follow-on brick that adds K^T
12// transpose + multi-row softmax.
13//
14// Algorithm (Vaswani 2017 multi-head + Ainslie 2023 GQA):
15//
16// q_dim = n_heads * head_dim (= hidden_dim typically)
17// kv_dim = n_kv_heads * head_dim
18// group_size = n_heads / n_kv_heads
19//
20// Q = matmul(x, W_q) [1, q_dim]
21// K = matmul(x, W_k) [1, kv_dim]
22// V = matmul(x, W_v) [1, kv_dim]
23//
24// For each query head h in 0..n_heads:
25// kv_head = h / group_size
26// Q_h = Q[h * head_dim : (h+1) * head_dim]
27// K_h = K[kv_head * head_dim : (kv_head+1) * head_dim]
28// V_h = V[kv_head * head_dim : (kv_head+1) * head_dim]
29// score_h = dot(Q_h, K_h) * attn_scale
30// prob_h = softmax([score_h]) (degenerate -> [1.0])
31// attn_out[h*head_dim : (h+1)*head_dim] = prob_h * V_h
32//
33// out = matmul(attn_out, W_o) [1, hidden_dim]
34//
35// Caller does the residual: x = x + out.
36//
37// genealogy_id: vaswani_2017_multihead + ainslie_2023_gqa
38// lineage_id: substrate_f32_mha_single_token_v1
39
40import "nx_syscalls.nx"
41import "nx_tier.nx"
42import "nx_f32.nx"
43import "nx_f32_matmul.nx"
44import "nx_f32_softmax.nx"
45
46const NX_F32_MHA_OK: nx_int = 0
47const NX_F32_MHA_ERR_BAD_DIM: nx_int = 1
48const NX_F32_MHA_ERR_BAD_GQA: nx_int = 2
49const NX_F32_MHA_N_VERDICTS: nx_int = 3
50
51func nx_f32_mha_verdict_is_valid(v: nx_int) -> nx_int {
52 if v < 0 { return 0 }
53 if v >= NX_F32_MHA_N_VERDICTS { return 0 }
54 return 1
55}
56
57// Single-token MHA forward. Writes the attention sub-block output
58// to `out` (not yet added to x; caller does the residual).
59//
60// Constraints:
61// hidden_dim = n_heads * head_dim (must hold)
62// n_heads % n_kv_heads == 0 (GQA group size is integer)
63//
64// 11 args -- under the 16-arg limit.
65
66func nx_f32_mha_single_token(
67 x: *i64,
68 hidden_dim: nx_int,
69 n_heads: nx_int,
70 n_kv_heads: nx_int,
71 head_dim: nx_int,
72 W_q: *i64,
73 W_k: *i64,
74 W_v: *i64,
75 W_o: *i64,
76 attn_scale: i64,
77 out: *i64) -> nx_int {
78
79 if hidden_dim <= 0 { return NX_F32_MHA_ERR_BAD_DIM }
80 if n_heads <= 0 { return NX_F32_MHA_ERR_BAD_DIM }
81 if n_kv_heads <= 0 { return NX_F32_MHA_ERR_BAD_DIM }
82 if head_dim <= 0 { return NX_F32_MHA_ERR_BAD_DIM }
83 if n_heads * head_dim != hidden_dim {
84 return NX_F32_MHA_ERR_BAD_DIM
85 }
86 if n_heads - (n_heads / n_kv_heads) * n_kv_heads != 0 {
87 return NX_F32_MHA_ERR_BAD_GQA
88 }
89
90 let q_dim: nx_int = n_heads * head_dim
91 let kv_dim: nx_int = n_kv_heads * head_dim
92 let group_size: nx_int = n_heads / n_kv_heads
93
94 // Project x into Q, K, V.
95 let Q: *i64 = sys_mmap(q_dim * 8) as *i64
96 let K: *i64 = sys_mmap(kv_dim * 8) as *i64
97 let V: *i64 = sys_mmap(kv_dim * 8) as *i64
98 nx_f32_matmul(x, W_q, Q, 1, hidden_dim, q_dim)
99 nx_f32_matmul(x, W_k, K, 1, hidden_dim, kv_dim)
100 nx_f32_matmul(x, W_v, V, 1, hidden_dim, kv_dim)
101
102 // Per-head attention.
103 let attn_out: *i64 = sys_mmap(q_dim * 8) as *i64
104
105 var h: nx_int = 0
106 while h < n_heads {
107 let kv_head: nx_int = h / group_size
108
109 // Compute slice base addresses (raw pointer arithmetic).
110 let q_base: i64 = (Q as i64) + h * head_dim * 8
111 let k_base: i64 = (K as i64) + kv_head * head_dim * 8
112 let v_base: i64 = (V as i64) + kv_head * head_dim * 8
113 let Q_h: *i64 = q_base as *i64
114 let K_h: *i64 = k_base as *i64
115 let V_h: *i64 = v_base as *i64
116
117 // score = dot(Q_h, K_h) * attn_scale
118 let raw_score: i64 = nx_f32_dot(Q_h, K_h, head_dim)
119 let score: i64 = nx_f32_mul(raw_score, attn_scale)
120
121 // softmax([score]) = [1.0] (single-element vector)
122 // We run the path for substrate honesty (composes nx_f32_softmax).
123 let scores_slot: *i64 = sys_mmap(8) as *i64
124 let probs_slot: *i64 = sys_mmap(8) as *i64
125 scores_slot[0] = score
126 nx_f32_softmax(scores_slot, 1, probs_slot)
127 let prob_h: i64 = probs_slot[0]
128
129 // attn_out[h*head_dim .. ] = prob_h * V_h
130 let attn_h_base: i64 = (attn_out as i64) + h * head_dim * 8
131 let attn_h: *i64 = attn_h_base as *i64
132 var d: nx_int = 0
133 while d < head_dim {
134 attn_h[d] = nx_f32_mul(prob_h, V_h[d])
135 d = d + 1
136 }
137
138 h = h + 1
139 }
140
141 // Output projection: [1, q_dim] @ [q_dim, hidden_dim] -> [1, hidden_dim]
142 nx_f32_matmul(attn_out, W_o, out, 1, q_dim, hidden_dim)
143 return NX_F32_MHA_OK
144}