Skip to content

Make ONNX dependency optional#76

Merged
dacorvo merged 16 commits into
mainfrom
refactor/inference-backend
Jul 15, 2026
Merged

Make ONNX dependency optional#76
dacorvo merged 16 commits into
mainfrom
refactor/inference-backend

Conversation

@dacorvo

@dacorvo dacorvo commented Jul 15, 2026

Copy link
Copy Markdown
Collaborator

funes' embedder (bge-small-en-v1.5) and reranker (bge-reranker-base) ran exclusively on ONNX Runtime via fastembed. That one dependency decided two things: ort's prebuilt binaries require glibc ≥ 2.38, locking release binaries out of Ubuntu 22.04, and on Apple Silicon its MLAS kernels run on NEON — well below what the machine's AMX units can do.

A second inference backend

embed/rerank sit behind Embedder/Reranker traits (src/inference.rs), built through factories so no call site names a concrete ML stack. The blas backend (src/blas.rs) is a hand-written forward for both models: the encoder, tokenization, and trait impls are shared cross-platform source, and the only OS-specific code is a four-function seam — sgemm, vexp, vmax, vsum — wired to Accelerate (cblas on AMX, vForce, vDSP) on macOS and to faer's pure-Rust GEMM plus runtime-dispatched vectorized kernels on Linux. Release binaries stay baseline x86-64/aarch64 and pick AVX2/AVX-512/NEON at runtime. Weights load from safetensors, fetched into the standard HF cache on first use.

blas is the default feature; the ONNX backend remains one flag away:

cargo build --release --no-default-features --features onnx   # ONNX backend instead
cargo run --release --features onnx --example bench_backends  # A/B both backends

Building with both compiles ONNX in as the reference for bench_backends; funes itself runs blas. A build with neither backend is a compile error.

Correctness and speed

