Foundry/metal/tests/test_metal_rope.c

235 lines
8.6 KiB
C

/* SPDX-License-Identifier: AGPL-3.0-or-later */
/* Copyright (C) 2026 David (Corey) Herrera */
/*
* test_metal_rope.c — partial NEOX RoPE kernels (fm_rope_neox_partial,
* fm_rope_neox_partial_pos)
*
* The oracle is the CPU-reference golden set in metal/tests/golden (see
* fm_golden_vectors.h for provenance). Every assertion here is against
* those absolute values or against the kernel's own input — never against
* another GPU result, so a kernel cannot pass by being consistently wrong.
*
* Two properties get their own tests because a golden diff of the rotated
* span alone would not notice them:
* - dims [rope_dim, head_dim) must pass through BIT-identical,
* - the NEOX pair is j <-> j + rope_dim/2, not j <-> j + 1.
*
* Helper functions are named case_* so lib/test.h's failure line, which
* prints __func__ + 5, still reads as the case that failed.
*
* Exits 77 when no Metal device is present so GPU-less CI skips cleanly.
*/
#include "lib/test.h"
#include "metal/fm_metal.h"
#include "metal/tests/fm_golden_vectors.h"
#include <math.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
/* qwen35moe attention shape the golden set was generated for. */
#define ROPE_HEAD_DIM 256
#define ROPE_DIM 64
#define ROPE_Q_HEADS 16
#define ROPE_K_HEADS 2
#define ROPE_THETA 1e7f
/* Deliberately far tighter than the 1e-4..1e-3 relative band the golden set
* documents for Kepler: measured on M4 / macOS 26, precise:: trig against
* the host libm leaves sub-ULP differences, so anything the tolerance would
* have to be loosened for is a real divergence, not GPU trig noise. */
#define ROPE_EPS_ABS 1e-5f
#define ROPE_EPS_REL 1e-5f
static const int rope_positions[] = { 0, 1, 7, 100 };
#define ROPE_N_POSITIONS ((int)(sizeof(rope_positions) / sizeof(rope_positions[0])))
/* Uploads `n` floats into a fresh device buffer. */
static fm_buffer *upload_floats(const float *src, size_t n) {
fm_buffer *buf = fm_buffer_alloc(n * sizeof(float));
if (buf) memcpy(fm_buffer_data(buf), src, n * sizeof(float));
return buf;
}
/* Diffs against a golden vector. The golden file name is printed just
* before the assertion fires, because lib/test.h names the failing function
* and several cases share this one — without it a failure would not say
* which position diverged. */
static void case_compare(const char *golden_file, const float *golden,
const float *got, size_t n) {
for (size_t i = 0; i < n; i++) {
float tol = ROPE_EPS_ABS + ROPE_EPS_REL * fabsf(golden[i]);
if (fabsf(golden[i] - got[i]) > tol)
printf(" %s: element %zu of %zu (head %zu, dim %zu)\n",
golden_file, i, n, i / ROPE_HEAD_DIM, i % ROPE_HEAD_DIM);
ASSERT_FLT_NEAR(golden[i], got[i], tol);
}
}
/* Rotates one head layout at `pos` and diffs every element against the
* matching golden file. */
static void case_rope_position(const char *input_file, const char *golden_file,
int n_heads, int pos) {
size_t count = (size_t)n_heads * ROPE_HEAD_DIM;
float *input = fm_golden_load_f32(input_file, count);
float *golden = fm_golden_load_f32(golden_file, count);
ASSERT_NOT_NULL(input);
ASSERT_NOT_NULL(golden);
fm_buffer *data = upload_floats(input, count);
ASSERT_NOT_NULL(data);
fm_rope_neox_partial(data, n_heads, ROPE_HEAD_DIM, ROPE_DIM, pos, ROPE_THETA);
fm_sync();
case_compare(golden_file, golden, (const float *)fm_buffer_data(data),
count);
fm_buffer_free(data);
free(input);
free(golden);
}
static void test_rope_q_matches_golden(void) {
char golden_file[64];
for (int p = 0; p < ROPE_N_POSITIONS; p++) {
snprintf(golden_file, sizeof(golden_file), "rope_q_pos%d.bin",
rope_positions[p]);
case_rope_position("rope_q_input.bin", golden_file, ROPE_Q_HEADS,
rope_positions[p]);
if (__ft_current_failed) return;
}
}
static void test_rope_k_matches_golden(void) {
char golden_file[64];
for (int p = 0; p < ROPE_N_POSITIONS; p++) {
snprintf(golden_file, sizeof(golden_file), "rope_k_pos%d.bin",
rope_positions[p]);
case_rope_position("rope_k_input.bin", golden_file, ROPE_K_HEADS,
rope_positions[p]);
if (__ft_current_failed) return;
}
}
/* THE PARTIAL-ROTATION TRAP. A kernel that rotates all 256 dims instead of
* the first 64 still matches the golden on [0,64) — the rotation there is
* identical — and only differs in the tail. Compare the tail bit-for-bit
* against the input, not approximately: the kernel is supposed to leave
* those elements completely untouched. */
static void test_rope_pass_through_region_is_bit_identical(void) {
size_t count = (size_t)ROPE_Q_HEADS * ROPE_HEAD_DIM;
float *input = fm_golden_load_f32("rope_q_input.bin", count);
ASSERT_NOT_NULL(input);
fm_buffer *data = upload_floats(input, count);
ASSERT_NOT_NULL(data);
fm_rope_neox_partial(data, ROPE_Q_HEADS, ROPE_HEAD_DIM, ROPE_DIM, 7,
ROPE_THETA);
fm_sync();
const float *got = (const float *)fm_buffer_data(data);
for (int h = 0; h < ROPE_Q_HEADS; h++) {
size_t base = (size_t)h * ROPE_HEAD_DIM;
int differing = memcmp(got + base + ROPE_DIM, input + base + ROPE_DIM,
(ROPE_HEAD_DIM - ROPE_DIM) * sizeof(float));
ASSERT_INT_EQ(0, differing);
}
/* And the rotated span really was rotated, so the check above is not
* passing simply because the kernel did nothing. */
ASSERT_TRUE(memcmp(got, input, ROPE_DIM * sizeof(float)) != 0);
fm_buffer_free(data);
free(input);
}
/* THE NEOX PAIRING TRAP. One head, a single 1.0 at dim 0, everything else
* zero. Correct NEOX pairing sends cos(angle) to dim 0 and sin(angle) to
* dim 0 + 32; the adjacent-pair convention would send it to dim 1. */
static void test_rope_neox_pairs_j_with_j_plus_half(void) {
const int pos = 3;
float head[ROPE_HEAD_DIM];
memset(head, 0, sizeof(head));
head[0] = 1.0f;
fm_buffer *data = upload_floats(head, ROPE_HEAD_DIM);
ASSERT_NOT_NULL(data);
fm_rope_neox_partial(data, 1, ROPE_HEAD_DIM, ROPE_DIM, pos, ROPE_THETA);
fm_sync();
/* j = 0 => freq = theta^0 = 1, so the angle is just the position. */
const float *got = (const float *)fm_buffer_data(data);
ASSERT_FLT_NEAR(cosf((float)pos), got[0], 1e-5f);
ASSERT_FLT_NEAR(sinf((float)pos), got[ROPE_DIM / 2], 1e-5f);
ASSERT_FLT_NEAR(0.0f, got[1], 1e-6f);
fm_buffer_free(data);
}
/* The batched form against the same absolute goldens: four rows of the
* golden Q input, one per position, checked row by row. */
static void test_rope_pos_variant_matches_golden(void) {
size_t row = (size_t)ROPE_Q_HEADS * ROPE_HEAD_DIM;
float *input = fm_golden_load_f32("rope_q_input.bin", row);
ASSERT_NOT_NULL(input);
fm_buffer *data = fm_buffer_alloc(ROPE_N_POSITIONS * row * sizeof(float));
fm_buffer *pos_buf = fm_buffer_alloc(ROPE_N_POSITIONS * sizeof(int32_t));
ASSERT_NOT_NULL(data);
ASSERT_NOT_NULL(pos_buf);
float *rows = (float *)fm_buffer_data(data);
int32_t *positions = (int32_t *)fm_buffer_data(pos_buf);
for (int p = 0; p < ROPE_N_POSITIONS; p++) {
memcpy(rows + (size_t)p * row, input, row * sizeof(float));
positions[p] = rope_positions[p];
}
fm_rope_neox_partial_pos(data, pos_buf, ROPE_N_POSITIONS, ROPE_Q_HEADS,
ROPE_HEAD_DIM, ROPE_DIM, ROPE_THETA);
fm_sync();
char golden_file[64];
for (int p = 0; p < ROPE_N_POSITIONS; p++) {
snprintf(golden_file, sizeof(golden_file), "rope_q_pos%d.bin",
rope_positions[p]);
float *golden = fm_golden_load_f32(golden_file, row);
ASSERT_NOT_NULL(golden);
case_compare(golden_file, golden, rows + (size_t)p * row, row);
free(golden);
if (__ft_current_failed) return;
}
fm_buffer_free(data);
fm_buffer_free(pos_buf);
free(input);
}
int main(int argc, char **argv) {
(void)argc;
fm_golden_locate(argv[0]);
printf("\n Metal RoPE Kernel Tests\n");
printf(" =======================\n\n");
if (fm_init() != 0 || !fm_available()) {
fprintf(stderr, " Metal unavailable — skipping.\n\n");
return 77;
}
RUN_TEST(rope_q_matches_golden);
RUN_TEST(rope_k_matches_golden);
RUN_TEST(rope_pass_through_region_is_bit_identical);
RUN_TEST(rope_neox_pairs_j_with_j_plus_half);
RUN_TEST(rope_pos_variant_matches_golden);
fm_free();
TEST_REPORT();
return __ft_failed > 0 ? 1 : 0;
}