Make ONNX dependency optional#76
Merged
Merged
Conversation
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>
There was a problem hiding this comment.
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.rstraits + factories and migrates call sites to use them instead offastembedtypes directly. - Adds a new default
blasinference backend (src/blas.rs) and makesfastembedoptional behind theonnxfeature. - 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.
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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/Rerankertraits (src/inference.rs), built through factories so no call site names a concrete ML stack. Theblasbackend (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.blasis the default feature; the ONNX backend remains one flag away: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_backendsruns two workloads — short docs (per-call overheads) and 30 docs at the 512-token cap (recall's rerank worst case, GEMM-bound):On Linux the residual vs MLAS is invisible end-to-end:
funes recallagainst 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.04container: glibc compatibility is one-way, and even without ort a 24.04-built binary binds__isoc23_*@GLIBC_2.38from the C dependencies compiled during the build. A post-build check fails the job if any symbol rises aboveGLIBC_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