rml / Build and testing

Build and testing

How rml is built and checked: one Bazel module with five dependencies, macros that put every target under -Werror, GoogleTest and Google Benchmark, death tests for programmer errors, golden tests that hold the decoder to a numpy reference, the Python tools that write every fixture, and the CI jobs that run it all.

On this page
  1. The Bazel module
  2. Configs
  3. The rml_cc_* macros
  4. GoogleTest and Google Benchmark
  5. Death tests
  6. Golden LLM tests
  7. Toy models: every family, every format
  8. Real checkpoints
  9. The fixture tools
  10. CI

In short: MODULE.bazel pulls in rules_cc, platforms and nlohmann/json, plus GoogleTest and Google Benchmark as dev dependencies; every target goes through the rml_cc_* macros in rml/copts/rml.bzl, which add the warning flags with -Werror and link gtest_main or benchmark_main. At the pin there are 125 test files with 837 TEST, TEST_F and TEST_P definitions, 96 death tests and seven benchmark programs. The LLM decoder is checked against tools/llm_reference.py, a numpy forward pass that follows Hugging Face's modeling code: on committed two-layer toy models for all eight families in safetensors and GGUF, and on five downloaded real checkpoints when they are present. CI builds and tests everything on macOS (Clang: opt, ASan, UBSan) and Ubuntu (GCC and Clang opt, GCC ASan), runs the SmolLM2-135M golden on both, and checks formatting. Python is never needed to build or test; it only regenerates fixtures.

The rules this page traces are in rml's AGENTS.md: Bazel with bzlmod and no CMake; no ML dependencies; Python only in tools/; a scalar reference and a comparison test for every optimized kernel; GoogleTest and Google Benchmark as the only test frameworks; fixed seeds; and every differentiable op with a numerical gradient check (AGENTS.md L50–67, L127–153). How the kernel references are tested is on Kernels; the gradient checks are on Autograd.

The Bazel module

ItemValueMeaningSource
modulerml, version 0.1.0The whole repository is one bzlmod module.MODULE.bazel L1
rules_cc, platforms0.1.1, 0.0.11Bazel's C++ rules and platform constraints.L3–4
nlohmann_json3.12.0.bcr.2The one runtime library: safetensors headers, model configs, rml-graph files.L5
google_benchmark, googletest1.9.4, 1.15.2, dev_dependency = TrueTest-only, so a project depending on rml does not inherit them.L6–7
Bazel version8.4.2Pinned in .bazelversion; bazelisk reads it locally and on CI runners..bazelversion, ci.yml L35

The README's dependency table lists each with its license, all permissive (README.md L301–313); NumPy, PyTorch, JAX, TensorFlow and torch-mlir appear there too but are used only by scripts in tools/ and are never built or linked.

Configs

ConfigFlagsSource
always--enable_bzlmod, -std=c++20 for target and host, --strip=never, convenience symlinks under .bazel/, --test_output=errors.bazelrc L1–7
opt-c opt --copt=-O3, for benchmarks and inferenceL9–11
asan-c dbg, -fsanitize=address, frame pointers kept; tests run with allocator_may_return_null=1L13–17
ubsan-c dbg, -fsanitize=undefined, -fno-sanitize-recover=all so any finding fails the testL19–21
tsan-c dbg, -fsanitize=thread; not in the CI matrixL23–25
cloudA GitHub mirror of the Bazel Central Registry, lockfile off, for sandboxes that cannot reach bcr.bazel.buildL27–29
user.bazelrcOptional, gitignored, imported last (a cloud session puts common --config=cloud and module overrides there)L31

The rml_cc_* macros

No BUILD.bazel in rml calls cc_library directly. The four macros in rml.bzl add one flag list to first-party code only, so third-party headers are not held to it:

RML_COPTS = ["-Wall", "-Wextra", "-Wpedantic", "-Wshadow",
             "-Wconversion", "-Wno-sign-conversion", "-Werror"]
MacroWrapsAddsSource
rml_cc_librarycc_libraryRML_COPTSL15–16
rml_cc_binarycc_binaryRML_COPTSL18–19
rml_cc_testcc_testRML_COPTS; srcs defaults to <name>.cc; links @googletest//:gtest and gtest_mainL21–32
rml_cc_benchmarkcc_binaryRML_COPTS; srcs defaults to <name>.cc; links benchmark and benchmark_main; testonly = TrueL34–49

The flags are the reason so much of rml's code carries explicit static_casts: -Wconversion rejects implicit narrowing (an int64_t into an int, a double into a float), while -Wno-sign-conversion leaves signed–unsigned mixing to review. Each package also turns on the layering_check and parse_headers features, so a file may only include headers of targets it lists in deps, and every header must compile on its own (AGENTS.md L121–125; for example examples/mlp/BUILD.bazel L3–9). Formatting is Google style at 100 columns (.clang-format), checked in CI.

GoogleTest and Google Benchmark