bench_backends runs two workloads — short docs (per-call overheads) and 30 docs at the 512-token cap (recall's rerank worst case, GEMM-bound):

platform rerank embed agreement
macOS (Apple Silicon) 2.3× faster than ort 1.9× faster max Δ 2.1e-5 / cosine 0.999999
Linux (64-core EPYC) 0.86× (2.55 s vs 2.19 s at 30×512) ~parity (1.16 vs 1.13 s) max Δ 4e-6 / cosine 0.999999

On Linux the residual vs MLAS is invisible end-to-end: funes recall against a real store measures identical (~6.2 s) with either backend — recall time is search-dominated and real chunks sit well below the token cap.

Releases run on Ubuntu 22.04

Linux release builds move into an ubuntu:22.04 container: glibc compatibility is one-way, and even without ort a 24.04-built binary binds __isoc23_*@GLIBC_2.38 from the C dependencies compiled during the build. A post-build check fails the job if any symbol rises above GLIBC_2.35. Replayed locally in docker: floor 2.35, no onnxruntime in the binary, and a real-store recall runs on 22.04.

CI

Pull requests validate what default builds ship — fmt, clippy, unit and integration tests on the blas backend — and additionally clippy the onnx feature set, so a toolchain bump can't break the opt-in backend silently. Building the onnx variant and running its real-model integration test happen only on pushes to main. Model caches split accordingly: BAAI safetensors for the PR jobs, fastembed's cache for the merge-only job; the release cache keys carry the container tag so images never share compiled C objects.

🤖 Generated with Claude Code

dacorvo and others added 15 commits July 15, 2026 06:54
Introduce Embedder/Reranker traits and route recall, index, scrub, and the
built-in hello fallback through them instead of calling fastembed directly. The
sole backend is OnnxEmbedder/OnnxReranker wrapping fastembed/ort, so behavior is
unchanged. This is the seam a hand-written Accelerate backend (macOS) can slot
into behind the same traits, selected by a build feature, with no call-site
changes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A from-scratch forward (src/blas.rs) for both models funes uses — bge-reranker-base
and bge-small-en-v1.5 — as an alternative to fastembed/ort, behind the off-by-default
`blas` feature. Implements the Embedder/Reranker traits; a single point in inference.rs
(the `Default*` aliases + the `embedder()`/`reranker()` factories) selects the backend at
build time, so `--features blas` switches recall/index/scrub with no per-call-site cfg.

All matmuls go through a platform seam (sgemm/vexp): macOS wires it to Accelerate (cblas_sgemm
on the AMX units + vForce vvexpf), linked only for macOS + feature via build.rs; Linux is a
marked TODO. The encoder, tokenization (tokenizers + the model's tokenizer.json), and weight
loading (safetensors) are shared, cross-platform source. Bit-faithful to ONNX (embed cosine
0.999999, rerank diff 1.5e-5) and faster on Apple Silicon (Accelerate/AMX vs MLAS/NEON).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
An A/B harness over the Embedder/Reranker traits: per-backend latency plus agreement against
the ONNX reference (embed cosine, rerank score delta). Backend-agnostic — it compares whatever
backends are compiled in, so the macOS Accelerate backend and a future Linux backend plug in the
same way.

  cargo run --release --features blas --example bench_backends

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
faer's pure-Rust GEMM won the provider bench on EPYC at the model's shapes
(~2x the packaged OpenBLAS on the big batched linears, ~10x matrixmultiply)
and, unlike a BLAS with its own thread pool, stays fast when called
sequentially from the attention workers. Both seam halves runtime-dispatch
on the host CPU, so release binaries keep the baseline x86-64/aarch64
target and need no native library or link.

The exp is the classic hi/lo-split range reduction with a degree-6
polynomial: max relative error 2.5e-7, about 2x scalar libm, vectorized by
LLVM via multiversion clones.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
linear() allocated its output Vec on every call — ~500 MB of fresh pages
per encoder layer. glibc malloc mmaps blocks that size and munmaps them on
free, so every layer re-faulted its pages inside the GEMMs, tripling the
forward's wall time (macOS malloc recycles large blocks, hiding this).
Callers now own the buffers: encode() allocates the seven layer buffers
once and reuses them across layers. The bias add also moves to par_rows,
off the single-threaded path.

30 docs x ~500 tokens on a 64-core EPYC: rerank 8.5 s -> 3.2 s, embed
3.9 s -> 1.5 s (onnx reference: 2.2 s / 1.1 s).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The eight forward buffers (~500 MB for a 30x512 rerank) were allocated per
encode() call; the allocator returns blocks that large to the OS on free, so
even warm calls re-faulted every page inside the GEMMs. The buffers now live
on the model structs and grow to the high-water mark, like ort's arena. Costs
resident memory between calls on a long-lived process.

30x~500tok on 64-core EPYC, NT=16: rerank 3.18 s -> 2.73 s, embed 1.52 -> 1.37
(onnx: 2.2 / 1.15).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Attention split its sequences across nthreads() workers — NT defaults to 8,
so a 30-pair rerank on a big machine left most cores idle in its second
hottest section, and the embedding gather ran on one thread. Per-sequence
compute now sizes itself as min(sequences, cores); NT keeps governing only
the memory-bound helpers, where more threads stopped paying long before.

30x~500tok on 64-core EPYC: rerank 2.73 s -> 2.55 s, embed 1.37 -> 1.18 at
default NT (onnx: 2.2 / 1.15).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Every par_* call spawned nthreads() scoped threads regardless of size, so
small buffers — the classifier head, single-query embeds, short batches —
paid ~100 us of spawn/join per pass. At or below 64k elements the pass now
runs inline.

16 short docs: rerank 620 ms -> 489 ms, embed 433 ms -> 321 ms (onnx:
272 / 139). Long shapes unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
hf_snapshot only read an existing local HF cache and told the user to go run
huggingface-cli — a dead end for a backend meant to be the default (the ONNX
backend auto-downloads). Missing snapshots are now fetched with the hf-hub
blocking client into the standard cache. The download runs on a dedicated
thread because the blocking client drives its own runtime: block_on would
panic on an MCP-server thread that is already inside tokio.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
default = ["blas"]; fastembed/ort moves behind a new `onnx` feature. Default
binaries no longer link ONNX Runtime — whose glibc 2.38 prebuilts were what
kept releases off Ubuntu 22.04 — and the ONNX types stay available as the
reference for bench_backends (build with --features onnx to A/B; a build
with neither backend is a compile error). When both are compiled in, funes
runs BLAS.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
PR jobs now exercise what default builds ship — the blas backend — with the
BAAI safetensors cached in place of fastembed's ONNX files (funes downloads
them on first use). The onnx variant stays covered without spending PR
runners on a backend PRs don't change: its clippy pass runs in the PR lint
job (a rustfmt or clippy bump can't break it silently; fmt itself is
feature-independent), while building it and running its real-model
integration test happen in a separate job on pushes to main only.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… build

With ONNX Runtime out of default builds, nothing needs glibc 2.38 anymore —
but building on the 24.04 runner image would still bind __isoc23_*@2.38 from
the C deps compiled during the build. Both Linux targets now build inside
ubuntu:22.04 (the aarch64 cross sysroot pins the same floor) and a post-build
check fails the job if any symbol rises above GLIBC_2.35. Replayed in a local
22.04 container: floor 2.35, no onnxruntime in the binary, real recall runs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The onnx feature existed only in Cargo.toml comments — nothing a builder
reads said the variant exists or how to select it. README's build-from-source
section now names the default backend (and the glibc 2.35 floor it buys) and
the opt-in onnx build; AGENTS.md adds the second clippy pass CI enforces and
the A/B bench command.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A batch of short docs measures per-call overheads (tokenization, thread
spawns); 30 docs at the 512-token truncation cap — recall's rerank worst
case — measures GEMM throughput and memory behavior. Optimizations routinely
move one and not the other, so the A/B runs both and reports one row per
backend per workload.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The max and sum passes in softmax_rows were strict-order folds, which LLVM
cannot vectorize (float reassociation) — ~2.3G scalar ops per 30x512 rerank.
The seam grows vmax/vsum: vDSP_maxv/vDSP_sve on macOS, lane-accumulator
loops under multiversion on Linux. Fixed lane count keeps results
deterministic; the lane-wise sum also lands closer to ort's own vectorized
reduction (rerank agreement 1.1e-5 -> 4e-6).

On a 64-core EPYC the attention workers already hid most of the scalar cost
(30x512: rerank ~2.59 s -> ~2.55 s, embed ~1.19 -> ~1.16); the fewer cores a
machine has, the larger the wall share this removes. The macOS half needs a
parity + speed run on a Mac.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR makes the ONNX/fastembed inference stack optional by introducing an Embedder/Reranker abstraction and a new default “blas” backend (Accelerate on macOS, pure Rust on Linux), while keeping an opt-in ONNX backend via a Cargo feature.

Changes:

  • Introduces src/inference.rs traits + factories and migrates call sites to use them instead of fastembed types directly.
  • Adds a new default blas inference backend (src/blas.rs) and makes fastembed optional behind the onnx feature.
  • Updates docs/CI/release workflows to reflect backend selection and ensure build + glibc-floor constraints remain enforced.

Reviewed changes

Copilot reviewed 14 out of 15 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
src/scrub.rs Switches re-embedding during scrubbing to the backend-agnostic Embedder trait.
src/recall.rs Uses Embedder/Reranker trait objects in the cached model singleton and rerank flow.
src/lib.rs Exposes inference module and gates blas module behind the blas feature.
src/inference.rs Defines the backend interface and selects default backend via feature-based aliases.
src/index.rs Makes embedding batching backend-agnostic and constructs the embedder via inference::embedder().
src/hello.rs Uses trait-based embedding for the built-in guide dataset.
src/blas.rs Adds the new default BLAS-based embedder/reranker implementation and model loading.
README.md Documents default backend and how to build/run with the ONNX backend.
Cargo.toml Makes fastembed optional; adds blas feature + optional deps and sets blas as default.
Cargo.lock Updates lockfile for newly introduced dependencies.
build.rs Links Accelerate only when blas is enabled on macOS.
benchmark/bench_backends.rs Adds an example to compare backends for latency and output agreement.
AGENTS.md Updates contributor guidance to lint both backend variants and benchmark them.
.github/workflows/release.yml Builds inside ubuntu:22.04 container and adds a glibc symbol-floor check.
.github/workflows/ci.yml Lints both feature variants and adds a merge-only job to integration-test the ONNX backend.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/blas.rs Outdated
Copilot on #76: the doc claimed the newest snapshot; read_dir order is
filesystem-dependent and snapshot names are commit SHAs, so there is no
newest to pick. Any complete copy of these frozen model repos is
interchangeable — the comment now says exactly that.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@dacorvo
dacorvo merged commit d177a07 into main Jul 15, 2026
8 checks passed
@dacorvo
dacorvo deleted the refactor/inference-backend branch July 15, 2026 11:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants