rml / The rml-graph IR

The rml-graph IR

rml-graph is the contract between the Python exporters and the C++ runtime: a model as a flat, topologically ordered list of ops over typed values, with its weights in a safetensors file that is mapped, not read. This page covers the format, the op set, and the two passes that check a graph before anything runs: the loader and shape inference.

On this page
  1. Two files
  2. The JSON layout
  3. Worked example: the tiny MLP
  4. Loader validation
  5. The op set
  6. Shape inference
  7. Tests

In short: a model is <stem>.json plus <stem>.safetensors. The JSON lists values (a name, a dtype of f32 or i32, and a static shape), the graph's inputs and outputs, constants bound to stored tensors or inline data, and nodes in topological order. LoadGraph maps the weights and ParseGraph checks structure: every value is defined exactly once and before it is read, and every constant matches its recorded type. Op semantics come second: InferTypes compiles each node against its inputs' types and rejects any op, arity, attribute or recorded shape that disagrees with rml's own rules. A graph that passes both never aborts at run time.

The format sits in the middle of the inference stack: the exporters write it, and the executor runs it. The idea of a model as a dataflow graph is on computation graphs, which the header of graph.h links.

Two files

A model is two files that share a stem (graph_ir.md L27–30): <stem>.json holds the graph and <stem>.safetensors holds every weight and large constant. The format is inference only, with no backward pass (L3–6), and both export routes, direct and StableHLO, write the same format (L8–25).

LoadGraph(stem) reads the JSON into a string, opens the safetensors file through io::SafetensorsReader only if it exists, and hands both to ParseGraph, prefixing any error with the JSON path. A stored constant comes back from SafetensorsReader::Get as a tensor view onto the mapped bytes that shares ownership of the mapping, so the weights stay mapped for as long as the graph holds them, even after the reader itself is gone. The one exception is a tensor misaligned for its element type, which is copied rather than read with unaligned loads. How the mapping works is on Model files.

From two files to a checked graph The JSON file is read into a string and the safetensors file is mapped. ParseGraph checks structure and binds each constant either to a view of the mapping or to inline data, producing a Graph of values, inputs, outputs, constants and nodes. Executor::Create then runs InferTypes, which compiles every node with CompileOp against its inputs' types and compares each inferred output with the recorded one. Only a graph that passes both checks reaches the executor. <stem>.json read into a string <stem>.safetensors mmap, read-only ParseGraph: structure format and version · ids in range · defined once, before use constants: a view of the mapping, or inline data, matching their type Graph values · inputs · outputs · constants · nodes (topological) InferTypes: semantics, in Executor::Create CompileOp per node: known op · arity · attributes · input types inferred dtype and shape must equal the recorded ones every failure is a Status naming the value or node; nothing aborts
Structure is checked when the graph is parsed, and op semantics when an executor is built from it (graph.h L8–10).

The JSON layout

The top-level object carries "format": "rml-graph", "version": 1, an optional producer string, and the arrays values, inputs, outputs, constants and nodes (graph_ir.md L34–54). The rules (L56–63):

  • A value's id is its index in values, and every value is produced exactly once: as a graph input, a constant, or a node output.
  • Shapes are static. Exporters trace at a fixed batch size, which is why each zoo model ships one fixture per size.
  • dtype is "f32" or "i32". Exporters turn booleans into f32 masks of 0 and 1, and 64-bit integers into i32.
  • Nodes are in topological order: a node may only read values defined before it.

In C++ the parsed graph is three small structs (graph.h L28–51):

ItemType/SignatureMeaningSource
ValueInfoname, DType dtype = kF32, Shape shapeOne entry of values. Unnamed values get "v<id>".graph.h L28
Nodeop, inputs, outputs: value ids, Json attrsOne op application. attrs stays as JSON until the op compiles it.L37
Graphproducer, values, inputs, outputs, constants: (id, Tensor) pairs, nodesThe whole model; nodes in topological order.L44
ParseGraphExpected<Graph>(string_view json, const io::SafetensorsReader* weights)Parses and checks structure. weights may be null when no constant names a stored tensor.L53
LoadGraphExpected<Graph>(const string& stem)Reads <stem>.json and maps <stem>.safetensors when it exists.L56
TypeStringstring(DType, const Shape&)"f32 [2, 3]", used in every type error.L34

