Files
llama.cpp/tests/test-fusion.cpp
T
Georgi Gerganov 4af6790656 tests : use backend base name for fusion baseline output
The fusion test is invoked with a specific device name (e.g. MTL0), but
its output - the recorded baseline and the header it writes - should be
named after the backend base name (e.g. MTL, via ggml_backend_reg_name),
since the counters depend on the backend, not on the specific device
index. Rename the committed baseline to MTL.tsv.

Assisted-by: pi:llama.cpp/DeepSeek-V4-Flash-0731
2026-09-03 17:19:49 +03:00

491 lines
19 KiB
C++

// test-fusion: verify the Metal fusion logic against a per-device baseline.
//
// for every dummy model generated by test-llama-archs, the tool runs the model on a single
// device (single-threaded encoding, n_cb = 0) with fusion enabled and disabled, and reports:
// - the per-fusion-type counters for each mode (prefill / decode)
// - the NMSE between the fused and unfused logits
// - the NMSE between the device and a CPU reference
//
// the per-fusion-type counters are compared against a per-device baseline file (TSV) so a
// fusion pattern that silently stops matching (or fires when it should not) is caught as a
// regression.
//
// usage:
// test-fusion --models DIR --device MTL0 --record baseline.tsv # generate a baseline
// test-fusion --models DIR --device MTL0 --check baseline.tsv # validate against it
// test-fusion --model FILE --device MTL0 --check baseline.tsv # validate a single model
#include "common.h"
#include "log.h"
#include "llama-cpp.h"
#include "ggml.h"
#include "gguf.h"
#include <algorithm>
#include <cmath>
#include <cstring>
#include <filesystem>
#include <fstream>
#include <iostream>
#include <map>
#include <random>
#include <string>
#include <vector>
// generic fusion debugging API, resolved through the ad-hoc get_proc_address mechanism
// (not part of the official ggml backend interface yet). a backend that adopts fusion debugging
// exports these exact names.
typedef void ( * fusion_stats_init_t) (ggml_backend_dev_t);
typedef void ( * fusion_stats_reset_t)(ggml_backend_dev_t);
typedef int ( * fusion_stats_get_t) (ggml_backend_dev_t, const char **, uint64_t *, int);
typedef void ( * fusion_set_enabled_t)(ggml_backend_dev_t, bool);
static bool silent_model_load_progress(float, void *) {
return true;
}
struct gguf_context_ptr {
gguf_context * ctx;
gguf_context_ptr(gguf_context * c) : ctx(c) {}
~gguf_context_ptr() { if (ctx) { gguf_free(ctx); } }
gguf_context * get() const { return ctx; }
gguf_context_ptr(const gguf_context_ptr &) = delete;
gguf_context_ptr & operator=(const gguf_context_ptr &) = delete;
};
// NMSE between two vectors (same as tests/test-llama-archs.cpp)
static double nmse(const std::vector<float> & a, const std::vector<float> & b) {
GGML_ASSERT(a.size() == b.size());
double mse_a_b = 0.0;
double mse_a_0 = 0.0;
for (size_t i = 0; i < a.size(); i++) {
const float a_i = a[i];
const float b_i = b[i];
mse_a_b += (a_i - b_i) * (a_i - b_i);
mse_a_0 += a_i * a_i;
}
return mse_a_b / mse_a_0;
}
// deterministic token sequence
static std::vector<llama_token> get_tokens(const uint32_t n_tokens, const uint32_t n_vocab, const size_t seed) {
std::mt19937 gen(seed);
std::uniform_int_distribution<> dis(0, n_vocab - 1);
std::vector<llama_token> ret;
ret.reserve(n_tokens);
for (uint32_t i = 0; i < n_tokens; i++) {
ret.push_back(dis(gen));
}
return ret;
}
static std::string get_arch(const std::string & path) {
gguf_init_params params = { /*no_alloc=*/true, /*ctx=*/nullptr };
gguf_context_ptr ctx(gguf_init_from_file(path.c_str(), params));
if (!ctx.get()) {
throw std::runtime_error("failed to read gguf: " + path);
}
const int idx = gguf_find_key(ctx.get(), "general.architecture");
if (idx < 0) {
return "unknown";
}
const char * val = gguf_get_val_str(ctx.get(), idx);
return val ? val : "unknown";
}
static llama_model_ptr load_model(const std::string & path, ggml_backend_dev_t dev) {
llama_model_params model_params = llama_model_default_params();
model_params.progress_callback = silent_model_load_progress;
std::vector<ggml_backend_dev_t> devs = { dev, nullptr };
model_params.devices = devs.data();
model_params.split_mode = LLAMA_SPLIT_MODE_LAYER;
llama_model_ptr model(llama_model_load_from_file(path.c_str(), model_params));
if (!model) {
throw std::runtime_error("failed to load model: " + path);
}
return model;
}
// a fresh context (fresh state) from an already-loaded model
static llama_context_ptr create_ctx(llama_model * model, int n_ubatch) {
llama_context_params ctx_params = llama_context_default_params();
ctx_params.n_ctx = 0;
ctx_params.n_threads = 4;
ctx_params.n_threads_batch = 4;
ctx_params.n_ubatch = n_ubatch;
ctx_params.n_batch = n_ubatch;
llama_context_ptr lctx(llama_init_from_model(model, ctx_params));
if (!lctx) {
throw std::runtime_error("failed to init context");
}
return lctx;
}
// decode all tokens in one batch; returns the logits of every token
static std::vector<float> decode_prefill(llama_model * model, llama_context * lctx, const std::vector<llama_token> & tokens) {
const uint32_t n_vocab = llama_vocab_n_tokens(llama_model_get_vocab(model));
llama_batch batch = llama_batch_init(tokens.size(), 0, 1);
for (size_t i = 0; i < tokens.size(); i++) {
common_batch_add(batch, tokens[i], i, { 0 }, true);
}
batch.n_tokens = tokens.size();
if (llama_decode(lctx, batch)) {
llama_batch_free(batch);
throw std::runtime_error("prefill decode failed");
}
std::vector<float> ret;
ret.reserve(tokens.size() * n_vocab);
for (size_t i = 0; i < tokens.size(); i++) {
const float * logits_ith = llama_get_logits_ith(lctx, i);
for (uint32_t j = 0; j < n_vocab; j++) {
ret.push_back(logits_ith[j]);
}
}
llama_batch_free(batch);
return ret;
}
// decode one token at a time; returns the logits of the last token of each step
static std::vector<float> decode_token_by_token(llama_model * model, llama_context * lctx, const std::vector<llama_token> & tokens) {
const uint32_t n_vocab = llama_vocab_n_tokens(llama_model_get_vocab(model));
llama_batch batch = llama_batch_init(1, 0, 1);
std::vector<float> ret;
for (size_t i = 0; i < tokens.size(); i++) {
common_batch_clear(batch);
common_batch_add(batch, tokens[i], i, { 0 }, true);
if (llama_decode(lctx, batch)) {
llama_batch_free(batch);
throw std::runtime_error("decode failed");
}
const float * logits = llama_get_logits_ith(lctx, 0);
for (uint32_t j = 0; j < n_vocab; j++) {
ret.push_back(logits[j]);
}
}
llama_batch_free(batch);
return ret;
}
static void read_counts(fusion_stats_get_t api_stats_get, ggml_backend_dev_t dev,
std::vector<const char *> & labels, std::vector<uint64_t> & counts) {
const int n = api_stats_get(dev, nullptr, nullptr, 0);
labels.assign(n, nullptr);
counts.assign(n, 0);
api_stats_get(dev, labels.data(), counts.data(), n);
}
// one row of the per-label report
struct fusion_row {
std::string arch;
bool moe;
std::string mode;
const char * label;
uint64_t count_fused;
uint64_t count_unfused;
uint64_t expected;
double nmse_fus;
double nmse_dev;
bool ok_count; // counts match the baseline
bool ok_nmse; // nmse within epsilon
bool skip_nmse; // nmse unreliable (NaN logits or arch already broken on the device)
};
static void usage(const char * argv0) {
printf("%s: verify fusion counts on a device against a per-device baseline\n\n", argv0);
printf("usage: %s [options]\n\n", argv0);
printf("options:\n");
printf(" --models DIR run over all .gguf models in a directory\n");
printf(" --model FILE run over a single model file (mutually exclusive with --models)\n");
printf(" --device NAME device to run on (e.g. MTL0, CPU)\n");
printf(" --record TSV write the golden baseline\n");
printf(" --check TSV validate the counters against a baseline (default)\n");
printf(" -h, --help show this message and exit\n");
}
int main(int argc, char ** argv) {
std::string models_dir;
std::string model_file;
std::string device_name;
std::string record_path;
std::string check_path;
for (int i = 1; i < argc; i++) {
const std::string arg = argv[i];
const auto next = [&](const char * name) -> std::string {
if (i + 1 >= argc) {
LOG_ERR("%s: %s requires an argument\n", __func__, name);
exit(1);
}
return argv[++i];
};
if (arg == "-h" || arg == "--help") {
usage(argv[0]);
exit(0);
}
if (arg == "--models") { models_dir = next("--models"); }
else if (arg == "--model") { model_file = next("--model"); }
else if (arg == "--device"){ device_name = next("--device"); }
else if (arg == "--record"){ record_path = next("--record"); }
else if (arg == "--check") { check_path = next("--check"); }
else {
LOG_ERR("%s: unknown argument: %s\n", __func__, arg.c_str());
return 1;
}
}
if (device_name.empty()) {
LOG_ERR("%s: --device NAME is required\n", __func__);
return 1;
}
if (models_dir.empty() && model_file.empty()) {
LOG_ERR("%s: --models DIR or --model FILE is required\n", __func__);
return 1;
}
if (!models_dir.empty() && !model_file.empty()) {
LOG_ERR("%s: --models DIR and --model FILE are mutually exclusive\n", __func__);
return 1;
}
if (!record_path.empty() && !check_path.empty()) {
LOG_ERR("%s: --record and --check are mutually exclusive\n", __func__);
return 1;
}
std::vector<std::string> models;
if (!model_file.empty()) {
if (!std::filesystem::is_regular_file(model_file)) {
LOG_ERR("%s: model file '%s' does not exist\n", __func__, model_file.c_str());
return 1;
}
models.push_back(model_file);
} else {
if (!std::filesystem::exists(models_dir) || !std::filesystem::is_directory(models_dir)) {
LOG_ERR("%s: models directory '%s' does not exist\n", __func__, models_dir.c_str());
return 1;
}
for (const auto & entry : std::filesystem::directory_iterator(models_dir)) {
if (entry.is_regular_file() && entry.path().extension() == ".gguf") {
models.push_back(entry.path().string());
}
}
std::sort(models.begin(), models.end());
if (models.empty()) {
LOG_ERR("%s: no .gguf models found in '%s'\n", __func__, models_dir.c_str());
return 1;
}
}
common_init();
ggml_backend_load_all();
ggml_backend_dev_t dev = ggml_backend_dev_by_name(device_name.c_str());
if (!dev) {
LOG_WRN("%s: device '%s' not found - skipping (baseline is device-specific)\n",
__func__, device_name.c_str());
return 0;
}
// resolve the generic fusion debugging functions through the ad-hoc get_proc_address
// mechanism; a backend that does not adopt fusion debugging exports none of them
auto * reg = ggml_backend_dev_backend_reg(dev);
// output naming uses the backend base name (e.g. "MTL") rather than the specific device
// name (e.g. "MTL0") the test was invoked with
const std::string base_name = ggml_backend_reg_name(reg);
auto api_stats_init = (fusion_stats_init_t) ggml_backend_reg_get_proc_address(reg, "ggml_backend_fusion_stats_init");
auto api_stats_reset = (fusion_stats_reset_t) ggml_backend_reg_get_proc_address(reg, "ggml_backend_fusion_stats_reset");
auto api_stats_get = (fusion_stats_get_t) ggml_backend_reg_get_proc_address(reg, "ggml_backend_fusion_stats_get");
auto api_set_enabled = (fusion_set_enabled_t) ggml_backend_reg_get_proc_address(reg, "ggml_backend_fusion_set_enabled");
if (!api_stats_init || !api_set_enabled || !api_stats_reset || !api_stats_get) {
LOG_ERR("%s: device '%s' does not export the generic fusion debugging API "
"(ggml_backend_fusion_*) - cannot run the fusion regression test\n",
__func__, device_name.c_str());
return 1;
}
// enable fusions stats
api_stats_init(dev);
const bool has_counts = true;
// load the baseline (if any): key arch|moe|mode|label -> expected count
std::map<std::string, uint64_t> baseline;
if (!check_path.empty()) {
std::ifstream in(check_path);
if (!in) {
LOG_ERR("%s: cannot open baseline '%s'\n", __func__, check_path.c_str());
return 1;
}
std::string line;
while (std::getline(in, line)) {
if (line.empty() || line[0] == '#') {
continue;
}
std::vector<std::string> cols;
size_t pos = 0;
while ((pos = line.find('\t')) != std::string::npos) {
cols.push_back(line.substr(0, pos));
line.erase(0, pos + 1);
}
cols.push_back(line);
if (cols.size() != 5) {
continue;
}
baseline[cols[0] + "|" + cols[1] + "|" + cols[2] + "|" + cols[3]] = std::stoull(cols[4]);
}
}
std::vector<fusion_row> rows;
LOG_INF("%s: running fusion test over %zu models on '%s'\n", __func__, models.size(), base_name.c_str());
const size_t seed = 1;
for (const auto & model_path : models) {
const std::string arch = get_arch(model_path);
const bool moe = arch.find("moe") != std::string::npos;
llama_model_ptr model;
llama_model_ptr model_cpu;
uint32_t n_vocab = 0;
try {
model = load_model(model_path, dev);
model_cpu = load_model(model_path, ggml_backend_dev_by_name("CPU"));
n_vocab = llama_vocab_n_tokens(llama_model_get_vocab(model.get()));
} catch (const std::exception & e) {
LOG_ERR("%s: %s: %s\n", __func__, model_path.c_str(), e.what());
continue;
}
struct mode_cfg {
std::string name;
std::vector<float> (*decode)(llama_model *, llama_context *, const std::vector<llama_token> &);
int n_tokens;
};
const mode_cfg modes[] = {
{ "prefill", decode_prefill, 32 },
{ "decode", decode_token_by_token, 16 },
};
for (const auto & mode : modes) {
const auto tokens = get_tokens(mode.n_tokens, n_vocab, seed);
// CPU reference for this mode (fresh context, fresh state)
std::vector<float> logits_cpu;
try {
llama_context_ptr ctx = create_ctx(model_cpu.get(), 32);
logits_cpu = mode.decode(model_cpu.get(), ctx.get(), tokens);
} catch (const std::exception & e) {
LOG_WRN("%s: %s: cpu reference: %s\n", __func__, model_path.c_str(), e.what());
}
// fused run on a fresh context (fresh state)
std::vector<float> logits_fused;
std::vector<const char *> labels;
std::vector<uint64_t> counts_fused;
{
llama_context_ptr ctx = create_ctx(model.get(), 32);
if (has_counts) {
api_set_enabled(dev, true);
api_stats_reset(dev);
}
logits_fused = mode.decode(model.get(), ctx.get(), tokens);
if (has_counts) {
read_counts(api_stats_get, dev, labels, counts_fused);
}
}
// unfused run on another fresh context (fresh state)
std::vector<float> logits_unfused;
std::vector<uint64_t> counts_unfused;
{
llama_context_ptr ctx = create_ctx(model.get(), 32);
if (has_counts) {
api_set_enabled(dev, false);
api_stats_reset(dev);
}
logits_unfused = mode.decode(model.get(), ctx.get(), tokens);
if (has_counts) {
read_counts(api_stats_get, dev, labels, counts_unfused);
}
}
const double nmse_fus = nmse(logits_fused, logits_unfused);
const double nmse_dev = logits_cpu.empty() ? 0.0 : nmse(logits_fused, logits_cpu);
// an arch that is already broken on the device (huge device-vs-CPU NMSE, e.g. plamo2
// on Metal) produces garbage regardless of fusion, so the fused-vs-unfused NMSE is not
// a meaningful signal - skip it and rely on the count regression check only
// TODO: run in Debug build to get an assert in `ggml-alloc.c` and fix it
const bool dev_broken = !std::isnan(nmse_dev) && nmse_dev > 1e-4;
// build the per-label rows
if (has_counts) {
for (int i = 0; i < (int) labels.size(); i++) {
if (counts_fused[i] == 0 && counts_unfused[i] == 0) {
continue;
}
const std::string key = arch + "|" + (moe ? "1" : "0") + "|" + mode.name + "|" + labels[i];
const uint64_t expected = baseline.count(key) ? baseline.at(key) : 0;
const bool skip_nmse = dev_broken || std::isnan(nmse_fus);
rows.push_back({ arch, moe, mode.name, labels[i], counts_fused[i], counts_unfused[i],
expected, nmse_fus, nmse_dev,
!check_path.empty() ? (counts_fused[i] == expected) : true,
!skip_nmse && nmse_fus <= 1e-4,
skip_nmse });
}
} else {
const bool skip_nmse = dev_broken || std::isnan(nmse_fus);
rows.push_back({ arch, moe, mode.name, "?", 0, 0, 0, nmse_fus, nmse_dev, true,
!skip_nmse && nmse_fus <= 1e-4, skip_nmse });
}
}
LOG_INF("%s: %-20s (%s) done\n", __func__, arch.c_str(), model_path.c_str());
}
// print the report
{
std::ofstream out(record_path);
std::ostream & os = record_path.empty() ? std::cout : out;
if (!record_path.empty()) {
os << "# test-fusion baseline for device " << base_name << "\n";
os << "# arch\tmoe\tmode\tlabel\tcount\n";
}
LOG_INF("%-20s %-4s %-8s %-22s %7s %7s %7s %10s %10s %s\n",
"arch", "moe", "mode", "label", "fused", "unfused", "expected", "nmse_fus", "nmse_dev", "status");
int n_ok = 0;
int n_bad = 0;
int n_skip = 0;
for (const auto & r : rows) {
const bool ok = r.ok_count && (r.skip_nmse || r.ok_nmse);
const char * status = ok ? (r.skip_nmse ? "skip" : "ok") : "FAIL";
if (r.skip_nmse) { n_skip++; }
if (ok) { n_ok++; } else { n_bad++; }
LOG_INF("%-20s %-4s %-8s %-22s %7llu %7llu %7llu %10.2e %10.2e %s\n",
r.arch.c_str(), r.moe ? "moe" : "dense", r.mode.c_str(), r.label,
(unsigned long long) r.count_fused, (unsigned long long) r.count_unfused,
(unsigned long long) r.expected, r.nmse_fus, r.nmse_dev, status);
if (!record_path.empty()) {
os << r.arch << '\t' << (r.moe ? "1" : "0") << '\t' << r.mode << '\t' << r.label << '\t'
<< r.count_fused << '\n';
}
}
LOG_INF("summary: %d ok, %d failed, %d skipped (of %d rows)\n", n_ok, n_bad, n_skip, n_ok + n_bad);
if (!record_path.empty()) {
LOG_INF("%s: baseline written to '%s'\n", __func__, record_path.c_str());
}
return n_bad;
}
}