Skip to content

Cap onnxruntime intra-op threads on Apple Silicon to stop the embedding CPU peg#124

Open
scottyallen wants to merge 1 commit into
obra:mainfrom
scottyallen:embeddings-intraop-thread-cap
Open

Cap onnxruntime intra-op threads on Apple Silicon to stop the embedding CPU peg#124
scottyallen wants to merge 1 commit into
obra:mainfrom
scottyallen:embeddings-intraop-thread-cap

Conversation

@scottyallen

Copy link
Copy Markdown

Problem

Embedding is done one exchange at a time (batch=1) throughout indexing, sync, and the re-embed migration. At that size onnxruntime-node's default intra-op thread pool fans each tiny int8 matmul across every core. On Apple Silicon this pegs ~5 cores for almost no throughput gain, and it makes a bulk re-embed (e.g. the model migration) hog the whole machine. It's especially visible when several Claude Code sessions/worktrees trigger syncs, or when the machine is otherwise busy and the pool oversubscribes and fights itself.

Fix

Cap the embedding session's thread pools when we know parallelism won't help:

  • intraOpNumThreads: 2 and interOpNumThreads: 1, applied only on darwin && arm64.
  • Gated as a strict no-op elsewhere: when no cap resolves, the pipeline() call passes no session_options at all, so Linux/Intel/CI are byte-for-byte unchanged.
  • Env override EPISODIC_MEMORY_EMBED_THREADS: a positive integer sets the cap; 0 / negative / non-numeric restores onnxruntime's default (off-switch). Parsed defensively.

Same model, same q8 CPU execution provider, so embeddings are bit-identical to the uncapped output — no re-index is triggered.

Measurements (M2 Max, bge-small-en-v1.5 q8, batch=1, N=200)

Config per-item wall cores (cpu/wall) vs stored q8 vectors re-index
Uncapped default (current) 11.3 ms 4.67 baseline -
Capped intra=2 / inter=1 10.6 ms 1.78 cosine 1.000000 (exact) no

Same-or-faster wall time while using ~2.6x fewer cores. The gap widens under system load, because capping stops the pool from oversubscribing.

Alternatives evaluated and rejected

  • CoreML EP (device: 'coreml') on the q8 model: ~9x slower (~105 ms/item), never actually leaves the CPU (still ~5 cores — the int8 graph partitions poorly), and produces different vectors (would force a re-index). Hard no.
  • WebGPU fp32 (dawn): genuinely offloads (~0.78 cores) but only at fp32, whose vectors differ from the stored q8 ones (full re-index) for no wall-clock gain, and it's experimental.

The thread cap is the only option that reduces CPU with zero behavioral change to the embeddings.

Tests

  • New unit test for resolveIntraOpThreads() covering positive override, 0, non-numeric, and the platform default.
  • Full suite green (211/211).

🤖 Generated with Claude Code

… the CPU peg

Indexing/sync/re-embed compute embeddings one exchange at a time (batch=1).
At that size onnxruntime-node's default intra-op thread pool fans each tiny
int8 matmul across every core, pegging ~5 cores on Apple Silicon during a bulk
re-embed for almost no throughput gain. Cap intra-op threads (default 2 on
darwin/arm64) so embedding stops hogging the machine.

Same CPU / q8 execution provider, so embeddings are bit-identical to the
uncapped output (verified cosine 1.000000, 256/256 exact) -> no re-index.
Measured on an M-series Mac: a cap of 2 is as fast or faster than the uncapped
default while using ~1.7 cores instead of ~5, and pulls further ahead when the
machine is busy (uncapped oversubscribes and fights itself).

CoreML and WebGPU were evaluated on this quantized model and rejected: CoreML
device runs ~9x slower and never leaves the CPU (still ~5 cores) and its vectors
differ (cosine 0.998, would force a re-index); WebGPU only offloads with an fp32
model, whose vectors also differ from the stored q8 ones (cosine 0.997, full
re-index) for no wall-clock gain. Thread-capping is the only option that stops
the peg without changing the embeddings.

Override with EPISODIC_MEMORY_EMBED_THREADS (positive int = cap; 0 = onnxruntime
default). Gated to darwin/arm64 so other platforms and CI are unaffected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@scottyallen
scottyallen marked this pull request as ready for review July 12, 2026 18:17
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