rml / Tensors and dtypes

Tensors and dtypes

rml/core is the layer everything else stands on. It defines seven dtypes, shapes of up to eight dimensions, broadcasting, and a reference-counted Tensor whose views share storage. It also holds the plain f32 ops that higher layers are tested against.

On this page
  1. The seven dtypes
  2. f16 and bf16 rounding
  3. Quantized blocks
  4. Shape and strides
  5. Broadcasting
  6. The row loop
  7. Storage and Tensor
  8. Views and copies
  9. Reference ops
  10. RML_CHECK
  11. Tests

In short: a Tensor has a shared Storage, an element offset, a Shape, Strides and a DType. Views such as transposes, slices and expands only change the offset, shape and strides. One loop, ForEachRow, walks strided data row by row for conversions and the ops; plain copies merge axes first and copy two-axis blocks, threaded when large. Broadcasting gives a stretched axis stride 0. The reference ops are f32-only, and quantized dtypes are stored but never computed on here. Contract violations abort through RML_CHECK.

This page covers rml/core apart from the allocator, status and thread pool, which are on Runtime. For the ideas behind it, see Tensors and shapes and Quantization in the ML models reference.

The seven dtypes

DType is a uint8_t enum. A table indexed by it holds each type's name, how many elements a storage block has, how many bytes that block takes, and two flags. Plain types have one element per block. The two GGML formats pack 32 elements into a block. Storage sizes are always computed from this table, so a tensor's byte size is numel / block_elems × block_bytes.

DTypeBlockMeaningSource
kF321 elem, 4 BThe only type the reference ops and autograd compute in.L18
kF161 elem, 2 BIEEE binary16 (Float16); the KV cache stores this.L19
kBF161 elem, 2 Bbfloat16 (BFloat16), the dtype of most safetensors checkpoints.L20
kI81 elem, 1 BSigned bytes.L21
kI321 elem, 4 BIndices: token ids, ArgMax results, IndexSelect indices.L22
kQ8_032 elems, 34 BOne f16 scale plus 32 int8 values.L23
kQ4_032 elems, 18 BOne f16 scale plus 32 four-bit values, two per byte.L24
ItemType/SignatureMeaningSource
DTypeenum class DType : std::uint8_tThe seven values above, in table order.L20
DTypeBlockElems, DTypeBlockBytes, DTypeIsQuantized, DTypeIsFloating(DType) -> size_t / boolLookups into the table. Only f32, f16 and bf16 count as floating.L32
DTypeStorageBytessize_t DTypeStorageBytes(DType, size_t numel)Bytes for numel elements. Aborts unless numel is a whole number of blocks.L37
kDTypeOf<T>template <class T>Maps float, Float16, BFloat16, int8_t and int32_t to their dtype. The quantized types have no C++ element type, so there is no mapping for them.L84

f16 and bf16 rounding

Both half types are a uint16_t of bits with FromFloat and ToFloat. They differ in which bits of an f32 they keep. bf16 keeps f32's 8-bit exponent and cuts the mantissa to 7 bits, so it has f32's range at lower precision. f16 has a 5-bit exponent and a 10-bit mantissa, so it is more precise but overflows above 65504.

ItemType/SignatureMeaningSource
Float16::FromFloatstatic Float16 FromFloat(float)Handles inf and NaN first; NaN stays a quiet NaN. Values at or above 65520 become infinity, since that is the point where rounding would carry out of the largest finite half. Values too small for a normal half become subnormals by shifting the mantissa, with round to nearest, ties to even. Normal values round the same way.L43
Float16::ToFloatfloat ToFloat() constWidens exactly. Subnormal halves are renormalized into normal floats.L74
HalfToFloatvoid HalfToFloat(const Float16*, float*, size_t n)The bulk version, written branch-free so the compiler vectorizes it. It shifts the half's bits into f32 position and multiplies by 2112 to fix the exponent bias, which also handles subnormals. Attention reads the f16 KV cache through it.L99
BFloat16::FromFloatstatic BFloat16 FromFloat(float)Adds 0x7fff plus the lowest kept bit, then takes the top 16 bits. That is round to nearest, ties to even, in one addition. NaN is forced quiet with | 0x40 so rounding can't turn it into infinity.L113
BFloat16::ToFloatfloat ToFloat() constA 16-bit left shift and a bit_cast; widening bf16 is free.L52

