SKEEP-002: Off-heap tensor storage on Android
Status: Draft
Audience: SKaiNET maintainers and contributors
Created: 2026-08-10
Tracking issue: #921
Summary
On Android, every SKaiNET tensor lives on the ART managed heap as a Kotlin
ByteArray / FloatArray. The managed heap is hard-capped per app — 256 MB by
default, 512 MB with android:largeHeap="true" — so the practical model-size
ceiling on Android is a couple hundred megabytes regardless of how good the
kernels get. llama.cpp, ONNX Runtime, TFLite and MediaPipe load 1 GB+ models on
the same phones because their weights live in the native heap or in file-backed
mapped pages, neither of which counts against the ART cap.
This proposal makes weight storage on Android file-backed by default and
opt-in explicit: model weights stay in mmap-ed pages (FileChannel.map,
available since API 1), the storage layer’s existing-but-inert
BufferHandle.FileBacked becomes readable, and the loaders gain a placement
mode that hands tensors through as mapped views instead of heap copies. The
kernel SPI is extended so packed matmul can consume buffer-backed memory
without a per-call copy.
This SKEEP is self-contained: every phase works with the kernels SKaiNET has
today (the SPI extension defaults to copy-and-delegate, so existing providers
are untouched). It also composes with the separately proposed native kernel
work for mobile
(#920): a native
(JNI) kernel can read a mapped region zero-copy via
GetDirectBufferAddress, which is where file-backed weights and fast kernels
compound — but nothing here depends on that work landing.
Measured context (SmolLM2-135M-Instruct, Q8_0, 138 MiB GGUF, engine 0.38.0): loading packed costs ~145 MB resident managed heap today; with file-backed weights the managed-heap cost of the same load drops to tokenizer + activations + KV cache, and the weights become reclaimable page cache.
Motivation
-
The heap cap is the binding constraint on Android, ahead of kernel speed. Even after #920 delivers NEON-class kernels, a 512 MB heap cannot hold a 1 GB model — and a 4-bit 1.1 B-parameter model is ~600 MB.
-
Loading currently costs roughly 2x the model size transiently (raw bytes plus built tensors; see #782 for the FP32 dequant variant of the same problem). File-backed weights make load cost independent of model size on the managed heap.
-
Every comparable mobile inference stack made this move; developers arriving from llama.cpp or TFLite assume a 138 MiB file is trivially loadable and are surprised when it OOMs (#922).
-
Mapped pages are shared and evictable: the OS reclaims cold weight pages under memory pressure instead of the app being killed, and a re-launch warms from page cache instead of re-reading the file.
Current State
The pieces this proposal builds on already exist, but none of them reach Android:
-
Off-heap storage is
jvmMain-only.MemorySegmentTensorData(Arena/FFM, 64-byte aligned),MmapTensorData/FloatBufferTensorData(MappedByteBuffer), andQ4MemorySegmentTensorData/Q8MemorySegmentTensorDataall live inskainet-lang/skainet-lang-core/src/jvmMain/. Android cannot use FFM at all (java.lang.foreignis absent on ART). -
Every common
TensorDataimplementation is heap-bound. The packed quant family (Q4_0BlockTensorData,Q8_0BlockTensorData, …) exposespackedData: ByteArray; the dense family wraps Kotlin arrays. -
BufferHandle.FileBackedis inert metadata. TheTensorStoragelayer models the right concepts —BufferHandle.FileBacked(path, fileOffset, size),Placement/MemoryDomain.MMAP_FILE— but no common-code accessor reads mapped pages, andcopyMaterialize()throwsUnsupportedOperationExceptionfor anything other thanOwned/Borrowed(TensorStorage.kt). -
MappedMemoryChunkhas no Android implementation.skainet-io-coredeclares the interface withJvmMappedMemoryChunk(jvmMain) andFallbackMappedMemoryChunk(heap copy). Until PR #924,skainet-io-corehad noandroidMainsource set at all. -
Both streaming readers copy to heap arrays.
StreamingGGUFReaderandStreamingSafeTensorsReadereach know every tensor’s byte range in the file (GGUFabsoluteDataOffset/nBytes, SafeTensorsdata_offsets), and both expose onlyloadTensorData(…): ByteArray— the region is always copied onto the heap even when the caller could consume it in place. The ONNX reader is different in kind: tensors are embedded in protobuf, so contiguous region mapping does not apply there today. -
The kernel SPI is array-shaped.
Q8_0MatmulKerneland friends takeByteArray/FloatArray— an intentional lowest common denominator that every target satisfies, but one that forces mapped memory to be copied before a kernel can touch it.
Proposed Design
The design is four phases, each independently shippable and testable. Phases 1 and 2 are pure additions; phase 3 changes loader behavior behind an explicit placement opt-in; phase 4 extends the kernel SPI in a way that stands on its own but pays off most once a native Android kernel implementation exists.
Phase 1 — AndroidMappedMemoryChunk
An androidMain implementation of MappedMemoryChunk in skainet-io-core
over FileChannel.map(READ_ONLY, offset, size), following the
AndroidRandomAccessSource precedent from
PR #924:
-
one mapping per requested region, not per file — GGUF tensor data offsets and sizes are known from the streaming reader, so each tensor (or the whole data section) maps independently;
-
regions larger than 2 GiB split into multiple
MappedByteBufferwindows (FileChannel.mapisInt-limited per mapping); the chunk presents one logicalLong-indexed region over the windows; -
AutoCloseablelifecycle; unmapping is left to the platform (no supported explicit-unmap API on Android — see Risks).
Phase 2 — readable BufferHandle.FileBacked
Give the storage layer a real accessor path so FileBacked stops being
metadata-only:
public sealed interface BufferAccess {
/** Zero-copy view when the platform can map; a heap copy otherwise. */
public fun asReadOnlyBuffer(): PlatformBuffer // expect/actual
public fun readInto(dest: ByteArray, destOffset: Int, srcOffset: Long, length: Int): Int
}
-
copyMaterialize()gains aFileBackedarm (viareadInto) instead of throwing, so any existing consumer that insists on heap bytes still works — it pays the copy explicitly rather than being unable to proceed. -
Placement/MemoryDomain.MMAP_FILEbecomes reachable: aTensorStorageFactoryfor Android that producesFileBackedhandles over anAndroidMappedMemoryChunk.
Phase 3 — mapped loading in the streaming readers (format-agnostic core, per-format adoption)
The capability is defined once at the skainet-io-core level and adopted per
format — it is not a GGUF feature. Both streaming readers already know every
tensor’s byte range in the file; today both only copy it out.
Core addition (io-core):
-
loadTensorDataMapped(offset, length): MappedMemoryChunkon the shared reader infrastructure — a view over the file region, no bytes move — plus a placement knob on the parameter loaders (defaulting to today’s heap behavior) that wraps pass-through tensors in buffer-backedTensorDataviews instead ofByteArray-backed ones.
Per-format adoption, in order:
-
GGUF (reference implementation — the measured mobile LLM path):
StreamingGGUFReadermapsabsoluteDataOffset/nBytesranges. The on-disk block layout is the packed layout the eager kernels consume for Q8_0/Q4_K, so a mapped region is directly usable — no repack, hence no copy. -
SafeTensors (structurally the easiest case):
StreamingSafeTensorsReadermapsdata_offsetsranges. Payloads are raw dense little-endian tensors (F32/F16/BF16) with no block structure at all, and the KEEP_NATIVE narrow- float path already wraps on-disk bytes verbatim (Fp16DenseTensorData,Bf16DenseTensorData) — mapping slots straight under it. -
ONNX (out of scope here): tensor data is embedded inside protobuf messages, so contiguous region mapping does not apply; ONNX joins if/when it gains an external-data or offset-indexed path.
In every format, tensors that must be rewritten at load (dequant-to-FP32, block-major repacks, endian/layout conversions) keep materializing — mapping only helps when bytes are consumed as-is.
Phase 4 — buffer-aware kernel dispatch
A ByteBuffer-capable overload on the packed matmul SPI. The default
implementation copies to ByteArray and delegates, so every existing provider
keeps working unchanged and the overload is purely opt-in. Expected consumers:
-
a future native (JNI) kernel implementation on Android reads the mapped address zero-copy via
GetDirectBufferAddress— this is where file-backed weights and fast kernels compound (#920 proposes such kernels; this SKEEP only defines the SPI surface they would use); -
the scalar Kotlin fallback reads through the buffer directly; per-element
MappedByteBuffer.getis slower than array access, so the scalar path may choose the copy-once default instead — measured, not assumed (see Acceptance criteria).
Compatibility and Migration
-
All phases are additive. Default behavior on every platform is unchanged: heap-backed
TensorData,loadTensorDataas today. -
The JVM keeps its
MemorySegment/ FFM path untouched; this proposal is the Android counterpart, not a replacement. -
The kernel SPI change is a defaulted interface addition — binary-compatible for existing providers (the BCV dumps gain the default methods).
-
No serialized formats change; GGUF files are consumed in place.
Rollout Plan
-
Phase 1 + 2 land together behind tests (host-side
androidHostTest, the lane introduced in PR #924). -
Phase 3 lands default-off, GGUF first (the measured mobile path), with the SafeTensors adoption as an immediate follow-up on the same core API; the PromptPong-style field test (transformers#272 reproducer) validates resident-memory numbers on a physical device.
-
Phase 4 can land any time (the defaulted SPI overload is inert on its own); scheduling it alongside the first native Android kernel, if #920 proceeds, is when the zero-copy benefit becomes measurable.
-
Docs: a "Memory on Android" page documenting the placement knob, the FP32-fallback trap (do not use
DEQUANTIZE_TO_FP32as OOM recovery — it costs ~4x packed), and the model-size guidance per heap configuration.
Acceptance Criteria
-
Loading SmolLM2-135M Q8_0 on a physical Android device with mapped placement adds less than 40 MB to the managed heap (vs ~145 MB today), verified with
Debug.getMemoryInfoin the reproducer app. -
A ~600 MB Q4_K model (1.1 B params) loads and generates on a device with the default 256 MB heap — impossible today by construction.
-
Decode throughput with mapped weights is within 10% of heap-backed weights on the same device once pages are warm (scalar kernels), and not slower with JNI kernels (phase 4).
-
No regression in JVM/native test suites;
androidHostTestcovers chunk windowing (>2 GiB logic can be tested with small synthetic window sizes),FileBackedmaterialization, and mapped-vs-heap tensor parity. -
Mapped-vs-heap parity holds for both adopted formats: a synthesized GGUF (packed quant blocks) and a synthesized SafeTensors file (dense F32/F16/BF16) produce bit-identical tensors under either placement.
Risks
-
Page-fault latency. First-touch of cold pages during decode adds jitter. Mitigation: optional eager warm-up read of the mapped region at load time (sequential touch), which restores today’s latency profile while keeping pages evictable. Java exposes no
madvise; a JNImadvise(WILLNEED)can ride along with #920’s bridge later. -
No explicit unmap. Android/JVM unmap mapped buffers only when the buffer is garbage-collected. Model reload cycles could accumulate address-space usage until GC runs. Mitigation: document lifecycle, hold mappings in a closeable model handle, and reuse mappings across reloads of the same file.
-
2 GiB per-mapping limit. Handled by design (windowed chunk), but the windowing seam is a correctness-sensitive spot; it gets dedicated tests.
-
32-bit devices.
armeabi-v7ahas a ~3 GiB address space; mapping a large model may fail there even though the heap math works. Scope: 64-bit devices are the target; 32-bit falls back to heap loading with the existing error behavior. -
Repacked layouts lose the benefit. Any policy that rewrites bytes at load (dequant, block-major repack) must materialize; the design keeps those paths on-heap and only maps pass-through layouts. This is a documented limitation, not a silent performance cliff.
Open Questions
-
Should the buffer-aware SPI use
java.nio.ByteBufferinandroidMain
jvmMainonly, or a smallexpectPlatformBufferso Kotlin/Native (posixmmapinnative64Main, e.g. for iOS) can join later? The posix side already has precedent (PosixPreadRandomAccessSource); anmmap-backed chunk for Apple/Linux native would generalize this SKEEP beyond Android. -
Map the whole GGUF data section once vs per-tensor mappings: one mapping is simpler and shares pages across tensors; per-tensor mappings give tighter address-space usage on 32-bit. Current lean: one windowed mapping of the data section.
-
Should mapped placement eventually become the Android default once JNI kernels consume buffers natively, with heap placement as the opt-out?
References
-
Tracking issue: #921
-
Android OOM report and fix that unblocked streaming reads: #922, PR #924
-
Native mobile kernels (JNI bridge that makes zero-copy real): #920
-
Dequant over-allocation: #782
-
Cross-target reproducer used for the memory measurements: transformers#272
-
Prior art: llama.cpp
--mmap(default-on) weight mapping; ONNX Runtime and TFLite native-heap weight storage.