Browser-side inference engine for small encoder classifiers: WGSL kernels, WASM-SIMD fallback, parity-tested against PyTorch
TypeScript
0
7 commits
updated Oct 13, 2026
Encoder models in the browser, on the GPU, with no server.
kleinhirn runs text encoders in the user's browser: classification, zero-shot classification over NLI, sentence embeddings, named entities and reranking. The text stays on the device, there is no inference server to pay for, and it works offline once the model is cached.
It loads Hugging Face checkpoints of six
encoder families directly from config.json and
safetensors:
BERT (with
ELECTRA and
MiniLM),
RoBERTa,
XLM-R,
DistilBERT,
DeBERTa-v2/v3 and
ModernBERT. Two models with
their own heads run on the same engine:
GLiNER2.5, a zero-shot
classifier that takes any set of labels, and
Julia 1, a decision
model that picks one of up to 20 options. The engine ships its own GPU
programs (WGSL kernels) for
WebGPU; browsers without WebGPU fall
back to a WASM-SIMD executor on the CPU that runs the same plan for
every family, on up to 8 threads.
The usual tool for running such models in a browser is ONNX Runtime Web (ORT) from Microsoft. It is the baseline for every speed number below.
Same answers as the original. All 89 checkpoints on our list (the three most downloaded per family and task, up to 200M parameters) make the same decisions as the PyTorch reference: 100 % in full precision (f32), at least 99.5 % in half precision (f16) of the decisions that f16 rounding cannot flip, embeddings with cosine at least 0.9999 and 0.999.
Faster than ORT at its best, in every family. One model per family (the most downloaded), inputs of exactly 128 and 512 tokens, f16, against ORT's fastest setting we found for each model (graph optimizer variants, three ORT builds, graph capture where it starts):
| Model | 128 tokens kleinhirn / ORT | 512 tokens kleinhirn / ORT |
|---|---|---|
| all-MiniLM-L6-v2 (BERT) | 3.29 / 5.29 ms | 8.97 / 12.06 ms |
| twitter-roberta-base-sentiment (RoBERTa) | 13.40 / 18.96 ms | 47.55 / 59.67 ms |
| mmarco-mMiniLMv2-L12 (XLM-R) | 6.07 / 10.09 ms | 17.86 / 24.33 ms |
| distilbert-base-uncased-sst-2 (DistilBERT) | 6.99 / 9.76 ms | 24.54 / 33.34 ms |
| deberta-v3-base-prompt-injection-v2 (DeBERTa-v3) | 14.54 / 24.35 ms | 57.31 / 95.53 ms |
| granite-embedding-small-english-r2 (ModernBERT) | 6.99 / 12.44 ms | 21.35 / 35.81 ms |
1.26x to 1.78x faster; all three kleinhirn repetitions below all three ORT repetitions in each of the twelve cells.
Faster again on real texts. kleinhirn computes only the rows a text has; ORT runs a fixed-shape graph padded to the bucket or a dynamic-length graph without graph capture. Same six models, each on its 200 test texts at their real length, against ORT's fastest setting per browser and model (including dynamic-length graphs), ms per call:
| Model | Chromium f16 | Chromium f32 | Safari f16 | Safari f32 |
|---|---|---|---|---|
| all-MiniLM-L6-v2 | 2.09 / 5.22 | 2.58 / 6.23 | 5.25 / 11.44 | 6.30 / 14.24 |
| twitter-roberta-base-sentiment | 7.27 / 17.23 | 10.69 / 19.67 | 19.52 / 40.96 | 28.85 / 48.05 |
| mmarco-mMiniLMv2-L12 | 4.03 / 10.16 | 5.23 / 11.89 | 10.68 / 21.94 | 13.86 / 28.12 |
| distilbert-base-uncased-sst-2 | 3.95 / 9.48 | 5.61 / 10.52 | 10.38 / 21.14 | 15.07 / 25.01 |
| deberta-v3-base-prompt-injection-v2 | 7.77 / 23.48 | 11.57 / 30.01 | 20.85 / 48.73 | 29.05 / 67.36 |
| granite-embedding-small-english-r2 | 3.96 / 10.80 | 5.67 / 11.98 | 10.48 / 24.13 | 13.44 / 27.18 |
kleinhirn / ORT. 1.66x to 3.02x faster, on average 2.58x (Chromium f16), 2.17x (Chromium f32), 2.16x (Safari f16) and 1.98x (Safari f32); all three kleinhirn repetitions below all three ORT repetitions in each of the 24 cells. On the 1,000-text corpus (median 45 of 128 tokens) GLiNER2.5-small runs 4.4 ms against 12.2 ms for ORT with graph capture in f16 (2.8x) and 5.4 against 14.8 ms in f32 (2.7x). Julia 1 decides in 10.4 ms against 25.5 ms for its upstream ORT pipeline in f16 (2.4x) and 14.0 against 29.2 ms in f32 (2.1x).
Faster than ORT without a GPU too. The WASM path against ORT's WASM runtime with all cores in its fastest setting per browser and model, all six models at 128 tokens, 512 tokens and real length (18 cells per browser), ms per call, kleinhirn / ORT:
| Model | Chromium 128 | Chromium real | Safari 128 | Safari real |
|---|---|---|---|---|
| all-MiniLM-L6-v2 | 8.20 / 55.11 | 2.34 / 8.91 | 22.03 / 33.77 | 3.24 / 10.18 |
| twitter-roberta-base-sentiment | 46.65 / 209.03 | 15.39 / 60.29 | 73.40 / 79.69 | 22.01 / 28.71 |
| mmarco-mMiniLMv2-L12 | 13.34 / 63.75 | 6.73 / 28.54 | 25.64 / 26.57 | 10.05 / 25.14 |
| distilbert-base-uncased-sst-2 | 23.22 / 103.87 | 8.27 / 31.45 | 36.54 / 40.09 | 11.72 / 18.35 |
| deberta-v3-base-prompt-injection-v2 | 51.08 / 231.18 | 15.84 / 72.72 | 79.25 / 94.80 | 22.72 / 43.63 |
| granite-embedding-small-english-r2 | 18.57 / 80.26 | 5.39 / 19.91 | 27.69 / 37.13 | 7.62 / 12.05 |
Geometric mean of ORT / kleinhirn over the 18 cells: 4.20x in Chromium (kleinhirn ahead in all 18, 3.59x to 6.72x), 1.53x in WebKit, 1.34x in Safari and 4.37x in a regular Firefox 157. At 512 tokens Safari and WebKit are about even (0.98x to 1.29x). Same answers as PyTorch in all 72 cells. At real text length alone (6 cells per browser): 4.00x Chromium, 2.01x WebKit, 1.91x Safari, 4.43x Firefox. One run per cell and engine: single cells vary by 20 to 50 % between two nights, the means over 18 cells hold. Threads need a cross-origin isolated page; without it both engines run one thread, and kleinhirn is 1.74x faster in Chromium and 1.09x in Safari. Measured 8 and 9 October 2026.
Closer to the reference than ORT. On the measurement inputs above, ORT's f16 output deviates 2.9 to 20.5 times more from ORT's own f32 output than kleinhirn's f16 does. On Julia 1 the largest logit error in f16 is 0.109 for kleinhirn and 1.02 for ORT. Every kernel accumulates in f32.
Less memory. 52 to 71 % of ORT's peak memory, depending on the path (941 vs 1,442 MiB for GLiNER2.5-small in f16, 1,138 vs 1,599 MiB for Julia 1). The ORT peaks are from September; kleinhirn's grew by 5 to 14 % with the new kernels.
Small. The engine is 47 KB gzip, plus 16 KB for the WASM path (17 KB with threads). ORT's WebGPU runtime files are 6.3 MB gzip (25.9 MB raw). The model weights come on top in both cases.
Not done yet. Phones have not been measured since the kernels changed (October 2026); the last iPhone run missed the latency target (below).
Reproducible. Every number comes from a script in this repo under
a fixed protocol: interleaved runs, a quiet machine, the median of
three repetitions. Details under "How we measure" and in
docs/FINDINGS.md, sections 14 and 15.
data/k28/models.json (six families,
five tasks: sequence classification, NLI, embeddings, token
classification, reranking), GLiNER2.5 small, base and multi
(classification) and Julia 1. GLiNER2.5's entity extraction is not
implemented yet.The repository holds a static page, site/, that runs the engine on the
device that opens it. It loads the weights from Hugging Face, checks
parity against the goldens, measures latency for two length buckets on
every stage the device supports (f16, f32, WASM), and shows one JSON
file you can download. Page address:
https://smashburger-dev.github.io/kleinhirn/
tools/check_device_result.mjs, which
recomputes parity from the logits in the file, and adds the result to
docs/COMMUNITY.md.docs/device-results.md.For the six families: one ONNX export per model and length with fixed
shape [1, L] and the head inside the graph, then
onnxruntime.transformers.optimizer with its default fusions and two
variants (without EmbedLayerNormalization, with MultiHeadAttention), the
optimizer's fp16 conversion, three ORT Web 1.29.0 builds (default,
webgpu, jspi), graph capture on and off. The fastest combination per
model and length that passes the parity check on the CPU is the
baseline (data/k28/k28.8-best.json): the plain export with graph
capture in six cells, the optimized graph with capture in four, and for
DeBERTa the optimized graph without capture (capture does not start
there), always in one of the native WebGPU builds.
For GLiNER2.5 and Julia 1 the plain export goes through the same
optimizer (BERT path). For WebGPU, convert/capture_surgery.py replaces
a Squeeze/Unsqueeze pair in GLiNER's attention-mask chain with one
Reshape, so ORT's graph capture can run. ORT also gets its cheapest
timing boundary: inputs in persistent GPU buffers, one readback per
call.
data/k28/models.json, each at a pinned
revision: per family and task the three most downloaded Hugging Face
models up to 200M parameters. Convert one with
node tools/k28_convert.ts <model-id> after fetching it
(convert/k28_fetch.py).small-upstream
(fastino/gliner2.5-small-v1),
base-upstream
(fastino/gliner2.5-base-v1),
multi-upstream
(fastino/gliner2.5-multi-v1),
each at a pinned revision (tools/fetch_models.py).import 'kleinhirn' loads dist/kleinhirn.js, 15.7 KB gzip. Everything a
model does not need loads on demand: the WebGPU executor with its kernels,
the WASM executor in a worker, the tokenizer, and the GLiNER and Julia
engines. Engine bytes a page loads until its first result
(node tools/r6_paths.mjs, gzip -9):
| Load path | gzip -9 |
|---|---|
Core alone (dist/kleinhirn.js) | 15,652 B |
| Encoder model on WebGPU, with tokenizer | 39,154 B |
| Encoder model on WASM, plain build | 45,091 B |
| Encoder model on WASM, relaxed SIMD with threads | 45,948 B |
| GLiNER on WebGPU | 35,296 B |
| Julia on WebGPU | 34,826 B |
Word rows on demand (words: 'rows'), extra chunk | 3,234 B |
Tokenizers ship as tokenizer.bin: the tokenizer.json with vocabulary,
scores and merges in binary sections, gzip inside the file, decoded back
to the identical object. The XLM-R tokenizer drops from 17.1 MB to 1.99 MB,
DeBERTa-v3 from 8.7 MB to 0.89 MB, BERT WordPiece from 0.47 MB to 0.12 MB
(node tools/r6_tokbin.ts --survey). Folders that still name a
tokenizer.json load unchanged. A tokenizer is ready 4 to 22 times
faster than in the first tokenizer.bin release, because the vocabulary
lands in one typed hash table instead of a Map of 250,000 strings
(Node on an M1 Pro, node --expose-gc --import ./tests/helpers/node-hooks.mjs tools/r4_tokload.ts --survey): XLM-R
232 to 40 ms, Julia 695 to 83 ms, all list tokenizers together 5,050 to
875 ms. Unigram tokenizers also encode twice as fast.
The tokenizers alone are a second entry: import { JsonTokenizer } from 'kleinhirn/tokenizer' (dist/tokenizer.js) with the synchronous
JsonTokenizer.fromString and await JsonTokenizer.fromBytes(bytes) for
tokenizer.bin or tokenizer.json. A loaded model tokenizes itself, so
model code needs no tokenizer import.
For comparison, onnxruntime-web 1.29.0 loads three runtime files on its
WebGPU path (ort.webgpu.bundle.min.mjs,
ort-wasm-simd-threaded.asyncify.mjs and .wasm): 25,917,382 B raw,
6,338,621 B gzip -9. The model weights are a separate download in both
cases.
The word-embedding table is 31 to 81 % of a model's download (mMiniLM
192 of 237 MB), but a text needs only the rows of its own tokens.
words.bin holds the table with a checksum after each row, and the
load option words picks how it arrives:
'full' (default): the whole table at load, as before. Nothing about
the text leaves the device.'rows': only the rows a text needs, by HTTP Range, checked row by
row and kept in memory and Cache Storage. Every text with new tokens
waits one round trip (about 250 ms from our test machine to the
Hugging Face CDN).'progressive': like 'rows' at first, then the whole table in the
background; after that it computes like 'full'.Ready-made weights for the six bench models, GLiNER2.5 small, base and multi and Julia 1 (f16 and f32, every mode) are on Hugging Face in smashburger-dev/kleinhirn-weights, one folder per source model; pin a revision in the manifest URL.
Row requests show the host which tokens a text contains. Use 'rows'
and 'progressive' for public text or a host you control; keep
'full' for private text. A host that ignores Range gets the full
download with the same results.
Measured in Chromium on an M1 Pro, WebGPU f16, fresh browser profile
(node bench/run-r4-load.mjs, bench/results/r4-r4-load-summary.md).
Logits are bit-identical in every mode.
| Model | Download, full / rows | First result at 50 Mbit/s, full / rows | Memory peak on WASM, full / rows |
|---|---|---|---|
| all-MiniLM-L6-v2 | 45.4 / 22.1 MB | 7.5 / 4.3 s | 212 / 176 MiB |
| twitter-roberta-base-sentiment | 249.9 / 172.9 MB | 40.3 / 28.5 s | 1,044 / 920 MiB |
| mmarco-mMiniLMv2 (XLM-R) | 238.3 / 45.5 MB | 38.5 / 8.3 s | 907 / 286 MiB |
| distilbert-base-uncased-sst-2 | 134.2 / 87.6 MB | 21.8 / 14.8 s | 597 / 510 MiB |
| deberta-v3-base-prompt-injection | 388.4 / 191.5 MB | 62.5 / 31.4 s | 1,365 / 1,013 MiB |
| granite-embedding-small-english-r2 | 95.9 / 57.3 MB | 15.6 / 10.1 s | 397 / 307 MiB |
The 'full' mode got lighter too: the table is read without a copy and
the WASM worker no longer receives it, so mMiniLM peaks at 907 MiB on
WASM instead of 1,538 MiB, and models load 12 to 32 % faster from a
local server.
const model = await EncoderModel.load({ manifestUrl, words: 'progressive' });
console.log(model.info().words); // { mode, range, rows, requests, bytes }
Fallback chain (backend: 'auto'):
shader-f16)threads: 'auto' uses up to 8 threads on a
cross-origin isolated page (COOP and COEP headers), else one, and
the relaxed-SIMD build where the browser has itThe engine requests the device with exactly the WebGPU minimum limits
(limits: 'minimum', the default): 256 invocations per workgroup,
16 KiB workgroup memory, 128 MiB per storage binding, etc. All kernels
stay inside these limits, so a desktop adapter cannot silently use more
than a mobile adapter provides.
Sequence buckets: the general engine loads 128 and 512 by default (any
multiple of 4 up to the model's positions); GLiNER uses L64-L1024 with up
to 16 labels per call and a wide L1280/K80 bucket for label sets up to
80 (see docs/ARCHITECTURE.md).
npm ci
npm run build # dist/kleinhirn.js + wasm worker
# Python env for conversion + goldens (uv or venv, Python 3.12)
uv venv .venv && uv pip install --python .venv/bin/python -r convert/requirements.txt
# Julia-1 scripts (fetch_datasets, julia_golden, export_julia) need
# transformers 5.x in a second env
uv venv .venv-julia && uv pip install --python .venv-julia/bin/python -r convert/requirements-julia.txt
# Pinned model checkpoints -> models/ (gitignored)
.venv/bin/python tools/fetch_models.py small-upstream
.venv/bin/python convert/export_weights.py small-upstream # -> models/small-upstream/{f32,f16}
# Julia-1: fetch_models.py julia-1, then .venv-julia/bin/python convert/export_julia.py
# Pinned benchmark inputs -> models/k9-data, models/jev-benchmarks
.venv-julia/bin/python tools/fetch_datasets.py
node --test tests/*.test.ts # tokenizer + limits tests
In the browser (the package is not on npm; serve dist/ or import the
built file directly):
Any model of the list (Python env with convert/requirements-k28.txt):
.venv-k28/bin/python convert/k28_fetch.py sentence-transformers/all-MiniLM-L6-v2
node tools/k28_convert.ts sentence-transformers/all-MiniLM-L6-v2 # -> models/k28/<slug>/{f32,f16}
import { EncoderModel } from './dist/kleinhirn.js';
const model = await EncoderModel.load({
manifestUrl: '/models/k28/sentence-transformers__all-MiniLM-L6-v2/f16/manifest.json',
});
const [vector] = await model.embed(['The rocket launched successfully.']);
// also: classify(text), zeroShot(text, labels), rerank(query, passages), tokenClassify(text)
GLiNER2.5:
import { loadEngine } from './dist/kleinhirn.js';
const kh = await loadEngine({
manifestUrl: '/models/small-upstream/f16/manifest.json',
buckets: [128, 256, 512, 1024],
});
const out = await kh.classify(text, [
{ task: 'topic', labels: ['science', 'sports', 'politics'] },
]);
tests/corpus/texts1000.json (1,000 texts: 500 from the
argmin content tree under CC-BY-4.0, 500 generated by
tools/synthetic_texts.py) via convert/golden.py.node bench/run-parity.mjs <model> <f32|f16>.convert/manifest_forward.py) reads the
kleinhirn manifest + shards without PyTorch and must match the goldens
to 1e-4 with 100 % argmax. This validates the weight format
independent of any browser.docs/BENCHMARKS.md (K9 suite): every
dataset is fetched at a pinned revision by tools/fetch_datasets.py;
per-example prediction files contain only id, gold, predicted,
probabilities.convert/export_onnx.py) after
onnxruntime.transformers.optimizer (convert/optimize_onnx.py),
in f32 and in the optimizer's own fp16 conversion. On WebGPU a
two-node rewrite of the attention-mask chain
(convert/capture_surgery.py) lets ORT's graph capture
(enableGraphCapture) run, with inputs in persistent GPU buffers and
one readback per call.The tables below are the September measurements of GLiNER2.5 and Julia 1
(before the kernel rewrite); current numbers are under Results and in
docs/FINDINGS.md, sections 14 and 15.
Engine parity on the 1,000-text corpus (WebGPU, minimum limits;
bench/run-parity.mjs):
| Model | Precision | Argmax agreement | Max logit diff | Gates |
|---|---|---|---|---|
| small-upstream | f32 | 100 % (968) | 2.10e-5 | passed |
| small-upstream | f16 | 99.90 % (968) | 2.10e-2 | passed |
| base-upstream | f32 | 100 % (968) | 2.93e-5 | passed |
| base-upstream | f16 | 99.90 % (968) | 2.60e-2 | passed |
| multi-upstream | f32 | 100 % (953) | 1.32e-4 | passed |
| multi-upstream | f16 | 99.90 % (953) | 5.15e-2 | passed |
WASM-SIMD parity (f32, --backend=wasm, small-upstream): argmax 100 %,
max logit diff 2.25e-5; all gates passed.
Julia 1 (bench/run-julia-parity.mjs): f32 100/100 choices, logit diff
1.0e-4; f16 100/100 choices, max probability diff 2.4e-2, above our
1e-2 probability gate (known limitation, see docs/FINDINGS.md).
Tokenizer parity (node --test tests/): 100 % equal token ids vs the HF
tokenizer on all 1,000 corpus texts, for all three GLiNER models and
Julia 1.
Measured on a 10-core M1 Pro (16 GB) in Playwright Chromium
151.0.7922.34 (Apple Metal adapter, WebGPU minimum limits), interleaved
ABAB, median of 3 repetitions; ranges show the repetition min-max.
Runner bench/run-official.mjs, results under bench/results/, full
detail in docs/FINDINGS.md. The WebGPU comparison against ONNX Runtime
Web with graph capture comes from a pass at commit 2b7dacd
(k20gc-official-*); the WASM pair, Julia 1 and the memory table
come from a pass at commit ba3a2c7 (k20-official-*, k20-mem-p*),
both with a start-load bound of 4. The bucket sweep and the batch throughput come from
earlier passes with a bound of 6 (commits ccef9a7 and 8535538,
official-*).
What the ORT optimizer does to these graphs (onnxruntime 1.29.0, BERT
path, the only one that applies; bench/results/k20-optimize-*.json):
it fuses LayerNorm, SkipLayerNorm and Gelu, but not the attention of
either model. DeBERTa-v2 keeps its relative-position terms (c2p, p2c)
as separate gathers, ModernBERT keeps RoPE and the windowed mask
unfused: 0 Attention and 0 MultiHeadAttention nodes in both. The fused
graphs are still faster, mostly in f16, because they drop the casts
that the plain fp16 conversion inserts. ORT's graph capture does not
start on the fused graph: a Memcpy pair around a CPU-placed Squeeze in
the DeBERTa mask chain blocks it. Replacing that Squeeze and the
following Unsqueeze with one Reshape to the fixed shape
(element-identical, same CPU parity) puts every node on WebGPU, and
capture runs.
kleinhirn vs ONNX Runtime Web, small-upstream, L128 (median ms per inference call on 968 corpus texts; the ORT page serves L128 only):
| Path | Precision | Median ms (range) | p95 ms | Argmax vs PyTorch |
|---|---|---|---|---|
| kleinhirn | f16 | 6.5 (6.5-6.6) | 11.4 | 99.90 % |
| ORT WebGPU, fused graph + graph capture | f16 | 12.5 (12.5-12.5) | 13.2 | 99.59 % |
| kleinhirn | f32 | 7.4 (7.4-7.5) | 13.2 | 100 % |
| ORT WebGPU, fused graph + graph capture | f32 | 16.0 (15.5-16.1) | 16.6 | 100 % |
| kleinhirn WASM | f32 | 69.2 (69.2-69.8) | 120.6 | 100 % |
| ORT wasm, fused graph | f32 | 180.0 (179.9-180.4) | 180.8 | 100 % |
Each kleinhirn row pairs with the ORT row below it (ABAB). The WASM pair comes from the ba3a2c7 pass; the wasm EP has no graph capture. In its own ABAB pair the plain export ran 17.4 ms (f16) and 18.2 ms (f32) against 14.2 and 17.5 ms for the fused graph; graph capture then took the fused graph from 14.8 to 12.5 ms (f16) and from 18.4 to 15.7 ms (f32). The same path moves by up to 14 % between passes (kleinhirn f32 6.5 ms in the ba3a2c7 pass, 7.4 ms in the capture pass), so a factor holds only within its pair. Both ORT fp16 graphs miss our fp16 probability gate (max probability diff 5.3e-2 plain, 3.5e-2 fused, gate 1e-2); kleinhirn f16 stays at 4.2e-3.
kleinhirn across sequence buckets (earlier pass, commit ccef9a7; no ORT counterpart beyond L128):
| Bucket | f16 median (range) | f32 median (range) | f16 p95 | f32 p95 |
|---|---|---|---|---|
| L128 | 6.8 (6.8-6.9) | 6.9 (6.9-6.9) | 12.1 | 12.5 |
| L256 | 36.2 (35.3-37.4) | 37.4 (37.4-37.4) | 47.9 | 49.7 |
| L512 | 95.9 (95.4-109.5) | 97.5 (97.3-97.8) | 131.8 | 134.4 |
| L1024 | 321.3 (320.8-321.3) | 321.5 (320.0-327.0) | 414.6 | 417.8 |
kleinhirn WASM at L256 in the same pass: 360.9 ms (358.8-361.2), p95 450.4.
Julia 1 against the upstream WebGPU loop on the fused upstream graph.
100 upstream parity requests; every path picks the same option as
PyTorch in 100/100. bench/upstream-julia/bench-graph.html runs the
upstream benchmark loop verbatim with the graph file as a parameter.
kleinhirn reports ms per decision, upstream ms per request, the same
unit:
| Pair (ABAB) | kleinhirn ms | upstream ms | Max logit diff vs PyTorch, kleinhirn / upstream |
|---|---|---|---|
| f32, one request per call, tokenization timed on both sides | 24.3 (24.3-24.4) | 24.8 (24.8-24.8) | 1.0e-4 / 1.6e-4 |
| f16, one request per call, tokenization timed on both sides | 24.1 (23.4-24.1) | 21.5 (21.4-22.1) | 0.091 / 1.02 |
| fastest paths, f16: kleinhirn batch API at B1 (pre-tokenized) vs upstream batch 4 | 20.7 (20.5-21.2) | 15.8 (15.7-15.8) | not reported / 1.02 |
Against the unchanged upstream graph (earlier pass, commit ccef9a7) kleinhirn f16 ran 24.9 ms per decision, upstream batch 4 23.9 and batch 1 30.2 ms per request.
Download, GPU buffers and peak process memory. Peak memory is the footprint of the benchmark's own browser process tree, median of 3 browser launches with the launch range in parentheses; footprint varies up to ~15 % per launch, so a "uses less" claim stands only where the ranges do not overlap:
| Path | Download MiB | GPU buffer MiB (L128) | Peak MiB, median (range) |
|---|---|---|---|
| kleinhirn small f16 | 152.3 | 51.9 | 850 (790-856) |
| ORT WebGPU f16, fused | 187.7 | not measured | 1442 (1425-1454) |
| kleinhirn small f32 | 296.6 | 103.7 | 1151 (1140-1152) |
| ORT WebGPU f32, fused | 331.6 | not measured | 2321 (2301-2329) |
| kleinhirn WASM f32 | 296.6 | - | 572 (545-737) |
| ORT wasm f32, fused | 331.6 | - | 1280 (1273-1282) |
| kleinhirn julia f16 | 308.0 | 109.9 | 1001 (1000-1002) |
| upstream julia batch 4 / batch 1, fused f16 | not measured (graph files 275.2) | not measured | 1621 (1617-1629) / 1599 (1597-1603) |
| upstream julia batch 4, fused f32 | not measured (graph files 550.2) | not measured | 2497 (2382-2515) |
With graph capture the ORT WebGPU peaks were 1476 MiB (f16) and 2368 MiB (f32), provisional: three of the six launches started above the load bound.
A load-sensitivity pass found no latency change under pure CPU load up to a 1-minute load of ~10 on this machine; mixed build and browser load did affect earlier runs, so the start-load bound stays.
On a Modal NVIDIA L4 under Linux (Chromium 151, Vulkan; gate:
adapterInfo reports vendor nvidia, architecture lovelace, no
fallback adapter) kleinhirn f32 passed every parity gate:
small-upstream 100 % argmax (968 cases) with max logit diff 2.35e-5,
julia-1 all parity gates green. small-upstream f32 at L128 measured
4.5 ms median per call in a single pass (provisional). That Chromium
build does not expose shader-f16, so the engine runs in its f32
fallback by design there. bench/results/nvidia-*.json.
Read: against the best ORT configuration we could produce (fused graph plus graph capture), kleinhirn is 1.9x faster than ONNX Runtime Web in f16 and 2.2x in f32 on WebGPU, and 2.6x on the WASM path (small-upstream, L128); it peaks at 45-59 % of ORT's memory. For Julia 1 the fused fp16 graph makes the upstream loop faster than kleinhirn: 21.5 vs 24.1 ms at equal boundaries, 15.8 vs 20.7 ms between the fastest paths. That graph deviates up to 1.02 in logits from PyTorch fp32, kleinhirn f16 by 0.091. In f32 the two are level (24.3 vs 24.8 ms). kleinhirn's Julia advantage is peak memory (1001 vs 1599-1621 MiB) and fp16 accuracy, not speed. Graph capture was not tried for Julia: it needs fixed shapes, and the upstream loop calls with varying lengths.
The batch API (classifyBatch / decideBatch, batch sizes 1/4/8/16,
stride-packed at 64-token increments) produces outputs bit-identical
to single calls on the same inputs (batch parity B1-B16, f32 and f16,
both models, mixed lengths). Official throughput (ms per decision,
same protocol):
| Path | B1 | B4 | B8 | B16 |
|---|---|---|---|---|
| kleinhirn small f16 L128 | 6.90 | 6.39 | 6.02 | 8.83 |
| kleinhirn julia f16 | 21.7 | 35.8 | 47.0 | 46.2 |
For small-upstream, batching matches the single-call path at B1 and helps slightly up to B8; B16 regresses. For julia-1 the batch path is faster than the single path at B1 (21.7 vs 24.9 ms, though the batch number times pre-tokenized inputs while the single-call number includes tokenization) but slower per decision from B4 on: in this throughput pass kleinhirn B4 ran 35.8 ms against an upstream batch-4 25.6 ms from a different pass. The published Julia comparison is the fused-graph pass above. The API is kept for the parity guarantee and for hosts that batch anyway.
| Task | Published | Measured here | Status |
|---|---|---|---|
| Typed Decisions | 1,463/2,000 (their GPU BF16 run); 1,451/2,000 (their CPU run) | 1,451/2,000 | matches their CPU run exactly |
| AG News | 94/100 | 94/100 | exact |
| DAIR Emotion | 86/100 | 86/100 | exact |
| Banking77 | 64/100 | ambiguous: 57-69 by router parameters | open, "top-16" not uniquely reconstructable |
| MASSIVE | 71.50 % | en-US 51.71 % | not reproducible; no public request format derives it |
AG News, DAIR Emotion and Banking77 are read from the BTZSC copies, sampled as in jev-benchmarks; MASSIVE from the AmazonScience/massive dataset.
kleinhirn's Julia engine (WebGPU f16, minimum limits) scores the same
1,451/2,000 on Typed Decisions as PyTorch CPU; argmax agreement
1,997/2,000 (bench/results/k9-typed-kleinhirn-julia-f16.json).
| Task | small | base | multi |
|---|---|---|---|
| typed | 36.0 % | 39.7 % | 35.3 % |
| agnews | 47 % | 82 % | 67 % |
| emotiondair | 33 % | 43 % | 36 % |
| banking77 | 47 % | 73 % | 63 % |
Jev reference values under the pinned protocol are reported, not
measured by us: AG News 91/100, DAIR Emotion 48/100 and Banking77
87/100 come from the pinned btzsc-pilot-v1 report (see
docs/BENCHMARKS.md); Typed Decisions 72.70 % is listed on the
Julia 1 model card
("Evaluation", column "Jev reference") as a supplied comparison
reference.
src/ engine: device, weights, tokenizer, WGSL graph, WASM fallback
convert/ checkpoint -> weight format, goldens, numpy reference forward
bench/ measurement pages + Playwright runners + k9 suite + results
tests/ Node tests, corpus, goldens (per model)
tools/ corpus builder, pinned model/dataset downloaders
site/ device measurement page (source)
pages/ prebuilt page served by GitHub Pages, no weights
docs/ ARCHITECTURE, RESEARCH, BENCHMARKS, FINDINGS
models/ checkpoints and weights, never in git
Every project, model and dataset this README names, with the place its owners publish it. kleinhirn is not affiliated with any of them.
Models measured or supported:
data/k28/models.jsonModel families:
Runtimes, formats and tools:
convert/gliner2_export.py is
adapted: FluidInference/gliner2-5-small-coremlBrowsers and platforms:
Datasets and benchmarks:
Corpus and first user:
Device results, bug reports and pull requests are welcome; see
CONTRIBUTING.md. Anything beyond a small fix starts
with an issue. Security problems go through
SECURITY.md, not a public issue.
Apache-2.0, copyright Noa Katana (see LICENSE). Third-party models,
datasets, adapted code and corpus texts keep their own licenses (see
NOTICE).
Browser-side inference engine for small encoder classifiers: WGSL kernels, WASM-SIMD fallback, parity-tested against PyTorch
TypeScript
0
7 commits
updated Oct 13, 2026
Encoder models in the browser, on the GPU, with no server.
kleinhirn runs text encoders in the user's browser: classification, zero-shot classification over NLI, sentence embeddings, named entities and reranking. The text stays on the device, there is no inference server to pay for, and it works offline once the model is cached.
It loads Hugging Face checkpoints of six
encoder families directly from config.json and
safetensors:
BERT (with
ELECTRA and
MiniLM),
RoBERTa,
XLM-R,
DistilBERT,
DeBERTa-v2/v3 and
ModernBERT. Two models with
their own heads run on the same engine:
GLiNER2.5, a zero-shot
classifier that takes any set of labels, and
Julia 1, a decision
model that picks one of up to 20 options. The engine ships its own GPU
programs (WGSL kernels) for
WebGPU; browsers without WebGPU fall
back to a WASM-SIMD executor on the CPU that runs the same plan for
every family, on up to 8 threads.
The usual tool for running such models in a browser is ONNX Runtime Web (ORT) from Microsoft. It is the baseline for every speed number below.
Same answers as the original. All 89 checkpoints on our list (the three most downloaded per family and task, up to 200M parameters) make the same decisions as the PyTorch reference: 100 % in full precision (f32), at least 99.5 % in half precision (f16) of the decisions that f16 rounding cannot flip, embeddings with cosine at least 0.9999 and 0.999.
Faster than ORT at its best, in every family. One model per family (the most downloaded), inputs of exactly 128 and 512 tokens, f16, against ORT's fastest setting we found for each model (graph optimizer variants, three ORT builds, graph capture where it starts):
| Model | 128 tokens kleinhirn / ORT | 512 tokens kleinhirn / ORT |
|---|---|---|
| all-MiniLM-L6-v2 (BERT) | 3.29 / 5.29 ms | 8.97 / 12.06 ms |
| twitter-roberta-base-sentiment (RoBERTa) | 13.40 / 18.96 ms | 47.55 / 59.67 ms |
| mmarco-mMiniLMv2-L12 (XLM-R) | 6.07 / 10.09 ms | 17.86 / 24.33 ms |
| distilbert-base-uncased-sst-2 (DistilBERT) | 6.99 / 9.76 ms | 24.54 / 33.34 ms |
| deberta-v3-base-prompt-injection-v2 (DeBERTa-v3) | 14.54 / 24.35 ms | 57.31 / 95.53 ms |
| granite-embedding-small-english-r2 (ModernBERT) | 6.99 / 12.44 ms | 21.35 / 35.81 ms |
1.26x to 1.78x faster; all three kleinhirn repetitions below all three ORT repetitions in each of the twelve cells.
Faster again on real texts. kleinhirn computes only the rows a text has; ORT runs a fixed-shape graph padded to the bucket or a dynamic-length graph without graph capture. Same six models, each on its 200 test texts at their real length, against ORT's fastest setting per browser and model (including dynamic-length graphs), ms per call:
| Model | Chromium f16 | Chromium f32 | Safari f16 | Safari f32 |
|---|---|---|---|---|
| all-MiniLM-L6-v2 | 2.09 / 5.22 | 2.58 / 6.23 | 5.25 / 11.44 | 6.30 / 14.24 |
| twitter-roberta-base-sentiment | 7.27 / 17.23 | 10.69 / 19.67 | 19.52 / 40.96 | 28.85 / 48.05 |
| mmarco-mMiniLMv2-L12 | 4.03 / 10.16 | 5.23 / 11.89 | 10.68 / 21.94 | 13.86 / 28.12 |
| distilbert-base-uncased-sst-2 | 3.95 / 9.48 | 5.61 / 10.52 | 10.38 / 21.14 | 15.07 / 25.01 |
| deberta-v3-base-prompt-injection-v2 | 7.77 / 23.48 | 11.57 / 30.01 | 20.85 / 48.73 | 29.05 / 67.36 |
| granite-embedding-small-english-r2 | 3.96 / 10.80 | 5.67 / 11.98 | 10.48 / 24.13 | 13.44 / 27.18 |
kleinhirn / ORT. 1.66x to 3.02x faster, on average 2.58x (Chromium f16), 2.17x (Chromium f32), 2.16x (Safari f16) and 1.98x (Safari f32); all three kleinhirn repetitions below all three ORT repetitions in each of the 24 cells. On the 1,000-text corpus (median 45 of 128 tokens) GLiNER2.5-small runs 4.4 ms against 12.2 ms for ORT with graph capture in f16 (2.8x) and 5.4 against 14.8 ms in f32 (2.7x). Julia 1 decides in 10.4 ms against 25.5 ms for its upstream ORT pipeline in f16 (2.4x) and 14.0 against 29.2 ms in f32 (2.1x).
Faster than ORT without a GPU too. The WASM path against ORT's WASM runtime with all cores in its fastest setting per browser and model, all six models at 128 tokens, 512 tokens and real length (18 cells per browser), ms per call, kleinhirn / ORT:
| Model | Chromium 128 | Chromium real | Safari 128 | Safari real |
|---|---|---|---|---|
| all-MiniLM-L6-v2 | 8.20 / 55.11 | 2.34 / 8.91 | 22.03 / 33.77 | 3.24 / 10.18 |
| twitter-roberta-base-sentiment | 46.65 / 209.03 | 15.39 / 60.29 | 73.40 / 79.69 | 22.01 / 28.71 |
| mmarco-mMiniLMv2-L12 | 13.34 / 63.75 | 6.73 / 28.54 | 25.64 / 26.57 | 10.05 / 25.14 |
| distilbert-base-uncased-sst-2 | 23.22 / 103.87 | 8.27 / 31.45 | 36.54 / 40.09 | 11.72 / 18.35 |
| deberta-v3-base-prompt-injection-v2 | 51.08 / 231.18 | 15.84 / 72.72 | 79.25 / 94.80 | 22.72 / 43.63 |
| granite-embedding-small-english-r2 | 18.57 / 80.26 | 5.39 / 19.91 | 27.69 / 37.13 | 7.62 / 12.05 |
Geometric mean of ORT / kleinhirn over the 18 cells: 4.20x in Chromium (kleinhirn ahead in all 18, 3.59x to 6.72x), 1.53x in WebKit, 1.34x in Safari and 4.37x in a regular Firefox 157. At 512 tokens Safari and WebKit are about even (0.98x to 1.29x). Same answers as PyTorch in all 72 cells. At real text length alone (6 cells per browser): 4.00x Chromium, 2.01x WebKit, 1.91x Safari, 4.43x Firefox. One run per cell and engine: single cells vary by 20 to 50 % between two nights, the means over 18 cells hold. Threads need a cross-origin isolated page; without it both engines run one thread, and kleinhirn is 1.74x faster in Chromium and 1.09x in Safari. Measured 8 and 9 October 2026.
Closer to the reference than ORT. On the measurement inputs above, ORT's f16 output deviates 2.9 to 20.5 times more from ORT's own f32 output than kleinhirn's f16 does. On Julia 1 the largest logit error in f16 is 0.109 for kleinhirn and 1.02 for ORT. Every kernel accumulates in f32.
Less memory. 52 to 71 % of ORT's peak memory, depending on the path (941 vs 1,442 MiB for GLiNER2.5-small in f16, 1,138 vs 1,599 MiB for Julia 1). The ORT peaks are from September; kleinhirn's grew by 5 to 14 % with the new kernels.
Small. The engine is 47 KB gzip, plus 16 KB for the WASM path (17 KB with threads). ORT's WebGPU runtime files are 6.3 MB gzip (25.9 MB raw). The model weights come on top in both cases.
Not done yet. Phones have not been measured since the kernels changed (October 2026); the last iPhone run missed the latency target (below).
Reproducible. Every number comes from a script in this repo under
a fixed protocol: interleaved runs, a quiet machine, the median of
three repetitions. Details under "How we measure" and in
docs/FINDINGS.md, sections 14 and 15.
data/k28/models.json (six families,
five tasks: sequence classification, NLI, embeddings, token
classification, reranking), GLiNER2.5 small, base and multi
(classification) and Julia 1. GLiNER2.5's entity extraction is not
implemented yet.The repository holds a static page, site/, that runs the engine on the
device that opens it. It loads the weights from Hugging Face, checks
parity against the goldens, measures latency for two length buckets on
every stage the device supports (f16, f32, WASM), and shows one JSON
file you can download. Page address:
https://smashburger-dev.github.io/kleinhirn/
tools/check_device_result.mjs, which
recomputes parity from the logits in the file, and adds the result to
docs/COMMUNITY.md.docs/device-results.md.For the six families: one ONNX export per model and length with fixed
shape [1, L] and the head inside the graph, then
onnxruntime.transformers.optimizer with its default fusions and two
variants (without EmbedLayerNormalization, with MultiHeadAttention), the
optimizer's fp16 conversion, three ORT Web 1.29.0 builds (default,
webgpu, jspi), graph capture on and off. The fastest combination per
model and length that passes the parity check on the CPU is the
baseline (data/k28/k28.8-best.json): the plain export with graph
capture in six cells, the optimized graph with capture in four, and for
DeBERTa the optimized graph without capture (capture does not start
there), always in one of the native WebGPU builds.
For GLiNER2.5 and Julia 1 the plain export goes through the same
optimizer (BERT path). For WebGPU, convert/capture_surgery.py replaces
a Squeeze/Unsqueeze pair in GLiNER's attention-mask chain with one
Reshape, so ORT's graph capture can run. ORT also gets its cheapest
timing boundary: inputs in persistent GPU buffers, one readback per
call.
data/k28/models.json, each at a pinned
revision: per family and task the three most downloaded Hugging Face
models up to 200M parameters. Convert one with
node tools/k28_convert.ts <model-id> after fetching it
(convert/k28_fetch.py).small-upstream
(fastino/gliner2.5-small-v1),
base-upstream
(fastino/gliner2.5-base-v1),
multi-upstream
(fastino/gliner2.5-multi-v1),
each at a pinned revision (tools/fetch_models.py).import 'kleinhirn' loads dist/kleinhirn.js, 15.7 KB gzip. Everything a
model does not need loads on demand: the WebGPU executor with its kernels,
the WASM executor in a worker, the tokenizer, and the GLiNER and Julia
engines. Engine bytes a page loads until its first result
(node tools/r6_paths.mjs, gzip -9):
| Load path | gzip -9 |
|---|---|
Core alone (dist/kleinhirn.js) | 15,652 B |
| Encoder model on WebGPU, with tokenizer | 39,154 B |
| Encoder model on WASM, plain build | 45,091 B |
| Encoder model on WASM, relaxed SIMD with threads | 45,948 B |
| GLiNER on WebGPU | 35,296 B |
| Julia on WebGPU | 34,826 B |
Word rows on demand (words: 'rows'), extra chunk | 3,234 B |
Tokenizers ship as tokenizer.bin: the tokenizer.json with vocabulary,
scores and merges in binary sections, gzip inside the file, decoded back
to the identical object. The XLM-R tokenizer drops from 17.1 MB to 1.99 MB,
DeBERTa-v3 from 8.7 MB to 0.89 MB, BERT WordPiece from 0.47 MB to 0.12 MB
(node tools/r6_tokbin.ts --survey). Folders that still name a
tokenizer.json load unchanged. A tokenizer is ready 4 to 22 times
faster than in the first tokenizer.bin release, because the vocabulary
lands in one typed hash table instead of a Map of 250,000 strings
(Node on an M1 Pro, node --expose-gc --import ./tests/helpers/node-hooks.mjs tools/r4_tokload.ts --survey): XLM-R
232 to 40 ms, Julia 695 to 83 ms, all list tokenizers together 5,050 to
875 ms. Unigram tokenizers also encode twice as fast.
The tokenizers alone are a second entry: import { JsonTokenizer } from 'kleinhirn/tokenizer' (dist/tokenizer.js) with the synchronous
JsonTokenizer.fromString and await JsonTokenizer.fromBytes(bytes) for
tokenizer.bin or tokenizer.json. A loaded model tokenizes itself, so
model code needs no tokenizer import.
For comparison, onnxruntime-web 1.29.0 loads three runtime files on its
WebGPU path (ort.webgpu.bundle.min.mjs,
ort-wasm-simd-threaded.asyncify.mjs and .wasm): 25,917,382 B raw,
6,338,621 B gzip -9. The model weights are a separate download in both
cases.
The word-embedding table is 31 to 81 % of a model's download (mMiniLM
192 of 237 MB), but a text needs only the rows of its own tokens.
words.bin holds the table with a checksum after each row, and the
load option words picks how it arrives:
'full' (default): the whole table at load, as before. Nothing about
the text leaves the device.'rows': only the rows a text needs, by HTTP Range, checked row by
row and kept in memory and Cache Storage. Every text with new tokens
waits one round trip (about 250 ms from our test machine to the
Hugging Face CDN).'progressive': like 'rows' at first, then the whole table in the
background; after that it computes like 'full'.Ready-made weights for the six bench models, GLiNER2.5 small, base and multi and Julia 1 (f16 and f32, every mode) are on Hugging Face in smashburger-dev/kleinhirn-weights, one folder per source model; pin a revision in the manifest URL.
Row requests show the host which tokens a text contains. Use 'rows'
and 'progressive' for public text or a host you control; keep
'full' for private text. A host that ignores Range gets the full
download with the same results.
Measured in Chromium on an M1 Pro, WebGPU f16, fresh browser profile
(node bench/run-r4-load.mjs, bench/results/r4-r4-load-summary.md).
Logits are bit-identical in every mode.
| Model | Download, full / rows | First result at 50 Mbit/s, full / rows | Memory peak on WASM, full / rows |
|---|---|---|---|
| all-MiniLM-L6-v2 | 45.4 / 22.1 MB | 7.5 / 4.3 s | 212 / 176 MiB |
| twitter-roberta-base-sentiment | 249.9 / 172.9 MB | 40.3 / 28.5 s | 1,044 / 920 MiB |
| mmarco-mMiniLMv2 (XLM-R) | 238.3 / 45.5 MB | 38.5 / 8.3 s | 907 / 286 MiB |
| distilbert-base-uncased-sst-2 | 134.2 / 87.6 MB | 21.8 / 14.8 s | 597 / 510 MiB |
| deberta-v3-base-prompt-injection | 388.4 / 191.5 MB | 62.5 / 31.4 s | 1,365 / 1,013 MiB |
| granite-embedding-small-english-r2 | 95.9 / 57.3 MB | 15.6 / 10.1 s | 397 / 307 MiB |
The 'full' mode got lighter too: the table is read without a copy and
the WASM worker no longer receives it, so mMiniLM peaks at 907 MiB on
WASM instead of 1,538 MiB, and models load 12 to 32 % faster from a
local server.
const model = await EncoderModel.load({ manifestUrl, words: 'progressive' });
console.log(model.info().words); // { mode, range, rows, requests, bytes }
Fallback chain (backend: 'auto'):
shader-f16)threads: 'auto' uses up to 8 threads on a
cross-origin isolated page (COOP and COEP headers), else one, and
the relaxed-SIMD build where the browser has itThe engine requests the device with exactly the WebGPU minimum limits
(limits: 'minimum', the default): 256 invocations per workgroup,
16 KiB workgroup memory, 128 MiB per storage binding, etc. All kernels
stay inside these limits, so a desktop adapter cannot silently use more
than a mobile adapter provides.
Sequence buckets: the general engine loads 128 and 512 by default (any
multiple of 4 up to the model's positions); GLiNER uses L64-L1024 with up
to 16 labels per call and a wide L1280/K80 bucket for label sets up to
80 (see docs/ARCHITECTURE.md).
npm ci
npm run build # dist/kleinhirn.js + wasm worker
# Python env for conversion + goldens (uv or venv, Python 3.12)
uv venv .venv && uv pip install --python .venv/bin/python -r convert/requirements.txt
# Julia-1 scripts (fetch_datasets, julia_golden, export_julia) need
# transformers 5.x in a second env
uv venv .venv-julia && uv pip install --python .venv-julia/bin/python -r convert/requirements-julia.txt
# Pinned model checkpoints -> models/ (gitignored)
.venv/bin/python tools/fetch_models.py small-upstream
.venv/bin/python convert/export_weights.py small-upstream # -> models/small-upstream/{f32,f16}
# Julia-1: fetch_models.py julia-1, then .venv-julia/bin/python convert/export_julia.py
# Pinned benchmark inputs -> models/k9-data, models/jev-benchmarks
.venv-julia/bin/python tools/fetch_datasets.py
node --test tests/*.test.ts # tokenizer + limits tests
In the browser (the package is not on npm; serve dist/ or import the
built file directly):
Any model of the list (Python env with convert/requirements-k28.txt):
.venv-k28/bin/python convert/k28_fetch.py sentence-transformers/all-MiniLM-L6-v2
node tools/k28_convert.ts sentence-transformers/all-MiniLM-L6-v2 # -> models/k28/<slug>/{f32,f16}
import { EncoderModel } from './dist/kleinhirn.js';
const model = await EncoderModel.load({
manifestUrl: '/models/k28/sentence-transformers__all-MiniLM-L6-v2/f16/manifest.json',
});
const [vector] = await model.embed(['The rocket launched successfully.']);
// also: classify(text), zeroShot(text, labels), rerank(query, passages), tokenClassify(text)
GLiNER2.5:
import { loadEngine } from './dist/kleinhirn.js';
const kh = await loadEngine({
manifestUrl: '/models/small-upstream/f16/manifest.json',
buckets: [128, 256, 512, 1024],
});
const out = await kh.classify(text, [
{ task: 'topic', labels: ['science', 'sports', 'politics'] },
]);
tests/corpus/texts1000.json (1,000 texts: 500 from the
argmin content tree under CC-BY-4.0, 500 generated by
tools/synthetic_texts.py) via convert/golden.py.node bench/run-parity.mjs <model> <f32|f16>.convert/manifest_forward.py) reads the
kleinhirn manifest + shards without PyTorch and must match the goldens
to 1e-4 with 100 % argmax. This validates the weight format
independent of any browser.docs/BENCHMARKS.md (K9 suite): every
dataset is fetched at a pinned revision by tools/fetch_datasets.py;
per-example prediction files contain only id, gold, predicted,
probabilities.convert/export_onnx.py) after
onnxruntime.transformers.optimizer (convert/optimize_onnx.py),
in f32 and in the optimizer's own fp16 conversion. On WebGPU a
two-node rewrite of the attention-mask chain
(convert/capture_surgery.py) lets ORT's graph capture
(enableGraphCapture) run, with inputs in persistent GPU buffers and
one readback per call.The tables below are the September measurements of GLiNER2.5 and Julia 1
(before the kernel rewrite); current numbers are under Results and in
docs/FINDINGS.md, sections 14 and 15.
Engine parity on the 1,000-text corpus (WebGPU, minimum limits;
bench/run-parity.mjs):
| Model | Precision | Argmax agreement | Max logit diff | Gates |
|---|---|---|---|---|
| small-upstream | f32 | 100 % (968) | 2.10e-5 | passed |
| small-upstream | f16 | 99.90 % (968) | 2.10e-2 | passed |
| base-upstream | f32 | 100 % (968) | 2.93e-5 | passed |
| base-upstream | f16 | 99.90 % (968) | 2.60e-2 | passed |
| multi-upstream | f32 | 100 % (953) | 1.32e-4 | passed |
| multi-upstream | f16 | 99.90 % (953) | 5.15e-2 | passed |
WASM-SIMD parity (f32, --backend=wasm, small-upstream): argmax 100 %,
max logit diff 2.25e-5; all gates passed.
Julia 1 (bench/run-julia-parity.mjs): f32 100/100 choices, logit diff
1.0e-4; f16 100/100 choices, max probability diff 2.4e-2, above our
1e-2 probability gate (known limitation, see docs/FINDINGS.md).
Tokenizer parity (node --test tests/): 100 % equal token ids vs the HF
tokenizer on all 1,000 corpus texts, for all three GLiNER models and
Julia 1.
Measured on a 10-core M1 Pro (16 GB) in Playwright Chromium
151.0.7922.34 (Apple Metal adapter, WebGPU minimum limits), interleaved
ABAB, median of 3 repetitions; ranges show the repetition min-max.
Runner bench/run-official.mjs, results under bench/results/, full
detail in docs/FINDINGS.md. The WebGPU comparison against ONNX Runtime
Web with graph capture comes from a pass at commit 2b7dacd
(k20gc-official-*); the WASM pair, Julia 1 and the memory table
come from a pass at commit ba3a2c7 (k20-official-*, k20-mem-p*),
both with a start-load bound of 4. The bucket sweep and the batch throughput come from
earlier passes with a bound of 6 (commits ccef9a7 and 8535538,
official-*).
What the ORT optimizer does to these graphs (onnxruntime 1.29.0, BERT
path, the only one that applies; bench/results/k20-optimize-*.json):
it fuses LayerNorm, SkipLayerNorm and Gelu, but not the attention of
either model. DeBERTa-v2 keeps its relative-position terms (c2p, p2c)
as separate gathers, ModernBERT keeps RoPE and the windowed mask
unfused: 0 Attention and 0 MultiHeadAttention nodes in both. The fused
graphs are still faster, mostly in f16, because they drop the casts
that the plain fp16 conversion inserts. ORT's graph capture does not
start on the fused graph: a Memcpy pair around a CPU-placed Squeeze in
the DeBERTa mask chain blocks it. Replacing that Squeeze and the
following Unsqueeze with one Reshape to the fixed shape
(element-identical, same CPU parity) puts every node on WebGPU, and
capture runs.
kleinhirn vs ONNX Runtime Web, small-upstream, L128 (median ms per inference call on 968 corpus texts; the ORT page serves L128 only):
| Path | Precision | Median ms (range) | p95 ms | Argmax vs PyTorch |
|---|---|---|---|---|
| kleinhirn | f16 | 6.5 (6.5-6.6) | 11.4 | 99.90 % |
| ORT WebGPU, fused graph + graph capture | f16 | 12.5 (12.5-12.5) | 13.2 | 99.59 % |
| kleinhirn | f32 | 7.4 (7.4-7.5) | 13.2 | 100 % |
| ORT WebGPU, fused graph + graph capture | f32 | 16.0 (15.5-16.1) | 16.6 | 100 % |
| kleinhirn WASM | f32 | 69.2 (69.2-69.8) | 120.6 | 100 % |
| ORT wasm, fused graph | f32 | 180.0 (179.9-180.4) | 180.8 | 100 % |
Each kleinhirn row pairs with the ORT row below it (ABAB). The WASM pair comes from the ba3a2c7 pass; the wasm EP has no graph capture. In its own ABAB pair the plain export ran 17.4 ms (f16) and 18.2 ms (f32) against 14.2 and 17.5 ms for the fused graph; graph capture then took the fused graph from 14.8 to 12.5 ms (f16) and from 18.4 to 15.7 ms (f32). The same path moves by up to 14 % between passes (kleinhirn f32 6.5 ms in the ba3a2c7 pass, 7.4 ms in the capture pass), so a factor holds only within its pair. Both ORT fp16 graphs miss our fp16 probability gate (max probability diff 5.3e-2 plain, 3.5e-2 fused, gate 1e-2); kleinhirn f16 stays at 4.2e-3.
kleinhirn across sequence buckets (earlier pass, commit ccef9a7; no ORT counterpart beyond L128):
| Bucket | f16 median (range) | f32 median (range) | f16 p95 | f32 p95 |
|---|---|---|---|---|
| L128 | 6.8 (6.8-6.9) | 6.9 (6.9-6.9) | 12.1 | 12.5 |
| L256 | 36.2 (35.3-37.4) | 37.4 (37.4-37.4) | 47.9 | 49.7 |
| L512 | 95.9 (95.4-109.5) | 97.5 (97.3-97.8) | 131.8 | 134.4 |
| L1024 | 321.3 (320.8-321.3) | 321.5 (320.0-327.0) | 414.6 | 417.8 |
kleinhirn WASM at L256 in the same pass: 360.9 ms (358.8-361.2), p95 450.4.
Julia 1 against the upstream WebGPU loop on the fused upstream graph.
100 upstream parity requests; every path picks the same option as
PyTorch in 100/100. bench/upstream-julia/bench-graph.html runs the
upstream benchmark loop verbatim with the graph file as a parameter.
kleinhirn reports ms per decision, upstream ms per request, the same
unit:
| Pair (ABAB) | kleinhirn ms | upstream ms | Max logit diff vs PyTorch, kleinhirn / upstream |
|---|---|---|---|
| f32, one request per call, tokenization timed on both sides | 24.3 (24.3-24.4) | 24.8 (24.8-24.8) | 1.0e-4 / 1.6e-4 |
| f16, one request per call, tokenization timed on both sides | 24.1 (23.4-24.1) | 21.5 (21.4-22.1) | 0.091 / 1.02 |
| fastest paths, f16: kleinhirn batch API at B1 (pre-tokenized) vs upstream batch 4 | 20.7 (20.5-21.2) | 15.8 (15.7-15.8) | not reported / 1.02 |
Against the unchanged upstream graph (earlier pass, commit ccef9a7) kleinhirn f16 ran 24.9 ms per decision, upstream batch 4 23.9 and batch 1 30.2 ms per request.
Download, GPU buffers and peak process memory. Peak memory is the footprint of the benchmark's own browser process tree, median of 3 browser launches with the launch range in parentheses; footprint varies up to ~15 % per launch, so a "uses less" claim stands only where the ranges do not overlap:
| Path | Download MiB | GPU buffer MiB (L128) | Peak MiB, median (range) |
|---|---|---|---|
| kleinhirn small f16 | 152.3 | 51.9 | 850 (790-856) |
| ORT WebGPU f16, fused | 187.7 | not measured | 1442 (1425-1454) |
| kleinhirn small f32 | 296.6 | 103.7 | 1151 (1140-1152) |
| ORT WebGPU f32, fused | 331.6 | not measured | 2321 (2301-2329) |
| kleinhirn WASM f32 | 296.6 | - | 572 (545-737) |
| ORT wasm f32, fused | 331.6 | - | 1280 (1273-1282) |
| kleinhirn julia f16 | 308.0 | 109.9 | 1001 (1000-1002) |
| upstream julia batch 4 / batch 1, fused f16 | not measured (graph files 275.2) | not measured | 1621 (1617-1629) / 1599 (1597-1603) |
| upstream julia batch 4, fused f32 | not measured (graph files 550.2) | not measured | 2497 (2382-2515) |
With graph capture the ORT WebGPU peaks were 1476 MiB (f16) and 2368 MiB (f32), provisional: three of the six launches started above the load bound.
A load-sensitivity pass found no latency change under pure CPU load up to a 1-minute load of ~10 on this machine; mixed build and browser load did affect earlier runs, so the start-load bound stays.
On a Modal NVIDIA L4 under Linux (Chromium 151, Vulkan; gate:
adapterInfo reports vendor nvidia, architecture lovelace, no
fallback adapter) kleinhirn f32 passed every parity gate:
small-upstream 100 % argmax (968 cases) with max logit diff 2.35e-5,
julia-1 all parity gates green. small-upstream f32 at L128 measured
4.5 ms median per call in a single pass (provisional). That Chromium
build does not expose shader-f16, so the engine runs in its f32
fallback by design there. bench/results/nvidia-*.json.
Read: against the best ORT configuration we could produce (fused graph plus graph capture), kleinhirn is 1.9x faster than ONNX Runtime Web in f16 and 2.2x in f32 on WebGPU, and 2.6x on the WASM path (small-upstream, L128); it peaks at 45-59 % of ORT's memory. For Julia 1 the fused fp16 graph makes the upstream loop faster than kleinhirn: 21.5 vs 24.1 ms at equal boundaries, 15.8 vs 20.7 ms between the fastest paths. That graph deviates up to 1.02 in logits from PyTorch fp32, kleinhirn f16 by 0.091. In f32 the two are level (24.3 vs 24.8 ms). kleinhirn's Julia advantage is peak memory (1001 vs 1599-1621 MiB) and fp16 accuracy, not speed. Graph capture was not tried for Julia: it needs fixed shapes, and the upstream loop calls with varying lengths.
The batch API (classifyBatch / decideBatch, batch sizes 1/4/8/16,
stride-packed at 64-token increments) produces outputs bit-identical
to single calls on the same inputs (batch parity B1-B16, f32 and f16,
both models, mixed lengths). Official throughput (ms per decision,
same protocol):
| Path | B1 | B4 | B8 | B16 |
|---|---|---|---|---|
| kleinhirn small f16 L128 | 6.90 | 6.39 | 6.02 | 8.83 |
| kleinhirn julia f16 | 21.7 | 35.8 | 47.0 | 46.2 |
For small-upstream, batching matches the single-call path at B1 and helps slightly up to B8; B16 regresses. For julia-1 the batch path is faster than the single path at B1 (21.7 vs 24.9 ms, though the batch number times pre-tokenized inputs while the single-call number includes tokenization) but slower per decision from B4 on: in this throughput pass kleinhirn B4 ran 35.8 ms against an upstream batch-4 25.6 ms from a different pass. The published Julia comparison is the fused-graph pass above. The API is kept for the parity guarantee and for hosts that batch anyway.
| Task | Published | Measured here | Status |
|---|---|---|---|
| Typed Decisions | 1,463/2,000 (their GPU BF16 run); 1,451/2,000 (their CPU run) | 1,451/2,000 | matches their CPU run exactly |
| AG News | 94/100 | 94/100 | exact |
| DAIR Emotion | 86/100 | 86/100 | exact |
| Banking77 | 64/100 | ambiguous: 57-69 by router parameters | open, "top-16" not uniquely reconstructable |
| MASSIVE | 71.50 % | en-US 51.71 % | not reproducible; no public request format derives it |
AG News, DAIR Emotion and Banking77 are read from the BTZSC copies, sampled as in jev-benchmarks; MASSIVE from the AmazonScience/massive dataset.
kleinhirn's Julia engine (WebGPU f16, minimum limits) scores the same
1,451/2,000 on Typed Decisions as PyTorch CPU; argmax agreement
1,997/2,000 (bench/results/k9-typed-kleinhirn-julia-f16.json).
| Task | small | base | multi |
|---|---|---|---|
| typed | 36.0 % | 39.7 % | 35.3 % |
| agnews | 47 % | 82 % | 67 % |
| emotiondair | 33 % | 43 % | 36 % |
| banking77 | 47 % | 73 % | 63 % |
Jev reference values under the pinned protocol are reported, not
measured by us: AG News 91/100, DAIR Emotion 48/100 and Banking77
87/100 come from the pinned btzsc-pilot-v1 report (see
docs/BENCHMARKS.md); Typed Decisions 72.70 % is listed on the
Julia 1 model card
("Evaluation", column "Jev reference") as a supplied comparison
reference.
src/ engine: device, weights, tokenizer, WGSL graph, WASM fallback
convert/ checkpoint -> weight format, goldens, numpy reference forward
bench/ measurement pages + Playwright runners + k9 suite + results
tests/ Node tests, corpus, goldens (per model)
tools/ corpus builder, pinned model/dataset downloaders
site/ device measurement page (source)
pages/ prebuilt page served by GitHub Pages, no weights
docs/ ARCHITECTURE, RESEARCH, BENCHMARKS, FINDINGS
models/ checkpoints and weights, never in git
Every project, model and dataset this README names, with the place its owners publish it. kleinhirn is not affiliated with any of them.
Models measured or supported:
data/k28/models.jsonModel families:
Runtimes, formats and tools:
convert/gliner2_export.py is
adapted: FluidInference/gliner2-5-small-coremlBrowsers and platforms:
Datasets and benchmarks:
Corpus and first user:
Device results, bug reports and pull requests are welcome; see
CONTRIBUTING.md. Anything beyond a small fix starts
with an issue. Security problems go through
SECURITY.md, not a public issue.
Apache-2.0, copyright Noa Katana (see LICENSE). Third-party models,
datasets, adapted code and corpus texts keep their own licenses (see
NOTICE).