code wiki / (root) / nx_kv_cache.nx

nx_kv_cache.nx source

↩ module page · 248 lines · 8934 B

1// nx_kv_cache.nx -- autoregressive attention KV cache. 2// 3// THE foundation primitive of efficient autoregressive generation 4// (LLM serving, forever-generating world models, autoregressive 5// diffusion). Without it, each new token recomputes attention over 6// every past token: O(n^2) per step. With it: O(n) per step. 7// For n=1000 generation steps that's a 1000x speedup with bit- 8// identical output (algorithmic correctness, not approximation). 9// 10// Per the min-hardware-floor + algo-led cardinal: this brick is 11// what makes a $50 SBC run autoregressive generation that a naive 12// O(n^2) recompute couldn't finish in a year. 13// 14// Composes with: 15// * nx_sparse_tensor -- KV cache rows are mostly zero post-softmax; 16// store sparse, compute sparse 17// * nx_quant_block -- cached K and V can be 4-bit; 10x less 18// memory bandwidth at each new-token step 19// * nx_compute_graph -- KV cache is a typed CONTROL node in the 20// graph; runner persists state across runs of the same graph 21// 22// Storage layout: 23// k_storage[head * max_seq_len * head_dim + pos * head_dim + d] 24// v_storage[head * max_seq_len * head_dim + pos * head_dim + d] 25// 26// Multi-head attention works by treating each head as an independent 27// per-position dot product space. We don't materialise the [Q, K, V] 28// triple as a single tensor here -- the cache OWNS K and V, the 29// caller's Q changes every step. 30// 31// genealogy_id: vaswani_2017_transformer_kv + flashattention_2_kv + 32// pope_2023_efficient_serving + mistral_sliding_window 33// lineage_id: substrate_kv_cache_v1 34 35// nx_safety_envelope: 36// intended_use: AUTO_APPLIED -- primitive-specific tuning queued 37// sil_target: SIL1 38// evidence: [bulk_applied_2026-05-16, see-file-comment-for-detail] 39// verdict: NOT_YET_EVALUATED 40 41import "nx_syscalls.nx" 42import "nx_tier.nx" 43const NX_MAGIC_1024: i64 = 1024 44 45// ===== Sealed-enum: KvVerdict ===================================== 46 47const NX_KV_OK: nx_int = 0 48const NX_KV_ERR_FULL: nx_int = 1 // append past max_seq_len 49const NX_KV_ERR_BAD_POSITION: nx_int = 2 // read past current length 50const NX_KV_ERR_BAD_HEAD: nx_int = 3 // head index out of range 51const NX_KV_ERR_BAD_DIM: nx_int = 4 // dim index out of range 52const NX_KV_ERR_SHAPE_MISMATCH: nx_int = 5 // input wrong size 53const NX_KV_N_VERDICTS: nx_int = 6 54 55func nx_kv_verdict_is_valid(v: nx_int) -> nx_int { 56 if v < 0 { return 0 } 57 if v >= NX_KV_N_VERDICTS { return 0 } 58 return 1 59} 60 61// ===== Cache struct =============================================== 62 63struct NxKvCache { 64 n_heads: nx_int, 65 head_dim: nx_int, 66 max_seq_len: nx_int, 67 n_filled: nx_int, 68 k_storage: *i64, // [n_heads * max_seq_len * head_dim] 69 v_storage: *i64 // same shape 70} 71 72const NX_KV_BYTES: nx_int = 48 // 6 fields * 8 73 74// ===== Builder ==================================================== 75 76func nx_kv_alloc(n_heads: nx_int, head_dim: nx_int, max_seq_len: nx_int) -> *NxKvCache { 77 let kv: *NxKvCache = (sys_mmap(NX_KV_BYTES)) as *NxKvCache 78 kv.n_heads = n_heads 79 kv.head_dim = head_dim 80 kv.max_seq_len = max_seq_len 81 kv.n_filled = 0 82 let storage_count: nx_int = n_heads * max_seq_len * head_dim 83 let storage_bytes: nx_int = storage_count * NX_SIZEOF_NX_INT 84 kv.k_storage = (sys_mmap(storage_bytes)) as *i64 85 kv.v_storage = (sys_mmap(storage_bytes)) as *i64 86 // Zero-init so reads past n_filled return zeros instead of garbage 87 var i: nx_int = 0 88 while i < storage_count { 89 kv.k_storage[i] = 0 90 kv.v_storage[i] = 0 91 i = i + 1 92 } 93 return kv 94} 95 96// Reset (new sequence) 97func nx_kv_clear(kv: *NxKvCache) -> nx_int { 98 kv.n_filled = 0 99 return 0 100} 101 102// ===== Append a new token's K + V projections ==================== 103// 104// k_in / v_in: flat [n_heads * head_dim] -- each head's new vector 105// concatenated. The cache moves them into per-head per-position 106// storage so subsequent reads can re-walk past tokens. 107// 108// Returns the position the new token was placed at, or -1 if full. 109 110func nx_kv_append(kv: *NxKvCache, k_in: *i64, v_in: *i64) -> nx_int { 111 if kv.n_filled >= kv.max_seq_len { return 0 - 1 } 112 let pos: nx_int = kv.n_filled 113 var h: nx_int = 0 114 while h < kv.n_heads { 115 var d: nx_int = 0 116 while d < kv.head_dim { 117 let dst_idx: nx_int = h * kv.max_seq_len * kv.head_dim + pos * kv.head_dim + d 118 let src_idx: nx_int = h * kv.head_dim + d 119 kv.k_storage[dst_idx] = k_in[src_idx] 120 kv.v_storage[dst_idx] = v_in[src_idx] 121 d = d + 1 122 } 123 h = h + 1 124 } 125 kv.n_filled = kv.n_filled + 1 126 return pos 127} 128 129// ===== Read accessors ============================================= 130 131func nx_kv_get_k(kv: *NxKvCache, head: nx_int, pos: nx_int, dim: nx_int) -> nx_int { 132 if head < 0 { return 0 } 133 if head >= kv.n_heads { return 0 } 134 if pos < 0 { return 0 } 135 if pos >= kv.n_filled { return 0 } 136 if dim < 0 { return 0 } 137 if dim >= kv.head_dim { return 0 } 138 let idx: nx_int = head * kv.max_seq_len * kv.head_dim + pos * kv.head_dim + dim 139 return kv.k_storage[idx] 140} 141 142func nx_kv_get_v(kv: *NxKvCache, head: nx_int, pos: nx_int, dim: nx_int) -> nx_int { 143 if head < 0 { return 0 } 144 if head >= kv.n_heads { return 0 } 145 if pos < 0 { return 0 } 146 if pos >= kv.n_filled { return 0 } 147 if dim < 0 { return 0 } 148 if dim >= kv.head_dim { return 0 } 149 let idx: nx_int = head * kv.max_seq_len * kv.head_dim + pos * kv.head_dim + dim 150 return kv.v_storage[idx] 151} 152 153// ===== Compute Q @ K^T across the cache ========================== 154// 155// q_in: flat [n_heads * head_dim] -- the new token's query vectors. 156// scores_out: flat [n_heads * n_filled] -- pre-softmax attention 157// scores. Caller composes softmax (when we have it) and feeds back 158// into nx_kv_attention_apply. 159// 160// Standard attention shape: score[h, p] = dot(Q[h, :], K[h, p, :]). 161// This is O(n_heads * n_filled * head_dim) -- linear in current 162// sequence length, not quadratic. 163 164func nx_kv_score(kv: *NxKvCache, q_in: *i64, scores_out: *i64) -> nx_int { 165 if kv.n_filled <= 0 { return NX_KV_OK } // nothing to score 166 var h: nx_int = 0 167 while h < kv.n_heads { 168 var p: nx_int = 0 169 while p < kv.n_filled { 170 var acc: nx_int = 0 171 var d: nx_int = 0 172 while d < kv.head_dim { 173 let q_idx: nx_int = h * kv.head_dim + d 174 let k_idx: nx_int = h * kv.max_seq_len * kv.head_dim + p * kv.head_dim + d 175 acc = acc + q_in[q_idx] * kv.k_storage[k_idx] 176 d = d + 1 177 } 178 scores_out[h * kv.n_filled + p] = acc 179 p = p + 1 180 } 181 h = h + 1 182 } 183 return NX_KV_OK 184} 185 186// ===== Apply attention weights to V ============================== 187// 188// weights_in: flat [n_heads * n_filled] -- typically softmax(scores). 189// For testing we accept raw weights; the caller decides whether 190// to normalise. 191// 192// out: flat [n_heads * head_dim] -- weighted sum of V vectors: 193// out[h, d] = sum_p(weights[h, p] * V[h, p, d]) 194// 195// For Q10 substrate semantics, weights are typically Q10 already. 196// We do (acc) / Q10 at the end to normalise. 197 198const NX_KV_Q10: nx_int = 1024 199 200func nx_kv_apply(kv: *NxKvCache, weights_in: *i64, out: *i64) -> nx_int { 201 if kv.n_filled <= 0 { 202 // Nothing in cache -> output is zero 203 var z: nx_int = 0 204 while z < kv.n_heads * kv.head_dim { 205 out[z] = 0 206 z = z + 1 207 } 208 return NX_KV_OK 209 } 210 var h: nx_int = 0 211 while h < kv.n_heads { 212 var d: nx_int = 0 213 while d < kv.head_dim { 214 var acc: nx_int = 0 215 var p: nx_int = 0 216 while p < kv.n_filled { 217 let w_idx: nx_int = h * kv.n_filled + p 218 let v_idx: nx_int = h * kv.max_seq_len * kv.head_dim + p * kv.head_dim + d 219 acc = acc + weights_in[w_idx] * kv.v_storage[v_idx] 220 p = p + 1 221 } 222 out[h * kv.head_dim + d] = acc / NX_KV_Q10 223 d = d + 1 224 } 225 h = h + 1 226 } 227 return NX_KV_OK 228} 229 230// ===== Memory usage (Q10 fraction of capacity) =================== 231// 232// fill_ratio_q10 = n_filled / max_seq_len * 1024 233// Useful for "should we evict?" decisions in long-context serving. 234 235func nx_kv_fill_ratio_q10(kv: *NxKvCache) -> nx_int { 236 if kv.max_seq_len <= 0 { return 0 } 237 return (kv.n_filled * NX_MAGIC_1024) / kv.max_seq_len 238} 239 240// ===== Bytes-per-token (memory accounting) ======================= 241// 242// Per token: n_heads * head_dim * 8 bytes (K) + same (V) = 2 * 243// n_heads * head_dim * 8. Useful for capacity planning on small 244// hardware ("can I run 4096 tokens on 4 GB?"). 245 246func nx_kv_bytes_per_token(kv: *NxKvCache) -> nx_int { 247 return 2 * kv.n_heads * kv.head_dim * 8 248}