Serving observability + SLO autoscaler#
Track 05 - Serving · Notebook 11 · Runtime: ≈30 s on CPU
Prerequisites:
05_serving/10(DistServe).Reference: vLLM Prometheus metrics; SLO-driven autoscaling patterns.
What#
Two building blocks of a real LLM serving deployment:
Metrics: Prometheus-style counters and histograms for requests, tokens, TTFT, TPOT, queue depth, KV utilisation.
Autoscaler: a control loop that reads recent SLO attainment and scales replicas up/down accordingly.
We simulate a non-stationary workload (traffic spikes at minute 2, drops at minute 5), record the metrics, and drive a replicas-count controller that keeps SLO attainment above a target while minimising over-provisioning.
from llm_systems_cookbook.nb import bootstrap
from dataclasses import dataclass, field
import numpy as np
s = bootstrap("05_serving_11_serving_observability_slo_autoscaler")
Metrics registry#
A minimal Prometheus-shaped registry: counters (monotonic) and
histograms (bucketed). Exposes observe, inc, and snapshot
methods.
@dataclass
class Counter:
name: str
value: float = 0.0
def inc(self, by: float = 1.0) -> None:
self.value += by
@dataclass
class Histogram:
name: str
buckets_le: list[float] = field(default_factory=lambda: [0.05, 0.1, 0.5, 1.0, 2.0, 5.0, 10.0])
counts: list[int] = field(default_factory=list)
sum_: float = 0.0
n: int = 0
def __post_init__(self) -> None:
if not self.counts:
self.counts = [0] * (len(self.buckets_le) + 1)
def observe(self, value: float) -> None:
self.sum_ += value
self.n += 1
for i, le in enumerate(self.buckets_le):
if value <= le:
self.counts[i] += 1
return
self.counts[-1] += 1
def quantile(self, q: float) -> float:
if self.n == 0:
return 0.0
target = q * self.n
seen = 0
for i, le in enumerate(self.buckets_le):
seen += self.counts[i]
if seen >= target:
return le
return self.buckets_le[-1] * 2
@dataclass
class Registry:
counters: dict[str, Counter] = field(default_factory=dict)
histograms: dict[str, Histogram] = field(default_factory=dict)
def counter(self, name: str) -> Counter:
if name not in self.counters:
self.counters[name] = Counter(name)
return self.counters[name]
def histogram(self, name: str) -> Histogram:
if name not in self.histograms:
self.histograms[name] = Histogram(name)
return self.histograms[name]
def snapshot(self) -> dict:
return {
"counters": {n: c.value for n, c in self.counters.items()},
"histograms": {n: {"p50": h.quantile(0.5), "p99": h.quantile(0.99), "n": h.n}
for n, h in self.histograms.items()},
}
registry = Registry()
registry.counter("requests_total").inc()
registry.histogram("ttft_seconds").observe(0.3)
registry.histogram("ttft_seconds").observe(1.8)
snap = registry.snapshot()
print(f"snapshot after 2 requests: {snap}")
s.check(
"counter_increments",
lambda: registry.counter("requests_total").value == 1.0,
)
s.check(
"histogram_quantile_sane",
lambda: registry.histogram("ttft_seconds").quantile(0.99) <= 2.0,
msg=f"p99 = {registry.histogram('ttft_seconds').quantile(0.99)}",
)
Non-stationary workload#
Arrivals vary over time: low (4 req/s) minutes 0-2, spike (14 req/s) minutes 2-5, back down (5 req/s) minutes 5-8.
rng = np.random.default_rng(0)
def generate_arrivals() -> list[tuple[float, float]]:
'''Return (arrival_time, requested_compute_seconds) per request.'''
arrivals: list[tuple[float, float]] = []
t = 0.0
while t < 480: # 8 minutes
if t < 120:
lam = 4.0
elif t < 300:
lam = 14.0
else:
lam = 5.0
t += rng.exponential(1.0 / lam)
work = float(rng.lognormal(-0.5, 0.3)) # ~0.6s mean service
arrivals.append((t, work))
return arrivals
ARRIVALS = generate_arrivals()
print(f"workload: {len(ARRIVALS)} requests over 8 minutes")
SLO-driven autoscaler#
A simple control loop: every 30s, look at the SLO attainment (TTFT
p99 ≤ 1s) over the last window; if below target, scale up by 1
replica; if above by a margin, scale down. Cap replicas at
[1, 8].
SLO_TTFT = 1.0
TARGET_SLO = 0.95
PER_REPLICA_CAPACITY = 0.8 # service rate per replica (req/s)
def simulate(static_replicas: int | None = None) -> dict:
reg = Registry()
replicas = static_replicas if static_replicas is not None else 1
queue: list[tuple[float, float]] = [] # (arrival_time, work_seconds)
in_flight_until = [0.0] * 16 # support up to 16 replicas
replicas_history: list[tuple[float, int]] = [(0.0, replicas)]
last_control = 0.0
window_attainment: list[bool] = []
for arr, work in ARRIVALS:
# Advance to arrival.
queue.append((arr, work))
# Auto-scale decision every 30s.
if static_replicas is None and arr - last_control > 30:
if window_attainment:
attain = sum(window_attainment) / len(window_attainment)
if attain < TARGET_SLO and replicas < 8:
replicas += 1
elif attain > TARGET_SLO + 0.03 and replicas > 1:
replicas -= 1
window_attainment = []
last_control = arr
replicas_history.append((arr, replicas))
# Admit queued items to any free replica.
while queue:
a, w = queue[0]
# Pick the earliest-free replica among the active ones.
free_idx = int(min(range(replicas), key=lambda i: in_flight_until[i]))
start = max(a, in_flight_until[free_idx])
ttft = start - a
reg.histogram("ttft_seconds").observe(ttft)
reg.counter("requests_total").inc()
window_attainment.append(ttft <= SLO_TTFT)
in_flight_until[free_idx] = start + w / PER_REPLICA_CAPACITY
queue.pop(0)
break # one admission per arrival to keep simulation simple
snap = reg.snapshot()
# Attainment = fraction of per-request ttft measurements within SLO.
all_attainments = []
for le, count in zip(reg.histogram("ttft_seconds").buckets_le,
reg.histogram("ttft_seconds").counts[:-1], strict=True):
if le <= SLO_TTFT:
all_attainments.append(count)
total_n = reg.histogram("ttft_seconds").n
attained = sum(all_attainments)
slo_attainment = attained / max(total_n, 1)
return {
"ttft_p99": snap["histograms"]["ttft_seconds"]["p99"],
"ttft_p50": snap["histograms"]["ttft_seconds"]["p50"],
"slo_attainment": slo_attainment,
"total": snap["counters"]["requests_total"],
"replicas_history": replicas_history,
"replicas_avg": float(np.mean([r for _, r in replicas_history])),
}
result_autoscale = simulate(static_replicas=None)
result_static_4 = simulate(static_replicas=4)
result_static_2 = simulate(static_replicas=2)
for name, r in [("autoscale", result_autoscale), ("static-4", result_static_4), ("static-2", result_static_2)]:
print(f" {name:<11} ttft_p99={r['ttft_p99']:.3f} p50={r['ttft_p50']:.3f} "
f"slo={r['slo_attainment']:.1%} replicas_avg={r['replicas_avg']:.1f}")
s.check(
"autoscaler_adjusts_replicas",
lambda: len({r for _, r in result_autoscale["replicas_history"]}) >= 2,
msg=f"replicas seen: {set(r for _, r in result_autoscale['replicas_history'])}",
)
s.check(
"autoscaler_slo_at_least_static_2",
lambda: result_autoscale["slo_attainment"] >= result_static_2["slo_attainment"] - 0.05,
msg=f"auto={result_autoscale['slo_attainment']:.1%} s2={result_static_2['slo_attainment']:.1%}",
)
s.check(
"autoscaler_uses_more_replicas_than_static_2",
lambda: result_autoscale["replicas_avg"] > result_static_2["replicas_avg"],
msg=f"auto={result_autoscale['replicas_avg']:.2f} s2={result_static_2['replicas_avg']:.2f}",
)
s.check(
"more_replicas_improves_slo",
lambda: result_static_4["slo_attainment"] >= result_static_2["slo_attainment"],
msg=f"s4={result_static_4['slo_attainment']:.1%} s2={result_static_2['slo_attainment']:.1%}",
)
s.check(
"autoscaler_beats_static_2_slo",
lambda: result_autoscale["slo_attainment"] >= result_static_2["slo_attainment"],
msg=f"auto={result_autoscale['slo_attainment']:.1%} s2={result_static_2['slo_attainment']:.1%}",
)
Replicas and arrival rate, side by side#
Two stacked panels. Top: arrival rate over time (the 2-5 minute spike). Bottom: replica count chosen by the autoscaler, stepping up as the spike arrives and draining afterwards. The lag between the two panels is the control loop’s reaction time - longer lag means more SLO violations during the ramp.
import matplotlib.pyplot as plt
# Arrival rate: count arrivals inside 10-second windows.
bin_s = 10.0
t_end = max(t for t, _ in ARRIVALS)
edges = np.arange(0, t_end + bin_s, bin_s)
times = np.array([t for t, _ in ARRIVALS])
rate, _ = np.histogram(times, bins=edges)
rate_per_s = rate / bin_s
# Replicas step-function from autoscaler history.
hist = result_autoscale["replicas_history"]
rt = [t for t, _ in hist] + [t_end]
rv = [r for _, r in hist] + [hist[-1][1]]
fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(8, 4.8), sharex=True)
ax1.fill_between(edges[:-1] / 60, rate_per_s, alpha=0.4, color="tab:blue")
ax1.plot(edges[:-1] / 60, rate_per_s, color="tab:blue", lw=1.5, label="arrivals / s")
ax1.axvspan(2, 5, color="tab:orange", alpha=0.15, label="spike window")
ax1.set_ylabel("arrival rate (req/s)")
ax1.set_title("workload arrival rate (non-stationary)")
ax1.legend(loc="upper right", fontsize=9)
ax1.grid(True, alpha=0.3)
ax2.step([t / 60 for t in rt], rv, where="post", color="tab:green", lw=2)
ax2.fill_between([t / 60 for t in rt], rv, step="post", alpha=0.2, color="tab:green")
ax2.axvspan(2, 5, color="tab:orange", alpha=0.15)
ax2.set_ylabel("active replicas")
ax2.set_xlabel("time (minutes)")
ax2.set_title(f"autoscaler response (SLO attainment {result_autoscale['slo_attainment']:.0%})")
ax2.set_ylim(0, max(rv) + 1)
ax2.grid(True, alpha=0.3)
fig.tight_layout()
plt.show()
Exercises#
Add more metrics. KV cache utilisation, queue depth histogram, per-model request counts. In production these all feed Grafana dashboards.
Alerting. Send a “burning through error budget” alert when SLO attainment drops below 90 % for 5 minutes.
Cost-aware scaling. Adjust the controller to optimise
goodput / replica_cost; the optimum is usually 2 replicas below what the naive SLO-attainment target would pick.
References#
Google SRE book, Service Level Objectives chapter.
vLLM’s
/metricsendpoint for the reference Prometheus labels.KEDA (Kubernetes Event-Driven Autoscaler) for a real-world autoscaler wiring.
s.summary()
s.save()