Because there are only 65,536 halves, the tests check every one. EveryHalfRoundTrips converts each half to f32 and back and expects the same bits. Other tests compare against the compiler's own _Float16 conversion and check the bulk path against ToFloat for every half. The Mixed precision page explains why bf16 replaced f16 for training.

Quantized blocks

rml stores two GGML block formats in core so that a tensor can hold quantized weights straight out of a GGUF file. A block is 32 consecutive elements of a row with one f16 scale d. The formulas are in the header comments.

ItemType/SignatureMeaningSource
kQuantBlockinline constexpr size_t = 32Elements per block for both formats.L65
BlockQ8_0struct { Float16 d; int8_t qs[32]; }x[i] = d × qs[i]. 34 bytes, 8.5 bits per weight.L67
BlockQ4_0struct { Float16 d; uint8_t qs[16]; }x[i] = d × ((qs[i] & 0xF) − 8) for the first 16 elements, and the high nibbles hold elements 16 to 31. 18 bytes, 4.5 bits per weight.L73
size checksstatic_assertPin the layouts at 2, 34 and 18 bytes so they match GGUF byte for byte.L80

core only stores and moves these blocks. Quantizing, dequantizing, the K-quant formats and the dot products are on Quantization. Since a block can't be split, every view of a quantized tensor must keep whole blocks along the last axis (see Views and copies).

Shape and strides

ItemType/SignatureMeaningSource
Shapeclass { array<int64_t, 8> dims_; int rank_; }A fixed-capacity list of dimensions. It never allocates, so copying shapes in view code is cheap. NumElements() is 1 for a scalar.L21
kMaxRankstatic constexpr int = 8The largest rank a tensor can have.L23
Stridesusing Strides = ShapeStrides are counted in elements, not bytes. For a quantized tensor that means elements, and the byte offset comes from DTypeStorageBytes.L55
ContiguousStridesStrides ContiguousStrides(const Shape&)Row-major strides: the last axis has stride 1, and each earlier axis multiplies in the sizes after it.L63
NormalizeAxisint NormalizeAxis(int axis, int rank)Maps −1 to the last axis, and so on. Out-of-range axes abort.L73
One buffer, two views A buffer of six floats, 0 to 5. Read as a 2 by 3 tensor with strides 3 and 1, its rows are 0 1 2 and 3 4 5. Its transpose has shape 3 by 2 and strides 1 and 3, so its rows are 0 3, 1 4 and 2 5, with no copy. Storage (6 × f32) 0 1 2 3 4 5 offset 0 x: shape [2, 3], strides [3, 1] 0 1 2 3 4 5 x.Transpose(0, 1): shape [3, 2], strides [1, 3] 0 3 1 4 2 5 same storage, IsContiguous() = false
Element [i, j] lives at offset + i·stride[0] + j·stride[1]. A transpose swaps the strides and touches no data.

Broadcasting

rml follows NumPy. Shapes are aligned on the right, and each pair of dimensions must be equal or one of them must be 1. A missing leading dimension counts as 1.

ItemType/SignatureMeaningSource
BroadcastShapesoptional<Shape> BroadcastShapes(const Shape& a, const Shape& b)The result shape, or nullopt when a pair of dimensions conflicts. [4, 1, 3] and [2, 1] give [4, 2, 3].L78
BroadcastStridesStrides BroadcastStrides(const Shape&, const Strides&, const Shape& target)Strides that read a tensor as if it had the target shape. Added leading axes and stretched size-1 axes get stride 0, so the same element is read again. Anything that does not broadcast aborts.L92

Stride 0 is how broadcasting avoids copies. Tensor::Expand uses it to make a view, and the binary ops use it to read both operands at the output shape. Writing through such a view would write the same element several times, so the header says expanded tensors must not be written.

The row loop

