Repository navigation
feat(io): browser IO parity — Long Blob sources, worker-side blocking reads, sharded SafeTensors from Blobs, PlannerProfile.BROWSER, browser MemoryProbe - #1427
Merged
michalharakal merged 5 commits intoOct 20, 2026
Conversation
…l on the JVM Wasm: the sidecar exports skainet_ternary_f32_gemv, skainet_ternary_lmhead_stage1 and skainet_bitnet_gemv_tq2_0. The portable branch of skainet_ternary_f32.c (MSVC, Emscripten) holds the vendored decode table as a compile-time constant with a SIMD128 body and threads over output rows through the shared row pool; bitnet_gemv.c gains a SIMD128 block body and the same row threading on every tier. WasmTernaryKernelPack (installed by WasmSimd.install) registers the three native faces under the dispatch keys the JVM FFM and Android JNI ternary packs serve. JVM: Fp32MatmulWtKernel / KernelProvider.matmulFp32Wt() and the JVM-only Fp32MemSegMatmulWtKernel / MemSegKernelProvider.matmulFp32WtMemSeg() expose skainet_fp32_matmul_wt; NativeFp32MatmulWtKernel binds it with a heap and a segment face. DefaultCpuOpsJvm.matmulWeightTransposed multiplies a dense [out, in] FP32 weight in place over a resident off-heap weight instead of transposing and caching a copy. WasmKernelProvider exposes the same accessor. Kernel support matrix: a fourth table, "Kernel packs (view dispatch)". Bench: engine-fp32-linear on both harnesses. Docs: eager-execution ternary rows and x·Wᵀ row; the wasm ternary gap is closed.
…ion and reduction kernels PlatformStorage.offHeapAllocator (Kotlin/Wasm) binds MemoryDomain.HOST_OFFHEAP to a backend's memory. WasmSimd.install() binds it to the SIMD128 sidecar's linear memory: WasmLinearStorage (Storage.OffHeap: pointer, bulk copies, slices, close frees) and WasmLinearTensorData (FloatArrayTensorData whose heap mirror is downloaded once on first access), so a ForwardScope frees sidecar activations at reset() and StorageCapabilities reports off-heap support in a browser. Ambient outputs are freed through a FinalizationRegistry; WasmSimd.residentBudgetBytes bounds what stays resident. WasmCpuOps keeps matmul / matmulWeightTransposed outputs resident and runs add/subtract/multiply/ divide, relu/sigmoid/silu/gelu/erf/tanh and sum/mean in the sidecar on resident operands (transcendentals on heap operands too); two heap operands keep the common path. toResident() uploads an input once. New shared C kernels in f32_ops.c (skainet_f32_binary/unary/reduce_all/reduce_axis; SIMD128 and NEON bodies, row-pool chunks), compiled into the native library and the sidecar. wasm-bench gains the laya.chain case and resident-operand variants of the add and sum scenarios.
…on A/B; chain line reports the budget
…sm sidecar and the JVM FFM provider
New shared C kernels in f32_fused.c — skainet_f32_softmax_rows, skainet_f32_layer_norm,
skainet_f32_rms_norm and skainet_f32_sdpa (grouped-query heads, causal, sliding window with
rightContext, per-batch key lengths, dense additive mask with trailing-aligned broadcast) —
with SIMD128 and NEON bodies and row-pool threading over query rows. SPI faces
Fp32SoftmaxKernel / Fp32NormKernel / Fp32AttentionKernel behind KernelProvider.softmaxFp32() /
normFp32() / attentionFp32() (supports("softmax" | "layer_norm" | "rms_norm" | "sdpa",
["Float32"])), served by NativeKernelProvider through FFM and by WasmKernelProvider.
DefaultCpuOpsJvm routes softmax/logSoftmax over the last dimension and scaledDotProductAttention
to the native kernels when the FFM provider is loaded; WasmCpuOps runs them in the sidecar with
resident outputs. The common SDPA's validation moves into protected helpers both overrides share.
FusedNormOps (lang-core) is an optional interface beside TensorOps that LayerNormalization
consults for FP32 input; only the sidecar and the JVM FFM op sets implement it, so recording and
differentiating op sets keep the decomposition.
Also fixes a latent loader failure: GCC with -ffast-math vectorised the expf/tanhf/erf loops of
f32_ops.c into libmvec calls the shared library is not linked against, which terminated the
process at the first JVM call into such a kernel; the transcendentals now go through opaque
scalar wrappers and libskainet_kernels.so has no libmvec reference.
Bench: engine-softmax, engine-layer-norm, engine-sdpa on both harnesses. Kernel matrix: three
fused rows. Docs, CHANGELOG, apiDump.
…worker-side blocking reads, sharded SafeTensors from Blobs, PlannerProfile.BROWSER, browser MemoryProbe JsBlobRandomAccessSource (Kotlin/Wasm) addresses a Blob with Long positions and size (Blob.slice takes 53-bit numbers; the Int path truncated a shard over 2 GB), moves bytes into Kotlin through 4 MB stages in wasm linear memory instead of one interop call per byte (219 ms -> 36 ms per 8 MB), and serves blocking reads beyond the preloaded window from the Blob itself inside a Web Worker through FileReaderSync (supportsSyncReads, openSync, hasFileReaderSync()); on the main thread such a read throws with the explanation. The Kotlin/JS variant gets Long addressing and a copy-free ByteArray view. ShardedSafeTensorsParametersLoader(indexJson, shardSource) and StreamingShardedSafeTensorsReader.openFromIndexContent / openFromParsedIndex take the index as text and a suspending provider that opens each shard by file name, so a sharded checkpoint loads from Blobs or fetched bytes where there is no file system; withPolicy for the new form. Both forms now open every shard once and close what was opened on failure. PlannerProfile.BROWSER (Budget.RESERVE_BROWSER, BROWSER_AVAILABLE_BYTES): 256 MB reserved, dense weights quantized to Q8_0 at load, KV auto-quantized past 80 %, nothing mapped; forDevice() returns it on JS and wasmJs (expect/actual isWebHost); skainet-plan --profile browser. MemoryProbe on Kotlin/Wasm sums the JS heap in use (performance.memory), the module's linear memory and the installed OffHeapAllocator's heldBytes() (new default-null member; the SIMD128 sidecar reports resident weights + activations); Kotlin/JS reports the JS heap. Faults stay null. Docs: io-readers gains the browser section and a per-target table naming each remaining difference with its reason; planner and memory-model pages updated. Tests: Blob source under Karma incl. the worker path (FileReaderSync polyfilled by a sync XHR on a Blob URL), sharded checkpoint from Blobs end to end, common reader provider tests, JVM path-vs-provider parity, browser profile rules. M2AcceptanceTest no longer couples "RSS known" to "faults known".
This was referenced Oct 20, 2026
aharakal
approved these changes
Oct 20, 2026
michalharakal
merged commit Oct 20, 2026
af31426
into
SKaiNET-developers:develop
29 of 31 checks passed
michalharakal
added a commit
that referenced
this pull request
Oct 21, 2026
CHANGELOG [0.62.0] - 2026-10-21 with the parity headline over #1363, #1364, #1368, #1369, #1426, #1427, #1428 and #1430, plus entries for the operator coverage matrix (#1370), the BitNet decode allocation and threading fixes (#1359, #1360, #1361, #1362), the ClassificationSupport extraction (#1429) and the toolchain bumps (Kotlin 2.4.21, kotest 6.2.6, Gradle 9.8.1). VERSION_NAME, CITATION, Antora version, README bom coordinate, What's New and contributors, Android tutorial coordinates; regenerated operator and kernel-support matrices; SKEEP-007 rollout step 3 marked shipped.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Sixth step of the JVM ↔ wasm parity series (after #1363, #1364, #1368, #1369, #1426), and the IO half of it: the places where a browser build could not load what a JVM loads, closed where the platform allows and named as an explicit N/A with its reason where it does not.
JsBlobRandomAccessSource(Kotlin/Wasm,skainet-io-core) — theRandomAccessSourceover a browserBlob:Longpositions and size.Blob.slicetakes JavaScript numbers (53 bits); the source addressed it throughInt, so a Blob over 2 GB — one shard of a sharded checkpoint, routinely — truncated its positions. The Kotlin/JS variant gets the same fix and a copy-freeByteArrayview (Int8Arrayis aByteArraythere).Uint8Array.setper 4 MB stage and a wasm loop into theByteArray, instead of one interop call per byte (fifty million of them for the default 50 MB preload).FileReaderSync, so the streaming readers (StreamingSafeTensorsReader,StreamingGGUFReader) load a whole model from a Blob through the same blocking code path the JVM takes over a file.supportsSyncReadssays which thread the source is on,openSyncopens without preloading for worker code outside a coroutine, and a blocking read beyond the window on the main thread throws with that explanation instead of blocking the page. The suspendingread()works everywhere, as before.Sharded SafeTensors without a file system —
ShardedSafeTensorsParametersLoader(indexJson, shardSource)andStreamingShardedSafeTensorsReader.openFromIndexContent/openFromParsedIndex: the index as text and a (suspending) provider that opens each shard by file name — aBlobper shard in a browser,ByteArrayRandomAccessSourceover fetched bytes — pluswithPolicyfor the new form. The path constructor is unchanged and delegates to the same reader, which now opens every shard exactly once (it used to open, close and reopen each) and releases what it opened when a shard is missing or fails to parse. Mapped loading needs file-backed shards and says so for Blobs.PlannerProfile.BROWSER— the tab profile: 256 MB reserved (Budget.RESERVE_BROWSER), dense weights quantized toQ8_0at load, KV auto-quantized past 80 %, nothing mapped; off-heap allocations go to the kernel sidecar's linear memory onceWasmSimd.install()has bound it.PlannerProfile.forDevice()returns it on Kotlin/JS and Kotlin/Wasm (a tab's RAM figure says nothing about what the tab may use);BROWSER_AVAILABLE_BYTES(2 GB) is what to plan against when the host reports nothing;skainet-plan --profile browser.MemoryProbein a browser — a tab has no/proc, sorssBytes()on Kotlin/Wasm sums the three places SKaiNET's bytes live: the JavaScript heap in use (Chromium'sperformance.memory; Kotlin/Wasm objects are GC objects there), the module's own linear memory, and the bytes the installedOffHeapAllocatorholds — a newheldBytes()on the hook, which the SIMD128 sidecar answers with its resident weights and activations. Kotlin/JS reports the JavaScript heap. Fault counters staynull, as the common contract allows.Docs —
how-to/io-readers.adocgains the browser section (Blob sources, worker-side loads, sharded checkpoints from Blobs) and a per-target table listing every remaining difference with its reason: no file system (path APIs, mmap, file writers),FileReaderSyncworker-only, 2 GB arrays, no Kotlin threads. The planner and memory-model pages carry the profile and the probe.Verification
:skainet-io:skainet-io-core:wasmJsBrowserTest— newJsBlobRandomAccessSourceTest(Chromium under Karma): size and preload, blocking reads inside the window and the explanatory failure beyond it on the main thread, suspending reads across the window edge, deep in the Blob, at its end and across two 4 MB stage boundaries byte for byte, and the worker path —FileReaderSyncpolyfilled for the main thread by a synchronousXMLHttpRequeston a Blob URL, so the production call site runs under Karma — includingopenSync.:skainet-io:skainet-io-safetensors:wasmJsBrowserTest— newShardedSafeTensorsBlobTest: a two-shard checkpoint from Blobs through the provider constructor into tensors on the CPU context, on the main thread (preload) and on the worker path (nothing preloaded, blocking reads from the Blob), plus a missing shard with and withoutallowPartial.ShardedSafeTensorsReaderSourceTest(common, every target): provider asked once per shard, name-sorted tensors, metadata merged, sources closed with the reader, missing shard / corrupt shard / throwing provider with and withoutallowPartial, mapped loading through a source that names its file.ShardedSafeTensorsParametersLoaderJvmTestgains the path-vs-provider parity test (file sources and in-memory bytes).PlannerProfileTestgains the browser rules;MemoryProbeTestruns unchanged on the wasm and JS browser legs with the probe now answering.SkainetPlanTestknowsbrowser.M2AcceptanceTest.m2a3(backend-cpu) no longer asserts that a platform which knows its RSS knows its faults — a browser now reports the former and never the latter.scripts/pr-gate.sh --quick, full gate,apiDump.Measured
Chromium under Karma (Kotlin/Wasm, 12-core laptop), an 8 MB range of a Blob:
Int8Array→ByteArrayread()incl.blob.arrayBuffer()(16 ms)For the default 50 MB preload that is roughly 1.4 s → 0.3 s before the first header byte is parsed — the old preload alone cost more than a Laya decision does after #1426. The remaining cost is the wasm loop that moves the staged bytes into the
ByteArray; Kotlin/Wasm offers no bulk path into a GC array.Not covered by a test: a Blob over 2 GB (Karma cannot hold one); the
Longpath is the same code with a larger number, and the JS boundary takes aDouble.