Skip to content

Micro-batch local embeddings into one batched MLX forward#1917

Open
mimeding wants to merge 3 commits into
osaurus-ai:mainfrom
mimeding:mlx-perf/embedding-microbatch
Open

Micro-batch local embeddings into one batched MLX forward#1917
mimeding wants to merge 3 commits into
osaurus-ai:mainfrom
mimeding:mlx-perf/embedding-microbatch

Conversation

@mimeding

@mimeding mimeding commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

What

Micro-batches the local embedding path into genuinely batched MLX forwards:

  • EmbeddingBatcher (actor): the first request in an idle period opens a 25 ms window (later arrivals never extend it, so a lone caller's added latency is bounded by one window); up to 16 requests coalesce, with immediate flush when full; per-batch errors propagate to every waiter; cancelling one waiter removes only that caller. Clock and window are injectable for tests.
  • A true [N, L] batched forward in VMLXModel2VecEmbedder: padded gather → masked mean → row L2-normalize → one eval per batch. This was necessary because vmlx-swift's embed(texts:) is secretly a sequential loop (texts.map(embedOne) — one gather+eval per text), so a coalescing front alone would have been a fake batch.
  • All four VecturaKit call sites (memory/skill/tool/method search) pick up batching through the shared embedder with zero call-site changes; /v1/embeddings and the plugin embed(texts:) paths are pass-through.
  • MetalGate discipline preserved and improved: one coalesced batch acquires the embedding gate once, instead of N sequential acquisitions each potentially queueing behind active generation.

Why

Embeddings are the highest-frequency small inference in osaurus (memory search + distillation), and they serialize today — both at the gate and in vmlx's per-text loop. Batched execution amortizes the gate acquisition, kernel launches, and eval/asArray round-trips; the biggest practical win is under generation load, where each embedding previously re-queued behind the generation gate individually.

Measurement

  • Reference parity against vmlx's original pipeline (the implementation this replaces), on the real potion-base-4M bundle: max delta 1.49e-08 on the longest padded text, exactly 0.0 on the others including the empty string; unit norms verified. The reference dependency (MLXEmbedders) is test-target-only.
  • One intentional convention difference, asserted explicitly on both sides so silent drift fails the test: empty input array returns [] here vs vmlx throwing emptyBatch.
  • 9/9 batcher tests (batched==sequential via counting mock; N concurrent callers → exactly 1 backend call; lone-caller latency bound; error propagation; cancellation isolation; full-batch early flush) + 106 memory-suite regression tests green; build + swiftlint clean.
  • The real-model parity test is opt-in (OSAURUS_EMBEDDING_MODEL_DIR) because MLX hard-aborts the test process when its metallib isn't locatable under plain swift test — the same reason no existing test runs MLX compute.

Risk & rollback

Worst case for a lone embedding call is +25 ms (documented; negligible against gate-contention waits it replaces). The batched math is validated to 1e-8 against the prior implementation. A follow-up worth doing upstream: vectorize Model2VecStaticEmbeddingPipeline.embed(texts:) in vmlx-swift so the ~40 mirrored loader lines here can shrink back to a coalescing front. Revert = two commits.

🤖 Generated with Claude Code

mimeding and others added 3 commits July 7, 2026 11:51
Memory search and distillation fan out many concurrent single-text
embeds (VecturaKit embeds each search query via embed(text:)), and each
one serialized through MetalGate as its own MLX forward. Two changes
make those requests genuinely batch:

- EmbeddingBatcher: an actor that coalesces single-text requests
  arriving within a 25 ms window (first request opens the window; later
  arrivals never extend it, so a lone caller's added latency is bounded
  by one window) into one backend call, capped at 16 per batch with an
  immediate flush when full. Errors propagate to every waiter;
  cancelling one waiter resumes only that caller and leaves the batch
  running. The window duration and clock are injectable so tests never
  depend on wall-clock time.

- VMLXModel2VecEmbedder now runs a real batched forward: vmlx-swift's
  Model2VecStaticEmbeddingPipeline.embed(texts:) just loops
  embedOne per text, so coalescing through it would have changed
  nothing. The embedder loads the same bundle (embeddings tensor,
  normalize flag, tokenizer) and computes a padded [N, L] gather +
  masked mean + row L2-normalize with a single eval for the whole
  batch.

The batching layer sits above MetalGate: the batcher's backend is
MetalSafeEmbedder.embed(texts:), so one coalesced batch acquires the
gate exactly once, and gate semantics are unchanged.
EmbeddingService.sharedEmbedder is now a CoalescingEmbedder that routes
single-text embeds through the batcher and passes caller-assembled
batches straight through (they are already one forward);
directEmbedder keeps the old non-coalescing path callable.

Batch parity against the real potion-base-4M bundle (padding and
masking must not change any vector, max delta < 1e-4, unit norms) is
covered by an opt-in test: MLX aborts the test process when its
metallib is not locatable, so it only runs when
OSAURUS_EMBEDDING_MODEL_DIR is set explicitly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The parity test previously compared the batched forward against its
own embed(text:) path, which routes through embed(texts:) — both
sides ran the new implementation, so only padding/masking invariance
was proven. A systematic semantic deviation from vmlx's Model2Vec
pipeline (tokenizer handling, unknown-token filtering, normalize
behavior) would have passed unnoticed.

Compare each text's batched vector against vmlx-swift's original
sequential Model2VecStaticEmbeddingPipeline loaded from the same
bundle (observed max delta on potion-base-4M: 1.5e-08). The one
intentional convention difference — an empty input array returns []
here but throws emptyBatch in vmlx — is asserted on both sides so a
silent change in either implementation fails the test. MLXEmbedders
joins the test target's dependencies for the reference import.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adversarial review finding on the embedding micro-batch path: the
CoalescingEmbedder hands caller-assembled arrays straight to
VMLXModel2VecEmbedder.embed(texts:), whose padded gather materializes
[N, L_max, D] floats with N client-controlled via /v1/embeddings — an
unbounded request meant an unbounded GPU allocation.

embed(texts:) now processes the input in chunks of at most
EmbeddingBatcher.defaultMaxBatchSize (16 — the same bound the
micro-batcher keeps: ~4 MB per chunk at 512 tokens x 128 dims),
concatenating results in order. The chunks run sequentially inside the
CALLER's single MetalGate acquisition — MetalSafeEmbedder.embed(texts:)
wraps the whole method, so chunking never releases/re-acquires the gate
mid-batch. The per-chunk forward is unchanged (extracted to
embedChunk); chunk boundary math lives in the pure static
forwardChunkRanges so it is unit-testable without loading MLX.

Tests: chunked == unchunked equality for 40 texts at the batcher level
(every backend batch capped at 16, vectors match the direct reference),
a real-model 40-text chunk-parity case in the env-gated suite (chunk
boundaries invisible in output), forwardChunkRanges boundary/edge
cases, and the previously unexercised mismatchedResultCount path via a
mock backend returning the wrong vector count.

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

mimeding commented Jul 7, 2026

Copy link
Copy Markdown
Contributor Author

Adversarial-review fix pushed (06f84bc): the direct embed(texts:) pass-through now chunks client-controlled arrays to ≤16 per forward inside the single MetalGate acquisition — previously /v1/embeddings could materialize an unbounded [N, L_max, D] padded tensor (~8.6 GB for a hostile batch). Chunk-parity and mismatched-count tests added.

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.

1 participant