fix: cap inference-pod CPU threads and offload blocking event-loop calls - #442
Merged
Conversation
…ent loop Root cause: inference pods had no CPU thread limits, causing each pod to spin up ~112 OMP threads. With N detector copies = 2N pods, total thread count exceeded 1000 on a 112-core machine, causing congestion collapse past 3 copies (throughput peaked at ~88 req/s then fell, CPU cost per request grew 5×). Deployment changes (inference-deployment-template.yaml): - Add OMP_NUM_THREADS/MKL_NUM_THREADS/OPENBLAS_NUM_THREADS=4 env vars to cap intra-op thread pools to a sensible per-pod budget (112 cores / ~20-30 pods) - Add resources requests/limits (cpu: 4/8) so the scheduler spreads pods and cgroup-aware PyTorch sizes its pool correctly App changes: - Add timeout=2 to is_edge_inference_ready() requests.get — previously no timeout meant a briefly-unreachable pod could block the event loop for ~60s - Offload get_detector_metadata, inference_is_available, and run_inference from async post_image_query via run_in_threadpool — all three are sync-blocking network/thread-wait calls that were running directly on the event loop - Make get_app_state async def — it does only an attribute read; the sync def was causing an unnecessary threadpool dispatch on every single request Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
Make the inference-pod CPU thread cap and CPU request Helm values instead of hardcoded, and remove the CPU limit: - inferenceDeployment.cpuThreads (default 4) -> OMP/MKL/OPENBLAS_NUM_THREADS - inferenceDeployment.cpuRequest (default 1) -> resources.requests.cpu - removed resources.limits.cpu (threads are bounded by cpuThreads; omitting the limit avoids CFS throttling / tail-latency spikes) The previous hardcoded requests.cpu="4" reserved 4 cores per pod, capping a 112-core node at ~13 detectors (28 pods); beyond that, pods stayed Pending and set_config timed out waiting for readiness. requests.cpu="1" raises the ceiling to ~55 detectors while threads stay capped at cpuThreads. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Default inferenceDeployment.cpuRequest to "" so the resources.requests block is omitted entirely => inference pods are BestEffort and the scheduler launches all detectors regardless of core count. Rationale: most detectors are idle at any instant, active pods are already bounded by cpuThreads (OMP/MKL/OpenBLAS), and the OS time-slices simultaneous bursts. A non-empty cpuRequest still reserves CPU and caps density for nodes that want a hard scheduling guarantee. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
honeytung
force-pushed
the
edge-endpoint-perf-implementation
branch
from
June 30, 2026 17:43
f49bf9c to
25f8e02
Compare
Load testing showed BestEffort (no CPU request) is the right behavior in every case: threads are bounded by cpuThreads, detectors are mostly idle at any instant, and the scheduler should launch all of them regardless of core count. The optional cpuRequest knob is unneeded, so drop it along with the conditional resources block. Inference pods now set no CPU requests or limits. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
timmarkhuff
reviewed
Jul 1, 2026
| # Ensure that detector_id has correct casing by pulling the detector ID out of detector_metadata | ||
| # get_detector_metadata returns the correctly-cased, canonical detector ID | ||
| detector_metadata = get_detector_metadata(detector_id=detector_id, gl=gl) # NOTE: API call (once, then cached) | ||
| detector_metadata = await run_in_threadpool( |
Contributor
There was a problem hiding this comment.
This seems unnecessary, but maybe it doesn't hurt.
I ran a load test with this code and didn't see any issues.
timmarkhuff
reviewed
Jul 1, 2026
| logger.debug("Received human_review=ALWAYS. Skipping edge inference.") | ||
| record_activity_for_metrics(detector_id, activity_type="escalations") | ||
| elif app_state.edge_inference_manager.inference_is_available(detector_id=detector_id): | ||
| elif await run_in_threadpool(app_state.edge_inference_manager.inference_is_available, detector_id=detector_id): |
Contributor
There was a problem hiding this comment.
Eventually I would like to get rid of this readiness cache. I have a PR that should do the trick: #419
But for now this seem fine.
timmarkhuff
reviewed
Jul 1, 2026
| value: "{{ .Values.edgeCompileEnabled }}" | ||
| # Cap PyTorch/OpenMP/MKL/OpenBLAS intra-op threads so that many inference pods | ||
| # don't oversubscribe the node's cores. See values.yaml inferenceDeployment.cpuThreads. | ||
| - name: OMP_NUM_THREADS |
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.
Summary
inferenceDeployment.cpuThreads(default4) setsOMP_NUM_THREADS/MKL_NUM_THREADS/OPENBLAS_NUM_THREADS. Inference pods set no CPU requests or limits (BestEffort), so the scheduler launches every detector regardless of core count; threads are bounded bycpuThreads, which also avoids CFS throttling.timeout=2to the no-timeoutis_edge_inference_readyhealth check; offloadget_detector_metadata,inference_is_available, andrun_inferencefrom the async event loop viarun_in_threadpool; makeget_app_stateasync defto remove the pointless threadpool dispatch on every request.Changes
deploy/helm/groundlight-edge-endpoint/files/inference-deployment-template.yamlOMP_NUM_THREADS / MKL_NUM_THREADS / OPENBLAS_NUM_THREADSfrominferenceDeployment.cpuThreads(default4) — caps each pod's intra-op thread pool so N pods don't oversubscribe the cores. Tune per device.cpuThreads, and the OS time-slices simultaneous bursts). No CFS throttling.deploy/helm/groundlight-edge-endpoint/values.yamlinferenceDeployment.cpuThreads(default4) so the thread cap is tunable per device without editing the chart.app/core/edge_inference.pytimeout=2torequests.get()inis_edge_inference_ready— previously no timeout; a briefly-unreachable pod could block the event loop for up to 60s.Timeoutis a subclass ofRequestException, so the existingexceptalready handles it.app/api/routes/image_queries.pyawait run_in_threadpool(get_detector_metadata, ...)— was a sync network call on the event loop (cached after first call, but first call can take 2–3s).await run_in_threadpool(inference_is_available, ...)— was 2 syncrequests.gethealth checks on the event loop, each previously with no timeout (p99=1023ms in profiling).await run_in_threadpool(run_inference, ...)— was blockingf_primary.result() / f_oodd.result()waits on the event loop.app/core/app_state.pyget_app_state:def→async def— the function is a pure attribute read; FastAPI was dispatching it to a threadpool on every single request unnecessarily.Test plan
make test— 342 unit tests pass, pre-existing PermissionError fixture failures are unrelated to these changeskubectl exec -n edge <inference-pod> -- sh -c 'echo OMP=$OMP_NUM_THREADS; python -c "import torch; print(torch.get_num_threads())"'— expect 4OMP_NUM_THREADS=4) and sets no CPUresourcesblock (BestEffort)copies: [1,2,3,4,5],target_fps: 0) and confirm throughput no longer collapses past 3 copies, per-request CPU cost stays roughly flat, and GPU% rises🤖 Generated with Claude Code
Validated on hardware
Device
avc2-profiling(2× Xeon Gold 5520+ / 112 threads, 4× RTX Pro 4000 Blackwell). Lens benchmarkbbox_to_binary,objects=5,target_fps=0(saturate), copies 1→5:Root cause was PyTorch/OpenMP thread oversubscription in the inference pods (no thread cap → each pod spawned ~112 threads; N pods thrashed the cores). The thread cap + event-loop offload eliminate it; per-request CPU cost is now flat with load (the oversubscription signature is gone) and the box is no longer CPU-bound.