Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions skainet-io/skainet-io-core/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
@@ -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<IllegalArgumentException> { src.readAt(expected.size - 1L, 16) }
}
}

@Test
fun negative_position_throws() {
AndroidRandomAccessSource.open(file).use { src ->
assertFailsWith<IllegalArgumentException> { src.readAt(-1L, 4) }
}
}

@Test
fun read_after_close_throws() {
val src = AndroidRandomAccessSource.open(file)
src.close()
assertFailsWith<ClosedChannelException> {
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<IllegalArgumentException> { AndroidRandomAccessSource.open(missing) }
}

@Test
fun concurrent_reads_from_different_positions_are_consistent() {
AndroidRandomAccessSource.open(file).use { src ->
val errors = java.util.concurrent.ConcurrentLinkedQueue<Throwable>()
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()}")
}
}
}
Original file line number Diff line number Diff line change
@@ -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))
}
}
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
@@ -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
}
}
Loading