Tests sit next to the code they test as foo_test.cc and live in namespace rml { namespace { … } }. The house style prefers EXPECT_* so one run reports every failure, keeps ASSERT_* for when the rest cannot run, compares floating point with EXPECT_FLOAT_EQ or EXPECT_NEAR and a stated tolerance, sweeps shapes and dtypes with TEST_P, and seeds every randomized test (AGENTS.md L127–140). At the pin:

CountValueWhere
Test files12593 under rml/, 32 under examples/ (see Examples)
TEST, TEST_F, TEST_P definitions837Parameterized suites run once per value, so more cases run than this
INSTANTIATE_TEST_SUITE_P34Under rml/; for example every decoder family × file format
Death-test assertions96 in 40 filesMost in tensor_test.cc (11) and ops_test.cc (8)
Benchmark programs7thread_pool, gemm, dot, attention, decoder, graph executor and graph kernels

Benchmarks are Google Benchmark programs run with bazel run --config=opt //rml/<module>:<name>; they are testonly binaries, so bazel test //... builds but never runs them. Their numbers are recorded in docs/kernels.md and quoted on Kernels, Linear algebra and Lowering and fast CPU.

Death tests

Errors cross API boundaries as Status or Expected<T>; a programmer error, such as a shape mismatch or a bad index, is an RML_CHECK that prints and aborts instead of throwing, so it is safe on hot paths (check.h L4–6, check.cc L9–13). RML_DCHECK compiles to nothing under NDEBUG (check.h L23–29). Death tests hold each check to its message:

EXPECT_DEATH(t.data<std::int32_t>(), "type does not match");
EXPECT_DEATH(Matrix2x3().Reshape(Shape{4, 2}), "number of elements");
EXPECT_DEATH(tt.View(Shape{6}), "contiguous");

(tensor_test.cc L52–89.) Suites made only of death tests are named *DeathTest, as GoogleTest recommends so they run first, for example DropoutDeathTest and ScheduleDeathTest. How Status and the checks divide the work is on Errors, memory and threads.

Golden LLM tests

The decoder cannot be checked against a hand-computed answer, so it is checked against an independent implementation: tools/llm_reference.py, a numpy forward pass that follows the Hugging Face transformers modeling code for every family rml loads, takes weights named as in a Hugging Face checkpoint, and runs in float64 by default (L1–7, L108). Two tools feed it, and two tests read what they write.

How the decoder's golden tests are made On the left, make_llm_fixtures.py builds two-layer toy models for eight families and writes each as a Hugging Face directory and a GGUF file into rml/llm/testdata/models, with logits.json from llm_reference.py in float64; decoder_test compares every prompt position's logits. On the right, make_real_model_goldens.py reads a downloaded checkpoint from models/, runs the same reference in float32 and writes rml/llm/testdata/goldens/name.json with tokenizer ids, the chat prompt, top-8 logits and an 8-token greedy continuation; real_model_test checks them when RML_MODELS_DIR holds the checkpoint. Both scripts share llm_reference.py in the middle. llm_reference.py numpy forward, follows HF modeling code float64 float32 make_llm_fixtures.py 8 toy families, random exact weights make_real_model_goldens.py a checkpoint in models/<name> testdata/models/ (committed) HF dir (bf16) + GGUF (f16), llama Q8_0 logits.json: every position testdata/goldens/ (committed) tokenizer ids, chat prompt, top-8 logits, 8 greedy tokens decoder_test 17 cases, tolerance 2e-4 (Q8_0 3e-2) real_model_test 5 models, skips if not downloaded
Two routes from one reference. The weights themselves never enter the repository for real models: only the goldens do, and the test skips until $RML_MODELS_DIR holds the checkpoint.

Toy models: every family, every format

make_llm_fixtures.py builds a two-layer model a few thousand parameters in size per variant (vocab 64, hidden 32, four heads, two KV heads) and saves it twice: as a Hugging Face directory with bf16 safetensors and as a GGUF laid out the way llama.cpp's converter lays it out (f16 matrices, f32 vectors, q/k rows permuted for Llama-family models, experts stacked, Gemma's norms stored as 1 + w). The weights are exactly representable in both bf16 and f16, so both files hold the same model; the llama variant also gets a Q8_0 GGUF whose reference uses the dequantized weights (L2–16, L30–44). The eight variants are llama, qwen2, qwen3, mistral, mixtral, qwen3_moe, gemma3 and olmoe (L47–101).

DecoderGoldenTest runs 17 cases, each family from both files plus the Q8_0 GGUF, twice: once as a whole-prompt prefill comparing every position's logits, and once as a three-token prefill followed by one token at a time through a cache with four-token pages, so the sequence spans several pages. The bound is tol + tol · |expected| with tol = 2e-4, loosened to 3e-2 for Q8_0 because rml dots quantized weights against int8-rounded activations, as llama.cpp does, which the float reference does not model (decoder_test.cc L38–98).

Real checkpoints

make_real_model_goldens.py takes a directory name under models/ and a chat format, and writes rml/llm/testdata/goldens/<name>.json (L130–168):

