code wiki / (root) / nx_mcu_q4.nx

nx_mcu_q4.nx source

↩ module page · 104 lines · 4502 B

1// nx_mcu_q4.nx -- the int4-weight x int8-activation group dot product: the kernel MCU-class LLM inference 2// actually spends its time in. Pure integer, no float lib, no libc. 3// 4// WHY INTEGER AND WHY THIS KERNEL. On an ESP32-S3 the weights are group-wise symmetric int4 and the 5// activations are quantized to int8 per token, so every dot is int4 x int8 accumulated into int32. That 6// int32 group sum is EXACTLY what the S3 SIMD int8 dot instruction produces, which is why a scalar 7// reference and a vectorised device kernel are numerically identical rather than merely close. Being 8// integer, it is also bit-exact -- so its gate asserts EQUALITY, never a tolerance. A float kernel would 9// need an epsilon, and an epsilon is where a wrong answer hides. 10// 11// LAYOUT (mirrors the exported PLE1 format byte-for-byte): 12// - a row is ceil(cols/2) bytes of packed nibbles; rows are byte-aligned, so odd cols leave a dead 13// high nibble in the last byte. Ragged, no padding. 14// - nibble = value + 8, giving the signed range -8..+7 from an unsigned 0..15 code. 15// - EVEN column index -> LOW nibble; ODD column index -> HIGH nibble. Getting this backwards still 16// produces plausible-looking numbers, so the gate pins it with a discriminating control. 17// - activations are int8 two's complement in a byte, so they need sign extension, not a cast. 18// 19// Group scales are fp16 and are applied OUTSIDE this kernel (acc * scale), deliberately: this file stays 20// integer-exact and single-responsibility, and the float scaling lives with the code that owns floats. 21// 22// Semantics derived from the int8-activation path of esp32-ai firmware/common/llm.h (MIT, (c) 2026 23// Viacheslav Sierbov). Independent NishiLang implementation; behaviour is pinned by nx_mcu_q4_gate. 24// Shifts are written as / and % so this stays portable to the no-shift lowering path. 25// license_tier: DERIVED-MIT No hw writes (Rule 26). 26import "nx_syscalls.nx" 27 28const Q4_BIAS: i64 = 8 29const Q4_CODES: i64 = 16 30const Q4_I8_WRAP: i64 = 256 31const Q4_I8_MAX: i64 = 127 32 33// bytes needed for one packed row of `cols` int4 values (ragged, byte-aligned) 34func q4_row_bytes(cols: i64) -> i64 { 35 if cols <= 0 { return 0 } 36 return (cols + 1) / 2 37} 38 39// sign-extend a byte holding an int8 two's complement activation: 0..127 stay, 128..255 become -128..-1 40func q4_i8(b: i64) -> i64 { 41 if b > Q4_I8_MAX { return b - Q4_I8_WRAP } 42 return b 43} 44 45// raw 0..15 nibble code at column j. EVEN j -> low nibble, ODD j -> high nibble. 46func q4_code_at(row: *u8, j: i64) -> i64 { 47 let byte: i64 = row[j / 2] as i64 48 if (j % 2) == 0 { return byte % Q4_CODES } 49 return byte / Q4_CODES 50} 51 52// signed weight value at column j, in -8..+7 53func q4_val_at(row: *u8, j: i64) -> i64 { 54 return q4_code_at(row, j) - Q4_BIAS 55} 56 57// THE HOT LOOP: int32-equivalent accumulator over columns [begin, end) of one row. 58// Returns sum( weight[j] * activation[j] ). Caller multiplies by the group scale. 59func q4_group_dot(row: *u8, xq: *u8, begin: i64, end: i64) -> i64 { 60 var g: i64 = 0 61 var j: i64 = begin 62 while j < end { 63 g = g + q4_val_at(row, j) * q4_i8(xq[j] as i64) 64 j = j + 1 65 } 66 return g 67} 68 69// full-row dot across every group boundary; scales are applied by the caller per group, so this is the 70// single-group / scale-free form used by the gate and by any caller with one group per row. 71func q4_row_dot(row: *u8, xq: *u8, cols: i64) -> i64 { 72 return q4_group_dot(row, xq, 0, cols) 73} 74 75// number of groups covering `cols` at group width `group` (last group is short, never padded) 76func q4_n_groups(cols: i64, group: i64) -> i64 { 77 if group <= 0 { return 0 } 78 if cols <= 0 { return 0 } 79 return (cols + group - 1) / group 80} 81 82// end column of group gi, clamped to cols -- the ragged-tail rule in one place 83func q4_group_end(cols: i64, group: i64, gi: i64) -> i64 { 84 var e: i64 = (gi + 1) * group 85 if e > cols { e = cols } 86 return e 87} 88 89// TEST/EXPORT HELPER: pack signed values (-8..+7) from vals[0..cols) into packed nibbles at dst. 90func q4_pack(dst: *u8, vals: *i64, cols: i64) -> i64 { 91 let rb: i64 = q4_row_bytes(cols) 92 var i: i64 = 0 93 while i < rb { dst[i] = 0 as u8; i = i + 1 } 94 var j: i64 = 0 95 while j < cols { 96 let code: i64 = vals[j] + Q4_BIAS 97 let bi: i64 = j / 2 98 var cur: i64 = dst[bi] as i64 99 if (j % 2) == 0 { cur = cur + code } else { cur = cur + code * Q4_CODES } 100 dst[bi] = cur as u8 101 j = j + 1 102 } 103 return 0 104}