code wiki / (root) / nx_quant_block.nx

nx_quant_block.nx source

↩ module page · 256 lines · 9616 B

1// nx_quant_block.nx -- 4-bit block quantization on i64 substrate. 2// 3// Algo-led memory win per the min-hardware-floor cardinal. 4// Modern LLMs and diffusion models DO NOT fit on small hardware at 5// f32 precision; q4_K-style quantization is what makes Llama-3 70B 6// run on a 24 GB consumer GPU and what makes Z-Image fit on a 16 GB 7// card. On a $50 SBC with 4-8 GB total RAM, quantization is the 8// difference between "model loads" and "model OOMs." 9// 10// v1 shape: q4_0-style symmetric quantization on i64 substrate. 11// * Block size: 32 values per block (standard) 12// * Per-block scale: one i64 (Q-format multiplier) 13// * Values: 4-bit signed nibbles in [-7, 7]; packed two per byte 14// * Storage per block: 8 bytes (scale) + 16 bytes (nibbles) = 24 bytes 15// * Dense storage: 32 i64 = 256 bytes 16// * Compression: 256 / 24 ~ 10.67x 17// 18// When the compiler-I2 f-types land, this slots in q4_K (k-means per- 19// group scales) + q5_K + q8_0 without API change. The block layout 20// is the contract; the per-block calibration is the dtype. 21// 22// Algorithmic precision note (the audit-honest answer): 23// * Per-value max rounding error = scale/2 = max_abs/14 24// * Relative error per value ≤ 1/14 ≈ 7.1% in the worst case 25// * Mean error across a uniform-distribution block is ~1/28 ≈ 3.6% 26// * Oracle round-trip uses eps_q10 = 102 (10%) -- well above mean 27// error, below worst-case, so failures here are real corruption 28// not normal quantization noise. 29// 30// genealogy_id: q4_0_ggml_2023 + q4_K_ggml_2024 + lloyd_max_1957 + 31// jacob_kligys_2018_8bit_quant 32// lineage_id: substrate_quant_block_v1 33 34// nx_safety_envelope: 35// intended_use: AUTO_APPLIED -- primitive-specific tuning queued 36// sil_target: SIL1 37// evidence: [bulk_applied_2026-05-16, see-file-comment-for-detail] 38// verdict: NOT_YET_EVALUATED 39 40import "nx_syscalls.nx" 41import "nx_tier.nx" 42import "nx_tensor.nx" 43 44const NX_QB_BLOCK_SIZE: nx_int = 32 // values per block 45const NX_QB_NIBBLE_MAX: nx_int = 7 // [-7..+7] 46const NX_QB_NIBBLES_PER_BYTE: nx_int = 2 47 48// Bytes per block: 8 (scale) + BLOCK_SIZE/2 (nibbles) 49const NX_QB_BYTES_PER_BLOCK: nx_int = 24 50 51// ===== Sealed-enum: QuantVerdict ================================= 52 53const NX_QB_OK: nx_int = 0 54const NX_QB_ERR_BAD_LEN: nx_int = 1 // n not multiple of BLOCK_SIZE 55const NX_QB_ERR_BAD_DTYPE: nx_int = 2 // input not i64 56const NX_QB_ERR_SHAPE_MISMATCH: nx_int = 3 // dequant target wrong shape 57const NX_QB_N_VERDICTS: nx_int = 4 58 59func nx_qb_verdict_is_valid(v: nx_int) -> nx_int { 60 if v < 0 { return 0 } 61 if v >= NX_QB_N_VERDICTS { return 0 } 62 return 1 63} 64 65// ===== Quantised buffer struct ==================================== 66 67struct NxQuantBlock { 68 n_blocks: nx_int, // total blocks 69 n_values: nx_int, // n_blocks * BLOCK_SIZE 70 scales: *i64, // [n_blocks] 71 packed: *u8 // [n_blocks * BLOCK_SIZE / 2] 72} 73 74const NX_QB_STRUCT_BYTES: nx_int = 32 // 4 fields * 8 75 76func nx_qb_alloc(n_values: nx_int) -> *NxQuantBlock { 77 let qb: *NxQuantBlock = (sys_mmap(NX_QB_STRUCT_BYTES)) as *NxQuantBlock 78 let n_blocks: nx_int = (n_values + NX_QB_BLOCK_SIZE - 1) / NX_QB_BLOCK_SIZE 79 qb.n_blocks = n_blocks 80 qb.n_values = n_blocks * NX_QB_BLOCK_SIZE // round up; tail zero-padded 81 qb.scales = (sys_mmap(n_blocks * NX_SIZEOF_NX_INT)) as *i64 82 let packed_bytes: nx_int = qb.n_values / NX_QB_NIBBLES_PER_BYTE 83 qb.packed = (sys_mmap(packed_bytes)) as *u8 84 // Zero-init 85 var i: nx_int = 0 86 while i < n_blocks { 87 qb.scales[i] = 0 88 i = i + 1 89 } 90 var j: nx_int = 0 91 while j < packed_bytes { 92 qb.packed[j] = 0 93 j = j + 1 94 } 95 return qb 96} 97 98// ===== Nibble pack / unpack helpers =============================== 99// 100// We store 2 signed-nibble values per byte. Even positions in the 101// low nibble (bits 0-3), odd positions in the high nibble (bits 4-7). 102// Sign-extension is explicit (NOT C's "auto sign-extend") -- nxc2's 103// integer ops are well-defined per the no-UB cardinal. 104 105func _qb_pack_nibble(qb: *NxQuantBlock, value_idx: nx_int, signed_nib: nx_int) -> nx_int { 106 let byte_idx: nx_int = value_idx / 2 107 let is_high: nx_int = value_idx - byte_idx * 2 // 0 or 1 108 // Map [-7..7] to [1..15] (0 reserved for "encoded zero" so we 109 // never round 0.5 -> -8 boundary). Actually keep it simple: 110 // map signed [-7..7] to unsigned [0..14] via +7 offset. 111 let unsigned_nib: nx_int = signed_nib + 7 112 let cur: nx_int = qb.packed[byte_idx] as nx_int 113 var new_byte: nx_int = 0 114 if is_high == 0 { 115 // low nibble: clear low 4 bits, OR new value 116 new_byte = (cur - (cur - (cur / 16) * 16)) + unsigned_nib 117 } 118 if is_high == 1 { 119 // high nibble: keep low 4 bits, set high 4 120 new_byte = (cur - (cur / 16) * 16) + unsigned_nib * 16 121 } 122 qb.packed[byte_idx] = new_byte 123 return 0 124} 125 126func _qb_unpack_nibble(qb: *NxQuantBlock, value_idx: nx_int) -> nx_int { 127 let byte_idx: nx_int = value_idx / 2 128 let is_high: nx_int = value_idx - byte_idx * 2 129 let cur: nx_int = qb.packed[byte_idx] as nx_int 130 var unsigned_nib: nx_int = 0 131 if is_high == 0 { 132 unsigned_nib = cur - (cur / 16) * 16 // low nibble 133 } 134 if is_high == 1 { 135 unsigned_nib = cur / 16 // high nibble 136 } 137 return unsigned_nib - 7 // back to signed [-7..7] 138} 139 140// ===== Quantise a buffer of i64 values ============================ 141// 142// For each block of BLOCK_SIZE values: 143// 1. find max(|values|) 144// 2. scale = max_abs / 7 (i64 division floors; small ranges may 145// collapse to scale=1) 146// 3. for each value: nibble = round(value / scale), clamped to 147// [-7, 7] 148// 149// Caller supplies the source buffer + length. qb must have 150// n_values >= length (alloc with the same n). 151 152func _qb_abs(x: nx_int) -> nx_int { 153 if x < 0 { return 0 - x } 154 return x 155} 156 157func nx_qb_quantize(values: *i64, n: nx_int, qb: *NxQuantBlock) -> nx_int { 158 if n > qb.n_values { return NX_QB_ERR_BAD_LEN } 159 160 let n_blocks: nx_int = (n + NX_QB_BLOCK_SIZE - 1) / NX_QB_BLOCK_SIZE 161 var b: nx_int = 0 162 while b < n_blocks { 163 let block_start: nx_int = b * NX_QB_BLOCK_SIZE 164 var block_end: nx_int = block_start + NX_QB_BLOCK_SIZE 165 if block_end > n { block_end = n } 166 167 // Pass 1: find max absolute value in block 168 var max_abs: nx_int = 0 169 var i: nx_int = block_start 170 while i < block_end { 171 let a: nx_int = _qb_abs(values[i]) 172 if a > max_abs { max_abs = a } 173 i = i + 1 174 } 175 176 // Scale = max_abs / NIBBLE_MAX, with floor 1 to avoid /0. 177 var scale: nx_int = max_abs / NX_QB_NIBBLE_MAX 178 if scale < 1 { scale = 1 } 179 qb.scales[b] = scale 180 181 // Pass 2: quantise each value to nibble in [-7..7] 182 var j: nx_int = block_start 183 while j < block_end { 184 // Round half away from zero: q = (v + sign*scale/2) / scale 185 var v: nx_int = values[j] 186 var sign: nx_int = 1 187 if v < 0 { sign = 0 - 1 } 188 // (|v| + scale/2) / scale, then re-sign 189 let abs_v: nx_int = _qb_abs(v) 190 var q: nx_int = (abs_v + scale / 2) / scale 191 if q > NX_QB_NIBBLE_MAX { q = NX_QB_NIBBLE_MAX } 192 q = q * sign 193 _qb_pack_nibble(qb, j, q) 194 j = j + 1 195 } 196 // Zero-pad nibbles in incomplete trailing block (if any). 197 var k: nx_int = block_end 198 while k < block_start + NX_QB_BLOCK_SIZE { 199 _qb_pack_nibble(qb, k, 0) 200 k = k + 1 201 } 202 b = b + 1 203 } 204 return NX_QB_OK 205} 206 207// ===== Dequantise back to i64 ===================================== 208// 209// values_out[i] = nibble[i] * scale[i / BLOCK_SIZE] 210 211func nx_qb_dequantize(qb: *NxQuantBlock, values_out: *i64, n: nx_int) -> nx_int { 212 if n > qb.n_values { return NX_QB_ERR_BAD_LEN } 213 var i: nx_int = 0 214 while i < n { 215 let block_id: nx_int = i / NX_QB_BLOCK_SIZE 216 let nib: nx_int = _qb_unpack_nibble(qb, i) 217 values_out[i] = nib * qb.scales[block_id] 218 i = i + 1 219 } 220 return NX_QB_OK 221} 222 223// ===== Compression ratio (Q10) ==================================== 224// 225// dense_bytes = n_values * 8 (i64) 226// quant_bytes = n_blocks * NX_QB_BYTES_PER_BLOCK 227// ratio_q10 = (dense_bytes * 1024) / quant_bytes 228 229func nx_qb_compression_ratio_q10(qb: *NxQuantBlock) -> nx_int { 230 let dense: nx_int = qb.n_values * 8 231 let quant: nx_int = qb.n_blocks * NX_QB_BYTES_PER_BLOCK 232 if quant <= 0 { return 0 } 233 return (dense * 1024) / quant 234} 235 236// ===== Quantise a 1-D nx_tensor + return new qb =================== 237// 238// Convenience: assumes the tensor is i64, contiguous, 1-D. 239 240func nx_qb_quantize_tensor(t: *NxTensor) -> *NxQuantBlock { 241 if t.dtype != NX_DT_I64 { return 0 as *NxQuantBlock } 242 if t.ndim != 1 { return 0 as *NxQuantBlock } 243 if nx_t_is_contiguous(t) == 0 { return 0 as *NxQuantBlock } 244 let qb: *NxQuantBlock = nx_qb_alloc(t.numel) 245 nx_qb_quantize(t.storage as *i64, t.numel, qb) 246 return qb 247} 248 249// ===== Dequantise back into a caller-allocated tensor ============= 250 251func nx_qb_dequantize_tensor(qb: *NxQuantBlock, t: *NxTensor) -> nx_int { 252 if t.dtype != NX_DT_I64 { return NX_QB_ERR_BAD_DTYPE } 253 if t.ndim != 1 { return NX_QB_ERR_SHAPE_MISMATCH } 254 if nx_t_is_contiguous(t) == 0 { return NX_QB_ERR_SHAPE_MISMATCH } 255 return nx_qb_dequantize(qb, t.storage as *i64, t.numel) 256}