Skip to content

Replace the scaling metric with an occupancy ratio (in-flight / served requests) - #89

Open
kondratyevd wants to merge 5 commits into
mainfrom
occupancy-ratio-metric
Open

Replace the scaling metric with an occupancy ratio (in-flight / served requests)#89
kondratyevd wants to merge 5 commits into
mainfrom
occupancy-ratio-metric

Conversation

@kondratyevd

Copy link
Copy Markdown
Collaborator

What

Replaces the default KEDA/Envoy scaling metric (mean inference queue time per batch execution, threshold 100) with an occupancy ratio: the mean number of requests in flight between Envoy and the Triton fleet, divided by the fleet's serving occupancy. The metric estimates how many replicas the current in-flight work needs, so the autoscaler can size the fleet in one proportional step.

Why

The queue-latency metric is non-linear in load and its scale depends on the model mixture:

  • Once capacity is adequate it collapses toward zero, so the controller cannot tell "correctly sized" from "over-provisioned" — in a 30-client CMSSW A/B run on Geddes it scaled down mid-run and cost the remaining six minutes ~25% throughput.
  • At moderate sustained queueing it under-reads — in a 15-client run it sat at 20–50 (threshold 100) for six minutes while clients spent 43% of their in-flight time queued.
  • Its magnitude at saturation differs by ~25× between model mixtures observed on Geddes (UParT-scouting vs Run 3 MiniAOD), so no single threshold fits two deployments.

The occupancy ratio fixed both failure modes in back-to-back A/B runs (same jobs, same cluster): equal or lower GPU-minutes, −7–8% wall time, −17–21% p90 latency, steady fixed-point behaviour at both 5 replicas (capacity-capped) and 3 replicas (free choice).

How the PromQL query is constructed

Little's law (L = λW) turns cumulative time counters into mean occupancies: rate(<cumulative time counter>) is "seconds of request time per second of wall clock", i.e. the mean number of requests inside that system. The query combines three measured quantities:

Term Meaning Source
L_envoy requests in flight between Envoy and Triton (queued + executing + on the wire) sum(rate(envoy_cluster_upstream_rq_time_sum{...triton_grpc_service}[1m])) / 1e3 (counter in ms)
L_service requests actively executing, all models and pods sum(rate(nv_inference_request_duration_us − nv_inference_queue_duration_us)) / 1e6
R_healthy Triton endpoints Envoy routes to max(envoy_cluster_membership_healthy{...}) (max across Envoy pods — they report the same cluster)

and renders the extensive form

R_needed = L_envoy / max(L_service / R_healthy, 1)
         = L_envoy × R_healthy / max(L_service, R_healthy, 1)     # as written in PromQL

clamp_min(v, s) is PromQL for max(v, s). The two floors are physical, not tuning: each healthy replica can execute at least one request concurrently (serving capacity is never below the replica count — this is what makes scale-down work at low load), and at zero replicas the metric degrades to "requests in flight" rather than dividing by zero (never +Inf, which KEDA would read as "scale to max"). Because every term is a time integral, models are weighted by the time they consume — the metric contains no model-specific constants and reads the same for any mixture: ~1 when nothing queues, ~2 when requests wait as long as they are served, linear in the overload factor above that.

Consumers

  • KEDA now uses metricType: AverageValue: desired = ceil(R_needed / serverLoadThreshold) — independent of pods that never scheduled (with the old Value type, pending pods inflate the request; observed as 3 unschedulable pods held for 10 minutes in testing). serverLoadThreshold defaults to 2 (tolerated sojourn inflation; 1.5 trades GPUs for latency).
  • Envoy rate limiter compares the per-replica form (R_needed / R_healthy) against a new serverAdmissionThreshold (default 3). It is deliberately above the scaling threshold: the autoscaler settles the system near serverLoadThreshold, so gating admission at the same value would reject new clients during normal operation.
  • serverLoadRateInterval (default 1m) exposes the rate() window — keep it ≥ 4× the Prometheus scrape interval; shorter windows double the noise without detecting load faster (measured: cv 18% vs 8% at 30s vs 1m, same threshold-crossing sample).