internal::ForEachRow<N> is the one loop for strided data in core. Dtype conversions, quantized copies, the binary ops and the reductions all go through it; plain copies use their own blocked path (see below). It takes a shape and N stride lists, one per operand. It then calls fn(offsets, n, inner_strides) once for each row of the innermost axis, where offsets[k] is where the row starts in operand k.

  • The outer axes advance like an odometer: add the axis stride, and when the index reaches the axis size, subtract stride × size and carry to the next axis out (L37–46). There is no division or modulo per element.
  • A rank-0 tensor is a single row of one element, and a shape with zero elements makes no calls (L23–28).
  • The callback's inner loop is a plain counted loop over n elements with a fixed stride. When every inner stride is 1, the compiler can vectorize it.

Storage and Tensor

ItemType/SignatureMeaningSource
Storageclass { byte* data_; size_t size_; bool owns_; shared_ptr<const void> owner_; }A byte buffer held by shared_ptr. It owns 64-byte-aligned heap memory, or wraps external memory such as an mmapped weight file and keeps that file alive through owner_. It can't be copied.L32
Storage::Allocate, Storage::Wrapstatic shared_ptr<Storage>Allocate returns null on failure instead of throwing. While a ScopedBufferPool is active on the thread it takes the block from that BufferPool and hands it back when the storage dies (see Runtime). Wrap never frees the memory itself; dropping owner_ does.L235
Tensorclass { shared_ptr<Storage>; int64_t offset_; Shape; Strides; DType; }A view into a storage. Copying a Tensor copies the view, not the data. The offset is in elements. A default-constructed tensor has no storage and defined() is false.L59
Empty, TryEmptystatic Tensor Empty(const Shape&, DType = kF32)Uninitialized, contiguous. TryEmpty returns kOutOfMemory through Expected; Empty aborts instead. Dequantize uses the soft form.L259
Zeros, Full, Scalar, Arange, FromValuesstatic TensorThe usual factories. FromValues checks the value count against the shape.L66
FromBufferstatic Tensor FromBuffer(void*, const Shape&, DType, shared_ptr<const void> owner)A contiguous tensor over memory rml did not allocate. The GGUF and safetensors readers return their tensors this way, which is how weights stay mmapped (see Model files).L86
data<T>, raw_dataT* data() constPointer to the first element, after the offset. data<T> aborts if T is not the tensor's dtype, so a bf16 tensor can't be read as floats by mistake.L105
At<T>, ItemT& At(initializer_list<int64_t>) constStrided access by index, bounds-checked only in debug builds. Meant for tests and reference code, not kernels.L114
IsContiguousbool IsContiguous() constTrue when the strides are row-major. Axes of size 1 are skipped, since their stride never matters, and a tensor with zero elements is always contiguous.L307

Views and copies

Every view method copies the Tensor (a shared_ptr copy) and edits the offset, shape or strides. SharesStorageWith tells you whether two tensors are views of one buffer. Methods that need whole blocks check for them on quantized tensors.

ItemCopies?MeaningSource
ViewneverNew shape with contiguous strides. Aborts unless the tensor is contiguous and the element count matches. Accepts one -1 dimension, inferred from the count.L349
Reshapeonly if not contiguousView when possible, otherwise Contiguous().View(...), like torch.reshape.L364
Transpose, PermuteneverReorder shape and strides together. Permute aborts on a repeated axis. Neither works on quantized tensors, because that would move the block axis.L372
SliceneverPython slicing on one axis: negative bounds count from the end, out-of-range bounds clamp, and step multiplies the stride. On a quantized row, start and length must be whole blocks and the step must be 1.L401
Select, Squeeze, UnsqueezeneverRemove an axis at an index, remove a size-1 axis, or insert one.L424
ExpandneverBroadcast to a shape by setting stride 0 (see Broadcasting).L454
Contiguousonly if not contiguousReturns *this when the tensor is already contiguous, so callers can use it freely before a kernel.L464
Clone, CopyFromalwaysA fresh contiguous buffer, filled by StridedCopy. CopyFrom writes into an existing view, so a slice can be filled in place.L466
ToalwaysConverts between f32, f16, bf16, i32 and i8; float to int truncates. Quantizing is not here, it is in rml/quant.L479

