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