Skip to content

Commit 9aa675e

Browse files
yogyamclaude
andcommitted
[ci] Surface worker process logs on perf hard regressions
When the performance CI failed on a fixed-threshold breach, the only output was a one-line message like "wan-t2v-1.3b-2gpu dit_time_s exceeded fixed threshold (current=18.252, threshold=10.000)" - the worker-process logs that explain why were never persisted or attached. Capture worker logs per benchmark run via the existing executor log_queue mechanism into a size-capped per-benchmark file, record its path in the raw result (worker_log_path), print a 200-line tail on hard-regression failures in both the pytest path (PR runs) and the compare_baseline path (scheduled runs), and upload the log files as Buildkite artifacts. Fixes #1604 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 7bb76b5 commit 9aa675e

8 files changed

Lines changed: 324 additions & 0 deletions

File tree

.buildkite/scripts/lanes/performance.sh

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ if [ "$pytest_rc" -eq 0 ] || [ "$PERF_UPLOAD_POLICY" = always ]; then
4141
fi
4242
python ./fastvideo/tests/performance/dashboard.py || true
4343
cp -f fastvideo/tests/performance/results/*.json "$PERF_REPORTS_DIR/" 2>/dev/null || true
44+
cp -rf fastvideo/tests/performance/results/worker_logs "$PERF_REPORTS_DIR/" 2>/dev/null || true
4445

4546
echo "--- GPU telemetry (clocks.sm vs clocks.max.sm reveals capped hosts) ---"
4647
cat "$PERF_REPORTS_DIR/gpu_telemetry.csv" || true

docs/contributing/performance_benchmarks.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -297,6 +297,7 @@ Written by `test_inference_performance.py`. One file per benchmark run.
297297
"max_dit_time_s": 10.0,
298298
"max_vae_decode_time_s": 10.0
299299
},
300+
"worker_log_path": "<container-local path to the captured worker log, or null>",
300301
"regression_thresholds": {
301302
"latency": {
302303
"threshold_percent": 0.10,
@@ -482,6 +483,19 @@ record is checked against its own static thresholds: a measured breach reports
482483
nonzero pytest exit with no attributable static-threshold breach reports
483484
`INFRA_ERROR`. Failed records have `success=false` and are excluded from future
484485
rolling baselines. The dashboard still runs best-effort for observability.
486+
487+
Each benchmark run also captures worker-process logs into
488+
`results/worker_logs/worker_<benchmark_id>_<ts>.log` (raw records point to it
489+
via `worker_log_path`; the field is null/absent in older and HF-synced
490+
records). On a hard regression, a 200-line tail of that log is printed in the
491+
failure output — by the pytest assertion on PR runs and by
492+
`compare_baseline.py` on scheduled runs — and the performance lane
493+
(`.buildkite/scripts/lanes/performance.sh`) copies `results/worker_logs/` into
494+
`$PERF_REPORTS_DIR` next to the raw JSON results, so the CI host relays it as
495+
a build artifact even when the comparison phase never runs. Coverage
496+
caveat: only the `fastvideo` logger is captured (no torch/NCCL or raw stderr
497+
output), and ranks > 0 suppress `logger.info` by default, so the file contains
498+
rank-0 INFO plus WARNING/ERROR from all ranks.
485499
When the rolling-baseline phase runs, it emits:
486500

487501
* **Markdown summary** — appended to `$GITHUB_STEP_SUMMARY` when that variable

fastvideo/tests/performance/compare_baseline.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
resolve_metric_policies,
3636
serialize_metric_thresholds,
3737
)
38+
from fastvideo.tests.performance.worker_log_capture import format_worker_log_tail
3839
except ImportError:
3940
repo_root = os.path.abspath(os.path.join(os.path.dirname(__file__), "../../.."))
4041
if repo_root not in sys.path:
@@ -52,6 +53,7 @@
5253
resolve_metric_policies,
5354
serialize_metric_thresholds,
5455
)
56+
from fastvideo.tests.performance.worker_log_capture import format_worker_log_tail
5557

5658
RESULTS_DIR = os.path.join(
5759
os.path.dirname(os.path.abspath(__file__)),
@@ -693,6 +695,12 @@ def main() -> int:
693695
print("Performance regression check failed:")
694696
for item in all_failures:
695697
print(f" - {item}")
698+
for raw, fixed in zip(current_results, static_threshold_failures, strict=True):
699+
if fixed:
700+
print(format_worker_log_tail(
701+
raw.get("benchmark_id", "unknown"),
702+
raw.get("worker_log_path"),
703+
))
696704
return 1
697705

698706
print("Performance baseline comparison passed")

fastvideo/tests/performance/test_compare_baseline_policy.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1936,3 +1936,50 @@ def test_upload_policy_pass_allows_calibration_record(monkeypatch):
19361936
"success": True,
19371937
"comparison_status": compare_baseline.STATUS_CALIBRATION_NEEDED,
19381938
}) is True
1939+
1940+
1941+
def _run_main_with_static_breach(monkeypatch, tmp_path, raw_overrides):
1942+
results_dir = tmp_path / "results"
1943+
reports_dir = tmp_path / "reports"
1944+
tracking_root = tmp_path / "tracking"
1945+
results_dir.mkdir()
1946+
1947+
raw_result = _v2_raw_result(
1948+
avg_generation_time_s=18.252,
1949+
thresholds={"max_generation_time_s": 10.0},
1950+
**raw_overrides,
1951+
)
1952+
(results_dir / "perf_breach.json").write_text(json.dumps(raw_result), encoding="utf-8")
1953+
1954+
monkeypatch.setenv("PERF_RUN_SOURCE", "scheduled_main")
1955+
monkeypatch.delenv("PERF_PYTEST_RC", raising=False)
1956+
monkeypatch.setattr(compare_baseline, "RESULTS_DIR", str(results_dir))
1957+
monkeypatch.setattr(compare_baseline, "PERF_REPORTS_DIR", str(reports_dir))
1958+
monkeypatch.setattr(compare_baseline, "TRACKING_ROOT", str(tracking_root))
1959+
monkeypatch.setattr(compare_baseline, "UPLOAD_POLICY", "never")
1960+
monkeypatch.setattr(compare_baseline, "sync_from_hf", lambda local_dir, strict=False: local_dir)
1961+
1962+
assert compare_baseline.main() == 1
1963+
1964+
1965+
def test_static_threshold_failure_prints_worker_log_tail(monkeypatch, tmp_path, capsys):
1966+
worker_log = tmp_path / "worker_wan-t2v-1.3b-2gpu.log"
1967+
worker_log.write_text("attention backend fell back to slow path\n", encoding="utf-8")
1968+
1969+
_run_main_with_static_breach(
1970+
monkeypatch, tmp_path, {"worker_log_path": str(worker_log)})
1971+
1972+
output = capsys.readouterr().out
1973+
assert "exceeded fixed threshold" in output
1974+
assert "Worker log tail for wan-t2v-1.3b-2gpu" in output
1975+
assert "attention backend fell back to slow path" in output
1976+
1977+
1978+
def test_static_threshold_failure_without_worker_log_degrades_gracefully(
1979+
monkeypatch, tmp_path, capsys):
1980+
_run_main_with_static_breach(monkeypatch, tmp_path, {})
1981+
1982+
output = capsys.readouterr().out
1983+
assert "exceeded fixed threshold" in output
1984+
assert "no log file recorded" in output
1985+
assert "worker log unavailable" in output

fastvideo/tests/performance/test_inference_performance.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@
1919

2020
from fastvideo import VideoGenerator
2121
from fastvideo.logger import init_logger
22+
from fastvideo.tests.performance.worker_log_capture import (
23+
WorkerLogCapture,
24+
format_worker_log_tail,
25+
)
2226
from fastvideo.tests.performance.identity import (
2327
benchmark_identity_from_config,
2428
build_recipe_from_benchmark_config,
@@ -292,6 +296,14 @@ def _write_results(results):
292296
logger.info("Performance results written to %s", filepath)
293297

294298

299+
_WORKER_LOG_DIRNAME = "worker_logs"
300+
301+
302+
def _worker_log_dir():
303+
script_dir = os.path.dirname(os.path.abspath(__file__))
304+
return os.path.join(script_dir, "results", _WORKER_LOG_DIRNAME)
305+
306+
295307
def _backend_name(value) -> str:
296308
if hasattr(value, "name"):
297309
return str(value.name)
@@ -461,6 +473,7 @@ def _build_result_record(
461473
runtime_identity: Mapping[str, Any],
462474
device_name: str,
463475
timestamp: str | None = None,
476+
worker_log_path: str | None = None,
464477
) -> dict[str, Any]:
465478
if not times or not peak_memories:
466479
raise ValueError("Cannot build a performance result record without measurement runs")
@@ -498,6 +511,8 @@ def _build_result_record(
498511
"individual_peak_memories_mb": [round(m, 1) for m in peak_memories],
499512
"thresholds":
500513
dict(thresholds),
514+
"worker_log_path":
515+
worker_log_path,
501516
"regression_thresholds":
502517
cfg.get("regression_thresholds", {}),
503518
"commit":
@@ -548,10 +563,19 @@ def _run_benchmark(cfg):
548563
os.makedirs(output_dir, exist_ok=True)
549564
gen_kwargs["output_path"] = output_dir
550565

566+
capture = WorkerLogCapture(
567+
_worker_log_dir(),
568+
cfg["benchmark_id"],
569+
datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ"),
570+
)
551571
generator = None
552572
try:
573+
# log_queue only goes to from_pretrained: workers keep the handler for
574+
# their lifetime, covering model load + warmups + measured runs.
575+
# Passing it to generate_video would detach it after the first call.
553576
generator = VideoGenerator.from_pretrained(
554577
model_path=model_info["model_path"],
578+
log_queue=capture.log_queue,
555579
**init_kwargs,
556580
)
557581
runtime_identity = _runtime_identity_from_generator(generator)
@@ -575,7 +599,12 @@ def _run_benchmark(cfg):
575599
peak_memories.append(peak_mb)
576600
all_component_times.append(component_times)
577601
finally:
602+
# Shutdown stops workers producing; close() then drains the queue so
603+
# the log file is complete before any assertion reads it back. The
604+
# performance CI lane copies results/worker_logs/ into PERF_REPORTS_DIR
605+
# for artifact upload.
578606
_shutdown_executor(generator)
607+
capture.close()
579608

580609
avg_time = sum(times) / len(times)
581610
max_peak_memory = max(peak_memories)
@@ -594,12 +623,23 @@ def _run_benchmark(cfg):
594623
prompt=prompt,
595624
runtime_identity=runtime_identity,
596625
device_name=device_name,
626+
worker_log_path=capture.log_path,
597627
)
598628

599629
logger.info("Performance results: avg_time=%.2fs, "
600630
"max_peak_memory=%.0fMB", avg_time, max_peak_memory)
601631
_write_results(results)
602632

633+
try:
634+
_assert_thresholds(results, thresholds, device_name)
635+
except AssertionError:
636+
print(format_worker_log_tail(cfg["benchmark_id"], capture.log_path), flush=True)
637+
raise
638+
639+
640+
def _assert_thresholds(results, thresholds, device_name):
641+
avg_time = results["avg_generation_time_s"]
642+
max_peak_memory = results["max_peak_memory_mb"]
603643
max_time = thresholds["max_generation_time_s"]
604644
max_mem = thresholds["max_peak_memory_mb"]
605645

fastvideo/tests/performance/test_inference_performance_result_schema.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,9 +107,11 @@ def test_build_result_record_emits_v2_wan_shape(monkeypatch):
107107
},
108108
device_name="NVIDIA L40S",
109109
timestamp="2026-07-05T00:00:00+00:00",
110+
worker_log_path="/tmp/worker_logs/worker_wan-t2v-1.3b-2gpu.log",
110111
)
111112

112113
assert record["result_schema_version"] == perf_test.RESULT_SCHEMA_VERSION
114+
assert record["worker_log_path"] == "/tmp/worker_logs/worker_wan-t2v-1.3b-2gpu.log"
113115
assert record["benchmark_id"] == "wan-t2v-1.3b-2gpu"
114116
assert record["workload_id"] == "wan-t2v"
115117
assert record["variant_id"] == "1.3b-sp2"
@@ -136,6 +138,28 @@ def test_build_result_record_emits_v2_wan_shape(monkeypatch):
136138
assert record["vae_decode_time_s"] == 3.2
137139

138140

141+
def test_build_result_record_defaults_worker_log_path_to_none(monkeypatch):
142+
monkeypatch.setenv("PERF_RUN_SOURCE", "scheduled_main")
143+
record = perf_test._build_result_record(
144+
cfg={"benchmark_id": "wan-t2v-1.3b-2gpu"},
145+
model_info={},
146+
init_kwargs={},
147+
gen_kwargs={},
148+
num_warmup=1,
149+
num_measure=1,
150+
thresholds={},
151+
times=[10.0],
152+
peak_memories=[10000.0],
153+
all_component_times=[],
154+
prompt="A cinematic video.",
155+
runtime_identity={},
156+
device_name="NVIDIA L40S",
157+
timestamp="2026-07-05T00:00:00+00:00",
158+
)
159+
160+
assert record["worker_log_path"] is None
161+
162+
139163
def test_validate_run_counts_rejects_zero_measurement_runs():
140164
with pytest.raises(ValueError, match="num_measurement_runs"):
141165
perf_test._validate_run_counts({
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
# SPDX-License-Identifier: Apache-2.0
2+
3+
import logging
4+
5+
from fastvideo.tests.performance.worker_log_capture import (
6+
DEFAULT_TAIL_LINES,
7+
LOG_DELIMITER,
8+
WorkerLogCapture,
9+
format_worker_log_tail,
10+
read_log_tail,
11+
)
12+
13+
14+
def _make_record(msg):
15+
return logging.LogRecord(
16+
name="fastvideo.worker",
17+
level=logging.INFO,
18+
pathname=__file__,
19+
lineno=1,
20+
msg=msg,
21+
args=(),
22+
exc_info=None,
23+
)
24+
25+
26+
def test_capture_round_trips_queue_and_parent_logger(tmp_path):
27+
capture = WorkerLogCapture(str(tmp_path), "bench", "20260807T000000Z")
28+
try:
29+
capture.log_queue.put(_make_record("from worker queue"))
30+
logging.getLogger("fastvideo").warning("from parent process")
31+
finally:
32+
capture.close()
33+
34+
content = open(capture.log_path, encoding="utf-8").read()
35+
assert "from worker queue" in content
36+
assert "from parent process" in content
37+
38+
39+
def test_close_drains_pending_queue_records(tmp_path):
40+
capture = WorkerLogCapture(str(tmp_path), "bench", "20260807T000000Z")
41+
for i in range(50):
42+
capture.log_queue.put(_make_record(f"pending record {i}"))
43+
capture.close()
44+
45+
content = open(capture.log_path, encoding="utf-8").read()
46+
for i in range(50):
47+
assert f"pending record {i}" in content
48+
49+
50+
def test_read_log_tail_truncates_to_last_lines(tmp_path):
51+
log_path = tmp_path / "worker_bench.log"
52+
total = DEFAULT_TAIL_LINES + 50
53+
log_path.write_text("".join(f"line {i}\n" for i in range(total)), encoding="utf-8")
54+
55+
tail = read_log_tail(str(log_path))
56+
assert tail is not None
57+
assert f"showing last {DEFAULT_TAIL_LINES} of {total} lines" in tail
58+
assert f"line {total - 1}" in tail
59+
assert "line 0\n" not in tail
60+
61+
62+
def test_read_log_tail_concatenates_rotated_backup(tmp_path):
63+
log_path = tmp_path / "worker_bench.log"
64+
(tmp_path / "worker_bench.log.1").write_text("older rotated line\n", encoding="utf-8")
65+
log_path.write_text("newer live line\n", encoding="utf-8")
66+
67+
tail = read_log_tail(str(log_path))
68+
assert tail == "older rotated line\nnewer live line\n"
69+
70+
71+
def test_read_log_tail_handles_missing_paths():
72+
assert read_log_tail(None) is None
73+
assert read_log_tail("/nonexistent/worker.log") is None
74+
75+
76+
def test_format_worker_log_tail_renders_unavailable_block():
77+
block = format_worker_log_tail("bench", None)
78+
assert LOG_DELIMITER in block
79+
assert "no log file recorded" in block
80+
assert "worker log unavailable" in block
81+
82+
block = format_worker_log_tail("bench", "/nonexistent/worker.log")
83+
assert "worker log unavailable" in block
84+
85+
86+
def test_format_worker_log_tail_renders_content(tmp_path):
87+
log_path = tmp_path / "worker_bench.log"
88+
log_path.write_text("dit slowdown detected\n", encoding="utf-8")
89+
90+
block = format_worker_log_tail("bench", str(log_path))
91+
assert "Worker log tail for bench" in block
92+
assert "dit slowdown detected" in block
93+
assert block.endswith(LOG_DELIMITER)

0 commit comments

Comments
 (0)