StridedCopy backs Contiguous, Clone and CopyFrom, and has three paths. When both sides are contiguous it is one memcpy. Quantized data is copied a row at a time with memcpy, and rows must be contiguous because a block has no element stride. Everything else first goes through CoalesceAxes, which drops size-1 axes and merges adjacent axes that both sides step through as one, so most views shrink to two or three axes. CopyBlock then copies the innermost two axes as one block: each row is a memcpy when both inner strides are 1, a fill_n when the destination stride is 1 and the source stride is 0 (a broadcast), and a strided loop otherwise. CopyPlain splits the outer blocks over the default thread pool once a copy has 216 elements or more and at least two blocks.

The old loop handled one innermost row per callback on one thread. Upstream measured the U-Net graphs at batch 64 on 4 threads: the nearest-2× upsample's reshape went from 8.2 to 1.7 ms for PyTorch and 7.5 to 1.6 ms for TensorFlow, and concat from 4.7 to 2.7 and 9.0 to 4.2 ms (kernels.md L230–256).

Reference ops

ops.h is the f32 reference library. These ops are written to be obviously correct, not fast. Autograd builds on them, and the fast kernels on Kernels are tested against them. Each op returns a new contiguous tensor and aborts on a non-f32 input.

GroupOpsNotesSource
UnaryNeg Abs Exp Log Sqrt Rsqrt Square Tanh Sigmoid Relu Silu Gelu Pow Clamp SignGelu is the tanh approximation used by GPT-2 and most LLMs. Sign keeps NaN as NaN.L20
BinaryAdd Sub Mul Div Maximum Minimum, plus scalar Add and MulBroadcast both operands to the output shape.L40
ComparisonsGreater GreaterEqual Less LessEqual Equal WhereReturn f32 masks of 0 and 1, so a mask multiplies straight into another tensor.L51
ReductionsSum Mean Max Min ArgMax, SumAll MeanAll MaxAllOver one axis, optionally keeping it as size 1. ArgMax returns i32 and picks the first maximum.L63
NormalizationsSoftmax LogSoftmaxSubtract the max before exp.L79
Gather / scatterIndexSelect IndexAdd ConcatIndexSelect is NumPy take (embedding lookup). IndexAdd is its adjoint, used for its gradient. Concat works for any dtype.L85
TestingAllClose|a − b| ≤ atol + rtol·|b| for every element.L98

Three implementation details carry most of the file:

  • Binary computes the broadcast shape, builds three stride lists (output, then a and b through BroadcastStrides), and runs the element function in ForEachRow<3>. All six binary ops and the comparisons share it.
  • ForEachReducedRow permutes the reduced axis to the end as a view, then walks rows with ForEachRow<1>. The remaining axes keep their order, so the rows come out in the output's row-major order and need no index math.
  • RowSum accumulates in double, so summing a long row doesn't lose the low-order bits a float accumulator would.

Softmax is three lines built from the other ops: Sub(x, Max(x, axis, true)), then Exp, then Div by the kept-dimension Sum. Subtracting the max keeps exp from overflowing and doesn't change the result. The keep-dim reductions broadcast back against the input through Binary.

RML_CHECK

rml has no exceptions. A broken contract, such as a shape mismatch, a wrong dtype or an out-of-range axis, is a programming error. RML_CHECK(cond, message) calls CheckFailed, which prints the file, line, condition and message to stderr and calls abort(). The failing branch is marked [[unlikely]]. RML_DCHECK compiles to nothing under NDEBUG. It guards the per-element bounds checks in At, where a check in release builds would cost too much.

Failures a caller can recover from, like running out of memory or a malformed file, go through Status and Expected<T> instead. Those are on Runtime.

Tests

FileTestsWhat they pin down
dtype_test.cc9Storage sizes, partial blocks aborting, ties to even, and every f16 and bf16 value round-tripping.
shape_test.cc8The rank limit, contiguous strides, negative axes, and compatible and incompatible broadcasts.
tensor_test.cc24Which methods share storage and which copy: transpose swaps strides, reshape of a non-contiguous tensor copies, expand uses zero strides, Contiguous of broadcast, permuted, sliced and transposed views matches their strides element by element, FromBuffer keeps its owner alive, and quantized views stay block-aligned.
ops_test.cc24Each op family against hand-computed values, ops on non-contiguous views, higher-rank broadcasting, softmax rows summing to one, and IndexAdd as the adjoint of IndexSelect.