code wiki / (root) / nx_q4k_matmul.nx

nx_q4k_matmul.nx source

↩ module page · 140 lines · 6059 B

1// nx_q4k_matmul.nx -- fused-dequant Q4_K row × column dot product. 2// 3// Phase C foundation brick (conductor cardinal 2026-05-19). 4// Composes nx_dequant_iter.nx with the existing matmul pattern to 5// produce a row × column dot product WITHOUT materializing a full 6// dequantized weight tensor. 7// 8// What the brick does: 9// * Caller holds the Q4_K-quantized weight buffer (e.g. raw bytes 10// from a GGUF file -- composes with nx_gguf_load_lazy) 11// * Caller holds the dense column vector (Q10 i64) 12// * This brick walks the row's Q4_K blocks, dequants each block's 13// 256 values inline, multiplies by the corresponding column 14// entries, and accumulates the sum 15// * NO scratch buffer for dequantized weights; the substrate-side 16// RAM cost is the column vector + per-block iterator (~152 bytes) 17// 18// Honest framing (per no-strawman-perf-comparisons cardinal): 19// - This brick proves the FUSED dequant+dot composition works and 20// produces bit-exact results vs the materialize-then-matmul path. 21// - Per-element throughput on qemu-RV64 is measured by the bench 22// below. This is NOT a claim about CUDA or llama.cpp -- those 23// systems have their own per-element costs that require running 24// them on the same hardware to compare honestly. 25// 26// Output unit: dot product is returned in Q34 (sum of Q24 weight × 27// Q10 column products). The Q4_K weight dequant is now Q24 (EXACT 28// super-scales -- Q10/Q14 underflow/flip sign on real trained-model 29// super-scales). Caller uses nx_q4km_q20_to_q10 to recover Q10 30// (>>24 round-half). Q34 is kept internally so successive matmuls can 31// compose without intermediate normalization rounding. 32// 33// genealogy_id: ggml_q4k_dot_canon + standard_fused_dequant_pattern 34// lineage_id: substrate_q4k_matmul_v1_fused 35 36import "nx_syscalls.nx" 37import "nx_tier.nx" 38import "nx_le.nx" 39import "nx_gguf.nx" 40import "nx_gguf_load.nx" 41import "nx_dequant_iter.nx" 42 43// ===== Sealed-enum: Q4KMatmulVerdict ============================== 44 45const NX_Q4KM_OK: nx_int = 0 46const NX_Q4KM_ERR_BAD_DIM: nx_int = 1 47const NX_Q4KM_N_VERDICTS: nx_int = 2 48 49func nx_q4km_verdict_is_valid(v: nx_int) -> nx_int { 50 if v < 0 { return 0 } 51 if v >= NX_Q4KM_N_VERDICTS { return 0 } 52 return 1 53} 54 55// ===== Public: fused row × col dot product ========================= 56// 57// weight_buf: byte buffer containing Q4_K weight blocks 58// weight_super_off: byte offset of the row's FIRST Q4_K super-block 59// (subsequent blocks are at +144, +288, ... up 60// to weight_super_off + n_blocks*144) 61// n_blocks: number of Q4_K super-blocks in the row 62// (= K_dim / 256; K_dim must be a multiple of 256) 63// col_q10: dense column vector, K_dim Q10 i64 values 64// iter: caller-owned NxQ4KBlockIter (alloc once, reuse 65// across rows/columns -- the matmul caller's job) 66// 67// Returns: dot product in Q34 (Q24 weight × Q10 col). Caller uses 68// nx_q4km_q20_to_q10 to recover Q10. 69 70func nx_q4k_dot_row_col(weight_buf: *u8, weight_super_off: i64, 71 n_blocks: nx_int, 72 col_q10: *i64, 73 iter: *NxQ4KBlockIter) -> i64 { 74 // NO PER-CALL SCRATCH. This function is called m*n times by a GEMM; the 75 // two sys_mmap(8) cells it used to allocate here for nx_q4k_iter_scale_min 76 // were never unmapped, leaking a page-rounded mapping per call (~9,700 per 77 // matmul at n=4864) -- an unclosed resource in a loop is a clock, not a 78 // leak. nx_q4k_iter_scale/nx_q4k_iter_min return the values directly. 79 var dot: i64 = 0 80 81 var blk: nx_int = 0 82 while blk < n_blocks { 83 let blk_off: i64 = weight_super_off + blk * NX_GL_Q4_K_BPB 84 nx_q4k_iter_init(weight_buf, blk_off, iter) 85 let col_base: i64 = blk * 256 86 87 // ggml layout (verified bit-exact): sub-block sb draws from 88 // 32-byte group g = sb/2; output position l (0..32, in order) = 89 // byte qs[g*32 + l], LOW nibble when sb even, HIGH when sb odd. 90 // Activation column aligns to OUTPUT position col_base + sb*32 + l. 91 var sb: nx_int = 0 92 while sb < 8 { 93 let sc_sb: i64 = nx_q4k_iter_scale(iter, sb) 94 let m_sb: i64 = nx_q4k_iter_min(iter, sb) 95 let d1: i64 = iter.d_q10 * sc_sb // Q24 * sc 96 let m1: i64 = iter.dmin_q10 * m_sb // Q24 * m 97 let g: nx_int = sb / 2 98 let is_high: nx_int = sb - g * 2 // 0 low, 1 high 99 let grp_base: i64 = g * 32 100 let col_sb_base: i64 = col_base + sb * 32 101 102 var l: nx_int = 0 103 while l < 32 { 104 let byte_v: i64 = nx_le_read_u8(iter.qs_ptr, grp_base + l) 105 var q4: i64 = 0 106 if is_high == 0 { 107 q4 = byte_v & 0x0F 108 } else { 109 q4 = byte_v >> 4 110 } 111 let v: i64 = d1 * q4 - m1 // Q24 112 dot = dot + v * col_q10[col_sb_base + l] // Q24 * Q10 = Q34 113 l = l + 1 114 } 115 sb = sb + 1 116 } 117 blk = blk + 1 118 } 119 return dot 120} 121 122// ===== Public: convenience -- normalize fused dot -> Q10 ========== 123// 124// The fused dot now accumulates in Q34 (Q24 weight × Q10 column). 125// Dividing by 2^24 recovers a Q10 scalar. Round-half away from zero 126// (add/subtract 2^23 before the shift), matching the i64 truncation 127// semantics used elsewhere in the substrate. Name kept for callers. 128// 129// NOTE: this is now a >>24 normalization (was /1024 for the old Q20 130// path). Any caller feeding a genuinely Q20 value must not use this. 131 132const NX_Q4KM_Q24: i64 = 16777216 // 2^24 (weight super-scale unit) 133const NX_Q4KM_Q23: i64 = 8388608 // 2^23 (round-half bias) 134 135func nx_q4km_q20_to_q10(dot_q34: i64) -> i64 { 136 if dot_q34 >= 0 { 137 return (dot_q34 + NX_Q4KM_Q23) / NX_Q4KM_Q24 138 } 139 return (dot_q34 - NX_Q4KM_Q23) / NX_Q4KM_Q24 140}