litellm/tests/e2e/perf/test_perf_cases.py
Kerry Lu b99d2cc06e
test(perf): ramp the load cases until the pod stops keeping up and floor sustained_rps (#47758)
* test(perf): add small_bodies_30rps and mixed_size_requests_4rps load cases

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(perf): run the load cases at 50 rps small and 5 rps mixed, the pod's knee

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(perf): ramp the load cases until the pod stops keeping up and report sustained_rps

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(perf): hand the ramp to the observation and widen the mixed ramp to 14 rps

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(perf): floor the small ramp at 20 rps and emit the per-step readings

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(perf): emit the ramp's hold bound and each step's unexpected statuses

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(perf): read each ramp step's cpu, redis latency and connections from the samplers' windows

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(perf): floor mixed_size_requests_ramp at 8 rps from the lane's 10 rps sustained step

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(perf): compare step readings with math.isclose so the e2e basedpyright gate stays clean

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: kerry <kerry@berri.ai>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-20 22:31:08 -07:00

188 lines
8.1 KiB
Python

"""One perf run per `PerformanceTestCase` in `cases/`: check the gateway was deployed with the case's
model groups and guardrails, build its teams, users and keys, replay its deterministic plan from a separate Locust
process against the out-of-process mock providers, then check every `Limit` against what the
clients saw, what the mocks received and how the workers' memory moved."""
from __future__ import annotations
import os
import time
from collections.abc import Mapping
from datetime import UTC, datetime
from typing import Final
import pytest
from cases import CASES
from idp import Keycloak
from lifecycle import ResourceManager
from memory_readings import read_rss_everywhere
from models import SpendLogsParams
from proxy_client import ProxyClient
from setup.datastore import connections_sampler, read_db_counters, read_redis_counters, redis_monitor
from setup.event_loop import (
LOG_FILE_ENV,
OPT_IN_ENV,
StallRow,
offenders,
parse_stalls,
read_gateway_log,
render_stalls,
summarize,
)
from setup.event_loop import junit_properties as stall_properties
from setup.observe import (
Observation,
evaluate,
junit_properties,
read_guardrail_calls,
read_mock_requests,
render,
reset_mock,
set_mock_latency,
)
from setup.performance_test_case import MockLatency, PerformanceTestCase
from setup.plan import PlannedRequest, build_plan
from setup.podcpu import gateway_cpu_sampler
from setup.provision import MOCK_PROVIDERS, build_setup, guardrail_url, mock_url
from setup.ramp import step_properties
from setup.run import run_plan
from setup.telemetry import read_gateway_telemetry, telemetry_sampler
pytestmark = [pytest.mark.perf, pytest.mark.quiet_stack]
_SPEND_SAMPLE: Final = 40
_WARMUP_SECONDS: Final = 60
def _spend_logged_share(proxy: ProxyClient, call_ids: tuple[str, ...]) -> float | None:
"""Share of a sample of served requests whose spend row the gateway wrote, read back by the
request id the gateway returned in `x-litellm-call-id`."""
if not call_ids:
return None
found = 0
for call_id in call_ids:
rows = proxy.spend_logs(SpendLogsParams(request_id=call_id))
found += 1 if rows else 0
return found / len(call_ids)
def _warmup_plan(case: PerformanceTestCase, plan: tuple[PlannedRequest, ...]) -> tuple[PlannedRequest, ...]:
"""The plan's first minute of traffic. It is sent before the memory baseline is read so a worker
has shed its startup allocations and `memory_growth_megabytes` is what the measured traffic added,
not startup churn (a lightly loaded case read negative growth)."""
return plan[: max(1, round(case.traffic.rps * _WARMUP_SECONDS))]
def _arm_mocks(mock_urls: Mapping[str, str], latency: MockLatency) -> None:
for url in (*mock_urls.values(), guardrail_url()):
_ = reset_mock(url)
for url in mock_urls.values():
_ = set_mock_latency(url, latency)
def _event_loop_rows(case: PerformanceTestCase, since: datetime) -> tuple[StallRow, ...]:
"""The gateway's asyncio debug holds for the traffic window; a case that asks for them cannot pass without a log."""
assert case.event_loop is not None
lines: Final = read_gateway_log(since)
assert lines is not None, (
f"{case.name} needs the gateway log: run in the perf lane (pods/log with the runner service account) or set "
f"{LOG_FILE_ENV} to the gateway's log file"
)
return summarize(case.event_loop, parse_stalls(lines))
@pytest.mark.parametrize("case", CASES, ids=[case.name for case in CASES])
def test_perf_case(
case: PerformanceTestCase,
proxy: ProxyClient,
idp: Keycloak,
resources: ResourceManager,
request: pytest.FixtureRequest,
) -> None:
if case.event_loop is not None and not os.environ.get(OPT_IN_ENV):
pytest.skip(
f"{case.name} reads asyncio debug output: deploy the gateway with PYTHONASYNCIODEBUG=1 and "
f"LITELLM_EVENT_LOOP=asyncio, then set {OPT_IN_ENV}=1"
)
plan: Final = build_plan(case)
mock_urls: Final = {provider: mock_url(provider) for provider in MOCK_PROVIDERS}
_arm_mocks(mock_urls, case.mock_latency)
setup_started: Final = time.monotonic()
setup: Final = build_setup(case, proxy=proxy, idp=idp, resources=resources)
_ = run_plan(case, _warmup_plan(case, plan), setup)
_arm_mocks(mock_urls, case.mock_latency)
rss_before: Final = read_rss_everywhere(proxy)
telemetry_before: Final = read_gateway_telemetry(proxy)
db_before: Final = read_db_counters()
redis_before: Final = read_redis_counters()
run_started: Final = time.monotonic()
traffic_started_at: Final = datetime.now(tz=UTC)
with (
gateway_cpu_sampler() as cpu,
redis_monitor() as monitor,
connections_sampler() as connections,
telemetry_sampler(proxy) as telemetry,
):
outcome: Final = run_plan(case, plan, setup)
run_finished: Final = time.monotonic()
db_after: Final = read_db_counters()
redis_after: Final = read_redis_counters()
rss_after: Final = read_rss_everywhere(proxy)
telemetry_after: Final = read_gateway_telemetry(proxy)
rss_read: Final = time.monotonic()
assert outcome.records, f"locust wrote no records (exit {outcome.locust_exit}):\n{outcome.locust_output}"
served_call_ids: Final = tuple(
record.call_id for record in outcome.records if record.ok and not record.blocked and record.call_id
)
spend_share: Final = _spend_logged_share(
proxy, served_call_ids[:: max(1, len(served_call_ids) // _SPEND_SAMPLE)][:_SPEND_SAMPLE]
)
spend_read: Final = time.monotonic()
mock_requests: Final = tuple(seen for url in mock_urls.values() for seen in read_mock_requests(url))
guardrail_calls: Final = tuple(read_guardrail_calls(guardrail_url())) if case.guardrails else ()
mocks_read: Final = time.monotonic()
observation: Final = Observation(
records=outcome.records,
mock_requests=mock_requests,
guardrail_calls=guardrail_calls,
rss_before=rss_before,
rss_after=rss_after,
wall_seconds=outcome.wall_seconds,
spend_logged_share=spend_share,
db_before=db_before,
db_after=db_after,
redis_before=redis_before,
redis_after=redis_after,
redis_commands=monitor.commands,
gateway_cpu_seconds=cpu.seconds,
telemetry_before=telemetry_before,
telemetry_after=telemetry_after,
peak_connections=connections.peak,
ramp=case.traffic.ramp,
cpu_series=cpu.series,
connection_series=connections.series,
telemetry_series=telemetry.series,
)
rows, exceeded_limits = evaluate(case, observation)
stall_rows: Final = _event_loop_rows(case, traffic_started_at) if case.event_loop is not None else ()
item: Final = request.node # pyright: ignore[reportUnknownMemberType, reportUnknownVariableType] # pytest: untyped
assert isinstance(item, pytest.Item)
item.user_properties[:] = [
entry for entry in item.user_properties if not entry[0].startswith("perf_")
] # rebind-ok: a --reruns retry replaces the first attempt's numbers instead of appending a second set
item.user_properties.extend(
stall_properties(case.event_loop, stall_rows) if case.event_loop is not None else junit_properties(rows)
)
item.user_properties.extend(step_properties(observation.steps()))
print(render(case, observation, rows))
if case.event_loop is not None:
print(render_stalls(case.event_loop, stall_rows))
print(
f" phases: setup {run_started - setup_started:.0f}s, locust {run_finished - run_started:.0f}s, "
f"observe {time.monotonic() - run_finished:.0f}s (rss {rss_read - run_finished:.0f}s, spend sample "
f"{spend_read - rss_read:.0f}s, mock logs {mocks_read - spend_read:.0f}s); locust exit {outcome.locust_exit}"
)
assert len(outcome.records) == len(plan), (
f"{len(plan)} planned, {len(outcome.records)} recorded; locust said:\n{outcome.locust_output}"
)
assert not exceeded_limits, "limits exceeded (observed > max):\n" + "\n".join(exceeded_limits)
assert not offenders(stall_rows), "event-loop holds over the threshold:\n" + "\n".join(offenders(stall_rows))