Files
vllm-omni/benchmarks/edge_harness/analyze_profile_resources.py
T

300 lines
13 KiB
Python

# SPDX-License-Identifier: Apache-2.0
"""Summarize raw host telemetry and traces without adding overlapping timings."""
import argparse
import gzip
import json
import math
import re
from collections import defaultdict
from pathlib import Path
from summarize_e2e_profiles import read_lines, stats
def interval_union(intervals):
total = 0.0
end = None
for start, stop in sorted(intervals):
if stop <= start:
continue
total += stop - start if end is None or start >= end else max(0, stop - end)
end = stop if end is None else max(end, stop)
return total
def trace_events(path):
"""Read one event at a time, avoiding a multi-GB decoded trace document."""
opener = gzip.open if path.suffix == ".gz" else open
with opener(path, "rt", encoding="utf-8") as stream:
buffer = ""
while True:
block = stream.read(65536)
if not block:
raise ValueError("No traceEvents array")
buffer += block
match = re.search(r'"traceEvents"\s*:\s*\[', buffer)
if match:
buffer = buffer[match.end() :]
break
if len(buffer) > 8 * 1024 * 1024:
raise ValueError("Trace header exceeds 8 MiB")
decoder = json.JSONDecoder()
while True:
buffer = buffer.lstrip(" \t\r\n,")
if buffer.startswith("]"):
return
try:
event, end = decoder.raw_decode(buffer)
except json.JSONDecodeError:
block = stream.read(65536)
if not block:
raise ValueError("Truncated traceEvents array") from None
buffer += block
if len(buffer) > 16 * 1024 * 1024:
raise ValueError("One trace event exceeds 16 MiB") from None
continue
buffer = buffer[end:]
yield event
def trace_summary(path):
categories, ops = defaultdict(list), defaultdict(list)
for event in trace_events(path):
if event.get("ph") != "X" or not isinstance(event.get("dur"), (int, float)):
continue
categories[event.get("cat", "uncategorized")].append((event["ts"], event["ts"] + event["dur"]))
ops[(event.get("cat", ""), event.get("name", ""))].append(float(event["dur"]))
return {
"source": str(path),
"scope": "one process trace; event duration sums overlap and are not E2E time",
"categories": {
name: {
"events": len(group),
"duration_sum_us": sum(b - a for a, b in group),
"busy_union_us": interval_union(group),
}
for name, group in categories.items()
},
"largest_ops_by_duration_sum": sorted(
[
{"category": cat, "name": name, "duration_sum_us": sum(values), "duration_us": stats(values)}
for (cat, name), values in ops.items()
],
key=lambda r: r["duration_sum_us"],
reverse=True,
)[:40],
}
def telemetry_summary(path, start=None, stop=None):
rows = [
r for r in read_lines(path) if (start is None or r["unix"] >= start) and (stop is None or r["unix"] <= stop)
]
series = defaultdict(list)
power = []
for row in rows:
series["host_available_bytes"].append(row["host_memory"]["available"])
series["host_used_bytes"].append(row["host_memory"]["used"])
series["host_cpu_percent"].append(row.get("host_cpu_percent"))
fields = row.get("gpu_csv", "").split(",")
if len(fields) != 10:
continue
for index, name in [
(2, "gpu_memory_mib"),
(4, "gpu_temperature_c"),
(5, "gpu_power_w"),
(6, "gpu_sm_clock_mhz"),
(7, "gpu_mem_clock_mhz"),
(8, "gpu_utilization_percent"),
]:
try:
value = float(fields[index])
except ValueError:
continue
if not math.isfinite(value):
continue
series[name].append(value)
if index == 5:
power.append((row["unix"], value))
gaps = [b["unix"] - a["unix"] for a, b in zip(rows, rows[1:])]
intervals = [(a, b) for a, b in zip(power, power[1:]) if 0 < b[0] - a[0] <= 5]
energy = sum((b[0] - a[0]) * (a[1] + b[1]) / 2 for a, b in intervals)
return {
"source": str(path),
"samples": len(rows),
"span_s": rows[-1]["unix"] - rows[0]["unix"] if len(rows) >= 2 else None,
"requested_window": {"start": start, "stop": stop},
"sampling_gap_s": stats(gaps),
"gaps_over_5s": sum(g > 5 for g in gaps),
"metrics": {key: stats(values) for key, values in series.items()},
"ac_states": sorted({r["power"]["ACLineStatus"] for r in rows if "power" in r}),
"whole_gpu_energy_j_over_observed_intervals": energy if intervals else None,
"energy_observed_duration_s": sum(b[0] - a[0] for a, b in intervals),
"energy_scope": (
"Whole NVIDIA GPU incl. other processes; trapezoids skip gaps >5s; not whole-system, CPU or NPU energy."
),
}
def main():
p = argparse.ArgumentParser(description=__doc__)
p.add_argument("--runs", type=Path, required=True)
p.add_argument("--out", type=Path, required=True)
p.add_argument(
"--skip-traces", action="store_true", help="Summarize telemetry only while inference timing is still running."
)
a = p.parse_args()
result = {"telemetry": {}, "traces": [], "trace_analysis": "deferred" if a.skip_traces else "requested"}
for host in sorted(a.runs.glob("*-host")):
raw = host / "telemetry.jsonl"
if not raw.is_file():
continue
name = host.name.removesuffix("-host")
record = {"whole_run": telemetry_summary(raw)}
report_path = a.runs / name / "report.json"
if report_path.is_file():
report = json.loads(report_path.read_text(encoding="utf-8"))
host_report_path = host / "status.json"
host_report = json.loads(host_report_path.read_text(encoding="utf-8")) if host_report_path.is_file() else {}
rows = read_lines(a.runs / name / "requests.jsonl")
warmups = [r for r in rows if r.get("phase") == "warmup"]
launch, report_start = host_report.get("start_unix"), report.get("start_unix")
log_path = host / "command.log"
timing_lines = []
log_lines = []
if log_path.is_file():
log_lines = log_path.read_text(encoding="utf-8", errors="replace").splitlines()
timing_lines = [
line
for line in log_lines
if any(
marker in line
for marker in (
"Loading model weights took",
"Model loading took",
"init engine (",
"torch.compile took",
"Captured talker_mtp graphs",
"engine startup completed",
)
)
]
record["lifecycle_log_observations"] = {
"scope": "Counts are log lines, not request counts or a correctness verdict. Inspect source logs.",
"markers": {},
}
for marker in ("Dropping output for unknown req", "force killing remaining process "):
matches = [line for line in log_lines if marker in line]
record["lifecycle_log_observations"]["markers"][marker] = {
"count": len(matches),
"examples": matches[:3] + matches[-3:] if len(matches) > 6 else matches,
}
first_submit = warmups[0].get("submitted_unix") if warmups else None
first_latency = warmups[0].get("ttft_s") if warmups else None
if first_latency is None and warmups and warmups[0].get("ttfa_ms") is not None:
first_latency = warmups[0]["ttfa_ms"] / 1000
record["startup"] = {
"cache_condition": report.get("cache_condition", "See source report"),
"constructor_s": report.get("startup_s"),
"host_launch_to_benchmark_report_start_s": report_start - launch if launch and report_start else None,
"host_launch_to_first_request_s": first_submit - launch if first_submit and launch else None,
"host_launch_to_first_output_s": (
first_submit - launch + first_latency
if first_submit and launch and first_latency is not None
else None
),
"inclusive_startup_scope": (
"Host launch through first warmup submission/output includes imports, artifact hashing, "
"plan construction and engine startup; uses recorded host/engine wall timestamps. "
"Constructor timing alone excludes preparation before the constructor."
),
"pre_constructor_scope": (
"Host launch to report creation includes Python/import/preparation work; "
"not isolated OS process creation."
),
"first_warmup": {
key: warmups[0].get(key)
for key in (
"request_id",
"length_band",
"concurrency",
"wall_s",
"ttft_s",
"total_wall_s",
"ttfa_ms",
"rtf_total",
)
}
if warmups
else None,
"runtime_loading_compilation_evidence": timing_lines,
"scope": (
"Runtime-reported component lines retain original boundaries; "
"do not sum parallel-stage times into startup latency."
),
}
start, duration = report.get("sustained_start_unix"), report.get("sustained_wall_s")
if start and duration:
record["sustained"] = telemetry_summary(raw, start, start + duration)
record["sustained_first_5min"] = telemetry_summary(raw, start, start + 300)
record["sustained_last_5min"] = telemetry_summary(raw, start + duration - 300, start + duration)
record["sustained_last_60s"] = telemetry_summary(raw, start + duration - 60, start + duration)
requests = [r for r in rows if r.get("phase") == "sustained"]
record["sustained_request_summary"] = {
"n": len(requests),
"rtf_above_one_requests": sum((r.get("rtf_total") or 0) > 1 for r in requests),
"simulated_playback_deficit_requests": sum((r.get("stall_at_ttfa_ms") or 0) > 0 for r in requests),
"rtf_total": stats([r.get("rtf_total") for r in requests]),
"scope": "All sustained requests; timing/real-time gates are separate from successful completion.",
}
record["sustained_request_trends"] = {}
for label, begin, end in [
("first_5min", start, start + 300),
("last_5min", start + duration - 300, start + duration),
("last_60s", start + duration - 60, start + duration),
]:
window = [r for r in requests if begin <= r.get("submitted_unix", -1) < end]
record["sustained_request_trends"][label] = {
"n": len(window),
"window_basis": "request submission time",
"metrics": {
metric: stats([r.get(metric) for r in window])
for metric in (
"wall_s",
"ttft_s",
"decode_tok_per_s",
"total_wall_s",
"ttfa_ms",
"rtf_total",
"stall_at_ttfa_ms",
)
},
}
result["telemetry"][name] = record
for path in [] if a.skip_traces else sorted(a.runs.glob("*trace*/traces/**/*")):
if path.is_file() and (path.name.endswith(".json") or path.name.endswith(".json.gz")):
report_path = a.runs / path.relative_to(a.runs).parts[0] / "report.json"
if (
not report_path.is_file()
or json.loads(report_path.read_text(encoding="utf-8")).get("status") != "completed"
):
result["traces"].append(
{
"source": str(path),
"status": "not_analyzed",
"reason": "Diagnostic has no completed report; trace may be incomplete.",
}
)
continue
try:
result["traces"].append(trace_summary(path))
except Exception as error:
result["traces"].append({"source": str(path), "error": repr(error)})
a.out.write_text(json.dumps(result, indent=2) + "\n", encoding="utf-8")
print(f"Summarized {len(result['telemetry'])} telemetry runs and {len(result['traces'])} traces")
if __name__ == "__main__":
main()