SKEEP-003: Unifying the tensor storage model β one byte-owner, enforced ownership, coherent dtype/encoding
Status: Draft
Audience: SKaiNET maintainers and contributors
Created: 2026-08-10
Tracking issue: #932
Summary
SKaiNET carries two tensor-storage abstractions. TensorData
(skainet-lang-core, package sk.ainet.lang.tensor.data) is the live one:
every Tensor holds one, and backends dispatch by downcasting to concrete
classes. TensorStorage (package sk.ainet.lang.tensor.storage, 20 files) is
a designed descriptor layer β buffer handles with ownership variants,
placement, encodings, a memory planner β whose own KDoc directs new code to
target it, but which no Tensor ever holds and most of which has no
production consumer.
This proposal lays out the analysis of that split and two candidate end-states, deliberately without a recommendation β the trade-off is a maintainer decision this document is meant to anchor:
-
Storage-first:
TensorStoragebecomes the single owner of bytes, placement and lifetime;TensorDatabecomes a typed view protocol over it; op dispatch keys on (logical dtype, encoding) instead of concrete classes. -
Data-first:
TensorDatastays primary and absorbs the buffer-handle and placement vocabulary; the parallel descriptor layer is retired, keeping only the pieces with live consumers.
Under either end-state, the proposal names four cross-cutting improvements β enforced ownership with scoped lifetimes, a single view mechanism, dtype/ encoding coherence, and IO/staging as a first-class pipeline (including a feasibility assessment of a multiplatform userland file-system layer) β and one hard constraint: SKaiNET’s packed-encoding system is ahead of comparable frameworks and must survive any refactor with bit-identical behavior.
Motivation
The split is not cosmetic; it produces recurring, measurable costs:
-
Ownership is recorded but never enforced.
BufferHandle.Ownershipexists, yet nothing consults it to free, borrow-check, or prevent use-after-close. Lifetime management retreated to the garbage collector (Arena.ofAuto()) after two documented failures with explicit arenas: a shared arena pinned every op-output tensor ("tens of GB β¦ monotonically",MemorySegmentTensorData.kt), and per-call confined arenas leaked tens of MB per matmul across a 35-layer forward pass (DefaultCpuOpsJvm.kt). Both failures share one root cause: weights and activations were given the same lifetime. The vocabulary to distinguish them (Placement.Residency { PERSISTENT, TRANSIENT }) already exists β unused. -
Copy semantics are ambient, not expressed. Because ownership transfer is not part of the creation API,
DenseTensorDataFactorycopies every input array defensively (data.copyOf()), and only callers who know about the parallelwrapFloatArrayborrow entry point can avoid it. The GGUF load path paid this as a full-size extra copy of every tensor on top of the dequant intermediate β one of the three compounding causes behind issue #782's ">12 GB transient for a 4.4 GB model" (fixed at the call sites; a storage model where creation takes an explicit owned/borrowed handle fixes the class of bug). -
Two disconnected view systems.
SlicedTensorView(index remap over a parent tensor, zero-copy, shares grad state) andBufferHandle.Aliased(bounds-checked byte-range view whose mutability delegates to its parent) express the same concept and do not know about each other;Aliasedis never produced by production code. Transpose implements a third ad-hoc variant: rebuilding a packed wrapper around the same byte array. -
Three type representations. The generic
DType(aKClass<T>witness), the storage-layerLogicalDTypeenum (bridged one-way βfromDTypeexists,toDTypedoes not), andTensorEncoding. Packed tensors erase their logical type entirely:Q4_KTensorData : TensorData<DType, Byte>β a logically-FP32 weight is not typed as such, and ops locate it by class check while type-checking only the other operand. -
Placement machinery that is never consulted.
ExecutionContext. memoryPlannerconstructs a freshMemoryPlanneron every property read and no allocation path calls it;StorageSpecβ written to carry (dtype, encoding, ownership, placement) into factory routing β has zero consumers; the@Place/@Weightsannotations are runtime-retained and read by nothing. -
Placement work keeps landing at the edges. Off-heap and mmap efforts (issues #921, #922, proposal SKEEP-002) integrate at the IO boundary because the middle β tensor construction and op dispatch β has no placement seam to plug into.
For calibration, ZML (a Zig/MLIR inference stack) builds its memory story on four strictly separated types β shape metadata; host bytes that are explicitly owned or borrowed; device buffers; and purely symbolic tensors β with placement, memory and IO as first-class parameters. SKaiNET’s storage package is structurally the same design; the difference is that ZML’s is wired through and SKaiNET’s is not. In the other direction, SKaiNET’s first-class packed encodings have no ZML equivalent β the goal is wiring, not imitation.
Current State
The live path and the designed path, side by side:
| Concern | Live path (tensor.data) |
Designed path (tensor.storage) |
|---|---|---|
Byte ownership |
implicit in whichever |
|
Op dispatch |
|
|
Views |
|
|
File-backed weights |
none in common code (heap arrays; |
|
Device memory |
none |
|
Type identity |
|
|
Creation routing |
|
|
What is real and worth keeping from each side: the data side’s zero-copy
packed transposes, LazyZeroFloatArrayTensorData placeholders,
RowDequantSource for embedding-scale tensors, and the genuinely zero-copy
TensorView family; the storage side’s TensorEncoding β
TensorSpec.metadata β StableHLO skainet.tensor_encodings export seam,
which works end-to-end today.
Proposed Design
The decision: two end-states
End-state A β storage-first. TensorStorage becomes the sole byte owner.
TensorData survives as a typed view protocol (element access + dtype
witness) constructed from a storage; every constructor of bytes goes
through a storage factory that takes a StorageSpec. Op dispatch keys on
(logical dtype, encoding, placement) triples instead of concrete classes.
-
Pros: one source of truth; ownership, placement and lifetime enforceable in a single place; resolves the dtype-erasure problem structurally; the device story (when it arrives) has a home instead of a placeholder.
-
Cons: touches every
TensorDataimplementor and the entire dispatch ladder; needs a long compatibility-faΓ§ade phase; hot element-access paths gain an indirection that must be proven flat (inline classes / JIT) before commitment.
End-state B β data-first. TensorData stays primary. Each implementation
exposes its BufferHandle and Placement as properties; the descriptor
class, StorageSpec, MemoryPlanner and the annotations are deleted unless
they acquire a consumer during this SKEEP’s discussion; TensorEncoding and
the StableHLO seam are kept as-is.
-
Pros: incremental; no dispatch rewrite; zero migration risk for existing consumers; honest about what is actually used.
-
Cons: marker-downcast dispatch and the erased packed dtype remain; a future device backend will re-need a descriptor layer; ownership stays advisory unless every implementation is retrofitted individually.
Shared prerequisites, either way (small, and worth doing first):
-
Make the type bridge two-way:
LogicalDType.toDType()alongsidefromDTypeβ without it, nothing that enters the storage layer can produce theKClass<T>aTensorrequires. -
Decide `StorageSpec’s fate explicitly: it becomes the factory-routing input (A), or it is removed (B). The current zero-consumer limbo is the worst option.
Cross-cutting improvement 1 β ownership as behavior, scoped lifetimes
Introduce exactly two allocation scopes: a model scope (weights, KV-cache
backing; closed on model unload) and a forward scope (op outputs and
activations; closed β or recycled as a ring β per forward pass), keyed off
the existing Residency. Op-output allocation goes through the active scope;
unscoped use falls back to today’s GC behavior, so the change is additive.
This addresses both documented arena failures directly: the shared arena
failed because activations don’t belong in a model-lifetime arena; the
per-call arena failed because op outputs escape a single call but not a
forward pass. On the JVM the scopes ride on Arena.ofShared(); other targets
map to their allocators as they gain off-heap paths.
Cross-cutting improvement 2 β one view mechanism
Define view once: same underlying buffer + (shape, strides, offset), with
mutability delegated to the parent, produced by slicing, transpose and
unsqueeze alike. A view is explicitly not-owned; materializing it into an
owned copy is the existing MaterializationStrategy escape hatch, made the
only copy point. isContiguous stays queryable so kernels reject or
gather-copy strided views deliberately. This subsumes SlicedTensorView,
BufferHandle.Aliased and the transpose rewrap idiom.
Cross-cutting improvement 3 β dtype/encoding coherence
One rule: logical dtype is what a tensor means; encoding is how its bytes
are laid out; both are always explicit and never inferred from the Kotlin
class. A Q4_K-quantized weight is logically FP32 with encoding Q4_K β not
a TensorData<DType, Byte>. Dispatch keys on the (dtype, encoding) pair.
This is the highest-leverage coherence fix; it is a prerequisite for
end-state A and independently valuable under B. It also removes the current
inconsistency where DenseTensorDataFactory widens FP16 to FP32 in some
entry points and keeps it narrow in others.
Cross-cutting improvement 4 β IO and staging as one pipeline
Weight loading becomes source Γ staging Γ destination-placement:
-
Source: local file today. A userland file-system layer is feasible in Kotlin Multiplatform β assessed as part of this proposal:
RandomAccessSource(skainet-io-core, commonMain: positional reads, thread-safe, closeable) is already the exact read contract such a layer needs, and both streaming readers consume it. HTTP(S) range-request sources, S3 request signing, model-hub resolution and an LRU block cache are all implementable in common code (an HTTP client dependency would be net-new β it belongs in an optional module, keepingskainet-io-coredependency-free). One real API decision:RandomAccessSourceis synchronous, and JS/Wasm targets cannot block β remote sources on web targets need asuspendvariant of the interface with adapters, or a preload-to-memory mode. -
Staging: heap copy (today), mapped view (SKEEP-002’s subject β the mobile slice of this pipeline), and later direct/staged reads for cold loads. Direct IO is a per-platform capability, not common code: the JVM has
ExtendedOpenOption.DIRECT(alignment-constrained), Android hasO_DIRECTviaandroid.system.Os, Linux native hasopen(2)flags β and Apple platforms have noO_DIRECTat all (fcntl(F_NOCACHE)is the nearest analog). OS-pinned DMA-visible staging memory has no portable Kotlin Multiplatform form and is explicitly deferred until a device backend exists; the staging interface should merely leave room for such an allocator. -
Destination: the placement/scope vocabulary from the improvements above.
The readers' existing loadTensorStorage / loadTensorStorageMapped entry
points are the natural anchor for this pipeline.
Cross-cutting improvement 5 β placement consulted at creation
One MemoryPlanner per execution context (not per property read), consulted
by the tensor-creation path via whatever routing object survives the
end-state decision. The placement annotations are either wired to it or
removed. Optionally, while touching Shape: allow attaching axis labels
(batch/sequence/head-dim), purely additive, for attention-code readability.
Design Constraint β preserve the encoding system
The packed-encoding capability must survive any outcome bit-identically:
seven GGML block formats plus ternary and TurboQuant as first-class storage
types with block-level accessors; kernel-registry dispatch per encoding;
zero-copy packed transposes; RowDequantSource; and the encoding metadata
export into StableHLO. These are ahead of comparable frameworks and are the
reason this proposal reworks the plumbing around them, not them.
Compatibility and Migration
The binding rule for every phase: the 0.39.0 public API is preserved β deprecate, don’t delete.
-
Public types, signatures and semantics that shipped in 0.39.0 stay source-compatible throughout. Superseded entry points get
@Deprecatedwith aReplaceWithpointing at the successor and stay functional until a major release β the existingGgufParametersLoaderβStreamingGgufParametersLoaderdeprecation is the house pattern. -
Changes to existing classes are additive with defaults that reproduce the historical behavior (the #782 slice below is the worked example: a new optional
quantPolicyconstructor parameter whose default is bit-for-bit the old packed behavior), or implementation swaps behind an unchanged type (same slice:GGUFReader’s eagerly-boxed `List<Any>payloads became constant-space lazy views β same static type, same contents, same equality). -
Shared prerequisites (type bridge,
StorageSpecdecision) are additive or deletion-of-dead-code respectively β dead code with zero consumers is the one category exempt from deprecate-don’t-delete, and each such removal must show the zero-consumer evidence in its PR. -
End-state A requires a compatibility faΓ§ade phase in which
TensorDataimplementations delegate to storage-backed equivalents; public signatures (Tensor.data, factory entry points) remain source-compatible until a major release. -
End-state B is a sequence of small PRs with no behavioral change intended; BCV dumps track the additive properties.
-
The scoped-lifetime improvement is opt-in per context; unscoped use keeps GC semantics.
Implementation Slices
The proposal is deliberately sliceable: each slice is a normal PR that stands on its own merits and moves the storage model toward whichever end-state the discussion settles on. Slices in flight or landed:
| Slice | Issue | What it contributes to this SKEEP |
|---|---|---|
Streaming dequant (first concrete slice, landed) |
The load path stops copying what it already owns: |
|
Android off-heap / mmap |
The mobile destination-placement stage of improvement 4, specified in
sibling proposal SKEEP-002: |
|
Mechanical storage fixes (landed, 0.39.0) |
#927βhttps://github.com/SKaiNET-developers/SKaiNET/issues/931[#931] |
Contract violations found in the audit that motivated this SKEEP (factory
ownership labels, GGUF encoding map, transfer/materialize gaps, rank-broken
|
Rollout Plan
-
Maintainer discussion on this SKEEP settles the end-state (or rejects both with rationale β also a valid outcome that ends the limbo).
-
Shared prerequisites land first, small PRs.
-
Cross-cutting improvements land in the order 3 β 2 β 1 β 5 β 4 (coherence first, since views and scopes want the (dtype, encoding) pair in place); each phase independently shippable and benchmarked.
-
Mechanical bugs found during the audit that motivated this SKEEP were filed independently and are already fixed (PRs #934β#938, shipped in 0.39.0), demonstrating the slice model: #927, #928, #929, #930, #931.
-
Larger behavior-preserving slices proceed in parallel with the discussion β see Implementation Slices: the streaming-dequant slice (#782) first, then the Android off-heap/mmap slice (#921, SKEEP-002).
Acceptance Criteria
-
Exactly one abstraction owns bytes, and its ownership field is enforced (a borrowed or view buffer cannot be freed or resized through the view; a scoped buffer is unusable after scope close β verified by tests).
-
One view mechanism: slicing, transpose and unsqueeze produce the same view type; packed-transpose behavior remains zero-copy and bit-identical.
-
A packed tensor’s logical dtype is queryable and correct (a Q4_K weight reports FP32 + encoding Q4_K); no dispatch site downcasts to a concrete
TensorDataclass outside the storage/data package itself. -
Kernel benchmarks (existing
StorageBenchmarks, matmul microbenches) show no regression beyond noise on the hot paths. -
A model load under a transient forward scope shows flat direct-memory use across repeated forward passes on the JVM (the failure mode that forced the GC retreat, now prevented by construction).
Risks
-
Indirection cost on element access (end-state A): must be measured early with a spike, not assumed; inline/value-class layering is the mitigation, JIT behavior the risk.
-
Migration fatigue: end-state A touches everything; the faΓ§ade phase must keep
developgreen throughout, or the effort stalls mid-way and produces a third layer. -
Scope misuse: a tensor allocated in a forward scope that escapes into model state is a use-after-free. Debug-mode scope tagging + a leak-check test mirror how the Arena experiments were validated.
-
Encoding regressions: the preserve-list is guarded by golden parity tests (packed matmul outputs and StableHLO encoding attributes before vs after each phase).
Open Questions
-
Which end-state β or a deliberate "B now, A when a device backend is scheduled" sequencing?
-
Should the
suspendsource variant (required for remote weights on JS/Wasm) live inskainet-io-coreor in the optional remote-IO module? -
Are axis labels on
Shapewanted at all, or noise? -
Does
LogicalDTypesurvive (as the storage-layer type) once the bridge is two-way, or should the storage layer useDTypedirectly and delete the enum?
References
-
Tracking issue: #932
-
Sibling proposal: SKEEP-002 (off-heap tensor storage on Android) β the mobile/mmap slice of improvement 4
-
Related issues: #921 (Android off-heap), #922 (Android load OOM), #920 (native mobile kernels), #782 (GGUF dequant over-allocation β the first implementation slice)
-
ZML memory concepts: https://docs.zml.ai/learn/concepts/ and https://zml.ai/posts/zml-v2/ (explicit allocators, pinned staging, userland VFS)
-
Audit evidence with file references: key sites named inline above β
TensorData.kt,TensorStorage.kt,BufferHandle.kt,Placement.kt,TensorEncoding.kt,LogicalDType.kt,StorageSpec.kt,DefaultCpuOps.kt,DefaultCpuOpsJvm.kt,MemorySegmentTensorData.kt,ExecutionContext.kt,DenseTensorDataFactory.kt