629 lines
25 KiB
C
629 lines
25 KiB
C
/* SPDX-License-Identifier: AGPL-3.0-or-later */
|
|
/* Copyright (C) 2026 David (Corey) Herrera */
|
|
/**
|
|
* @file test_metal_matvec_q6_k.c
|
|
* @brief Absolute-correctness tests for the Metal Q6_K matvec.
|
|
*
|
|
* Q6_K carries `output.weight` — the LM head, which decides every emitted
|
|
* token — so the oracle here is a CPU dequant+dot written straight from the
|
|
* standard 210-byte / 256-weight GGUF Q6_K block layout, NOT from the kernel.
|
|
* Every assertion is absolute, so a uniform error (a scaled row sum, a wrong
|
|
* scale decode) cannot cancel the way it does in a GPU-against-GPU
|
|
* offset-invariance comparison.
|
|
*
|
|
* The blocks are real, pulled from the shipped Q3_K_M GGUF — including the LM
|
|
* head itself. The nonzero-region-offset test is an ADDITION to the absolute
|
|
* tests, never a substitute for one.
|
|
*
|
|
* Exits 77 when no Metal device is present so GPU-less CI skips cleanly.
|
|
*
|
|
* @status All tests pass on M4 / macOS 26; each was proven able to fail (scaled
|
|
* row sum, signed scales read unsigned, off-by-one block stride, missing -32
|
|
* bias, ignored bind offset). The oracle below was itself cross-checked
|
|
* against an independent dequantizer and agreed BIT-IDENTICALLY on 32768
|
|
* real weights, `output.weight` among them. ragged_row_tail_* covers the
|
|
* hazard the rows-per-threadgroup split adds: the grid rounds up, so the last
|
|
* threadgroup carries rows past the end.
|
|
* @issues None
|
|
* @todo None
|
|
*/
|
|
|
|
#include "lib/test.h"
|
|
#include "metal/fm_metal.h"
|
|
#include "metal/tests/gguf_test_reader.h"
|
|
|
|
#include <fcntl.h>
|
|
#include <math.h>
|
|
#include <stdio.h>
|
|
#include <stdlib.h>
|
|
#include <sys/mman.h>
|
|
#include <unistd.h>
|
|
|
|
#define Q6K_BLOCK_BYTES 210u
|
|
#define Q6K_WEIGHTS_PER_BLOCK 256u
|
|
|
|
/* The LM head first — it is the reason this kernel's correctness is not
|
|
* optional — then a per-layer projection for a second real shape. */
|
|
static const char *kTensorLmHead = "output.weight";
|
|
static const char *kTensorAttnQ = "blk.3.attn_q.weight";
|
|
|
|
static const char *kDefaultModelPath =
|
|
"models/Nex-N2-mini/nex-agi_Nex-N2-mini-Q3_K_M.gguf";
|
|
|
|
#define SCRATCH_DIR \
|
|
"metal/tests/" /* run from the repo root; test_* is gitignored */
|
|
|
|
static const char *kScratchPath = SCRATCH_DIR "test_q6k_region.bin";
|
|
|
|
/* 32-byte aligned (GGUF's tensor alignment) but deliberately not a multiple of
|
|
* the 16 KiB page — the real case a weight inside an mmap'd model hits. */
|
|
#define REGION_WEIGHT_OFFSET (1024u * 1024u + 32u)
|
|
|
|
/* 2048 columns = 8 blocks per row, the shape both Q6_K tensors here have. */
|
|
#define MAX_ROW_STRIDE (8u * Q6K_BLOCK_BYTES)
|
|
|
|
static gguf_test_file g_model;
|
|
static int g_model_is_open;
|
|
|
|
/* ================================================================
|
|
* CPU reference — the oracle
|
|
*
|
|
* Standard GGUF Q6_K super-block, 210 bytes per 256 weights:
|
|
*
|
|
* offset 0 : uchar ql[128] the low 4 bits, two weights per byte
|
|
* offset 128 : uchar qh[64] the high 2 bits, four weights per byte
|
|
* offset 192 : char scales[16] signed 8-bit scale per 16 weights
|
|
* offset 208 : half d super-block scale
|
|
*
|
|
* Weight i in 0..255 decomposes as half h = i / 128, r = i % 128,
|
|
* quarter u = r / 32 and lane l = r % 32:
|
|
*
|
|
* packed = ql[64 * h + 32 * (u & 1) + l]
|
|
* nibble = (u < 2) ? packed & 0xF : packed >> 4
|
|
* high = (qh[32 * h + l] >> (2 * u)) & 3
|
|
* q = (int)(nibble | (high << 4)) - 32 // signed, range [-32, 31]
|
|
* value = d * scales[8 * h + 2 * u + l / 16] * q
|
|
* ================================================================ */
|
|
|
|
static float f16_to_f32(uint16_t bits)
|
|
{
|
|
uint32_t sign = (uint32_t)(bits >> 15) << 31;
|
|
uint32_t exponent = (bits >> 10) & 0x1Fu;
|
|
uint32_t mantissa = bits & 0x3FFu;
|
|
uint32_t out;
|
|
|
|
if (exponent == 0) {
|
|
if (mantissa == 0) {
|
|
out = sign;
|
|
} else { /* subnormal: renormalize */
|
|
exponent = 127 - 15 + 1;
|
|
while ((mantissa & 0x400u) == 0) { mantissa <<= 1; exponent--; }
|
|
mantissa &= 0x3FFu;
|
|
out = sign | (exponent << 23) | (mantissa << 13);
|
|
}
|
|
} else if (exponent == 0x1Fu) {
|
|
out = sign | 0x7F800000u | (mantissa << 13);
|
|
} else {
|
|
out = sign | ((exponent + 127 - 15) << 23) | (mantissa << 13);
|
|
}
|
|
|
|
float value;
|
|
memcpy(&value, &out, sizeof(value));
|
|
return value;
|
|
}
|
|
|
|
/* Dequantizes one 210-byte block into 256 floats. */
|
|
static void q6k_dequantize_block(const uint8_t *block, float *out)
|
|
{
|
|
const uint8_t *ql = block + 0;
|
|
const uint8_t *qh = block + 128;
|
|
const int8_t *scales = (const int8_t *)(block + 192);
|
|
uint16_t d_bits;
|
|
memcpy(&d_bits, block + 208, 2);
|
|
const float d = f16_to_f32(d_bits);
|
|
|
|
for (unsigned i = 0; i < Q6K_WEIGHTS_PER_BLOCK; i++) {
|
|
const unsigned half = i / 128u;
|
|
const unsigned rest = i % 128u;
|
|
const unsigned quarter = rest / 32u;
|
|
const unsigned lane = rest % 32u;
|
|
|
|
const uint8_t packed = ql[64u * half + 32u * (quarter & 1u) + lane];
|
|
const uint8_t nibble = (quarter < 2u) ? (uint8_t)(packed & 0xFu)
|
|
: (uint8_t)(packed >> 4);
|
|
const unsigned high = (qh[32u * half + lane] >> (2u * quarter)) & 3u;
|
|
const int quant = (int)(nibble | (uint8_t)(high << 4)) - 32;
|
|
const int scale = scales[8u * half + 2u * quarter + lane / 16u];
|
|
|
|
out[i] = d * (float)scale * (float)quant;
|
|
}
|
|
}
|
|
|
|
/* out[r] = sum_c dequant(w)[r][c] * x[c], accumulated in double so the oracle
|
|
* itself is not the thing under suspicion. Also reports the per-row sum of
|
|
* absolute terms, which sets an honest float tolerance for the GPU compare. */
|
|
static void q6k_reference_matvec(const uint8_t *w, int rows, int cols,
|
|
size_t row_stride_bytes, const float *x,
|
|
float *out, float *abs_sum_out)
|
|
{
|
|
const int blocks_per_row = cols / (int)Q6K_WEIGHTS_PER_BLOCK;
|
|
float block[Q6K_WEIGHTS_PER_BLOCK];
|
|
|
|
for (int r = 0; r < rows; r++) {
|
|
double dot = 0.0;
|
|
double abs_sum = 0.0;
|
|
for (int b = 0; b < blocks_per_row; b++) {
|
|
q6k_dequantize_block(w + (size_t)r * row_stride_bytes + (size_t)b * Q6K_BLOCK_BYTES,
|
|
block);
|
|
for (unsigned i = 0; i < Q6K_WEIGHTS_PER_BLOCK; i++) {
|
|
const double term = (double)block[i] * (double)x[b * 256 + (int)i];
|
|
dot += term;
|
|
abs_sum += fabs(term);
|
|
}
|
|
}
|
|
out[r] = (float)dot;
|
|
if (abs_sum_out) abs_sum_out[r] = (float)abs_sum;
|
|
}
|
|
}
|
|
|
|
/* Float tolerance justified by the reordering the GPU does: the kernel splits
|
|
* each row across 32 lanes and reduces with simd_sum, so the summation order
|
|
* differs from the reference's. Drift scales with the row's L1 mass, not with
|
|
* its (possibly cancelling) result.
|
|
*
|
|
* The constant is the MEASURED noise floor with headroom, not a number picked
|
|
* to make the test green: the real-tensor tests print the worst drift they
|
|
* observe (~3e-8 x row L1 on M4 / macOS 26), so this sits ~30x above it and
|
|
* still rejects a 0.01% error in a row sum. */
|
|
#define Q6K_DRIFT_TOLERANCE 1e-6f
|
|
|
|
static float tolerance_for(float abs_sum)
|
|
{
|
|
return Q6K_DRIFT_TOLERANCE * abs_sum + 1e-7f;
|
|
}
|
|
|
|
/* ================================================================
|
|
* Harness helpers
|
|
* ================================================================ */
|
|
|
|
static void fill_probe_vector(float *x, int cols, unsigned seed)
|
|
{
|
|
uint32_t rng = 0x9E3779B9u + seed * 0x85EBCA6Bu;
|
|
for (int i = 0; i < cols; i++) {
|
|
rng ^= rng << 13; rng ^= rng >> 17; rng ^= rng << 5;
|
|
x[i] = (float)((int32_t)(rng & 0xFFFFu) - 32768) / 32768.0f;
|
|
}
|
|
}
|
|
|
|
/* Runs the kernel and copies `rows` floats out of the result binding. */
|
|
static void run_q6k(fm_bufref w, fm_bufref x, fm_bufref out,
|
|
int rows, int cols, int row_stride_bytes, float *host_out)
|
|
{
|
|
char *out_host = (char *)fm_buffer_data(out.buffer) + out.offset;
|
|
memset(out_host, 0, (size_t)rows * sizeof(float));
|
|
|
|
fm_matvec_q6k(w, x, out, rows, cols, row_stride_bytes);
|
|
fm_sync();
|
|
memcpy(host_out, out_host, (size_t)rows * sizeof(float));
|
|
}
|
|
|
|
/* Pulls `rows` consecutive quantized rows of a real Q6_K tensor out of the
|
|
* model file. Reports the tensor's column count. Returns 0 on success. */
|
|
static int load_real_q6k_rows(const char *tensor_name, int rows,
|
|
int *cols_out, uint8_t *dst, size_t dst_capacity)
|
|
{
|
|
gguf_tensor_location location;
|
|
if (gguf_test_find_tensor(&g_model, tensor_name, &location) != 0)
|
|
return -1;
|
|
if (location.ggml_type != GGUF_GGML_TYPE_Q6_K || location.n_dims != 2)
|
|
return -1;
|
|
|
|
const int cols = (int)location.dims[0];
|
|
if (cols % (int)Q6K_WEIGHTS_PER_BLOCK != 0 || (uint64_t)rows > location.dims[1])
|
|
return -1;
|
|
|
|
const size_t row_stride = (size_t)(cols / 256) * Q6K_BLOCK_BYTES;
|
|
const size_t bytes = (size_t)rows * row_stride;
|
|
if (bytes > dst_capacity)
|
|
return -1;
|
|
if (gguf_test_read_at(&g_model, location.file_offset, dst, bytes) != 0)
|
|
return -1;
|
|
|
|
*cols_out = cols;
|
|
return 0;
|
|
}
|
|
|
|
/* Body shared by the two real-tensor tests: extract rows, run, compare
|
|
* absolutely against the CPU oracle. Returns 0 if the tensor loaded. */
|
|
static int check_real_tensor_against_reference(const char *tensor_name, int rows,
|
|
unsigned probe_seed)
|
|
{
|
|
static uint8_t weight_bytes[64 * MAX_ROW_STRIDE];
|
|
int cols = 0;
|
|
if (load_real_q6k_rows(tensor_name, rows, &cols, weight_bytes, sizeof(weight_bytes)) != 0)
|
|
return -1;
|
|
|
|
const int row_stride = (cols / 256) * (int)Q6K_BLOCK_BYTES;
|
|
|
|
fm_buffer *w = fm_buffer_alloc((size_t)rows * (size_t)row_stride);
|
|
fm_buffer *x = fm_buffer_alloc((size_t)cols * sizeof(float));
|
|
fm_buffer *out = fm_buffer_alloc((size_t)rows * sizeof(float));
|
|
if (!w || !x || !out) return -1;
|
|
|
|
memcpy(fm_buffer_data(w), weight_bytes, (size_t)rows * (size_t)row_stride);
|
|
float *x_host = (float *)fm_buffer_data(x);
|
|
fill_probe_vector(x_host, cols, probe_seed);
|
|
|
|
float *expected = (float *)malloc((size_t)rows * sizeof(float));
|
|
float *abs_sum = (float *)malloc((size_t)rows * sizeof(float));
|
|
float *actual = (float *)malloc((size_t)rows * sizeof(float));
|
|
q6k_reference_matvec(weight_bytes, rows, cols, (size_t)row_stride, x_host,
|
|
expected, abs_sum);
|
|
run_q6k(fm_bufref_at(w, 0), fm_bufref_at(x, 0), fm_bufref_at(out, 0),
|
|
rows, cols, row_stride, actual);
|
|
|
|
int failures = 0;
|
|
float worst_drift = 0.0f;
|
|
for (int r = 0; r < rows; r++) {
|
|
const float diff = fabsf(expected[r] - actual[r]);
|
|
if (diff > tolerance_for(abs_sum[r])) {
|
|
if (failures < 4)
|
|
printf(" row %d: expected %.8f got %.8f (diff %.3g tol %.3g)\n",
|
|
r, expected[r], actual[r], diff, tolerance_for(abs_sum[r]));
|
|
failures++;
|
|
}
|
|
const float drift = abs_sum[r] > 0.0f ? diff / abs_sum[r] : 0.0f;
|
|
if (drift > worst_drift) worst_drift = drift;
|
|
}
|
|
/* The measured noise floor, so the tolerance above stays honest rather
|
|
* than being whatever number made the test go green. */
|
|
printf(" %-28s worst drift = %.3g x row L1 (tolerance %.3g)\n",
|
|
tensor_name, (double)worst_drift, (double)Q6K_DRIFT_TOLERANCE);
|
|
|
|
/* A kernel that wrote nothing would "agree" with an all-zero reference. */
|
|
float magnitude = 0.0f;
|
|
for (int r = 0; r < rows; r++) magnitude += fabsf(expected[r]);
|
|
|
|
free(expected); free(abs_sum); free(actual);
|
|
fm_buffer_free(w); fm_buffer_free(x); fm_buffer_free(out);
|
|
|
|
if (magnitude <= 1e-3f) return -2;
|
|
return failures == 0 ? 0 : 1;
|
|
}
|
|
|
|
/* ================================================================
|
|
* Absolute correctness against real model blocks
|
|
* ================================================================ */
|
|
|
|
/* The LM head. If this kernel is wrong, every emitted token is wrong. */
|
|
static void test_lm_head_matches_cpu_reference(void)
|
|
{
|
|
ASSERT_TRUE(g_model_is_open);
|
|
ASSERT_INT_EQ(0, check_real_tensor_against_reference(kTensorLmHead, 64, 1));
|
|
}
|
|
|
|
static void test_real_attn_q_matches_cpu_reference(void)
|
|
{
|
|
ASSERT_TRUE(g_model_is_open);
|
|
ASSERT_INT_EQ(0, check_real_tensor_against_reference(kTensorAttnQ, 48, 2));
|
|
}
|
|
|
|
/* One-hot x isolates a single weight: out[r] must be exactly dequant(row r)[i].
|
|
* This is the localizing test — it catches a wrong nibble, dropped high bits,
|
|
* a wrong signed-scale index, a missing -32 bias and any permutation of the
|
|
* 256 positions, and names the offending element instead of just the row. */
|
|
static void test_one_hot_probe_recovers_every_weight(void)
|
|
{
|
|
ASSERT_TRUE(g_model_is_open);
|
|
|
|
const int rows = 4, cols = 256;
|
|
static uint8_t weight_bytes[4 * Q6K_BLOCK_BYTES];
|
|
static uint8_t full_rows[4 * MAX_ROW_STRIDE];
|
|
int tensor_cols = 0;
|
|
ASSERT_INT_EQ(0, load_real_q6k_rows(kTensorLmHead, rows, &tensor_cols,
|
|
full_rows, sizeof(full_rows)));
|
|
const size_t tensor_stride = (size_t)(tensor_cols / 256) * Q6K_BLOCK_BYTES;
|
|
for (int r = 0; r < rows; r++)
|
|
memcpy(weight_bytes + (size_t)r * Q6K_BLOCK_BYTES,
|
|
full_rows + (size_t)r * tensor_stride, Q6K_BLOCK_BYTES);
|
|
|
|
float expected_block[4][Q6K_WEIGHTS_PER_BLOCK];
|
|
for (int r = 0; r < rows; r++)
|
|
q6k_dequantize_block(weight_bytes + (size_t)r * Q6K_BLOCK_BYTES, expected_block[r]);
|
|
|
|
fm_buffer *w = fm_buffer_alloc(sizeof(weight_bytes));
|
|
fm_buffer *x = fm_buffer_alloc((size_t)cols * sizeof(float));
|
|
fm_buffer *out = fm_buffer_alloc((size_t)rows * sizeof(float));
|
|
ASSERT_NOT_NULL(w); ASSERT_NOT_NULL(x); ASSERT_NOT_NULL(out);
|
|
memcpy(fm_buffer_data(w), weight_bytes, sizeof(weight_bytes));
|
|
float *x_host = (float *)fm_buffer_data(x);
|
|
|
|
int mismatches = 0;
|
|
float nonzero_mass = 0.0f;
|
|
for (int i = 0; i < cols; i++) {
|
|
memset(x_host, 0, (size_t)cols * sizeof(float));
|
|
x_host[i] = 1.0f;
|
|
|
|
float actual[4];
|
|
run_q6k(fm_bufref_at(w, 0), fm_bufref_at(x, 0), fm_bufref_at(out, 0),
|
|
rows, cols, (int)Q6K_BLOCK_BYTES, actual);
|
|
|
|
for (int r = 0; r < rows; r++) {
|
|
const float want = expected_block[r][i];
|
|
nonzero_mass += fabsf(want);
|
|
if (fabsf(want - actual[r]) > 1e-6f * (1.0f + fabsf(want))) {
|
|
if (mismatches < 8)
|
|
printf(" weight[row %d][%d]: expected %.8f got %.8f\n",
|
|
r, i, want, actual[r]);
|
|
mismatches++;
|
|
}
|
|
}
|
|
}
|
|
|
|
fm_buffer_free(w); fm_buffer_free(x); fm_buffer_free(out);
|
|
|
|
ASSERT_TRUE(nonzero_mass > 1e-3f);
|
|
ASSERT_INT_EQ(0, mismatches);
|
|
}
|
|
|
|
/* row_stride_bytes is an independent parameter, not cols/256*210. Padding the
|
|
* stride and poisoning the gap makes an off-by-one in the block or row stride
|
|
* read the poison. The pad is even so the block's trailing half stays aligned. */
|
|
static void test_padded_row_stride_is_honored(void)
|
|
{
|
|
ASSERT_TRUE(g_model_is_open);
|
|
|
|
const int rows = 32;
|
|
static uint8_t packed[32 * MAX_ROW_STRIDE];
|
|
int cols = 0;
|
|
ASSERT_INT_EQ(0, load_real_q6k_rows(kTensorAttnQ, rows, &cols, packed, sizeof(packed)));
|
|
|
|
const size_t natural_stride = (size_t)(cols / 256) * Q6K_BLOCK_BYTES;
|
|
const size_t padded_stride = natural_stride + 64;
|
|
|
|
fm_buffer *w = fm_buffer_alloc((size_t)rows * padded_stride);
|
|
fm_buffer *x = fm_buffer_alloc((size_t)cols * sizeof(float));
|
|
fm_buffer *out = fm_buffer_alloc((size_t)rows * sizeof(float));
|
|
ASSERT_NOT_NULL(w); ASSERT_NOT_NULL(x); ASSERT_NOT_NULL(out);
|
|
|
|
uint8_t *w_host = (uint8_t *)fm_buffer_data(w);
|
|
memset(w_host, 0xA5, (size_t)rows * padded_stride); /* poison the gaps */
|
|
for (int r = 0; r < rows; r++)
|
|
memcpy(w_host + (size_t)r * padded_stride,
|
|
packed + (size_t)r * natural_stride, natural_stride);
|
|
|
|
float *x_host = (float *)fm_buffer_data(x);
|
|
fill_probe_vector(x_host, cols, 3);
|
|
|
|
float expected[32], abs_sum[32], actual[32];
|
|
q6k_reference_matvec(w_host, rows, cols, padded_stride, x_host, expected, abs_sum);
|
|
run_q6k(fm_bufref_at(w, 0), fm_bufref_at(x, 0), fm_bufref_at(out, 0),
|
|
rows, cols, (int)padded_stride, actual);
|
|
|
|
for (int r = 0; r < rows; r++)
|
|
ASSERT_FLT_NEAR(expected[r], actual[r], tolerance_for(abs_sum[r]));
|
|
|
|
fm_buffer_free(w); fm_buffer_free(x); fm_buffer_free(out);
|
|
}
|
|
|
|
/* ================================================================
|
|
* Ragged row tail — the hazard the rows-per-threadgroup split introduces
|
|
* ================================================================ */
|
|
|
|
/* Sentinels are finite on purpose: the shader library is built MTLMathModeFast
|
|
* and the host -ffast-math, so -INFINITY is undefined behaviour and isfinite()
|
|
* folds to constant-true. */
|
|
#define TAIL_GUARD_FLOATS 16
|
|
#define TAIL_GUARD_VALUE (-12345.0f)
|
|
|
|
/* Builds a [rows, cols] Q6_K weight out of REAL model bytes by keeping only the
|
|
* first cols/256 super-blocks of each source row, so every column count in the
|
|
* sweep is backed by genuine blocks rather than synthetic ones. */
|
|
static int build_narrowed_weight(const char *tensor_name, int rows, int cols,
|
|
uint8_t *dst, size_t dst_capacity)
|
|
{
|
|
static uint8_t source[40 * MAX_ROW_STRIDE];
|
|
int source_cols = 0;
|
|
if (load_real_q6k_rows(tensor_name, rows, &source_cols, source, sizeof(source)) != 0)
|
|
return -1;
|
|
if (cols > source_cols)
|
|
return -1;
|
|
|
|
const size_t source_stride = (size_t)(source_cols / 256) * Q6K_BLOCK_BYTES;
|
|
const size_t stride = (size_t)(cols / 256) * Q6K_BLOCK_BYTES;
|
|
if ((size_t)rows * stride > dst_capacity)
|
|
return -1;
|
|
|
|
for (int r = 0; r < rows; r++)
|
|
memcpy(dst + (size_t)r * stride, source + (size_t)r * source_stride, stride);
|
|
return 0;
|
|
}
|
|
|
|
/* Runs the kernel into an output buffer padded with sentinels past `rows` and
|
|
* compares every live row absolutely against the CPU oracle.
|
|
*
|
|
* @param tail_intact Set nonzero only if every sentinel past `rows` survived
|
|
* @return Rows outside tolerance, or -1 if a buffer could not be
|
|
* allocated
|
|
*/
|
|
static int ragged_tail_failures(const uint8_t *weight, int rows, int cols,
|
|
int row_stride, int *tail_intact)
|
|
{
|
|
fm_buffer *w = fm_buffer_alloc((size_t)rows * (size_t)row_stride);
|
|
fm_buffer *x = fm_buffer_alloc((size_t)cols * sizeof(float));
|
|
fm_buffer *out = fm_buffer_alloc((size_t)(rows + TAIL_GUARD_FLOATS) * sizeof(float));
|
|
float *expected = (float *)malloc((size_t)rows * sizeof(float));
|
|
float *abs_sum = (float *)malloc((size_t)rows * sizeof(float));
|
|
int failures = -1;
|
|
|
|
*tail_intact = 0;
|
|
if (!w || !x || !out || !expected || !abs_sum) goto cleanup;
|
|
|
|
memcpy(fm_buffer_data(w), weight, (size_t)rows * (size_t)row_stride);
|
|
float *x_host = (float *)fm_buffer_data(x);
|
|
fill_probe_vector(x_host, cols, (unsigned)(rows * 31 + cols));
|
|
q6k_reference_matvec(weight, rows, cols, (size_t)row_stride, x_host,
|
|
expected, abs_sum);
|
|
|
|
float *o = (float *)fm_buffer_data(out);
|
|
for (int i = 0; i < rows + TAIL_GUARD_FLOATS; i++)
|
|
o[i] = TAIL_GUARD_VALUE;
|
|
|
|
fm_matvec_q6k(fm_bufref_at(w, 0), fm_bufref_at(x, 0), fm_bufref_at(out, 0),
|
|
rows, cols, row_stride);
|
|
fm_sync();
|
|
|
|
o = (float *)fm_buffer_data(out);
|
|
*tail_intact = 1;
|
|
for (int i = rows; i < rows + TAIL_GUARD_FLOATS; i++)
|
|
if (o[i] != TAIL_GUARD_VALUE) *tail_intact = 0;
|
|
|
|
failures = 0;
|
|
for (int r = 0; r < rows; r++)
|
|
if (fabsf(expected[r] - o[r]) > tolerance_for(abs_sum[r])) failures++;
|
|
|
|
cleanup:
|
|
if (w) fm_buffer_free(w);
|
|
if (x) fm_buffer_free(x);
|
|
if (out) fm_buffer_free(out);
|
|
free(expected);
|
|
free(abs_sum);
|
|
return failures;
|
|
}
|
|
|
|
/* 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 a row owned the whole SIMD group.
|
|
*
|
|
* Swept over every column count the LM head can be narrowed to: cols=256 puts
|
|
* 32 rows in a group (1 lane each), 512 puts 16, 1024 puts 8 and 2048 puts 4,
|
|
* 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] = {
|
|
{ 256, 1 }, { 256, 17 }, { 256, 31 }, { 256, 33 },
|
|
{ 512, 1 }, { 512, 15 }, { 512, 17 }, { 512, 33 },
|
|
{ 1024, 1 }, { 1024, 7 }, { 1024, 9 },
|
|
{ 2048, 1 }, { 2048, 3 }, { 2048, 5 },
|
|
};
|
|
static uint8_t weight[40 * MAX_ROW_STRIDE];
|
|
|
|
ASSERT_TRUE(g_model_is_open);
|
|
for (size_t i = 0; i < sizeof(shape) / sizeof(shape[0]); i++) {
|
|
const int cols = shape[i][0];
|
|
const int rows = shape[i][1];
|
|
const int row_stride = (cols / 256) * (int)Q6K_BLOCK_BYTES;
|
|
int tail_intact = 0;
|
|
|
|
ASSERT_INT_EQ(0, build_narrowed_weight(kTensorLmHead, rows, cols,
|
|
weight, sizeof(weight)));
|
|
const int failures = ragged_tail_failures(weight, rows, cols, row_stride,
|
|
&tail_intact);
|
|
if (failures != 0 || !tail_intact)
|
|
printf(" cols=%d rows=%d: %d bad rows, tail_intact=%d\n",
|
|
cols, rows, failures, tail_intact);
|
|
ASSERT_TRUE(tail_intact);
|
|
ASSERT_INT_EQ(0, failures);
|
|
}
|
|
}
|
|
|
|
/* ================================================================
|
|
* The same absolute check, with the weight inside an fm_region at a
|
|
* 32-aligned, non-page-aligned offset. An ADDITION to the tests above.
|
|
* ================================================================ */
|
|
|
|
static void test_region_offset_matches_cpu_reference(void)
|
|
{
|
|
ASSERT_TRUE(g_model_is_open);
|
|
|
|
const int rows = 32;
|
|
static uint8_t packed[32 * MAX_ROW_STRIDE];
|
|
int cols = 0;
|
|
ASSERT_INT_EQ(0, load_real_q6k_rows(kTensorLmHead, rows, &cols, packed, sizeof(packed)));
|
|
const size_t row_stride = (size_t)(cols / 256) * Q6K_BLOCK_BYTES;
|
|
|
|
const size_t mapped_bytes = 8u * 1024 * 1024;
|
|
int fd = open(kScratchPath, O_RDWR | O_CREAT | O_TRUNC, 0644);
|
|
ASSERT_TRUE(fd >= 0);
|
|
ASSERT_INT_EQ(0, ftruncate(fd, (off_t)mapped_bytes));
|
|
void *base = mmap(NULL, mapped_bytes, PROT_READ | PROT_WRITE, MAP_PRIVATE, fd, 0);
|
|
ASSERT_TRUE(base != MAP_FAILED);
|
|
|
|
/* Decoy at 0: if the binding ignored its offset the kernel would read
|
|
* these bytes and disagree with the oracle for the real weight. */
|
|
memset(base, 0x3C, mapped_bytes);
|
|
memcpy((uint8_t *)base + REGION_WEIGHT_OFFSET, packed, (size_t)rows * row_stride);
|
|
ASSERT_TRUE(REGION_WEIGHT_OFFSET % 32 == 0);
|
|
ASSERT_TRUE(REGION_WEIGHT_OFFSET % 16384 != 0);
|
|
|
|
fm_region *region = fm_region_open(base, mapped_bytes, 0);
|
|
ASSERT_NOT_NULL(region);
|
|
fm_bufref w;
|
|
ASSERT_INT_EQ(0, fm_region_bind(region, REGION_WEIGHT_OFFSET,
|
|
(size_t)rows * row_stride, &w));
|
|
|
|
fm_buffer *x = fm_buffer_alloc((size_t)cols * sizeof(float));
|
|
fm_buffer *out = fm_buffer_alloc((size_t)rows * sizeof(float));
|
|
ASSERT_NOT_NULL(x); ASSERT_NOT_NULL(out);
|
|
float *x_host = (float *)fm_buffer_data(x);
|
|
fill_probe_vector(x_host, cols, 4);
|
|
|
|
float expected[32], abs_sum[32], actual[32];
|
|
q6k_reference_matvec(packed, rows, cols, row_stride, x_host, expected, abs_sum);
|
|
run_q6k(w, fm_bufref_at(x, 0), fm_bufref_at(out, 0),
|
|
rows, cols, (int)row_stride, actual);
|
|
|
|
for (int r = 0; r < rows; r++)
|
|
ASSERT_FLT_NEAR(expected[r], actual[r], tolerance_for(abs_sum[r]));
|
|
|
|
fm_buffer_free(x); fm_buffer_free(out);
|
|
fm_region_close(region);
|
|
munmap(base, mapped_bytes);
|
|
close(fd);
|
|
unlink(kScratchPath);
|
|
}
|
|
|
|
/* ================================================================
|
|
* main
|
|
* ================================================================ */
|
|
|
|
int main(void)
|
|
{
|
|
printf("\n Metal Q6_K Matvec — Absolute Correctness (includes the LM head)\n");
|
|
printf(" ==============================================================\n\n");
|
|
|
|
if (fm_init() != 0 || !fm_available()) {
|
|
fprintf(stderr, "Metal unavailable\n");
|
|
return 77;
|
|
}
|
|
|
|
const char *model_path = getenv("FOUNDRY_TEST_GGUF");
|
|
if (!model_path) model_path = kDefaultModelPath;
|
|
g_model_is_open = gguf_test_open(model_path, &g_model) == 0;
|
|
|
|
if (!g_model_is_open) {
|
|
printf(" %sMODEL FILE MISSING — every test below is driven by real\n"
|
|
" Q6_K blocks and cannot run without it:%s\n %s\n\n",
|
|
FT_RED, FT_RESET, model_path);
|
|
fm_free();
|
|
return 1;
|
|
}
|
|
printf(" model = %s\n", model_path);
|
|
printf(" data starts = %llu\n\n", (unsigned long long)g_model.tensor_data_start);
|
|
|
|
RUN_TEST(lm_head_matches_cpu_reference);
|
|
RUN_TEST(real_attn_q_matches_cpu_reference);
|
|
RUN_TEST(one_hot_probe_recovers_every_weight);
|
|
RUN_TEST(padded_row_stride_is_honored);
|
|
RUN_TEST(ragged_row_tail_is_exact_and_writes_nothing_past_rows);
|
|
RUN_TEST(region_offset_matches_cpu_reference);
|
|
|
|
gguf_test_close(&g_model);
|
|
fm_free();
|
|
|
|
TEST_REPORT();
|
|
return __ft_failed > 0 ? 1 : 0;
|
|
}
|