Skip to content

Commit 780bdae

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 6eb9569 commit 780bdae

8 files changed

Lines changed: 349 additions & 0 deletions

File tree

.buildkite/scripts/pr_test.sh

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,19 @@ upload_performance_artifacts() {
151151
fi
152152
}
153153

154+
_upload_worker_logs() {
155+
local found=0
156+
while IFS= read -r -d '' target; do
157+
found=1
158+
log "Found worker log: $target. Uploading to Buildkite..."
159+
buildkite-agent artifact upload "$target"
160+
done < <(find "$LOCAL_DIR" -path "*/worker_logs/worker_*.log*" -print0)
161+
162+
if [ "$found" -eq 0 ]; then
163+
log "No worker log artifacts found."
164+
fi
165+
}
166+
154167
_cleanup_modal_volume() {
155168
log "Cleaning up perf_reports/ from Modal Volume..."
156169
if modal volume rm hf-model-weights "perf_reports/" --recursive; then
@@ -170,6 +183,7 @@ upload_performance_artifacts() {
170183
_upload_dashboard
171184
_upload_perf_summary
172185
_upload_normalized_perf_results
186+
_upload_worker_logs
173187
_cleanup_modal_volume
174188
_cleanup_local
175189
}

docs/contributing/performance_benchmarks.md

Lines changed: 13 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,
@@ -480,6 +481,18 @@ record is checked against its own static thresholds: a measured breach reports
480481
nonzero pytest exit with no attributable static-threshold breach reports
481482
`INFRA_ERROR`. Failed records have `success=false` and are excluded from future
482483
rolling baselines. The dashboard still runs best-effort for observability.
484+
485+
Each benchmark run also captures worker-process logs into
486+
`results/worker_logs/worker_<benchmark_id>_<ts>.log` (raw records point to it
487+
via `worker_log_path`; the field is null/absent in older and HF-synced
488+
records). On a hard regression, a 200-line tail of that log is printed in the
489+
failure output — by the pytest assertion on PR runs and by
490+
`compare_baseline.py` on scheduled runs — and the log file is copied to
491+
`$PERF_REPORTS_DIR/worker_logs/` so `upload_performance_artifacts` uploads it
492+
as a Buildkite artifact even when the comparison phase never runs. Coverage
493+
caveat: only the `fastvideo` logger is captured (no torch/NCCL or raw stderr
494+
output), and ranks > 0 suppress `logger.info` by default, so the file contains
495+
rank-0 INFO plus WARNING/ERROR from all ranks.
483496
When the rolling-baseline phase runs, it emits:
484497

485498
* **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__)),
@@ -725,6 +727,12 @@ def main() -> int:
725727
print("Performance regression check failed:")
726728
for item in all_failures:
727729
print(f" - {item}")
730+
for raw, fixed in zip(current_results, static_threshold_failures, strict=True):
731+
if fixed:
732+
print(format_worker_log_tail(
733+
raw.get("benchmark_id", "unknown"),
734+
raw.get("worker_log_path"),
735+
))
728736
return 1
729737

730738
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: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import glob
1010
import json
1111
import os
12+
import shutil
1213
import time
1314
from collections.abc import Mapping
1415
from datetime import datetime, timezone
@@ -19,6 +20,10 @@
1920

