rlx-bert
BERT encoder graph builder for RLX. Given a BertConfig and a bag of weights, it emits an RLX IR graph for a BERT encoder — embeddings → N transformer layers → hidden_states.
This crate only builds the graph. It has no tokenizer, no weight loader, no CLI, and no inference loop. Loading files, compiling the graph for a device, feeding inputs, running, and pooling all live in consumer crates:
| Consumer | Uses rlx-bert for |
|---|---|
rlx-embed |
sentence-embedding runtime (MiniLM, sentence-transformers) — the main end-to-end path |
rlx-clinicalbert |
clinical-domain encoder + MLM/pooler head folded onto the encoder |
rlx-grounding-dino |
BERT text encoder for the vision-language model |
Public API
use ;
use BertConfig;
use WeightMap;
let cfg = from_file?; // HF config.json (or ::from_gguf)
let mut wm = from_file?; // BF16/F16 auto-converted to F32
// Option A — a lowered IR `Graph` + params, ready to compile:
let = build_bert_graph_sized?;
// Option B — a `BuiltModel` (fold a task head on / customize compile before lowering):
let built = build_bert_built?;
// or the configurable builder directly:
let built = new.with_profile.build?;
# Ok
Dimensions are baked into the graph — batch/seq are concrete, so build again for a different shape. (RLX compiles for static shapes to get better fusion/codegen; consumers keep a small shape→graph cache and recompile on a new shape.)
How it's built
Construction is one thin file (bert.rs) over a declarative flow (flow.rs). BertFlow::build describes the whole encoder with the shared rlx_flow::ModelFlow DSL:
new
.with_profile
.input.input
.input.input
.embed // token lookup → [B,S,H]
.gather_add
.gather_add
.layer_norm // embedding LN
.repeat_bert_layers
.output // → [B, S, H]
.build
Two things are auto-detected from the weight keys at build time:
- Prefix — keys under
bert.(aBertFor…checkpoint) get thebert.prefix; a bare encoder gets"". - QKV style —
attention.self.{query,key,value}⇒BertQkvStyle::Bert(BERT/MiniLM); otherwiseattention.attn.{q,k,v}⇒BertQkvStyle::Mpnet. Either way Q/K/V are fused into one[H, 3H]matrix so attention is a single matmul.
One encoder layer (post-LayerNorm topology)
repeat_bert_layers emits this per layer (defined in rlx-flow/.../blocks/bert_layer.rs):
INPUT [B,S,H]
→ MatMul(qkv_w) + qkv_b → [B,S,3H]
→ Narrow ×3 (split last axis) → Q, K, V each [B,S,H]
→ Attention(Q,K,V, attention_mask, heads, head_dim) → [B,S,H] # QKᵀ/√dₕ, mask, softmax, ·V
→ MatMul(out_w) + out_b
→ Add(INPUT) → LayerNorm(ln1) # residual THEN norm
→ MatMul(int_w) + int_b → GELU → [B,S,intermediate]
→ MatMul(out2_w) + out2_b
→ Add(post-attn) → LayerNorm(ln2) → [B,S,H]
CompileProfile::encoder() = Direct fusion (no KV-cache patterns — a stateless encoder), DCE + constant-folding, F32.
End-to-end (how rlx-embed runs it)
BertConfig::from_file(config.json)
WeightMap::from_file(model.safetensors)
│
build_bert_built(cfg, &mut wm, batch, seq) ← this crate
│
compile: CPU+F32 → compile_built(built, device)
GPU/precision → Session::new(device).compile_with(graph, opts) + set_param(...)
▼
CompiledGraph
│ per request:
tokenize (HF tokenizers) → input_ids / attention_mask / token_type_ids (u32)
→ cast to f32, flatten [B*S]; position_ids = 0..seq
→ compiled.run([input_ids, attention_mask, token_type_ids, position_ids])
→ hidden_states [B, S, H]
→ pool (mean / cls / none) → embeddings [B, H]
Note: every graph input is declared DType::F32 — token ids are passed as floats that feed the embedding Gather.
Build & test
Real-weights parity (downloads sentence-transformers/all-MiniLM-L6-v2, CPU vs TPU): crates/rlx-models/tests/tpu_real_minilm.rs.
License
GPL-3.0-only. © 2026 Eugene Hauptmann, Nataliya Kosmyna.