FieldHoldsChecked by
tokenizerHugging Face tokenizers ids for assorted texts (punctuation, numbers, accents, CJK, emoji with a skin-tone modifier, code, whitespace runs), no special tokensTokenizerMatchesHuggingFace
chatA short conversation, its prompt text in the model's format, and the prompt's ids with the BOS the tokenizer addsChatPromptMatchesTemplate
logitsThe top 8 logits at every prompt position from the float32 referenceLogitsAndGreedyMatchReference, within 2e-3
greedyThe reference's 8-token greedy continuation

The reference runs in float32 here, reading bf16 and f16 weights lazily, so checkpoints too large to hold in float32 still fit (L102–117). The script knows five chat formats, chatml, zephyr, tulu, llama3 and gemma (L53–83); the committed goldens use four of them:

GoldenChat formatWhere it runs
SmolLM2-135M-InstructchatmlCI on macOS and Ubuntu, and locally
TinyLlama-1.1B-Chat-v1.0zephyrLocally, with the checkpoint downloaded
Qwen3-0.6BchatmlLocally
gemma-3-1b-itgemmaLocally
OLMoE-1B-7B-0924-InstructtuluLocally

llama3 waits for the Llama 3.1 8B golden, which is planned for the Mac. The worst errors each model reached and the status of each are on Models and memory; the test cases are listed at real_model_test.cc L130–134.

The fixture tools

Everything under testdata/ is generated by a script in tools/ and committed when small, so the C++ tests never run Python (AGENTS.md L152–153). Each script checks rml against an independent implementation:

ScriptWritesReferencePage
make_llm_fixtures.pyrml/llm/testdata/models/llm_reference.py (float64), gguf-pyThe decoder
make_real_model_goldens.pyrml/llm/testdata/goldens/llm_reference.py (float32), HF tokenizersModels and memory
make_quant_fixtures.pyrml/quant/testdata/gguf_quants.jsongguf-py's (de)quantizationQuantization
make_tokenizer_fixtures.pySmall tokenizers and encodings in rml/llm/testdata/HF tokenizers, sentencepieceTokenizers
make_pretokenizer_fixtures.pyrml/llm/testdata/pretokenizer.jsonPython's regex on each family's pattern
make_unicode_tables.pyrml/llm/unicode_tables.cc (code, not a fixture)unicodedata
make_sklearn_fixtures.pyrml/classical/testdata/sklearn.jsonscikit-learnClassical models
make_graph_fixtures.py, make_tiny_net.py, make_tiny_models.pyrml/graph/testdata/, examples/tiny_*/testdata/PyTorch, JAX and TensorFlow outputsExporters and the model zoo
cross_framework_test.py, bench_frameworks.pyAgreement checks; docs/bench/frameworks.mdThe frameworks themselvesBenchmarks against the frameworks
download_model.pymodels/<name>/ (gitignored)—Models and memory

CI

One workflow, .github/workflows/ci.yml, runs on every push to main and every pull request, cancelling a superseded run on the same ref (L3–10). It has three jobs:

rml's CI jobs Three parallel jobs. test runs bazel test //... in six configurations: macos-14 with clang in opt, asan and ubsan, and ubuntu-24.04 with gcc opt, clang opt and gcc asan. real-models runs on macos-14 and ubuntu-24.04: it downloads SmolLM2-135M-Instruct at a pinned revision, caches it, and runs real_model_test. format runs clang-format in dry-run mode over every tracked header and source file. push to main · pull request test: bazel test //... macos-14 · clang · opt macos-14 · clang · asan macos-14 · clang · ubsan ubuntu-24.04 · gcc · opt ubuntu-24.04 · clang · opt ubuntu-24.04 · gcc · asan real-models macos-14, ubuntu-24.04 download SmolLM2-135M at a pinned revision real_model_test --config=opt format ubuntu-24.04 clang-format --dry-run --Werror
The three jobs at the pin (ci.yml L12–70). The macOS runners are the only place the NEON and sdot paths are compiled and tested.
JobWhat it runsCachingSource
testbazel test --config=<config> //... on six OS × compiler × config combinations, fail-fast: false so one failure does not cancel the rest; CC picks the compiler~/.cache/bazel, keyed on OS, compiler, config and the hash of MODULE.bazel and .bazelversionL13–37
real-modelstools/download_model.py for SmolLM2-135M-Instruct at revision 12fd25f, then real_model_test with RML_MODELS_DIR setThe Bazel cache and the models/ directory, keyed on the revisionL39–63
formatclang-format --dry-run --Werror over every tracked *.h and *.cc—L65–70

What CI does not cover: the tsan config is defined but has no job; the four larger real-model goldens run only where someone has downloaded the checkpoints; and benchmarks are never run in CI, so every number on these pages comes from a developer's run recorded in rml's docs. rml's guidance says to run --config=asan and --config=ubsan locally before pushing anything that touches memory (AGENTS.md L140), and that a change that could not be run in the Linux development container, such as NEON, Accelerate or Metal code or a large-model test, is verified on CI's macos-14 runners or on the Mac and noted in docs/progress.md (AGENTS.md L101–103).