Files
Georgi Gerganov a17e94d229 tests : consolidate context state tests into test-llama-context (#30023)
* tests : consolidate the context state tests into test-llama-context

test-save-load-state, test-recurrent-state-rollback and
test-state-restore-fragmented check the same object from three places:
the KV cache and the recurrent state of a llama_context, saved, loaded,
copied, rolled back. Each had its own main, its own loop over the
models, its own copy of the token and the comparison helpers, and a run
of all three loaded the same models several times.

They are one binary now, and the case is the unit of the test:

  tests/llama-context/test-case.h   the interface a case implements
  tests/llama-context/cases-kv.cpp  the cases of the KV cache
  tests/llama-context/cases-rs.cpp  the cases of the recurrent state
  tests/llama-context/test-util.cpp tokens, generation, comparison
  tests/llama-context/test-runner.cpp the models of a case, on threads
  tests/llama-context/test-log.cpp  the log file of a model

A run is a table: a row per model, a column per case, a cell holds the
verdict of that pair. The models load on several threads (-j, --threads,
min(cores, 4) by default), the table is printed when it is complete and
the log of a model that failed is printed after it, in the order of the
rows. A case returns a verdict and the reason for it, and the runner is
the only one who reports - the test does not use tests/testing.h, a run
over 130 models is a matrix of verdicts and not a tree of subtests.

Staging no longer travels through the environment: llama_model_set_attn_rot
replaces the LLAMA_ATTN_ROT_DISABLE variable, which a model loaded in one
thread used to change for the models the other threads were loading, and
llama_memory_get_buffers is added for the cases that copy the buffers of
a memory. common_log_set_stdo keeps the console quiet while the table is
being built.

ggml_aligned_malloc allocates with posix_memalign when the thread
sanitizer is on. On macOS it allocates with the Mach VM, which the
sanitizer does not intercept, so it never sees a page range change hands
and reports the first write into recycled memory as a race - 30 of them
in this test, and every one of them false.

Assisted-by: pi:llama.cpp/Qwen3.8-Flash-Next

* cont : run single-threaded by default

* cont : try to build the tests on all platforms

* cont : trim down some body fat

* cont : fix

* cont : fix the fix
2026-10-13 16:21:55 +03:00

120 lines
3.5 KiB
C++

#include "test-log.h"
#include <algorithm>
#include <cstdarg>
#include <cstdio>
#include <filesystem>
#include <fstream>
#include <iterator>
namespace {
// a log is printed after the row of its model, what does not fit stays in the file
const size_t LOG_DUMP_MAX_BYTES = 64 * 1024;
// one worker thread runs one model and the log callback fires on the thread that logged the
// message, so the file of a model is held by the thread that runs it
thread_local FILE * t_file;
char level_char(enum ggml_log_level level) {
switch (level) {
case GGML_LOG_LEVEL_DEBUG: return 'D';
case GGML_LOG_LEVEL_INFO: return 'I';
case GGML_LOG_LEVEL_WARN: return 'W';
case GGML_LOG_LEVEL_ERROR: return 'E';
case GGML_LOG_LEVEL_CONT: return 'C';
default: return '?';
}
}
// every line of a model log carries its level, so that a failure can be looked up with a grep
void test_log_level(enum ggml_log_level level) {
if (level != GGML_LOG_LEVEL_NONE) {
fprintf(t_file, "%c ", level_char(level));
}
}
} // namespace
void test_log_capture(const std::string & path) {
if (t_file) {
fclose(t_file);
t_file = nullptr;
}
if (path.empty()) {
return;
}
t_file = fopen(path.c_str(), "w");
if (!t_file) {
fprintf(stderr, "%s: failed to open '%s'\n", __func__, path.c_str());
}
}
void test_log_callback(enum ggml_log_level level, const char * text, void * user_data) {
if (!t_file) {
common_log_default_callback(level, text, user_data);
return;
}
// gate the capture the way the console would be - the debug chatter of the graph builder is
// orders of magnitude more than everything else, a capture never holds less than info
if (common_log_get_verbosity(level) <= std::max(common_log_get_verbosity_thold(), LOG_LEVEL_INFO)) {
test_log_level(level);
fputs(text, t_file);
}
}
void test_log_add(enum ggml_log_level level, const char * fmt, ...) {
va_list args;
va_start(args, fmt);
if (t_file) {
test_log_level(level);
vfprintf(t_file, fmt, args);
} else if (level == GGML_LOG_LEVEL_ERROR) {
// without a capture the run is followed on the console, a detail line has no place there
vfprintf(stderr, fmt, args);
fflush(stderr);
}
va_end(args);
}
bool test_log_dump(const std::string & path) {
std::ifstream f(path, std::ios::binary);
if (!f) {
fprintf(stderr, "failed to read the captured log '%s'\n", path.c_str());
return false;
}
const std::string text((std::istreambuf_iterator<char>(f)), std::istreambuf_iterator<char>());
f.close();
size_t skip = 0;
if (text.size() > LOG_DUMP_MAX_BYTES) {
skip = text.find('\n', text.size() - LOG_DUMP_MAX_BYTES);
skip = skip == std::string::npos ? 0 : skip + 1;
}
if (skip > 0) {
const size_t n_all = std::count(text.begin(), text.end(), '\n');
const size_t n_shown = std::count(text.begin() + skip, text.end(), '\n');
fprintf(stdout, "... %zu of %zu lines omitted, full log: %s\n", n_all - n_shown, n_all, path.c_str());
}
fwrite(text.data() + skip, 1, text.size() - skip, stdout);
fflush(stdout);
return skip > 0;
}
std::string test_log_path(const std::string & logs_dir, const std::string & model_name) {
const std::string file = "test-llama-context." + model_name + ".log";
return logs_dir.empty() ? file : (std::filesystem::path(logs_dir) / file).string();
}