957 lines
36 KiB
C
957 lines
36 KiB
C
/* SPDX-License-Identifier: AGPL-3.0-or-later */
|
|
/* Copyright (C) 2026 David (Corey) Herrera */
|
|
/**
|
|
* @file test_metal_q3_k.c
|
|
* @brief Absolute-correctness tests for the Metal Q3_K matvec kernels.
|
|
*
|
|
* The bar here is ABSOLUTE, never GPU-against-GPU. Every test compares the
|
|
* kernel either to the CPU dequant+dot reference written below — decoded
|
|
* straight from the GGUF Q3_K block spec, walking the block in its storage order
|
|
* rather than the kernel's inverted-index order, so the two derive the same
|
|
* mapping by different routes — or to the Pantheon golden vectors, which
|
|
* come from a third independent implementation. A GPU-vs-GPU test proves
|
|
* offset invariance and nothing else: a uniformly scaled row sum cancels in
|
|
* both arms and passes. The offset test here is therefore ADDITIONAL to an
|
|
* absolute one, not a substitute for it.
|
|
*
|
|
* Inputs are real Q3_K super-blocks read out of the shipped model file, not
|
|
* only synthetic ones, because a synthetic block cannot catch a misread of a
|
|
* real tensor's layout or placement.
|
|
*
|
|
* @status All tests pass on M4 / macOS 26. Each was proven able to fail
|
|
* against a deliberately broken kernel (scaled row sum, wrong scale decode,
|
|
* off-by-one block stride) before being trusted.
|
|
* @issues None
|
|
* @todo None
|
|
*/
|
|
|
|
#include "lib/test.h"
|
|
#include "metal/fm_metal.h"
|
|
|
|
#include <fcntl.h>
|
|
#include <math.h>
|
|
#include <stdint.h>
|
|
#include <stdio.h>
|
|
#include <stdlib.h>
|
|
#include <string.h>
|
|
#include <sys/mman.h>
|
|
#include <unistd.h>
|
|
|
|
/* ================================================================
|
|
* Fixtures
|
|
* ================================================================ */
|
|
|
|
#define Q3K_BLOCK_BYTES 110
|
|
#define Q3K_BLOCK_WEIGHTS 256
|
|
|
|
/* ggml tensor type id for Q3_K, as written in a GGUF tensor info record. */
|
|
#define GGUF_TENSOR_TYPE_Q3_K 11
|
|
|
|
/* A byte offset that is a multiple of 32 (GGUF's tensor alignment) but
|
|
* deliberately not a multiple of the 16384-byte page — the real GGUF case. */
|
|
#define SCRATCH_WEIGHT_OFFSET (1024u * 1024u + 32u)
|
|
|
|
#define SCRATCH_DIR \
|
|
"metal/tests/" /* run from the repo root; test_* is gitignored */
|
|
|
|
static const char *kScratchPath = SCRATCH_DIR "test_q3k_region_map.bin";
|
|
|
|
static const char *kDefaultGoldenDir =
|
|
"../Pantheon/tests/golden";
|
|
static const char *kDefaultModelPath =
|
|
"models/Nex-N2-mini/nex-agi_Nex-N2-mini-Q3_K_M.gguf";
|
|
|
|
/* The golden matvec fixture's shape, from tests/golden/README.md. */
|
|
#define GOLDEN_ROWS 24
|
|
#define GOLDEN_COLS 1024
|
|
|
|
/* Rows of the real model tensor to exercise. Enough to cover many blocks
|
|
* and many threadgroups without making the CPU reference slow. */
|
|
#define MODEL_ROWS 24
|
|
|
|
static const char *golden_dir(void)
|
|
{
|
|
const char *env = getenv("PN_GOLDEN_DIR");
|
|
return (env && *env) ? env : kDefaultGoldenDir;
|
|
}
|
|
|
|
static const char *model_path(void)
|
|
{
|
|
const char *env = getenv("NEX_N2_GGUF");
|
|
return (env && *env) ? env : kDefaultModelPath;
|
|
}
|
|
|
|
/* ================================================================
|
|
* CPU reference — decoded from the GGUF Q3_K block spec
|
|
*
|
|
* hmask[32] @ 0 high bit of each of the 256 quants
|
|
* qs[64] @ 32 low 2 bits of each quant, 4 per byte
|
|
* scales[12] @ 96 16 six-bit sub-block scales, interleaved packing
|
|
* d (fp16) @ 108 super-block scale
|
|
* ================================================================ */
|
|
|
|
/* IEEE-754 binary16 -> binary32, including subnormals, Inf and NaN. */
|
|
static float half_bits_to_float(uint16_t h)
|
|
{
|
|
uint32_t sign = (uint32_t)(h & 0x8000u) << 16;
|
|
uint32_t exp = (h >> 10) & 0x1Fu;
|
|
uint32_t mant = h & 0x03FFu;
|
|
uint32_t bits;
|
|
|
|
if (exp == 0) {
|
|
if (mant == 0) {
|
|
bits = sign;
|
|
} else {
|
|
/* Subnormal: value = mant * 2^-24. Shifting left until bit 10 is set
|
|
* leaves 1.f * 2^(-14 - shift), so the binary32 exponent field is
|
|
* 127 - 14 - shift. Off by one here HALVES every subnormal scale,
|
|
* and real Q4_K/Q3_K `d` values do go subnormal (4.55e-05 is below
|
|
* half's 6.10e-05 smallest normal). A wrong oracle fails a correct
|
|
* kernel. */
|
|
int shift = 0;
|
|
while ((mant & 0x0400u) == 0) { mant <<= 1; shift++; }
|
|
mant &= 0x03FFu;
|
|
bits = sign | ((uint32_t)(127 - 14 - shift) << 23) | (mant << 13);
|
|
}
|
|
} else if (exp == 31) {
|
|
bits = sign | 0x7F800000u | (mant << 13);
|
|
} else {
|
|
bits = sign | ((exp + 127 - 15) << 23) | (mant << 13);
|
|
}
|
|
|
|
float f;
|
|
memcpy(&f, &bits, sizeof(f));
|
|
return f;
|
|
}
|
|
|
|
/* Unpack the 16 six-bit sub-block scales, already biased by -32. ggml packs
|
|
* the low 4 bits of scales 0..7 into the low nibbles of scales_raw[0..7], the
|
|
* low 4 bits of scales 8..15 into their high nibbles, and the top 2 bits of
|
|
* all sixteen into scales_raw[8..11], two bits per scale. */
|
|
static void q3k_unpack_subblock_scales(const uint8_t *scales_raw, int out[16])
|
|
{
|
|
for (int i = 0; i < 4; i++) {
|
|
uint8_t hi = scales_raw[8 + i];
|
|
out[i] = (int)((scales_raw[i] & 0x0F) | (((hi >> 0) & 3) << 4)) - 32;
|
|
out[i + 4] = (int)((scales_raw[i + 4] & 0x0F) | (((hi >> 2) & 3) << 4)) - 32;
|
|
out[i + 8] = (int)((scales_raw[i] >> 4) | (((hi >> 4) & 3) << 4)) - 32;
|
|
out[i + 12] = (int)((scales_raw[i + 4] >> 4) | (((hi >> 6) & 3) << 4)) - 32;
|
|
}
|
|
}
|
|
|
|
/*
|
|
* Dequantize one 110-byte Q3_K super-block into 256 floats.
|
|
*
|
|
* Walks the block the way ggml writes it — two halves of 128 weights, four
|
|
* 2-bit shift levels per half, two 16-weight groups per level, the high-mask
|
|
* bit advancing once per 32 weights — rather than the closed-form index the
|
|
* kernel uses. A mistake in either derivation shows up as a mismatch.
|
|
*
|
|
* @param block Pointer to the 110-byte super-block
|
|
* @param out Receives 256 dequantized weights
|
|
*/
|
|
static void q3k_dequant_block_reference(const uint8_t *block, float *out)
|
|
{
|
|
const uint8_t *hmask = block;
|
|
const uint8_t *qs = block + 32;
|
|
uint16_t d_bits;
|
|
memcpy(&d_bits, block + 108, sizeof(d_bits));
|
|
const float d = half_bits_to_float(d_bits);
|
|
|
|
int scales[16];
|
|
q3k_unpack_subblock_scales(block + 96, scales);
|
|
|
|
int idx = 0, is = 0;
|
|
uint8_t mask_bit = 1;
|
|
for (int half = 0; half < 2; half++) {
|
|
const uint8_t *q = qs + half * 32;
|
|
for (int shift = 0; shift < 8; shift += 2) {
|
|
for (int group = 0; group < 32; group += 16) {
|
|
float dl = d * (float)scales[is++];
|
|
for (int l = 0; l < 16; l++) {
|
|
int qv = (q[group + l] >> shift) & 3;
|
|
int hb = (hmask[group + l] & mask_bit) ? 0 : 4;
|
|
out[idx++] = dl * (float)(qv - hb);
|
|
}
|
|
}
|
|
mask_bit = (uint8_t)(mask_bit << 1);
|
|
}
|
|
}
|
|
}
|
|
|
|
/*
|
|
* out[r] = dequant(w[r]) . x, in fp32, block by block.
|
|
*
|
|
* @param w Q3_K bytes, [rows, cols], rows strided by row_stride
|
|
* @param x [cols] activations
|
|
* @param out [rows] result
|
|
* @param row_stride Bytes per weight row, (cols/256)*110
|
|
*/
|
|
static void q3k_matvec_reference(const uint8_t *w, const float *x, float *out,
|
|
int rows, int cols, int row_stride)
|
|
{
|
|
const int n_blocks = cols / Q3K_BLOCK_WEIGHTS;
|
|
for (int r = 0; r < rows; r++) {
|
|
float acc = 0.0f;
|
|
for (int b = 0; b < n_blocks; b++) {
|
|
float dq[Q3K_BLOCK_WEIGHTS];
|
|
q3k_dequant_block_reference(w + (size_t)r * (size_t)row_stride
|
|
+ (size_t)b * Q3K_BLOCK_BYTES, dq);
|
|
const float *xb = x + b * Q3K_BLOCK_WEIGHTS;
|
|
for (int i = 0; i < Q3K_BLOCK_WEIGHTS; i++)
|
|
acc += dq[i] * xb[i];
|
|
}
|
|
out[r] = acc;
|
|
}
|
|
}
|
|
|
|
/* ================================================================
|
|
* Error metric
|
|
*
|
|
* The spec's acceptance bar for an exact kernel is max_abs_err / RMS, so
|
|
* that is what these report — a scalar big enough to see a scaled row sum
|
|
* and small enough that fp32 reassociation noise stays well under it.
|
|
* ================================================================ */
|
|
|
|
static float max_abs_error_over_rms(const float *got, const float *want, int count)
|
|
{
|
|
double sumsq = 0.0;
|
|
float worst = 0.0f;
|
|
for (int i = 0; i < count; i++) {
|
|
double diff = (double)got[i] - (double)want[i];
|
|
if (fabs(diff) > worst) worst = (float)fabs(diff);
|
|
sumsq += (double)want[i] * (double)want[i];
|
|
}
|
|
double rms = sqrt(sumsq / (count > 0 ? count : 1));
|
|
/* An all-zero reference has no scale to divide by: any difference at all
|
|
* is a total failure, so report a value no tolerance will accept.
|
|
* (A literal, not INFINITY — the build enables -ffast-math.) */
|
|
if (rms < 1e-30) return worst > 0.0f ? 1e30f : 0.0f;
|
|
return (float)(worst / rms);
|
|
}
|
|
|
|
/* ================================================================
|
|
* Golden vector loading (Pantheon tests/golden, PNGOLDEN1 format:
|
|
* one ASCII header line, then count raw little-endian values)
|
|
* ================================================================ */
|
|
|
|
/* Reads a golden file's payload into a fresh buffer. Caller frees.
|
|
* Returns NULL if the file is missing or shorter than expected. */
|
|
static void *load_golden_payload(const char *name, size_t expect_bytes)
|
|
{
|
|
char path[1024];
|
|
snprintf(path, sizeof(path), "%s/%s", golden_dir(), name);
|
|
|
|
FILE *f = fopen(path, "rb");
|
|
if (!f) return NULL;
|
|
|
|
/* Skip the single ASCII header line. */
|
|
int c;
|
|
while ((c = fgetc(f)) != EOF && c != '\n') { }
|
|
if (c == EOF) { fclose(f); return NULL; }
|
|
|
|
void *buf = malloc(expect_bytes);
|
|
if (!buf) { fclose(f); return NULL; }
|
|
size_t got = fread(buf, 1, expect_bytes, f);
|
|
fclose(f);
|
|
if (got != expect_bytes) { free(buf); return NULL; }
|
|
return buf;
|
|
}
|
|
|
|
/* Bytes in the golden weight matrix — also the bound on how many rows a test
|
|
* may reinterpret it as at a narrower column count. */
|
|
#define GOLDEN_WEIGHT_BYTES \
|
|
((size_t)GOLDEN_ROWS * (GOLDEN_COLS / 256) * Q3K_BLOCK_BYTES)
|
|
|
|
/* Loads the golden Q3_K weight matrix (24 rows x 1024 cols). Caller frees. */
|
|
static uint8_t *load_golden_weight(void)
|
|
{
|
|
return load_golden_payload("q3k_matvec_weight.bin", GOLDEN_WEIGHT_BYTES);
|
|
}
|
|
|
|
/* ================================================================
|
|
* Minimal GGUF locator — enough to find a real Q3_K tensor
|
|
* ================================================================ */
|
|
|
|
typedef struct {
|
|
uint64_t file_offset; /* absolute byte offset of the tensor's data */
|
|
uint32_t cols; /* ne[0] — weights per row */
|
|
uint64_t rows; /* product of ne[1..] */
|
|
} gguf_q3k_location;
|
|
|
|
static int gguf_read_exact(FILE *f, void *dst, size_t bytes)
|
|
{
|
|
return fread(dst, 1, bytes, f) == bytes ? 0 : -1;
|
|
}
|
|
|
|
static int gguf_skip_value(FILE *f, uint32_t type);
|
|
|
|
static int gguf_skip_string(FILE *f)
|
|
{
|
|
uint64_t len;
|
|
if (gguf_read_exact(f, &len, 8) != 0) return -1;
|
|
return fseeko(f, (off_t)len, SEEK_CUR);
|
|
}
|
|
|
|
/* Byte width of a GGUF scalar value type, or -1 for string/array. */
|
|
static int gguf_scalar_width(uint32_t type)
|
|
{
|
|
static const int width[13] = { 1, 1, 2, 2, 4, 4, 4, 1, -1, -1, 8, 8, 8 };
|
|
return type < 13 ? width[type] : -1;
|
|
}
|
|
|
|
static int gguf_skip_value(FILE *f, uint32_t type)
|
|
{
|
|
if (type == 8) return gguf_skip_string(f);
|
|
if (type == 9) {
|
|
uint32_t elem_type;
|
|
uint64_t count;
|
|
if (gguf_read_exact(f, &elem_type, 4) != 0) return -1;
|
|
if (gguf_read_exact(f, &count, 8) != 0) return -1;
|
|
int w = gguf_scalar_width(elem_type);
|
|
if (w > 0) /* one seek for a whole scalar array — vocabs are huge */
|
|
return fseeko(f, (off_t)(count * (uint64_t)w), SEEK_CUR);
|
|
for (uint64_t i = 0; i < count; i++)
|
|
if (gguf_skip_value(f, elem_type) != 0) return -1;
|
|
return 0;
|
|
}
|
|
int w = gguf_scalar_width(type);
|
|
if (w < 0) return -1;
|
|
return fseeko(f, w, SEEK_CUR);
|
|
}
|
|
|
|
/* Skips the header and metadata, leaving the stream on the first tensor info
|
|
* record. Writes the tensor count and the data-section alignment. */
|
|
static int gguf_seek_to_tensor_infos(FILE *f, uint64_t *tensor_count,
|
|
uint64_t *alignment)
|
|
{
|
|
char magic[4];
|
|
uint32_t version;
|
|
uint64_t kv_count;
|
|
|
|
if (gguf_read_exact(f, magic, 4) != 0 || memcmp(magic, "GGUF", 4) != 0) return -1;
|
|
if (gguf_read_exact(f, &version, 4) != 0 || version < 2) return -1;
|
|
if (gguf_read_exact(f, tensor_count, 8) != 0) return -1;
|
|
if (gguf_read_exact(f, &kv_count, 8) != 0) return -1;
|
|
|
|
*alignment = 32; /* GGUF's default when general.alignment is absent */
|
|
for (uint64_t i = 0; i < kv_count; i++) {
|
|
uint64_t key_len;
|
|
char key[256];
|
|
if (gguf_read_exact(f, &key_len, 8) != 0) return -1;
|
|
size_t copy = key_len < sizeof(key) - 1 ? (size_t)key_len : sizeof(key) - 1;
|
|
if (gguf_read_exact(f, key, copy) != 0) return -1;
|
|
key[copy] = '\0';
|
|
if (fseeko(f, (off_t)(key_len - copy), SEEK_CUR) != 0) return -1;
|
|
|
|
uint32_t type;
|
|
if (gguf_read_exact(f, &type, 4) != 0) return -1;
|
|
if (strcmp(key, "general.alignment") == 0 && type == 4) {
|
|
uint32_t a;
|
|
if (gguf_read_exact(f, &a, 4) != 0) return -1;
|
|
*alignment = a;
|
|
} else if (gguf_skip_value(f, type) != 0) {
|
|
return -1;
|
|
}
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
/*
|
|
* Finds the first Q3_K tensor whose data does NOT start on a page boundary —
|
|
* the case every pn_qweight in a real GGUF is in, and the one a kernel that
|
|
* binds at offset 0 cannot read.
|
|
*
|
|
* @param path GGUF file to scan
|
|
* @param out Receives the tensor's absolute file offset and shape
|
|
* @return 0 on success, -1 if the file is unreadable or has no such tensor
|
|
*/
|
|
static int gguf_find_unaligned_q3k_tensor(const char *path, gguf_q3k_location *out)
|
|
{
|
|
FILE *f = fopen(path, "rb");
|
|
if (!f) return -1;
|
|
|
|
uint64_t tensor_count = 0, alignment = 32;
|
|
if (gguf_seek_to_tensor_infos(f, &tensor_count, &alignment) != 0) {
|
|
fclose(f);
|
|
return -1;
|
|
}
|
|
|
|
/* The data-section start is only known after the last info record, so
|
|
* collect a few candidates and pick an unaligned one afterwards. */
|
|
enum { MAX_CANDIDATES = 8 };
|
|
gguf_q3k_location candidate[MAX_CANDIDATES];
|
|
int n_candidates = 0;
|
|
uint64_t dims[16];
|
|
for (uint64_t t = 0; t < tensor_count; t++) {
|
|
uint32_t n_dims, type;
|
|
uint64_t offset;
|
|
if (gguf_skip_string(f) != 0) break; /* tensor name */
|
|
if (gguf_read_exact(f, &n_dims, 4) != 0 || n_dims > 16) break;
|
|
if (gguf_read_exact(f, dims, (size_t)n_dims * 8) != 0) break;
|
|
if (gguf_read_exact(f, &type, 4) != 0) break;
|
|
if (gguf_read_exact(f, &offset, 8) != 0) break;
|
|
if (type != GGUF_TENSOR_TYPE_Q3_K || n_candidates == MAX_CANDIDATES) continue;
|
|
|
|
uint64_t rows = 1;
|
|
for (uint32_t d = 1; d < n_dims; d++) rows *= dims[d];
|
|
candidate[n_candidates].cols = (uint32_t)dims[0];
|
|
candidate[n_candidates].rows = rows;
|
|
candidate[n_candidates].file_offset = offset; /* rebased below */
|
|
n_candidates++;
|
|
}
|
|
|
|
off_t infos_end = ftello(f);
|
|
fclose(f);
|
|
if (n_candidates == 0 || infos_end < 0) return -1;
|
|
|
|
uint64_t data_start = ((uint64_t)infos_end + alignment - 1) / alignment * alignment;
|
|
size_t page = (size_t)getpagesize();
|
|
for (int i = 0; i < n_candidates; i++) {
|
|
uint64_t off = candidate[i].file_offset + data_start;
|
|
if (off % page == 0) continue; /* want the unaligned case */
|
|
*out = candidate[i];
|
|
out->file_offset = off;
|
|
return 0;
|
|
}
|
|
return -1;
|
|
}
|
|
|
|
/* ================================================================
|
|
* Shared helpers
|
|
* ================================================================ */
|
|
|
|
/* Deterministic activations — xorshift32 so a failure is reproducible. */
|
|
static void fill_pseudorandom(float *dst, int count, uint32_t seed)
|
|
{
|
|
uint32_t s = seed ? seed : 1u;
|
|
for (int i = 0; i < count; i++) {
|
|
s ^= s << 13; s ^= s >> 17; s ^= s << 5;
|
|
dst[i] = (float)((int32_t)(s >> 8) - 8388608) / 8388608.0f;
|
|
}
|
|
}
|
|
|
|
/* Runs fm_matvec_q3k over plain buffers and returns the GPU output buffer's
|
|
* contents. Caller owns nothing — the buffers are freed here. */
|
|
static void run_matvec_on_bytes(const uint8_t *w_bytes, const float *x,
|
|
float *out, int rows, int cols, int row_stride)
|
|
{
|
|
fm_buffer *w = fm_buffer_alloc((size_t)rows * (size_t)row_stride);
|
|
fm_buffer *bx = fm_buffer_alloc((size_t)cols * sizeof(float));
|
|
fm_buffer *bo = fm_buffer_alloc((size_t)rows * sizeof(float));
|
|
if (!w || !bx || !bo) { if (w) fm_buffer_free(w); if (bx) fm_buffer_free(bx);
|
|
if (bo) fm_buffer_free(bo); return; }
|
|
|
|
memcpy(fm_buffer_data(w), w_bytes, (size_t)rows * (size_t)row_stride);
|
|
memcpy(fm_buffer_data(bx), x, (size_t)cols * sizeof(float));
|
|
memset(fm_buffer_data(bo), 0, (size_t)rows * sizeof(float));
|
|
|
|
fm_matvec_q3k(w, bx, bo, rows, cols, row_stride);
|
|
fm_sync();
|
|
memcpy(out, fm_buffer_data(bo), (size_t)rows * sizeof(float));
|
|
|
|
fm_buffer_free(w);
|
|
fm_buffer_free(bx);
|
|
fm_buffer_free(bo);
|
|
}
|
|
|
|
/* Creates a sparse scratch file and maps it read/write, private. */
|
|
static void *map_scratch(size_t bytes, int *fd_out)
|
|
{
|
|
int fd = open(kScratchPath, O_RDWR | O_CREAT | O_TRUNC, 0644);
|
|
if (fd < 0) return NULL;
|
|
if (ftruncate(fd, (off_t)bytes) != 0) { close(fd); return NULL; }
|
|
void *base = mmap(NULL, bytes, PROT_READ | PROT_WRITE, MAP_PRIVATE, fd, 0);
|
|
if (base == MAP_FAILED) { close(fd); return NULL; }
|
|
*fd_out = fd;
|
|
return base;
|
|
}
|
|
|
|
static void unmap_scratch(void *base, size_t bytes, int fd)
|
|
{
|
|
munmap(base, bytes);
|
|
close(fd);
|
|
unlink(kScratchPath);
|
|
}
|
|
|
|
/* The real model's first unaligned Q3_K tensor, mapped through a page-aligned
|
|
* window so the tensor keeps its true (32-aligned, not page-aligned) offset. */
|
|
typedef struct {
|
|
void *window; /* mmap base, page-aligned */
|
|
size_t window_bytes;
|
|
size_t tensor_offset; /* tensor's byte offset inside the window */
|
|
int fd;
|
|
int cols;
|
|
int row_stride;
|
|
} model_tensor_window;
|
|
|
|
static int open_model_tensor_window(model_tensor_window *w, int rows)
|
|
{
|
|
gguf_q3k_location loc;
|
|
memset(w, 0, sizeof(*w));
|
|
if (gguf_find_unaligned_q3k_tensor(model_path(), &loc) != 0) return -1;
|
|
if (loc.cols % Q3K_BLOCK_WEIGHTS != 0 || loc.rows < (uint64_t)rows) return -1;
|
|
|
|
int fd = open(model_path(), O_RDONLY);
|
|
if (fd < 0) return -1;
|
|
|
|
size_t page = (size_t)getpagesize();
|
|
uint64_t window_start = loc.file_offset & ~(uint64_t)(page - 1);
|
|
size_t inside = (size_t)(loc.file_offset - window_start);
|
|
int row_stride = (int)(loc.cols / Q3K_BLOCK_WEIGHTS) * Q3K_BLOCK_BYTES;
|
|
size_t span = inside + (size_t)rows * (size_t)row_stride;
|
|
size_t bytes = (span + page - 1) / page * page;
|
|
|
|
void *base = mmap(NULL, bytes, PROT_READ, MAP_PRIVATE, fd, (off_t)window_start);
|
|
if (base == MAP_FAILED) { close(fd); return -1; }
|
|
|
|
w->window = base;
|
|
w->window_bytes = bytes;
|
|
w->tensor_offset = inside;
|
|
w->fd = fd;
|
|
w->cols = (int)loc.cols;
|
|
w->row_stride = row_stride;
|
|
return 0;
|
|
}
|
|
|
|
static void close_model_tensor_window(model_tensor_window *w)
|
|
{
|
|
if (w->window) munmap(w->window, w->window_bytes);
|
|
if (w->fd >= 0) close(w->fd);
|
|
w->window = NULL;
|
|
w->fd = -1;
|
|
}
|
|
|
|
/* ================================================================
|
|
* Tests — dequant
|
|
* ================================================================ */
|
|
|
|
/* Anchors this file's CPU reference to Pantheon's independent CPU dequant.
|
|
* If this fails, every other absolute test in the file is suspect. */
|
|
static void test_reference_dequant_matches_the_golden_block(void)
|
|
{
|
|
uint8_t *weight = load_golden_weight();
|
|
float *golden = load_golden_payload("q3k_dequant_block0.bin", 256 * sizeof(float));
|
|
ASSERT_NOT_NULL(weight);
|
|
ASSERT_NOT_NULL(golden);
|
|
|
|
float mine[256];
|
|
q3k_dequant_block_reference(weight, mine);
|
|
|
|
float err = max_abs_error_over_rms(mine, golden, 256);
|
|
printf(" dequant block0 max_abs/RMS = %.3e\n", (double)err);
|
|
ASSERT_TRUE(err < 1e-6f);
|
|
|
|
free(weight);
|
|
free(golden);
|
|
}
|
|
|
|
/* ================================================================
|
|
* Tests — matvec, absolute
|
|
* ================================================================ */
|
|
|
|
static void test_matvec_matches_the_golden_vectors(void)
|
|
{
|
|
const int row_stride = (GOLDEN_COLS / 256) * Q3K_BLOCK_BYTES;
|
|
uint8_t *weight = load_golden_weight();
|
|
float *x = load_golden_payload("q3k_matvec_input.bin", GOLDEN_COLS * sizeof(float));
|
|
float *golden = load_golden_payload("q3k_matvec_output.bin", GOLDEN_ROWS * sizeof(float));
|
|
ASSERT_NOT_NULL(weight);
|
|
ASSERT_NOT_NULL(x);
|
|
ASSERT_NOT_NULL(golden);
|
|
|
|
float gpu[GOLDEN_ROWS];
|
|
memset(gpu, 0, sizeof(gpu));
|
|
run_matvec_on_bytes(weight, x, gpu, GOLDEN_ROWS, GOLDEN_COLS, row_stride);
|
|
|
|
float err = max_abs_error_over_rms(gpu, golden, GOLDEN_ROWS);
|
|
printf(" golden matvec max_abs/RMS = %.3e (gpu[0]=%.6f golden[0]=%.6f)\n",
|
|
(double)err, (double)gpu[0], (double)golden[0]);
|
|
ASSERT_TRUE(err < 1e-5f);
|
|
|
|
free(weight);
|
|
free(x);
|
|
free(golden);
|
|
}
|
|
|
|
static void test_matvec_matches_the_cpu_reference_on_real_model_blocks(void)
|
|
{
|
|
model_tensor_window win;
|
|
ASSERT_INT_EQ(0, open_model_tensor_window(&win, MODEL_ROWS));
|
|
|
|
const uint8_t *w = (const uint8_t *)win.window + win.tensor_offset;
|
|
float *x = malloc((size_t)win.cols * sizeof(float));
|
|
float *want = malloc(MODEL_ROWS * sizeof(float));
|
|
float *got = malloc(MODEL_ROWS * sizeof(float));
|
|
ASSERT_NOT_NULL(x);
|
|
ASSERT_NOT_NULL(want);
|
|
ASSERT_NOT_NULL(got);
|
|
|
|
fill_pseudorandom(x, win.cols, 0x51EDU);
|
|
q3k_matvec_reference(w, x, want, MODEL_ROWS, win.cols, win.row_stride);
|
|
memset(got, 0, MODEL_ROWS * sizeof(float));
|
|
run_matvec_on_bytes(w, x, got, MODEL_ROWS, win.cols, win.row_stride);
|
|
|
|
float err = max_abs_error_over_rms(got, want, MODEL_ROWS);
|
|
printf(" real blocks max_abs/RMS = %.3e (cols=%d stride=%d)\n",
|
|
(double)err, win.cols, win.row_stride);
|
|
ASSERT_TRUE(err < 1e-5f);
|
|
|
|
free(x); free(want); free(got);
|
|
close_model_tensor_window(&win);
|
|
}
|
|
|
|
/* ================================================================
|
|
* Tests — matvec at a nonzero region offset
|
|
*
|
|
* Additional to the absolute tests above, never a replacement: both arms
|
|
* here are still checked against a CPU reference, so a uniform scale
|
|
* cannot cancel out the way it does in a GPU-vs-GPU comparison.
|
|
* ================================================================ */
|
|
|
|
static void test_matvec_at_nonzero_region_offset_is_still_absolutely_right(void)
|
|
{
|
|
const int row_stride = (GOLDEN_COLS / 256) * Q3K_BLOCK_BYTES;
|
|
const size_t weight_bytes = (size_t)GOLDEN_ROWS * row_stride;
|
|
uint8_t *weight = load_golden_weight();
|
|
float *x = load_golden_payload("q3k_matvec_input.bin", GOLDEN_COLS * sizeof(float));
|
|
float *golden = load_golden_payload("q3k_matvec_output.bin", GOLDEN_ROWS * sizeof(float));
|
|
ASSERT_NOT_NULL(weight);
|
|
ASSERT_NOT_NULL(x);
|
|
ASSERT_NOT_NULL(golden);
|
|
|
|
const size_t map_bytes = 4u * 1024u * 1024u;
|
|
int fd = -1;
|
|
void *base = map_scratch(map_bytes, &fd);
|
|
ASSERT_NOT_NULL(base);
|
|
memcpy((uint8_t *)base + SCRATCH_WEIGHT_OFFSET, weight, weight_bytes);
|
|
|
|
ASSERT_INT_EQ(0, SCRATCH_WEIGHT_OFFSET % 32);
|
|
ASSERT_INT_NE(0, SCRATCH_WEIGHT_OFFSET % 16384);
|
|
|
|
fm_region *region = fm_region_open(base, map_bytes, 0);
|
|
ASSERT_NOT_NULL(region);
|
|
fm_bufref wref;
|
|
ASSERT_INT_EQ(0, fm_region_bind(region, SCRATCH_WEIGHT_OFFSET, weight_bytes, &wref));
|
|
|
|
fm_buffer *bx = fm_buffer_alloc(GOLDEN_COLS * sizeof(float));
|
|
fm_buffer *bo = fm_buffer_alloc(GOLDEN_ROWS * sizeof(float));
|
|
ASSERT_NOT_NULL(bx);
|
|
ASSERT_NOT_NULL(bo);
|
|
memcpy(fm_buffer_data(bx), x, GOLDEN_COLS * sizeof(float));
|
|
memset(fm_buffer_data(bo), 0, GOLDEN_ROWS * sizeof(float));
|
|
|
|
fm_matvec_q3k_at(wref, fm_bufref_at(bx, 0), fm_bufref_at(bo, 0),
|
|
GOLDEN_ROWS, GOLDEN_COLS, row_stride);
|
|
fm_sync();
|
|
|
|
float err = max_abs_error_over_rms((const float *)fm_buffer_data(bo),
|
|
golden, GOLDEN_ROWS);
|
|
printf(" offset %u max_abs/RMS = %.3e\n",
|
|
(unsigned)SCRATCH_WEIGHT_OFFSET, (double)err);
|
|
ASSERT_TRUE(err < 1e-5f);
|
|
|
|
fm_buffer_free(bx);
|
|
fm_buffer_free(bo);
|
|
fm_region_close(region);
|
|
unmap_scratch(base, map_bytes, fd);
|
|
free(weight); free(x); free(golden);
|
|
}
|
|
|
|
static void test_matvec_reads_a_real_tensor_through_a_region(void)
|
|
{
|
|
model_tensor_window win;
|
|
ASSERT_INT_EQ(0, open_model_tensor_window(&win, MODEL_ROWS));
|
|
|
|
/* This is the whole point of fm_bufref: a real GGUF tensor is 32-byte
|
|
* aligned and never page-aligned, so offset-0 binding cannot reach it. */
|
|
ASSERT_INT_EQ(0, win.tensor_offset % 32);
|
|
ASSERT_INT_NE(0, win.tensor_offset % (size_t)getpagesize());
|
|
|
|
const size_t weight_bytes = (size_t)MODEL_ROWS * (size_t)win.row_stride;
|
|
fm_region *region = fm_region_open(win.window, win.window_bytes, 0);
|
|
ASSERT_NOT_NULL(region);
|
|
fm_bufref wref;
|
|
ASSERT_INT_EQ(0, fm_region_bind(region, win.tensor_offset, weight_bytes, &wref));
|
|
|
|
float *x = malloc((size_t)win.cols * sizeof(float));
|
|
float *want = malloc(MODEL_ROWS * sizeof(float));
|
|
ASSERT_NOT_NULL(x);
|
|
ASSERT_NOT_NULL(want);
|
|
fill_pseudorandom(x, win.cols, 0xC0FFEEU);
|
|
q3k_matvec_reference((const uint8_t *)win.window + win.tensor_offset,
|
|
x, want, MODEL_ROWS, win.cols, win.row_stride);
|
|
|
|
fm_buffer *bx = fm_buffer_alloc((size_t)win.cols * sizeof(float));
|
|
fm_buffer *bo = fm_buffer_alloc(MODEL_ROWS * sizeof(float));
|
|
ASSERT_NOT_NULL(bx);
|
|
ASSERT_NOT_NULL(bo);
|
|
memcpy(fm_buffer_data(bx), x, (size_t)win.cols * sizeof(float));
|
|
memset(fm_buffer_data(bo), 0, MODEL_ROWS * sizeof(float));
|
|
|
|
fm_matvec_q3k_at(wref, fm_bufref_at(bx, 0), fm_bufref_at(bo, 0),
|
|
MODEL_ROWS, win.cols, win.row_stride);
|
|
fm_sync();
|
|
|
|
float err = max_abs_error_over_rms((const float *)fm_buffer_data(bo),
|
|
want, MODEL_ROWS);
|
|
printf(" model tensor @+%zu max_abs/RMS = %.3e\n",
|
|
win.tensor_offset, (double)err);
|
|
ASSERT_TRUE(err < 1e-5f);
|
|
|
|
fm_buffer_free(bx);
|
|
fm_buffer_free(bo);
|
|
fm_region_close(region);
|
|
free(x); free(want);
|
|
close_model_tensor_window(&win);
|
|
}
|
|
|
|
/* ================================================================
|
|
* Tests — the ragged row tail
|
|
* ================================================================ */
|
|
|
|
/* Floats of sentinel written past the output rows, to catch a store from a
|
|
* lane whose row index ran off the end of the grid's last threadgroup. */
|
|
#define TAIL_GUARD_FLOATS 16
|
|
#define TAIL_GUARD_VALUE (-12345.0f)
|
|
|
|
/*
|
|
* Runs fm_matvec_q3k over an output buffer padded with a sentinel.
|
|
*
|
|
* @param weight Q3_K bytes, at least rows * row_stride of them
|
|
* @param rows, cols Shape to dispatch; cols must be a multiple of 256
|
|
* @param row_stride Bytes per weight row, (cols/256)*110
|
|
* @param tail_intact Set true only if every sentinel past `rows` survived
|
|
* @return max_abs/RMS over the valid rows, or -1 if a buffer
|
|
* could not be allocated
|
|
*/
|
|
static float matvec_error_with_tail_guard(const uint8_t *weight, int rows,
|
|
int cols, int row_stride,
|
|
bool *tail_intact)
|
|
{
|
|
float *x = malloc((size_t)cols * sizeof(float));
|
|
float *want = malloc((size_t)rows * sizeof(float));
|
|
fm_buffer *bw = fm_buffer_alloc((size_t)rows * (size_t)row_stride);
|
|
fm_buffer *bx = fm_buffer_alloc((size_t)cols * sizeof(float));
|
|
fm_buffer *bo = fm_buffer_alloc((size_t)(rows + TAIL_GUARD_FLOATS) * sizeof(float));
|
|
float err = -1.0f;
|
|
|
|
*tail_intact = false;
|
|
if (!x || !want || !bw || !bx || !bo) goto cleanup;
|
|
|
|
fill_pseudorandom(x, cols, 0x51EDU + (uint32_t)rows);
|
|
q3k_matvec_reference(weight, x, want, rows, cols, row_stride);
|
|
|
|
memcpy(fm_buffer_data(bw), weight, (size_t)rows * (size_t)row_stride);
|
|
memcpy(fm_buffer_data(bx), x, (size_t)cols * sizeof(float));
|
|
float *o = (float *)fm_buffer_data(bo);
|
|
for (int i = 0; i < rows + TAIL_GUARD_FLOATS; i++)
|
|
o[i] = TAIL_GUARD_VALUE;
|
|
|
|
fm_matvec_q3k(bw, bx, bo, rows, cols, row_stride);
|
|
fm_sync();
|
|
|
|
o = (float *)fm_buffer_data(bo);
|
|
*tail_intact = true;
|
|
for (int i = rows; i < rows + TAIL_GUARD_FLOATS; i++)
|
|
if (o[i] != TAIL_GUARD_VALUE) *tail_intact = false;
|
|
err = max_abs_error_over_rms(o, want, rows);
|
|
|
|
cleanup:
|
|
if (bw) fm_buffer_free(bw);
|
|
if (bx) fm_buffer_free(bx);
|
|
if (bo) fm_buffer_free(bo);
|
|
free(x);
|
|
free(want);
|
|
return err;
|
|
}
|
|
|
|
/*
|
|
* A threadgroup covers 32/(cols/256) output rows, so the grid is rounded up and
|
|
* the last group carries row indices past the end. Those lanes must contribute
|
|
* nothing to a live row's reduction and store nothing — a hazard that did not
|
|
* exist while every threadgroup owned exactly one row.
|
|
*
|
|
* Swept over both column counts the golden weight can be reinterpreted at:
|
|
* cols=512 splits a group 16 ways (2 lanes per row, the MoE expert shape) and
|
|
* cols=1024 splits it 8 ways, with row counts chosen to leave every kind of
|
|
* remainder including a single row and one short of a full group.
|
|
*/
|
|
static void test_ragged_row_tail_is_exact_and_writes_nothing_past_rows(void)
|
|
{
|
|
static const int shape[][2] = {
|
|
{ 512, 1 }, { 512, 15 }, { 512, 17 }, { 512, 31 }, { 512, 33 },
|
|
{ 1024, 1 }, { 1024, 7 }, { 1024, 9 }, { 1024, 23 },
|
|
};
|
|
uint8_t *weight = load_golden_weight();
|
|
ASSERT_NOT_NULL(weight);
|
|
|
|
for (size_t i = 0; i < sizeof(shape) / sizeof(shape[0]); i++) {
|
|
int cols = shape[i][0];
|
|
int rows = shape[i][1];
|
|
int row_stride = (cols / 256) * Q3K_BLOCK_BYTES;
|
|
ASSERT_TRUE((size_t)rows * (size_t)row_stride <= GOLDEN_WEIGHT_BYTES);
|
|
|
|
bool tail_intact = false;
|
|
float err = matvec_error_with_tail_guard(weight, rows, cols, row_stride,
|
|
&tail_intact);
|
|
printf(" tail cols=%-4d rows=%-3d max_abs/RMS = %.3e\n",
|
|
cols, rows, (double)err);
|
|
ASSERT_TRUE(tail_intact);
|
|
ASSERT_TRUE(err >= 0.0f && err < 1e-5f);
|
|
}
|
|
free(weight);
|
|
}
|
|
|
|
/* ================================================================
|
|
* Tests — batched _nx
|
|
* ================================================================ */
|
|
|
|
/*
|
|
* Runs fm_matvec_q3k_nx over `n` columns and returns the WORST per-column
|
|
* max_abs/RMS against the CPU reference, so a tile-indexing bug cannot hide
|
|
* behind a column that happens to land right.
|
|
*
|
|
* @return Worst per-column error, or -1 if a buffer could not be allocated
|
|
*/
|
|
static float nx_worst_column_error(const uint8_t *weight, int rows, int cols,
|
|
int row_stride, int n)
|
|
{
|
|
const size_t weight_bytes = (size_t)rows * (size_t)row_stride;
|
|
float *x = malloc((size_t)n * (size_t)cols * sizeof(float));
|
|
float *want = malloc((size_t)n * (size_t)rows * sizeof(float));
|
|
fm_buffer *bw = fm_buffer_alloc(weight_bytes);
|
|
fm_buffer *bx = fm_buffer_alloc((size_t)n * (size_t)cols * sizeof(float));
|
|
fm_buffer *bo = fm_buffer_alloc((size_t)n * (size_t)rows * sizeof(float));
|
|
float worst = -1.0f;
|
|
if (!x || !want || !bw || !bx || !bo) goto cleanup;
|
|
|
|
fill_pseudorandom(x, n * cols, 0x9E37U + (uint32_t)n);
|
|
for (int t = 0; t < n; t++)
|
|
q3k_matvec_reference(weight, x + (size_t)t * cols, want + (size_t)t * rows,
|
|
rows, cols, row_stride);
|
|
|
|
memcpy(fm_buffer_data(bw), weight, weight_bytes);
|
|
memcpy(fm_buffer_data(bx), x, (size_t)n * (size_t)cols * sizeof(float));
|
|
memset(fm_buffer_data(bo), 0, (size_t)n * (size_t)rows * sizeof(float));
|
|
|
|
fm_matvec_q3k_nx(bw, bx, bo, rows, cols, row_stride, n);
|
|
fm_sync();
|
|
|
|
const float *got = (const float *)fm_buffer_data(bo);
|
|
worst = 0.0f;
|
|
for (int t = 0; t < n; t++) {
|
|
float e = max_abs_error_over_rms(got + (size_t)t * rows,
|
|
want + (size_t)t * rows, rows);
|
|
if (e > worst) worst = e;
|
|
}
|
|
printf(" nx n=%-3d worst column max_abs/RMS = %.3e\n", n, (double)worst);
|
|
|
|
cleanup:
|
|
if (bw) fm_buffer_free(bw);
|
|
if (bx) fm_buffer_free(bx);
|
|
if (bo) fm_buffer_free(bo);
|
|
free(x);
|
|
free(want);
|
|
return worst;
|
|
}
|
|
|
|
static void test_nx_single_column_matches_the_reference(void)
|
|
{
|
|
const int row_stride = (GOLDEN_COLS / 256) * Q3K_BLOCK_BYTES;
|
|
uint8_t *weight = load_golden_weight();
|
|
ASSERT_NOT_NULL(weight);
|
|
float err = nx_worst_column_error(weight, GOLDEN_ROWS, GOLDEN_COLS, row_stride, 1);
|
|
free(weight);
|
|
ASSERT_TRUE(err >= 0.0f && err < 1e-5f);
|
|
}
|
|
|
|
static void test_nx_full_tile_matches_the_reference(void)
|
|
{
|
|
const int row_stride = (GOLDEN_COLS / 256) * Q3K_BLOCK_BYTES;
|
|
uint8_t *weight = load_golden_weight();
|
|
ASSERT_NOT_NULL(weight);
|
|
float err = nx_worst_column_error(weight, GOLDEN_ROWS, GOLDEN_COLS, row_stride,
|
|
FM_Q3_K_NX_MAX_COLS);
|
|
free(weight);
|
|
ASSERT_TRUE(err >= 0.0f && err < 1e-5f);
|
|
}
|
|
|
|
/* n = 19 is two full 8-wide tiles plus a 3-wide remainder tile, so it
|
|
* exercises both specialized pipelines and the tile->column arithmetic. */
|
|
static void test_nx_tiles_plus_remainder_match_the_reference(void)
|
|
{
|
|
const int row_stride = (GOLDEN_COLS / 256) * Q3K_BLOCK_BYTES;
|
|
uint8_t *weight = load_golden_weight();
|
|
ASSERT_NOT_NULL(weight);
|
|
float err = nx_worst_column_error(weight, GOLDEN_ROWS, GOLDEN_COLS, row_stride, 19);
|
|
free(weight);
|
|
ASSERT_TRUE(err >= 0.0f && err < 1e-5f);
|
|
}
|
|
|
|
static void test_nx_on_real_model_blocks_matches_the_reference(void)
|
|
{
|
|
model_tensor_window win;
|
|
ASSERT_INT_EQ(0, open_model_tensor_window(&win, MODEL_ROWS));
|
|
float err = nx_worst_column_error((const uint8_t *)win.window + win.tensor_offset,
|
|
MODEL_ROWS, win.cols, win.row_stride, 11);
|
|
close_model_tensor_window(&win);
|
|
ASSERT_TRUE(err >= 0.0f && err < 1e-5f);
|
|
}
|
|
|
|
/* ================================================================
|
|
* main
|
|
* ================================================================ */
|
|
|
|
static int fixtures_present(void)
|
|
{
|
|
char path[1024];
|
|
snprintf(path, sizeof(path), "%s/q3k_matvec_weight.bin", golden_dir());
|
|
if (access(path, R_OK) != 0) {
|
|
fprintf(stderr, "golden vectors not found at %s (set PN_GOLDEN_DIR)\n", golden_dir());
|
|
return 0;
|
|
}
|
|
if (access(model_path(), R_OK) != 0) {
|
|
fprintf(stderr, "model not found at %s (set NEX_N2_GGUF)\n", model_path());
|
|
return 0;
|
|
}
|
|
return 1;
|
|
}
|
|
|
|
int main(void)
|
|
{
|
|
printf("\n Metal Q3_K Matvec Tests (absolute correctness)\n");
|
|
printf(" =============================================\n\n");
|
|
|
|
if (fm_init() != 0 || !fm_available()) {
|
|
fprintf(stderr, "Metal unavailable\n");
|
|
return 77;
|
|
}
|
|
if (!fixtures_present()) {
|
|
fm_free();
|
|
return 77;
|
|
}
|
|
|
|
printf(" golden dir = %s\n", golden_dir());
|
|
printf(" model = %s\n\n", model_path());
|
|
|
|
RUN_TEST(reference_dequant_matches_the_golden_block);
|
|
|
|
RUN_TEST(matvec_matches_the_golden_vectors);
|
|
RUN_TEST(matvec_matches_the_cpu_reference_on_real_model_blocks);
|
|
|
|
RUN_TEST(matvec_at_nonzero_region_offset_is_still_absolutely_right);
|
|
RUN_TEST(matvec_reads_a_real_tensor_through_a_region);
|
|
RUN_TEST(ragged_row_tail_is_exact_and_writes_nothing_past_rows);
|
|
|
|
RUN_TEST(nx_single_column_matches_the_reference);
|
|
RUN_TEST(nx_full_tile_matches_the_reference);
|
|
RUN_TEST(nx_tiles_plus_remainder_match_the_reference);
|
|
RUN_TEST(nx_on_real_model_blocks_matches_the_reference);
|
|
|
|
fm_free();
|
|
|
|
TEST_REPORT();
|
|
return __ft_failed > 0 ? 1 : 0;
|
|
}
|