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 + } +}