Repository navigation
Highlights
- One API in 25 languages, generated from one table.
rlx-bindholds every graph builder and the runtime once;rlx-capi(rlx.h) exposes it as a C ABI, andrlx-bindgengenerates Python, JavaScript, Swift, Go, Java, C#, Ruby, Julia, Zig, C++, Node/TypeScript, Kotlin, Dart, Lua, MATLAB/Octave, R, PHP, Nim, D, V, Haskell, OCaml, Elixir and Odin from it. Contract tests keep every language one-to-one, and the same six examples (inference,training,loading,distributed,backends,models) run and print the same numbers in each. - Prebuilt packages, one per platform. This release attaches librlx_capi for macOS (arm64, x64; with Metal + Accelerate), Linux (x64, arm64) and Windows x64, plus CUDA variants for Linux and Windows x64. npm (
@rlx/rlx+@rlx/rlx-<os>-<cpu>), RubyGems (platform gems), NuGet (Rlx+Rlx.Native.<rid>) and Maven (natives-<platform>jars) download only the host's library; Python shipspyrlxandpyrlx-cu12wheels plus an sdist; the C SDK carries pkg-config and CMake files; Swift gets anRLX.xcframeworkfor SwiftPM. - Models from any provider, by task.
rlx_runtime::modelopens a model by path or id and runs it by name;chat,transcribe,synthesizeandembedanswer each task in one call, in every binding. Plugins load at run time, or link into the app on iOS, Android, wasm and consoles. - Packages that carry their own logic.
.rlxppackages can hold several entries, prompt-cache snapshots, an SAE spliced in at open, and host logic as WebAssembly programs (rlx-program); packages train in place, and.rlxmodelgives rlx IR a stable text encoding. - StableHLO / IREE export (
rlx-stablehlo), C and C++ on microcontrollers (rlx-micro: no_std, no heap, 24.9 KB flash), and torch-exact CPU numerics inrlx-numpy,rlx-linalg,rlx-rngandrlx-optim. - Caller mistakes are named errors, not silent zeros: boundary checks on
CompiledGraph,Session::try_new/try_compile, and errors by kind in every binding. Plus a long list of backend fixes and CPU speedups (convcol2im6–9×, non-last-axis reductions 12–14×, all bit-identical) below.
Added
-
rlx-numpy::torch: torch CPU float32/float64 reductions bit-for-bit (sum_f32,
sum_f64,sum_rows_f32/_f64,mean_f32). These follow ATen'scascade_sum: 4/2
lanes on arm64, ILP 4, a 4-level cascade. Alsoexp_f32, which is SLEEF'sxexpf,
the exp torch calls on arm64 (libmexpfdiffers in about 1 value in 10). Pinned
against torch 2.11. -
rlx-numpy::npy: float32.npyread/write. -
rlx-linalg::lsap::linear_sum_assignment: a port of SciPy'srectangular_lsap,
including its tie rules. -
rlx-linalg::algos::eigh_torch/eigh_torch_f32:torch.linalg.eigh's spelling,
LAPACK?syevdon the lower triangle, with eigenvectors in columns. The f32 version is
bit-identical to torch 2.11 on macOS. Backed by the newrlx-cpu::blas::ssyevdand
dsyevd_uplo. -
rlx-rng::torch:normal_tensor_f64(the doublenormal_fill), plusInit,
random_int64,epoch_permutationanddropout_mask. The last four moved here from
an exg crate: torch default initialisers and theDataLoader(shuffle=True)stream. -
rlx-optim::clip_grad_norm_torch:torch.nn.utils.clip_grad_norm_exactly
(max/(total + 1e-6), clamped at 1, always applied). -
rlx_runtime::placement: measured device placement for concurrent jobs, moved from
exg's moabb crate.measured_device(key, candidate, time_step)times a few real steps of
the caller's workload on the cost model's GPU pick and on the CPU, keeps the GPU only at a
1.5× margin, and decides once per key for the process (RLX_PLACEMENT_CACHEremembers
decisions across runs;RLX_PLACEMENT=static/RLX_FORCE_DEVICEskip it).gpu_slot
shares a GPU among concurrent jobs, optionally capped (RLX_GPU_JOBS); it nests on one
thread, gives a placement probe the GPU to itself, and records the devices each thread
compiled for (take_devices_used). Written after the cost model put EEGConformer's
training graph on Metal at 20–99 s per 64 steps against 1.7 s on the CPU. Tests moved
with it. -
rlx_runtime::job_slots::JobSlots: a first-come-first-served pool bounding concurrent
jobs by count and memory, moved from exgb. Withgate_memory, the next job waits while
RSS plus the measured growth per running job would exceed the soft memory budget; a job
always starts on an idle pool, so it never deadlocks. Tests: the memory gate, and the
count cap under 12 threads. -
Graph outputs have names.
Graph::name_output(i, "logits")/
set_named_outputs([("logits", y), …]), andCompiledGraph::outputs()
(name, dtype, static shape, inrunorder) /output_index("logits"),
which names the closest outputs when there is no match. Optimizer passes
keep output positions, so a name still finds its output after fusion has
renumbered the nodes behind it. ONNX imports keep the model's output names
(through HIR,HirModule::output_names)..rlxmodelwrites them as
return %lm_head as logits.extract,parallelandsequentialcarry
them. In every binding:name_output/output_nameson graphs,
outputs()/output_index()onCompiled. -
Bindings report boundary and compile errors by kind. Every language's
Compiled(Python, JS, C, Swift and the other generated bindings) now goes
throughtry_run/try_set_param/try_compile. A misspelled input or a
wrong-length buffer is aTypeerror (ValueErrorin Python,TypeErrorin
JS,RLX_ERR_TYPEin C) that names it. Before, it ran on zeros or failed
with an opaque panic. NewCompiledrows:inputs(),params(),
check_bindings(names),allow_unbound(names). See
docs/bindings.md. -
README examples are doctests (
just check-readme, part ofjust ci):
every Rust block in the README is now a complete program that compiles and
asserts its result, using only therlxcrate. Several were fragments with
undefined variables, imported crates the prelude already covers, or called
std::env::set_var(unsafe in edition 2024); the training example could not
have trained (see Fixed). Newdocs/troubleshooting.md
— errors by message, wrong numbers on one device, NaN, slowness. README crate
list now covers all 85 workspace crates. -
Caller mistakes at the
CompiledGraphboundary are errors, by name.
A misspelled input (the graph has no inputtoken(did you meantokens?)), or a buffer whose length is not a static input's or param's
shape, used to compute on the arena's zeros and return plausible numbers; it
now panics inrun/run_typed/set_param, and the new
CompiledGraph::try_runreturns it as anIoError. Inputs left unsupplied
and params never set are named in a one-time warning (some callers rely on
them reading zero);check_bindingsreturns them as an error and
allow_unbounddeclares the ones meant to read zeros (an empty KV state). Unknown
param names stay accepted (loading a full checkpoint into a subgraph).
inputs()/params()list the declared boundary (name, dtype, shape).
A cache that anOp::KvAppendwrites into takes any whole number of its
rows: decode graphs declare a spare row for the in-place write
(rlx_flow::blocks::kv_spare_rows) and are fed only the filled ones. The
wholerlx-runtimeCPU suite runs unchanged with the checks on, as do the
Gemma and Qwen3 decode tests in rlx-models. The unbound warning stays a
warning: it fired in none of about 350 rlx-models tests across 12 model
families. -
Session::try_new/try_compile/try_compile_withreturn a
CompileError—Invalid(the full IR verifier, run up front, so errors
name the caller's graph rather than the first pass),DeviceUnavailable
(not built in, or absent with the backend's reason),Unsupported(node
and op the device cannot lower), orBackend— instead of panicking. -
rlx_stablehlo::export_specializations— one symbolic graph exported
at severalDimBindings as functions@main_<suffix>in one module (prefill
buckets); with parameters, all functions share one weight file.
DimBinding::withandFromIterator<(u32, usize)>. -
SimplifyTransposes(rlx_compile; opt-in
CompileOptions::simplify_transposes/RLX_SIMPLIFY_TRANSPOSES, default
off). Removes identity transposes, composes chains, folds transposes of
constants, and sinks a transpose through single-use elementwise ops when that
makes it cancel another. On imported Supertonic TTS models it removes 35–38%
of transposes (duration predictor 49→31, text encoder 80→50, vocoder 34→22)
with bit-identical outputs on CPU and Metal; transformer and CNN imports have
none to remove. No reliable speedup measured yet (host load ~80 during the
runs), hence off by default.rlx-onnx-import --example transpose_census [--run] [--device D]reproduces the census. -
StableHLO weights as IREE parameters —
StableHloOptions::with_parameters
emits weights asutil.global+#flow.parameter.named, read from a file at
load time (write_safetensorswrites one). Int8 weights export asi8
tensors dequantized in the module. Both are in the IREE round trip. -
StableHLO / IREE export — new crate
rlx-stablehloand
ExportTarget::StableHlo(rlx-runtimefeaturestablehlo). Graphs go
through the TPU backend's preparation and rlx → HLO lowering and are printed
as StableHLO MLIR (model.mlir+model.signature.json) foriree-compile --iree-input-type=stablehlo. NCHW convolutions export without transposes.
tests/iree_roundtrip.rscompiles and runs seven graphs — MLP, conv + pool,
embedding/LayerNorm/shape ops, RmsNorm, batched matmul, and Llama and BERT
layers built throughrlx-flow— under IREE (llvm-cpu) and matches rlx CPU,
with an op-by-op bisect on mismatch. It found the five bugs under Fixed. -
Known failures as data (
rlx_ir::known_failures). A case a backend is
known to get wrong is a line intests/known_failures/<test>.txt—
case device[@os] issue [note], issue required — instead of a skip: it still
runs, its failure is reported as known, and once it passes the test fails
until the entry is removed.RLX_RUN_KNOWN_FAILURES,
RLX_KNOWN_FAILURES_ISSUE,RLX_KNOWN_FAILURES_REPORT(JSON lines) and
RLX_KNOWN_FAILURES_DIR(rig-local lists);just known-failures list|updateapplies a report to the files. Wired intofd_backward_gate
andgpu_filters_parity. Seedocs/adr/0005-known-failures-as-data.md. -
cargo rlx debug(rlx_runtime::debug::debug_graph): runs a graph on a
device and on CPU with the same deterministic feeds and classifies the
result (unsupported-op,compile-panic,run-panic,non-finite,
mismatch, …).--probereruns with one compile option changed at a time
(fusion off, regions unfused, F32, common-IR kernels, no constant folding, no
MPSGraph);--bisectexposes every op and names the first that diverges,
flagging one that reads only graph inputs. On the known Metal BF16-input
defect it names theNarrowreading the input, with every probe failing. -
Fusions-must-fire test (
rlx-runtime/tests/fusions_fire_on_model_blocks.rs):
Llama and BERT blocks built throughrlx-flow, compiled on every registered
device, with exact fused-op counts plus two controls (the block still emits
the pattern; with fusion off none of the fused ops appear), and checks that
fuse()andsupported_for_targetagree with each backend's real claims. -
ExpertPager::with_prefetch_cap(fraction)andPagerStats::{prefetched, prefetch_skipped, prefetch_used, prefetch_wasted}: bound how much of the
cache unconsumed prefetches may hold, and see whether prefetching pays.
Default unchanged (no cap beyond the budget). -
Architecture decision records in
docs/adr/. -
RLX_POISON_ARENA=1: CPU scratch starts as0xFFbytes (NaN, −1)
instead of zeros — at allocation and at the per-run reset — so an output
nothing wrote reads NaN instead of a plausible zero. The wholerlx-runtime
CPU suite passes with it on, so CPU kernels do not depend on zeroed scratch;
it found the two test defects under Fixed. -
Model::runon a language model takes media besideinput_ids(a
tensor named as the entry's media kind, a speaker vector): the prompt's
logits with each medium spliced in at its placeholder token
(prefill_with_media); the next run, the context grown, goes on from its
cache. A package program drives a speaker-conditioned LM through
entry.runthis way (KittenTTS 2). -
tokenizer.encodewithspecial: falsefor package programs: the
text's ids alone (TextCodec::encode_plain), none of the special tokens
the tokenizer's template adds around them — ChatterBox's wraps every text
in[EXAGGERATION] [START] … [STOP] [START_SPEECH] [START_SPEECH], which
its program builds itself. -
rlx_program::run_body: a function run with the request body the
host reads —from,keepanddimstogether, whichrun_withand
run_sizedeach send one part of. A prompt graph whose caches have as
many rows as the program asks (no input's shape says) keeps them in
slots (MetaVoice). -
Training a package (
rlx_runtime::model::trainer, features
model-io+training):open_trainer(path, options)trains any
function of an.rlxpwith nothing about the model it is — its symbols
sized (dims), a loss ({output},{cross_entropy: {logits, labels, mask?}},{mse: {output, target}}), the params ("all",{params: [globs]},{lora: {targets, rank, alpha}}, or{sae: {tap, width, l1}}
on one activation, the base frozen) and AdamW, onResidentTrainer. A
package's model block may hold the same object astrain, its defaults.
save_adapterwrites LoRA factors as an adapter package (each update in
the layout its tensor is stored, transposed references included), which
opens as the trained model;save_saewrites the SAE as a package of its
own (h→features,reconstruction);savewrites a full fine-tune
as the package with the trained values held. Integer inputs (token ids)
are fed as f32 and cast in the graph.preparegives any trainer the
graph, the trainable names and the starting values, andSaverwrites
the results back. Testspackage_training. -
An SAE spliced into a package as it opens (open option
sae: "<sae package>"or{path, mode}):replace(default) feeds each function's
tap through the SAE so the model runs on its reconstruction;features
leaves the model as it was and adds the outputsae.features. Test
package_training. -
Trainer.from_package/Trainer.save_packagein every binding
(api.idl; rlx-capirlx_trainer_from_package/
rlx_trainer_save_package, rlx-jsTrainer.fromPackage/
savePackage, pyrlxTrainer.from_package/save_package, Swift and
the generated languages): the bindings' trainer (its optimizers,
freezing, resident path, checkpoints) on a package's function. Test
rlx-bind/tests/package_trainer.rs. -
Every op kind round-trips through
.rlxmodel: test
every_op_kind_round_tripsprints and reads back a graph of each
sample_opskind, so a new op the text format cannot carry fails there. -
Package program inputs read from the weights: a run's body
weights: {input: [tensor, …]}feeds each input the named tensors of the
package's weights, read for that run and stacked as the input declares
them (WeightCache::bytesnow reads a safetensors tensor's bytes as
stored). A program routes a mixture of experts this way: Drama 3's 32 GB
of FP8 experts are read eight at a time, none bound to a function. -
Symbolic fits of a symbol twice beside another (
rlx_ir::dynamic):
a dim2·s − 1or2·s − l(a flow decoder's relative positions over a
prompt's and a generation's tokens, a prompt's mel frames padded to them)
is derived when no fit with each symbol once is; test
a_dim_of_a_symbol_twice_and_another_is_derived. -
The
samplehost service for package programs. A program sends
logits ([n]f32) and the sampling options (seed,step,
temperature,top_p,top_k, …) and gets back{id}, drawn by the
runtime's sampler chain from the streamseed + step(the argmax when
greedy). Orpheus's program applies its own repetition penalty, then samples
through it. -
Batched, differentiable ReEig/LogEig on the CPU (
rlx_cpu::spd_batched).
spectral_batched(g, x, batch, n, eps, Re | Log)emits two custom ops:
spd.matfn_batched([B,n,n]→ packedY ‖ λ ‖ U, one f64 LAPACKdsyevd
per matrix, rayon over the batch, f32 or f64 graphs) and its Loewner
(Daleckii–Krein) backward, which reuses the forward's eigendecomposition.
GPU backends run both through their host-kernel fallback, bit-identical to
the CPU on Metal, MLX and wgpu. It replaces the graph Jacobi scan
(spd_*_batched) for training: TensorCSPNet and GraphCSPNet train ~6×
faster. Testsrlx-runtime/tests/spd_batched_native.rs: forward vs host f64
(4e-8), gradient vs finite differences (1e-7; the Jacobi path is 1e-5) and
vs autodiff through Jacobi on symmetric parts (8e-6), and the GPU backends.
The raw gradients of the two paths differ (0.85 here) in how they split
dX + dXᵀbetween the triangles, which a symmetric input cannot see.
It also covers√,1/√and powers (BatchedSpectral::{Sqrt, InvSqrt, Pow}), each against finite differences (≤ 1.2e-7), and a
SpectralBatchedExt::spd_nativemethod as the drop-in for the Jacobi
builders.
Every function's gradient is the derivative off(max(λ, ε)), zero where
the floor clamps, as the Jacobi builders compute it. ForLogthat differs
from the native single-matrixOp::LogEig(1/max), which matters when
Logat ε = 1e-4 stands for ReEig∘LogEig; the test clamps part of the
spectrum, and1/maxfails it. -
A program runs its package's other entries. The host service
entry.runruns an entry of the package (by name or kind) on tensors
with options, asModel::run— agenerateentry generates ids — so an
LM-and-codec TTS is one package: the LM's entries, the codec's graphs, a
program between. Generation'sseed_per_stepoption draws each sampled
token from its own stream (seed + step, history empty), as rlx-qwen3's
sample_token_atdoes. -
Generalizing from many sizes.
rlx_ir::dynamic::generalize_many
fits through the first two builds and keeps, of the fits, the one
through every other — dims and attributes both, including those the same
at the first two (a quotient can be:⌈37/16⌉ = ⌈46/16⌉). Of quotients
as small, a power-of-two divisor is taken. -
Quotient derived dims. A
SymDefmay divide, rounding down, and add
a second symbol (inner,div,plus,plus_scale;.rlxmodel
(audio)/256+1,(t+1)/2,(audio*-1+255)/256+frames*1-1): an STFT's
frames, a strided conv's, a decoder's frames past a prompt's.
generalize_checkedfits them through its third size (a quotient alone
first, then the smallest offset), and refuses sizes two symbols step in
proportion across, which no fit can tell apart. -
Programs size symbols by name.
rlx_program::run_sized(a request's
dims: {name: size}) sizes a symbol no input carries. -
Scaled symbolic attributes. A
SymAttris the size timesscale
plusoffset(.rlxmodelstart: seq*-1+1000): a start that falls as
the size grows, a relative position table read around its middle
(ZipVoice's and LuxTTS' encoders). -
Square derived dims; generalizing from three sizes. A derived dim
(rlx_ir::dynamic::SymDef) may be a square with a linear term
(SymDef::linear;.rlxmodelf32[seq*seq*2+seq-1]), what an
attention's relative positions flatten to (VITS, Piper's encoder).
rlx_ir::dynamic::generalize_checkedtakes the graph at a third size
and keeps, of the fits through the first two, the one through it — two
sizes cannot tell96·seq − 480from a square. -
Programs: a package's host logic as WebAssembly (
rlx-program, and
rlx-runtime's featureprogram). An entry of kindprogramnames a
wasm module (built withrlx-program's guest SDK) that answers the
model's tasks —run,transcribe,synthesize,embed,generate,
chat,score— and calls back for the package's functions (each
specialized to its inputs' shapes and compiled once per shape, on the
model's device; outputs kept in host-side slots so caches never cross
into the module), its files and tokenizers. Run bywasmi, an
interpreter, so it works where JIT does not (iOS, watchOS). For what a
graph says badly: phonemizers, FST and beam searches, entropy decoders,
a model's own decode loop. -
Programs read the package's files. A program built for
wasm32-wasip1gets a read-only WASI filesystem of the package's
sidecars at/package(in memory, nothing of the host's), sostd::fs
code — espeak-ng reading its dictionaries — runs as is. A package of a
program alone keeps its model block inmodel.json. -
transcribefor a front end symbolic in the audio's length.
audio_multiplepads the audio to whole frames, the stages read the real
length asaudio_samples, andtoken_budget: {per_second, extra}sets
the decode length from the audio's. Its stages open from their own
handle on the package (assynthesize's do), so a symbolic one keeps
its weights. A no-step decoder'sprefill.inputstake{context: …}
feeds, and a function symbolic in their length (an encoder's) is
specialized to it when it runs, compiled once per length. -
Op::Whileloops at run time, unbounded or not. It was unrolled to
max_iterationseverywhere, so a loop with no bound could not run at all.
The CPU now compiles itscondandbodyonce and loops
(rlx_cpu::thunk::{Thunk::While, execute_while}); Metal, CUDA, ROCm and
wgpu run it through their one-op host fallback (HostOp).LowerIf
(inlineOp::Ifalone) replacesLowerControlFlowon those backends and
in the fusion pipeline when the backend claimsWhile
(DecomposePolicy::while_nativefor the GPU decompose pass); the others
still unroll. Checked on CPU, Metal and wgpu against a host loop
(rlx-runtime/tests/while_runtime_loop.rs: a data-dependent count, a
bound, a false start); CUDA and ROCm compile-checked. -
Several entries in one package, as one model. An entry's key is its
name and its kind itskindfield (or the name, as before), so a
package holds several entries of any kinds. Opened, it is one model:
each task goes to the entry of its kind (embed,chat,transcribe,
…) andrunto the entry whose inputs are the tensors given. The first
entry opens with the package, the others when first asked for. The open
optionentryopens one by name.rlxp::{entries_of, entry_of_kind};
the featureentry.kindkeeps an older rlx from missing a named entry. -
rlx-search(re-exported asrlx_runtime::search): host-side sequence
search shared by every decoder, dependency-free.argmax/top_k/
log_softmaxwith one tie rule (lowest index); beam search as a stepping
state machine (BeamSearch::advance, callers own and reorder their KV by
Step::parents) with the Hugging Face rule (BeamConfig::new) and
openai/whisper's (BeamConfig::whisper) as presets plusConstraint
masks; CTC greedy, streaming prefix beam (PrefixBeam), and the CTC
trellisctc::forwardover theLog/Tropicalsemirings; monotonic
Viterbi alignment.lm::beam_search,lm::argmax_u32and
samplers::argmax_logitsnow delegate to it, so greedy decode and the
sampler chain get the lane-parallelargmax: 46 µs vs 164 µs on a
151,936-entry row (Apple M-series, release). Verified token-identical to
the previouslm::beam_searchand to a transcription ofopenai/whisper's
BeamSearchDecoderover 600 scripted models each. -
dynamic::generalize_with: a derived dim's offset may exceed its
scale (a convolution's padding:4·frames + 6) when the caller checks
the result at a third size; and an attribute may be a derived symbol's
size (a slice as long as a padded dim), not only a base symbol's. -
Packages with no decode step. A
generateentry with aprefilland
nofunctionsruns its prompt function over the whole context for each
token, as a runner with no cache does, so a language model whose only
graph is a full-sequence forward (Llama 4, MiniMax M3) runs from a
package.prefill.functionslists one prompt function per length, for a
graph not symbolic in its length. -
A held tensor of the wrong size fails the open:
PackageWeights::bind
checks each held tensor against the shape its param declares. It was
bound silently, and a package written wrong ran with the wrong values. -
Prompt-cache snapshots for packages. A package's language model
(GraphLm) takesLmRunner::export_session/restore_session/
prefill_logits_reusing: its caches (read back from the executable when
they live there) and the tokens in them, restored by the next step, so a
prompt sharing a snapshot's prefix runs only its new tokens. -
rlx-pkg inspectshows the model: who wrote it and who may read it,
its symbols by name, each function's inputs, outputs, params and
variables, its entries, and its weight sources. -
Chat templates with
{% generation %}(Hugging Face's assistant-mask
tag, LFM2's and SmolLM3's templates) compile: the tag renders its body, as
transformers does. A package with such a template failed to open. -
Combine and split graphs:
rlx_ir::compose, in every binding.
Graph::splicecopies one graph into another, binding named inputs or
params to existing nodes (a weight tied under another name); same-named
leaves are shared after a kind and shape check, or kept apart with
Splice::prefix;Splice::adapt_shapesre-infers shapes from bound
operands.compose::parallel/compose::sequentialbuild on it.
Graph::partition(any order-respecting node→part assignment, named
__boundary.<id>tensors),Graph::split_atandGraph::componentsall
return aPartitionof self-contained graphs thatmergereassembles;
Graph::extracttakes one subgraph. Variables and symbolic attributes are
carried through. Works underno_std. Bound in Python (pyrlx.splice,
parallel,sequential,extract,Partition), JS (rlx.*), C
(rlx_splice,rlx_partition_*) and every generated language. See
docs/graph-compose.md. -
no_stdgraph verification: rlx-ir core and rlx-fpga. rlx-ir has a
default-onstdfeature; with--no-default-featuresit builds asno_std+
allocand keeps the graph / op / shape / verify core (RLX_*switches read
as unset, the custom-op registry is empty, float math comes fromlibm).
rlx-fpga'sno_stdbuild now works (it never compiled before, GitHub #7):
Model →optimize(still checked byrlx_ir::verify) → in-memory
collect_artifacts; graph import/export and file emission stay onstd.
just check-no-std(injust ci) builds rlx-ir, rlx-cortexm and rlx-fpga for
thumbv7em-none-eabihfand runs their host tests without default features. -
XDNA bf16 matmul (opt-in, not yet hardware-validated).
Session::new_with_precision(Device::Xdna, Precision::BF16)runs matmuls
through mlir-aie'sbf16_f32microkernel (bf16 operands, f32 accumulation)
instead of per-channel int8. Host-side tile packing is tested against the
kernel's tile contract; the NPU path has not run yet. -
XDNA host checks and live geometry.
rlx_xdna::host_blockers()(XDNA 2
part,/dev/accel/accelNnot read/write for the user),aie_geometry()
(columns/rows/AIE version from the driver), and a memlock warning in
diagnostic().RLX_XDNA_MAX_CONTEXTS(default 4) bounds the NPU hardware
contexts a process holds; chains evict idle ones to stay under it. -
rlx-distributed: addressed layer pipeline building blocks (lifted from
rlx-kimi-k3's pipeline):rpc— request/reply over TCP whose server sends heartbeat frames while a
request runs, so a client bounds every read: a peer silent for
RpcOptions::timeoutis an error naming it, a slow request is not.serve
runs anRpcHandlerwith one thread per connection (a health probe is never
stuck behind an idle client), returns handler errors and panics as messages
and keeps serving;OP_SHUTDOWNstops it and closes idle connections.
Enc/Decare the checked little-endian payload codec (incl. lists of
f32vectors).stages—StageSpec(an address and its layer range),plan_stages(even,
explicit counts, orw:weights, onLayerSplit),validate_stages(refuses
stages that do not tile the model),parse_stages.cluster::sizing— roofline step timing:NodeSpecwith presets, a model's
StepWork(dense / expert FLOPs and bytes, disk-served expert share, pipeline
boundary and all-to-all bytes), andtime_stepfor pipeline (micro-batches in
flight) and expert-parallel layouts, with the bottleneck resource.
-
.rlxmodel: rlx IR as text, and its stable encoding
(rlx_ir::rlxmodel, featureserialize). A graph prints as
%2 = MatMul %0, %1 : f32[1, 2], and every op and attribute is written as
theOpvariant itself, by name:Reshape{new_shape: [1, -1]},
Activation(Gelu). NestedIf/While/Scanbodies print as
graph @… { … }blocks. Values keepnan,±infand exact floats, and
bytes print asb64"…". The file starts withrlxmodel 1/opset 1,
and a file from a newer version is refused rather than misread. Every op
round-trips to identical bytes (tests/rlxmodel_every_op.rs, in
just check-ops), andtests/fixtures/opset1.rlxmodelkeeps the format
readable across laterOpchanges..rlxppackages now store their graph
this way (rlxmodel_v1, readable asgraph/model.rlxmodelin a directory
pack), and bincode packages from earlier versions still open. A
100k-node graph is 6.8 MB of text (bincode: 9.3 MB) and reads in 129 ms.-
Versions in the file.
rlx 0.2.18records the writer.
requires 0.2.19makes an older rlx refuse the file, with a message
saying so.model {name: "…", version: "…", …}is the model's own block:
its name and version, plus whatever its runtime reads. All three are
optional, and files without them read as before (rlxmodel::Module). -
A package holds a whole model. It can store several graphs
(WriteOptions::extra_graphs,Package::module) and the model block
(WriteOptions::model,requires_rlx). Therlxpprovider reports
the model'sname,version, writer version and functions in
info().metadata. -
Weights by reference. The model block's
weights: [{uri, prefix?, map?}]names where each param the package doesn't hold comes from:
safetensors://,gguf://,rlxp://and the otherresolve_weight_uri
schemes, with relative paths beside the package. A param found nowhere
fails the open with its name, instead of running on zeros. Only f32
params bind this way so far. -
Model cards. The model block's
authors,license(SPDX, one or
a list),repository,homepageandcard(the full Markdown model
card) have accessors onModuleand appear inModel.info().metadata.
A multi-line text prints verbatim as a"""block, and the block prints
one field per line, name first and card last. -
Entries: an
embedmethod from the package alone. A model block's
entries.embedgives the encoder graphs, which are buckets at
[batch, seq], the tokenizer (sidecar:or a path), each input's role,
the position offset and the pooling. Therlxpprovider then answers
embed(texts)with no provider code. Tokenizers load through
model::set_tokenizer_loader, which the bindings set to rlx-text's
(rlx_text::install_model_tokenizer). Referenced weights may be
transposed (map: {p: {tensor, transpose: true}}). rlx-models'
rlx-embedexports BERT / XLM-R checkpoints this way
(export::export_rlxp): multilingual-e5-base becomes an 89 MB package
that references 125 of its 149 params. Opened by rlx alone, from Rust
or Python, it embeds within 2e-7 (cosine ≥ 0.9999995) of rlx-models' own
embedder. -
Symbolic graphs: one graph for every size. Named symbolic dims
(dims {seq: 0}in the header; shapes readf32[1, seq]), and
symbolic integer attributes: an op field that is a symbol plus a
constant, written in place (KvAppend{axis: 1, pos: upper},
start: seq-1). The table lives onGraph(sym_attrs), skipped by
serde so bincode packages still read.rlx_ir::dynamic::specialize
binds the symbols, writes those attributes, re-derivesReshape/
Expandtargets from the bound shapes, and re-infers. The deferred
dynamic-shape compile now uses it, where before it re-inferred from
stale targets.dynamic::generalize(a, b, sizes)turns two static
builds of a model into one symbolic graph, and an error names any node
that isn't symbolic. Entries run a symbolic function at thesizes
they list. multilingual-e5 (encode) and Qwen3-0.6B (decode) now
export as one graph each instead of six and four. They give the same
vectors, within 2e-7, and the same 24 greedy tokens as before. -
Readable
.rlxmodel. Values print by their node's name
(%q_proj,%model.layers.0.self_attn.k_proj.weight) where it is a
unique label, else by position. Inputs and params named like their
value print as%x = input/%w = param, and optional fields that
arenoneare left out. Labels are checked on read: a number must be
its position, a name defined once, and a reference to a value above.
Files written before still read, and every op still round-trips to
identical bytes. -
Constants and variables in
.rlxmodel. A small constant reads as
its values (%iota = const [0, 1, 2, 3] : f32[4], exact for f32/f64
and integers, NaN bits included), and a larger one stays base64.
Variables (Graph::vars) are inputs whose next value is an output:
%past_k_0 = var : …withreturn …, %past_k_0 = %k_0.
stateful_decodedeclares a step's caches as variables, and a
generateentry then needs nostatepairs. Exporters drop the
builder's provenance and shared node names (tidy), so a layer reads
%83 = MatMul %82, %model.layers.0.self_attn.q_proj.weight : f32[1, 1, 2048].
A longreturnlists one value per line, and long objects in the
model block break one field per line.generalizekeeps aReshape/
Expandtarget symbolic (new_shape: [1, cap, 8, 128]) instead of
showing the size it was probed at. A stateful step's written caches read
%past_k_0.nextand its first output%logits. A weight reference
that is the param's own tensor, transposed, is just
{transpose: true}. -
Decode state in the graph.
rlx_ir::stateful::stateful_decode
gives a symbolic decode step apositioninput. RoPE rows become a
Gatherfrom held tables, the mask is computed in-graph
(iota[0..cap] <= position, through a symbolicNarrowlength), and
eachKvAppendbecomes aScatterNdat the position. The caches it
returns are its next inputs. Thegenerateentry gains
positionfeeds andstate_update: "replace".stateful::rebuildis
the general node-by-node rewriter it uses. -
A stateful step's caches stay in the executable. A
generate
entry whose step keeps its caches as variables binds them once
(bind_gpu_handle) and copies only the new row each step
(feed_kv_row), reading back just the logits. The host used to pass
every cache in and read it back whole each token: 56 ×caprows of
4 KB each way for Qwen3-0.6B, ~0.9 GB a token atcap4097. When the
context outgrows a step, its caches move to the longer one, and a new
generation keeps the longer step rather than recompiling the shorter.
A backend that keeps no handles falls back to the host path. -
Derived symbols. A dim that is a multiple of a symbol plus a
constant (seq*16,seq+1) is a symbol of its own,
rlx_ir::dynamic::SymDef, bound wherever its base is.generalize
finds them (its offset no larger than its scale, soseq²flattened is
refused), and.rlxmodelwrites them in place:f32[seq*16, 128].
Qwen3's prompt graph, which flattens[seq, heads, head_dim], now
generalizes. -
Op::DynamicUpdateSlice { axis }.operandwithupdate
written at astartread when the graph runs (JAX's
dynamic_update_slice_in_dim; start clamped as JAX clamps), its output
aliasingoperand. Native on CPU and Metal (one kernel reads the start
on the GPU);rlx_fusion::lower_dynamic_update_slicegives every other
backend aScatterNd.stateful_decodewrites cache rows with it
instead ofScatterNd, which Metal runs on the host: 56 GPU round trips
a token for Qwen3-0.6B, each copying the cache. With it the exported
Qwen3 step (f16 weights) decodes at ~14 ms a token on an M4 Pro, ahead
ofQwen3Runner's ~16 ms, and its first token (the prefill) takes
~34 ms against ~155 ms.tests/dynamic_update_slice_backends.rs
checks CPU, Metal, MLX and wgpu against a computed reference. -
Package builds for a device.
rlx-pkg build <in> -o <out> --target metal --target cpu(rlx_pkg::build::build_for_targets) adds a
rewritten version of each function,step:metalbesidestep, and a
model opened on that device runs it (else the portable one). Recipes
(TargetRecipe::for_target): Metal f16 matmul weights and attention on
the shared K/V heads; CPU the attention only. The rewrites are
rlx_ir::retarget::{matmul_weights_as, gqa_native_attention, prune},
which keep a function's variables and symbols. Weights and sidecars are
copied byte for byte, and referenced weight paths rebased.
rlx_pkg::Package::sidecar_idslists a package's sidecars. -
Vulkan
barrier()only synchronized a sub-group. naga's GLSL front
end parsesbarrier()with every barrier flag, sub-group included, and
its SPIR-V writer takes the sub-group flag for a sub-group execution
scope. Each warp then waited only for itself before reading shared tiles
the others were still writing: on NVIDIAscaled_lowp_matmulwas wrong
from row 8 of every 16-row tile (e4m3 cosine 0.87 against the CPU; MoltenVK
happened to keep warps in step).build.rsnow drops the sub-group flag
from workgroup barriers before emitting SPIR-V, for every shader. The
Philox normal-stream gate is relative above 1 and absolute below, at
1e-5: Box–Muller scales a last-ULPcosdifference by up to ~4, which
NVIDIA shows near zero crossings (Vulkan promisescosto 2^-11). The
wholerlx-vulkansuite passes on an RTX 3080 Ti and on MoltenVK. -
Vulkan dropped a
KvAppendwhose row came from the GPU. The
executor's two run loops consumed a host copy that directly followed GPU
work without running it (copies used to come only before a dispatch), so
the cache kept its old row — and the build-segments loop also left every
later step's barrier info one off.Qwen3Runneron Vulkan decoded
garbage after its first token (and differently each run), while
kv_append_backends, whose rows are graph inputs, passed. Both loops
now leave such a copy to the loop head;Qwen3Runneron Vulkan matches
the CPU's 40 tokens on an RTX 3080 Ti, and
kv_append_writes_a_row_computed_on_the_devicecovers the case. -
Attention with fewer K/V heads than Q (grouped-query) was wrong on
CUDA and Vulkan.Op::Attentiontakes K / V with fewer heads; the
CPU, Metal and wgpu kernels index a query head's K/V head, but the CUDA
(and so ROCm, which shares them) and Vulkan kernels read K / V as
num_headsheads and returned a wrong tensor, no error (0.62 / 0.72 off
on an 8-over-2 case, RTX 3080 Ti). MLX and Metal's MPSGraph lowering
aborted on it. CUDA, ROCm, Vulkan and MLX now repeat shared heads first
(rlx_fusion::expand_shared_kv::ExpandSharedKvHeads, at each crate's
own compile entry), and MPSGraph hands such attention to the thunks.
tests/gqa_native_attention_backends.rschecks every backend against the
broadcast graph on the CPU: all within 1.8e-7 (CUDA, Vulkan, wgpu on
msi; Metal, MLX, wgpu here). The package builds' shared-head rewrite
(rlx_ir::retarget::gqa_native_attention) is what found it. -
Referenced weights bind in their param's dtype. An f16 / bf16
param takes a checkpoint tensor converted once, two bytes an element,
instead of failing (only f32 params bound before); held weights convert
the same way. The host keeps them in that dtype too. The Qwen3 exporter's
weight_dtypedeclares the matmul weights f16 or bf16
(rlx_core::package_export::matmul_weights_as). -
A prefill function. A
generateentry'sprefillruns a whole
prompt at once, padded to the shortest of its sizes that holds it, and
seeds the step's caches from its K/V outputs;lastpicks the row whose
logits it returns. Longer prompts and continuations go through the step
one token at a time. The Qwen3 exporter writes one (sizes 16, 64, 256),
checked against a build at a third length. Each executable holds its own copy
of the weights, so the prefill beside the step doubles them (2 × ~4 GB
for Qwen3-0.6B in f32); the open optionprefill: "release"frees it
after each prompt, at a compile per prompt. On CUDA with
RLX_CUDA_SHARED_PARAMS=1the two map one copy instead: the package
compiles in its own sharing scope
(rlx_runtime::with_param_sharing_scope, which restores the caller's),
and Qwen3-0.6B's package takes 5.4 GB of GPU memory rather than 8.3 GB
(RTX 3080 Ti; same tokens, chat "Paris"). -
State kinds, packed references, speech recognition. A
generate
entry's state isrows,appendorreplace(recurrent state, taken
whole; a step with only that has no length limit), per state or by
state_kinds. A U8 / I8 param referenced from a GGUF binds the
tensor's bytes as stored (WeightCache::bytes), so K-quant weights stay
packed. Atranscribeentry runsstages(functions reading and naming
values; a stage'spackagetakes its function from another package, so
an audio front end is exported once and shared), then a decoder that is
agenerateentry's loop with{context: …}inputs, a prompt,
stop_ids,suppress_idsandbegin_suppress_ids. In rlx-models:
rlx-qwen35exports a Qwen3.5 GGUF (3.9 MB package, same tokens as
Qwen35Runneron CPU and Metal) andrlx-whispera Whisper checkpoint
with its log-mel front end as a graph (2e-5 frompcm_to_log_mel; the
same transcript asWhisperRunner).Op::LogMel's doc now says it
floors each frame at its own max, which is not Whisper's. -
Pipelines and image models. A
pipelineentry is stages alone:
Model.runtakes named tensors, runs the stages and returns named
outputs. Stages, here and intranscribe, can be symbolic in their
inputs' dims. They are specialized to the shapes they are given and
compiled once per shape. A"<value>#shape"source feeds a value's
dims as numbers, input tensors of any number type are converted to the
graph's dtype, and a stage'sdeviceruns it elsewhere.
Opened::take_graphreturns a function as packaged. In rlx-models:
rlx-siglip2exports a fixed-resolution SigLIP 2 image tower. Its
preprocessing is a graph that is symbolic in the image's height and
width: Pillow's antialiased bilinear resize in Pillow's fixed point,
exact in f32. On the test images it is closer to Pillow than the
hand-written preprocessing (1 vs 227 of 150,528 values off at 300×200),
and it can be exported as its own package that runs alone. Embeddings
matchSiglip2Runner::encode_imageon CPU and Metal (cosine ≥
0.999996). Whisper's front-end package runs alone too. -
Context past the exported sizes. A
generateentry with
max_contextgrows its symbolic step pastsizes. When the context
outgrows every listed size, the step is specialized at twice the
longest cache, capped atmax_context, with each symbol's offset from
the cache length kept.Opened::{specialize_at, specialize}replace
take_function's inline specialization. In rlx-models,
rlx-qwen3'sExportOptions::max_contextsizes the RoPE tables and
sets the entry. Qwen3-0.6B exported with one 16-token size produces
Qwen3Runner's 40 tokens on Metal, and the same ids on CUDA (checked
against those, as the runner did not fit beside another GPU tenant).
Also on CUDA (RTX 3080 Ti): the SigLIP 2 package (cosine ≥ 0.99999 to
the runner), the Qwen3.5 package (chat "Paris") and Whisper's package
(the CPU transcript). -
Batch generation from a package.
Model::generate_batchand
LmRunner::generate_batch(by default one after another). A
generateentry naming its step's batch symbol (batch) decodes the
prompts together. The sequences run in lockstep from position 0,
prompts of any lengths, each ending at its own stop id; a stateful
step keeps the caches resident and feeds each sequence's new row.
rlx_ir::statefulaccepts a batched step: table rows and the mask
repeat over the batch, and the cache write is aDynamicUpdateSlice
at any batch.rlx_flow::blocks::with_kv_append_any_batchbuilds such
a step withKvAppendpast batch 1, for that rewrite.rlx-qwen3
exports its step symbolic in the batch too (ExportOptions::batch,
default on). Three prompts give exactly the text each gives alone on
CPU, Metal and CUDA, in about half the time (Metal 300 vs 664 ms; CPU
1.30 vs 2.59 s; CUDA 498 vs 1030 ms). Batch 8 decodes at 254 tok/s on
Metal (317 built for Metal), against 67 for one sequence.
generate_batchis forwarded by every model and runner wrapper
(Stamped,Box<dyn LmRunner>,HostDriven); a wrapper that
forwarded onlygenerate_texthad made every batch run one by one,
which equality checks alone did not catch. A runtime test now checks
that the batched path ran. Withprefill: "release"the batched step
is freed after each batch. A batch runs its prompts' shared length less
one through the prefill function for each sequence, seeding the
batch's caches (open optionbatch_prefill, on by default). Four
200-token prompts on Metal: 1.26 s, against 10.3 s a token a step. -
Beam search.
num_beams/length_penaltyon any text model.
One algorithm (rlx_runtime::lm::beam_search, ties broken the same
way everywhere) underLmRunner::generate_beam. By default each
hypothesis is extended by its ownpredict_logits. A package with a
batch symbol keeps the hypotheses as the sequences of its batched
step, prompt once, caches reordered after each step. Batched and
one-by-one give the same text, on a toy package (2-4 beams) and on
Qwen3-0.6B (CPU, Metal, CUDA). On Metal 3 beams take 328 ms vs 4.3 s.
Open optionbatched: falsetakes the one-by-one paths. In
prefill: "release"mode a batch prefills before its step compiles,
and the single-sequence step is freed meanwhile: three executables
had run CUDA out of memory beside another GPU tenant. -
Packages say which entry features they use. Writing a package
recordsfeatures(each entry and each of its keys,generate.check, …)
in its model block. An rlx opening one that uses a feature it lacks
refuses it by name ("uses generate.teleport that this rlx (0.2.18) does
not run; update rlx") rather than run without it
(rlxp::FEATURES,rlxp::features_of). Format and opset versions
(requires,opset) were already checked. -
Input / output contracts. A
pipelineentry'sinputsand
outputsmay be contracts,{name, kind, dtype, shape, …}with
anything else a caller needs (color: "rgb",sample_rate).info()
gives the typed specs and, undermetadata.inputs/outputs, the
contracts.runrefuses a tensor of another rank or static size, and
embed,generateandtranscribedescribe theirs (text; audio with
its sample rate). The SigLIP 2 and Whisper exporters declare theirs
(u8 RGB[height, width, 3]; 30 s of 16 kHz mono). -
Generation defaults, tools, JSON output.
- A
generateentry'sgeneration(fromgeneration_config.json:
do_sample,temperature,top_p,top_k,min_p,
repetition_penalty,max_new_tokens) fills in what a caller leaves
out ofgenerate_text,chatandgenerate_batch.do_sample: false
stays greedy.runwith ids keeps its own defaults. TextCodec::apply_chat_template_jsonrenders JSON messages (an
assistant turn'stool_calls, a tool turn'stool_call_id) with
tools,enable_thinkingandadd_generation_prompt;chat
passes them through. A template with notoolsbranch refuses tools
rather than drop them.response_format: "json"(or{"type": "json_object"}) keeps the
reply to one JSON value: each step, of the 256 likeliest tokens, only
those whose text continues a JSON prefix may be picked
(model::json_prefix::JsonPrefix), and a stop token only once the
value is whole.- Qwen3-0.6B:
{"city": "Paris"}in JSON mode; told about a
get_weathertool, it calls it with{"city": "Paris"}.
- A
-
Loops in a pipeline. A
pipelineentry may runstages, then a
loop: {stages, steps, schedule, until}, thenfinishstages. Each
step seesstepandsteps, and its value from eachschedulelist
(a diffusion's timesteps, a sampler's sigmas). The loop's outputs
replace earlier values, and it ends early once theuntilvalue is
true. Thestepsoption overrides the count, and theseedoption
is a value for stages that draw noise. All the stages share one
weight set. Tested with Newton's iteration for √2, stopping when it
converges. -
Images (and other media) in a prompt. A
generateentry'smedia
gives anembedfunction (ids to embeddings), a prefill that takes
embeddings (padded to listedsizes), andkinds(image: {token, input, stages, output}).Model::generate_with_media(prompt, media, options)(andLmRunner::prefill_with_media) works like this: each
run of a kind's placeholder token takes the next medium, expanded to
as many positions as its embedding has rows; the media rows replace
those positions in the prompt's embedding; the prefill seeds the
caches; decoding continues by tokens. -
Multimodal RoPE (Qwen-VL).
media.mrope: {sections, layout, cos, sin}gives each token a(t, h, w)position (an image's rows its grid,
fromkinds.<kind>.grid.patch) and the prefill each token's RoPE rows
(rope_cos/rope_sin), frequencies by section (Qwen2/2.5-VL) or
interleaved (Qwen3-VL). Decoding goes on from one past the image's
largest position:rlx_ir::stateful::StatefulDecode::rope_offsetreads
the tables atposition + rope_offset, the decode input feed
rope_offsetsupplies it (and a host-fed table row is taken there). -
Graphs symbolic in a product of sizes. A derived dim may be the
product of two symbols times a scale plus an offset (h*w*4;
SymDef::times, writtenh*w*4in.rlxmodeltext).generalize
tries pairs of symbols when no single one fits, and binding works both
ways: a bound derived dim (an image'sh*32rows) binds its base, so a
stage is specialized from its inputs' shapes alone. The Qwen3-VL and
Qwen2.5-VL vision towers are now one graph each for every image size,
their position tables, patch order, RoPE rows and window masks
computed in the graph from the grid. -
Images of any size.
media.kinds.<kind>.resize: {multiple, min_pixels, max_pixels}resizes au8 [height, width, 3]image as
Qwen-VL's processor does (model::image_resize::smart_resize, Python
rounding) with an antialiased bicubic that matches torchvision's
uint8resize value for value (resize_rgb8_bicubic; int16 weights at
the widest precision that fits, rows first, each pass rounded).
Qwen2.5-VL-3B and Qwen3-VL-2B on 448×448 and 500×375 images (the
latter resized to 364×504 / 384×512):transformers' text, CPU and
Metal. -
Weights stream, and fit. Measured on Qwen2.5-VL-3B (CPU, exact
/usr/bin/time -lfootprints), the run was 74 GB at its peak on a
64 GB machine — the OOM kills.- Binding reads each tensor's byte range from its safetensors file (the
WeightCacheheld whole files, 7 GB here), andPackageWeightsno
longer keeps every resolved weight beside the backend's copy. - Open option
weight_dtype: "f16" | "bf16" | "auto"narrows matmul
weights as they bind; the CPU now keeps a half weight read only as a
matmul's right operand packed (promote_to_f32_keeping_half_weights;
itsSgemmF16/SgemmBf16read it), where it used to widen it back. - A package opened narrower or short of memory compiles without the
weight-concatenating fusions (FusionOptions::no_weight_concat, the
RLX_NO_WEIGHT_CONCAT_FUSIONenv as a compile option): their packed
QKV / gate-up weights were a resident second copy of ~65% of a
transformer's weights. prefill: "release"also frees media stages (and the step before a
media prompt), and is the default when the functions' f32 copies
would not fit in half the memory ("keep"opts out).- Metal keeps f16 weights and embedding tables half: its compile
promotes a graph with any half node to f32, and the half rows of a
half embedding table widened every weight of the step and the text
prefill back (prepare_f32_exec_graph_keeping_half_matmul_weights;
bf16 still widens, Metal has no bf16 storage). The table's gather
stays a half row copy, read through a newgather_axis0_h_fidx
kernel:gather_axis0_hread the ids as half, but the arena holds
integer ids as f32 (Thunk::Gather.idx_dtpicks). Qwen2.5-VL-3B's
whole test on Metal withweight_dtype: "f16": 36.9 → 21.1 GB peak.
A video's tower compiles once for all its frame pairs underprefill: "release". - One copy of each weight across Metal executables. A package's
decode step, prompt prefill and media prefill each held their own
weights. Under a sharing scope (with_param_sharing_scope; every
package compile now runs under its own) the Metal weight buffer is a
page-aligned VM region wrapped no-copy, and each weight in it is an
owned Mach memory entry from a process-wide registry
(rlx_metal::shared_weights, by scope, name, dtype and size): later
executables map the same pages and skip binding them
(CompiledGraph::param_already_set). Owned entries are charged to
the footprint once however often mapped (plain shared anonymous pages
are charged per mapping — what iOS's limit counts). Under a scope the
weight buffer may pass 4 GiB; such an executable stays off MPSGraph,
whose binds fail there. Whole tests on Metal (images, video, text),
peak: Qwen3-VL-2B f32 39.9 → 16.4 GB, f16 22.5 → 8.9 GB; Qwen2.5-VL-3B
f32 36.8 → 22.5 GB, f16 21.1 → 12.3 GB — the same logits and text, and
faster (mapped weights are not read again).
RLX_METAL_SHARED_WEIGHTS=0keeps a copy per executable. - A dropped Metal executable gives its memory back. Compiling to
MPSGraph made autoreleased objects (the lowered graph, its
executable, the arrays bound to the arena) that retain the arena;
on a thread with no autorelease pool they lived until the thread
exited, so each compile-and-drop kept its whole arena (6 cycles of a
128 MiB weight: +775 MiB, now 0). Each compile now runs in its own
pool, and the no-copy view MPSGraph binds at an offset is released.
Qwen2.5-VL-3B f32 test on Metal: 22.5 → 16.4 GB shared, 36.8 →
30.7 GB unshared; the released stages' memory now returns. - A package compiles lean (no weight-concat packs) also when its
functions' f32 params times 1.7 — the packs measured — would pass
half the memory: Qwen3-VL-2B's f32 test on Metal 43.1 → 32.1 GB, and
faster (73 → 56 s). - One step of Qwen2.5-VL-3B: 24 GB → 11.3 GB (f16); a prompt with an
image 24 → 14.6 GB. Its whole test (two images, then text): 74 GB →
34 GB as opened (now lean and releasing by itself), 20 GB with
weight_dtype: "f16", the same text.
- Binding reads each tensor's byte range from its safetensors file (the
-
Per-medium extra rows.
media.kinds.<kind>.rows: [names]: further
outputs of the kind's stages, laid out like its embedding, reach the
prefill as[1, seq, width]values, zeros but at each medium's
positions (Qwen3-VL's deepstack features). -
Video.
media.kinds.<kind>.frames: {patch, layout, t_step}takes
u8 [frames, height, width, 3], runs the kind's stagespatchframes
at a time (the last repeated to fill a group), and lays the groups out
as one placeholder run each ("runs", Qwen3-VL) or as one run with
groupgat time positiong · t_step("joined", Qwen2.5-VL).
resize.over_framesbounds a video's pixels over all its frames
(image_resize::smart_resize_frames, Qwen3-VL's video rule). -
Half embedding tables.
weight_dtypenow narrows embedding tables
too (rlx_ir::retarget::embedding_tables_as: the table f16/bf16, each
gathered row widened); the CPU keeps a table read only by
Gather{axis: 0}packed and widens just the rows it gathers
(Thunk::Gather.table_half). -
Qwen3-family MoE MLP in rlx-flow.
Qwen3Moeon a decoder layer
spec: router softmax, top-k, optional renormalization, then each slot's
experts through twoGroupedMatMuls. It reads the fused expert tensors
in either orientation (the original[E, H, 2I]or transformers 5's
[E, 2I, H]) or per-expert linears. -
Sliding-window masks in a stateful step.
StatefulDecode::windows: [(input, window)]: further mask inputs, each
the step's mask admitting onlyposition - window < key, computed from
the sameiota(rlx-flow's Gemma decode layer reads one by name,
GemmaDecodeLayerSpec::mask_input). The rewrite now makes one param
node per name: a secondiotanode was left unbound (a backend binds a
name once), so the plain mask compared zeros and admitted every key. -
Batched prefill functions.
generate.prefill.batches: [{function, batch}]: the prompt function at fixed batch sizes.generate_batch
runs its prompts' shared prefix through the smallest that holds them in
one call instead of one after another (batch × seqproducts keep it
from being one symbolic function). The Qwen3 exporter adds batch 2, 4
and 8 (ExportOptions::prefill_batches); three prompts decode to the
same text. -
Speech synthesis entry.
synthesize: {tokenizer, template, sample_rate, stages, voices, decoder, token_offset, codebook, output}. The text, tokenized into the template's{text}, is the
valueids, and avoicevector is taken fromvoices(option
voice). Then either stages make the audio straight from the ids, or
adecoder(agenerateentry, greedy or sampled) writes codec
tokens first, and their ids, lesstoken_offset, withincodebook,
are the valuecodesthe stages decode. It is atext-to-speech
model (synthesize). -
Weights: checksums, adapters, quantized storage.
- A weight source may carry the file's
size(checked on every open:
"the weights changed under it") andsha256(checked with the open
optionverify_weights: true).rlxp::weight_sourcewrites both,
and the Qwen3, Qwen3.5, Whisper, SigLIP 2 and BERT exporters use it. - Adapter packages:
rlxp::save_adapter(path, name, base, [LoraUpdate{tensor, rank, a, b, scale}])writes{adapter: {base, lora}}. Opening one opens its base withW + scale · B·Amerged
into each named checkpoint tensor (or held param) as the weights
bind, and adapters stack. rlxp::quantize_package(in, out, GgufQ8_0 | GgufQ4_0)stores a
package's f32 matmul weights quantized in it. EachMatMul(x, w)
becomes aDequantMatMuloverw's GGUF blocks, read from
whereverwwas. Qwen3-0.6B, from 2.4 GB of f32 weights: Q8_0 is
636 MB with the same 40 greedy tokens, Q4_0 is 338 MB (chat
"Paris"; its tokens part from f32's after the first).rlx_pkg::build::rebase_weight_urisis now public.
- A weight source may carry the file's
-
Serving: a draft model, continuous batching.
- The open option
draft: "<package>"gives speculative decoding a
draft model, a package with the same vocabulary, instead of prompt
lookup. Its greedy guesses come from its own cache, rolled back
(truncate) to where it and the context agree. With the package as
its own draft, Qwen3-0.6B gives the same 40 tokens. - Continuous batching:
LmRunner::serve_open(slots),
serve_add(prompt),serve_step()(each slot's next token once its
prompt is in),serve_remove(slot). Requests join and leave between
steps.- Every slot runs at the batch's position, from its own start row:
rlx_ir::statefultakes astartsinput, and the mask admits
starts[b] ..= position. RoPE depends only on the distance
between positions, so a late start computes what a start at 0
does. - When the batch reaches the end of its cache, each request's rows
move back so they all end together. Its keys are rotated back by
the rows they moved,R(-δ), using the entry's
rope: {cos, sin, style, head_dim, keys}, which the Qwen3
exporter writes. - On Qwen3-0.6B: B joins A's batch ten steps in, A leaves, and the
32-row cache moves back. Each request gets exactly its solo
tokens (CPU and Metal).
- Every slot runs at the batch's position, from its own start row:
- The open option
-
Rerankers: a
scoreentry.Model::score(query, passages, options)returns one relevance score per passage, for a
"text-ranking"model.TextRanking+text_ranking_modeladapt a
provider's own, andruntakes aqueryandpassagetext tensors
and returnsscores. A package'sscore: {functions, sizes, tokenizer, inputs, position_offset, label, activation}runs each pair
as the tokenizer pairs it (TextCodec::encode_pair: ids and segment
ids, the new input roletoken_types). The pair goes through an
encoder whose output is[batch, labels]logits.labelpicks the
column, andactivation: "sigmoid"maps it into (0, 1). A pair too long for the
longest function loses the end of its passage.embedandscore
share one encoder runner. rlx-embed'sexport::export_reranker_rlxp
appends the classifier head (BERT's pooler +classifier, or RoBERTa's
classifier.dense+out_proj) to the encoder graph:
ms-marco-MiniLM-L6-v2 scores within 2e-6 oftransformers(CPU,
Metal). -
Llama, Mistral and Qwen2 checkpoints through the Qwen3 exporter.
The family comes fromconfig.json'smodel_type. A config that
does not mentionqk_normdefaults it off for those types. Scaled
RoPE (rope_scaling) is refused rather than built wrong.
SmolLM2-135M exports and greedy-decodes the same 20 tokens as
transformers, alone and in a batch (CPU, Metal). -
Speculative decoding (prompt lookup). A
generateentry'scheck
function runs several tokens after its cache (the last token, then a
draft). With the open optionspeculative: true, greedy decoding
drafts what followed the context's latest 3-, 2- or 1-gram, checks it
in one run, and keeps the draft as far as the model agrees, plus the
model's own pick. The tokens are the same as one at a time.
rlx_ir::statefultakes such a step:concat(cache, rows)becomes a
DynamicUpdateSliceof all the rows, the tables give
position..position + tokens, and the mask is a bias letting tokeni
see keys0..=position + i. rlx-flow's Qwen3 decode layer takes
several tokens (per-head norm over them, a bias mask);
rlx_qwen3::flow::build_qwen3_check_builtbuilds the check, and the
exporter adds it (ExportOptions::speculate, 3 by default). Qwen3-0.6B
on Metal: the same 40 tokens in 680 ms against 891 ms. -
Metal: bias attention in the tiled prefill kernel. An additive bias
(MaskKind::Bias) fell tosdpa_long, one thread per head: a
4-token check's attention took 70 ms a step.sdpa_prefill_fa(and
its MMA variant) now adds the bias. Checked against the CPU with 4
queries over 37 keys on Metal, MLX and wgpu. -
Metal: a few rows of f32 matmul read the weights once.
gemv_f32_splitk_rowsruns m = 2-4 as the split-K GEMV a single row
uses: each weight is read once for every row, with the same 32-way K
split and order, so each row is bit-identical to that row alone. The
tiled sgemm had reread the weights and cost 2× a single decode step at
m = 2-4. Now a batch-2 step costs 1.18× and batch 4 1.51×. At m = 8,
two passes lost to the sgemm (38 vs 31 ms), so the limit is 4.
RLX_METAL_GEMV_ROWS=0opts out. -
DynamicUpdateSlicelowers at any batch. TheScatterNd
lowering (CUDA, ROCm, Vulkan, wgpu, MLX) indexes every position
before the axis, so a batched cache[batch, cap, width]writes each
sequence's rows; it covered batch 1 only, and CUDA rejected a batched
stateful step. Checked against the reference on CPU, Metal, MLX, wgpu,
CUDA and Vulkan. -
Open options for packages.
stage_devicessets a stage's device
over its entry's;max_contextlowers a package's;context_growth
sets how much a grown step grows (2). -
rlx_ir::envreads are cached. EveryRLX_*read went to the
override map and thengetenv, allocating the key and the value: ~80-100
ns each, and backends read their switches per dispatch (92 reads in
Metal's encoder, 54 in CUDA's run loop; 844 call sites read 581 keys).
Values are now resolved once and kept;flag/var/parse_ortake
17-24 ns, with no allocation forflag,flag_or,parse_orand
is_unset.set,remove,unset,clear_overridesand
RuntimeOverridesempty the cache;rlx_ir::env::reload()does it for
code that changesRLX_*variables withstd::env::set_varafter
reading them (the PS5 node, which seeds them from its config file).
One read path:rlx_ir::envresolves a key through the registry
(canonical spelling, aliases, a deprecated name's replacement) on its
first read and caches the result, so every reader gets alias and
deprecation handling;env_registry::{var, flag, parse_or}forward to
it (flag_orkeeps the registry's declared default) and its copy of the
truthy parser is gone. rlx-wgpu's FFT switches and the Cortex-M trainer
read flags withenv::flaginstead of private helpers that accepted
fewer spellings (RLX_FFT_WGPU_ONCHIP=yeswas off). -
prefill_device: the prompt on another device. The open option
{"prefill_device": "cpu"}runs a package's prefill on the CPU and
hands its K/V rows to the step's caches on the model's device, keeping
the prefill's weight copy out of GPU memory. It is a memory lever, not
a speed one: the first token is slower at every prompt length, and the
gap grows with the prompt (Qwen3-0.6B, best of five, same tokens):prompt Metal CPU prefill CUDA CPU prefill 16 25 ms 77 ms 21 ms 155 ms 64 128 ms 323 ms 33 ms 456 ms 250 120 ms 393 ms 116 ms 2656 ms (M4 Pro; RTX 3080 Ti shared with another job.) On CUDA,
RLX_CUDA_SHARED_PARAMS=1saves the same memory without the cost.
rlx-qwen3'stests/prefill_device_bench.rs(opt-in,
RLX_BENCH_PREFILL=1) measures it; each run takes a different prompt,
since the same prompt again is a continuation of the cache and returns
its first token without running anything. -
Hugging Face model cards. The model block's
card_dataholds a
card's metadata under the Hub's own keys (license,language,
tags,datasets,base_model,pipeline_tag,model-index, …), and
cardholds its Markdown body. Exporters read a checkpoint's
README.mdinto them (add_huggingface_card, which parses the YAML
front matter).rlxp::huggingface_readmewrites a HubREADME.mdback
from a package. Both are inModel.info().metadata. -
Entries: a language model from the package alone.
entries.generatedeclares the decode functions (one per cache
capacity), what feeds each input (the token, a row of a held table, the
bucket's mask, a cache), which output updates each cache, the stop ids,
and the chat template. Therlxpprovider then runs it as an
LmRunner, sochat,generate_textandgenerate(greedy or
sampled) work with no provider code. A continuation feeds only its new
tokens. Tokenizer loaders now also take the chat setup
(TokenizerLoader = fn(&[u8], &Value)), and rlx-text renders the
template. rlx-models'rlx-qwen3exports Hugging Face checkpoints this
way (export::export_rlxp): Qwen3-0.6B becomes a 4.9 MB package with a
4096-token context that references 311 of its 313 params. Opened by rlx
alone, it greedy-decodes the same 24 tokens asQwen3Runnerand
answerschatfrom the model's own template, from Rust and Python.
The shared matcher that splits params into referenced and held ones
lives inrlx-models-core'spackage_export. -
dist::WeightCachereads a safetensors file once. It used to
re-read the whole file for every tensor. For a 1.1 GB checkpoint with
200 tensors, that was about 220 GB of reads. -
NaNand±infinrlx_ir::text. They used to print as JSON
null, which didn't read back. An op holding one now prints in the
.rlxmodelvalue syntax (Clamp{min: -inf, max: nan}(%0)), which the
parser also accepts. Every other op prints as before..rlxmodelkeeps
a non-canonical NaN's exact bits (nan(0xffc00000)).
-
-
Linked model plugins, for iOS, Android, wasm and consoles. These
platforms can't load a plugin at run time, so the same plugin crate links
into the app's library instead. A plugin lists its providers once:
export_model_plugin!("name", [A, B]), and an entry may carry a#[cfg].
The host names its plugins withlink_model_plugins!(rlx-runtime feature
linked-plugins), and the app callsrlx_link_model_plugins()once
(Swift:linkModelPlugins()). That call keeps the providers in a static
library whatever the build profile. Before this, a C linker silently
dropped their start-up registrations unless the library was built with fat
LTO. Linked builds also leave out each plugin'srlx_model_plugin_v1
symbol, so several plugins can share one fat-LTO library, where before
they failed with "symbol multiply defined".ios/build-xcframework.sh --capi-cratepackages such a library asRLX.xcframework. On those
targets rlx no longer scans~/.rlx/plugins, and the "no provider" error
says how to link one. The built-inrlxpprovider is now registered in
code, not at start-up. -
Models from any provider, in every language:
rlx_runtime::model. A
Modelopens a model by path or id and runs it by name, with named, typed
tensors. Language models also getgenerate,generate_textand
predict_logits, with sampling options. Providers register with
register_provider!. The built-inrlxpprovider runs a graph saved with
save_modelon any device. Plugins arecdylibs exporting a C table
(rlx_model_plugin_v1, ABI 1), loaded fromRLX_MODEL_PLUGINSor
load_model_plugin, so a model collection downstream of rlx reaches every
binding without rlx depending on it.rlx-model-pluginis the reference
plugin and authoring guide. Every binding getsModel,model_providers,
load_model_pluginandsave_modelfromapi.idl, plus a sixthmodels
example. The example is checked in all 25 languages: natively, under
RLX_TEST_DOCKER=1, and in the Python, JS, Swift and C suites. -
Task methods:
chat,transcribe,synthesize,embed.Model
answers each task with one call, in every binding. Making a model runnable
takes one task trait (SpeechToText,TextToSpeech,TextEmbedding, or
LmRunnerfor text) plus a declarativeProviderwithClaims: by id,
prefix, GGUFgeneral.architecture, HFmodel_type, file name or
extension.chatapplies the tokenizer's chat template
(TextCodec::apply_chat_template). Plugins in~/.rlx/pluginsload with
no setting needed, and a method called on the wrong kind of model names the
one that fits. The tasks travel as named tensors overrun, so plugin ABI 1
is unchanged. -
C and C++ on microcontrollers:
rlx-micro. The C ABI ofrlx-cortexm's
INT8 kernels (conv2d, dense, max-pool, ReLU, argmax, quantize) and its MNIST
model. It isno_std, never allocates, and needs no OS or libc. Calls
check buffer sizes and return a status instead of trapping. Ships
rlx_micro.h, a freestanding C++rlx/micro.hpp(no exceptions, heap or
STL), C/C++ examples, and Cortex-M start-up, semihosting and linker-script
files. The firmware is 24.9 KB flash / 10.8 KB RAM.cargo test -p rlx-micro
runs the examples on the host and bare-metal on a Cortex-M4 under QEMU
(mps2-an386). -
One op table generates every language binding:
rlx-bind,rlx-capi,
rlx-bindgen, and Swift.crates/bindings/rlx-bindholds the graph
builders as one table (for_each_graph_op!, ~200 rows) plus the runtime as
plain Rust (Session,Compiled,Trainer,Optimizer, tokenizer,
sampler,check, devices, GGUF, RLXP, weight loaders). rlx-js and pyrlx now
expand that table at compile time instead of keeping two hand-written copies,
and wrap the shared runtime.crates/bindings/rlx-capiis a C ABI over the
same surface (include/rlx.h, 366 functions) for any FFI language;
crates/tooling/rlx-bindgengenerates itsrlx_graph_*glue and its header
(parsed from the crate, so it cannot drift), the SwiftGraphmethods, the
.pyiGraphstubs anddocs/bindings-api.md.crates/bindings/rlx-swift
is a SwiftPM package on the C ABI whose runtime classes are generated too
(see the next entry), withRLXError.kindand an XCTest suite (swift test);
ios/build-xcframework.sh --capi [--apple]packages the static library as
RLX.xcframework(iOS + macOS) andRLX_XCFRAMEWORK=1builds the package on
it, for apps. Adding an op is one table row and
just gen-bindings;just check-bindings(injust ci) fails on stale
output, and anir_drifttest fails when rlx-ir gains a builder the table
does not reach. C and Swift (example + XCTest, and a check that every C
function has a Swift wrapper) run end to end incargo test -p rlx-capi. See
docs/bindings.md. -
A runtime API table keeps every binding one-to-one.
crates/bindings/rlx-bind/src/api.idllists every runtime class member and
function — sessions,FlexibleSession,Runner/Router, autodiff,
check, devices, GGUF, weights, RLXP, training, text, distributed — with
argument and return kinds and its name in Python, JS, C and Swift.
rlx-bindgenchecks each row against the exported C function's parameter
types (and each C function against a row) and generates
rlx-swift/Sources/RLX/API.swiftfrom the rows; only the callback loop and
the in-place optimizer step are hand-written. rlx-js and pyrlx test the table
both ways (every row is exported, every export is a row or a listed host
helper). Filling the gaps it found addedparse_device,FlexibleSession,
Runner.from_env/fastest/resolve/set_params/run_on/
run_chain/run_with_inputs,Router.from_env/devices/run_on/
set_params/set_param_typed,Gguf.architecture/metadata_value,
convert_to_ggufandexport_fpgato the C ABI and Swift;Graph.clone/
is_consumed,load_weightsandto_rlxpto Python and JS where they were
missing; andrlx_trainer_freeze/unfreezein place ofset_frozen.
load_weightsalso takes"safetensors". The name map is in
docs/bindings-api.md. -
Distributed nodes from every binding.
rlx_bind::runtime::dist::Group
(andGroupin Python, JS and Swift,rlx_group_*in C) joins a mesh of rlx
nodes:connect/from_env,send/recv,all_reduce,all_gather,
broadcast,barrier, and the ship-graph protocol —collect_caps,
ship_stage/serve_worker/send_activation/recv_activationfor
pipelined inference,ship_train/run_train/serve_trainerfor
data-parallel training — plusloopback_peersfor running every rank on one
machine. Python releases the GIL in each blocking call, so ranks can be
threads. -
Fifteen more generated languages. C++ (
rlx-cpp, header-only RAII,
designated-initializer options), Node.js / TypeScript (rlx-node, koffi, a
.d.ts), Kotlin (rlx-kotlin, generated JNI glue — the JVM and Android),
Dart / Flutter (rlx-dart,dart:ffi), Lua (rlx-lua, a C module), MATLAB /
GNU Octave (rlx-matlab, one MEX gateway + a+rlxpackage), R (rlx-r,
.Call), PHP (rlx-php, FFI), Nim (rlx-nim), D (rlx-d), V (rlx-v),
Haskell (rlx-haskell,foreign import ccall), OCaml (rlx-ocaml, C
stubs), Elixir (rlx-elixir, dirty NIFs) and Odin (rlx-odin). Each is
printed byrlx-bindgenfrom the same lowered IR, has a README, a test
suite and the five examples printing the same numbers, and runs in
cargo test -p rlx-capi --test ffi_languages— natively, or with
RLX_TEST_DOCKER=1in the language's container against a Linux build of
the library (just linux-capi-volume). -
Go, Java, C#, Ruby, Julia and Zig bindings, all generated.
rlx-bindgen
lowers both tables into one typed view of the C ABI (src/ir.rs, every
function checked against its exported signature) and prints each language
from it:crates/bindings/rlx-go(cgo;…Optsstructs,(T, error)),
rlx-java(the FFM API, Java 22+; no JNI),rlx-csharp(P/Invoke, .NET 8+),
rlx-ruby(stdlib Fiddle, no gems),rlx-julia(@ccall gc_safe, no
dependencies) andrlx-zig(@cImport, Zig 0.16). Each has every C
function, a README, a test suite and the same five examples printing the
same numbers as Python, JS and Swift;cargo test -p rlx-capi --test ffi_languagesruns them all (C# in the .NET SDK container with
RLX_TEST_DOTNET_DOCKER=1whendotnetis absent). The C ABI gains
rlx_{u32s,f32s,bytes}_releasefor FFIs that cannot pass a struct by value. -
The same five examples in Python, JavaScript and Swift.
inference,
training,loading,distributedandbackendslive in each package
(pyrlx/examples/,rlx-js/examples/,rlx-swift/Examples/as
rlx-example-*products) and print the same numbers; each binding's tests
run them (the JS distributed example as a real three-process mesh, the Swift
and Python ones as three threads).crates/bindings/README.mdindexes every
language's package and examples, and the top-level README links it. The C
example moved torlx-capi/examples/train_linear.c. -
The JS (
rlx-js) and Python (pyrlx) bindings now reach the whole
rlx_ir::Graphbuilder surface. JS grows from 101 to 205Graphmethods
(all chainable throughrlx.dsl), Python from ~60 to ~200: conv 1d/3d and
transposed variants, pooling, interpolation, attention with a bias / v-dim /
score scale / logit softcap, RoPEstyle(neox/gptj), batch / adaLN /
fused residual norms, LSTM / GRU / RNN, Mamba-2, Gated DeltaNet,scanover
a body graph, scatter / gather ND, per-channel and block-scaled quantization,
dequantMatmul(int8, MLX, GGUF K- and IQ-quants), VQ, KAN splines,
FIR / IIR / SOS / Hilbert DSP, spectral features and SPD manifold layers.
activation/binaryin both readActivation::ALL/BinaryOp::ALL, so
every IR activation and binary op is reachable by name. Python also gains
what only JS had —check, precision policies,Trainer/Optimizer, the
rlx-texttokenizer / sampler / chat-template layer,verify_rlxp— and JS
gainsdirectionalNthGradandcheck'sbudgetswitch. Trainer checkpoints
share one GGUF layout across both.
Changed
-
supported_for_targetnow matches every backend's realSUPPORTED_OPS
on the kinds that gate fusion (newFUSION_GATING_KINDS), and
FusionOptions::for_targetunfuses regions for CUDA/ROCm as the runtime does.
The hand-copied lists had drifted — CUDA/ROCm lackedFusedSwiGLU, Metal
FusedAttentionBlock, all of themWhile/Fft/WelchPeaks/
FusedConvBiasAct— sofuse(g, target), benchmarks, and anrlx-metal
compile without an explicit claim set fused differently from the backend.
Runtime compiles already passed the real claims and are unaffected. -
ExpertPager::prefetchis no longer a demand read: it does not move
hits/misses(it used to inflatehit_rate), only refreshes an
already-resident expert, and tracks what it admitted as speculative. -
XDNA tiled GEMM cuts each activation tile once per K-block instead of
once per (N-block, K-block) in the resident-weight path; same accumulation
order, so bit-identical by construction. Compiles; not yet run on the NPU. -
Device::Xdnacomes afterCpuin automatic device selection. A
Phoenix NPU measured 1.9–2.7× slower than same-precision CPU bf16 on
transformer models, sofastest_device()no longer picks it; select it
explicitly. -
A
Sessionon an unavailable XDNA / eGPU device says why. The panic now
carries the backend's diagnostic (missing env var, path, library, toolchain
error) instead of "no such device/driver was found". -
Both bindings now share one implementation, so a few behaviours line up.
pyrlxCompiledGraph.runrefuses non-f32 outputs (as rlx-js always did)
instead of reinterpreting their bytes — userun_typedorcast. A Rust
panic inside a pyrlx call is aRuntimeError, not pyo3'sPanicException
(aBaseExceptionthatexcept Exceptionmisses). Unknown optimizer and
sampling options are an error in rlx-js too. pyrlxdequant_ggufhandles
every GGUF type (F16 / BF16 were refused), andGgufFile.metadata()returns
arrays as lists rather than a debug string. JSGguf.info().dtypereads
"Q4_K"(the GGML name) rather than"Q4K". Graph builders gained the
other binding's spellings: JSeq/ne/lt/le/gt/ge,
Pythonifft,le_,node_count,name,fused_residual_layer_norm; JS
RunnergainedrunOn/runTry/runChain, JSGguftensorBytes/
architecture. -
rlx-cuda:
RLX_CUDA_PARITY=1now also keeps cuDNN's forward conv on the
GEMM algorithms. cuDNN's heuristic picks WINOGRAD for most 3×3 convs. Even
in strict FP32 math (CUDNN_FMA_MATH), its transform loses about 10× in
accuracy. CLEF's VQGANconv_inweight-gradient was 1.05e-4 off PyTorch with
WINOGRAD and 1.06e-5 with IMPLICIT_PRECOMP_GEMM. The default (non-parity) path
is unchanged. -
Rust builds can share an sccache cache; two settings decide whether it ever
hits.docs/development.mdgains a Build cache section.incremental = falseis required — sccache forwards-C incrementalcompilations to rustc
uncached — andCARGO_TARGET_DIRmust not be exported, because the
environment variable is part of sccache's Rust cache key: varying it misses
every time, while the identical directory change passed as--target-dir
hits. Measured 2026-10-16 on a box that had run sccache for months through
CARGO_TARGET_DIR-exporting scripts and sat at a 0.00% Rust hit rate against
83% for C/C++.rlx-onnx-import'stests/codegen_compile.rsnow passes
--target-dirrather than setting the variable, keeping its isolated target
dir without defeating the cache. The MLX C++/CUDA build was already cached
(RLX_MLX_CCACHE); only Rust was uncovered.
Fixed
-
FuseSwiGLUDualMatmulon batched weights: asilu(x·Wg)·(x·Wu)
with[B, K, N]weights (an expert per batch row) was concatenated as if
2-D, declaring a[B, 2N]weight the operands give as[B, K, 2N]; the
pass now fuses[K, N]weights only (test
only_two_dimensional_weights_fuse). -
Backend calibration could be re-measured mid-process. rlx-metal, rlx-cuda and rlx-mlx
saved their calibration withfs::write(truncate, then write) and re-read the file on every
cost-model query. A concurrent reader that caught the file empty or half-written measured
again, with timing-dependent numbers, so two device queries in one process could disagree.
The calibration is now loaded once per process (OnceLock) and saved write-then-rename. -
ONNX
ArgMax/ArgMin/MaxPoolindices imported as invalid IR.
The importer declaredOp::ArgMaxitselfi64(ONNX's index type), but the
IR op returns f32-encoded indices, so the verifier rejected any model with
an argmax and it could not compile in a debug build (the conformance
gqa_layernormfixture). It now emits the f32 op, widening a
half-precision input first, and casts the indices to i64
(tests/arg_reduce_indices.rs). -
A param passed to
runwas ignored, so a training loop that fed its
updated weights throughrun(as the README's example did) kept computing
with the old ones.run/try_runnow upload a param name with
set_param. -
An invalid input graph was blamed on the first pass that changed it.
Debug builds re-verify after each pass; an error the input already had read
IR verifier failed at after passselect_peaks_only_outputs``. It now reads
the input graph (already invalid before any pass …); inputs a lowering
pass legitimately repairs (`LowerIf`) still compile. -
TPU
Op::Roperotated only the first head, and ignoredn_rotand
style. The lowering treated the last axis as one head, so on a packed
[B, S, H·D]tensor it emitted a shape-inconsistent module; partial rotary
and GPT-J pairing were silently NeoX full-width. Rewritten to the CPU
semantics (per head, firstn_rotdims, NeoX or GPT-J pairing, per-position
or per-token tables);RopeBackwardshares it withsinnegated, so GPT-J
gradients now lower instead of asserting. -
TPU
RmsNormdroppedbeta— the defect fixed on the other backends
earlier; TPU had been missed. -
TPU
AttentionwithMaskKind::Customadded the mask to the scores
instead of treating it as the[B, S_k]keep-mask the IR defines (< 0.5
ignored). A 0/1 padding mask masked nothing. -
Fusions absorbed producers that were also graph outputs.
FuseMatMulBiasAct,FuseConvBiasAct/FuseConvAffineActand
FuseMatMulResidualchecked one consumer withusers().len() == 1rather
thanhas_single_use, so an exported matmul returnedx·w + bon CPU. -
CPU returned zeros for an exported
NarrowfeedingAttentionor
Rope. The thunk rewrites that read the parent buffer with a row stride
turn the Narrow into a Nop when it has one reader, and did not count the
caller reading a graph output. -
jvp_hvp_third_regressionasserted the bug it was named after. It
checked thatjvp(hvp(f))returns zero rather than the third derivative —
the behaviour thetangent_<name>_2collision fix removed — and kept
passing because it fed onlytangent_x, so the outer tangent read the
arena's zeros. UnderRLX_POISON_ARENAit read NaN. It now feeds both
tangents and requires6.0, matchingnth_order_grad. -
lstm_autodiffnever wrote graph constants into its arena (it drives
thunks directly, bypassing the runtime that does). It passed only while
every constant in the unfused LSTM is zero. -
ExpertPager::prefetchcounted as cache hits and misses, inflating
PagerStats::hit_rate— the number that decides whether paging is viable. -
CPU conv weight gradients were not deterministic. The parallel
Conv2dBackwardWeightpath added each chunk's partial gradient under a
lock, in finishing order, so the f32 sum could change between two runs of
the same graph. Chunks are now added in chunk order: bit-identical runs for a
given thread count. Testrlx-runtime/tests/conv_dw_deterministic.rs(10
runs, plus an f64 reference); the old reduction fails it on the second run.
The conv batch loops also take a work-aware grain (pool::grain_for). -
RLX_WORKERScould be silently ignored. The global rayon pool is built
at the first kernel; if the host program used rayon before that, rayon's
default pool stood and the setting had no effect. rlx-cpu now says so when
RLX_WORKERSis set and cannot apply. -
process_rss_bytes()returned 0 on macOS. It called the legacy
TASK_BASIC_INFOwith arepr(C)mirror of a struct the SDK declares under
#pragma pack(4), and read a resident size of 0. Somemory_headroom_bytes
was always the whole budget andwould_exceed_soft_budgetnever fired on
macOS. It now readsMACH_TASK_BASIC_INFOinto a naturals buffer. Test
process_rss_is_the_real_resident_size(touching 64 MiB must show up). -
Memory plan: a folded transpose's input died too early. A 2-D
transpose read only by a matmul is folded into the GEMM's flag (never
executed; the matmul reads its input), but liveness ended that input's
life at the transpose. With twotranspose(x) @ wbranches whosexare
computed, the second branch's tensor took the first's freed slot before
the first matmul read it — off by hundreds, silently (an ISTFT head,
re @ cos + im @ sin). The input now lives until the matmul. Test
rlx-runtime/tests/folded_transpose_liveness.rs. -
wgpu: a skipped static weight pack dropped deferred host writes, so
every run after the first was wrong. After a host step, the run loop
flushes the host mirror only if the next step is a GPU step. It decided
on the raw schedule, so when the next step was a static-once hostConcat
(a fused QKV/adaLN weight, skipped from run 2 on), it kept deferring. It
then landed on a GPU matmul whose pass cleared the mirror with the writes
still unflushed, and a later host op read its input slot's previous
tenant from the device. Run 1 was exact, later runs were wrong (cosine
−0.12 onrlx-montageflow's final layer, discrete Vulkan with
matmul-bias fusion off). The lookahead now skips the steps the loop is
about to skip. The testrlx-wgpu/tests/static_pack_skip_rerun.rs
reproduces it on Metal viaRLX_WGPU_FORCE_HOST=1and fails with the fix
reverted. Separately, two of the five static-once arming sites set the
flag directly, soRLX_STATIC_WEIGHT_PACK=0did not disable the skip
for a graph that ends on a host step. All five now go through
arm_static_once. -
wgpu: the wide-matmul GELU epilogue used the tanh form for exact GELU.
matmul_wide,matmul_wide_vec4,matmul_wide_nv,matmul_f16wand
the fourmatmul_coop_f16_vulkan*kernels mapped activation code 9
(GeluErf) and 11 (GeluApprox) both to the tanh approximation. That is
about 6e-5 per transformer block wherever the fused matmul+bias+GELU
kernel was picked: discrete Vulkan, rank-3 inputs. Code 9 now evaluates
erf, the same waymatmul.wgsldoes. Test
rlx-wgpu/tests/fused_matmul_bias_parity.rsdrives pre-activations
across [-4, 4]. It fails at 1.2e-4 withmatmul_wide_vec4reverted. -
ONNX import: a negative
Padcrops. ONNX defines a negative pad as
a crop; the importer stubbed such aPadwith zeros. VITS' relative
attention pads its position embeddings bylen − 5, below zero for a
sequence under five, so a short Piper input ran every attention layer on
zero embeddings, silently. Testrlx-onnx-import/tests/pad_negative.rs. -
shared_weightsfootprint test read au64off its alignment (an
i32buffer fromtask_info): a debug build aborted the whole
rlx-metaltest binary. Read unaligned. -
A CPU
Scanbody gathered row 0 at any integer index. The CPU
compiled scan bodies (forward, backward, and the recompute between
checkpoints) without the preparation every other graph gets
(prepare_graph_for_thunks): an I32 index was read as f32 bits, about
3e-45, so a gather in a body took its first row, and a claimed fused op
in a body would have run as a no-op. TEN-VAD's exported pitch track (its
Viterbi backtrack gathers by state) found it; regression test
rlx-runtime/tests/scan_body_integer_index.rs. -
.rlxmodel: an attribute naming a derived symbol pointed at another.
The reader numbers each derived dim anew, past every symbol the header
declares; an attribute (Narrow.len: frames*8-7) kept the number
generalizegave it. With the header declaring a symbol the graph does
not use (a pipeline'scodebooks), the slice read its length from the
next derived dim and the specialized graph failed its shape check. The
writer now names each such attribute by the reader's number. -
inline_into/ autodiffmerge_subgraphdeclared a param twice. An
unbound param or input re-emitted as a new leaf even when the target already
had one of that name — a scan backward merging its body per step got one
Paramper step, and binding by name reaches only one of them. Both are now
Graph::splice, which shares the existing leaf (and errors on a kind or
shape clash instead). -
XDNA detection failed silently (GitHub #5). Each missing
RLX_XDNA_*variable or nonexistent path is now named, the toolchain probe
reports the compiler's error, and an absent NPU lists what/sys/class/accel
held.RLX_XDNA_XCLBIN/RLX_XDNA_INSTSare registered (and documented)
env vars. The device node is reported at/dev/accel/accelN(was
/dev/accelN), and the XRT search covers/usr/lib(Arch) and/usr/lib64. -
XDNA matmul kernel build broke on mlir-aie v1.4.4. The vendor kernel moved
toaie_kernels/linalg/mm.ccand dropped itszero_*functions; rlx now
reads either layout and supplieszero_<type>when absent. -
ExpandSharedKvHeadsrebuilt every graph. The pass copied each
graph node by node even when no attention had shared K/V heads. It now
returns the graph as is. -
(rlx-models) GPU-resident K/V lost rows on CUDA; batch > 1 Whisper
broken everywhere. A decode step that returns its caches as
concat(past, new)has its new row last; the handle feed copied only the
rows before it, so every new row inside a bucket was dropped
(WhisperRunneron CUDA looped "the capital of the capital of …").
rlx-models-core::gpu_kvnow keeps the handles by a per-model
KvLayout.InPlacecopies the leading rows, as before.AppendLast
moves only each sequence's new row (feed_kv_row) and reads a bucket's
last row from the output.Detect, the default, reads the layout off
the step's shapes when it compiles, so callers that weren't changed
(Gemma, Qwen3-TTS, LocateAnything) get the right one. Whisper declares
AppendLast. Batch > 1 was also wrong on every device, host path
included:- the host helpers padded and compacted
[batch, past, kv]caches as
one block (pad_kv_sequencespads each sequence; the compaction is
per sequence); - the cross K/V install asserted at batch > 1;
- Whisper broadcast one position row over a batch that gathers one per
sequence; - Whisper fed one mask row for
[batch, upper + 1].
Two regions decoded together now transcribe as each does alone, on CPU
and on CUDA, with resident K/V on and off. - the host helpers padded and compacted
-
CPU: I32 / U32 / I64 tensors meant different numbers to different
kernels. Casts, scans, the fixed-point FFT and custom ops read integers
as integers; arithmetic, compare, reduce and gather indices computed in
f32 over the same bytes; andrun()wrote I32 as f32 bits and I64 as half
an 8-byte slot. So a cast after an integer multiply returned1065353216
for1, token ids fed throughrun_typedall selected row 0, and any I64
input throughrun()was garbage. Integers are now stored as integers
throughout. A CPU pass (rlx_cpu::int_storage) casts around each
f32-computing op that touches one (gather: only its indices; reduce over
I64: through f64), andrun()writes and reads I32 / U32 / I64 as
integers.tests/cpu_integer_storage.rschecks 8 ops × 3 dtypes × both
feeds. -
Metal: a view of a parameter read from offset
usize::MAXonce the
weights were split out. With large weights packed into their own buffer
(RLX_METAL_WEIGHT_SPLIT_MIN), the activation arena is planned without
params, and a pure view rooted at one (aNarrowat 0, aReshape, a
same-dtypeCast) got no offset at all. A stateful decode step's mask
(iota[0..cap] <= position) read back as all ones, so the exported Qwen3
package attended to every cache row and diverged after the first token.
Views now take their root's offset in the arena.tests/metal_view_offsets.rs
fails without the fix; the Qwen3 package now decodes the same 24 tokens as
Qwen3Runneron Metal. -
rlx-cpu:
Concatof 1- and 2-byte element types. The CPUConcat
thunk moved 4-byte words whatever the dtype, so a bf16 / f16 / u8 / i8 /
bool concat came out with half its elements missing and rows interleaved
(bf16[a | b]read back as[a1, a3, a5, b1, …, 0, 0]), silently. It
surfaced throughFuseSharedInputMatMul, which concatenates the weights of
matmuls sharing an input: a model holding bf16 weights (gate + up of an MLP)
computed garbage on CPU while every single-matmul test passed. Half types now
go throughThunk::ConcatHalf(packed params and widened activations are
different layouts, so inputs are widened to f32, assembled, and a packed
output rounded back); other 1- and 2-byte types through a raw byte copy
(Thunk::ConcatBytes). Test:rlx-runtime/tests/concat_narrow_dtypes.rs. -
rlx-coreml: training graphs with bias gradients segfaulted (Apple BNNS
defect, now avoided). CoreML's CPU engine mis-executes
transpose → reduce → transpose. It reads out of bounds inside
BNNSGraphContextExecute_v2(SIGSEGV/SIGBUS), or at some shapes silently
returns wrong sums:[3,32,32]summed over two axes was off by 98. This
reproduces with coremltools and no rlx code (macOS 26.6.2), on every
compute-unit setting. rlx-autodiff's sum-to-shape emits exactly this chain for
every bias gradient. The newhybrid::hoist_transpose_out_of_reducepass
rewritesreduce(transpose(x, p), A)asreduce(x, p[A])plus a reshape, or a
transpose after the reduce, so the pattern can't form. Regression test:
rlx-coreml/tests/coreml_transpose_reduce.rs. Its bias-gradient cases crash
without the pass, and two breakages of the rewrite are each caught. -
rlx-coreml: a MIL segment could declare an input it never used. The hybrid
plan can hand a segment anInputwhose readers all live in other segments,
and CoreML refuses to load that model ("Error in declaring input d_output with
error -1").build_mil_subgraphnow leaves such an input out, unless it's the
segment's only input: a model with no inputs makes CoreML's E5 runtime throw
and silently fall back to another engine. With both fixes, CLEF's three
training stages run on CoreML at torch-gradient parity (≤2e-5). -
rlx-vulkan:
StopGradientoutputs were never written. The lowering treated
StopGradientas a no-op view, likeReshape. But the memory planner
(rlx_compile::memory::pure_view_offset) aliases onlyReshape, same-dtype
Castand axis-0Narrow, soStopGradientowns a slot, and every reader got
stale bytes. It now copies when the slots differ. Found porting CLEF: the VQ
codebook loss‖sg(z) − q‖²read as exactly 0, and real-weight T5 overflowed
to inf, because the softmax max-shiftx − sg(max x)subtracted 0. rlx-cuda and
rlx-rocm aliasStopGradientin their own arenas and were not affected.
Regression test:rlx-vulkan/tests/stop_gradient_slot.rs, which fails on the
old lowering (reads 0.0 for 9.0). -
rlx-wgpu: LayerNorm backward was wrong on most transformer shapes. The
kernel had two defects. First, the workgroup reduction is called back-to-back
with no barrier after the read, so lane 0's next write raced the other lanes'
read: a 64×64 input failed while 65×64 passed. Second,dgammaonly covered
the first 64 columns, so any hidden size above 64 got wrong gamma gradients
for column 64 onward.ada_layer_norm_backwardhad the same reduction race.
Found porting CLEF, whose Stage-I/II gradients were off by up to 8 % on wgpu
only. Regression test:tests/layer_norm_backward_parity.rs. -
rlx-wgpu:
StopGradientis now lowered, as the forward identity it is
(Metal already did this).supported_opsadvertised the op, so any graph
containing one was accepted and then panicked inlower.rs. -
rlx-js: a resident trainer loading a checkpoint aborted the host.
Trainer.loadsize-checked each parameter against the host path's weight
copy, which the resident (on-device AdamW) path leaves empty, so the index
panicked outside any guard; and the weights it would have restored were
rebuilt from that same empty copy. A host checkpoint now resumes on the
resident path (a_checkpoint_resumes_on_the_resident_path, Metal). -
pyrlx:rlx.load_rlxp(as documented) and the other RLXP / weight
loaders raisedAttributeError— compiled into the extension but never
re-exported from the package.
Performance
-
Graph compile no longer dominates small training runs (rlx-compile, rlx-runtime, rlx-cpu).
A CPU profile of exgb's 16-cell test sweep (small EEG decoders, a fresh compile per fold)
had 24.6 % of busy time in graph compilation. Three costs, none of which touches
arithmetic (the sweep's results are bit-identical before and after):-
CpuExecutable::restore_arena_baselinerebuilt the persistent byte ranges (a scan of
every node plus a sort) at the start of every run, so every training step. They follow
from the compiled layout, so they are now computed once and recomputed only when
arena_handleschanges. Testarena_keep_rangesfails if that invalidation is dropped. -
is_elidable_matmul_transposescanned the whole graph for each 2-D transpose, which is
quadratic in a training graph with one per weight. The planner and the CPU thunk compiler
now ask aReaderIndexbuilt in one pass.the_reader_index_agrees_with_the_scan
compares it with the scan on every node. -
The planner's placement loop sorted the time-overlapping buffers for every buffer it
placed.placedis now kept sorted by offset, giving the same sequence without the sort.
plan_sweep_matches_candidate_scan_with_long_lived_branchesand
a_gap_filled_below_an_earlier_buffer_is_respectedcheck every offset against the cubic
candidate scan, planned with reuse on. The earlier test planned with every buffer pinned
to the end, so nothing was reused and no ordering bug could show. -
FusionReport::scan, built on every compile even with no report requested, called
Graph::users(a whole-graph scan) for every matmul. It now uses a users index built in
one pass.the_users_index_matches_graph_userschecks it node for node.
After: compile 8.2 % of busy time (the planner sorts, the transpose check and the
per-step range rebuild are gone from the profile). Wall time on the shared, loaded
machine was too noisy to state a speedup. -
-
Faster conv im2col gathers, bit-identical (rlx-cpu). The forward gather (
im2col,
im2col_par, now sharing onefill_col_row) zeroes each output row's padding and copies
its in-bounds range, one slice at stride 1, instead of a bounds test per element: 2–20×
faster on EEGNet/ShallowConvNet shapes, slower only on toy rows a few values wide. The
weight-gradient gather (im2col_rows_layout) does the same for kernels 16 taps wide or
more (2–4.5× faster on temporal convs) and keeps its original per-element loop for
narrower ones. Routing every kernel through the copy was 2–3× slower on 1-wide kernels
(EEGNet's spatial conv, 1×1), where each copy is a library call for one value. That was a
first version, caught when the end-to-end profile showed the time moving intomemmove.
The buffers are identical, so every GEMM sees the same input. Tests compare both gathers
with the original loops bit for bit over more than 500 and 600 stride, padding, dilation
and kernel shapes; an off-by-one in either range fails them.gather_timing/
forward_gather_timing(ignored) time them against the originals. 1-wide kernels (EEGNet's
spatial conv, 1×1) then got their own branch in the weight-gradient gather: the
kernel rows' input offsets are worked out once per output row and the single input
column is checked once per position. About 2× faster on the spatial conv, 0.5–0.8× the old
time on 1×1 in 5 of 6 timings. Same buffer:rows_layout_matches_the_original_loopcovers
it, and an off-by-one in its column fails it. -
col2im(conv input gradient) 6–9× faster, bit-identical (rlx-cpu). The scatter-add
ran a bounds test per position; the in-bounds outputs of each row are one range that
depends on the kernel column alone (wo_range, now shared with the forward gather). At
stride 1 it adds them as one slice. Every input element receives the same additions in
the same order.col2im_matches_the_per_element_loopcompares it with the original over
more than 600 shapes, accumulating into a non-zero gradient, and an off-by-one in the
slice offset fails it. Timed on EEGNet/ShallowConvNet shapes inforward_gather_timing:
0.11–0.18× the old time, and 0.5–0.66× on rows a few values wide. -
Reductions over a non-last axis 12–14× faster, bit-identical (rlx-cpu).
Thunk::Reduce
reduced each output on its own, reading its strip with strideinner, which is a cache
miss per element when the reduced axis isn't last: batch means, gradients summed over the
batch. The kernel is nowreduce_into: it accumulates whole contiguous rows ofinner
outputs at a time, each output still seeing the same operations in the same order (f64
for Sum/Mean). Last-axis reductions (inner == 1) keep a scalar loop and their speed
(0.97–1.02×).reduce_into_matches_the_strided_loopcompares it with the original kernel
bit for bit for all five ops, serial and parallel, over shapes whose chunks cross outer
boundaries, with NaN, ±0 and ±1e17 against 1.0 (so a sum in any other order lands on
different bits, even in f64). Reversing the accumulation order fails it. Timed in
reduce_timing(ignored). -
Conv bias broadcasts take the per-channel fill (rlx-cpu).
Thunk::Transpose's
per-channel fast path ([C]to[N,C,H,W]) required a zero stride on every axis but the
channel's. A bias reshaped[1,C,1,1]and expanded to[N,C,1,W]carries stride 1 on its
size-1 height axis, so every EEG temporal conv's bias fell through to the per-element
index walk. A per-thunk profile (RLX_PROFILE_THUNKS) of EEGNet + ShallowConvNet put
Transpose at 22.9 % of thunk time, and a shape trace put 88 % of that on this one
pattern. Size-1 axes now count as unused, whatever their stride. Same values (a copy).
Testexpand_channel_broadcastchecks the broadcast against the definition, bit for bit,
over the traced shapes; corrupting the fast path's channel index fails it. -
Binary ops: parallel same-shape NEON, and a held-scalar path (rlx-cpu,
Thunk::BinaryFull), bit-identical. A shape trace of EEGNet + ShallowConvNet found the
same-shape NEON Add/Mul loop running on one thread for 2.4 M-element tensors (538 µs a
call), andx op scalargoing through the per-element broadcast index walk at 0.6–0.9
elements/ns. The NEON loop now splits a large tensor across threads, each element still
getting one IEEE add or multiply. A 1-element operand on either side is held in a plain
loop. The three f32 loops share one op table (f32_op!) instead of three copies of the
14-op match. Testbinary_elementwise_pathschecks Add/Sub/Mul/Div/Max/Min, same-shape
and scalar on either side, at 7 / 1000 / 2.4 M elements, against per-element arithmetic
bit for bit. A wrong chunk offset or scalar operand fails it. -
Metal: BF16-weight decode matmuls read the weight in-kernel. A
Sgemm { b_bf16 }with M ≤ 4 used to unpack the whole bf16 weight into an
f32 scratch on every call and run the f32 GEMM — 5 bytes moved per weight,
3× slower than storing f32. Newgemv_bf16w_splitk8(8 columns per thread,
16-byte loads, 16-way K split;_n64variant with a 64-column tile and
64-way split for narrow N),gemv_bf16w_splitk(even N) and
sgemm_bf16w_small_m(M 2–4). Fish S2-Pro decode step on an M4 Pro:
f32 312 ms → unpacked bf16 961 ms → in-kernel bf16 ~110 ms, at 9 GB instead
of 25. Bit-exact widening;RLX_METAL_BF16W_DISABLE=1restores the unpack
path. Pinned by rlx-models'half_weights_match_f32_math_at_decode_shapes. -
Metal: contiguous mid-axis concat. With one outer row (a KV cache plus
its new rows) each segment is a single block; it is now copied asfloat4s
(copy_f32x4) instead of one element per thread with three div/mods. Fish
S2-Pro at 4096 cached rows, where the 72 per-step concats took 76 ms of a
265 ms step: 284 → 217 ms per step (best of 4 batches; shared machine). -
samplers::TopPsorts only a growing top prefix (64, 256, 1024, …) of
the probabilities until it reachesp, instead of every finite one; the
prefix is in exactly the full sort's order, so the kept set is identical
(top_p_prefix_search_matches_a_full_sort).TopP,TypicalPand
sorted_descsort withtotal_cmp+ index ties (same order as before, no
panic on NaN). Throughrlx_qwen3::sample_token_atat temperature 0.7 /
top-p 0.95 on a 151,936-entry row: 2.7 ms → 1.0 ms per token (Apple
M-series, release).