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.
| DType | Block | Meaning | Source |
|---|---|---|---|
kF32 | 1 elem, 4 B | The only type the reference ops and autograd compute in. | L18 |
kF16 | 1 elem, 2 B | IEEE binary16 (Float16); the KV cache stores this. | L19 |
kBF16 | 1 elem, 2 B | bfloat16 (BFloat16), the dtype of most safetensors checkpoints. | L20 |
kI8 | 1 elem, 1 B | Signed bytes. | L21 |
kI32 | 1 elem, 4 B | Indices: token ids, ArgMax results, IndexSelect indices. | L22 |
kQ8_0 | 32 elems, 34 B | One f16 scale plus 32 int8 values. | L23 |
kQ4_0 | 32 elems, 18 B | One f16 scale plus 32 four-bit values, two per byte. | L24 |
| Item | Type/Signature | Meaning | Source |
|---|---|---|---|
DType | enum class DType : std::uint8_t | The seven values above, in table order. | L20 |
DTypeBlockElems, DTypeBlockBytes, DTypeIsQuantized, DTypeIsFloating | (DType) -> size_t / bool | Lookups into the table. Only f32, f16 and bf16 count as floating. | L32 |
DTypeStorageBytes | size_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.
| Item | Type/Signature | Meaning | Source |
|---|---|---|---|
Float16::FromFloat | static 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::ToFloat | float ToFloat() const | Widens exactly. Subnormal halves are renormalized into normal floats. | L74 |
HalfToFloat | void 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::FromFloat | static 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::ToFloat | float ToFloat() const | A 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.
| Item | Type/Signature | Meaning | Source |
|---|---|---|---|
kQuantBlock | inline constexpr size_t = 32 | Elements per block for both formats. | L65 |
BlockQ8_0 | struct { Float16 d; int8_t qs[32]; } | x[i] = d × qs[i]. 34 bytes, 8.5 bits per weight. | L67 |
BlockQ4_0 | struct { 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 checks | static_assert | Pin 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
| Item | Type/Signature | Meaning | Source |
|---|---|---|---|
Shape | class { 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 |
kMaxRank | static constexpr int = 8 | The largest rank a tensor can have. | L23 |
Strides | using Strides = Shape | Strides are counted in elements, not bytes. For a quantized tensor that means elements, and the byte offset comes from DTypeStorageBytes. | L55 |
ContiguousStrides | Strides ContiguousStrides(const Shape&) | Row-major strides: the last axis has stride 1, and each earlier axis multiplies in the sizes after it. | L63 |
NormalizeAxis | int NormalizeAxis(int axis, int rank) | Maps −1 to the last axis, and so on. Out-of-range axes abort. | L73 |
[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.
| Item | Type/Signature | Meaning | Source |
|---|---|---|---|
BroadcastShapes | optional<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 |
BroadcastStrides | Strides 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 × sizeand 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
nelements with a fixed stride. When every inner stride is 1, the compiler can vectorize it.
Storage and Tensor
| Item | Type/Signature | Meaning | Source |
|---|---|---|---|
Storage | class { 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::Wrap | static 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 |
Tensor | class { 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, TryEmpty | static 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, FromValues | static Tensor | The usual factories. FromValues checks the value count against the shape. | L66 |
FromBuffer | static 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_data | T* data() const | Pointer 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>, Item | T& At(initializer_list<int64_t>) const | Strided access by index, bounds-checked only in debug builds. Meant for tests and reference code, not kernels. | L114 |
IsContiguous | bool IsContiguous() const | True 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.
| Item | Copies? | Meaning | Source |
|---|---|---|---|
View | never | New shape with contiguous strides. Aborts unless the tensor is contiguous and the element count matches. Accepts one -1 dimension, inferred from the count. | L349 |
Reshape | only if not contiguous | View when possible, otherwise Contiguous().View(...), like torch.reshape. | L364 |
Transpose, Permute | never | Reorder shape and strides together. Permute aborts on a repeated axis. Neither works on quantized tensors, because that would move the block axis. | L372 |
Slice | never | Python 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, Unsqueeze | never | Remove an axis at an index, remove a size-1 axis, or insert one. | L424 |
Expand | never | Broadcast to a shape by setting stride 0 (see Broadcasting). | L454 |
Contiguous | only if not contiguous | Returns *this when the tensor is already contiguous, so callers can use it freely before a kernel. | L464 |
Clone, CopyFrom | always | A fresh contiguous buffer, filled by StridedCopy. CopyFrom writes into an existing view, so a slice can be filled in place. | L466 |
To | always | Converts 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.
| Group | Ops | Notes | Source |
|---|---|---|---|
| Unary | Neg Abs Exp Log Sqrt Rsqrt Square Tanh Sigmoid Relu Silu Gelu Pow Clamp Sign | Gelu is the tanh approximation used by GPT-2 and most LLMs. Sign keeps NaN as NaN. | L20 |
| Binary | Add Sub Mul Div Maximum Minimum, plus scalar Add and Mul | Broadcast both operands to the output shape. | L40 |
| Comparisons | Greater GreaterEqual Less LessEqual Equal Where | Return f32 masks of 0 and 1, so a mask multiplies straight into another tensor. | L51 |
| Reductions | Sum Mean Max Min ArgMax, SumAll MeanAll MaxAll | Over one axis, optionally keeping it as size 1. ArgMax returns i32 and picks the first maximum. | L63 |
| Normalizations | Softmax LogSoftmax | Subtract the max before exp. | L79 |
| Gather / scatter | IndexSelect IndexAdd Concat | IndexSelect is NumPy take (embedding lookup). IndexAdd is its adjoint, used for its gradient. Concat works for any dtype. | L85 |
| Testing | AllClose | |a − b| ≤ atol + rtol·|b| for every element. | L98 |
Three implementation details carry most of the file:
Binarycomputes the broadcast shape, builds three stride lists (output, thenaandbthroughBroadcastStrides), and runs the element function inForEachRow<3>. All six binary ops and the comparisons share it.ForEachReducedRowpermutes the reduced axis to the end as a view, then walks rows withForEachRow<1>. The remaining axes keep their order, so the rows come out in the output's row-major order and need no index math.RowSumaccumulates indouble, 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
| File | Tests | What they pin down |
|---|---|---|
dtype_test.cc | 9 | Storage sizes, partial blocks aborting, ties to even, and every f16 and bf16 value round-tripping. |
shape_test.cc | 8 | The rank limit, contiguous strides, negative axes, and compatible and incompatible broadcasts. |
tensor_test.cc | 24 | Which 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.cc | 24 | Each 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. |