Worked example: the tiny MLP

The committed PyTorch MLP fixture, testdata/torch_mlp.json, has 15 values, 6 constants and 8 nodes. Its input x is f32 [2, 12] (batch 2) and its output is value 14, f32 [2, 4]. The first layer reads:

{"name": "x",  "dtype": "f32", "shape": [2, 12]}       value 3, the graph input
{"name": "v7", "dtype": "f32", "shape": [12, 16]}      value 4, constant "const.4"
{"op": "matmul", "inputs": [3, 4], "outputs": [5]}     [2, 12] x [12, 16] -> [2, 16]
{"op": "add",    "inputs": [5, 0], "outputs": [6]}     + layers.0.bias [16], broadcast
{"op": "relu",   "inputs": [6],    "outputs": [7]}

The pattern repeats for the second and third layers, the last ending in an add with no activation. Each weight is stored in the safetensors file under a const.<id> name, and the biases keep their PyTorch parameter names. Shape inference re-derives every recorded shape here: matmul gives [2, 16] from NumPy rules, the bias add broadcasts [16] against it, and relu keeps its input's shape.

Loader validation

ParseGraph parses with nlohmann/json's non-throwing mode, so malformed text is a Status, not an exception. It then checks, in order:

CheckErrorSource
The text is a JSON object with format == "rml-graph"InvalidArgumentL90–96
version == 1UnsupportedL97–99
values, inputs, outputs and nodes are arrays (constants is optional)InvalidArgumentL100–104
Each value has dtype f32 or i32 and a shape of non-negative integers whose dimensions and element count stay at or below 240InvalidArgumentL23–45, L110–124
Every id in inputs, outputs and node lists is in [0, number of values)InvalidArgumentL47–59
No value is defined twice: graph inputs first, then constants, then node outputsInvalidArgument, naming both definersL126–137
A constant names a stored tensor (which needs a weights file) or carries inline data with exactly as many numbers as its shape, integers for i32InvalidArgument, or NotFound from the readerL139–162, L61–81
A constant's tensor has exactly its recorded dtype and shapeInvalidArgumentL163–168
Each node has an op string, inputs and outputs, reads only values already defined, and has object attrs if anyInvalidArgumentL171–196
Every graph output is defined somewhereInvalidArgumentL198–204

Because nodes are read in file order and a read of an undefined value fails, topological order is enforced by the same pass that parses, with no separate sort. The loader does not look at op names: graph_ir.md lists unknown ops and bad attributes among the loader's rejections (L65–68), and in the code those checks run in the second pass, when an executor is built.

The op set

Binary ops broadcast with NumPy rules, axes may be negative, and unless stated otherwise inputs and outputs are f32 with one output per op (graph_ir.md L72–74). The full table, with every attribute, is graph_ir.md L76–104. In ops.cc the set is three tables, and IsKnownOp is membership in any of them:

FamilyOpsAttributesSource
Unary elementwiseneg abs exp log sqrt rsqrt tanh sigmoid relu silu erf sin cosnoneUnaryOps
Binary, broadcastingadd sub mul div maximum minimum; comparisons greater greater_equal less less_equal equal giving an f32 masknoneBinaryOps
Other elementwisegelu, pow (scalar exponent), clamp, where (three inputs)approximate; exponent; min, maxOtherOps
Reductionsreduce_sum reduce_mean reduce_max reduce_min, argmax (i32 out), top_k (two outputs: values and i32 indices)axes, keep_dims; axis; kL863–867, L879
Normalizationsoftmax, log_softmax, layer_norm (weight and bias optional), rms_normaxis; num_axes, epsL868, L881
Linear algebra and attentionmatmul (NumPy semantics), sdpa (q, k, v and an optional mask)causal, scale (default 1/√D)L870, L887
Convolution and poolingconv2d (NCHW, OIHW, square kernel, optional bias), max_pool2d, avg_pool2dstride, padding; kernel, strideL883–885
Shape and data movementreshape transpose broadcast_to slice concat squeeze unsqueeze gather iota convertshape; perm; axes, starts, ends, steps; axis; dtypeL871–878, L880, L886

