rml / Executing a graph

Executing a graph

rml::graph::Executor turns a checked rml-graph into a list of steps once, then runs them in order on every call. This page covers how it is built, how values live and die during a run, how each op maps onto rml's kernels, the rml-graph-run tool and the executor benchmark.

On this page
  1. The interface
  2. Building the step list
  3. A run
  4. Value lifetimes
  5. How ops map onto kernels
  6. Views
  7. rml-graph-run
  8. The executor benchmark
  9. Tests

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

ItemType/SignatureMeaningSource
ExecutorOptionsbool fuse = trueFuse runs of elementwise nodes into single tiled programs.executor.h L23
Createstatic Expected<Executor>(Graph, ExecutorOptions = {})Checks and compiles the graph; takes ownership of it.L30
RunExpected<vector<Tensor>>(span<const Tensor> inputs) constInputs follow graph().inputs and must match their recorded dtype and shape. Outputs are contiguous.L32
RunProfiled(inputs, vector<double>& step_ms) constRun, adding each step's wall time in milliseconds to step_ms[i].L36
step_info()vector<StepInfo>: label, nodesWhat each step runs: one node's op, or "fused(...)", and how many graph nodes it covers.L41
Step (private)info, Kernel kernel, inputs, outputs, releaseOne 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:

  1. Check. InferTypes confirms every op, attribute and recorded type (L57), so an executor never exists for a graph that would fail later.
  2. Pin. Graph outputs and constants are marked as never released (L60–68).
  3. Fuse. With fuse on, FindFusionGroups picks runs of two or more consecutive elementwise nodes with one output shape, connected or not (L70–73). Each group becomes one step labelled fused(...) with a kernel from MakeFusedKernel, whose outputs are every member value read outside the group, and the nodes it covers are skipped (L105–114).
  4. Fold conv biases. Also with fuse on, a bias-free conv2d whose only reader is the next node, an add of an f32 per-channel constant shaped [O], [O, 1, 1] or [1, O, 1, 1], becomes one step labelled conv2d+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.
  5. 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).
  6. Plan releases. While building, each step overwrites last_reader for every value it reads. Afterwards each value that is read and not pinned is added to the release list of its last reader (L129–133).

A run

RunImpl, behind both Run and RunProfiled:

  1. Installs the executor's pool with a ScopedBufferPool for the length of the run (L156). From here every Tensor::Empty on this thread draws from that pool (BufferPool).
  2. 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).
  3. For each step: gathers its inputs, calls the kernel, stores its outputs, then resets every value in its release list 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).
  4. 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.

One run of a two-step program The test graph from executor_test: x times a constant W, plus a constant b, relu, plus x. Create turns it into two steps: a matmul step, and one fused step covering add, relu and add. During a run, after the matmul step the value x·W is live; the fused step reads it, b and x, writes the output, and releases x·W and x. Constants W and b and the output are pinned and never released. Released buffers return to the executor's BufferPool for the next run. graph: y = relu(x·W + b) + x, with W and b constants x [1, 2] W [2, 2] pinned b [2] pinned step 0: matmul writes x·W step 1: fused add, relu, add writes y [1, 2], pinned then releases x·W and x executor's BufferPool released buffers wait here; the next run's allocations of the same size take them x·W never leaves the executor; the add, relu and add between it and y run inside one fused step, so their intermediates are tile buffers, not tensors
The graph is the one in executor_test.cc L430–458, run twice to check that a second run over reused buffers gives the same answer.

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.

OpsKernelNotesSource
13 unary ops, gelukernels::UnaryFastVectorized and threaded; gelu picks its tanh or erf form at compile time.L127–137, L276
powUnaryFast or rml::PowExponents 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 minimumkernels::BinaryFastWhen 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, argmaxrml/core/ops.hThe readable reference ops, which broadcast and stride generally.L250, L304–317
reduce_*kernels::ReduceRowsFast or core reductionsReducing 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_softmaxkernels::SoftmaxFast or coreFast along the last axis only.L410–423
layer_normkernels::LayerNormFastRows of the product of the last num_axes dimensions.L606–632
rms_norman inline loopAccumulates the squares in double per row.L702–729
matmullinalg::MatMul, or MatMulPackedNumPy 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
conv2dIm2ColPositions + linalg::GemmOne 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_pool2dkernels::Pool2dFastEach N·C plane independently.L221–229
sdpaMatMul, SoftmaxFast, MatMulScores q kᵀ · scale (+ mask) are materialized; a causal mask sets j > i + (Tk − Tq) to −∞, aligned to the last query.L780–800
top_kan inline sort per rowDescending, ties to the lower index; values and i32 indices.L802–839
gatherrml::IndexSelectChecks every index first and returns OutOfRange rather than reading out of bounds.L675–700
concat, iota, convertrml::Concat, a fill loop, Tensor::ToCopies.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:

OpTensor callCopies whenSource
reshapeReshape(out)The input is not contiguous (tensor.cc L364–366).L448
transposePermute(perm)Never.L461
broadcast_toExpand(out)Never; broadcast axes get stride 0.L481
sliceSlice(axis, start, end, step) per cutNever; starts and ends are clamped to the axis at compile time.L492
squeeze, unsqueezeSqueeze, UnsqueezeNever.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).

FlagDefaultEffectSource
STEMrequiredNames STEM.json and STEM.safetensors.main.cc L28–40
--inputs FILEzerosA safetensors file holding input.0, input.1, …; without it every input is zeros of its recorded type.
--checkoffCompares outputs with output.0, … in the --inputs file and reports max_abs_error; requires --inputs.
--outputs FILEnoneWrites the outputs as output.0, ….
--bench N0Times N runs after the warmup.
--warmup N10Untimed runs before timing.
--threads Nall coresSets RML_NUM_THREADS before the process-wide pool first reads it.
--profile N0Times each step over N runs and adds profile to the JSON.
--no-fusionoffRuns 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

FileTestsWhat they pin down
executor_test.cc28Every 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.cc7A 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.cc2 + 2Every committed fixture through the executor against its framework, and every route of a model against the others; see Exporters and the model zoo.