Compatibility

  • A custom serverLoadMetric is still used verbatim by both consumers; keda.metricType: Value restores the old HPA semantics for per-replica custom metrics.
  • Breaking for default users: the default serverLoadThreshold changes 100 → 2 with the new default metric; values files that only set serverLoadThreshold: 100 (without a custom metric) should drop it — the example values files in values/ are updated accordingly.
  • The default metric now requires Envoy (envoy.enabled, the default) and Prometheus scraping of both Envoy and Triton, which the bundled Prometheus config already does.

Validation

  • helm template verified for: defaults, values-minimal-full.yaml (scale-from-zero CI values), values-geddes-cms.yaml, custom-metric override, and the external-Envoy-config path; helm lint passes for every file in values/.
  • Both rendered queries (KEDA and Lua forms) validated against the live Geddes Prometheus via promtool query instant.
  • Live A/B evidence (4 runs, 2 load levels, real CMSSW clients) summarized above; happy to attach the full run reports.

🤖 Generated with Claude Code

kondratyevd and others added 3 commits September 8, 2026 09:29
The default KEDA/Envoy load metric was mean inference queue time per batch
execution. It is non-linear in load (near zero once capacity is adequate,
explosive near saturation) and its scale depends on the model mixture, so a
single threshold cannot serve different deployments: in A/B runs on Geddes
it caused a premature mid-run scale-down under high load and six minutes of
under-scaling at moderate load.

The new default metric estimates the number of replicas the current
in-flight work needs, via Little's law (L = rate of a cumulative time
counter):

  R_needed = L_envoy / max(L_service / R_healthy, 1)

  L_envoy   in-flight requests between Envoy and Triton
            (rate of envoy_cluster_upstream_rq_time_sum, ms -> /1e3)
  L_service requests actively executing across all models and pods
            (rate of request-duration minus queue-duration, us -> /1e6)
  R_healthy Triton endpoints Envoy routes to (max of
            envoy_cluster_membership_healthy across Envoy pods)

Every term is a time integral, so models are weighted by the time they
consume and the metric carries no model-specific constants; it reads ~1
with no queueing and grows linearly with the overload factor. KEDA now
consumes it as metricType AverageValue (desired = ceil(metric/threshold),
independent of pods that never scheduled); the Envoy rate limiter compares
the per-replica form against a separate serverAdmissionThreshold so that
new clients are only rejected when scaling can no longer keep up, not at
the normal operating point. serverLoadThreshold now defaults to 2
(tolerated sojourn inflation); the rate() window is configurable via
serverLoadRateInterval (keep >= 4x the Prometheus scrape interval).

A custom serverLoadMetric is still used verbatim by both consumers, with
keda.metricType available to restore the old Value semantics.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The Prometheus Operator install floats on whatever
prometheus-community/kube-prometheus-stack is latest. 90.0.0, published
between 2026-09-05 and 2026-09-07, made the control-plane ServiceMonitors
authenticate via a Secret that is only rendered when prometheus.enabled
and prometheus.serviceAccount.create are both true. These call sites pass
prometheus.enabled=false -- they want the operator and its CRDs, nothing
else -- so templating now fails:

  The control-plane ServiceMonitors authenticate by default with the
  Secret created by prometheus.serviceAccount.createTokenSecret, which is
  only rendered when prometheus.enabled and prometheus.serviceAccount.create
  are also true.

Bisected: 89.2.4 and every earlier release template fine with these
flags; 90.0.0 is the first that does not. Pinning 89.2.4 keeps CI
reproducible and matches how the chart's own dependencies are pinned.
89.2.4 still ships the servicemonitors CRD and the operator Deployment,
which is all these steps need.

The alternative -- tracking latest and disabling every control-plane
exporter (kubelet, kubeApiServer, kubeControllerManager, kubeScheduler,
kubeProxy, kubeEtcd, coreDns) -- also works on 90.0.0 but adds seven
flags to each call site and would not protect against the next upstream
change.

This break is independent of this branch: it fails identically on main,
which has not run CI since 90.0.0 was published.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@kondratyevd
kondratyevd force-pushed the occupancy-ratio-metric branch from b916525 to 444bdc3 Compare September 8, 2026 20:42
kondratyevd and others added 2 commits September 8, 2026 16:53
Trim the helper header, values comments, and configuration-guide section
to what a reader needs: what each term measures, what the floors do, and
how the thresholds are consumed. Drop the derivation asides and the
repeated interpretation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants