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}