[Serve] Fix excessive CPU from per-handle cached metrics tasks at scale#62674
Conversation
When running ~1500 Serve apps, each RouterMetricsManager creates its own _report_cached_metrics_forever asyncio task that wakes every 100ms. This results in ~15,000 task wake-ups per second on a single event loop, dominating CPU on ingress replicas. Introduce SharedCachedMetricsReporter to consolidate all per-handle reporting tasks into a single shared asyncio task per event loop. All RouterMetricsManagers register with the shared reporter via a WeakSet, so metrics for every handle are flushed in a single loop iteration. This reduces N tasks to 1 regardless of deployment count. Fixes ray-project#62619 Signed-off-by: Kc Balusu <kcbalusu@meta.com>
There was a problem hiding this comment.
Code Review
This pull request introduces a SharedCachedMetricsReporter to consolidate metrics reporting for RouterMetricsManager instances into a single asyncio task per event loop, significantly reducing CPU overhead at scale. The feedback highlights a resource leak where the background task and reporter instance are not cleaned up when all managers are deregistered. Additionally, it is recommended to verify interval consistency in the singleton factory and to explicitly handle asyncio.CancelledError for a cleaner shutdown process.
…rn on interval mismatch - Cancel the background task and remove the reporter from _instances when the last RouterMetricsManager deregisters, preventing a leaked asyncio task and event-loop reference. - Wrap _report_forever in try/except asyncio.CancelledError so that task cancellation exits cleanly without log noise. - Log a warning in get_or_create when an existing reporter's interval differs from the requested interval, catching potential misuse. - Add test_reporter_recreated_after_full_cleanup to verify that a new manager gets a fresh reporter after all previous managers shut down. - Strengthen test_deregister_on_shutdown to verify task cancellation and _instances cleanup. Signed-off-by: Kc Balusu <kcbalusu@meta.com>
|
@abrarsheikh can you review this? |
abrarsheikh
left a comment
There was a problem hiding this comment.
change makes sense. Can you produce some benchmark numbers for support the improvements.
| except asyncio.CancelledError: | ||
| pass |
There was a problem hiding this comment.
curious why catch CancelledError?
There was a problem hiding this comment.
When deregister() is called for the last manager, it calls self._task.cancel(). That raises CancelledError inside the task at the next await point (the asyncio.sleep). Without catching it, asyncio would log an unhandled-exception warning on the event loop — noisy tracebacks during clean shutdown.
Since CancelledError inherits from BaseException (not Exception) in Python 3.9+, the inner except Exception blocks don't catch it — so the outer except asyncio.CancelledError: pass is needed for a silent, clean exit. This is the standard asyncio cleanup pattern (same as what the old per-handle _report_cached_metrics_forever did in RouterMetricsManager.shutdown()).
There was a problem hiding this comment.
deregister() calls task.cancel() which raises CancelledError at the next await. Without catching it, asyncio logs unhandled exception tracebacks which can be noisy. Since CancelledError inherits from BaseException, the inner blocks don't catch it. So, the outer handler is needed for a cleaner shutdown.
Benchmark ResultsRan a microbenchmark comparing old (N per-handle asyncio tasks) vs new (1 shared task with periodic yielding every 50 managers). Each simulated manager does the same work as 100 managers
500 managers
1500 managers (production scale)
Notes
Benchmark script: |
Synthetic benchmark is not that important here. Do you have the compute to run like a real test? |
|
@abrarsheikh adding them here. Numbers are flat. I don't have access to a cluster to reproduce the production conditions (GIL contention, real gRPC I/O, ~1500 live apps) where the original issue's profiling showed Real benchmark (actual Used real
|
|
@jugalshah291 would you be able to try this PR out in your cluster and share some benchmark results? |
|
This pull request has been automatically marked as stale because it has not had You can always ask for help on our discussion forum or Ray's public slack channel. If you'd like to keep this open, just leave any comment, and the stale label will be removed. |
|
@abrarsheikh pinging as this PR has been marked stale, anything I can do here? |
|
Hey sorry, have been busy with getting ray 2.54.0 on our side to cust (so we can upgrade kuberay next) and handling quarter end priorities |
|
@jugalshah291 @abrarsheikh just bumping this up |
|
@jugalshah291 Any updates on the benchmark results? |
…t-cached-metrics-f
|
Hi I had done a run and the result where not expected, probably something interfered with the perf |
|
Update: |
Why are these changes needed?
On a Ray Serve cluster running ~1500 Serve apps, the ingress replica consumes excessive CPU. Profiling shows the dominant consumer is the metrics reporting pipeline in
RouterMetricsManager._report_cached_metrics_forever, not actual request processing.Root cause: Each
RouterMetricsManagerinstance (one per deployment handle perRouter) creates its own_report_cached_metrics_foreverasyncio task. With ~1500 cached deployment handles on the ingress, this produces:RAY_SERVE_METRICS_EXPORT_INTERVAL_MS)_report_cached_metrics()which iterates over cached counters, increments Prometheus metrics, and sums replica request dictsFix: Introduce
SharedCachedMetricsReporter— a singleton per event loop that consolidates all per-handle reporting tasks into a single shared asyncio task. AllRouterMetricsManagerinstances register with the shared reporter via aWeakSet, so metrics for every handle are flushed in one loop iteration. This reduces N asyncio tasks to 1, regardless of deployment count.The design follows the same singleton-per-event-loop pattern already established by
SharedRouterLongPollClientin the same file.Changes
SharedCachedMetricsReporter(new class inrouter.py):get_or_create()singleton keyed by event loopWeakSet-based registration (auto-cleanup when managers are GC'd)_report_forever()loop with per-manager error isolation and exponential backoffRouterMetricsManager:_report_cached_metrics_forever()method (logic moved to shared reporter)shutdown()deregisters from the shared reporter instead of cancelling a tasktest_router.py):test_multiple_managers_share_single_reporter— verifies N managers share 1 reporter and all get metrics flushedtest_deregister_on_shutdown— verifies shutdown deregisters the managertest_shutdown_one_does_not_affect_others— verifies one manager's shutdown doesn't disrupt othersRelated issue number
Fixes #62619
Checks
scripts/format.shto lint the changes in this PR.pytest python/ray/serve/tests/unit/test_router.py -xvs -k TestSharedCachedMetricsReporterand existingTestRouterMetricsManagertests continue to pass.