perf(local): skip full GC for CPU reranker providers - #3858
Open
Sanderhoff-alt wants to merge 1 commit into
Open
perf(local): skip full GC for CPU reranker providers#3858Sanderhoff-alt wants to merge 1 commit into
Sanderhoff-alt wants to merge 1 commit into
Conversation
Strix Security ReviewWarning This pull request has 1 commit after the last Strix review ( No security issues found. Updated for Reviewed by Strix |
CPU local reranker providers release short-lived tensor and ONNX buffers through normal reference counting, while Python's cyclic collector runs on its threshold schedule. A full process-wide collection after every batch adds avoidable latency to both LocalST and FlashRank CPU inference. Keep full collection and allocator cleanup for CUDA, XPU, and MPS inference, where releasing Python wrappers precedes accelerator cache cleanup. Unknown device types retain the conservative full-collection behavior. Update cleanup tests to verify that CPU inference still trims the heap without calling gc.collect(). Tests: pytest -q hindsight-api-slim/tests/test_local_device.py
Sanderhoff-alt
force-pushed
the
perf/local-reranker-gc
branch
from
August 28, 2026 10:37
c990ed2 to
aa0ca8a
Compare
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.
flowchart LR A[LocalST or FlashRank inference] --> B{device type} B -->|cpu| C[heap trim] B -->|cuda / xpu / mps| D[gc.collect] B -->|unknown| D D --> C C -->|GPU only| E[empty_gpu_cache] C -->|CPU| F[return scores] E --> FDecision
gc.collect()+ heap trimgc.collect()+ heap trimgc.collect()+ heap trim + GPU cache cleanupThis change gates the single explicit
gc.collect()call inrelease_local_inference_memory()ondevice_type == "cpu". BothLocalSTCrossEncoderandFlashRankCrossEncodercall this helper from their post-inferencefinallyblocks, so the policy applies consistently to both CPU providers. The change does not disable Python's automatic cyclic GC, change reference counting, remove heap trimming, or alter model inference semantics.Problem
gc.collect()without a generation argument performs a synchronous full cyclic-GC scan for the entire Python process. Local reranker inference runs in a dedicated worker thread, but the collection is process-wide and adds latency to every completed inference. CPU inference already releases most short-lived tensors, ONNX buffers, and tokenization containers through normal reference counting, while Python's cyclic GC continues to run on its normal allocation thresholds.The remaining heap operation,
malloc_trimon Linux ormalloc_zone_pressure_reliefon macOS, addresses native allocator high-water marks rather than Python object cycles. It is therefore kept separate from the full-GC decision and remains on every CPU reranker call.Benchmark Results
The following fixed-size CPU benchmarks use median wall-clock milliseconds per inference call. Lower is better. Each table uses a cached model and the same three cleanup variants: inference only, full
gc.collect(), and the normal heap-trim path.LocalSTCrossEncoder
gc.collect()The full-GC penalty is approximately 73-74 ms per LocalST call across the tested candidate sizes. Heap trim is within measurement noise of inference-only execution on this macOS environment.
FlashRankCrossEncoder
gc.collect()FlashRank's full-GC penalty is approximately 17-19 ms per call. The absolute penalty is smaller than LocalST because ONNX inference is slower, but it is still a synchronous and unnecessary cost on every CPU request. Heap trim remains close to the control and is retained.
FlashRank RSS stress check
Both variants below kept heap trim enabled and differed only in whether they additionally called full GC. Values are macOS
ru_maxrsshigh-water measurements in MB from separate fresh processes.The high-water mark reaches a stable plateau in both processes; full GC does not provide a measurable RSS advantage for this workload. The tracked-object count remains constant within each process, which does not indicate accumulating Python cycles.
Benchmark Methodology
Environment: Apple Silicon arm64 MacBook Pro, macOS 26.6.2, Python 3.11.14, PyTorch 2.10.0, SentenceTransformers 5.2.0, and ONNX Runtime via FlashRank. LocalST used
cross-encoder/ms-marco-MiniLM-L-6-v2; FlashRank usedms-marco-MiniLM-L-12-v2. Both ran on CPU with batch size 32 and no GPU work. The model artifacts were cached before timing; the FlashRank zip was obtained from a Hugging Face-compatible mirror after the direct Hugging Face endpoint timed out, and download time was excluded.For each provider and candidate size (1, 8, 32, and 128 pairs), the harness used one stable query and deterministic short document strings. Each cleanup variant performed three warmup calls followed by eight measured calls. Timing used
time.perf_counter()around the synchronous provider call, and the reported value is the median of the eight samples.The cleanup variants isolate the cost by replacing the post-inference cleanup callback with: no cleanup (inference-only control),
gc.collect()only, or the normal local cleanup helper (heap trim on CPU). The deployed CPU path corresponds to inference plus heap trim, not the inference-only control.A separate LocalST variable-length workload ran 120 rounds and compared no explicit full GC against full GC after every round. The no-GC run had a 43.63 ms median and 50.13 ms mean; the per-round-GC run had a 125.35 ms median and 133.69 ms mean. RSS after 120 rounds was approximately 829-830 MB in both cases.
The LocalST stress check was extended to 240 rounds. With explicit GC disabled, RSS moved from 554.9 MB at round 0 to 829.6 MB at round 120 and then plateaued at 830.6 MB by round 240. In a separate run with Python automatic GC disabled, RSS moved from 556.0 MB to 831.9 MB, tracked objects changed from 488,397 to 488,465, and a final manual
gc.collect()returned 0. The initial RSS increase is consistent with model warmup and allocator high-water behavior; the later plateau and zero collected objects provide no evidence of an accumulating cyclic-reference leak.Design Logic
The proposed behavior follows the ownership of each cleanup mechanism:
gc.collect()is process-wide even though it is called from a reranker worker thread. Removing it from the CPU path therefore reduces contention for all threads in the API process, not just the worker that issued the call.Scope and Compatibility
This is intentionally a narrow policy change. There are no new configuration variables, no provider API changes, no model changes, and no changes to GPU cleanup behavior.
LocalSTCrossEncoderon CPU and CPU-onlyFlashRankCrossEncoderboth take the optimized path; CUDA, XPU, and opt-in MPS retain full cleanup. CPU embedding behavior was already guarded separately and is unchanged by this patch.Validation
uv run pytest -q hindsight-api-slim/tests/test_local_device.py hindsight-api-slim/tests/test_local_cross_encoder.py./scripts/hooks/lint.shpassed during implementation.The focused tests verify that CPU cleanup still trims the heap without calling
gc.collect(), while GPU cleanup still calls GC, heap trim, and the matching backend cache release.Operational Follow-up
After rollout, monitor reranker wall time/P95-P99 latency, process RSS, and container OOM events for local CPU deployments. If a future provider introduces a reproducible cyclic-reference regression, full GC can be reintroduced for that provider or changed to a periodic or threshold-triggered policy without changing the device cleanup interface.