nx_mcu_q4.nx
buildroot/runtime/nx_mcu_q4.nx
about
nx_mcu_q4.nx -- the int4-weight x int8-activation group dot product: the kernel MCU-class LLM inference
actually spends its time in. Pure integer, no float lib, no libc.
WHY INTEGER AND WHY THIS KERNEL. On an ESP32-S3 the weights are group-wise symmetric int4 and the
activations are quantized to int8 per token, so every dot is int4 x int8 accumulated into int32. That
int32 group sum is EXACTLY what the S3 SIMD int8 dot instruction produces, which is why a scalar
reference and a vectorised device kernel are numerically identical rather than merely close. Being
integer, it is also bit-exact -- so its gate asserts EQUALITY, never a tolerance. A float kernel would
need an epsilon, and an epsilon is where a wrong answer hides.
LAYOUT (mirrors the exported PLE1 format byte-for-byte):
- a row is ceil(cols/2) bytes of packed nibbles; rows are byte-aligned, so odd cols leave a dead
high nibble in the last byte. Ragged, no padding.
- nibble = value + 8, giving the signed range -8..+7 from an unsigned 0..15 code.
- EVEN column index -> LOW nibble; ODD column index -> HIGH nibble. Getting this backwards still
produces plausible-looking numbers, so the gate pins it with a discriminating control.
- activations are int8 two's complement in a byte, so they need sign extension, not a cast.
Group scales are fp16 and are applied OUTSIDE this kernel (acc * scale), deliberately: this file stays
integer-exact and single-responsibility, and the float scaling lives with the code that owns floats.
Semantics derived from the int8-activation path of esp32-ai firmware/common/llm.h (MIT, (c) 2026
Viacheslav Sierbov). Independent NishiLang implementation; behaviour is pinned by nx_mcu_q4_gate.
Shifts are written as / and % so this stays portable to the no-shift lowering path.
license_tier: DERIVED-MIT No hw writes (Rule 26).
dependencies 1 imports · 1 importers
imports: nx_syscalls.nx
imported by: nx_mcu_q4_gate.nx
structs
| none |
consts
| 28 | const Q4_BIAS: i64 = 8 |
| 29 | const Q4_CODES: i64 = 16 |
| 30 | const Q4_I8_WRAP: i64 = 256 |
| 31 | const Q4_I8_MAX: i64 = 127 |
functions
| 34 | func q4_row_bytes(cols: i64) -> i64 |
| 40 | func q4_i8(b: i64) -> i64 |
| 46 | func q4_code_at(row: *u8, j: i64) -> i64 called by 1: q4_val_at |
| 53 | func q4_val_at(row: *u8, j: i64) -> i64 |
| 59 | func q4_group_dot(row: *u8, xq: *u8, begin: i64, end: i64) -> i64 |
| 71 | func q4_row_dot(row: *u8, xq: *u8, cols: i64) -> i64 |
| 76 | func q4_n_groups(cols: i64, group: i64) -> i64 called by 1: main |
| 83 | func q4_group_end(cols: i64, group: i64, gi: i64) -> i64 called by 1: main |
| 90 | func q4_pack(dst: *u8, vals: *i64, cols: i64) -> i64 |