Skip to content

Commit 88e7504

Browse files
committed
Add initial implementation of safetensors
Implemants: #322 Related-To: #10
1 parent 8eb28db commit 88e7504

27 files changed

Lines changed: 5071 additions & 0 deletions
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi
2+
import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl
3+
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
4+
5+
plugins {
6+
alias(libs.plugins.kotlinMultiplatform)
7+
alias(libs.plugins.androidLibrary)
8+
alias(libs.plugins.vanniktech.mavenPublish)
9+
}
10+
11+
kotlin {
12+
targets.configureEach {
13+
compilations.configureEach {
14+
compileTaskProvider.get().compilerOptions {
15+
freeCompilerArgs.add("-Xexpect-actual-classes")
16+
}
17+
}
18+
}
19+
20+
jvm()
21+
androidTarget {
22+
publishLibraryVariants("release")
23+
@OptIn(ExperimentalKotlinGradlePluginApi::class)
24+
compilerOptions {
25+
jvmTarget.set(JvmTarget.JVM_1_8)
26+
}
27+
}
28+
29+
iosArm64()
30+
iosSimulatorArm64()
31+
macosArm64()
32+
linuxX64()
33+
linuxArm64()
34+
35+
js {
36+
browser()
37+
}
38+
39+
@OptIn(ExperimentalWasmDsl::class)
40+
wasmJs {
41+
browser()
42+
}
43+
44+
sourceSets {
45+
val commonMain by getting {
46+
dependencies {
47+
implementation(libs.kotlinx.io.core)
48+
implementation(libs.kotlinx.coroutines)
49+
implementation(project(":skainet-lang:skainet-lang-core"))
50+
implementation(project(":skainet-io:skainet-io-core"))
51+
implementation(project(":skainet-compile:skainet-compile-core"))
52+
implementation(project(":skainet-compile:skainet-compile-dag"))
53+
}
54+
}
55+
val commonTest by getting {
56+
dependencies {
57+
implementation(libs.kotlin.test)
58+
}
59+
}
60+
val jvmTest by getting {
61+
dependencies {
62+
implementation(libs.junit)
63+
implementation(libs.kotlinx.coroutines)
64+
implementation(libs.kotlinx.coroutines.test)
65+
implementation(project(":skainet-backends:skainet-backend-cpu"))
66+
}
67+
}
68+
}
69+
}
70+
71+
android {
72+
namespace = "sk.ainet.io.safetensors"
73+
compileSdk = libs.versions.android.compileSdk.get().toInt()
74+
defaultConfig {
75+
minSdk = libs.versions.android.minSdk.get().toInt()
76+
}
77+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
package sk.ainet.io.safetensors
2+
3+
import java.io.File
4+
5+
/**
6+
* Android implementation: Read a text file.
7+
*/
8+
internal actual fun readTextFile(path: String): String? {
9+
return try {
10+
File(path).readText()
11+
} catch (e: Exception) {
12+
null
13+
}
14+
}
15+
16+
/**
17+
* Android implementation: Get current time in milliseconds.
18+
*/
19+
internal actual fun currentTimeMillis(): Long = System.currentTimeMillis()
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package sk.ainet.io.safetensors
2+
3+
import sk.ainet.io.RandomAccessSource
4+
5+
/**
6+
* Android implementation of [createRandomAccessSource].
7+
*
8+
* Returns null on Android as file access patterns differ.
9+
* Callers should fall back to legacy (full file load) mode.
10+
*
11+
* Future: Could implement using Android-specific file APIs.
12+
*/
13+
public actual fun createRandomAccessSource(filePath: String): RandomAccessSource? = null
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
package sk.ainet.io.safetensors
2+
3+
/**
4+
* SafeTensors format constants.
5+
*
6+
* SafeTensors is a simple, safe format for storing tensors:
7+
* - 8 bytes: header size (little-endian u64)
8+
* - N bytes: JSON header with tensor metadata
9+
* - Remaining: raw tensor data at specified offsets
10+
*
11+
* Reference: https://huggingface.co/docs/safetensors
12+
*/
13+
14+
/** Size of the header length field in bytes */
15+
const val HEADER_SIZE_BYTES = 8
16+
17+
/** Maximum allowed header size (100 MB) - safety limit */
18+
const val MAX_HEADER_SIZE = 100 * 1024 * 1024
19+
20+
/** Key for custom metadata in the JSON header */
21+
const val METADATA_KEY = "__metadata__"
22+
23+
/**
24+
* SafeTensors data types as strings (as they appear in JSON).
25+
*/
26+
object SafeTensorsDataTypes {
27+
const val BOOL = "BOOL"
28+
const val U8 = "U8"
29+
const val I8 = "I8"
30+
const val U16 = "U16"
31+
const val I16 = "I16"
32+
const val U32 = "U32"
33+
const val I32 = "I32"
34+
const val U64 = "U64"
35+
const val I64 = "I64"
36+
const val F16 = "F16"
37+
const val BF16 = "BF16"
38+
const val F32 = "F32"
39+
const val F64 = "F64"
40+
41+
/** Size in bytes for each data type */
42+
val SIZES: Map<String, Int> = mapOf(
43+
BOOL to 1,
44+
U8 to 1,
45+
I8 to 1,
46+
U16 to 2,
47+
I16 to 2,
48+
U32 to 4,
49+
I32 to 4,
50+
U64 to 8,
51+
I64 to 8,
52+
F16 to 2,
53+
BF16 to 2,
54+
F32 to 4,
55+
F64 to 8
56+
)
57+
58+
/** Get size in bytes for a dtype, returns null for unknown types */
59+
fun sizeOf(dtype: String): Int? = SIZES[dtype.uppercase()]
60+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
package sk.ainet.io.safetensors
2+
3+
import sk.ainet.io.RandomAccessSource
4+
5+
/**
6+
* Platform-specific factory for creating RandomAccessSource instances.
7+
*
8+
* On JVM/Android, this uses efficient file channel-based random access.
9+
* On other platforms (JS, Native), this returns null and the fallback
10+
* non-streaming mode should be used.
11+
*
12+
* @param filePath Path to the file
13+
* @return RandomAccessSource if platform supports it, null otherwise
14+
*/
15+
public expect fun createRandomAccessSource(filePath: String): RandomAccessSource?
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
package sk.ainet.io.safetensors
2+
3+
import sk.ainet.io.model.DataType
4+
5+
/**
6+
* Maps between SafeTensors dtype strings and unified DataType enum.
7+
*
8+
* SafeTensors uses uppercase strings like "F32", "I64", etc.
9+
*/
10+
object SafeTensorsDataTypeMapper {
11+
12+
/**
13+
* Convert SafeTensors dtype string to DataType.
14+
*
15+
* @param safeTensorsType The dtype string from SafeTensors header (e.g., "F32", "I64")
16+
* @return The corresponding DataType, or UNKNOWN if not recognized
17+
*/
18+
fun toDataType(safeTensorsType: String): DataType {
19+
return when (safeTensorsType.uppercase()) {
20+
"BOOL" -> DataType.BOOL
21+
"U8" -> DataType.UINT8
22+
"I8" -> DataType.INT8
23+
"U16" -> DataType.UINT16
24+
"I16" -> DataType.INT16
25+
"U32" -> DataType.UINT32
26+
"I32" -> DataType.INT32
27+
"U64" -> DataType.UINT64
28+
"I64" -> DataType.INT64
29+
"F16" -> DataType.FLOAT16
30+
"BF16" -> DataType.BFLOAT16
31+
"F32" -> DataType.FLOAT32
32+
"F64" -> DataType.FLOAT64
33+
else -> {
34+
println("WARNING: Unknown SafeTensors dtype: $safeTensorsType")
35+
DataType.UNKNOWN
36+
}
37+
}
38+
}
39+
40+
/**
41+
* Convert DataType to SafeTensors dtype string.
42+
*
43+
* @param dataType The DataType to convert
44+
* @return The corresponding SafeTensors dtype string, or null if not mappable
45+
*/
46+
fun fromDataType(dataType: DataType): String? {
47+
return when (dataType) {
48+
DataType.BOOL -> "BOOL"
49+
DataType.UINT8 -> "U8"
50+
DataType.INT8 -> "I8"
51+
DataType.UINT16 -> "U16"
52+
DataType.INT16 -> "I16"
53+
DataType.UINT32 -> "U32"
54+
DataType.INT32 -> "I32"
55+
DataType.UINT64 -> "U64"
56+
DataType.INT64 -> "I64"
57+
DataType.FLOAT16 -> "F16"
58+
DataType.BFLOAT16 -> "BF16"
59+
DataType.FLOAT32 -> "F32"
60+
DataType.FLOAT64 -> "F64"
61+
DataType.STRING -> null // SafeTensors doesn't support strings
62+
DataType.UNKNOWN -> null
63+
}
64+
}
65+
66+
/**
67+
* Check if a SafeTensors dtype is supported.
68+
*/
69+
fun isSupported(safeTensorsType: String): Boolean {
70+
return toDataType(safeTensorsType) != DataType.UNKNOWN
71+
}
72+
73+
/**
74+
* Get the byte size for a SafeTensors dtype.
75+
*
76+
* @return Size in bytes, or null for unknown types
77+
*/
78+
fun sizeInBytes(safeTensorsType: String): Int? {
79+
return SafeTensorsDataTypes.sizeOf(safeTensorsType)
80+
}
81+
}
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
package sk.ainet.io.safetensors
2+
3+
/**
4+
* Represents the metadata from a SafeTensors index file.
5+
*
6+
* @property totalSize Total size in bytes of all tensor data (from "total_size" field)
7+
* @property additionalFields Any additional metadata fields beyond total_size
8+
*/
9+
public data class SafeTensorsIndexMetadata(
10+
val totalSize: Long?,
11+
val additionalFields: Map<String, String> = emptyMap()
12+
)
13+
14+
/**
15+
* Represents a parsed model.safetensors.index.json file for sharded SafeTensors models.
16+
*
17+
* The index file maps tensor names to their containing shard files:
18+
* ```json
19+
* {
20+
* "metadata": { "total_size": 14483464192 },
21+
* "weight_map": {
22+
* "model.embed_tokens.weight": "model-00001-of-00003.safetensors",
23+
* "model.layers.0.self_attn.q_proj.weight": "model-00001-of-00003.safetensors",
24+
* ...
25+
* }
26+
* }
27+
* ```
28+
*
29+
* @property metadata Index metadata (total_size, etc.)
30+
* @property weightMap Mapping from tensor name to shard filename
31+
*/
32+
public data class SafeTensorsIndex(
33+
val metadata: SafeTensorsIndexMetadata,
34+
val weightMap: Map<String, String>
35+
) {
36+
/**
37+
* Unique shard filenames derived from the weight map, sorted alphabetically.
38+
*
39+
* For a model with weight_map containing files like:
40+
* - "model-00001-of-00003.safetensors"
41+
* - "model-00002-of-00003.safetensors"
42+
* - "model-00003-of-00003.safetensors"
43+
*
44+
* Returns them in sorted order.
45+
*/
46+
public val shardFiles: List<String> by lazy {
47+
weightMap.values.distinct().sorted()
48+
}
49+
50+
/**
51+
* Number of shards in this model.
52+
*/
53+
public val shardCount: Int get() = shardFiles.size
54+
55+
/**
56+
* Total number of tensors in this model.
57+
*/
58+
public val tensorCount: Int get() = weightMap.size
59+
60+
/**
61+
* Get the shard filename containing a specific tensor.
62+
*
63+
* @param tensorName The fully-qualified tensor name
64+
* @return The shard filename, or null if tensor not found
65+
*/
66+
public fun getShardForTensor(tensorName: String): String? = weightMap[tensorName]
67+
68+
/**
69+
* Get all tensor names contained in a specific shard.
70+
*
71+
* @param shardFilename The shard filename
72+
* @return List of tensor names in that shard
73+
*/
74+
public fun getTensorsInShard(shardFilename: String): List<String> {
75+
return weightMap.filterValues { it == shardFilename }.keys.toList()
76+
}
77+
78+
/**
79+
* Get tensor count per shard as a map.
80+
*
81+
* @return Map from shard filename to tensor count
82+
*/
83+
public fun getTensorCountPerShard(): Map<String, Int> {
84+
return weightMap.values.groupingBy { it }.eachCount()
85+
}
86+
}

0 commit comments

Comments
 (0)