Skip to content

feat(io): one loader with quantPolicy × staging, one source factory for every format, suspending reads (SKEEP-003 P5, S2.4) - #1083

Merged
michalharakal merged 1 commit into
developfrom
feature/1037-io-pipeline
Aug 24, 2026
Merged

michalharakal merged 1 commit into
developfrom
feature/1037-io-pipeline

Conversation

@michalharakal

Copy link
Copy Markdown
Contributor

Closes #1037 · Phase P5 · Milestone M2 · PRD §7 outcome · Proposal §7, §10 decision #7

Three stories, one pipeline

IO had a streaming loader that always read onto the heap, a MappedGgufWeights helper that mapped files but loaded no model, and three copies of the same createRandomAccessSource expect/actual — which had already drifted: the safetensors copy returned null on Kotlin/Native while the GGUF copy used pread(2), so the same file was streamable through one format's loader and not the other's.

quantPolicy × staging

StagingPolicy { HEAP, MAPPED } joins QuantPolicy on StreamingGgufParametersLoader. The two axes answer different questions — quantPolicy says what the values are, staging says where the bytes live — and the streaming-dequant path (#782) and the mapped path are now configurations of one loader rather than two code paths that can disagree.

MAPPED maps the file once and serves dense F32 tensors as zero-heap views over its pages. That is the difference that mattered on Android, where every heap array counts against the ART cap (#921, #922). It defaults to HEAP, so existing behaviour is unchanged, and falls back to HEAP when the platform cannot map or the source has no path — a browser build behaves exactly as before.

Supporting pieces: MappedFile + openMappedFile in io-core (JVM/Android actual over MmapTensorSource, null elsewhere), and RandomAccessSource.filePath as a defaulted member so a source can name the file to map without any implementation changing.

One source factory

openRandomAccessSource now lives in io-core with one actual per platform. The three per-format functions became deprecated delegates and their eighteen platform actuals are deleted. Safetensors gains native positional reads it should have had all along.

Suspending reads

SuspendingRandomAccessSource exposes read(...) — deliberately not readAt, so a single class can serve both interfaces — with RandomAccessSource.asSuspending() for file sources. JsBlobRandomAccessSource implements it, which means a browser can read any range instead of only the preloaded 50 MB window it had to keep to satisfy the blocking interface.

A remote (HTTP range) implementation belongs with the module that brings an HTTP client; it is not in this slice, and the interface is what makes it possible without touching this code again.

What MAPPED does not do yet

Packed tensors still arrive as heap arrays under MAPPED, because that is what their kernels take until #973 lets a packed kernel consume a view. Said where it happens, in the loader, not left to be discovered.

Acceptance

  • StagingPolicyParityTest: HEAP and MAPPED produce identical values under both quant policies (element by element, four loads of the same file); dense F32 is MmapFloatTensorData under MAPPED and FloatArrayTensorData under HEAP; packed staging is unchanged; a source without a path falls back to the heap and still returns the right numbers.
  • RandomAccessSourcesTest: the shared factory opens a file and reports its path, returns null for a missing path or a directory rather than throwing, adapts to the suspending interface, and maps dense floats out of a file region.
  • 451 io tests green across gguf, safetensors, onnx and core.

Gate

scripts/pr-gate.sh — all legs passed: jvmTest · apiCheck · verifyNpmPins jsTest wasmJsTest wasmWasiTest (JS and Wasm compile and run with the new interfaces) · linuxX64Test · assemble · :skainet-test:skainet-test-java:test.

One note for the record: the first gate run failed on SlicingTest.testPerformanceAccessPatterns in the browser — a pre-existing test that walks 500 000 elements through a slice view under Karma's per-test budget. It passed on a forced re-run and on the full second gate; it is unrelated to this change (no slicing code is touched here), but it is a flake worth knowing about.

Keeps develop green by

New parameters with defaults equal to today's behaviour; a defaulted interface member; deprecated delegates instead of removed functions. The only deletions are the eighteen duplicated platform actuals, whose public entry points still exist and still work.

🤖 Generated with Claude Code

…or every format, suspending reads

Closes #1037 (SKEEP-003 P5, S2.4, proposal §7, decision #7).

IO had three separate stories: a streaming loader that always read onto the
heap, a `MappedGgufWeights` helper that mapped files but loaded no model,
and three copies of the same `createRandomAccessSource` expect/actual —
which had already drifted, the safetensors copy returning null on
Kotlin/Native where the GGUF copy used pread(2).

- `StagingPolicy { HEAP, MAPPED }` beside `QuantPolicy` on
  `StreamingGgufParametersLoader`: quantPolicy says *what the values are*,
  staging says *where the bytes live*. MAPPED maps the file once and serves
  dense F32 tensors as zero-heap views over its pages — what mattered on
  Android, where every heap array counts against the ART cap (#921/#922).
  Defaults to HEAP, so today's behaviour is unchanged, and it falls back to
  HEAP when the platform cannot map or the source has no path.
- `MappedFile` + `openMappedFile` in io-core (JVM/Android actual over
  `MmapTensorSource`; null elsewhere), and `RandomAccessSource.filePath` —
  a defaulted member — so a source can name the file a loader should map.
- `openRandomAccessSource` in io-core with one actual per platform. The
  three per-format functions are deprecated delegates; their eighteen
  platform actuals are gone. Safetensors gains native positional reads it
  should always have had.
- `SuspendingRandomAccessSource` with `read(...)` (deliberately not
  `readAt`, so one class can serve both interfaces) plus
  `RandomAccessSource.asSuspending()`. `JsBlobRandomAccessSource` now
  implements it: a browser can read *any* range instead of only the
  preloaded 50 MB window. A remote HTTP-range implementation belongs with
  the module that brings an HTTP client and is not in this slice.

Packed tensors still arrive as heap arrays under MAPPED, because that is
what their kernels take until #973; that is stated where it happens.

`StagingPolicyParityTest`: HEAP and MAPPED produce identical values under
both quant policies, dense F32 is `MmapFloatTensorData` under MAPPED and
`FloatArrayTensorData` under HEAP, and a source without a path falls back
to the heap rather than failing. `RandomAccessSourcesTest`: the shared
factory opens and names a file, returns null for a missing path or a
directory, adapts to the suspending interface, and maps dense floats.

Gate: scripts/pr-gate.sh — all legs passed (451 io tests green).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@michalharakal
michalharakal merged commit 7f64315 into develop Aug 24, 2026
11 checks passed
@michalharakal
michalharakal deleted the feature/1037-io-pipeline branch August 24, 2026 09:05
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.4] P5: IO as a pipeline — quantPolicy × staging = MAPPED|HEAP in one loader; suspend RandomAccessSource in io-core

1 participant