In short: Executor::Create runs shape inference, optionally groups runs of elementwise nodes
into fused steps, compiles every other node into a kernel closure, and records for each step which values
it is the last to read. Run checks the inputs against their recorded types, binds the
constants, and calls the steps in order, dropping each value right after its last reader. Buffers freed
that way go to the executor's BufferPool, so the next run reuses them instead of allocating.
Shape ops return views, elementwise, softmax, norm and reduction ops run on vectorized threaded kernels,
matmul and conv2d go through the GEMM, and a constant matmul weight is
packed once at load.
The graph it runs is described on The rml-graph IR; what fusion and the fast
kernels do inside a step is on Lowering and fast CPU. The executor is a general
model runner, not the allocation-free LLM decode loop, which stays in rml/llm
(graph_ir.md L118–119).
The interface
| Item | Type/Signature | Meaning | Source |
|---|---|---|---|
ExecutorOptions | bool fuse = true | Fuse runs of elementwise nodes into single tiled programs. | executor.h L23 |
Create | static Expected<Executor>(Graph, ExecutorOptions = {}) | Checks and compiles the graph; takes ownership of it. | L30 |
Run | Expected<vector<Tensor>>(span<const Tensor> inputs) const | Inputs follow graph().inputs and must match their recorded dtype and shape. Outputs are contiguous. | L32 |
RunProfiled | (inputs, vector<double>& step_ms) const | Run, adding each step's wall time in milliseconds to step_ms[i]. | L36 |
step_info() | vector<StepInfo>: label, nodes | What each step runs: one node's op, or "fused(...)", and how many graph nodes it covers. | L41 |
Step (private) | info, Kernel kernel, inputs, outputs, release | One unit of a run. release lists the values whose last reader is this step. | L52 |
pool_ (private) | shared_ptr<BufferPool> | Where one run's intermediates go for the next run to reuse. | L66 |
Run is const and keeps every per-call value in locals, so the compiled steps are
never modified by a run.
Building the step list
Executor::Create does all the work that does not depend
on input data, once:
- Check.
InferTypesconfirms every op, attribute and recorded type (L57), so an executor never exists for a graph that would fail later. - Pin. Graph outputs and constants are marked as never released (L60–68).
- Fuse. With
fuseon,FindFusionGroupspicks runs of two or more consecutive elementwise nodes with one output shape, connected or not (L70–73). Each group becomes one step labelledfused(...)with a kernel fromMakeFusedKernel, whose outputs are every member value read outside the group, and the nodes it covers are skipped (L105–114). - Fold conv biases. Also with
fuseon, a bias-freeconv2dwhose only reader is the next node, anaddof an f32 per-channel constant shaped[O],[O, 1, 1]or[1, O, 1, 1], becomes one step labelledconv2d+add: the constant is passed as the conv's bias input, saving a pass over its output (FoldableConvBias, L86–104). JAX and TensorFlow export a conv bias that way; a conv whose output is a graph output or is read elsewhere stays two steps. - Compile. Every other node becomes one step whose kernel comes from
CompileOp, which is also told which inputs are graph constants so it can prepare them once (L115–127). - Plan releases. While building, each step overwrites
last_readerfor every value it reads. Afterwards each value that is read and not pinned is added to thereleaselist of its last reader (L129–133).
A run
RunImpl, behind both Run and
RunProfiled:
- Installs the executor's pool with a
ScopedBufferPoolfor the length of the run (L156). From here everyTensor::Emptyon this thread draws from that pool (BufferPool). - Checks the input count and each input's dtype and shape, naming the value on a mismatch, and binds the inputs and constants into a vector indexed by value id (L157–171).
- For each step: gathers its inputs, calls the kernel, stores its outputs, then resets every value in its
releaselist to an empty tensor (L175–185). A kernel error returns at once. With profiling on, the step's wall time is added to its slot (L186–190). - Returns each graph output made contiguous (L194–197), so a view never leaks out of the executor.
Value lifetimes
Values are reference-counted tensors, so resetting a slot frees the buffer only when nothing else holds it. A view keeps its base alive, and an output that is a view of an intermediate keeps that intermediate's storage until the output is copied at the end. Peak memory therefore follows the graph's live set, not its total size (graph_ir.md L115–117). When the last reference to a pooled buffer goes, the buffer returns to the executor's pool, sized by its byte count, and the next allocation of that size, in this run or the next, takes it back.
How ops map onto kernels
Each op's compiler in ops.cc returns the kernel as a closure over its attributes, so a run does
no attribute parsing or dispatch beyond one call per step. The fast paths work on contiguous f32 data: a
strided input is copied to contiguous first, which costs less than the generic strided loops
(ops.cc L125–126). The kernels named *Fast are in
rml/graph/kernels.h.
| Ops | Kernel | Notes | Source |
|---|---|---|---|
13 unary ops, gelu | kernels::UnaryFast | Vectorized and threaded; gelu picks its tanh or erf form at compile time. | L127–137, L276 |
pow | UnaryFast or rml::Pow | Exponents 2, 3, 0.5, −0.5 and −1 map to square, cube, sqrt, rsqrt and reciprocal; 1 returns the input; anything else uses the core op. | L287–302 |
add sub mul div maximum minimum | kernels::BinaryFast | When each operand is the full output, a row repeated per row (a bias), one value per row, or a scalar (FastOperandLayout). Any other broadcast falls back to the core op. | L964–989, L894 |
comparisons, where, clamp, argmax | rml/core/ops.h | The readable reference ops, which broadcast and stride generally. | L250, L304–317 |
reduce_* | kernels::ReduceRowsFast or core reductions | Reducing a trailing block of axes is a row reduction; otherwise one axis at a time with keep_dim, then a reshape. | L345–389 |
softmax, log_softmax | kernels::SoftmaxFast or core | Fast along the last axis only. | L410–423 |
layer_norm | kernels::LayerNormFast | Rows of the product of the last num_axes dimensions. | L606–632 |
rms_norm | an inline loop | Accumulates the squares in double per row. | L702–729 |
matmul | linalg::MatMul, or MatMulPacked | NumPy semantics through the GEMM. A constant 2-D f32 weight with enough rows that the GEMM would pack it every call is packed once at compile time. | L425–446, L929–944 |
conv2d | Im2ColPositions + linalg::Gemm | One product per image: filters [O, C k k] times patch columns gives [O, OH OW], already NCHW. Patches are built in runs of output positions that fit in 1 MB (half a typical L2); a 1×1, stride-1, unpadded convolution reads the image directly. Units run in parallel when there are enough for every thread. | L157–219 |
max_pool2d, avg_pool2d | kernels::Pool2dFast | Each N·C plane independently. | L221–229 |
sdpa | MatMul, SoftmaxFast, MatMul | Scores q kᵀ · scale (+ mask) are materialized; a causal mask sets j > i + (Tk − Tq) to −∞, aligned to the last query. | L780–800 |
top_k | an inline sort per row | Descending, ties to the lower index; values and i32 indices. | L802–839 |
gather | rml::IndexSelect | Checks every index first and returns OutOfRange rather than reading out of bounds. | L675–700 |
concat, iota, convert | rml::Concat, a fill loop, Tensor::To | Copies. | L528, L576 |
Views
Shape ops cost no copy (graph_ir.md L117–118). Each returns a tensor sharing its input's storage with new shape and strides, as described on Tensors and dtypes:
| Op | Tensor call | Copies when | Source |
|---|---|---|---|
reshape | Reshape(out) | The input is not contiguous (tensor.cc L364–366). | L448 |
transpose | Permute(perm) | Never. | L461 |
broadcast_to | Expand(out) | Never; broadcast axes get stride 0. | L481 |
slice | Slice(axis, start, end, step) per cut | Never; starts and ends are clamped to the axis at compile time. | L492 |
squeeze, unsqueeze | Squeeze, Unsqueeze | Never. | L551–573 |
A view is paid for by its reader: the next fast kernel copies a strided input to contiguous, and the executor makes graph outputs contiguous before returning them. A chain of shape ops therefore costs at most one copy, made by whichever op first needs contiguous data.
rml-graph-run
examples/graph_run builds //examples/graph_run:rml-graph-run
(BUILD.bazel L27), which runs one exported model and
prints one line of JSON. Its logic lives in a library, graph_run.h, so the test can drive it
without the command line (graph_run.h L4–64).
| Flag | Default | Effect | Source |
|---|---|---|---|
STEM | required | Names STEM.json and STEM.safetensors. | main.cc L28–40 |
--inputs FILE | zeros | A safetensors file holding input.0, input.1, …; without it every input is zeros of its recorded type. | |
--check | off | Compares outputs with output.0, … in the --inputs file and reports max_abs_error; requires --inputs. | |
--outputs FILE | none | Writes the outputs as output.0, …. | |
--bench N | 0 | Times N runs after the warmup. | |
--warmup N | 10 | Untimed runs before timing. | |
--threads N | all cores | Sets RML_NUM_THREADS before the process-wide pool first reads it. | |
--profile N | 0 | Times each step over N runs and adds profile to the JSON. | |
--no-fusion | off | Runs every node on its own. |
main times LoadGraph plus Executor::Create as load_ms, runs
once, then optionally checks, benchmarks and profiles
(main.cc L109–161). Time runs the warmup,
then times each call with steady_clock (graph_run.cc
L87–100), and Summarize reports iterations, mean, min, p10, median and p90 by nearest rank.
ProfileOps sums RunProfiled's step times by step label, so all matmul steps share one
row and each distinct fused program has its own, sorted slowest first with the node count each row covers
(L102–121). The JSON keys are model,
threads, load_ms, then max_abs_error, the latency fields and
profile when present (L123–144).
tools/bench_frameworks.py reads this line for its rml cells
(Benchmarks against the frameworks).
bazel run --config=opt //examples/graph_run:rml-graph-run -- \
$PWD/rml/graph/testdata/torch_cnn --inputs $PWD/rml/graph/testdata/torch_cnn.golden.safetensors \
--check --bench 100 --threads 1
That invocation is from rml's README.
The executor benchmark
executor_benchmark.cc registers one
Google Benchmark per .json in $RML_GRAPH_DIR, or in
rml/graph/testdata when it is unset, for example the models/graphs directory that
tools/bench_frameworks.py --export-only fills (L1–3). Each graph is loaded and compiled once at
registration, and graphs that fail to load are skipped. The timed loop calls Run on inputs filled
with 0.5, so it measures steady-state runs with the buffer pool warm, reported in microseconds
(L22–56). rml commits no results for it;
published timings come from rml-graph-run through the framework benchmark.
Tests
| File | Tests | What they pin down |
|---|---|---|
executor_test.cc | 28 | Every op against the core reference ops or a direct loop: unary and broadcasting binary sweeps, both GELU forms, pow and clamp, where, multi-axis reductions, argmax as i32, softmax and log-softmax, matmul batch and vector broadcasting, a constant weight packed once, shape ops, Python slicing, concat, iota, conv2d against a direct loop, pools, gather and its out-of-range error, layer norm over trailing axes, RMS norm, sdpa against its composition, top-k against a stable sort; a chain run twice that releases intermediates; and the Create and input-type errors listed on The rml-graph IR. |
graph_run_test.cc | 7 | A fixture checks against its golden outputs; written outputs read back as their own reference; the profile covers every node; inputs default to zeros; a missing reference is an error; nearest-rank quantiles; timing and the JSON report. |
golden_test.cc, cross_framework_test.cc | 2 + 2 | Every committed fixture through the executor against its framework, and every route of a model against the others; see Exporters and the model zoo. |