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
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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 <T : DType> denseFloats(byteOffset: Long, shape: Shape): TensorData<T, Float>

/** [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?
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Original file line number Diff line number Diff line change
@@ -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?
Original file line number Diff line number Diff line change
@@ -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()
}
Original file line number Diff line number Diff line change
@@ -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,
}
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down Expand Up @@ -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" }
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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 <T : DType> denseFloats(byteOffset: Long, shape: Shape): TensorData<T, Float> =
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)
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading