Cap onnxruntime intra-op threads on Apple Silicon to stop the embedding CPU peg#124
Open
scottyallen wants to merge 1 commit into
Open
Cap onnxruntime intra-op threads on Apple Silicon to stop the embedding CPU peg#124scottyallen wants to merge 1 commit into
scottyallen wants to merge 1 commit into
Conversation
… 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
marked this pull request as ready for review
July 12, 2026 18:17
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.
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: 2andinterOpNumThreads: 1, applied only ondarwin && arm64.pipeline()call passes nosession_optionsat all, so Linux/Intel/CI are byte-for-byte unchanged.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
q8CPU execution provider, so embeddings are bit-identical to the uncapped output — no re-index is triggered.Measurements (M2 Max,
bge-small-en-v1.5q8, batch=1, N=200)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
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.The thread cap is the only option that reduces CPU with zero behavioral change to the embeddings.
Tests
resolveIntraOpThreads()covering positive override,0, non-numeric, and the platform default.🤖 Generated with Claude Code