764 lines
28 KiB
C
764 lines
28 KiB
C
/* SPDX-License-Identifier: AGPL-3.0-or-later */
|
|
/* Copyright (C) 2026 David (Corey) Herrera */
|
|
/**
|
|
* @file test_metal_moe.c
|
|
* @brief Absolute-correctness tests for the Metal MoE router, the
|
|
* sort-by-expert prepass, the fused silu*mul and the shared-expert gate.
|
|
*
|
|
* The bar is ABSOLUTE, never GPU-against-GPU. Every assertion compares the
|
|
* kernel either to a Pantheon golden vector (produced by a third
|
|
* implementation from a fixed seed) or to the CPU reference written below,
|
|
* which is a term-for-term transcription of `qwen35moe_moe.c`'s router and a
|
|
* from-scratch CSR inversion of the routing. A GPU-against-GPU arm would
|
|
* stay green through a uniform scale, which is exactly the break Phase 1
|
|
* proved slips past self-comparison.
|
|
*
|
|
* The tie-break gets its own fixture. A test whose logits happen to be
|
|
* distinct does not test a tie-break at all, so `router_tie_break_*` makes
|
|
* sixteen experts share the top probability bit-for-bit and requires the
|
|
* eight LOWEST of them, in ascending order.
|
|
*
|
|
* The sort prepass is exercised at 512 tokens, not at 1: its entire value is
|
|
* at prefill, and a single-token run cannot expose a scan or a per-expert
|
|
* offset that is off by one.
|
|
*
|
|
* @status All tests pass on M4 / macOS 26. Each was proven able to fail
|
|
* against a deliberately broken kernel before being trusted — a 0.01% scaled
|
|
* weight, a tie broken toward the higher index, an off-by-one per-expert
|
|
* count and a top-k returning the lowest probabilities instead of the
|
|
* highest were all induced and all caught.
|
|
* @issues None
|
|
* @todo None
|
|
*/
|
|
|
|
#include "lib/test.h"
|
|
/* The family header directly, not the fm_metal.h umbrella: the umbrella lists
|
|
* one include per family and is owned by the integration step, so a test that
|
|
* went through it could not build until that line lands. */
|
|
#include "metal/kernels/fm_kernel_moe.h"
|
|
#include "metal/tests/fm_golden_vectors.h"
|
|
|
|
#include <math.h>
|
|
#include <stdint.h>
|
|
#include <stdio.h>
|
|
#include <stdlib.h>
|
|
#include <string.h>
|
|
|
|
/* ================================================================
|
|
* Fixtures
|
|
* ================================================================ */
|
|
|
|
#define MOE_N_EXPERT 256
|
|
#define MOE_EXPERT_USED 8
|
|
#define MOE_N_EMBD 2048
|
|
|
|
/* Tokens for the sort prepass. The prepass exists for prefill, so it is
|
|
* measured at a prefill batch rather than at the decode batch of 1. */
|
|
#define MOE_SORT_TOKENS 512
|
|
|
|
/* Experts sharing the top probability in the tie fixture. Deliberately more
|
|
* than expert_used, so which ones are picked is decided by the tie-break. */
|
|
#define MOE_TIED_EXPERTS 16
|
|
|
|
static const int kTiedExperts[MOE_TIED_EXPERTS] = {
|
|
5, 9, 17, 33, 41, 60, 77, 88, 101, 130, 150, 171, 190, 201, 222, 250
|
|
};
|
|
|
|
/* Deterministic PRNG so a failure is always reproducible. */
|
|
static uint32_t moe_rand_state = 0x12345678u;
|
|
|
|
static uint32_t moe_rand_u32(void)
|
|
{
|
|
moe_rand_state ^= moe_rand_state << 13;
|
|
moe_rand_state ^= moe_rand_state >> 17;
|
|
moe_rand_state ^= moe_rand_state << 5;
|
|
return moe_rand_state;
|
|
}
|
|
|
|
static float moe_rand_unit(void)
|
|
{
|
|
return (float)(moe_rand_u32() >> 8) / (float)(1u << 24);
|
|
}
|
|
|
|
/**
|
|
* Reads a PNGOLDEN1 i32 vector. fm_golden_vectors.h reads f32 only, and the
|
|
* router's selected indices — the one thing that must match exactly — are i32.
|
|
*/
|
|
static int *moe_golden_load_i32(const char *name, size_t expect_count)
|
|
{
|
|
char path[1152];
|
|
char header[256];
|
|
char magic[16] = {0}, vec_name[64] = {0}, dtype[8] = {0};
|
|
unsigned long count = 0;
|
|
int *values = NULL;
|
|
FILE *f = NULL;
|
|
|
|
snprintf(path, sizeof(path), "%s/%s", fm_golden_dir, name);
|
|
f = fopen(path, "rb");
|
|
if (!f) {
|
|
fprintf(stderr, " golden: cannot open %s\n", path);
|
|
return NULL;
|
|
}
|
|
if (!fgets(header, sizeof(header), f)) {
|
|
fprintf(stderr, " golden: %s has no header line\n", path);
|
|
goto cleanup;
|
|
}
|
|
if (sscanf(header, "%15s %63s %7s %lu", magic, vec_name, dtype, &count) != 4 ||
|
|
strcmp(magic, "PNGOLDEN1") != 0 || strcmp(dtype, "i32") != 0 ||
|
|
count != expect_count) {
|
|
fprintf(stderr, " golden: %s has a bad header: %s", path, header);
|
|
goto cleanup;
|
|
}
|
|
values = (int *)malloc(expect_count * sizeof(int));
|
|
if (values && fread(values, sizeof(int), expect_count, f) != expect_count) {
|
|
fprintf(stderr, " golden: %s payload is truncated\n", path);
|
|
free(values);
|
|
values = NULL;
|
|
}
|
|
|
|
cleanup:
|
|
fclose(f);
|
|
return values;
|
|
}
|
|
|
|
/* ================================================================
|
|
* CPU reference — transcribed from Pantheon's qwen35moe_moe.c
|
|
* ================================================================ */
|
|
|
|
/**
|
|
* Softmax over the router logits, top-k by probability, norm_topk_prob and the
|
|
* routed-weight scale. Mirrors qwen35moe_router_softmax + qwen35moe_select_topk
|
|
* + the renormalization block: selection scans experts in ascending order with
|
|
* strict `>`, so a tie resolves to the lower index.
|
|
*/
|
|
static void cpu_router_topk(const float *logits, int n_expert, int k, float scale,
|
|
int *top_idx, float *top_wt)
|
|
{
|
|
float *probs = (float *)malloc((size_t)n_expert * sizeof(float));
|
|
float max_logit = logits[0];
|
|
for (int e = 1; e < n_expert; e++)
|
|
if (logits[e] > max_logit)
|
|
max_logit = logits[e];
|
|
|
|
float sum_exp = 0.0f;
|
|
for (int e = 0; e < n_expert; e++) {
|
|
probs[e] = expf(logits[e] - max_logit);
|
|
sum_exp += probs[e];
|
|
}
|
|
float inv = 1.0f / sum_exp;
|
|
for (int e = 0; e < n_expert; e++)
|
|
probs[e] *= inv;
|
|
|
|
float wt_sum = 0.0f;
|
|
for (int s = 0; s < k; s++) {
|
|
int best = -1;
|
|
float best_val = -1.0f;
|
|
for (int e = 0; e < n_expert; e++) {
|
|
int skip = 0;
|
|
for (int j = 0; j < s; j++)
|
|
if (top_idx[j] == e) {
|
|
skip = 1;
|
|
break;
|
|
}
|
|
if (!skip && probs[e] > best_val) {
|
|
best = e;
|
|
best_val = probs[e];
|
|
}
|
|
}
|
|
top_idx[s] = best;
|
|
top_wt[s] = best_val;
|
|
wt_sum += best_val;
|
|
}
|
|
|
|
float inv_wt = (wt_sum > 0.0f) ? (1.0f / wt_sum) : 0.0f;
|
|
for (int s = 0; s < k; s++)
|
|
top_wt[s] *= scale * inv_wt;
|
|
free(probs);
|
|
}
|
|
|
|
/**
|
|
* Inverts a routing table into the CSR form the prepass must produce: per
|
|
* expert, the tokens that selected it in ascending token order, plus the slot
|
|
* each used. Written from the definition, not from the kernel's tiled scan.
|
|
*/
|
|
static void cpu_expert_sort(const int *top_idx, int n_tokens, int n_expert, int k,
|
|
int *counts, int *offsets, int *sorted_token,
|
|
int *sorted_slot, int *sorted_expert)
|
|
{
|
|
for (int e = 0; e < n_expert; e++)
|
|
counts[e] = 0;
|
|
for (int t = 0; t < n_tokens; t++)
|
|
for (int s = 0; s < k; s++) {
|
|
int e = top_idx[t * k + s];
|
|
if (e >= 0 && e < n_expert)
|
|
counts[e]++;
|
|
}
|
|
|
|
offsets[0] = 0;
|
|
for (int e = 0; e < n_expert; e++)
|
|
offsets[e + 1] = offsets[e] + counts[e];
|
|
|
|
for (int e = 0; e < n_expert; e++) {
|
|
int pos = offsets[e];
|
|
for (int t = 0; t < n_tokens; t++)
|
|
for (int s = 0; s < k; s++)
|
|
if (top_idx[t * k + s] == e) {
|
|
sorted_token[pos] = t;
|
|
sorted_slot[pos] = s;
|
|
sorted_expert[pos] = e;
|
|
pos++;
|
|
}
|
|
}
|
|
}
|
|
|
|
/* ================================================================
|
|
* Router
|
|
* ================================================================ */
|
|
|
|
/* Runs the router over one token's logits and hands back the device results. */
|
|
static int run_router(const float *logits, int n_tokens, int n_expert, int k,
|
|
float scale, int *out_idx, float *out_wt)
|
|
{
|
|
fm_buffer *lg = fm_buffer_alloc((size_t)n_tokens * n_expert * sizeof(float));
|
|
fm_buffer *ti = fm_buffer_alloc((size_t)n_tokens * k * sizeof(int));
|
|
fm_buffer *tw = fm_buffer_alloc((size_t)n_tokens * k * sizeof(float));
|
|
if (!lg || !ti || !tw)
|
|
return -1;
|
|
|
|
memcpy(fm_buffer_data(lg), logits,
|
|
(size_t)n_tokens * n_expert * sizeof(float));
|
|
memset(fm_buffer_data(ti), 0xFF, (size_t)n_tokens * k * sizeof(int));
|
|
memset(fm_buffer_data(tw), 0, (size_t)n_tokens * k * sizeof(float));
|
|
|
|
fm_moe_router_topk(lg, ti, tw, n_tokens, n_expert, k, scale);
|
|
fm_sync();
|
|
|
|
memcpy(out_idx, fm_buffer_data(ti), (size_t)n_tokens * k * sizeof(int));
|
|
memcpy(out_wt, fm_buffer_data(tw), (size_t)n_tokens * k * sizeof(float));
|
|
|
|
fm_buffer_free(lg);
|
|
fm_buffer_free(ti);
|
|
fm_buffer_free(tw);
|
|
return 0;
|
|
}
|
|
|
|
static void test_router_topk_matches_golden(void)
|
|
{
|
|
float *logits = fm_golden_load_f32("moe_router_logits.bin", MOE_N_EXPERT);
|
|
float *golden_wt = fm_golden_load_f32("moe_topk_weights.bin", MOE_EXPERT_USED);
|
|
int *golden_idx = moe_golden_load_i32("moe_topk_indices.bin", MOE_EXPERT_USED);
|
|
ASSERT_NOT_NULL(logits);
|
|
ASSERT_NOT_NULL(golden_wt);
|
|
ASSERT_NOT_NULL(golden_idx);
|
|
|
|
int gpu_idx[MOE_EXPERT_USED];
|
|
float gpu_wt[MOE_EXPERT_USED];
|
|
ASSERT_INT_EQ(0, run_router(logits, 1, MOE_N_EXPERT, MOE_EXPERT_USED, 1.0f,
|
|
gpu_idx, gpu_wt));
|
|
|
|
float weight_sum = 0.0f;
|
|
for (int s = 0; s < MOE_EXPERT_USED; s++) {
|
|
ASSERT_INT_EQ(golden_idx[s], gpu_idx[s]);
|
|
ASSERT_FLT_NEAR(golden_wt[s], gpu_wt[s], 2e-6f);
|
|
weight_sum += gpu_wt[s];
|
|
}
|
|
ASSERT_FLT_NEAR(1.0f, weight_sum, 2e-6f);
|
|
|
|
free(logits);
|
|
free(golden_wt);
|
|
free(golden_idx);
|
|
}
|
|
|
|
static void test_router_tie_break_prefers_lower_index(void)
|
|
{
|
|
/* Sixteen experts share the maximum logit exactly, so their softmax
|
|
* probabilities are bit-identical and the SELECTION ORDER is decided
|
|
* entirely by the tie-break rule. */
|
|
float logits[MOE_N_EXPERT];
|
|
for (int e = 0; e < MOE_N_EXPERT; e++)
|
|
logits[e] = 0.0f;
|
|
for (int i = 0; i < MOE_TIED_EXPERTS; i++)
|
|
logits[kTiedExperts[i]] = 1.0f;
|
|
|
|
int gpu_idx[MOE_EXPERT_USED];
|
|
float gpu_wt[MOE_EXPERT_USED];
|
|
ASSERT_INT_EQ(0, run_router(logits, 1, MOE_N_EXPERT, MOE_EXPERT_USED, 1.0f,
|
|
gpu_idx, gpu_wt));
|
|
|
|
/* Ties resolve to the LOWER index, so the eight lowest of the tied set are
|
|
* selected, in ascending order. A rule that preferred the higher index
|
|
* would return kTiedExperts[15..8] descending. */
|
|
for (int s = 0; s < MOE_EXPERT_USED; s++) {
|
|
ASSERT_INT_EQ(kTiedExperts[s], gpu_idx[s]);
|
|
ASSERT_FLT_NEAR(1.0f / (float)MOE_EXPERT_USED, gpu_wt[s], 1e-6f);
|
|
}
|
|
}
|
|
|
|
static void test_router_tie_break_matches_cpu_selection_sort(void)
|
|
{
|
|
/* Same fixture through the CPU selection sort, which resolves ties by
|
|
* first-seen-highest. The two must agree index for index. */
|
|
float logits[MOE_N_EXPERT];
|
|
for (int e = 0; e < MOE_N_EXPERT; e++)
|
|
logits[e] = 0.0f;
|
|
for (int i = 0; i < MOE_TIED_EXPERTS; i++)
|
|
logits[kTiedExperts[i]] = 1.0f;
|
|
|
|
int cpu_idx[MOE_EXPERT_USED];
|
|
float cpu_wt[MOE_EXPERT_USED];
|
|
cpu_router_topk(logits, MOE_N_EXPERT, MOE_EXPERT_USED, 1.0f, cpu_idx, cpu_wt);
|
|
|
|
int gpu_idx[MOE_EXPERT_USED];
|
|
float gpu_wt[MOE_EXPERT_USED];
|
|
ASSERT_INT_EQ(0, run_router(logits, 1, MOE_N_EXPERT, MOE_EXPERT_USED, 1.0f,
|
|
gpu_idx, gpu_wt));
|
|
|
|
for (int s = 0; s < MOE_EXPERT_USED; s++) {
|
|
ASSERT_INT_EQ(cpu_idx[s], gpu_idx[s]);
|
|
ASSERT_FLT_NEAR(cpu_wt[s], gpu_wt[s], 1e-6f);
|
|
}
|
|
}
|
|
|
|
static void test_router_applies_expert_weights_scale(void)
|
|
{
|
|
float *logits = fm_golden_load_f32("moe_router_logits.bin", MOE_N_EXPERT);
|
|
ASSERT_NOT_NULL(logits);
|
|
|
|
const float scale = 2.5f;
|
|
int cpu_idx[MOE_EXPERT_USED];
|
|
float cpu_wt[MOE_EXPERT_USED];
|
|
cpu_router_topk(logits, MOE_N_EXPERT, MOE_EXPERT_USED, scale, cpu_idx, cpu_wt);
|
|
|
|
int gpu_idx[MOE_EXPERT_USED];
|
|
float gpu_wt[MOE_EXPERT_USED];
|
|
ASSERT_INT_EQ(0, run_router(logits, 1, MOE_N_EXPERT, MOE_EXPERT_USED, scale,
|
|
gpu_idx, gpu_wt));
|
|
|
|
float weight_sum = 0.0f;
|
|
for (int s = 0; s < MOE_EXPERT_USED; s++) {
|
|
ASSERT_INT_EQ(cpu_idx[s], gpu_idx[s]);
|
|
ASSERT_FLT_NEAR(cpu_wt[s], gpu_wt[s], 2e-6f);
|
|
weight_sum += gpu_wt[s];
|
|
}
|
|
ASSERT_FLT_NEAR(scale, weight_sum, 5e-6f);
|
|
free(logits);
|
|
}
|
|
|
|
/* Per-token logits that are distinct by construction (a permutation on a
|
|
* 0.03-wide grid), so index selection cannot be flipped by the last ulp of a
|
|
* differently-associated softmax while still giving every token its own
|
|
* expert set. */
|
|
static void fill_permuted_logits(float *logits, int n_tokens, int n_expert)
|
|
{
|
|
for (int t = 0; t < n_tokens; t++) {
|
|
int *perm = (int *)malloc((size_t)n_expert * sizeof(int));
|
|
for (int e = 0; e < n_expert; e++)
|
|
perm[e] = e;
|
|
for (int e = n_expert - 1; e > 0; e--) {
|
|
int j = (int)(moe_rand_u32() % (uint32_t)(e + 1));
|
|
int tmp = perm[e];
|
|
perm[e] = perm[j];
|
|
perm[j] = tmp;
|
|
}
|
|
for (int e = 0; e < n_expert; e++)
|
|
logits[t * n_expert + e] = (float)perm[e] * 0.03f - 4.0f;
|
|
free(perm);
|
|
}
|
|
}
|
|
|
|
static void test_router_batch_matches_cpu_reference(void)
|
|
{
|
|
const int n_tokens = 64;
|
|
float *logits = (float *)malloc((size_t)n_tokens * MOE_N_EXPERT * sizeof(float));
|
|
int *gpu_idx = (int *)malloc((size_t)n_tokens * MOE_EXPERT_USED * sizeof(int));
|
|
float *gpu_wt = (float *)malloc((size_t)n_tokens * MOE_EXPERT_USED * sizeof(float));
|
|
ASSERT_NOT_NULL(logits);
|
|
ASSERT_NOT_NULL(gpu_idx);
|
|
ASSERT_NOT_NULL(gpu_wt);
|
|
|
|
moe_rand_state = 0x2468ACE0u;
|
|
fill_permuted_logits(logits, n_tokens, MOE_N_EXPERT);
|
|
ASSERT_INT_EQ(0, run_router(logits, n_tokens, MOE_N_EXPERT, MOE_EXPERT_USED,
|
|
1.0f, gpu_idx, gpu_wt));
|
|
|
|
for (int t = 0; t < n_tokens; t++) {
|
|
int cpu_idx[MOE_EXPERT_USED];
|
|
float cpu_wt[MOE_EXPERT_USED];
|
|
cpu_router_topk(logits + (size_t)t * MOE_N_EXPERT, MOE_N_EXPERT,
|
|
MOE_EXPERT_USED, 1.0f, cpu_idx, cpu_wt);
|
|
for (int s = 0; s < MOE_EXPERT_USED; s++) {
|
|
ASSERT_INT_EQ(cpu_idx[s], gpu_idx[t * MOE_EXPERT_USED + s]);
|
|
ASSERT_FLT_NEAR(cpu_wt[s], gpu_wt[t * MOE_EXPERT_USED + s], 2e-6f);
|
|
}
|
|
}
|
|
free(logits);
|
|
free(gpu_idx);
|
|
free(gpu_wt);
|
|
}
|
|
|
|
/* ================================================================
|
|
* Sort-by-expert prepass
|
|
* ================================================================ */
|
|
|
|
/* Routing for the sort tests: real router output over per-token permuted
|
|
* logits, so the token->expert map is as irregular as a real batch's. */
|
|
static int build_routing(int n_tokens, int *top_idx)
|
|
{
|
|
float *logits = (float *)malloc((size_t)n_tokens * MOE_N_EXPERT * sizeof(float));
|
|
float *wt = (float *)malloc((size_t)n_tokens * MOE_EXPERT_USED * sizeof(float));
|
|
if (!logits || !wt)
|
|
return -1;
|
|
|
|
moe_rand_state = 0x0BADC0DEu;
|
|
fill_permuted_logits(logits, n_tokens, MOE_N_EXPERT);
|
|
int rc = run_router(logits, n_tokens, MOE_N_EXPERT, MOE_EXPERT_USED, 1.0f,
|
|
top_idx, wt);
|
|
free(logits);
|
|
free(wt);
|
|
return rc;
|
|
}
|
|
|
|
/* Runs the prepass and copies its five outputs back. */
|
|
static int run_expert_sort(const int *top_idx, int n_tokens, int *counts,
|
|
int *offsets, int *sorted_token, int *sorted_slot,
|
|
int *sorted_expert)
|
|
{
|
|
const size_t requests = (size_t)n_tokens * MOE_EXPERT_USED;
|
|
fm_buffer *ti = fm_buffer_alloc(requests * sizeof(int));
|
|
fm_buffer *bc = fm_buffer_alloc(MOE_N_EXPERT * sizeof(int));
|
|
fm_buffer *bo = fm_buffer_alloc((MOE_N_EXPERT + 1) * sizeof(int));
|
|
fm_buffer *bt = fm_buffer_alloc(requests * sizeof(int));
|
|
fm_buffer *bs = fm_buffer_alloc(requests * sizeof(int));
|
|
fm_buffer *be = fm_buffer_alloc(requests * sizeof(int));
|
|
if (!ti || !bc || !bo || !bt || !bs || !be)
|
|
return -1;
|
|
|
|
memcpy(fm_buffer_data(ti), top_idx, requests * sizeof(int));
|
|
memset(fm_buffer_data(bc), 0xFF, MOE_N_EXPERT * sizeof(int));
|
|
memset(fm_buffer_data(bo), 0xFF, (MOE_N_EXPERT + 1) * sizeof(int));
|
|
memset(fm_buffer_data(bt), 0xFF, requests * sizeof(int));
|
|
memset(fm_buffer_data(bs), 0xFF, requests * sizeof(int));
|
|
memset(fm_buffer_data(be), 0xFF, requests * sizeof(int));
|
|
|
|
fm_moe_expert_sort(ti, bc, bo, bt, bs, be, n_tokens, MOE_N_EXPERT,
|
|
MOE_EXPERT_USED);
|
|
fm_sync();
|
|
|
|
memcpy(counts, fm_buffer_data(bc), MOE_N_EXPERT * sizeof(int));
|
|
memcpy(offsets, fm_buffer_data(bo), (MOE_N_EXPERT + 1) * sizeof(int));
|
|
memcpy(sorted_token, fm_buffer_data(bt), requests * sizeof(int));
|
|
memcpy(sorted_slot, fm_buffer_data(bs), requests * sizeof(int));
|
|
memcpy(sorted_expert, fm_buffer_data(be), requests * sizeof(int));
|
|
|
|
fm_buffer_free(ti);
|
|
fm_buffer_free(bc);
|
|
fm_buffer_free(bo);
|
|
fm_buffer_free(bt);
|
|
fm_buffer_free(bs);
|
|
fm_buffer_free(be);
|
|
return 0;
|
|
}
|
|
|
|
static void test_expert_sort_matches_cpu_reference_at_prefill(void)
|
|
{
|
|
const int n_tokens = MOE_SORT_TOKENS;
|
|
const size_t requests = (size_t)n_tokens * MOE_EXPERT_USED;
|
|
|
|
int *top_idx = (int *)malloc(requests * sizeof(int));
|
|
int *counts = (int *)malloc(MOE_N_EXPERT * sizeof(int));
|
|
int *offsets = (int *)malloc((MOE_N_EXPERT + 1) * sizeof(int));
|
|
int *sorted_token = (int *)malloc(requests * sizeof(int));
|
|
int *sorted_slot = (int *)malloc(requests * sizeof(int));
|
|
int *sorted_expert = (int *)malloc(requests * sizeof(int));
|
|
int *ref_counts = (int *)malloc(MOE_N_EXPERT * sizeof(int));
|
|
int *ref_offsets = (int *)malloc((MOE_N_EXPERT + 1) * sizeof(int));
|
|
int *ref_token = (int *)malloc(requests * sizeof(int));
|
|
int *ref_slot = (int *)malloc(requests * sizeof(int));
|
|
int *ref_expert = (int *)malloc(requests * sizeof(int));
|
|
ASSERT_NOT_NULL(top_idx);
|
|
ASSERT_NOT_NULL(ref_expert);
|
|
|
|
ASSERT_INT_EQ(0, build_routing(n_tokens, top_idx));
|
|
ASSERT_INT_EQ(0, run_expert_sort(top_idx, n_tokens, counts, offsets,
|
|
sorted_token, sorted_slot, sorted_expert));
|
|
cpu_expert_sort(top_idx, n_tokens, MOE_N_EXPERT, MOE_EXPERT_USED, ref_counts,
|
|
ref_offsets, ref_token, ref_slot, ref_expert);
|
|
|
|
for (int e = 0; e < MOE_N_EXPERT; e++)
|
|
ASSERT_INT_EQ(ref_counts[e], counts[e]);
|
|
for (int e = 0; e <= MOE_N_EXPERT; e++)
|
|
ASSERT_INT_EQ(ref_offsets[e], offsets[e]);
|
|
ASSERT_INT_EQ((int)requests, offsets[MOE_N_EXPERT]);
|
|
for (size_t r = 0; r < requests; r++) {
|
|
ASSERT_INT_EQ(ref_token[r], sorted_token[r]);
|
|
ASSERT_INT_EQ(ref_slot[r], sorted_slot[r]);
|
|
ASSERT_INT_EQ(ref_expert[r], sorted_expert[r]);
|
|
}
|
|
|
|
free(top_idx);
|
|
free(counts);
|
|
free(offsets);
|
|
free(sorted_token);
|
|
free(sorted_slot);
|
|
free(sorted_expert);
|
|
free(ref_counts);
|
|
free(ref_offsets);
|
|
free(ref_token);
|
|
free(ref_slot);
|
|
free(ref_expert);
|
|
}
|
|
|
|
static void test_expert_sort_is_a_permutation_of_the_routing(void)
|
|
{
|
|
/* Independent of the CPU reference: every (token, slot) request must appear
|
|
* exactly once, at a position inside its own expert's CSR range, with the
|
|
* expert the router actually chose. This catches a scan that drops or
|
|
* duplicates an entry even if the reference made the same mistake. */
|
|
const int n_tokens = MOE_SORT_TOKENS;
|
|
const size_t requests = (size_t)n_tokens * MOE_EXPERT_USED;
|
|
|
|
int *top_idx = (int *)malloc(requests * sizeof(int));
|
|
int *counts = (int *)malloc(MOE_N_EXPERT * sizeof(int));
|
|
int *offsets = (int *)malloc((MOE_N_EXPERT + 1) * sizeof(int));
|
|
int *sorted_token = (int *)malloc(requests * sizeof(int));
|
|
int *sorted_slot = (int *)malloc(requests * sizeof(int));
|
|
int *sorted_expert = (int *)malloc(requests * sizeof(int));
|
|
char *seen = (char *)calloc(requests, 1);
|
|
ASSERT_NOT_NULL(top_idx);
|
|
ASSERT_NOT_NULL(seen);
|
|
|
|
ASSERT_INT_EQ(0, build_routing(n_tokens, top_idx));
|
|
ASSERT_INT_EQ(0, run_expert_sort(top_idx, n_tokens, counts, offsets,
|
|
sorted_token, sorted_slot, sorted_expert));
|
|
|
|
for (int e = 0; e < MOE_N_EXPERT; e++) {
|
|
ASSERT_INT_EQ(counts[e], offsets[e + 1] - offsets[e]);
|
|
for (int r = offsets[e]; r < offsets[e + 1]; r++) {
|
|
ASSERT_INT_EQ(e, sorted_expert[r]);
|
|
int t = sorted_token[r];
|
|
int s = sorted_slot[r];
|
|
ASSERT_TRUE(t >= 0 && t < n_tokens);
|
|
ASSERT_TRUE(s >= 0 && s < MOE_EXPERT_USED);
|
|
ASSERT_INT_EQ(e, top_idx[t * MOE_EXPERT_USED + s]);
|
|
ASSERT_INT_EQ(0, seen[(size_t)t * MOE_EXPERT_USED + s]);
|
|
seen[(size_t)t * MOE_EXPERT_USED + s] = 1;
|
|
/* Ascending token order within an expert is what makes the output
|
|
* deterministic; an atomic-ordered scatter would break here. */
|
|
if (r > offsets[e])
|
|
ASSERT_TRUE(sorted_token[r] > sorted_token[r - 1]);
|
|
}
|
|
}
|
|
for (size_t r = 0; r < requests; r++)
|
|
ASSERT_INT_EQ(1, seen[r]);
|
|
|
|
free(top_idx);
|
|
free(counts);
|
|
free(offsets);
|
|
free(sorted_token);
|
|
free(sorted_slot);
|
|
free(sorted_expert);
|
|
free(seen);
|
|
}
|
|
|
|
static void test_expert_sort_single_token(void)
|
|
{
|
|
int top_idx[MOE_EXPERT_USED];
|
|
int counts[MOE_N_EXPERT];
|
|
int offsets[MOE_N_EXPERT + 1];
|
|
int sorted_token[MOE_EXPERT_USED];
|
|
int sorted_slot[MOE_EXPERT_USED];
|
|
int sorted_expert[MOE_EXPERT_USED];
|
|
|
|
ASSERT_INT_EQ(0, build_routing(1, top_idx));
|
|
ASSERT_INT_EQ(0, run_expert_sort(top_idx, 1, counts, offsets, sorted_token,
|
|
sorted_slot, sorted_expert));
|
|
|
|
int total = 0;
|
|
for (int e = 0; e < MOE_N_EXPERT; e++) {
|
|
int expected = 0;
|
|
for (int s = 0; s < MOE_EXPERT_USED; s++)
|
|
if (top_idx[s] == e)
|
|
expected = 1;
|
|
ASSERT_INT_EQ(expected, counts[e]);
|
|
total += counts[e];
|
|
}
|
|
ASSERT_INT_EQ(MOE_EXPERT_USED, total);
|
|
for (int r = 0; r < MOE_EXPERT_USED; r++) {
|
|
ASSERT_INT_EQ(0, sorted_token[r]);
|
|
ASSERT_INT_EQ(sorted_expert[r], top_idx[sorted_slot[r]]);
|
|
}
|
|
}
|
|
|
|
/* ================================================================
|
|
* Shared expert
|
|
* ================================================================ */
|
|
|
|
static void test_silu_mul_matches_cpu_reference(void)
|
|
{
|
|
const int n = 4096;
|
|
float *a = (float *)malloc((size_t)n * sizeof(float));
|
|
float *b = (float *)malloc((size_t)n * sizeof(float));
|
|
ASSERT_NOT_NULL(a);
|
|
ASSERT_NOT_NULL(b);
|
|
|
|
moe_rand_state = 0x51A7ABCDu;
|
|
for (int i = 0; i < n; i++) {
|
|
a[i] = moe_rand_unit() * 8.0f - 4.0f;
|
|
b[i] = moe_rand_unit() * 4.0f - 2.0f;
|
|
}
|
|
|
|
fm_buffer *ba = fm_buffer_alloc((size_t)n * sizeof(float));
|
|
fm_buffer *bb = fm_buffer_alloc((size_t)n * sizeof(float));
|
|
fm_buffer *bo = fm_buffer_alloc((size_t)n * sizeof(float));
|
|
ASSERT_NOT_NULL(ba);
|
|
ASSERT_NOT_NULL(bb);
|
|
ASSERT_NOT_NULL(bo);
|
|
memcpy(fm_buffer_data(ba), a, (size_t)n * sizeof(float));
|
|
memcpy(fm_buffer_data(bb), b, (size_t)n * sizeof(float));
|
|
memset(fm_buffer_data(bo), 0, (size_t)n * sizeof(float));
|
|
|
|
fm_moe_silu_mul(ba, bb, bo, n);
|
|
fm_sync();
|
|
|
|
const float *got = (const float *)fm_buffer_data(bo);
|
|
for (int i = 0; i < n; i++) {
|
|
float expected = (a[i] / (1.0f + expf(-a[i]))) * b[i];
|
|
ASSERT_FLT_NEAR(expected, got[i], 1e-5f);
|
|
}
|
|
|
|
fm_buffer_free(ba);
|
|
fm_buffer_free(bb);
|
|
fm_buffer_free(bo);
|
|
free(a);
|
|
free(b);
|
|
}
|
|
|
|
static void test_shexp_dot_gate_matches_golden_scalar(void)
|
|
{
|
|
float *gate_w = fm_golden_load_f32("moe_shared_gate_weight.bin", MOE_N_EMBD);
|
|
float *x = fm_golden_load_f32("moe_shared_gate_input.bin", MOE_N_EMBD);
|
|
float *golden = fm_golden_load_f32("moe_shared_gate_scalar.bin", 1);
|
|
ASSERT_NOT_NULL(gate_w);
|
|
ASSERT_NOT_NULL(x);
|
|
ASSERT_NOT_NULL(golden);
|
|
|
|
fm_buffer *bw = fm_buffer_alloc(MOE_N_EMBD * sizeof(float));
|
|
fm_buffer *bx = fm_buffer_alloc(MOE_N_EMBD * sizeof(float));
|
|
fm_buffer *bs = fm_buffer_alloc(MOE_N_EMBD * sizeof(float));
|
|
ASSERT_NOT_NULL(bw);
|
|
ASSERT_NOT_NULL(bx);
|
|
ASSERT_NOT_NULL(bs);
|
|
memcpy(fm_buffer_data(bw), gate_w, MOE_N_EMBD * sizeof(float));
|
|
memcpy(fm_buffer_data(bx), x, MOE_N_EMBD * sizeof(float));
|
|
|
|
/* Seeding the branch with 1.0 makes the in-place kernel report the scalar
|
|
* gate itself, which is what the golden vector holds. */
|
|
float *shexp = (float *)fm_buffer_data(bs);
|
|
for (int i = 0; i < MOE_N_EMBD; i++)
|
|
shexp[i] = 1.0f;
|
|
|
|
fm_moe_shexp_dot_gate(bw, bx, bs, 1, MOE_N_EMBD);
|
|
fm_sync();
|
|
|
|
for (int i = 0; i < MOE_N_EMBD; i++)
|
|
ASSERT_FLT_NEAR(golden[0], shexp[i], 5e-7f);
|
|
|
|
fm_buffer_free(bw);
|
|
fm_buffer_free(bx);
|
|
fm_buffer_free(bs);
|
|
free(gate_w);
|
|
free(x);
|
|
free(golden);
|
|
}
|
|
|
|
static void test_shexp_dot_gate_scales_the_branch_per_token(void)
|
|
{
|
|
const int n_tokens = 5;
|
|
float *gate_w = fm_golden_load_f32("moe_shared_gate_weight.bin", MOE_N_EMBD);
|
|
float *x0 = fm_golden_load_f32("moe_shared_gate_input.bin", MOE_N_EMBD);
|
|
ASSERT_NOT_NULL(gate_w);
|
|
ASSERT_NOT_NULL(x0);
|
|
|
|
fm_buffer *bw = fm_buffer_alloc(MOE_N_EMBD * sizeof(float));
|
|
fm_buffer *bx = fm_buffer_alloc((size_t)n_tokens * MOE_N_EMBD * sizeof(float));
|
|
fm_buffer *bs = fm_buffer_alloc((size_t)n_tokens * MOE_N_EMBD * sizeof(float));
|
|
ASSERT_NOT_NULL(bw);
|
|
ASSERT_NOT_NULL(bx);
|
|
ASSERT_NOT_NULL(bs);
|
|
memcpy(fm_buffer_data(bw), gate_w, MOE_N_EMBD * sizeof(float));
|
|
|
|
/* Each token scales the golden input differently, so every token must end
|
|
* up with its own gate — a kernel that reduced token 0 for everyone passes
|
|
* the single-token test and fails here. */
|
|
float *x = (float *)fm_buffer_data(bx);
|
|
float *shexp = (float *)fm_buffer_data(bs);
|
|
for (int t = 0; t < n_tokens; t++)
|
|
for (int i = 0; i < MOE_N_EMBD; i++) {
|
|
x[t * MOE_N_EMBD + i] = x0[i] * (0.5f + 0.25f * (float)t);
|
|
shexp[t * MOE_N_EMBD + i] = 2.0f;
|
|
}
|
|
|
|
fm_moe_shexp_dot_gate(bw, bx, bs, n_tokens, MOE_N_EMBD);
|
|
fm_sync();
|
|
|
|
for (int t = 0; t < n_tokens; t++) {
|
|
double dot = 0.0;
|
|
for (int i = 0; i < MOE_N_EMBD; i++)
|
|
dot += (double)gate_w[i] * (double)x[t * MOE_N_EMBD + i];
|
|
float expected = 2.0f * (float)(1.0 / (1.0 + exp(-dot)));
|
|
for (int i = 0; i < MOE_N_EMBD; i += 257)
|
|
ASSERT_FLT_NEAR(expected, shexp[t * MOE_N_EMBD + i], 1e-5f);
|
|
}
|
|
|
|
fm_buffer_free(bw);
|
|
fm_buffer_free(bx);
|
|
fm_buffer_free(bs);
|
|
free(gate_w);
|
|
free(x0);
|
|
}
|
|
|
|
/* ================================================================
|
|
* main
|
|
* ================================================================ */
|
|
|
|
int main(int argc, char **argv)
|
|
{
|
|
(void)argc;
|
|
printf("\n Metal MoE — Router, Sort Prepass, Shared Expert\n");
|
|
printf(" ==============================================\n\n");
|
|
|
|
if (fm_init() != 0 || !fm_available()) {
|
|
fprintf(stderr, " Metal unavailable — no device to test against.\n");
|
|
return 77;
|
|
}
|
|
|
|
fm_golden_locate(argv[0]);
|
|
float *probe = fm_golden_load_f32("moe_router_logits.bin", MOE_N_EXPERT);
|
|
if (!probe) {
|
|
fprintf(stderr, " Golden vectors missing — every assertion below is "
|
|
"absolute and needs them.\n");
|
|
fm_free();
|
|
return 77;
|
|
}
|
|
free(probe);
|
|
|
|
RUN_TEST(router_topk_matches_golden);
|
|
RUN_TEST(router_tie_break_prefers_lower_index);
|
|
RUN_TEST(router_tie_break_matches_cpu_selection_sort);
|
|
RUN_TEST(router_applies_expert_weights_scale);
|
|
RUN_TEST(router_batch_matches_cpu_reference);
|
|
|
|
RUN_TEST(expert_sort_matches_cpu_reference_at_prefill);
|
|
RUN_TEST(expert_sort_is_a_permutation_of_the_routing);
|
|
RUN_TEST(expert_sort_single_token);
|
|
|
|
RUN_TEST(silu_mul_matches_cpu_reference);
|
|
RUN_TEST(shexp_dot_gate_matches_golden_scalar);
|
|
RUN_TEST(shexp_dot_gate_scales_the_branch_per_token);
|
|
|
|
fm_free();
|
|
|
|
TEST_REPORT();
|
|
return __ft_failed > 0 ? 1 : 0;
|
|
}
|