2021
from fastvideo import VideoGenerator
2122
from fastvideo.logger import init_logger
23+
from fastvideo.tests.performance.worker_log_capture import (
24+
WorkerLogCapture,
25+
format_worker_log_tail,
26+
)
2227
from fastvideo.tests.performance.identity import (
2328
benchmark_identity_from_config,
2429
build_recipe_from_benchmark_config,
@@ -304,6 +309,28 @@ def _write_results(results):
304309
logger.info("Performance results written to %s", filepath)
305310

306311

312+
_WORKER_LOG_DIRNAME = "worker_logs"
313+
314+
315+
def _worker_log_dir():
316+
script_dir = os.path.dirname(os.path.abspath(__file__))
317+
return os.path.join(script_dir, "results", _WORKER_LOG_DIRNAME)
318+
319+
320+
def _copy_log_to_perf_reports(log_path):
321+
"""Best-effort copy so the log survives as a Buildkite artifact even when
322+
compare_baseline.py never runs (PR hard-regression path). No-op locally."""
323+
reports_dir = os.environ.get("PERF_REPORTS_DIR", "/root/data/perf_reports")
324+
try:
325+
dest_dir = os.path.join(reports_dir, _WORKER_LOG_DIRNAME)
326+
os.makedirs(dest_dir, exist_ok=True)
327+
for path in (f"{log_path}.1", log_path):
328+
if os.path.isfile(path):
329+
shutil.copy2(path, dest_dir)
330+
except OSError as exc:
331+
logger.warning("Could not copy worker log to %s: %s", reports_dir, exc)
332+
333+
307334
def _backend_name(value) -> str:
308335
if hasattr(value, "name"):
309336
return str(value.name)
@@ -480,6 +507,7 @@ def _build_result_record(
480507
runtime_identity: Mapping[str, Any],
481508
device_name: str,
482509
timestamp: str | None = None,
510+
worker_log_path: str | None = None,
483511
) -> dict[str, Any]:
484512
if not times or not peak_memories:
485513
raise ValueError("Cannot build a performance result record without measurement runs")
@@ -512,6 +540,7 @@ def _build_result_record(
512540
"max_peak_memory_mb": round(max_peak_memory, 1),
513541
"individual_peak_memories_mb": [round(m, 1) for m in peak_memories],
514542
"thresholds": dict(thresholds),
543+
"worker_log_path": worker_log_path,
515544
"regression_thresholds": cfg.get("regression_thresholds", {}),
516545
"commit": os.environ.get("BUILDKITE_COMMIT", ""),
517546
**_ci_provenance_fields(),
@@ -558,10 +587,19 @@ def _run_benchmark(cfg):
558587
os.makedirs(output_dir, exist_ok=True)
559588
gen_kwargs["output_path"] = output_dir
560589

590+
capture = WorkerLogCapture(
591+
_worker_log_dir(),
592+
cfg["benchmark_id"],
593+
datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ"),
594+
)
561595
generator = None
562596
try:
597+
# log_queue only goes to from_pretrained: workers keep the handler for
598+
# their lifetime, covering model load + warmups + measured runs.
599+
# Passing it to generate_video would detach it after the first call.
563600
generator = VideoGenerator.from_pretrained(
564601
model_path=model_info["model_path"],
602+
log_queue=capture.log_queue,
565603
**init_kwargs,
566604
)
567605
runtime_identity = _runtime_identity_from_generator(generator)
@@ -585,7 +623,11 @@ def _run_benchmark(cfg):
585623
peak_memories.append(peak_mb)
586624
all_component_times.append(component_times)
587625
finally:
626+
# Shutdown stops workers producing; close() then drains the queue so
627+
# the log file is complete before any assertion reads it back.
588628
_shutdown_executor(generator)
629+
capture.close()
630+
_copy_log_to_perf_reports(capture.log_path)
589631

590632
avg_time = sum(times) / len(times)
591633
max_peak_memory = max(peak_memories)
@@ -604,13 +646,24 @@ def _run_benchmark(cfg):
604646
prompt=prompt,
605647
runtime_identity=runtime_identity,
606648
device_name=device_name,
649+
worker_log_path=capture.log_path,
607650
)
608651

609652
logger.info(
610653
"Performance results: avg_time=%.2fs, "
611654
"max_peak_memory=%.0fMB", avg_time, max_peak_memory)
612655
_write_results(results)
613656

657+
try:
658+
_assert_thresholds(results, thresholds, device_name)
659+
except AssertionError:
660+
print(format_worker_log_tail(cfg["benchmark_id"], capture.log_path), flush=True)
661+
raise
662+
663+
664+
def _assert_thresholds(results, thresholds, device_name):
665+
avg_time = results["avg_generation_time_s"]
666+
max_peak_memory = results["max_peak_memory_mb"]
614667
max_time = thresholds["max_generation_time_s"]
615668
max_mem = thresholds["max_peak_memory_mb"]
616669

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)