Only the shape and data-movement ops take i32 values (gather takes them as its index); everything else is f32 only, which OtherOps records per op in an OpSpec next to its minimum and maximum input count (ops.cc L849–854). The set is small on purpose. Exporters lower framework ops onto it, so JAX's dot_general becomes transpose + reshape + matmul and TensorFlow's NHWC convolutions become transpose + conv2d + transpose, and an op an exporter cannot lower fails the export with the framework's op name (graph_ir.md L106–110).

Shape inference

InferTypes(graph) starts from the recorded types of every value, then walks the nodes in order. For each node it gathers its inputs' types, calls CompileOp, and compares every inferred output with the type the exporter recorded. The first disagreement fails with the node's index and op, for example "node 3 (matmul) produces f32 [2, 16] but v12 is recorded as f32 [2, 8]" (L27–31). Names are kept, so the returned vector is the graph's values with every type confirmed (shape_inference.h L13–17). Executor::Create calls it first, so no executor exists for a graph whose shapes do not check.

Shape rules and kernels live together. CompileOp returns a CompiledOp: the output types and the Kernel that will run the node. One function therefore decides both what an op produces and how it is computed, so they cannot drift apart. CompileAny dispatches on the op name: unary ops need one f32 input and keep its shape, binary ops need two and take the broadcast shape (Broadcast), and the rest go through their OpSpec, which checks arity and dtype before the op's own compiler runs. An unknown name is Unsupported. CompileOp then requires the node to list exactly as many outputs as the op produces (L922–928).

OpRuleSource
attributesAn Attrs reader returns typed values or an error naming the op and key ("attribute "axes" must be an array of integers"). Axes are checked against the rank and normalized from negative.L44–100
reduce_*Each axis once; reduced axes kept as 1 or dropped by keep_dims; max and min reject an empty axis.L322–345
matmulA 1-D operand is promoted (a row for a, a column for b) and the added axis removed from the result; inner dimensions must match; batch axes broadcast.L425–446
reshapeNon-negative dimensions with the same element count.L448–459
conv2dx [N, C, H, W], w [O, C, k, k], bias [O]; stride ≥ 1, padding ≥ 0, kernel fits the padded image; out [N, O, (H + 2p − k)/s + 1, (W + 2p − k)/s + 1].L634–657
gatherf32 x and i32 index; out is x's shape with the gathered axis replaced by the index's shape.L675–700
sdpaq [..., Tq, D], k [..., Tk, D], v [..., Tk, Dv] of one rank; batch axes broadcast; a mask must broadcast to [Tq, Tk].L749–780
top_kRank ≥ 1 and 0 ≤ k ≤ the last axis; two outputs of the same shape, f32 values and i32 indices, ties to the lower index.L802–839

Static checking leaves one thing for run time: data the loader cannot see. A Kernel fails only on such data (ops.h L22–25), and the one case is a gather index outside its axis, which returns OutOfRange instead of reading out of bounds (ops.cc L689–699). The kernels themselves, the constant-weight packing CompileOp does for matmul, and the fast-path layouts belong to Executing a graph.

Tests

FileTestsWhat they pin down
graph_test.cc7, one a sweep of 14 casesValues, constants and nodes parse; unnamed values are named by id; 14 malformed graphs are rejected (not JSON, wrong format or version, missing nodes, a bad dtype, a negative dimension, an id out of range, a value defined twice or read before definition, an output never defined, inline data of the wrong length, a tensor constant without a weights file, a node without an op, non-object attrs); constants bind to a real safetensors file, and a stored shape that differs, a missing tensor and a missing file are errors.
executor_test.cc28 in the file; shape inference in InferTypesReturnsEveryValue and a sweep of 14 Create errorsThe Create sweep rejects an unknown op, a recorded shape or dtype that disagrees, wrong arity, a missing attribute, a duplicate reduce axis, a matmul inner mismatch, a bad permutation, a reshape that changes the count, a zero slice step, a conv channel mismatch, a layer norm weight without bias, an sdpa key-width mismatch and a concat off-axis mismatch. Separate tests cover top_k with k past the axis, a node listing the wrong number of outputs, and an out-of-range gather index. The op-by-op tests are on Executing a graph.
golden_test.cc2A sweep over every committed fixture in testdata/: each loads, builds an executor (so passes shape inference) and matches its framework's golden outputs; a second test requires a fixture for every framework and zoo model. How the fixtures are made is on Exporters and the model zoo.