Virtual tensors: one logical tensor, many physical forms
A kernel wants bytes in one particular arrangement. A file holds them in another. A phone wants them off the managed heap; a browser has no choice but on it. The classic failure mode is to let each of these parties reshape the tensor for themselves โ and pay a copy, a transpose, or a dequantization at every boundary.
SKaiNET’s answer, in the spirit of ML Drift’s tensor virtualization, is to make the
arrangement a described property instead of a hard-coded fact: operators address a tensor by
its logical coordinates, and a per-tensor Layout translates those coordinates to a physical
position. Because the translation is data, the runtime is free to pick the optimal physical
object per kernel and per device โ and nobody upstream notices.
This page is the map. The neighbouring pages hold the depth:
The memory model for Storage/Scope/ownership,
Packed weight layout for block orders and prepacking, and
Eager execution: backends and kernels for how kernels are dispatched.
The strides algebra
The mechanism is one multiplication per axis. A logical index (o, b) reaches physical
position oยทs[0] + bยทs[1] โ and which strides s the layout carries decides where the
bytes actually are:
The same logical cell lands at physical index 13 under row-major strides and at index 6 under
input-block-major strides. Transpose, narrow, and block reorder all become metadata edits โ
a strides swap, an offset, a block-order tag โ not copies. This is
sk.ainet.lang.memory.Layout: strides, offsetElements, and the block geometry
(blocked, blockAxis, blockOrder), hardened by the wrong-block bugs (#968, #971, #973)
that made the distinction impossible to ignore: for a block-quantized weight, canonical
storage and kernel feed order hold the same blocks at different physical positions, and
they coincide only at one block per row.
The four-way split
A tensor’s identity is four orthogonal questions, answered by four types that compose into a
TensorView:
| Type | Question it answers | What it deliberately does not know |
|---|---|---|
|
Who owns which bytes, and for how long โ |
What the bytes mean |
|
What the elements are โ dtype ร encoding. A Q4_K weight is logically FP32; the encoding says how it is stored and never replaces the dtype |
Where the bytes live |
|
How a logical index reaches a physical position โ strides, offset, block geometry |
Anything about lifetime or meaning |
|
How long allocations live โ |
Which bytes; it only tracks and frees |
TensorView = Shape + Format + Layout + Storage is the value kernels unwrap once per call.
Several views can address the same storage:
narrow and transpose hand out windows and relabelings over the same bytes;
materialize() is the single sanctioned copy point, and prepack() the single visible
relayout (it moves blocks once, at load, and the result says so via blockOrder โ so a
reader walking the logical grid still fetches each block from where the new order put it).
Who decides the physical form? Resolvers.
The model author never declares any of this โ they cannot know it. What the file holds, what the device has, and what the backend’s kernels can feed are all knowable at load time, in one place:
-
WeightFormResolver.resolve(stored, profile, capabilities)produces theWeightFormโ encoding request ร byte order ร shape orientation ร residency. A format with a packed kernel stays packed (and arrives in kernel feed order if the kernel wants it); a format with no kernel is dequantized once, at load, with the cost stated โ under a strict profile such asMOBILE_2GBit refuses instead. -
AllocationResolver.resolve(weight, profile, platform)turns the resolved form into anAllocationSpecโ memory domain and scope. Mapping from file pages requires all three: the form asks for it, the platform can map, and the bytes really are the file’s bytes (a dequantized or re-ordered weight is a load-time copy, and a copy cannot be paged from a file it no longer matches).AllocationResolver.explain()renders each decision with its reason, so a plan can print where every tensor lands before a byte of payload is read. -
You outrank both. The loader’s precedence is explicit: per-tensor
weightFormFor> uniformweightForm> the resolver.WeightForm(DequantizeTo(FP32), residency = HEAP)โ everything dense, on the managed heap โ is a supported one-liner, not a fight with the planner.
Consumers โ the memory plan, the GGUF loader (ResolvedGguf), an execution context โ carry
and obey. None of them decide.
Lifetime without a graph
Eager execution has no graph to run liveness analysis on, and does not need one: lifetimes
follow call structure. A generation loop knows where a step ends, so
ctx.forwardScope(slabFloats) { scoped, scope → โฆ } bump-allocates activations from one
pre-sized slab and scope.reset() recycles it per step โ steady-state decode allocates zero
new slab bytes, and a read of a previous step’s tensor throws StorageClosedException
naming the storage instead of returning garbage. ModelScope is the weights-lifetime
counterpart; Scope.Ambient (plain GC) remains the default everywhere.
Known limits, stated rather than implied
-
By decision (#1134), graph-level memory planning stays in the downstream compiler; core’s obligation on the compile lane is metadata carriage, and that landed (#1147): tensor identity, structural encodings and block order ride from the tape into the exported MLIR (
skainet.tensor_layouts) and.irpareferences, withLayoutAssignmentPassmaking the first layout decision on the production path. Validation against IREE waits for the conformity pipeline (#1148). -
Storage.OffHeapexists on every platform that can honour it, but no tensor factory allocates through it yet โ off the managed heap today means mapped, read-only weights (dense F32; packed formats still land on the heap until the buffer-aware kernel SPI of #973). -
`ForwardScope’s slab is a heap slab: it flattens allocation churn, it does not move activations off-heap.
-
Trainable parameters are heap-resident by construction โ a mapped page is read-only, and no optimizer allocates from
PlatformStorage.