Writing kernels
Add a kernel for an existing ISA
- Implement the kernel in
kernels/<isa>/k_<isa>.cwith the exact signature of its scalar version inkernels/scalar/k_scalar.h, renamed toml_k_<op>_<isa>. - In
kernels/<isa>/k_<isa>.h, declare it and add#define ML_HAS_<OP>_<ISA>. - Run
make ISA=<isa> test e2e.
You don’t need to touch anything else: config/ml_config.h binds the new kernel automatically, and every ISA higher in the chain that lacks the op now inherits it.
Add a new ISA
- Add it to
mk/isa.mk:- its name to
ISAS; - a
CHAIN_<isa>that lists the ISAs it falls back to; - its compiler flags in
ISAFLAGS_<isa>; - an architecture check, if the ISA only exists on x86 or only on ARM.
- its name to
- Create
kernels/<isa>/k_<isa>.h. It may start empty. - Add the ISA to
config/ml_config.h, in both places:- include
kernels/<isa>/k_<isa>.hunder#ifdef ML_ENABLE_<ISA>; - put its
ML_HAS_*_<ISA>test in each op’s#ifchain, in priority order.
- include
- In
core/isa_check.c, add the CPUID/HWCAP bits the new flags require. - Add negative guard cases to
tests/test_isa_guard*.sh: a qemu CPU model that lacks the feature must make the binary exit with code 3. - Add the target to the
CMakeLists.txtflag table if it is x86, and add a row totests/test_all.sh.
Rules every kernel must follow
| Rule | Why |
|---|---|
Handle any n, any row range [r0, r1), rows not a multiple of 4, and an odd number of 32-blocks. Use scalar tails or masked or predicated loads. | test_kernels runs n ∈ {1, 7, 32, 288, 4097}, rows ∈ {1, 3, 4, 7, 64}, cols ∈ {32, 96, 256}, and r0 ∈ {0, 1}. |
Leave rows outside [r0, r1) untouched. | Threads write disjoint row ranges of the same output. |
quantize_q8 must be bit-identical to scalar: compute id = 127/amax in scalar code, then a plain multiply and add (no FMA) of copysign(0.5, x), then truncate, then clamp to ±127. Blocks with amax < ML_QUANT_TINY are all zero. | Activations must match across ISAs, so e2e tests can compare generated text against the scalar build. |
| Integer dot products must never saturate. | Quants are in [-127, 127], which keeps pmaddubsw pair sums ≤ 32258. |
| Use unaligned loads. | Tensor arrays are 64-byte aligned, but row and block offsets are not. |
| Use only intrinsics from the ISA’s own flags, or from ISAs those flags imply. | The ISA guard only checks what mk/isa.mk enables. |
| No allocation and no global state. Small stack buffers are fine. | Kernels run on several threads at once. |
Integer dot-product idioms per ISA
| ISA | Q8 × Q8, 32 elements |
|---|---|
sse2 | Sign-extend both operands to int16 (punpck + psraw 8), then pmaddwd. |
sse41, avx2 | pmaddubsw(abs(x), sign(w, x)), then pmaddwd(·, 1). |
avxvnni | vpdpbusd(0, abs(x), sign(w, x)). |
avx512vnni | vpdpbusd on two blocks at once. There is no 512-bit psignb, so negate w with mask_sub where x < 0. |
neon | vmull_s8 + vpadalq_s16 (works on ARMv7 and AArch64). |
neon_dotprod | vdotq_s32. |
neon_i8mm | vmmlaq_s32 (smmla), batched prefill only: 2 rows × 2 tokens per instruction. |
sve | svdot_s32 over svcntb() bytes. Per-block scales come from a gather, so any vector length works. |
Q4 kernels unpack nibbles first (& 0x0F, >> 4, subtract 8) and then reuse the Q8 path.
The matvecs process 4 rows per pass, so each activation block is loaded once and used four times. They convert int32 lanes to float, multiply by the block scale d_w · d_x, and reduce horizontally only once per row. With that deferred reduction, precision is bounded by the magnitude of the products, and test_kernels measures error against exactly that scale.
Packed kernels
ISAs with packed kernels implement four more functions over the layout in core/pack.h:
void ml_k_matvec_q8_pk_<isa>(float *y, const ml_qtensor *W, const ml_qact *x, int r0, int r1);
void ml_k_matvec_q4_pk_<isa>(float *y, const ml_qtensor *W, const ml_qact *x, int r0, int r1);
void ml_k_matmul_q8_pk_<isa>(float *Y, int ldy, const ml_qtensor *W, const ml_qbatch *X, int r0, int r1);
void ml_k_matmul_q4_pk_<isa>(float *Y, int ldy, const ml_qtensor *W, const ml_qbatch *X, int r0, int r1);
W->pq and W->pd hold the packed quants and scales. r0 is a multiple of the group size, and rows at or above r1 must not be written.
To register them, define the following macros in the ISA header, then add a branch for the ISA to both packed #if chains in config/ml_config.h:
| Macro | Meaning |
|---|---|
ML_HAS_MATVEC_PK_<ISA> | the ISA has packed matvecs |
ML_HAS_MATMUL_PK_<ISA> | the ISA has packed matmuls; define it only if they exist |
ML_PACK_G | the group size |
ML_PACK_Q8_OFFSET | the Q8 offset: 128 if the dot instruction needs unsigned weights |
ML_PACK_Q4_XOR | the Q4 xor: 0x88 to read nibbles as signed q - 8, else 0 |
ML_PACK_Q8 | set to 0 to leave Q8 row-major |
ML_PACK_D16 | set to 1 if the kernels read f16 scales when a tensor’s pd16 is set (files with f16 scales) |
An ISA with a packed matvec but no matmul falls back to ml_k_matmul_*_pk_loop. An ISA may also add only a matmul on another ISA’s layout, as neon_i8mm does on the NEON dotprod one: define ML_HAS_MATMUL_PK_<ISA> and put it in the matmul chain only.
Write a matmul’s tile as an ML_INLINE function of the token count, mark its loops over tokens ML_UNROLL, and call it through PK_TILES (kernels/x86_pack.h), which passes the count as a constant. GCC otherwise keeps the per-token accumulators on the stack, which costs up to 1.6× in prefill speed.
The tests hold packed kernels to these rules:
- Matvec: must match the scalar row-major kernel. It is bit-exact with unit scales, and within 1e-5 of the product magnitude otherwise.
- Matmul: each token’s column must equal that ISA’s packed matvec bit for bit. Keep the float operations in the same order: the same multiply, then the same add or FMA.
Replacing a kernel with assembly
- List the kernel’s name in
kernels/<isa>/asm.mk:ASM_<isa> := add. - Write
kernels/<isa>/<name>.Sexportingml_k_<name>_<isa>. Use the same C ABI: System V x86-64, AAPCS or AAPCS64. - Wrap the C version in
#ifndef ML_ASM_<NAME>_<ISA>. - Build with
make ISA=<isa> ASM=1.
kernels/sse2/add.S is the sample. To check that the assembly is in use, run objdump -d build/sse2-asm/test_kernels | grep -A4 '<ml_k_add_sse2>:'.