Cut idle embedding memory without changing nomic quality - #8
TorstenDittmann wants to merge 11 commits into
Conversation
Keep full-precision NomicEmbedTextV15. Default pool size to 1, lazy-load ONNX sessions so /health does not pin weights, unload after idle, cap ORT intra-op threads, and use jemalloc so dropped sessions return RSS. Co-authored-by: Torsten Dittmann <TorstenDittmann@users.noreply.github.com>
The Rust global allocator alone did not capture ONNX Runtime's C++ heap. Override libc malloc on supported platforms so dropped sessions can be returned to the OS after idle unload. Co-authored-by: Torsten Dittmann <TorstenDittmann@users.noreply.github.com>
|
tikv-jemalloc-sys runs configure then make. The slim image had g++ but not make, so the image build failed with ENOENT after jemalloc configured. Co-authored-by: Torsten Dittmann <TorstenDittmann@users.noreply.github.com>
Idle unload still drops every ONNX session. Desired pool size is CPU count again (capped by available memory at load) so concurrent /embed requests are not serialized on a single mutex. In-flight embeds block unload so a slow request cannot reload a second full pool. Co-authored-by: Torsten Dittmann <TorstenDittmann@users.noreply.github.com>
Keep EMBEDDING_POOL_SIZE at 1 unless operators opt in. Cap extra sessions from RAM and fall back to one session when the RSS delta is unusable. Default intra-op threads to the CPU count so the single session can still use the machine. Add ignored MiniLM unload lifecycle tests and a Nomic 768-d cosine ranking test. Co-authored-by: Torsten Dittmann <TorstenDittmann@users.noreply.github.com>
When EMBEDDING_POOL_SIZE is greater than one, the first ONNX session kept the full CPU-count thread setting while extras used nproc / pool_size. Rebuild the first session when the per-session count changes so concurrent embeds cannot oversubscribe the host. Co-authored-by: Torsten Dittmann <TorstenDittmann@users.noreply.github.com>
When the pool shrinks and intra-op threads change, drop the probe session only after initializing its replacement, then run the same warmup inference so preload cannot report ready on a session that has never executed. Co-authored-by: Torsten Dittmann <TorstenDittmann@users.noreply.github.com>
Public env parsing already checks pool_size=1 and intra_threads=nproc. Idle unload and Nomic quality are covered by ignored e2e, not by reasserting private helper return values. Co-authored-by: Torsten Dittmann <TorstenDittmann@users.noreply.github.com>
ReviewThe memory work is sound and the headline numbers reproduce on my box: boot RSS 13 MiB with The problem is that moving model loading onto the request path put a large amount of blocking work inside async code, and it took the container's only liveness signal with it. I'd fix those two things before merging. 1. Model loading blocks tokio worker threads
// src/embedding.rs
fn acquire_instance(&self, loaded: &LoadedModel) -> Result<Arc<Mutex<TextEmbedding>>, String> {
for _ in 0..2 {
self.ensure_loaded(loaded)?; // <- synchronous download + ORT init + warmupNothing goes through
One worker is inside the load; the other three park on the Wrapping
2. Fail-fast is gone, and
|
Load ONNX sessions with block_in_place behind a tokio mutex so cold /embed cannot stall /health. Surface a failed load as 503 from /health and retry at most every 30s. Idle unload runs on a blocking thread; last-access uses a monotonic clock; the RAM cap is reused across reloads; jemalloc is the crate allocator so tests match the binary. Co-authored-by: Torsten Dittmann <TorstenDittmann@users.noreply.github.com>
|
Thanks — both merge blockers are in 05a38d7.
Also done from the rest of the review: monotonic last-access (ms), reuse the RAM-capped pool size across reloads, jemalloc on the library crate so tests match the binary, drop the redundant Not in this commit: concurrent p50/p99 vs |
/health 503 now lasts only for the 30s retry window so a liveness probe can recover; the next /embed retries the load. Model init uses spawn_blocking instead of block_in_place so current-thread runtimes do not panic. Co-authored-by: Torsten Dittmann <TorstenDittmann@users.noreply.github.com>
Acquire the load mutex inside spawn_blocking so a cancelled cold /embed cannot drop ownership while finish_load is still running. Co-authored-by: Torsten Dittmann <TorstenDittmann@users.noreply.github.com>
Fixes #7
The embedding process kept full-precision Nomic ONNX sessions resident from boot. Appwrite self-hosted does not set
EMBEDDING_POOL_SIZE. Idle RSS on this host was ~870 MiB for a single unused nomic session.Changes
/embed;/healthno longer pins weights. Load runs inspawn_blockingwith astd::sync::Mutexheld inside that blocking task, so a cancelled cold/embedcannot start a second pool, and a current-thread runtime gets anEmbedErrorinstead of panicking.EMBEDDING_IDLE_UNLOAD_SECS(default 300;0disables), then reload the sameNomicEmbedTextV15checkpoint from cache. In-flight requests block unload so a slow embed cannot create a second pool. Unload runs on a blocking thread.EMBEDDING_POOL_SIZE>1for parallel HTTP throughput. Extra sessions are RAM-capped on first load and that cap is reused on later reloads.nproc / pool_sizethreads.GET /healthis liveness (200while unused). After a failed model load it returns503for 30 seconds, then200again so a probe can recover; the next/embedretries. Failed loads retry at most every 30 seconds.{"model":"bge-small"}is 400 unless you add it toEMBEDDING_MODELS. With lazy load that no longer costs idle RSS, only image size.Quality
nomic/nomic-embed-textstill map to full-precisionNomicEmbedTextV15(768-d). No quantized default. Ignored e2e covers MiniLM embed → idle unload → embed (including concurrent unload) and Nomic 768-d cosine ranking.RSS on this host (4 CPUs, ~16 GiB, full-precision nomic)
master/health(model never used)/embedMiniLM on
masterwas 138 MiB at pool=1 vs 435 MiB at nproc=4 while loaded. That multiplier is now opt-in viaEMBEDDING_POOL_SIZE.