From ce1bfa007cd32839ccd9b8ee2614cd0d2a437a9d Mon Sep 17 00:00:00 2001 From: Michal Harakal Date: Sun, 9 Aug 2026 23:28:41 +0200 Subject: [PATCH] =?UTF-8?q?fix(io):=20implement=20createRandomAccessSource?= =?UTF-8?q?=20on=20Android=20=E2=80=94=20streaming=20loads=20instead=20of?= =?UTF-8?q?=20full-file=20OOM?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Android actuals in io-gguf, io-safetensors and io-onnx unconditionally returned null, so every model load fell back to materialising the whole file on the ART heap; a 138 MiB GGUF OOMs a real device (256/512 MB heap cap) before tensors are even built. Add AndroidRandomAccessSource to skainet-io-core androidMain — a mirror of JvmRandomAccessSource over RandomAccessFile + positional FileChannel.read (thread-safe, available since API 1) — and point all three android factory actuals at it. This makes the streaming GGUF/SafeTensors readers reachable on Android and un-breaks TokenizerFactory.fromGguf, which needs StreamingGGUFReader.fields. Enable android host tests for io-core (withHostTest, first in the repo) and cover the read contract: prefix/offset/EOF reads, buffer-offset windows, bounds and closed-channel failures, close idempotency, and concurrent positional reads from multiple threads. Verified: :skainet-io:skainet-io-core:testAndroidHostTest green (12 new tests), androidMain of all three io modules compiles, jvmTest of all four io modules green. Closes #922 Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 13 ++ skainet-io/skainet-io-core/build.gradle.kts | 3 + .../ainet/io/AndroidRandomAccessSourceTest.kt | 147 ++++++++++++++++++ .../sk/ainet/io/AndroidRandomAccessSource.kt | 111 +++++++++++++ .../gguf/RandomAccessSourceFactory.android.kt | 24 ++- .../onnx/RandomAccessSourceFactory.android.kt | 23 ++- .../RandomAccessSourceFactory.android.kt | 23 ++- 7 files changed, 329 insertions(+), 15 deletions(-) create mode 100644 skainet-io/skainet-io-core/src/androidHostTest/kotlin/sk/ainet/io/AndroidRandomAccessSourceTest.kt create mode 100644 skainet-io/skainet-io-core/src/androidMain/kotlin/sk/ainet/io/AndroidRandomAccessSource.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index fca59a3c2..c83163396 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,19 @@ ## [Unreleased] +### Fixed + +- **Random file access on Android: streaming model loads instead of full-file heap loads.** + `createRandomAccessSource` unconditionally returned `null` on Android in `skainet-io-gguf`, + `skainet-io-safetensors` and `skainet-io-onnx`, forcing every load through the legacy + materialise-the-whole-file path — on a real device a 138 MiB GGUF then OOMs the ART heap + (capped at 256/512 MB) before tensors are even built. A new `AndroidRandomAccessSource` in + `skainet-io-core` (androidMain, positional `FileChannel` reads — thread-safe, API 1+) now backs + all three actuals, making `StreamingGGUFReader`/streaming loaders reachable on Android; this + also un-breaks `TokenizerFactory.fromGguf`, which needs `StreamingGGUFReader.fields`. Android + host-side unit tests (`withHostTest {}`, a first in the repo) cover the read contract including + concurrent positional reads. Closes [#922](https://github.com/SKaiNET-developers/SKaiNET/issues/922). + ## [0.38.0] - 2026-07-30 ### Added diff --git a/skainet-io/skainet-io-core/build.gradle.kts b/skainet-io/skainet-io-core/build.gradle.kts index 13dff0505..70404d0b3 100644 --- a/skainet-io/skainet-io-core/build.gradle.kts +++ b/skainet-io/skainet-io-core/build.gradle.kts @@ -32,6 +32,9 @@ kotlin { compilerOptions { jvmTarget.set(JvmTarget.JVM_1_8) } + // Host-side (JVM) unit tests for androidMain — exercises + // AndroidRandomAccessSource without a device (#922). + withHostTest {} } iosArm64() diff --git a/skainet-io/skainet-io-core/src/androidHostTest/kotlin/sk/ainet/io/AndroidRandomAccessSourceTest.kt b/skainet-io/skainet-io-core/src/androidHostTest/kotlin/sk/ainet/io/AndroidRandomAccessSourceTest.kt new file mode 100644 index 000000000..6edda8832 --- /dev/null +++ b/skainet-io/skainet-io-core/src/androidHostTest/kotlin/sk/ainet/io/AndroidRandomAccessSourceTest.kt @@ -0,0 +1,147 @@ +package sk.ainet.io + +import java.io.File +import java.nio.channels.ClosedChannelException +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +class AndroidRandomAccessSourceTest { + + private val expected = ByteArray(8192) { (it and 0xFF).toByte() } // 0..255 repeating + private lateinit var file: File + + @BeforeTest + fun setUp() { + file = File.createTempFile("android-ras-test-", ".bin") + file.writeBytes(expected) + } + + @AfterTest + fun tearDown() { + file.delete() + } + + @Test + fun open_reports_correct_size() { + val src = AndroidRandomAccessSource.open(file) + try { + assertEquals(expected.size.toLong(), src.size) + } finally { + src.close() + } + } + + @Test + fun read_at_zero_returns_prefix() { + AndroidRandomAccessSource.open(file).use { src -> + val got = src.readAt(0, 16) + assertContentEquals(expected.copyOfRange(0, 16), got) + } + } + + @Test + fun read_at_arbitrary_offset_returns_slice() { + AndroidRandomAccessSource.open(file).use { src -> + val got = src.readAt(1234, 256) + assertContentEquals(expected.copyOfRange(1234, 1234 + 256), got) + } + } + + @Test + fun read_at_end_returns_suffix() { + AndroidRandomAccessSource.open(file).use { src -> + val got = src.readAt(expected.size - 32L, 32) + assertContentEquals(expected.copyOfRange(expected.size - 32, expected.size), got) + } + } + + @Test + fun read_into_buffer_reports_bytes_read() { + AndroidRandomAccessSource.open(file).use { src -> + val buf = ByteArray(64) + val n = src.readAt(100L, buf, 0, 64) + assertEquals(64, n) + assertContentEquals(expected.copyOfRange(100, 164), buf) + } + } + + @Test + fun read_into_buffer_with_offset() { + AndroidRandomAccessSource.open(file).use { src -> + val buf = ByteArray(128) + val n = src.readAt(50L, buf, offset = 32, length = 64) + assertEquals(64, n) + assertContentEquals(expected.copyOfRange(50, 114), buf.copyOfRange(32, 96)) + // Bytes outside the requested window must remain zero. + for (i in 0 until 32) assertEquals(0, buf[i]) + for (i in 96 until 128) assertEquals(0, buf[i]) + } + } + + @Test + fun read_past_end_throws() { + AndroidRandomAccessSource.open(file).use { src -> + assertFailsWith { src.readAt(expected.size - 1L, 16) } + } + } + + @Test + fun negative_position_throws() { + AndroidRandomAccessSource.open(file).use { src -> + assertFailsWith { src.readAt(-1L, 4) } + } + } + + @Test + fun read_after_close_throws() { + val src = AndroidRandomAccessSource.open(file) + src.close() + assertFailsWith { + src.readAt(0L, ByteArray(4), 0, 4) + } + } + + @Test + fun close_is_idempotent() { + val src = AndroidRandomAccessSource.open(file) + src.close() + src.close() // must not throw + assertTrue(true) + } + + @Test + fun open_missing_file_throws() { + val missing = File(file.parentFile, "definitely-does-not-exist-${System.nanoTime()}.bin") + assertFailsWith { AndroidRandomAccessSource.open(missing) } + } + + @Test + fun concurrent_reads_from_different_positions_are_consistent() { + AndroidRandomAccessSource.open(file).use { src -> + val errors = java.util.concurrent.ConcurrentLinkedQueue() + val threads = (0 until 8).map { t -> + Thread { + try { + repeat(200) { i -> + val pos = ((t * 997 + i * 131) % (expected.size - 64)) + val got = src.readAt(pos.toLong(), 64) + if (!got.contentEquals(expected.copyOfRange(pos, pos + 64))) { + error("mismatch at position $pos") + } + } + } catch (e: Throwable) { + errors.add(e) + } + } + } + threads.forEach { it.start() } + threads.forEach { it.join() } + assertTrue(errors.isEmpty(), "concurrent read failures: ${errors.firstOrNull()}") + } + } +} 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 new file mode 100644 index 000000000..f4bbf5f7e --- /dev/null +++ b/skainet-io/skainet-io-core/src/androidMain/kotlin/sk/ainet/io/AndroidRandomAccessSource.kt @@ -0,0 +1,111 @@ +package sk.ainet.io + +import java.io.File +import java.io.RandomAccessFile +import java.nio.ByteBuffer +import java.nio.channels.FileChannel + +/** + * Android implementation of [RandomAccessSource] using FileChannel. + * + * Positional [FileChannel.read] does not touch the channel's shared file + * pointer, so concurrent reads from different positions are thread-safe, + * and both `RandomAccessFile` and positional channel reads are available + * since API 1. Keeping random access working on Android is what lets the + * streaming GGUF/SafeTensors loaders read tensors incrementally instead of + * materialising the whole file on the ART heap (#922). + * + * Usage: + * ```kotlin + * AndroidRandomAccessSource.open("/path/to/model.gguf").use { source -> + * val header = source.readAt(0, 24) + * println("File size: ${source.size}") + * } + * ``` + */ +public class AndroidRandomAccessSource private constructor( + private val channel: FileChannel, + private val raf: RandomAccessFile, + override val size: Long +) : RandomAccessSource { + + override fun readAt(position: Long, length: Int): ByteArray { + require(position >= 0) { "Position must be non-negative: $position" } + require(length >= 0) { "Length must be non-negative: $length" } + require(position + length <= size) { + "Read beyond end of file: position=$position, length=$length, size=$size" + } + + if (length == 0) return ByteArray(0) + + val buffer = ByteArray(length) + val bytesRead = readAt(position, buffer, 0, length) + + if (bytesRead < length) { + // Unexpected EOF - return what we got + return buffer.copyOf(bytesRead) + } + + return buffer + } + + override fun readAt(position: Long, buffer: ByteArray, offset: Int, length: Int): Int { + require(position >= 0) { "Position must be non-negative: $position" } + require(offset >= 0) { "Offset must be non-negative: $offset" } + require(length >= 0) { "Length must be non-negative: $length" } + require(offset + length <= buffer.size) { + "Buffer overflow: offset=$offset, length=$length, buffer.size=${buffer.size}" + } + + if (length == 0) return 0 + + val byteBuffer = ByteBuffer.wrap(buffer, offset, length) + var totalRead = 0 + var currentPosition = position + + // FileChannel.read may return less than requested, so loop until done + while (totalRead < length) { + val read = channel.read(byteBuffer, currentPosition) + if (read == -1) break // EOF + totalRead += read + currentPosition += read + } + + return totalRead + } + + override fun close() { + try { + channel.close() + } finally { + raf.close() + } + } + + public companion object { + /** + * Open a file for random access reading. + * + * @param file The file to open + * @return A RandomAccessSource for the file + * @throws IllegalArgumentException if file doesn't exist or isn't readable + */ + public fun open(file: File): AndroidRandomAccessSource { + require(file.exists()) { "File not found: ${file.absolutePath}" } + require(file.isFile) { "Not a file: ${file.absolutePath}" } + require(file.canRead()) { "File not readable: ${file.absolutePath}" } + + val raf = RandomAccessFile(file, "r") + val channel = raf.channel + return AndroidRandomAccessSource(channel, raf, raf.length()) + } + + /** + * Open a file for random access reading. + * + * @param path Path to the file + * @return A RandomAccessSource for the file + */ + public fun open(path: String): AndroidRandomAccessSource = open(File(path)) + } +} 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 index dee3b3ac9..003e20260 100644 --- 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 @@ -1,13 +1,27 @@ package sk.ainet.io.gguf +import sk.ainet.io.AndroidRandomAccessSource import sk.ainet.io.RandomAccessSource +import java.io.File /** * Android implementation of [createRandomAccessSource]. * - * Returns null on Android as file access patterns differ. - * Callers should fall back to legacy GGUFReader which loads the full file. - * - * Future: Could implement using Android-specific file APIs. + * 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? = null +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-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 index de1fd648a..807917159 100644 --- 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 @@ -1,13 +1,26 @@ package sk.ainet.io.onnx +import sk.ainet.io.AndroidRandomAccessSource import sk.ainet.io.RandomAccessSource +import java.io.File /** * Android implementation of [createOnnxRandomAccessSource]. * - * Returns null on Android as file access patterns differ. - * Callers should fall back to standard ONNX loading. - * - * Future: Could implement using Android-specific file APIs. + * 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? = null +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-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 index c1ccdbf9b..a1efc203e 100644 --- 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 @@ -1,13 +1,26 @@ package sk.ainet.io.safetensors +import sk.ainet.io.AndroidRandomAccessSource import sk.ainet.io.RandomAccessSource +import java.io.File /** * Android implementation of [createRandomAccessSource]. * - * Returns null on Android as file access patterns differ. - * Callers should fall back to legacy (full file load) mode. - * - * Future: Could implement using Android-specific file APIs. + * 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? = null +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 + } +}