[ci] Surface worker process logs on perf hard regressions (#1701)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: SolitaryThinker <wlsaidhi@gmail.com>
This commit is contained in:
Yogya Mehrotra
2026-10-07 02:14:02 -07:00
committed by GitHub
co-authored by Claude Fable 5 SolitaryThinker
parent 13b3b36b8b
commit 13ef81e6da
8 changed files with 339 additions and 0 deletions
+12
View File
@@ -41,6 +41,18 @@ if [ "$pytest_rc" -eq 0 ] || [ "$PERF_UPLOAD_POLICY" = always ]; then
fi
python ./fastvideo/tests/performance/dashboard.py || true
cp -f fastvideo/tests/performance/results/*.json "$PERF_REPORTS_DIR/" 2>/dev/null || true
# The trusted host relays only .md/.html/.json/.csv from PERF_REPORTS_DIR, so
# mirror each captured worker log with an allowlisted extension.
for worker_log in fastvideo/tests/performance/results/worker_logs/*.log; do
[ -f "$worker_log" ] || continue
base=$(basename "${worker_log%.log}")
# WorkerLogCapture keeps a .log.1 backup after rollover, and read_log_tail
# includes it; mirror that retained history too so the artifact is complete.
if [ -f "$worker_log.1" ]; then
cp -f "$worker_log.1" "$PERF_REPORTS_DIR/${base}.1.md" 2>/dev/null || true
fi
cp -f "$worker_log" "$PERF_REPORTS_DIR/${base}.md" 2>/dev/null || true
done
echo "--- GPU telemetry (clocks.sm vs clocks.max.sm reveals capped hosts) ---"
cat "$PERF_REPORTS_DIR/gpu_telemetry.csv" || true
@@ -357,6 +357,7 @@ Written by `test_inference_performance.py`. One file per benchmark run.
"max_dit_time_s": 10.0,
"max_vae_decode_time_s": 10.0
},
"worker_log_path": "<container-local path to the captured worker log, or null>",
"regression_thresholds": {
"latency": {
"threshold_percent": 0.10,
@@ -542,6 +543,22 @@ record is checked against its own static thresholds: a measured breach reports
nonzero pytest exit with no attributable static-threshold breach reports
`INFRA_ERROR`. Failed records have `success=false` and are excluded from future
rolling baselines. The dashboard still runs best-effort for observability.
Each benchmark run also captures worker-process logs into
`results/worker_logs/worker_<benchmark_id>_<ts>.log` (raw records point to it
via `worker_log_path`; the field is null/absent in older and HF-synced
records). On a hard regression, a 200-line tail of that log is printed in the
failure output — by the pytest assertion on PR runs and by
`compare_baseline.py` on scheduled runs. The performance lane
(`.buildkite/scripts/lanes/performance.sh`) also mirrors each captured log into
`$PERF_REPORTS_DIR` as `worker_<benchmark_id>_<ts>.md` next to the raw JSON
results, so the log is retrievable as a build artifact even when the
comparison phase never runs. The mirror uses an allowlisted extension because
the trusted host relays only `.md`, `.html`, `.json`, and `.csv` files from
that directory; the `.log` files themselves are never uploaded. Coverage
caveat: only the `fastvideo` logger is captured (no torch/NCCL or raw stderr
output), and ranks > 0 suppress `logger.info` by default, so the file contains
rank-0 INFO plus WARNING/ERROR from all ranks.
When the rolling-baseline phase runs, it emits:
* **Markdown summary** — appended to `$GITHUB_STEP_SUMMARY` when that variable
@@ -35,6 +35,7 @@ try:
resolve_metric_policies,
serialize_metric_thresholds,
)
from fastvideo.tests.performance.worker_log_capture import format_worker_log_tail
except ImportError:
repo_root = os.path.abspath(os.path.join(os.path.dirname(__file__), "../../.."))
if repo_root not in sys.path:
@@ -52,6 +53,7 @@ except ImportError:
resolve_metric_policies,
serialize_metric_thresholds,
)
from fastvideo.tests.performance.worker_log_capture import format_worker_log_tail
RESULTS_DIR = os.path.join(
os.path.dirname(os.path.abspath(__file__)),
@@ -602,6 +604,7 @@ def main() -> int:
unattributed_pytest_failure = performance_pytest_failed and not any(static_threshold_failures)
all_failures: list[str] = []
regression_records: list[dict[str, Any]] = []
summary_rows: list[dict[str, Any]] = []
if persist_tracking:
@@ -670,6 +673,8 @@ def main() -> int:
record["baseline_eligible"] = (identity_filters is not None and _is_baseline_eligible(
record["run_source"], record["success"], comparison_status))
all_failures.extend(failures)
if comparison_status == STATUS_REGRESSION:
regression_records.append(raw)
print(f"{record['model_id']} comparison status: "
f"{comparison_status} - {comparison_status_reason}")
@@ -693,6 +698,11 @@ def main() -> int:
print("Performance regression check failed:")
for item in all_failures:
print(f" - {item}")
for raw in regression_records:
print(format_worker_log_tail(
raw.get("benchmark_id", "unknown"),
raw.get("worker_log_path"),
))
return 1
print("Performance baseline comparison passed")
@@ -1942,3 +1942,50 @@ def test_upload_policy_pass_allows_calibration_record(monkeypatch):
"success": True,
"comparison_status": compare_baseline.STATUS_CALIBRATION_NEEDED,
}) is True
def _run_main_with_static_breach(monkeypatch, tmp_path, raw_overrides, env_overrides):
results_dir = tmp_path / "results"
reports_dir = tmp_path / "reports"
tracking_root = tmp_path / "tracking"
results_dir.mkdir()
raw_result = _v2_raw_result(
avg_generation_time_s=18.252,
thresholds={"max_generation_time_s": 10.0},
**raw_overrides,
)
(results_dir / "perf_breach.json").write_text(json.dumps(raw_result), encoding="utf-8")
env_overrides.enter_context(envs.override_external("PERF_RUN_SOURCE", "scheduled_main"))
env_overrides.enter_context(envs.override_external("PERF_PYTEST_RC", None))
monkeypatch.setattr(compare_baseline, "RESULTS_DIR", str(results_dir))
monkeypatch.setattr(compare_baseline, "PERF_REPORTS_DIR", str(reports_dir))
monkeypatch.setattr(compare_baseline, "TRACKING_ROOT", str(tracking_root))
monkeypatch.setattr(compare_baseline, "UPLOAD_POLICY", "never")
monkeypatch.setattr(compare_baseline, "sync_from_hf", lambda local_dir, strict=False: local_dir)
assert compare_baseline.main() == 1
def test_static_threshold_failure_prints_worker_log_tail(monkeypatch, tmp_path, capsys, env_overrides):
worker_log = tmp_path / "worker_wan-t2v-1.3b-2gpu.log"
worker_log.write_text("attention backend fell back to slow path\n", encoding="utf-8")
_run_main_with_static_breach(
monkeypatch, tmp_path, {"worker_log_path": str(worker_log)}, env_overrides)
output = capsys.readouterr().out
assert "exceeded fixed threshold" in output
assert "Worker log tail for wan-t2v-1.3b-2gpu" in output
assert "attention backend fell back to slow path" in output
def test_static_threshold_failure_without_worker_log_degrades_gracefully(
monkeypatch, tmp_path, capsys, env_overrides):
_run_main_with_static_breach(monkeypatch, tmp_path, {}, env_overrides)
output = capsys.readouterr().out
assert "exceeded fixed threshold" in output
assert "no log file recorded" in output
assert "worker log unavailable" in output
@@ -20,6 +20,10 @@ import pytest
import fastvideo.envs as envs
from fastvideo import VideoGenerator
from fastvideo.logger import init_logger
from fastvideo.tests.performance.worker_log_capture import (
WorkerLogCapture,
format_worker_log_tail,
)
from fastvideo.tests.performance.identity import (
benchmark_identity_from_config,
build_recipe_from_benchmark_config,
@@ -315,6 +319,14 @@ def _write_results(results):
logger.info("Performance results written to %s", filepath)
_WORKER_LOG_DIRNAME = "worker_logs"
def _worker_log_dir():
script_dir = os.path.dirname(os.path.abspath(__file__))
return os.path.join(script_dir, "results", _WORKER_LOG_DIRNAME)
def _backend_name(value) -> str:
if hasattr(value, "name"):
return str(value.name)
@@ -484,6 +496,7 @@ def _build_result_record(
runtime_identity: Mapping[str, Any],
device_name: str,
timestamp: str | None = None,
worker_log_path: str | None = None,
) -> dict[str, Any]:
if not times or not peak_memories:
raise ValueError("Cannot build a performance result record without measurement runs")
@@ -521,6 +534,8 @@ def _build_result_record(
"individual_peak_memories_mb": [round(m, 1) for m in peak_memories],
"thresholds":
dict(thresholds),
"worker_log_path":
worker_log_path,
"regression_thresholds":
cfg.get("regression_thresholds", {}),
"commit":
@@ -594,10 +609,19 @@ def _run_benchmark(cfg):
os.makedirs(output_dir, exist_ok=True)
gen_kwargs["output_path"] = output_dir
capture = WorkerLogCapture(
_worker_log_dir(),
cfg["benchmark_id"],
datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ"),
)
generator = None
try:
# log_queue only goes to from_pretrained: workers keep the handler for
# their lifetime, covering model load + warmups + measured runs.
# Passing it to generate_video would detach it after the first call.
generator = VideoGenerator.from_pretrained(
model_path=model_info["model_path"],
log_queue=capture.log_queue,
**init_kwargs,
)
runtime_identity = _runtime_identity_from_generator(generator)
@@ -621,7 +645,12 @@ def _run_benchmark(cfg):
peak_memories.append(peak_mb)
all_component_times.append(component_times)
finally:
# Shutdown stops workers producing; close() then drains the queue so
# the log file is complete before any assertion reads it back. The
# performance CI lane mirrors results/worker_logs/*.log into
# PERF_REPORTS_DIR as allowlisted .md files for artifact upload.
_shutdown_executor(generator)
capture.close()
avg_time = sum(times) / len(times)
max_peak_memory = max(peak_memories)
@@ -640,12 +669,22 @@ def _run_benchmark(cfg):
prompt=prompt,
runtime_identity=runtime_identity,
device_name=device_name,
worker_log_path=capture.log_path,
)
logger.info("Performance results: avg_time=%.2fs, "
"max_peak_memory=%.0fMB", avg_time, max_peak_memory)
_write_results(results)
try:
_assert_thresholds(results, thresholds, device_name, avg_time=avg_time, max_peak_memory=max_peak_memory)
except AssertionError:
print(format_worker_log_tail(cfg["benchmark_id"], capture.log_path), flush=True)
raise
def _assert_thresholds(results, thresholds, device_name, avg_time, max_peak_memory):
"""Gate on the raw measurements: the emitted record stores rounded copies."""
max_time = thresholds["max_generation_time_s"]
max_mem = thresholds["max_peak_memory_mb"]
@@ -108,9 +108,11 @@ def test_build_result_record_emits_v2_wan_shape(monkeypatch, env_overrides):
},
device_name="NVIDIA L40S",
timestamp="2026-07-05T00:00:00+00:00",
worker_log_path="/tmp/worker_logs/worker_wan-t2v-1.3b-2gpu.log",
)
assert record["result_schema_version"] == perf_test.RESULT_SCHEMA_VERSION
assert record["worker_log_path"] == "/tmp/worker_logs/worker_wan-t2v-1.3b-2gpu.log"
assert record["benchmark_id"] == "wan-t2v-1.3b-2gpu"
assert record["workload_id"] == "wan-t2v"
assert record["variant_id"] == "1.3b-sp2"
@@ -137,6 +139,28 @@ def test_build_result_record_emits_v2_wan_shape(monkeypatch, env_overrides):
assert record["vae_decode_time_s"] == 3.2
def test_build_result_record_defaults_worker_log_path_to_none(monkeypatch, env_overrides):
env_overrides.enter_context(envs.override_external("PERF_RUN_SOURCE", "scheduled_main"))
record = perf_test._build_result_record(
cfg={"benchmark_id": "wan-t2v-1.3b-2gpu"},
model_info={},
init_kwargs={},
gen_kwargs={},
num_warmup=1,
num_measure=1,
thresholds={},
times=[10.0],
peak_memories=[10000.0],
all_component_times=[],
prompt="A cinematic video.",
runtime_identity={},
device_name="NVIDIA L40S",
timestamp="2026-07-05T00:00:00+00:00",
)
assert record["worker_log_path"] is None
def test_validate_run_counts_rejects_zero_measurement_runs():
with pytest.raises(ValueError, match="num_measurement_runs"):
perf_test._validate_run_counts({
@@ -0,0 +1,93 @@
# SPDX-License-Identifier: Apache-2.0
import logging
from fastvideo.tests.performance.worker_log_capture import (
DEFAULT_TAIL_LINES,
LOG_DELIMITER,
WorkerLogCapture,
format_worker_log_tail,
read_log_tail,
)
def _make_record(msg):
return logging.LogRecord(
name="fastvideo.worker",
level=logging.INFO,
pathname=__file__,
lineno=1,
msg=msg,
args=(),
exc_info=None,
)
def test_capture_round_trips_queue_and_parent_logger(tmp_path):
capture = WorkerLogCapture(str(tmp_path), "bench", "20260807T000000Z")
try:
capture.log_queue.put(_make_record("from worker queue"))
logging.getLogger("fastvideo").warning("from parent process")
finally:
capture.close()
content = open(capture.log_path, encoding="utf-8").read()
assert "from worker queue" in content
assert "from parent process" in content
def test_close_drains_pending_queue_records(tmp_path):
capture = WorkerLogCapture(str(tmp_path), "bench", "20260807T000000Z")
for i in range(50):
capture.log_queue.put(_make_record(f"pending record {i}"))
capture.close()
content = open(capture.log_path, encoding="utf-8").read()
for i in range(50):
assert f"pending record {i}" in content
def test_read_log_tail_truncates_to_last_lines(tmp_path):
log_path = tmp_path / "worker_bench.log"
total = DEFAULT_TAIL_LINES + 50
log_path.write_text("".join(f"line {i}\n" for i in range(total)), encoding="utf-8")
tail = read_log_tail(str(log_path))
assert tail is not None
assert f"showing last {DEFAULT_TAIL_LINES} of {total} lines" in tail
assert f"line {total - 1}" in tail
assert "line 0\n" not in tail
def test_read_log_tail_concatenates_rotated_backup(tmp_path):
log_path = tmp_path / "worker_bench.log"
(tmp_path / "worker_bench.log.1").write_text("older rotated line\n", encoding="utf-8")
log_path.write_text("newer live line\n", encoding="utf-8")
tail = read_log_tail(str(log_path))
assert tail == "older rotated line\nnewer live line\n"
def test_read_log_tail_handles_missing_paths():
assert read_log_tail(None) is None
assert read_log_tail("/nonexistent/worker.log") is None
def test_format_worker_log_tail_renders_unavailable_block():
block = format_worker_log_tail("bench", None)
assert LOG_DELIMITER in block
assert "no log file recorded" in block
assert "worker log unavailable" in block
block = format_worker_log_tail("bench", "/nonexistent/worker.log")
assert "worker log unavailable" in block
def test_format_worker_log_tail_renders_content(tmp_path):
log_path = tmp_path / "worker_bench.log"
log_path.write_text("dit slowdown detected\n", encoding="utf-8")
block = format_worker_log_tail("bench", str(log_path))
assert "Worker log tail for bench" in block
assert "dit slowdown detected" in block
assert block.endswith(LOG_DELIMITER)
@@ -0,0 +1,97 @@
# SPDX-License-Identifier: Apache-2.0
"""Capture worker-process logs during performance benchmark runs.
Workers spawned by MultiprocExecutor forward records from the "fastvideo"
logger into a multiprocessing queue when one is passed to
``VideoGenerator.from_pretrained(..., log_queue=...)``. This module consumes
that queue into a size-capped per-benchmark log file so the log can be
attached to CI failures and uploaded as a build artifact.
Coverage caveats: only the "fastvideo" logger is forwarded (no torch/NCCL or
raw stderr output), and ranks > 0 suppress ``logger.info`` by default
(``local_main_process_only=True``), so the file contains rank-0 INFO plus
WARNING/ERROR from all ranks.
"""
import logging
import logging.handlers
import multiprocessing
import os
LOG_MAX_BYTES = 10 * 1024 * 1024
DEFAULT_TAIL_LINES = 200
LOG_DELIMITER = "=" * 78
class WorkerLogCapture:
"""Capture "fastvideo" logger output from the test process and all worker
subprocesses into a size-capped per-benchmark log file."""
def __init__(self, log_dir: str, benchmark_id: str, timestamp: str):
os.makedirs(log_dir, exist_ok=True)
self.log_path = os.path.join(log_dir, f"worker_{benchmark_id}_{timestamp}.log")
# Manager queue required: with the spawn start method a plain
# mp.Queue() cannot be shipped to workers through the executor RPC.
self._manager = multiprocessing.Manager()
self.log_queue = self._manager.Queue()
self._file_handler = logging.handlers.RotatingFileHandler(
self.log_path, maxBytes=LOG_MAX_BYTES, backupCount=1, encoding="utf-8")
self._file_handler.setFormatter(
logging.Formatter("%(asctime)s [%(levelname)s] %(processName)s %(name)s: %(message)s"))
self._listener = logging.handlers.QueueListener(
self.log_queue, self._file_handler, respect_handler_level=True)
self._listener.start()
# Also persist parent-process logs (run markers, load orchestration)
# so the file reads as a single timeline.
self._parent_logger = logging.getLogger("fastvideo")
self._parent_logger.addHandler(self._file_handler)
def close(self) -> None:
"""Drain the queue and release resources.
Call after the executor is shut down (workers stop producing) and
before any threshold assertion reads the log back.
"""
try:
self._parent_logger.removeHandler(self._file_handler)
self._listener.stop()
self._file_handler.close()
finally:
self._manager.shutdown()
def read_log_tail(log_path: str | None, max_lines: int = DEFAULT_TAIL_LINES) -> str | None:
"""Return the last ``max_lines`` of the capture, or None if unavailable.
Concatenates the rotated ``.log.1`` backup (older) with the live file
(newer) so the tail spans a rollover boundary.
"""
if not log_path:
return None
lines: list[str] = []
for path in (f"{log_path}.1", log_path):
try:
if os.path.isfile(path):
with open(path, encoding="utf-8", errors="replace") as f:
lines.extend(f.readlines())
except OSError:
continue
if not lines:
return None
tail = lines[-max_lines:]
if len(lines) > max_lines:
tail.insert(0, f"... truncated, showing last {max_lines} of {len(lines)} lines ...\n")
return "".join(tail)
def format_worker_log_tail(benchmark_id: str, log_path: str | None) -> str:
"""Render a delimited worker-log tail block for CI failure output."""
tail = read_log_tail(log_path)
header = (f"{LOG_DELIMITER}\n"
f"Worker log tail for {benchmark_id} ({log_path or 'no log file recorded'})\n"
f"{LOG_DELIMITER}")
if tail is None:
return f"{header}\n<worker log unavailable: file missing or unreadable>\n{LOG_DELIMITER}"
if not tail.endswith("\n"):
tail += "\n"
return f"{header}\n{tail}{LOG_DELIMITER}"