code wiki / (root) / nx_rmsnorm.nx

nx_rmsnorm.nx source

↩ module page · 268 lines · 9571 B

1// nx_rmsnorm.nx -- Root Mean Square Layer Normalisation. 2// 3// Closes a substrate gap: prior to this commit, NishiLang had matmul 4// + attention + softmax + quantization shipped, but NO normalisation 5// primitive. That makes the substrate unable to run a transformer 6// block end-to-end (every modern transformer normalises between 7// attention and FFN sublayers). 8// 9// RMSNorm vs LayerNorm choice (Zhang & Sennrich 2019): 10// 11// LayerNorm (Ba/Kiros/Hinton 2016): 12// y = (x - mean(x)) / sqrt(var(x) + eps) * gamma + beta 13// 14// RMSNorm (Zhang & Sennrich 2019, simpler): 15// y = x / sqrt(mean(x^2) + eps) * gamma 16// 17// RMSNorm drops the centering term (mean subtraction). Faster 18// (~50% speedup per Zhang 2019 Table 2) at same downstream quality 19// on translation + speech recognition + summarisation benchmarks. 20// Modern transformer architectures use RMSNorm exclusively: 21// 22// Llama 1/2/3, Mistral, Mixtral, Qwen, Phi-3, Gemma 23// Z-Image, Stable Diffusion 3, Flux 24// Mamba state-space models 25// 26// LayerNorm support remains queued -- the math composes against 27// the same nx_isqrt_q10 with one extra centering pass. 28// 29// ===== Math ======================================================= 30// 31// For each token i, hidden dim D, input x[i, :] of length D: 32// 33// sum_sq = sum_{d=0}^{D-1} x[i, d]^2 34// mean_sq = sum_sq / D 35// rms = sqrt(mean_sq + eps) 36// y[i, d] = x[i, d] / rms * gamma[d] 37// 38// gamma is the per-channel learned scale vector (length D), 39// initialised to 1.0 (Q10=1024) at network construction time. 40// 41// ===== Q-format =================================================== 42// 43// All tensors are i64 in Q10 by substrate convention: 44// * x[i, d] in Q10 (range typically [-30 Q10, +30 Q10]) 45// * gamma[d] in Q10 (typically near Q10=1024 at init) 46// * y[i, d] in Q10 47// 48// sum_sq compute: x^2 of two Q10 values has Q20 scale. We track 49// in raw integer (sum is summed integer products) and divide back 50// after sqrt: rms in Q10 = nx_isqrt_q10(mean_sq + eps). 51// 52// eps_q10 = 1 (smallest non-zero Q10 stabiliser). In float 53// references eps = 1e-6; in Q10 the smallest representable non- 54// zero is ~1e-3, so eps_q10=1 is the tightest stabiliser we can 55// give. Matches GGML's eps handling for q-format quantized 56// inference. 57// 58// Per the bits-up cardinal: composes against 59// * nx_tensor.NxTensor (L1 canonical container) 60// * nx_isqrt.nx_isqrt_q10 (L2 canonical sqrt primitive) 61// * nx_loop.LoopVerdict (bounded-loop discipline) 62// 63// genealogy_id: zhang_sennrich_2019_rms_norm + ba_kiros_hinton_2016_layer_norm + 64// touvron_2023_llama_rms_norm_adoption 65// lineage_id: substrate_rmsnorm_v1 66 67// nx_safety_envelope: 68// intended_use: AUTO_APPLIED -- primitive-specific tuning queued 69// sil_target: SIL1 70// evidence: [bulk_applied_2026-05-16, see-file-comment-for-detail] 71// verdict: NOT_YET_EVALUATED 72 73import "nx_syscalls.nx" 74import "nx_tier.nx" 75import "nx_loop.nx" 76import "nx_tensor.nx" 77import "nx_isqrt.nx" 78 79// ===== Constants ================================================== 80 81const NX_RMSN_Q10: nx_int = 1024 82const NX_RMSN_EPS_Q10: nx_int = 1 83 84// ===== Sealed-enum: RmsNormVerdict ================================ 85 86const NX_RMSN_OK: nx_int = 0 87const NX_RMSN_ERR_BAD_DTYPE: nx_int = 1 88const NX_RMSN_ERR_BAD_NDIM: nx_int = 2 89const NX_RMSN_ERR_SHAPE_MISMATCH: nx_int = 3 90const NX_RMSN_ERR_NOT_CONTIGUOUS: nx_int = 4 91const NX_RMSN_ERR_BAD_GAMMA_LEN: nx_int = 5 92const NX_RMSN_N_VERDICTS: nx_int = 6 93 94func nx_rmsn_verdict_is_valid(v: nx_int) -> nx_int { 95 if v < 0 { return 0 } 96 if v >= NX_RMSN_N_VERDICTS { return 0 } 97 return 1 98} 99 100// ===== Forward pass ============================================== 101// 102// x: [n_tokens, hidden_dim] Q10 input 103// gamma: [hidden_dim] Q10 scale (learned) 104// out: [n_tokens, hidden_dim] Q10 output 105// 106// In-place is supported: caller can pass out == x. 107 108func nx_rmsnorm_forward(x: *NxTensor, gamma: *i64, out: *NxTensor) -> nx_int { 109 if x.dtype != NX_DT_I64 { return NX_RMSN_ERR_BAD_DTYPE } 110 if out.dtype != NX_DT_I64 { return NX_RMSN_ERR_BAD_DTYPE } 111 if x.ndim != 2 { return NX_RMSN_ERR_BAD_NDIM } 112 if out.ndim != 2 { return NX_RMSN_ERR_BAD_NDIM } 113 if x.shape[0] != out.shape[0] { return NX_RMSN_ERR_SHAPE_MISMATCH } 114 if x.shape[1] != out.shape[1] { return NX_RMSN_ERR_SHAPE_MISMATCH } 115 if nx_t_is_contiguous(x) == 0 { return NX_RMSN_ERR_NOT_CONTIGUOUS } 116 if nx_t_is_contiguous(out) == 0 { return NX_RMSN_ERR_NOT_CONTIGUOUS } 117 118 let n_tok: nx_int = x.shape[0] 119 let d: nx_int = x.shape[1] 120 let px: *i64 = x.storage as *i64 121 let po: *i64 = out.storage as *i64 122 123 var i: nx_int = 0 124 var iter: nx_int = 0 125 var verdict: nx_int = NX_LOOP_RUNNING 126 let BUDGET: nx_int = n_tok 127 while verdict == NX_LOOP_RUNNING && iter < BUDGET { 128 // Pass 1: sum of squares (raw Q20 accumulation). 129 var sum_sq: i64 = 0 130 var c: nx_int = 0 131 var iter_c: nx_int = 0 132 var verdict_c: nx_int = NX_LOOP_RUNNING 133 let BUDGET_C: nx_int = d 134 while verdict_c == NX_LOOP_RUNNING && iter_c < BUDGET_C { 135 let v: i64 = px[i * d + c] 136 sum_sq = sum_sq + v * v 137 c = c + 1 138 iter_c = iter_c + 1 139 } 140 // mean(x^2) in Q20 / D = Q20. We want Q10 mean for sqrt: 141 // mean_sq_q20 = sum_sq / d 142 // mean_sq_q10 = mean_sq_q20 / Q10 = sum_sq / (d * Q10) 143 let mean_sq_q10: i64 = sum_sq / (d * NX_RMSN_Q10) 144 let rms_q10: i64 = nx_isqrt_q10(mean_sq_q10 + NX_RMSN_EPS_Q10) 145 if rms_q10 <= 0 { verdict = NX_LOOP_DONE_EXIT } 146 147 // Pass 2: normalise + scale by gamma. 148 if verdict == NX_LOOP_RUNNING { 149 var c2: nx_int = 0 150 var iter_c2: nx_int = 0 151 var verdict_c2: nx_int = NX_LOOP_RUNNING 152 while verdict_c2 == NX_LOOP_RUNNING && iter_c2 < BUDGET_C { 153 let xv: i64 = px[i * d + c2] 154 // norm_q10 = x_q10 * Q10 / rms_q10 155 let norm: i64 = (xv * NX_RMSN_Q10) / rms_q10 156 // scaled = norm * gamma_q10 / Q10 157 po[i * d + c2] = (norm * gamma[c2]) / NX_RMSN_Q10 158 c2 = c2 + 1 159 iter_c2 = iter_c2 + 1 160 } 161 } 162 i = i + 1 163 iter = iter + 1 164 } 165 return NX_RMSN_OK 166} 167 168// ===== Convenience: build a unit-gamma vector ==================== 169// 170// Caller-friendly factory. Allocates a length-D array all set to 171// Q10=1024 (the standard RMSNorm initialisation). 172 173func nx_rmsnorm_gamma_unit(d: nx_int) -> *i64 { 174 let g: *i64 = sys_mmap(d * 8) as *i64 175 var i: nx_int = 0 176 var iter: nx_int = 0 177 var verdict: nx_int = NX_LOOP_RUNNING 178 let BUDGET: nx_int = d 179 while verdict == NX_LOOP_RUNNING && iter < BUDGET { 180 g[i] = NX_RMSN_Q10 181 i = i + 1 182 iter = iter + 1 183 } 184 return g 185} 186 187// ===== Self-test ================================================== 188// 189// Closed-form invariants: 190// 191// (a) Constant input vector: RMS(c, c, ..., c) = c. After RMSNorm 192// with gamma=1, y[d] = c / c = 1 (Q10=1024) for every d. 193// 194// (b) Scale invariance: RMSNorm(k*x) = RMSNorm(x). Verify on 195// two parallel tokens scaled 2x apart. 196// 197// (c) Gamma scaling: with gamma[d] = 2 (in Q10=2048), output 198// should be 2x what gamma=1 produces. 199 200func main() -> i64 { 201 let n_tok: nx_int = 2 202 let d: nx_int = 8 203 204 let sh: *nx_int = sys_mmap(2 * 8) as *nx_int 205 sh[0] = n_tok; sh[1] = d 206 207 let err: *nx_int = sys_mmap(8) as *nx_int 208 err[0] = 0 209 let xt: *NxTensor = nx_t_alloc(NX_DT_I64, sh, 2, err) 210 let yt: *NxTensor = nx_t_alloc(NX_DT_I64, sh, 2, err) 211 if err[0] != 0 { return 5 } 212 213 // --- (a) Constant token --- 214 // Fill token 0 with all 5*Q10 = 5120. After RMSNorm with gamma=1, 215 // every output should be Q10 = 1024 (since x/RMS(x) = 1 for 216 // constant input). 217 let px: *i64 = xt.storage as *i64 218 let py: *i64 = yt.storage as *i64 219 var c: nx_int = 0 220 while c < d { px[c] = 5 * NX_RMSN_Q10; c = c + 1 } 221 222 // Token 1: scaled 4x version of token 0 -- tests scale invariance. 223 var c2: nx_int = 0 224 while c2 < d { px[d + c2] = 20 * NX_RMSN_Q10; c2 = c2 + 1 } 225 226 let gamma_unit: *i64 = nx_rmsnorm_gamma_unit(d) 227 let v: nx_int = nx_rmsnorm_forward(xt, gamma_unit, yt) 228 if v != NX_RMSN_OK { return 10 + v } 229 230 // Verify token 0 output = Q10=1024 (allow +/- 2 for rounding). 231 var ci: nx_int = 0 232 while ci < d { 233 let drift: i64 = py[ci] - NX_RMSN_Q10 234 if drift > 2 { return 20 } 235 if drift < -2 { return 21 } 236 ci = ci + 1 237 } 238 239 // --- (b) Scale invariance: token 1 should produce same output. --- 240 var ck: nx_int = 0 241 while ck < d { 242 let drift: i64 = py[d + ck] - NX_RMSN_Q10 243 if drift > 2 { return 30 } 244 if drift < -2 { return 31 } 245 ck = ck + 1 246 } 247 248 // --- (c) Gamma scaling: 2x gamma -> 2x output --- 249 var cj: nx_int = 0 250 while cj < d { gamma_unit[cj] = 2 * NX_RMSN_Q10; cj = cj + 1 } 251 nx_rmsnorm_forward(xt, gamma_unit, yt) 252 var cm: nx_int = 0 253 while cm < d { 254 let drift: i64 = py[cm] - 2 * NX_RMSN_Q10 255 if drift > 4 { return 40 } 256 if drift < -4 { return 41 } 257 cm = cm + 1 258 } 259 260 // --- (d) Verdict gate --- 261 var k: nx_int = 0 262 while k < NX_RMSN_N_VERDICTS { 263 if nx_rmsn_verdict_is_valid(k) != 1 { return 50 + k } 264 k = k + 1 265 } 266 267 return 0 268}