Skip to content

feat(android): mapped weights as the Android loader configuration + a two-pool fit check before load (SKEEP-003 P5, S2.5) - #1084

Merged
michalharakal merged 1 commit into
developfrom
feature/1038-android-mmap-config
Aug 24, 2026
Merged

michalharakal merged 1 commit into
developfrom
feature/1038-android-mmap-config

Conversation

@michalharakal

Copy link
Copy Markdown
Contributor

Closes #1038 · Phase P5 · Milestone M2 · PRD M2-A5 · Proposal §4.8.2 · SKEEP-002, #921, #922

A phone has two memory pools

The planner totalled one number. On Android that cannot express the situation: the ART managed heap is hard-capped per app (256 MB, 512 MB with largeHeap) and holds every Kotlin array, while mapped weights live in file-backed pages that do not count against that cap at all — they compete for physical RAM, which the OS reclaims under pressure instead of killing the app. One total cannot distinguish a model that will not fit from one that fits perfectly well as long as its weights are mapped.

  • DeviceMemory — RAM, the ART cap, current heap use, and the OS's own low-memory threshold.
  • MemoryPlan.fitOn(device, weightsMapped) — managed heap carries KV + forward + headroom, plus the weights only when they are not mapped; physical RAM carries everything, mapped pages included, because evictable is not free. The RAM budget keeps the device above the kill threshold it declares itself, floored for devices that report none.
  • A failing fit names the pool that ran out and prices its advice against the actual shortfall — with "load with staging = MAPPED" first when that is the missing piece.

The Android configuration of one pipeline

AndroidGguf.loader() is #1037's loader with staging = MAPPED as the default — not a separate helper. deviceMemory(context) reads ActivityManager.MemoryInfo plus Runtime.maxMemory(), and fits(context, path, ctx) answers from the GGUF header before a byte of payload is read, so an app learns it will not fit instead of finding out by being killed.

Two things I did not claim

SKEEP-002 stays Draft. I added an implementation-status section recording exactly what landed (phases 1–3, plus the fit check the SKEEP never had) and what did not: packed weights still reach the managed heap, because the packed matmul SPI takes ByteArrays and a buffer-aware kernel first needs the byte-order contract of #973 settled — it has to know which order it is reading. So the ceiling is lifted for dense checkpoints, not yet for a Q4_K_M one.

M2-A5 is not met. "Llama-1B Q4_K_M loads on a 2 GB device with no OOM" needs both the packed-mapping work above and a physical device this repository's CI does not have. That measurement belongs to the skainet-decode sample in SKaiNET-transformers. Flipping the SKEEP to Implemented on the strength of the mechanism alone would have been a claim about hardware I never ran on.

CI gained a leg it was missing

The Android host tests compile against androidMain, and nothing in CI ran them — assemble compiles that code and runs nothing, so the existing MappedGgufWeightsAndroidHostTest from #921 has never executed in CI. build.yml now has an android leg (testAndroidHostTest), and so does scripts/pr-gate.sh.

Acceptance

  • DeviceFitTest (8 cases, every target): 640 MB of weights cannot live under a 512 MB cap and can when mapped; mapped pages still need RAM (a device with 300 MB free fails on the RAM pool with plenty of heap); the RAM budget respects the device's own threshold; the first suggestion is to map, priced at exactly the weight bytes; a fitting plan has no blocking pool and no advice; heap already in use counts against the cap.
  • AndroidGgufLoadingHostTest (3 cases, Android compilation): the default loader serves dense F32 from file-backed pages, the heap path is still reachable and produces identical numbers, the header-derived fit check answers before loading, and mapped vs heap differ by exactly the weight bytes.

Gate

scripts/pr-gate.sh — all legs passed, including the new Android leg (:skainet-io-gguf and :skainet-io-core host tests).

Keeps develop green by

Additive: new types in the planner, a new Android-only facade, an extra optional parameter path already merged in #1037. Nothing changes on non-Android platforms, and nothing changes by default on Android either — AndroidGguf is a facade a caller opts into.

🤖 Generated with Claude Code

…d a two-pool fit check before load

Closes #1038 (SKEEP-003 P5, S2.5, SKEEP-002, #921, #922).

A phone has two memory pools and the planner totalled one number. The ART
managed heap is hard-capped per app (256 MB, 512 MB with largeHeap) and
holds every Kotlin array; mapped weights live in file-backed pages that do
not count against that cap at all and compete for physical RAM instead,
which the OS reclaims rather than killing the app. A single total cannot
tell a model that will not fit from one that fits perfectly well as long as
its weights are mapped.

- `DeviceMemory` (RAM, the ART cap, the OS's own low-memory threshold) and
  `MemoryPlan.fitOn(device, weightsMapped)`: managed heap carries KV +
  forward + headroom, plus the weights only when they are not mapped;
  physical RAM carries everything, mapped pages included — they are
  evictable, not free. The RAM budget stays above the threshold the device
  itself declares. A failing fit names the pool that ran out and prices its
  advice against the actual shortfall, with "load with staging = MAPPED"
  first when that is what is missing.
- `AndroidGguf`: `loader()` is the Android configuration of #1037's
  pipeline — `staging = MAPPED` by default; `deviceMemory(context)` reads
  `ActivityManager.MemoryInfo` plus `Runtime.maxMemory()`; `fits()` answers
  from the GGUF header before a byte of payload is read.
- CI gains an `android` leg (`testAndroidHostTest`), and so does
  scripts/pr-gate.sh. Those host tests compile against androidMain, so
  nothing else in the gate proved that code even builds — `assemble`
  compiles it and runs nothing.

SKEEP-002 stays Draft, with an implementation-status section recording what
landed and what does not: packed weights still reach the heap, because the
packed matmul SPI takes ByteArrays and a buffer-aware kernel needs the
byte-order contract of #973 settled first. So the ceiling is lifted for
dense checkpoints, not yet for a Q4_K_M one — which is exactly the M2-A5
criterion that stays open, together with the device measurements this
repository has no hardware to make.

DeviceFitTest (8 cases, every target): 640 MB of weights cannot live under a
512 MB cap and can when mapped; mapped pages still need RAM; the RAM budget
respects the device's kill threshold; the first advice is to map; a fitting
plan has no blocking pool. AndroidGgufLoadingHostTest (3 cases, Android
compilation): the default loader maps, staging does not change the numbers,
and the header-derived fit differs between mapped and heap by exactly the
weight bytes.

Gate: scripts/pr-gate.sh — all legs passed, including the new Android leg.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@michalharakal
michalharakal merged commit da515cf into develop Aug 24, 2026
16 checks passed
@github-actions

Copy link
Copy Markdown

📖 Documentation Preview

The documentation has been built successfully for this PR.

Generated Files:

  • Operator documentation: docs/modules/operators/_generated_/
  • JSON schema output: operators.json

Artifacts:

  • Download the documentation-preview-1084 artifact to view the complete documentation locally.

This comment will be updated automatically when the PR is updated.

@michalharakal
michalharakal deleted the feature/1038-android-mmap-config branch August 27, 2026 14:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[S2.5] P5: Android mmap as a loader configuration (SKEEP-002, #921, #922) on Storage.Mapped

1 participant