From 6e6f03df100d0b9f560ed5a641b557158f423ec8 Mon Sep 17 00:00:00 2001 From: Michal Harakal Date: Mon, 24 Aug 2026 11:03:25 +0200 Subject: [PATCH] feat(io): one loader with quantPolicy x staging, one source factory for every format, suspending reads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../sk/ainet/io/AndroidRandomAccessSource.kt | 6 +- .../ainet/io/RandomAccessSources.android.kt | 15 ++ .../ainet/io/MappedFile.androidNativeArm32.kt | 4 + .../RandomAccessSources.androidNativeArm32.kt | 8 ++ .../kotlin/sk/ainet/io/MappedFile.kt | 35 +++++ .../kotlin/sk/ainet/io/RandomAccessSource.kt | 10 ++ .../kotlin/sk/ainet/io/RandomAccessSources.kt | 16 +++ .../ainet/io/SuspendingRandomAccessSource.kt | 46 ++++++ .../kotlin/sk/ainet/io/model/StagingPolicy.kt | 24 ++++ .../sk/ainet/io/JsBlobRandomAccessSource.kt | 15 +- .../kotlin/sk/ainet/io/MappedFile.js.kt | 4 + .../sk/ainet/io/RandomAccessSources.js.kt | 4 + .../kotlin/sk/ainet/io/JvmMappedFile.kt | 62 +++++++++ .../sk/ainet/io/MappedRandomAccessSource.kt | 8 +- .../sk/ainet/io/JvmRandomAccessSource.kt | 6 +- .../sk/ainet/io/RandomAccessSources.jvm.kt | 11 ++ .../sk/ainet/io/RandomAccessSourcesTest.kt | 95 +++++++++++++ .../kotlin/sk/ainet/io/MappedFile.native64.kt | 8 ++ .../ainet/io/PosixPreadRandomAccessSource.kt | 6 +- .../ainet/io/RandomAccessSources.native64.kt | 5 + .../sk/ainet/io/JsBlobRandomAccessSource.kt | 15 +- .../kotlin/sk/ainet/io/MappedFile.wasmJs.kt | 4 + .../sk/ainet/io/RandomAccessSources.wasmJs.kt | 4 + .../kotlin/sk/ainet/io/MappedFile.wasmWasi.kt | 4 + .../ainet/io/RandomAccessSources.wasmWasi.kt | 4 + .../gguf/RandomAccessSourceFactory.android.kt | 27 ---- .../sk/ainet/io/gguf/GgufModelParser.kt | 3 +- .../io/gguf/RandomAccessSourceFactory.kt | 21 ++- .../io/gguf/StreamingGgufParametersLoader.kt | 46 +++++- .../io/gguf/RandomAccessSourceFactory.js.kt | 14 -- .../sk/ainet/io/gguf/MappedGgufWeights.kt | 7 +- .../io/gguf/RandomAccessSourceFactory.jvm.kt | 24 ---- .../ainet/io/gguf/StagingPolicyParityTest.kt | 131 ++++++++++++++++++ .../gguf/RandomAccessSourceFactory.native.kt | 14 -- .../gguf/RandomAccessSourceFactory.wasmJs.kt | 11 -- .../RandomAccessSourceFactory.wasmWasi.kt | 11 -- .../onnx/RandomAccessSourceFactory.android.kt | 26 ---- .../io/onnx/RandomAccessSourceFactory.kt | 21 +-- ...urceFactory.js.kt => OnnxBlobSource.js.kt} | 14 +- .../io/onnx/RandomAccessSourceFactory.jvm.kt | 24 ---- .../onnx/RandomAccessSourceFactory.native.kt | 13 -- ...ory.wasmJs.kt => OnnxBlobSource.wasmJs.kt} | 22 +-- .../RandomAccessSourceFactory.android.kt | 26 ---- .../safetensors/RandomAccessSourceFactory.kt | 17 +-- .../RandomAccessSourceFactory.js.kt | 11 -- .../RandomAccessSourceFactory.jvm.kt | 24 ---- .../RandomAccessSourceFactory.native.kt | 11 -- .../RandomAccessSourceFactory.wasmJs.kt | 11 -- .../RandomAccessSourceFactory.wasmWasi.kt | 11 -- 49 files changed, 631 insertions(+), 328 deletions(-) create mode 100644 skainet-io/skainet-io-core/src/androidMain/kotlin/sk/ainet/io/RandomAccessSources.android.kt create mode 100644 skainet-io/skainet-io-core/src/androidNativeArm32Main/kotlin/sk/ainet/io/MappedFile.androidNativeArm32.kt create mode 100644 skainet-io/skainet-io-core/src/androidNativeArm32Main/kotlin/sk/ainet/io/RandomAccessSources.androidNativeArm32.kt create mode 100644 skainet-io/skainet-io-core/src/commonMain/kotlin/sk/ainet/io/MappedFile.kt create mode 100644 skainet-io/skainet-io-core/src/commonMain/kotlin/sk/ainet/io/RandomAccessSources.kt create mode 100644 skainet-io/skainet-io-core/src/commonMain/kotlin/sk/ainet/io/SuspendingRandomAccessSource.kt create mode 100644 skainet-io/skainet-io-core/src/commonMain/kotlin/sk/ainet/io/model/StagingPolicy.kt create mode 100644 skainet-io/skainet-io-core/src/jsMain/kotlin/sk/ainet/io/MappedFile.js.kt create mode 100644 skainet-io/skainet-io-core/src/jsMain/kotlin/sk/ainet/io/RandomAccessSources.js.kt create mode 100644 skainet-io/skainet-io-core/src/jvmAndroidMain/kotlin/sk/ainet/io/JvmMappedFile.kt create mode 100644 skainet-io/skainet-io-core/src/jvmMain/kotlin/sk/ainet/io/RandomAccessSources.jvm.kt create mode 100644 skainet-io/skainet-io-core/src/jvmTest/kotlin/sk/ainet/io/RandomAccessSourcesTest.kt create mode 100644 skainet-io/skainet-io-core/src/native64Main/kotlin/sk/ainet/io/MappedFile.native64.kt create mode 100644 skainet-io/skainet-io-core/src/native64Main/kotlin/sk/ainet/io/RandomAccessSources.native64.kt create mode 100644 skainet-io/skainet-io-core/src/wasmJsMain/kotlin/sk/ainet/io/MappedFile.wasmJs.kt create mode 100644 skainet-io/skainet-io-core/src/wasmJsMain/kotlin/sk/ainet/io/RandomAccessSources.wasmJs.kt create mode 100644 skainet-io/skainet-io-core/src/wasmWasiMain/kotlin/sk/ainet/io/MappedFile.wasmWasi.kt create mode 100644 skainet-io/skainet-io-core/src/wasmWasiMain/kotlin/sk/ainet/io/RandomAccessSources.wasmWasi.kt delete mode 100644 skainet-io/skainet-io-gguf/src/androidMain/kotlin/sk/ainet/io/gguf/RandomAccessSourceFactory.android.kt delete mode 100644 skainet-io/skainet-io-gguf/src/jsMain/kotlin/sk/ainet/io/gguf/RandomAccessSourceFactory.js.kt delete mode 100644 skainet-io/skainet-io-gguf/src/jvmMain/kotlin/sk/ainet/io/gguf/RandomAccessSourceFactory.jvm.kt create mode 100644 skainet-io/skainet-io-gguf/src/jvmTest/kotlin/sk/ainet/io/gguf/StagingPolicyParityTest.kt delete mode 100644 skainet-io/skainet-io-gguf/src/nativeMain/kotlin/sk/ainet/io/gguf/RandomAccessSourceFactory.native.kt delete mode 100644 skainet-io/skainet-io-gguf/src/wasmJsMain/kotlin/sk/ainet/io/gguf/RandomAccessSourceFactory.wasmJs.kt delete mode 100644 skainet-io/skainet-io-gguf/src/wasmWasiMain/kotlin/sk/ainet/io/gguf/RandomAccessSourceFactory.wasmWasi.kt delete mode 100644 skainet-io/skainet-io-onnx/src/androidMain/kotlin/sk/ainet/io/onnx/RandomAccessSourceFactory.android.kt rename skainet-io/skainet-io-onnx/src/jsMain/kotlin/sk/ainet/io/onnx/{RandomAccessSourceFactory.js.kt => OnnxBlobSource.js.kt} (68%) delete mode 100644 skainet-io/skainet-io-onnx/src/jvmMain/kotlin/sk/ainet/io/onnx/RandomAccessSourceFactory.jvm.kt delete mode 100644 skainet-io/skainet-io-onnx/src/nativeMain/kotlin/sk/ainet/io/onnx/RandomAccessSourceFactory.native.kt rename skainet-io/skainet-io-onnx/src/wasmJsMain/kotlin/sk/ainet/io/onnx/{RandomAccessSourceFactory.wasmJs.kt => OnnxBlobSource.wasmJs.kt} (61%) delete mode 100644 skainet-io/skainet-io-safetensors/src/androidMain/kotlin/sk/ainet/io/safetensors/RandomAccessSourceFactory.android.kt delete mode 100644 skainet-io/skainet-io-safetensors/src/jsMain/kotlin/sk/ainet/io/safetensors/RandomAccessSourceFactory.js.kt delete mode 100644 skainet-io/skainet-io-safetensors/src/jvmMain/kotlin/sk/ainet/io/safetensors/RandomAccessSourceFactory.jvm.kt delete mode 100644 skainet-io/skainet-io-safetensors/src/nativeMain/kotlin/sk/ainet/io/safetensors/RandomAccessSourceFactory.native.kt delete mode 100644 skainet-io/skainet-io-safetensors/src/wasmJsMain/kotlin/sk/ainet/io/safetensors/RandomAccessSourceFactory.wasmJs.kt delete mode 100644 skainet-io/skainet-io-safetensors/src/wasmWasiMain/kotlin/sk/ainet/io/safetensors/RandomAccessSourceFactory.wasmWasi.kt diff --git a/skainet-io/skainet-io-core/src/androidMain/kotlin/sk/ainet/io/AndroidRandomAccessSource.kt b/skainet-io/skainet-io-core/src/androidMain/kotlin/sk/ainet/io/AndroidRandomAccessSource.kt index f4bbf5f7e..25f9a2ac4 100644 --- a/skainet-io/skainet-io-core/src/androidMain/kotlin/sk/ainet/io/AndroidRandomAccessSource.kt +++ b/skainet-io/skainet-io-core/src/androidMain/kotlin/sk/ainet/io/AndroidRandomAccessSource.kt @@ -26,7 +26,9 @@ import java.nio.channels.FileChannel public class AndroidRandomAccessSource private constructor( private val channel: FileChannel, private val raf: RandomAccessFile, - override val size: Long + override val size: Long, + /** The file these bytes come from — what `StagingPolicy.MAPPED` maps (#1037). */ + override val filePath: String? = null, ) : RandomAccessSource { override fun readAt(position: Long, length: Int): ByteArray { @@ -97,7 +99,7 @@ public class AndroidRandomAccessSource private constructor( val raf = RandomAccessFile(file, "r") val channel = raf.channel - return AndroidRandomAccessSource(channel, raf, raf.length()) + return AndroidRandomAccessSource(channel, raf, raf.length(), file.absolutePath) } /** diff --git a/skainet-io/skainet-io-core/src/androidMain/kotlin/sk/ainet/io/RandomAccessSources.android.kt b/skainet-io/skainet-io-core/src/androidMain/kotlin/sk/ainet/io/RandomAccessSources.android.kt new file mode 100644 index 000000000..af9649af8 --- /dev/null +++ b/skainet-io/skainet-io-core/src/androidMain/kotlin/sk/ainet/io/RandomAccessSources.android.kt @@ -0,0 +1,15 @@ +package sk.ainet.io + +import java.io.File + +/** + * Android: positional `FileChannel` reads ([AndroidRandomAccessSource]), which keeps the streaming + * loader reachable on a device — the sequential fallback materializes the whole file on the ART + * heap and OOMs for model-sized files (#922). + */ +public actual fun openRandomAccessSource(filePath: String): RandomAccessSource? = try { + val file = File(filePath) + if (file.isFile && file.canRead()) AndroidRandomAccessSource.open(file) else null +} catch (e: Exception) { + null +} diff --git a/skainet-io/skainet-io-core/src/androidNativeArm32Main/kotlin/sk/ainet/io/MappedFile.androidNativeArm32.kt b/skainet-io/skainet-io-core/src/androidNativeArm32Main/kotlin/sk/ainet/io/MappedFile.androidNativeArm32.kt new file mode 100644 index 000000000..f7efb2636 --- /dev/null +++ b/skainet-io/skainet-io-core/src/androidNativeArm32Main/kotlin/sk/ainet/io/MappedFile.androidNativeArm32.kt @@ -0,0 +1,4 @@ +package sk.ainet.io + +/** No file mapping here — staging falls back to the heap. */ +public actual fun openMappedFile(filePath: String): MappedFile? = null diff --git a/skainet-io/skainet-io-core/src/androidNativeArm32Main/kotlin/sk/ainet/io/RandomAccessSources.androidNativeArm32.kt b/skainet-io/skainet-io-core/src/androidNativeArm32Main/kotlin/sk/ainet/io/RandomAccessSources.androidNativeArm32.kt new file mode 100644 index 000000000..22e551bfa --- /dev/null +++ b/skainet-io/skainet-io-core/src/androidNativeArm32Main/kotlin/sk/ainet/io/RandomAccessSources.androidNativeArm32.kt @@ -0,0 +1,8 @@ +package sk.ainet.io + +/** + * androidNativeArm32: no positional source. `PosixPreadRandomAccessSource` lives in `native64Main` + * because 32-bit `ssize_t`/`size_t` are `Int` here and `Long` everywhere else (see the target + * comment in this module's build script); on-device file I/O for arm32 is its own concern. + */ +public actual fun openRandomAccessSource(filePath: String): RandomAccessSource? = null diff --git a/skainet-io/skainet-io-core/src/commonMain/kotlin/sk/ainet/io/MappedFile.kt b/skainet-io/skainet-io-core/src/commonMain/kotlin/sk/ainet/io/MappedFile.kt new file mode 100644 index 000000000..c851fd516 --- /dev/null +++ b/skainet-io/skainet-io-core/src/commonMain/kotlin/sk/ainet/io/MappedFile.kt @@ -0,0 +1,35 @@ +package sk.ainet.io + +import sk.ainet.lang.tensor.Shape +import sk.ainet.lang.tensor.data.TensorData +import sk.ainet.lang.types.DType + +/** + * A file whose bytes can be handed out as file-backed pages instead of heap copies — the MAPPED + * half of `quantPolicy × staging` (SKEEP-003 §7, #1037). + * + * A dense FP32 tensor served by [denseFloats] never touches the managed heap: on Android that is + * the difference between a model fitting under the ART cap and not (#921). Bytes that a kernel + * still wants as an array come through [bytes], which copies out of the mapping. + * + * The returned tensor data stays valid after [close] on the platforms that implement this — the + * mapping outlives the channel it came from — but treat closing as "no more tensors from this + * file" and keep the object alive while you are still creating views. + */ +public interface MappedFile : AutoCloseable { + + /** Size of the mapped region in bytes. */ + public val sizeBytes: Long + + /** A dense FP32 tensor of [shape] over the mapping at [byteOffset] — zero heap bytes. */ + public fun denseFloats(byteOffset: Long, shape: Shape): TensorData + + /** [length] bytes copied out of the mapping at [byteOffset], for kernels that need an array. */ + public fun bytes(byteOffset: Long, length: Int): ByteArray +} + +/** + * Map [filePath] for tensor access, or return `null` when this platform cannot map files (JS, + * Wasm, 32-bit Kotlin/Native) or the file cannot be opened. Callers fall back to heap staging. + */ +public expect fun openMappedFile(filePath: String): MappedFile? diff --git a/skainet-io/skainet-io-core/src/commonMain/kotlin/sk/ainet/io/RandomAccessSource.kt b/skainet-io/skainet-io-core/src/commonMain/kotlin/sk/ainet/io/RandomAccessSource.kt index 2d6fa923d..24b0a3191 100644 --- a/skainet-io/skainet-io-core/src/commonMain/kotlin/sk/ainet/io/RandomAccessSource.kt +++ b/skainet-io/skainet-io-core/src/commonMain/kotlin/sk/ainet/io/RandomAccessSource.kt @@ -57,4 +57,14 @@ public interface RandomAccessSource : AutoCloseable { * Convenience method for reading single values. */ public fun readByteAt(position: Long): Byte = readAt(position, 1)[0] + + /** + * The path these bytes came from, when they came from a file — `null` for a Blob, a network + * stream or an in-memory source. + * + * This is what lets a loader honour `StagingPolicy.MAPPED` (#1037): the same source that reads + * the header positionally can name the file to map for the tensor payloads. Defaulted, so no + * existing implementation has to change. + */ + public val filePath: String? get() = null } diff --git a/skainet-io/skainet-io-core/src/commonMain/kotlin/sk/ainet/io/RandomAccessSources.kt b/skainet-io/skainet-io-core/src/commonMain/kotlin/sk/ainet/io/RandomAccessSources.kt new file mode 100644 index 000000000..3bd4ef0f2 --- /dev/null +++ b/skainet-io/skainet-io-core/src/commonMain/kotlin/sk/ainet/io/RandomAccessSources.kt @@ -0,0 +1,16 @@ +package sk.ainet.io + +/** + * Open [filePath] for positional reads, or return `null` when this platform has no file system to + * read it from (JS, Wasm) or the file cannot be opened. + * + * One declaration for the whole project (#1037): `skainet-io-gguf`, `-safetensors` and `-onnx` each + * carried their own `expect fun` plus six identical platform actuals, which drifted — the + * safetensors copy returned `null` on Kotlin/Native while the GGUF copy used `pread(2)`, so the + * same file was streamable through one loader and not the other. The per-format functions are now + * deprecated delegates to this one. + * + * Returning `null` rather than throwing is deliberate: callers fall back to whole-file sequential + * loading, which is how a browser reads a model today. + */ +public expect fun openRandomAccessSource(filePath: String): RandomAccessSource? diff --git a/skainet-io/skainet-io-core/src/commonMain/kotlin/sk/ainet/io/SuspendingRandomAccessSource.kt b/skainet-io/skainet-io-core/src/commonMain/kotlin/sk/ainet/io/SuspendingRandomAccessSource.kt new file mode 100644 index 000000000..c8d6f443d --- /dev/null +++ b/skainet-io/skainet-io-core/src/commonMain/kotlin/sk/ainet/io/SuspendingRandomAccessSource.kt @@ -0,0 +1,46 @@ +package sk.ainet.io + +/** + * A [RandomAccessSource] whose reads may suspend (SKEEP-003 §7, #1037). + * + * On the JVM and Kotlin/Native a positional read is a syscall and the blocking interface is the + * honest one. In a browser or a Wasm host it is not: reading a `Blob` range or issuing an HTTP + * range request is asynchronous, and the only way to serve the blocking interface there is to + * preload a window and fail outside it — which is exactly what `JsBlobRandomAccessSource` had to + * do. This interface is what those platforms can implement without lying. + * + * Blocking sources adapt with [asSuspending]; a remote (HTTP range) implementation belongs in the + * module that brings the HTTP client, not here. + */ +public interface SuspendingRandomAccessSource : AutoCloseable { + + /** The total size of the source in bytes. */ + public val size: Long + + /** + * Read exactly [length] bytes at [position]. + * + * Named `read`, not `readAt`, on purpose: a class can serve both interfaces — the JS blob + * source does — and a `suspend fun readAt` would collide with the blocking one. + */ + public suspend fun read(position: Long, length: Int): ByteArray + + /** Read into [buffer]; returns the number of bytes read (may be short at EOF). */ + public suspend fun read(position: Long, buffer: ByteArray, offset: Int = 0, length: Int = buffer.size): Int +} + +/** + * This blocking source seen as a suspending one. + * + * The reads do not become asynchronous — they are the same positional reads, which for a file are + * cheap syscalls. It exists so code written against the suspending interface can take a JVM or + * Native file source unchanged; a caller doing this on a latency-bound source should dispatch to + * an IO context itself. + */ +public fun RandomAccessSource.asSuspending(): SuspendingRandomAccessSource = object : SuspendingRandomAccessSource { + override val size: Long get() = this@asSuspending.size + override suspend fun read(position: Long, length: Int): ByteArray = this@asSuspending.readAt(position, length) + override suspend fun read(position: Long, buffer: ByteArray, offset: Int, length: Int): Int = + this@asSuspending.readAt(position, buffer, offset, length) + override fun close(): Unit = this@asSuspending.close() +} diff --git a/skainet-io/skainet-io-core/src/commonMain/kotlin/sk/ainet/io/model/StagingPolicy.kt b/skainet-io/skainet-io-core/src/commonMain/kotlin/sk/ainet/io/model/StagingPolicy.kt new file mode 100644 index 000000000..ca0f973d5 --- /dev/null +++ b/skainet-io/skainet-io-core/src/commonMain/kotlin/sk/ainet/io/model/StagingPolicy.kt @@ -0,0 +1,24 @@ +package sk.ainet.io.model + +/** + * Where a loader puts tensor bytes on their way from the file into a tensor (SKEEP-003 §7, #1037). + * + * Orthogonal to [QuantPolicy], which decides *what* the values are: staging decides *where the + * bytes live*. The two are the axes of one loader — `quantPolicy × staging` — instead of the + * separate code paths ("streaming loader" vs "mapped weights helper") they used to be. + */ +public enum class StagingPolicy { + /** Read tensor bytes onto the heap. The historical behaviour, and the only option in a browser. */ + HEAP, + + /** + * Map the file and serve tensors from file-backed pages: dense FP32 tensors become zero-heap + * views the OS pages in on demand and evicts under pressure — the difference between fitting a + * model on a 2 GB device and not (#921, #922). + * + * Falls back to [HEAP] when the platform cannot map (JS, Wasm), when the source is not a file, + * or for tensor types whose kernels still consume heap `ByteArray`s (every packed format, until + * the packed kernels take views — #973). + */ + MAPPED, +} diff --git a/skainet-io/skainet-io-core/src/jsMain/kotlin/sk/ainet/io/JsBlobRandomAccessSource.kt b/skainet-io/skainet-io-core/src/jsMain/kotlin/sk/ainet/io/JsBlobRandomAccessSource.kt index 14760ef73..35523a61c 100644 --- a/skainet-io/skainet-io-core/src/jsMain/kotlin/sk/ainet/io/JsBlobRandomAccessSource.kt +++ b/skainet-io/skainet-io-core/src/jsMain/kotlin/sk/ainet/io/JsBlobRandomAccessSource.kt @@ -34,7 +34,7 @@ import kotlin.js.Promise public class JsBlobRandomAccessSource private constructor( private val blob: Blob, private val preloadedBuffer: ByteArray -) : RandomAccessSource { +) : RandomAccessSource, SuspendingRandomAccessSource { override val size: Long = blob.size.toLong() @@ -98,6 +98,19 @@ public class JsBlobRandomAccessSource private constructor( * @param length Number of bytes to read * @return ByteArray of the requested data */ + /** + * The suspending read (#1037): unlike [readAt], this is not limited to the preloaded window — + * a range outside it is fetched from the blob instead of failing. + */ + override suspend fun read(position: Long, length: Int): ByteArray = readAtAsync(position, length) + + /** Suspending read into [buffer]; see [readAt]. */ + override suspend fun read(position: Long, buffer: ByteArray, offset: Int, length: Int): Int { + val bytes = readAtAsync(position, length) + bytes.copyInto(buffer, offset) + return bytes.size + } + public suspend fun readAtAsync(position: Long, length: Int): ByteArray { require(position >= 0) { "Position must be non-negative: $position" } require(length >= 0) { "Length must be non-negative: $length" } diff --git a/skainet-io/skainet-io-core/src/jsMain/kotlin/sk/ainet/io/MappedFile.js.kt b/skainet-io/skainet-io-core/src/jsMain/kotlin/sk/ainet/io/MappedFile.js.kt new file mode 100644 index 000000000..f7efb2636 --- /dev/null +++ b/skainet-io/skainet-io-core/src/jsMain/kotlin/sk/ainet/io/MappedFile.js.kt @@ -0,0 +1,4 @@ +package sk.ainet.io + +/** No file mapping here — staging falls back to the heap. */ +public actual fun openMappedFile(filePath: String): MappedFile? = null diff --git a/skainet-io/skainet-io-core/src/jsMain/kotlin/sk/ainet/io/RandomAccessSources.js.kt b/skainet-io/skainet-io-core/src/jsMain/kotlin/sk/ainet/io/RandomAccessSources.js.kt new file mode 100644 index 000000000..e3ef73923 --- /dev/null +++ b/skainet-io/skainet-io-core/src/jsMain/kotlin/sk/ainet/io/RandomAccessSources.js.kt @@ -0,0 +1,4 @@ +package sk.ainet.io + +/** No file system to open a path against — a browser or Wasm host reads a model through a Blob or a fetch. */ +public actual fun openRandomAccessSource(filePath: String): RandomAccessSource? = null diff --git a/skainet-io/skainet-io-core/src/jvmAndroidMain/kotlin/sk/ainet/io/JvmMappedFile.kt b/skainet-io/skainet-io-core/src/jvmAndroidMain/kotlin/sk/ainet/io/JvmMappedFile.kt new file mode 100644 index 000000000..d59a8d33e --- /dev/null +++ b/skainet-io/skainet-io-core/src/jvmAndroidMain/kotlin/sk/ainet/io/JvmMappedFile.kt @@ -0,0 +1,62 @@ +package sk.ainet.io + +import sk.ainet.lang.tensor.Shape +import sk.ainet.lang.tensor.data.MmapTensorSource +import sk.ainet.lang.tensor.data.TensorData +import sk.ainet.lang.types.DType +import java.io.File +import java.io.RandomAccessFile + +/** + * JVM and Android [MappedFile]: one read-only `FileChannel.map` over the whole file (available + * since API 1 — no JNI), tensors served as zero-copy views over its pages. + * + * Files larger than 2 GB are refused: a single mapped region is addressed with int offsets. + * Windowed mapping for bigger files is the follow-up SKEEP-003 §7 names. + */ +public class JvmMappedFile private constructor( + private val raf: RandomAccessFile, + private val mmap: MmapTensorSource, + override val sizeBytes: Long, +) : MappedFile { + + override fun denseFloats(byteOffset: Long, shape: Shape): TensorData = + mmap.floatTensorAt(byteOffset, shape) + + override fun bytes(byteOffset: Long, length: Int): ByteArray { + require(byteOffset >= 0 && length >= 0) { "byteOffset and length must be non-negative" } + require(byteOffset + length <= sizeBytes) { "region [$byteOffset, ${byteOffset + length}) exceeds $sizeBytes bytes" } + val out = ByteArray(length) + raf.seek(byteOffset) + raf.readFully(out) + return out + } + + /** Releases the channel; views already handed out keep working (the mapping outlives it). */ + override fun close() { + try { mmap.close() } finally { raf.close() } + } + + public companion object { + /** Map [filePath], or `null` if it is not a readable file or is larger than 2 GB. */ + public fun openOrNull(filePath: String): JvmMappedFile? = try { + val file = File(filePath) + if (!file.isFile || !file.canRead() || file.length() > Int.MAX_VALUE) { + null + } else { + val raf = RandomAccessFile(file, "r") + try { + JvmMappedFile(raf, MmapTensorSource.fromChannel(raf.channel), file.length()) + } catch (t: Throwable) { + raf.close() + throw t + } + } + } catch (e: Exception) { + null + } + } +} + +/** JVM/Android: map with [JvmMappedFile]. */ +public actual fun openMappedFile(filePath: String): MappedFile? = JvmMappedFile.openOrNull(filePath) diff --git a/skainet-io/skainet-io-core/src/jvmAndroidMain/kotlin/sk/ainet/io/MappedRandomAccessSource.kt b/skainet-io/skainet-io-core/src/jvmAndroidMain/kotlin/sk/ainet/io/MappedRandomAccessSource.kt index 1e92c99be..2324d6384 100644 --- a/skainet-io/skainet-io-core/src/jvmAndroidMain/kotlin/sk/ainet/io/MappedRandomAccessSource.kt +++ b/skainet-io/skainet-io-core/src/jvmAndroidMain/kotlin/sk/ainet/io/MappedRandomAccessSource.kt @@ -10,7 +10,9 @@ import java.io.File * model weights that are read repeatedly. */ public class MappedRandomAccessSource private constructor( - private val chunk: JvmMappedMemoryChunk + private val chunk: JvmMappedMemoryChunk, + /** The file these pages come from (#1037). */ + override val filePath: String? = null, ) : RandomAccessSource { override val size: Long get() = chunk.size @@ -44,9 +46,9 @@ public class MappedRandomAccessSource private constructor( public companion object { public fun open(file: File): MappedRandomAccessSource = - MappedRandomAccessSource(JvmMappedMemoryChunk.open(file)) + MappedRandomAccessSource(JvmMappedMemoryChunk.open(file), file.absolutePath) public fun open(path: String): MappedRandomAccessSource = - MappedRandomAccessSource(JvmMappedMemoryChunk.open(path)) + MappedRandomAccessSource(JvmMappedMemoryChunk.open(path), path) } } diff --git a/skainet-io/skainet-io-core/src/jvmMain/kotlin/sk/ainet/io/JvmRandomAccessSource.kt b/skainet-io/skainet-io-core/src/jvmMain/kotlin/sk/ainet/io/JvmRandomAccessSource.kt index ec0de392b..85d56aebd 100644 --- a/skainet-io/skainet-io-core/src/jvmMain/kotlin/sk/ainet/io/JvmRandomAccessSource.kt +++ b/skainet-io/skainet-io-core/src/jvmMain/kotlin/sk/ainet/io/JvmRandomAccessSource.kt @@ -22,7 +22,9 @@ import java.nio.channels.FileChannel public class JvmRandomAccessSource private constructor( private val channel: FileChannel, private val raf: RandomAccessFile, - override val size: Long + override val size: Long, + /** The file these bytes come from — what `StagingPolicy.MAPPED` maps (#1037). */ + override val filePath: String? = null, ) : RandomAccessSource { override fun readAt(position: Long, length: Int): ByteArray { @@ -93,7 +95,7 @@ public class JvmRandomAccessSource private constructor( val raf = RandomAccessFile(file, "r") val channel = raf.channel - return JvmRandomAccessSource(channel, raf, raf.length()) + return JvmRandomAccessSource(channel, raf, raf.length(), file.absolutePath) } /** diff --git a/skainet-io/skainet-io-core/src/jvmMain/kotlin/sk/ainet/io/RandomAccessSources.jvm.kt b/skainet-io/skainet-io-core/src/jvmMain/kotlin/sk/ainet/io/RandomAccessSources.jvm.kt new file mode 100644 index 000000000..05173b4e6 --- /dev/null +++ b/skainet-io/skainet-io-core/src/jvmMain/kotlin/sk/ainet/io/RandomAccessSources.jvm.kt @@ -0,0 +1,11 @@ +package sk.ainet.io + +import java.io.File + +/** JVM: positional reads through a `FileChannel` ([JvmRandomAccessSource]). */ +public actual fun openRandomAccessSource(filePath: String): RandomAccessSource? = try { + val file = File(filePath) + if (file.isFile && file.canRead()) JvmRandomAccessSource.open(file) else null +} catch (e: Exception) { + null // any failure falls back to sequential loading, as the per-format copies did +} diff --git a/skainet-io/skainet-io-core/src/jvmTest/kotlin/sk/ainet/io/RandomAccessSourcesTest.kt b/skainet-io/skainet-io-core/src/jvmTest/kotlin/sk/ainet/io/RandomAccessSourcesTest.kt new file mode 100644 index 000000000..9624d22f3 --- /dev/null +++ b/skainet-io/skainet-io-core/src/jvmTest/kotlin/sk/ainet/io/RandomAccessSourcesTest.kt @@ -0,0 +1,95 @@ +package sk.ainet.io + +import kotlinx.coroutines.runBlocking +import sk.ainet.lang.tensor.Shape +import java.io.File +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * #1037: one source factory for every format, a file path a loader can map, and a suspending read + * for the platforms where a positional read cannot block. + */ +class RandomAccessSourcesTest { + + private fun tempFile(bytes: ByteArray): File = + File.createTempFile("skainet-ras-", ".bin").apply { deleteOnExit(); writeBytes(bytes) } + + private val payload = ByteArray(256) { it.toByte() } + + @Test + fun `the shared factory opens a file and names it`() { + val f = tempFile(payload) + try { + val source = assertNotNull(openRandomAccessSource(f.absolutePath), "JVM must open a readable file") + source.use { + assertEquals(payload.size.toLong(), it.size) + assertContentEquals(payload.copyOfRange(16, 32), it.readAt(16, 16)) + assertEquals(f.absolutePath, it.filePath, "MAPPED staging needs the path (#1037)") + } + } finally { + f.delete() + } + } + + @Test + fun `a missing or unreadable path yields null rather than throwing`() { + assertNull(openRandomAccessSource("/definitely/not/here/model.gguf")) + val dir = File(System.getProperty("java.io.tmpdir")) + assertNull(openRandomAccessSource(dir.absolutePath), "a directory is not a source") + } + + @Test + fun `a blocking source adapts to the suspending interface`() { + val f = tempFile(payload) + try { + openRandomAccessSource(f.absolutePath)!!.asSuspending().use { source -> + runBlocking { + assertEquals(payload.size.toLong(), source.size) + assertContentEquals(payload.copyOfRange(64, 96), source.read(64, 32)) + val buffer = ByteArray(8) + assertEquals(8, source.read(8, buffer)) + assertContentEquals(payload.copyOfRange(8, 16), buffer) + } + } + } finally { + f.delete() + } + } + + @Test + fun `mapping a file serves dense floats without copying them onto the heap`() { + val floats = floatArrayOf(1f, -2.5f, 3.25f, 4f, 5f, 6f) + val bytes = ByteArray(floats.size * 4) + for (i in floats.indices) { + val bits = floats[i].toRawBits() + bytes[i * 4] = (bits and 0xFF).toByte() + bytes[i * 4 + 1] = ((bits ushr 8) and 0xFF).toByte() + bytes[i * 4 + 2] = ((bits ushr 16) and 0xFF).toByte() + bytes[i * 4 + 3] = ((bits ushr 24) and 0xFF).toByte() + } + val f = tempFile(bytes) + try { + val mapped = assertNotNull(openMappedFile(f.absolutePath), "JVM must map a readable file") + mapped.use { + assertEquals(bytes.size.toLong(), it.sizeBytes) + val data = it.denseFloats(byteOffset = 8, shape = Shape(2, 2)) + assertEquals(3.25f, data.get(0, 0)) + assertEquals(6f, data.get(1, 1)) + assertContentEquals(bytes.copyOfRange(0, 8), it.bytes(0, 8), "raw bytes come out of the same mapping") + } + } finally { + f.delete() + } + } + + @Test + fun `mapping refuses what it cannot map`() { + assertNull(openMappedFile("/definitely/not/here/model.gguf")) + assertTrue(openMappedFile(File(System.getProperty("java.io.tmpdir")).absolutePath) == null, "a directory is not mappable") + } +} diff --git a/skainet-io/skainet-io-core/src/native64Main/kotlin/sk/ainet/io/MappedFile.native64.kt b/skainet-io/skainet-io-core/src/native64Main/kotlin/sk/ainet/io/MappedFile.native64.kt new file mode 100644 index 000000000..0fc826690 --- /dev/null +++ b/skainet-io/skainet-io-core/src/native64Main/kotlin/sk/ainet/io/MappedFile.native64.kt @@ -0,0 +1,8 @@ +package sk.ainet.io + +/** + * Kotlin/Native: not yet. `mmap(2)` plus a `TensorData` over a `CPointer` is the natural + * implementation and belongs with the native `Storage.Mapped` work (#1020); until then native + * staging falls back to the heap rather than pretending. + */ +public actual fun openMappedFile(filePath: String): MappedFile? = null diff --git a/skainet-io/skainet-io-core/src/native64Main/kotlin/sk/ainet/io/PosixPreadRandomAccessSource.kt b/skainet-io/skainet-io-core/src/native64Main/kotlin/sk/ainet/io/PosixPreadRandomAccessSource.kt index df29a97fa..34aae21a5 100644 --- a/skainet-io/skainet-io-core/src/native64Main/kotlin/sk/ainet/io/PosixPreadRandomAccessSource.kt +++ b/skainet-io/skainet-io-core/src/native64Main/kotlin/sk/ainet/io/PosixPreadRandomAccessSource.kt @@ -30,7 +30,9 @@ import platform.posix.strerror @OptIn(ExperimentalForeignApi::class) public class PosixPreadRandomAccessSource private constructor( private val fd: Int, - override val size: Long + override val size: Long, + /** The file these bytes come from — what `StagingPolicy.MAPPED` would map (#1037). */ + override val filePath: String? = null, ) : RandomAccessSource { private var closed = false @@ -98,7 +100,7 @@ public class PosixPreadRandomAccessSource private constructor( platform.posix.close(fd) return@memScoped null } - PosixPreadRandomAccessSource(fd, st.st_size.toLong()) + PosixPreadRandomAccessSource(fd, st.st_size.toLong(), path) } } } diff --git a/skainet-io/skainet-io-core/src/native64Main/kotlin/sk/ainet/io/RandomAccessSources.native64.kt b/skainet-io/skainet-io-core/src/native64Main/kotlin/sk/ainet/io/RandomAccessSources.native64.kt new file mode 100644 index 000000000..5bbbe6372 --- /dev/null +++ b/skainet-io/skainet-io-core/src/native64Main/kotlin/sk/ainet/io/RandomAccessSources.native64.kt @@ -0,0 +1,5 @@ +package sk.ainet.io + +/** 64-bit Kotlin/Native: POSIX `pread(2)` ([PosixPreadRandomAccessSource]); `null` if it cannot open. */ +public actual fun openRandomAccessSource(filePath: String): RandomAccessSource? = + PosixPreadRandomAccessSource.open(filePath) diff --git a/skainet-io/skainet-io-core/src/wasmJsMain/kotlin/sk/ainet/io/JsBlobRandomAccessSource.kt b/skainet-io/skainet-io-core/src/wasmJsMain/kotlin/sk/ainet/io/JsBlobRandomAccessSource.kt index 12360b02a..636fb7176 100644 --- a/skainet-io/skainet-io-core/src/wasmJsMain/kotlin/sk/ainet/io/JsBlobRandomAccessSource.kt +++ b/skainet-io/skainet-io-core/src/wasmJsMain/kotlin/sk/ainet/io/JsBlobRandomAccessSource.kt @@ -27,7 +27,7 @@ public class JsBlobRandomAccessSource private constructor( private val blob: Blob, private val preloadedBuffer: ByteArray, private val blobSize: Long -) : RandomAccessSource { +) : RandomAccessSource, SuspendingRandomAccessSource { override val size: Long = blobSize @@ -86,6 +86,19 @@ public class JsBlobRandomAccessSource private constructor( * * Use this for loading tensor data that may be beyond the preloaded buffer. */ + /** + * The suspending read (#1037): unlike [readAt], this is not limited to the preloaded window — + * a range outside it is fetched from the blob instead of failing. + */ + override suspend fun read(position: Long, length: Int): ByteArray = readAtAsync(position, length) + + /** Suspending read into [buffer]; see [readAt]. */ + override suspend fun read(position: Long, buffer: ByteArray, offset: Int, length: Int): Int { + val bytes = readAtAsync(position, length) + bytes.copyInto(buffer, offset) + return bytes.size + } + public suspend fun readAtAsync(position: Long, length: Int): ByteArray { require(position >= 0) { "Position must be non-negative: $position" } require(length >= 0) { "Length must be non-negative: $length" } diff --git a/skainet-io/skainet-io-core/src/wasmJsMain/kotlin/sk/ainet/io/MappedFile.wasmJs.kt b/skainet-io/skainet-io-core/src/wasmJsMain/kotlin/sk/ainet/io/MappedFile.wasmJs.kt new file mode 100644 index 000000000..f7efb2636 --- /dev/null +++ b/skainet-io/skainet-io-core/src/wasmJsMain/kotlin/sk/ainet/io/MappedFile.wasmJs.kt @@ -0,0 +1,4 @@ +package sk.ainet.io + +/** No file mapping here — staging falls back to the heap. */ +public actual fun openMappedFile(filePath: String): MappedFile? = null diff --git a/skainet-io/skainet-io-core/src/wasmJsMain/kotlin/sk/ainet/io/RandomAccessSources.wasmJs.kt b/skainet-io/skainet-io-core/src/wasmJsMain/kotlin/sk/ainet/io/RandomAccessSources.wasmJs.kt new file mode 100644 index 000000000..e3ef73923 --- /dev/null +++ b/skainet-io/skainet-io-core/src/wasmJsMain/kotlin/sk/ainet/io/RandomAccessSources.wasmJs.kt @@ -0,0 +1,4 @@ +package sk.ainet.io + +/** No file system to open a path against — a browser or Wasm host reads a model through a Blob or a fetch. */ +public actual fun openRandomAccessSource(filePath: String): RandomAccessSource? = null diff --git a/skainet-io/skainet-io-core/src/wasmWasiMain/kotlin/sk/ainet/io/MappedFile.wasmWasi.kt b/skainet-io/skainet-io-core/src/wasmWasiMain/kotlin/sk/ainet/io/MappedFile.wasmWasi.kt new file mode 100644 index 000000000..f7efb2636 --- /dev/null +++ b/skainet-io/skainet-io-core/src/wasmWasiMain/kotlin/sk/ainet/io/MappedFile.wasmWasi.kt @@ -0,0 +1,4 @@ +package sk.ainet.io + +/** No file mapping here — staging falls back to the heap. */ +public actual fun openMappedFile(filePath: String): MappedFile? = null diff --git a/skainet-io/skainet-io-core/src/wasmWasiMain/kotlin/sk/ainet/io/RandomAccessSources.wasmWasi.kt b/skainet-io/skainet-io-core/src/wasmWasiMain/kotlin/sk/ainet/io/RandomAccessSources.wasmWasi.kt new file mode 100644 index 000000000..e3ef73923 --- /dev/null +++ b/skainet-io/skainet-io-core/src/wasmWasiMain/kotlin/sk/ainet/io/RandomAccessSources.wasmWasi.kt @@ -0,0 +1,4 @@ +package sk.ainet.io + +/** No file system to open a path against — a browser or Wasm host reads a model through a Blob or a fetch. */ +public actual fun openRandomAccessSource(filePath: String): RandomAccessSource? = null diff --git a/skainet-io/skainet-io-gguf/src/androidMain/kotlin/sk/ainet/io/gguf/RandomAccessSourceFactory.android.kt b/skainet-io/skainet-io-gguf/src/androidMain/kotlin/sk/ainet/io/gguf/RandomAccessSourceFactory.android.kt deleted file mode 100644 index 003e20260..000000000 --- a/skainet-io/skainet-io-gguf/src/androidMain/kotlin/sk/ainet/io/gguf/RandomAccessSourceFactory.android.kt +++ /dev/null @@ -1,27 +0,0 @@ -package sk.ainet.io.gguf - -import sk.ainet.io.AndroidRandomAccessSource -import sk.ainet.io.RandomAccessSource -import java.io.File - -/** - * Android implementation of [createRandomAccessSource]. - * - * Uses [AndroidRandomAccessSource] backed by positional FileChannel reads - * for efficient random access to GGUF files. This keeps the streaming - * loader reachable on Android; the legacy fallback materialises the whole - * file on the ART heap, which OOMs on real devices for model-sized files - * (#922). - */ -public actual fun createRandomAccessSource(filePath: String): RandomAccessSource? { - return try { - val file = File(filePath) - if (file.exists() && file.isFile && file.canRead()) { - AndroidRandomAccessSource.open(file) - } else { - null - } - } catch (e: Exception) { - null // Fall back to legacy mode on any error - } -} diff --git a/skainet-io/skainet-io-gguf/src/commonMain/kotlin/sk/ainet/io/gguf/GgufModelParser.kt b/skainet-io/skainet-io-gguf/src/commonMain/kotlin/sk/ainet/io/gguf/GgufModelParser.kt index 6dc5b1eb2..433f6ded3 100644 --- a/skainet-io/skainet-io-gguf/src/commonMain/kotlin/sk/ainet/io/gguf/GgufModelParser.kt +++ b/skainet-io/skainet-io-gguf/src/commonMain/kotlin/sk/ainet/io/gguf/GgufModelParser.kt @@ -2,6 +2,7 @@ package sk.ainet.io.gguf import kotlinx.io.buffered import sk.ainet.io.model.* +import sk.ainet.io.openRandomAccessSource /** * GGUF model parser extending BaseModelParser. @@ -54,7 +55,7 @@ public class GgufModelParser : BaseModelParser(), AutoCloseable { _filePath = filePath // Try streaming mode first (JVM only) - val streamingSource = createRandomAccessSource(filePath) + val streamingSource = openRandomAccessSource(filePath) if (streamingSource != null) { // Streaming mode: parse metadata only (~1 MB memory) diff --git a/skainet-io/skainet-io-gguf/src/commonMain/kotlin/sk/ainet/io/gguf/RandomAccessSourceFactory.kt b/skainet-io/skainet-io-gguf/src/commonMain/kotlin/sk/ainet/io/gguf/RandomAccessSourceFactory.kt index eb483dd06..2574648d5 100644 --- a/skainet-io/skainet-io-gguf/src/commonMain/kotlin/sk/ainet/io/gguf/RandomAccessSourceFactory.kt +++ b/skainet-io/skainet-io-gguf/src/commonMain/kotlin/sk/ainet/io/gguf/RandomAccessSourceFactory.kt @@ -1,18 +1,17 @@ package sk.ainet.io.gguf import sk.ainet.io.RandomAccessSource +import sk.ainet.io.openRandomAccessSource /** - * Platform-specific factory for creating [RandomAccessSource] instances. + * Open a GGUF file for positional reads, or `null` where the platform has no file system. * - * Returns null on platforms that don't support random file access, - * allowing callers to fall back to legacy sequential loading. - * - * Supported platforms: - * - JVM: Uses FileChannel for efficient random access - * - JS/Native: Returns null (use legacy GGUFReader instead) - * - * @param filePath Path to the file - * @return A RandomAccessSource, or null if not supported on this platform + * @deprecated One declaration for every format now lives in `skainet-io-core` (#1037): this module, + * `-safetensors` and `-onnx` each carried an identical `expect fun` with six platform actuals, + * and they had already drifted apart on Kotlin/Native. */ -public expect fun createRandomAccessSource(filePath: String): RandomAccessSource? +@Deprecated( + message = "The per-format source factories are one function in skainet-io-core (SKEEP-003 §7, #1037).", + replaceWith = ReplaceWith("openRandomAccessSource(filePath)", "sk.ainet.io.openRandomAccessSource"), +) +public fun createRandomAccessSource(filePath: String): RandomAccessSource? = openRandomAccessSource(filePath) diff --git a/skainet-io/skainet-io-gguf/src/commonMain/kotlin/sk/ainet/io/gguf/StreamingGgufParametersLoader.kt b/skainet-io/skainet-io-gguf/src/commonMain/kotlin/sk/ainet/io/gguf/StreamingGgufParametersLoader.kt index 769ca91d4..05e935135 100644 --- a/skainet-io/skainet-io-gguf/src/commonMain/kotlin/sk/ainet/io/gguf/StreamingGgufParametersLoader.kt +++ b/skainet-io/skainet-io-gguf/src/commonMain/kotlin/sk/ainet/io/gguf/StreamingGgufParametersLoader.kt @@ -5,6 +5,8 @@ import sk.ainet.io.ParametersLoader import sk.ainet.io.RandomAccessSource import sk.ainet.io.gguf.dequant.DequantOps import sk.ainet.io.model.QuantPolicy +import sk.ainet.io.model.StagingPolicy +import sk.ainet.io.openMappedFile import sk.ainet.lang.tensor.Shape import sk.ainet.lang.tensor.Tensor import sk.ainet.lang.tensor.data.Bf16DenseTensorData @@ -70,6 +72,18 @@ public class StreamingGgufParametersLoader( * packed block storage instead) and is rejected eagerly. */ private val quantPolicy: QuantPolicy = QuantPolicy.NATIVE_OPTIMIZED, + /** + * Where tensor bytes live on their way into a tensor (#1037). [QuantPolicy] says *what* the + * values are; this says *where the bytes are* — the two axes of one loader. + * + * - [StagingPolicy.HEAP] (default — today's behaviour): every tensor is read onto the heap. + * - [StagingPolicy.MAPPED]: the file is mapped once and dense F32 tensors are served as + * zero-heap views over its pages (what `MappedGgufWeights` did as a separate helper). Packed + * tensors still come through as heap arrays, because that is what their kernels take until + * #973; and the whole thing falls back to heap staging when the platform cannot map or the + * source is not a file, so a browser build behaves exactly as before. + */ + private val staging: StagingPolicy = StagingPolicy.HEAP, ) : ParametersLoader { init { @@ -86,7 +100,12 @@ public class StreamingGgufParametersLoader( dtype: KClass, onTensorLoaded: (String, Tensor) -> Unit ) { - StreamingGGUFReader.open(sourceProvider()).use { reader -> + val source = sourceProvider() + // MAPPED staging needs a file to map; a Blob or an in-memory source has no path and + // silently stays on the heap, which is the documented fallback rather than a failure. + val mapped = if (staging == StagingPolicy.MAPPED) source.filePath?.let { openMappedFile(it) } else null + try { + StreamingGGUFReader.open(source).use { reader -> val tensors = reader.tensors failFastOnUnsupportedTensorTypes(tensors) val total = tensors.size.toLong() @@ -94,7 +113,27 @@ public class StreamingGgufParametersLoader( for (tensorInfo in tensors) { val shape = Shape(*tensorInfo.shape.map { it.toInt() }.toIntArray()) - val rawBytes = reader.loadTensorData(tensorInfo) + // A dense F32 tensor under MAPPED staging never reaches the heap: it is a view over + // file-backed pages. Everything else reads its bytes (out of the mapping when there + // is one — one page-cache copy instead of a channel read). + val mappedFloats: Tensor? = + if (mapped != null && tensorInfo.tensorType == GGMLQuantizationType.F32 && dtype == FP32::class) { + @Suppress("UNCHECKED_CAST") + ctx.fromData( + mapped.denseFloats(tensorInfo.absoluteDataOffset, shape) as sk.ainet.lang.tensor.data.TensorData, + dtype, + ) + } else { + null + } + if (mappedFloats != null) { + onTensorLoaded(tensorInfo.name, mappedFloats) + current += 1 + onProgress(current, total, tensorInfo.name) + continue + } + val rawBytes = mapped?.bytes(tensorInfo.absoluteDataOffset, tensorInfo.nBytes.toInt()) + ?: reader.loadTensorData(tensorInfo) val tensor: Tensor? = when (tensorInfo.tensorType) { GGMLQuantizationType.F32 -> { @@ -166,6 +205,9 @@ public class StreamingGgufParametersLoader( onProgress(current, total, tensorInfo.name) } } + } finally { + mapped?.close() + } } /** diff --git a/skainet-io/skainet-io-gguf/src/jsMain/kotlin/sk/ainet/io/gguf/RandomAccessSourceFactory.js.kt b/skainet-io/skainet-io-gguf/src/jsMain/kotlin/sk/ainet/io/gguf/RandomAccessSourceFactory.js.kt deleted file mode 100644 index 93464ec4c..000000000 --- a/skainet-io/skainet-io-gguf/src/jsMain/kotlin/sk/ainet/io/gguf/RandomAccessSourceFactory.js.kt +++ /dev/null @@ -1,14 +0,0 @@ -package sk.ainet.io.gguf - -import sk.ainet.io.RandomAccessSource - -/** - * JS implementation of [createRandomAccessSource]. - * - * Returns null as JavaScript doesn't have efficient random file access. - * Callers should fall back to legacy GGUFReader which loads the full file. - * - * Future: Could implement using File System Access API for browsers - * that support it, or Node.js fs module for server-side. - */ -public actual fun createRandomAccessSource(filePath: String): RandomAccessSource? = null diff --git a/skainet-io/skainet-io-gguf/src/jvmAndroidMain/kotlin/sk/ainet/io/gguf/MappedGgufWeights.kt b/skainet-io/skainet-io-gguf/src/jvmAndroidMain/kotlin/sk/ainet/io/gguf/MappedGgufWeights.kt index fa508f831..c7eb8cc74 100644 --- a/skainet-io/skainet-io-gguf/src/jvmAndroidMain/kotlin/sk/ainet/io/gguf/MappedGgufWeights.kt +++ b/skainet-io/skainet-io-gguf/src/jvmAndroidMain/kotlin/sk/ainet/io/gguf/MappedGgufWeights.kt @@ -11,6 +11,11 @@ import java.nio.channels.FileChannel /** * Memory-mapped GGUF weight access for the JVM and Android (#921). * + * Since #1037 this is the **per-tensor** face of `StagingPolicy.MAPPED`: to load a whole model + * from mapped pages, pass `staging = StagingPolicy.MAPPED` to [StreamingGgufParametersLoader] and + * get the same file-backed tensors through the ordinary loader. This class stays for callers that + * want to reach individual tensors (or their `TensorStorage` descriptors) without loading a model. + * * On Android every heap array counts against the hard ART cap (256 MB * default, 512 MB with `largeHeap`), which limits practical model size no * matter how good the kernels are. This helper keeps weight bytes in @@ -114,7 +119,7 @@ public class MappedGgufWeights private constructor( * O(metadata)), then maps the whole file read-only. */ public fun open(filePath: String): MappedGgufWeights { - val source = createRandomAccessSource(filePath) + val source = sk.ainet.io.openRandomAccessSource(filePath) ?: throw IllegalArgumentException("Cannot open for random access: $filePath") val reader = StreamingGGUFReader.open(source) val raf = RandomAccessFile(filePath, "r") diff --git a/skainet-io/skainet-io-gguf/src/jvmMain/kotlin/sk/ainet/io/gguf/RandomAccessSourceFactory.jvm.kt b/skainet-io/skainet-io-gguf/src/jvmMain/kotlin/sk/ainet/io/gguf/RandomAccessSourceFactory.jvm.kt deleted file mode 100644 index 993b12df5..000000000 --- a/skainet-io/skainet-io-gguf/src/jvmMain/kotlin/sk/ainet/io/gguf/RandomAccessSourceFactory.jvm.kt +++ /dev/null @@ -1,24 +0,0 @@ -package sk.ainet.io.gguf - -import sk.ainet.io.JvmRandomAccessSource -import sk.ainet.io.RandomAccessSource -import java.io.File - -/** - * JVM implementation of [createRandomAccessSource]. - * - * Uses [JvmRandomAccessSource] backed by FileChannel for efficient - * random access to GGUF files. - */ -public actual fun createRandomAccessSource(filePath: String): RandomAccessSource? { - return try { - val file = File(filePath) - if (file.exists() && file.isFile && file.canRead()) { - JvmRandomAccessSource.open(file) - } else { - null - } - } catch (e: Exception) { - null // Fall back to legacy mode on any error - } -} diff --git a/skainet-io/skainet-io-gguf/src/jvmTest/kotlin/sk/ainet/io/gguf/StagingPolicyParityTest.kt b/skainet-io/skainet-io-gguf/src/jvmTest/kotlin/sk/ainet/io/gguf/StagingPolicyParityTest.kt new file mode 100644 index 000000000..c089038ee --- /dev/null +++ b/skainet-io/skainet-io-gguf/src/jvmTest/kotlin/sk/ainet/io/gguf/StagingPolicyParityTest.kt @@ -0,0 +1,131 @@ +package sk.ainet.io.gguf + +import kotlinx.coroutines.runBlocking +import sk.ainet.context.DefaultDataExecutionContext +import sk.ainet.io.JvmRandomAccessSource +import sk.ainet.io.model.QuantPolicy +import sk.ainet.io.model.StagingPolicy +import sk.ainet.lang.tensor.Tensor +import sk.ainet.lang.tensor.data.FloatArrayTensorData +import sk.ainet.lang.tensor.data.MmapFloatTensorData +import sk.ainet.lang.types.FP32 +import java.io.File +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * #1037: the streaming-dequant path (#782) and the mapped path are two configurations of **one** + * loader — `quantPolicy × staging` — not two code paths that can drift. + * + * Every combination must produce the same numbers; only *where the bytes live* differs. That is + * the whole claim, so it is asserted directly: four loads of the same file, compared element by + * element, plus the storage type each staging is supposed to produce. + */ +class StagingPolicyParityTest { + + private fun file(): File = SyntheticGguf.write( + SyntheticGguf.tensor("w_f32", GGMLQuantizationType.F32, elements = 1024), + SyntheticGguf.tensor("w_q4k", GGMLQuantizationType.Q4_K, elements = 1024), + SyntheticGguf.tensor("w_q80", GGMLQuantizationType.Q8_0, elements = 1024), + SyntheticGguf.tensor("w_f16", GGMLQuantizationType.F16, elements = 1024), + ) + + private fun load(f: File, quant: QuantPolicy, staging: StagingPolicy): Map> { + val ctx = DefaultDataExecutionContext() + val loaded = LinkedHashMap>() + runBlocking { + StreamingGgufParametersLoader( + sourceProvider = { JvmRandomAccessSource.open(f) }, + quantPolicy = quant, + staging = staging, + ).load(ctx, FP32::class) { name, tensor -> loaded[name] = tensor } + } + return loaded + } + + private fun values(t: Tensor): FloatArray = t.data.copyToFloatArray() + + @Test + fun `staging never changes the numbers, for either quant policy`() { + val f = file() + try { + // The claim of #1037: staging decides *where the bytes live*, quantPolicy decides *what + // the values are*. So HEAP and MAPPED must agree element for element under each policy. + // (Across policies they legitimately differ: a packed tensor's own `get` returns codes, + // which is what StreamingDequantPolicyParityTest covers.) + for (quant in listOf(QuantPolicy.NATIVE_OPTIMIZED, QuantPolicy.DEQUANTIZE_TO_FP32)) { + val heap = load(f, quant, StagingPolicy.HEAP) + val mapped = load(f, quant, StagingPolicy.MAPPED) + assertEquals(heap.keys, mapped.keys, "$quant: tensor set") + assertTrue(heap.isNotEmpty()) + for ((name, tensor) in mapped) { + assertContentEquals(values(heap.getValue(name)), values(tensor), "$quant: values of $name") + assertEquals(heap.getValue(name).shape, tensor.shape, "$quant: shape of $name") + } + } + } finally { + f.delete() + } + } + + @Test + fun `mapped staging serves dense F32 tensors from file-backed pages`() { + val f = file() + try { + val heap = load(f, QuantPolicy.NATIVE_OPTIMIZED, StagingPolicy.HEAP) + val mapped = load(f, QuantPolicy.NATIVE_OPTIMIZED, StagingPolicy.MAPPED) + + assertTrue( + heap.getValue("w_f32").data is FloatArrayTensorData<*>, + "heap staging keeps F32 on the heap, got ${heap.getValue("w_f32").data::class.simpleName}", + ) + assertTrue( + mapped.getValue("w_f32").data is MmapFloatTensorData<*>, + "mapped staging must not copy F32 onto the heap, got ${mapped.getValue("w_f32").data::class.simpleName}", + ) + // packed tensors still arrive as packed block data: their kernels take arrays until #973 + assertEquals( + heap.getValue("w_q4k").data::class.simpleName, + mapped.getValue("w_q4k").data::class.simpleName, + "packed staging is unchanged by the mapping", + ) + } finally { + f.delete() + } + } + + @Test + fun `mapped staging falls back to the heap when the source is not a file`() { + val f = file() + try { + // a source with no path — the documented fallback, not a failure + val ctx = DefaultDataExecutionContext() + val loaded = LinkedHashMap>() + runBlocking { + StreamingGgufParametersLoader( + sourceProvider = { PathlessSource(JvmRandomAccessSource.open(f)) }, + staging = StagingPolicy.MAPPED, + ).load(ctx, FP32::class) { name, tensor -> loaded[name] = tensor } + } + assertTrue(loaded.getValue("w_f32").data is FloatArrayTensorData<*>, "no path to map: stays on the heap") + assertContentEquals( + values(load(f, QuantPolicy.NATIVE_OPTIMIZED, StagingPolicy.HEAP).getValue("w_f32")), + values(loaded.getValue("w_f32")), + ) + } finally { + f.delete() + } + } + + /** A source that reads fine but cannot say where its bytes live (a Blob, a socket, a test). */ + private class PathlessSource(private val delegate: sk.ainet.io.RandomAccessSource) : sk.ainet.io.RandomAccessSource { + override val size: Long get() = delegate.size + override val filePath: String? get() = null + override fun readAt(position: Long, length: Int): ByteArray = delegate.readAt(position, length) + override fun readAt(position: Long, buffer: ByteArray, offset: Int, length: Int): Int = + delegate.readAt(position, buffer, offset, length) + override fun close(): Unit = delegate.close() + } +} diff --git a/skainet-io/skainet-io-gguf/src/nativeMain/kotlin/sk/ainet/io/gguf/RandomAccessSourceFactory.native.kt b/skainet-io/skainet-io-gguf/src/nativeMain/kotlin/sk/ainet/io/gguf/RandomAccessSourceFactory.native.kt deleted file mode 100644 index 5cd3b2086..000000000 --- a/skainet-io/skainet-io-gguf/src/nativeMain/kotlin/sk/ainet/io/gguf/RandomAccessSourceFactory.native.kt +++ /dev/null @@ -1,14 +0,0 @@ -package sk.ainet.io.gguf - -import sk.ainet.io.PosixPreadRandomAccessSource -import sk.ainet.io.RandomAccessSource - -/** - * Native implementation of [createRandomAccessSource] using POSIX `pread(2)`. - * - * Returns `null` if the file cannot be opened (missing, permission denied, - * etc.), matching the JVM actual's contract so callers can fall back to the - * legacy sequential reader. - */ -public actual fun createRandomAccessSource(filePath: String): RandomAccessSource? = - PosixPreadRandomAccessSource.open(filePath) diff --git a/skainet-io/skainet-io-gguf/src/wasmJsMain/kotlin/sk/ainet/io/gguf/RandomAccessSourceFactory.wasmJs.kt b/skainet-io/skainet-io-gguf/src/wasmJsMain/kotlin/sk/ainet/io/gguf/RandomAccessSourceFactory.wasmJs.kt deleted file mode 100644 index 42139bb57..000000000 --- a/skainet-io/skainet-io-gguf/src/wasmJsMain/kotlin/sk/ainet/io/gguf/RandomAccessSourceFactory.wasmJs.kt +++ /dev/null @@ -1,11 +0,0 @@ -package sk.ainet.io.gguf - -import sk.ainet.io.RandomAccessSource - -/** - * WasmJS implementation of [createRandomAccessSource]. - * - * Returns null as WasmJS doesn't have efficient random file access. - * Callers should fall back to legacy GGUFReader which loads the full file. - */ -public actual fun createRandomAccessSource(filePath: String): RandomAccessSource? = null diff --git a/skainet-io/skainet-io-gguf/src/wasmWasiMain/kotlin/sk/ainet/io/gguf/RandomAccessSourceFactory.wasmWasi.kt b/skainet-io/skainet-io-gguf/src/wasmWasiMain/kotlin/sk/ainet/io/gguf/RandomAccessSourceFactory.wasmWasi.kt deleted file mode 100644 index cdbdbf307..000000000 --- a/skainet-io/skainet-io-gguf/src/wasmWasiMain/kotlin/sk/ainet/io/gguf/RandomAccessSourceFactory.wasmWasi.kt +++ /dev/null @@ -1,11 +0,0 @@ -package sk.ainet.io.gguf - -import sk.ainet.io.RandomAccessSource - -/** - * WasmWASI implementation of [createRandomAccessSource]. - * - * Returns null as WasmWASI doesn't have efficient random file access. - * Callers should fall back to legacy GGUFReader which loads the full file. - */ -public actual fun createRandomAccessSource(filePath: String): RandomAccessSource? = null diff --git a/skainet-io/skainet-io-onnx/src/androidMain/kotlin/sk/ainet/io/onnx/RandomAccessSourceFactory.android.kt b/skainet-io/skainet-io-onnx/src/androidMain/kotlin/sk/ainet/io/onnx/RandomAccessSourceFactory.android.kt deleted file mode 100644 index 807917159..000000000 --- a/skainet-io/skainet-io-onnx/src/androidMain/kotlin/sk/ainet/io/onnx/RandomAccessSourceFactory.android.kt +++ /dev/null @@ -1,26 +0,0 @@ -package sk.ainet.io.onnx - -import sk.ainet.io.AndroidRandomAccessSource -import sk.ainet.io.RandomAccessSource -import java.io.File - -/** - * Android implementation of [createOnnxRandomAccessSource]. - * - * Uses [AndroidRandomAccessSource] backed by positional FileChannel reads - * for efficient random access to ONNX files, so streaming access works on - * Android instead of falling back to a full-file load on the ART heap - * (#922). - */ -public actual fun createOnnxRandomAccessSource(filePath: String): RandomAccessSource? { - return try { - val file = File(filePath) - if (file.exists() && file.isFile && file.canRead()) { - AndroidRandomAccessSource.open(file) - } else { - null - } - } catch (e: Exception) { - null // Fall back to legacy mode on any error - } -} diff --git a/skainet-io/skainet-io-onnx/src/commonMain/kotlin/sk/ainet/io/onnx/RandomAccessSourceFactory.kt b/skainet-io/skainet-io-onnx/src/commonMain/kotlin/sk/ainet/io/onnx/RandomAccessSourceFactory.kt index 74ec4f9e4..190cc743d 100644 --- a/skainet-io/skainet-io-onnx/src/commonMain/kotlin/sk/ainet/io/onnx/RandomAccessSourceFactory.kt +++ b/skainet-io/skainet-io-onnx/src/commonMain/kotlin/sk/ainet/io/onnx/RandomAccessSourceFactory.kt @@ -1,18 +1,11 @@ package sk.ainet.io.onnx import sk.ainet.io.RandomAccessSource +import sk.ainet.io.openRandomAccessSource -/** - * Platform-specific factory for creating [RandomAccessSource] instances for ONNX files. - * - * Returns null on platforms that don't support random file access, - * allowing callers to fall back to legacy sequential loading. - * - * Supported platforms: - * - JVM: Uses FileChannel for efficient random access - * - JS/Native: Returns null (use legacy OnnxLoader instead) - * - * @param filePath Path to the file - * @return A RandomAccessSource, or null if not supported on this platform - */ -public expect fun createOnnxRandomAccessSource(filePath: String): RandomAccessSource? +/** Open an ONNX file for positional reads, or `null` where the platform has no file system. */ +@Deprecated( + message = "The per-format source factories are one function in skainet-io-core (SKEEP-003 §7, #1037).", + replaceWith = ReplaceWith("openRandomAccessSource(filePath)", "sk.ainet.io.openRandomAccessSource"), +) +public fun createOnnxRandomAccessSource(filePath: String): RandomAccessSource? = openRandomAccessSource(filePath) diff --git a/skainet-io/skainet-io-onnx/src/jsMain/kotlin/sk/ainet/io/onnx/RandomAccessSourceFactory.js.kt b/skainet-io/skainet-io-onnx/src/jsMain/kotlin/sk/ainet/io/onnx/OnnxBlobSource.js.kt similarity index 68% rename from skainet-io/skainet-io-onnx/src/jsMain/kotlin/sk/ainet/io/onnx/RandomAccessSourceFactory.js.kt rename to skainet-io/skainet-io-onnx/src/jsMain/kotlin/sk/ainet/io/onnx/OnnxBlobSource.js.kt index 34147a37f..f18d4d9c0 100644 --- a/skainet-io/skainet-io-onnx/src/jsMain/kotlin/sk/ainet/io/onnx/RandomAccessSourceFactory.js.kt +++ b/skainet-io/skainet-io-onnx/src/jsMain/kotlin/sk/ainet/io/onnx/OnnxBlobSource.js.kt @@ -2,21 +2,13 @@ package sk.ainet.io.onnx import org.w3c.files.Blob import sk.ainet.io.JsBlobRandomAccessSource -import sk.ainet.io.RandomAccessSource - -/** - * JS implementation of [createOnnxRandomAccessSource]. - * - * Returns null for path-based access since file paths don't work in browsers. - * Use [createOnnxRandomAccessSourceFromBlob] for browser file input. - */ -public actual fun createOnnxRandomAccessSource(filePath: String): RandomAccessSource? = null /** * Create a RandomAccessSource from a browser Blob or File. * - * This is the browser-specific way to create streaming ONNX readers. - * Use with file input elements or File System Access API. + * This is the browser-specific way to create streaming ONNX readers — a path means nothing in a + * browser, which is why `openRandomAccessSource` returns `null` there. Use with file input elements + * or the File System Access API. * * Example: * ```kotlin diff --git a/skainet-io/skainet-io-onnx/src/jvmMain/kotlin/sk/ainet/io/onnx/RandomAccessSourceFactory.jvm.kt b/skainet-io/skainet-io-onnx/src/jvmMain/kotlin/sk/ainet/io/onnx/RandomAccessSourceFactory.jvm.kt deleted file mode 100644 index c9acd8ebe..000000000 --- a/skainet-io/skainet-io-onnx/src/jvmMain/kotlin/sk/ainet/io/onnx/RandomAccessSourceFactory.jvm.kt +++ /dev/null @@ -1,24 +0,0 @@ -package sk.ainet.io.onnx - -import sk.ainet.io.JvmRandomAccessSource -import sk.ainet.io.RandomAccessSource -import java.io.File - -/** - * JVM implementation of [createOnnxRandomAccessSource]. - * - * Uses [JvmRandomAccessSource] backed by FileChannel for efficient - * random access to ONNX files. - */ -public actual fun createOnnxRandomAccessSource(filePath: String): RandomAccessSource? { - return try { - val file = File(filePath) - if (file.exists() && file.isFile && file.canRead()) { - JvmRandomAccessSource.open(file) - } else { - null - } - } catch (e: Exception) { - null // Fall back to legacy mode on any error - } -} diff --git a/skainet-io/skainet-io-onnx/src/nativeMain/kotlin/sk/ainet/io/onnx/RandomAccessSourceFactory.native.kt b/skainet-io/skainet-io-onnx/src/nativeMain/kotlin/sk/ainet/io/onnx/RandomAccessSourceFactory.native.kt deleted file mode 100644 index dbe050e2b..000000000 --- a/skainet-io/skainet-io-onnx/src/nativeMain/kotlin/sk/ainet/io/onnx/RandomAccessSourceFactory.native.kt +++ /dev/null @@ -1,13 +0,0 @@ -package sk.ainet.io.onnx - -import sk.ainet.io.RandomAccessSource - -/** - * Native implementation of [createOnnxRandomAccessSource]. - * - * Returns null as native random file access is not yet implemented. - * Callers should fall back to legacy OnnxLoader which loads the full file. - * - * Future: Could implement using POSIX pread() for efficient random access. - */ -public actual fun createOnnxRandomAccessSource(filePath: String): RandomAccessSource? = null diff --git a/skainet-io/skainet-io-onnx/src/wasmJsMain/kotlin/sk/ainet/io/onnx/RandomAccessSourceFactory.wasmJs.kt b/skainet-io/skainet-io-onnx/src/wasmJsMain/kotlin/sk/ainet/io/onnx/OnnxBlobSource.wasmJs.kt similarity index 61% rename from skainet-io/skainet-io-onnx/src/wasmJsMain/kotlin/sk/ainet/io/onnx/RandomAccessSourceFactory.wasmJs.kt rename to skainet-io/skainet-io-onnx/src/wasmJsMain/kotlin/sk/ainet/io/onnx/OnnxBlobSource.wasmJs.kt index c42bf8447..f18d4d9c0 100644 --- a/skainet-io/skainet-io-onnx/src/wasmJsMain/kotlin/sk/ainet/io/onnx/RandomAccessSourceFactory.wasmJs.kt +++ b/skainet-io/skainet-io-onnx/src/wasmJsMain/kotlin/sk/ainet/io/onnx/OnnxBlobSource.wasmJs.kt @@ -2,21 +2,21 @@ package sk.ainet.io.onnx import org.w3c.files.Blob import sk.ainet.io.JsBlobRandomAccessSource -import sk.ainet.io.RandomAccessSource - -/** - * WASM JS implementation of [createOnnxRandomAccessSource]. - * - * Returns null for path-based access since file paths don't work in browsers. - * Use [createOnnxRandomAccessSourceFromBlob] for browser file input. - */ -public actual fun createOnnxRandomAccessSource(filePath: String): RandomAccessSource? = null /** * Create a RandomAccessSource from a browser Blob or File. * - * This is the browser-specific way to create streaming ONNX readers. - * Use with file input elements or File System Access API. + * This is the browser-specific way to create streaming ONNX readers — a path means nothing in a + * browser, which is why `openRandomAccessSource` returns `null` there. Use with file input elements + * or the File System Access API. + * + * Example: + * ```kotlin + * // With file input + * val file = document.getElementById("fileInput").files[0] + * val source = createOnnxRandomAccessSourceFromBlob(file) + * val reader = StreamingOnnxReader.open(source) + * ``` * * @param blob The Blob or File to read from * @param preloadSize How much to preload for sync metadata access (default 50MB) diff --git a/skainet-io/skainet-io-safetensors/src/androidMain/kotlin/sk/ainet/io/safetensors/RandomAccessSourceFactory.android.kt b/skainet-io/skainet-io-safetensors/src/androidMain/kotlin/sk/ainet/io/safetensors/RandomAccessSourceFactory.android.kt deleted file mode 100644 index a1efc203e..000000000 --- a/skainet-io/skainet-io-safetensors/src/androidMain/kotlin/sk/ainet/io/safetensors/RandomAccessSourceFactory.android.kt +++ /dev/null @@ -1,26 +0,0 @@ -package sk.ainet.io.safetensors - -import sk.ainet.io.AndroidRandomAccessSource -import sk.ainet.io.RandomAccessSource -import java.io.File - -/** - * Android implementation of [createRandomAccessSource]. - * - * Uses [AndroidRandomAccessSource] backed by positional FileChannel reads - * for efficient random access to SafeTensors files, so the streaming - * reader works on Android instead of falling back to a full-file load on - * the ART heap (#922). - */ -public actual fun createRandomAccessSource(filePath: String): RandomAccessSource? { - return try { - val file = File(filePath) - if (file.exists() && file.isFile && file.canRead()) { - AndroidRandomAccessSource.open(file) - } else { - null - } - } catch (e: Exception) { - null // Fall back to legacy mode on any error - } -} diff --git a/skainet-io/skainet-io-safetensors/src/commonMain/kotlin/sk/ainet/io/safetensors/RandomAccessSourceFactory.kt b/skainet-io/skainet-io-safetensors/src/commonMain/kotlin/sk/ainet/io/safetensors/RandomAccessSourceFactory.kt index b1548186d..e8f3f8295 100644 --- a/skainet-io/skainet-io-safetensors/src/commonMain/kotlin/sk/ainet/io/safetensors/RandomAccessSourceFactory.kt +++ b/skainet-io/skainet-io-safetensors/src/commonMain/kotlin/sk/ainet/io/safetensors/RandomAccessSourceFactory.kt @@ -1,15 +1,16 @@ package sk.ainet.io.safetensors import sk.ainet.io.RandomAccessSource +import sk.ainet.io.openRandomAccessSource /** - * Platform-specific factory for creating RandomAccessSource instances. + * Open a safetensors file for positional reads, or `null` where the platform has no file system. * - * On JVM/Android, this uses efficient file channel-based random access. - * On other platforms (JS, Native), this returns null and the fallback - * non-streaming mode should be used. - * - * @param filePath Path to the file - * @return RandomAccessSource if platform supports it, null otherwise + * Note this now streams on Kotlin/Native too: the copy this delegates to uses `pread(2)`, while + * this module's own native actual used to return `null` — the drift #1037 removed. */ -public expect fun createRandomAccessSource(filePath: String): RandomAccessSource? +@Deprecated( + message = "The per-format source factories are one function in skainet-io-core (SKEEP-003 §7, #1037).", + replaceWith = ReplaceWith("openRandomAccessSource(filePath)", "sk.ainet.io.openRandomAccessSource"), +) +public fun createRandomAccessSource(filePath: String): RandomAccessSource? = openRandomAccessSource(filePath) diff --git a/skainet-io/skainet-io-safetensors/src/jsMain/kotlin/sk/ainet/io/safetensors/RandomAccessSourceFactory.js.kt b/skainet-io/skainet-io-safetensors/src/jsMain/kotlin/sk/ainet/io/safetensors/RandomAccessSourceFactory.js.kt deleted file mode 100644 index f1cb06966..000000000 --- a/skainet-io/skainet-io-safetensors/src/jsMain/kotlin/sk/ainet/io/safetensors/RandomAccessSourceFactory.js.kt +++ /dev/null @@ -1,11 +0,0 @@ -package sk.ainet.io.safetensors - -import sk.ainet.io.RandomAccessSource - -/** - * JS implementation of [createRandomAccessSource]. - * - * Returns null as JavaScript doesn't support efficient random file access. - * Callers should fall back to legacy (full file load) mode. - */ -public actual fun createRandomAccessSource(filePath: String): RandomAccessSource? = null diff --git a/skainet-io/skainet-io-safetensors/src/jvmMain/kotlin/sk/ainet/io/safetensors/RandomAccessSourceFactory.jvm.kt b/skainet-io/skainet-io-safetensors/src/jvmMain/kotlin/sk/ainet/io/safetensors/RandomAccessSourceFactory.jvm.kt deleted file mode 100644 index cb552b3e4..000000000 --- a/skainet-io/skainet-io-safetensors/src/jvmMain/kotlin/sk/ainet/io/safetensors/RandomAccessSourceFactory.jvm.kt +++ /dev/null @@ -1,24 +0,0 @@ -package sk.ainet.io.safetensors - -import sk.ainet.io.JvmRandomAccessSource -import sk.ainet.io.RandomAccessSource -import java.io.File - -/** - * JVM implementation of [createRandomAccessSource]. - * - * Uses [JvmRandomAccessSource] backed by FileChannel for efficient - * random access to SafeTensors files. - */ -public actual fun createRandomAccessSource(filePath: String): RandomAccessSource? { - return try { - val file = File(filePath) - if (file.exists() && file.isFile && file.canRead()) { - JvmRandomAccessSource.open(file) - } else { - null - } - } catch (e: Exception) { - null // Fall back to legacy mode on any error - } -} diff --git a/skainet-io/skainet-io-safetensors/src/nativeMain/kotlin/sk/ainet/io/safetensors/RandomAccessSourceFactory.native.kt b/skainet-io/skainet-io-safetensors/src/nativeMain/kotlin/sk/ainet/io/safetensors/RandomAccessSourceFactory.native.kt deleted file mode 100644 index f9374868c..000000000 --- a/skainet-io/skainet-io-safetensors/src/nativeMain/kotlin/sk/ainet/io/safetensors/RandomAccessSourceFactory.native.kt +++ /dev/null @@ -1,11 +0,0 @@ -package sk.ainet.io.safetensors - -import sk.ainet.io.RandomAccessSource - -/** - * Native implementation of [createRandomAccessSource]. - * - * Returns null as native random access is not yet implemented. - * Callers should fall back to legacy (full file load) mode. - */ -public actual fun createRandomAccessSource(filePath: String): RandomAccessSource? = null diff --git a/skainet-io/skainet-io-safetensors/src/wasmJsMain/kotlin/sk/ainet/io/safetensors/RandomAccessSourceFactory.wasmJs.kt b/skainet-io/skainet-io-safetensors/src/wasmJsMain/kotlin/sk/ainet/io/safetensors/RandomAccessSourceFactory.wasmJs.kt deleted file mode 100644 index 7cd543e1b..000000000 --- a/skainet-io/skainet-io-safetensors/src/wasmJsMain/kotlin/sk/ainet/io/safetensors/RandomAccessSourceFactory.wasmJs.kt +++ /dev/null @@ -1,11 +0,0 @@ -package sk.ainet.io.safetensors - -import sk.ainet.io.RandomAccessSource - -/** - * WASM/JS implementation of [createRandomAccessSource]. - * - * Returns null as WASM doesn't support efficient random file access. - * Callers should fall back to legacy (full file load) mode. - */ -public actual fun createRandomAccessSource(filePath: String): RandomAccessSource? = null diff --git a/skainet-io/skainet-io-safetensors/src/wasmWasiMain/kotlin/sk/ainet/io/safetensors/RandomAccessSourceFactory.wasmWasi.kt b/skainet-io/skainet-io-safetensors/src/wasmWasiMain/kotlin/sk/ainet/io/safetensors/RandomAccessSourceFactory.wasmWasi.kt deleted file mode 100644 index 92a5c2fce..000000000 --- a/skainet-io/skainet-io-safetensors/src/wasmWasiMain/kotlin/sk/ainet/io/safetensors/RandomAccessSourceFactory.wasmWasi.kt +++ /dev/null @@ -1,11 +0,0 @@ -package sk.ainet.io.safetensors - -import sk.ainet.io.RandomAccessSource - -/** - * WASM/WASI implementation of [createRandomAccessSource]. - * - * Returns null as WASM WASI doesn't support efficient random file access. - * Callers should fall back to legacy (full file load) mode. - */ -public actual fun createRandomAccessSource(filePath: String): RandomAccessSource? = null