Skip to content

Commit 11ae84d

Browse files
authored
feat: Tighter FMA timing via DPC log parsing (#1504)
* feat: Add DPC log parser for per-request timing extraction Signed-off-by: Gloire Rubambiza <gloire@ibm.com> * feat: Integrate DPC log parsing to refine per-path timing Signed-off-by: Gloire Rubambiza <gloire@ibm.com> * feat: Display timing source (DPC/Kube) in analysis output Signed-off-by: Gloire Rubambiza <gloire@ibm.com> * fix: Make DPC log detection scan whole file The DPC timing refinement silently fell back to Kube upper bounds on real cluster runs. Two bugs, both surfaced during cluster validation: 1. _is_dpc_log_file() only sniffed the first 256KB of a .log for an indicator message. A real controller log was 1.08MB with its first relay/wake/create message at byte ~429K (after startup noise), so the log was discarded and the parser never ran. Now stream line-by-line with early-exit. Added a regression test with an indicator past 256KB. 2. native_to_br0_1 dropped the dpc_timing_available flag during conversion, so the analyzer's Source column always showed Kube even when timing was DPC-derived. Propagate the flag into launcher_infos. Validated on cluster: dpc_timing_available true on all iterations, hot-start t_wake ~0.53-0.59s (vs 8s Kube upper bound), Source=DPC. Assisted-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: Gloire Rubambiza <gloire@ibm.com>
1 parent f41d935 commit 11ae84d

7 files changed

Lines changed: 464 additions & 3 deletions

File tree

llmdbenchmark/analysis/benchmark_report/native_to_br0_1.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2554,6 +2554,9 @@ def _import_categories(cat_list: list[dict[str, Any]]) -> list[dict[str, Any]]:
25542554
"value": launcher_info.get("launcher_creation_timestamp", 0.0),
25552555
}
25562556
info["launcher_node"] = launcher_info.get("launcher_node", "")
2557+
info["dpc_timing_available"] = launcher_info.get(
2558+
"dpc_timing_available", False
2559+
)
25572560
if launcher_info.get("t_wake") is not None:
25582561
info["t_wake"] = {
25592562
"units": Units.S,

llmdbenchmark/analysis/scripts/nop-analyze_results.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -300,9 +300,12 @@ def write_fma_metrics( # pylint: disable=too-many-locals,too-many-statements
300300
file.write(" T_warm: existing launcher creates new vLLM instance\n")
301301
file.write(" T_hot: waking sleeping vLLM instance\n\n")
302302
file.write("T_actuation: Time for the Requester Pod to be ready\n")
303-
file.write("T_hot: Hot-start timing (upper bound)\n")
304-
file.write("T_warm: Warm-start timing (upper bound)\n")
305-
file.write("T_cold_launcher: Cold-start-with-launcher timing (upper bound)\n")
303+
file.write("T_hot: Hot-start timing\n")
304+
file.write("T_warm: Warm-start timing\n")
305+
file.write("T_cold_launcher: Cold-start-with-launcher timing\n")
306+
file.write(
307+
"Source: DPC = tighter timing from DPC logs, Kube = upper bound from Kube timestamps\n"
308+
)
306309
file.write("T_first_token: Time for vLLM server to return first token\n")
307310
file.write("Each iteration scales ReplicaSet from 0 to 1 and then from 1 to 0\n")
308311

@@ -344,6 +347,9 @@ def write_fma_metrics( # pylint: disable=too-many-locals,too-many-statements
344347
else None
345348
)
346349
node = launcher_info.get("launcher_node", "")
350+
source = (
351+
"DPC" if launcher_info.get("dpc_timing_available", False) else "Kube"
352+
)
347353

348354
pandas_datas.append(
349355
{
@@ -356,6 +362,7 @@ def write_fma_metrics( # pylint: disable=too-many-locals,too-many-statements
356362
"T_warm(s)": t_warm_val,
357363
"T_cold(s)": t_cold_val,
358364
"T_first_token(s)": ttft,
365+
"Source": source,
359366
}
360367
)
361368

tests/test_dpc_log_parser.py

Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
"""Tests for DPC log parser that extracts per-request timing from klog output."""
2+
3+
import pytest
4+
5+
from workload.harnesses.dpc_log_parser import (
6+
parse_dpc_log,
7+
parse_dpc_log_file,
8+
)
9+
10+
11+
# Representative klog lines from a DPC running with PR #522 at V(2)
12+
SAMPLE_LOG = """\
13+
I0603 15:29:00.100000 1 inference-server.go:145] "Reconciling server" serverUID="abc-123" requesterName="fma-req-1-1717424983-abcde"
14+
I0603 15:29:01.200000 1 inference-server.go:1131] "Woke inference server" endpoint="http://10.0.0.1:8005/wake_up" description="discovered-bound" requesterName="fma-req-1-1717424983-abcde" httpCallStartTime="2026-06-03T15:29:01.150000000Z"
15+
I0603 15:29:03.500000 1 inference-server.go:513] "Successfully relayed the readiness" requesterName="fma-req-1-1717424983-abcde" name="launcher-27hdz" readiness="ready" url="http://10.0.0.2:8888/v1/become-ready" httpCallStartTime="2026-06-03T15:29:03.480000000Z"
16+
I0603 15:30:10.000000 1 inference-server.go:464] "Created vLLM instance" instance_id="inst-456" status="running" requesterName="fma-req-2-1717424983-fghij" httpCallStartTime="2026-06-03T15:30:09.900000000Z"
17+
I0603 15:30:50.000000 1 inference-server.go:513] "Successfully relayed the readiness" requesterName="fma-req-2-1717424983-fghij" name="launcher-27hdz" readiness="ready" url="http://10.0.0.3:8888/v1/become-ready" httpCallStartTime="2026-06-03T15:30:49.950000000Z"
18+
I0603 15:31:00.000000 1 inference-server.go:667] "Created launcher-based server-providing pod" name="launcher-xq8lg" gpus="GPU-abc" requesterName="fma-req-3-1717424983-klmno" k8sCallStartTime="2026-06-03T15:30:59.800000000Z"
19+
I0603 15:32:05.000000 1 inference-server.go:513] "Successfully relayed the readiness" requesterName="fma-req-3-1717424983-klmno" name="launcher-xq8lg" readiness="ready" url="http://10.0.0.4:8888/v1/become-ready" httpCallStartTime="2026-06-03T15:32:04.900000000Z"
20+
"""
21+
22+
23+
class TestParseDpcLog:
24+
"""Test parse_dpc_log extracts timing records per requester."""
25+
26+
def test_hot_start_timing(self):
27+
records = parse_dpc_log(SAMPLE_LOG.splitlines())
28+
rec = records["fma-req-1-1717424983-abcde"]
29+
assert rec.relay_readiness_time is not None
30+
assert rec.wake_start_time is not None
31+
assert rec.instance_create_start_time is None
32+
assert rec.launcher_create_start_time is None
33+
t_hot = rec.t_hot()
34+
assert t_hot is not None
35+
assert 2.3 < t_hot < 2.4 # 15:29:03.48 - 15:29:01.15 = 2.33s
36+
37+
def test_warm_start_timing(self):
38+
records = parse_dpc_log(SAMPLE_LOG.splitlines())
39+
rec = records["fma-req-2-1717424983-fghij"]
40+
assert rec.instance_create_start_time is not None
41+
assert rec.relay_readiness_time is not None
42+
assert rec.wake_start_time is None
43+
t_warm = rec.t_instance_create()
44+
assert t_warm is not None
45+
assert 40.0 < t_warm < 40.1 # 15:30:49.95 - 15:30:09.90 = 40.05s
46+
47+
def test_cold_launcher_timing(self):
48+
records = parse_dpc_log(SAMPLE_LOG.splitlines())
49+
rec = records["fma-req-3-1717424983-klmno"]
50+
assert rec.launcher_create_start_time is not None
51+
assert rec.relay_readiness_time is not None
52+
t_cold = rec.t_cold_launcher()
53+
assert t_cold is not None
54+
assert 65.0 < t_cold < 65.2 # 15:32:04.90 - 15:30:59.80 = 65.1s
55+
56+
def test_unknown_requester_not_present(self):
57+
records = parse_dpc_log(SAMPLE_LOG.splitlines())
58+
assert "nonexistent-pod" not in records
59+
60+
def test_empty_log(self):
61+
records = parse_dpc_log([])
62+
assert records == {}
63+
64+
def test_log_with_no_timing_fields(self):
65+
records = parse_dpc_log(
66+
['I0603 15:29:00.100000 1 foo.go:1] "Something unrelated"']
67+
)
68+
assert records == {}
69+
70+
71+
class TestParseDpcLogFile:
72+
"""Test file-based entry point."""
73+
74+
def test_reads_log_file_and_parses(self, tmp_path):
75+
log_file = tmp_path / "dpctlr-pod--manager.log"
76+
log_file.write_text(SAMPLE_LOG)
77+
records = parse_dpc_log_file(str(tmp_path))
78+
assert "fma-req-1-1717424983-abcde" in records
79+
assert records["fma-req-1-1717424983-abcde"].t_hot() is not None
80+
81+
def test_missing_directory_returns_empty(self, tmp_path):
82+
records = parse_dpc_log_file(str(tmp_path / "nonexistent"))
83+
assert records == {}
84+
85+
def test_no_matching_files_returns_empty(self, tmp_path):
86+
(tmp_path / "unrelated.log").write_text("nothing relevant here")
87+
records = parse_dpc_log_file(str(tmp_path))
88+
assert records == {}
89+
90+
def test_indicator_message_past_256kb_still_parsed(self, tmp_path):
91+
"""A DPC log whose first indicator message lands past 256KB must still parse.
92+
93+
Regression for the original _is_dpc_log_file heuristic, which only sniffed
94+
the first 256KB. Real controller logs prepend large amounts of startup noise
95+
(flag dumps, leader election, reconcile churn) before the first
96+
relay/wake/create message, so the indicator can sit well past that boundary.
97+
"""
98+
filler_line = (
99+
"I0603 15:00:00.000000 1 inference-server.go:145] "
100+
'"Reconciling server" serverUID="noise" requesterName="noise-pod"\n'
101+
)
102+
# Prepend well over 256KB of benign filler, then the real sample block.
103+
filler = filler_line * (300 * 1024 // len(filler_line) + 1)
104+
assert len(filler) > 256 * 1024
105+
log_file = tmp_path / "dpctlr-pod--manager.log"
106+
log_file.write_text(filler + SAMPLE_LOG)
107+
108+
records = parse_dpc_log_file(str(tmp_path))
109+
assert "fma-req-1-1717424983-abcde" in records
110+
assert records["fma-req-1-1717424983-abcde"].t_hot() is not None
111+
112+
def test_file_without_relay_but_with_wake_still_parsed(self, tmp_path):
113+
"""DPC log where requester crashed before relay should still be found."""
114+
partial_log = (
115+
'I0603 15:29:01.000000 1 x.go:1] "Woke inference server" '
116+
'requesterName="req-crashed" httpCallStartTime="2026-06-03T15:29:00.900Z"\n'
117+
)
118+
(tmp_path / "dpctlr--manager.log").write_text(partial_log)
119+
records = parse_dpc_log_file(str(tmp_path))
120+
assert "req-crashed" in records
121+
assert records["req-crashed"].wake_start_time is not None
122+
assert records["req-crashed"].t_hot() is None # no relay = no duration
123+
124+
125+
class TestEdgeCases:
126+
"""Edge cases for DPC log parsing robustness."""
127+
128+
def test_multiple_relay_readiness_uses_last(self):
129+
"""If DPC retries readiness relay, use the last successful one."""
130+
lines = [
131+
'I0603 15:29:01.000000 1 x.go:1] "Woke inference server" '
132+
'requesterName="req-retry" httpCallStartTime="2026-06-03T15:29:00.900Z"',
133+
'I0603 15:29:03.000000 1 x.go:1] "Successfully relayed the readiness" '
134+
'requesterName="req-retry" readiness="ready" '
135+
'httpCallStartTime="2026-06-03T15:29:02.500Z"',
136+
'I0603 15:29:05.000000 1 x.go:1] "Successfully relayed the readiness" '
137+
'requesterName="req-retry" readiness="ready" '
138+
'httpCallStartTime="2026-06-03T15:29:04.800Z"',
139+
]
140+
records = parse_dpc_log(lines)
141+
rec = records["req-retry"]
142+
t_hot = rec.t_hot()
143+
assert t_hot is not None
144+
assert 3.8 < t_hot < 4.0 # 04.8 - 00.9 = 3.9s
145+
146+
def test_unready_relay_ignored(self):
147+
"""readiness='unready' lines should not set relay_readiness_time."""
148+
lines = [
149+
'I0603 15:29:01.000000 1 x.go:1] "Woke inference server" '
150+
'requesterName="req-unready" httpCallStartTime="2026-06-03T15:29:00.900Z"',
151+
'I0603 15:29:03.000000 1 x.go:1] "Successfully relayed the readiness" '
152+
'requesterName="req-unready" readiness="unready" '
153+
'httpCallStartTime="2026-06-03T15:29:02.500Z"',
154+
]
155+
records = parse_dpc_log(lines)
156+
rec = records["req-unready"]
157+
assert rec.relay_readiness_time is None
158+
assert rec.t_hot() is None
159+
160+
def test_malformed_timestamp_skipped(self):
161+
"""Malformed httpCallStartTime should not crash, just skip."""
162+
lines = [
163+
'I0603 15:29:01.000000 1 x.go:1] "Woke inference server" '
164+
'requesterName="req-bad" httpCallStartTime="not-a-timestamp"',
165+
'I0603 15:29:03.000000 1 x.go:1] "Successfully relayed the readiness" '
166+
'requesterName="req-bad" readiness="ready" '
167+
'httpCallStartTime="2026-06-03T15:29:02.500Z"',
168+
]
169+
records = parse_dpc_log(lines)
170+
rec = records["req-bad"]
171+
assert rec.wake_start_time is None
172+
assert rec.relay_readiness_time is not None
173+
174+
def test_multiple_requesters_independent(self):
175+
"""Different requesters get independent records."""
176+
lines = [
177+
'I0603 15:29:01.000000 1 x.go:1] "Woke inference server" '
178+
'requesterName="req-a" httpCallStartTime="2026-06-03T15:29:00.000Z"',
179+
'I0603 15:30:01.000000 1 x.go:1] "Created vLLM instance" '
180+
'requesterName="req-b" httpCallStartTime="2026-06-03T15:30:00.000Z"',
181+
'I0603 15:29:05.000000 1 x.go:1] "Successfully relayed the readiness" '
182+
'requesterName="req-a" readiness="ready" '
183+
'httpCallStartTime="2026-06-03T15:29:04.000Z"',
184+
'I0603 15:31:05.000000 1 x.go:1] "Successfully relayed the readiness" '
185+
'requesterName="req-b" readiness="ready" '
186+
'httpCallStartTime="2026-06-03T15:31:04.000Z"',
187+
]
188+
records = parse_dpc_log(lines)
189+
assert records["req-a"].t_hot() == pytest.approx(4.0, abs=0.01)
190+
assert records["req-b"].t_instance_create() == pytest.approx(64.0, abs=0.01)
191+
assert records["req-a"].t_instance_create() is None
192+
assert records["req-b"].t_hot() is None

workload/__init__.py

Whitespace-only changes.

workload/harnesses/__init__.py

Whitespace-only changes.

0 commit comments

Comments
 (0)