Latency metrics, collective signal, and verification export#69
Latency metrics, collective signal, and verification export#69kwang3170 wants to merge 3 commits into
Conversation
Three signal gaps in the loop, each a separate concern sharing one file (gitm/scheduler/loop.py), so they land as one commit rather than a broken intermediate state. 1. Serving latency (TTFT/TPOT/goodput) + request join The loop measured only aggregate tokens/sec and had no per-request view. vllm_stats gains RequestRecord (TTFT/TPOT derived from vLLM's RequestOutput.metrics), ServingSummary, and summarize_requests. TPOT divides by n-1 (the first token's cost is TTFT) and is None for a one-token response rather than a rate from a zero-length gap. The join: SchedulerStatsSampler now takes a wall clock alongside its monotonic one, surfaced as SchedulerStatsSummary.t0_wall_ns, putting the scheduler series, request timestamps, and Trace.captured_at_ns on one clock. Persisted to scheduler_stats.json. Signals only — the A/B objective is untouched. 2. Collective (NCCL) time as a ranked cause NCCL kernels count as busy time, so a communication-bound run reported as well-utilized. collective_signal reuses node_rollup's matcher and interval math and adds only the ranking: CollectiveCause (field-for-field identical to SchedulerCause) plus two rules, exposed_collective and collective_dominant. Emitted into residuals.json. worst_device_comm splits by device_id first: device_comm_stats assumes a single-device trace, and on a merged multi-GPU capture one GPU's compute masks another's communication, collapsing exposed comm to zero on a fully blocked GPU. 3. Customer-verification export EngineABResult was read for two numbers and discarded. verification_export writes it to verification.json with the full engine config on both sides, reps, scatter, and environment. kept follows ApplyResult.rolled_back (the gate) rather than EngineABResult.kept (a measure-time delta>=0 indicator) so the export never claims a lever was kept that was rolled back. agreement_band is floored at MIN_NOISE_BAND because a single-rep A/B reports zero scatter, which would make ordinary jitter read as a failed reproduction. Export only — no generated reproduce script, report.py untouched. 30 new tests. Full suite unchanged: the 5 pre-existing failures (missing pyarrow, HFT dataset generation) fail identically on main. Not verified on GPU: whether RequestOutput.metrics populates on the target vLLM build (degrades to None fields), and whether gitm_llm_kwargs survives the restart path in a real structural-knob A/B. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Code Review by GeminiThe changes introduce significant new features: latency metrics, collective communication signal attribution, and a detailed verification export. The implementation is well-structured, with new modules for distinct functionalities and careful integration into the existing `run_loop`. The accompanying tests are comprehensive and cover various scenarios, including edge cases and error handling.
No critical bugs were found. The code demonstrates good practices like handling `None` values, defensive programming, and clear documentation.
### Minor Improvements
**1. `gitm/optimizer/verification_export.py` - `build_record` function**
The fallback logic for `candidate_config` is a best-effort attempt to reconstruct the configuration if `gitm_llm_kwargs` is not available from the `live_engine`. While robust, for a "customer report" aiming for full reproducibility, it's ideal to always capture the *actual* full configuration. If `gitm_llm_kwargs` is the authoritative source, relying on `c.spec.knobs` as a fallback might introduce subtle discrepancies if the intervention involves more complex changes not fully captured by `c.spec.knobs`.
Consider adding a comment or assertion to clarify when this fallback is expected to be hit (e.g., only for `DryRunApplicator` or specific engine versions) and to emphasize that for `LiveEngineApplicator`, `gitm_llm_kwargs` should ideally always be present and complete.
```diff
--- a/gitm/optimizer/verification_export.py
+++ b/gitm/optimizer/verification_export.py
@@ -109,6 +109,10 @@
candidate_cfg = dict(getattr(live_engine, "gitm_llm_kwargs", None) or {})
if not candidate_cfg and baseline_cfg:
candidate_cfg = {**baseline_cfg, **(c.spec.knobs or {c.spec.knob: c.spec.value})}
+ # NOTE: The fallback above (if candidate_cfg is empty) reconstructs the config
+ # from baseline + the intervention spec. This is a best-effort for cases
+ # where the live_engine does not expose its full kwargs (e.g., DryRunApplicator).
+ # For LiveEngineApplicator, gitm_llm_kwargs should ideally be complete.
verification.append(
build_record(
c.spec, ab, result,
2. This is the call site for the --- a/gitm/scheduler/loop.py
+++ b/gitm/scheduler/loop.py
@@ -560,6 +560,10 @@
live_engine = getattr(applicator, "engine", cfg.engine)
candidate_cfg = dict(getattr(live_engine, "gitm_llm_kwargs", None) or {})
if not candidate_cfg and baseline_cfg:
candidate_cfg = {**baseline_cfg, **(c.spec.knobs or {c.spec.knob: c.spec.value})}
+ # NOTE: The fallback above (if candidate_cfg is empty) reconstructs the config
+ # from baseline + the intervention spec. This is a best-effort for cases
+ # where the live_engine does not expose its full kwargs (e.g., DryRunApplicator).
+ # For LiveEngineApplicator, gitm_llm_kwargs should ideally be complete.
verification.append(
build_record(
c.spec, ab, result,
|
🤖 Claude Code ReviewReview🐛 Bugs
goodput = (len(met) / window_s) if window_s else None
🔒 Security
out_path.parent.mkdir(parents=True, exist_ok=True)If ⚡ Performance
Per-iteration 📊 Reproducibility
self._t0_ns = time.perf_counter_ns()
self._t0_wall_ns = time.time_ns()These two calls have an unmeasured gap (typically microseconds). Any code using
💡 Suggestions
assert exposed_floor <= dominant_floor, "exposed_floor should be <= dominant_floor"
if not candidate_cfg and baseline_cfg:
candidate_cfg = {**baseline_cfg, **(c.spec.knobs or {c.spec.knob: c.spec.value})}This synthesized config may be wrong if the apply path changed other knobs as side-effects (e.g. a restart that resets defaults). The fallback should at minimum log a warning so operators know the exported config is inferred, not observed.
req_records = list(run_out.get("requests") or []) if isinstance(run_out, dict) else []
No test for
|
The CI lint job runs `ruff check .` over the whole repo and was already red on main (12 errors, all in importer/metrics files this branch never touched). Clearing them here so the PR's lint check goes green; squash-merge folds this in. - ruff --fix: 9 unused imports / import-order in gitm/importers/* and metrics.py - 3 UP038: isinstance((A, B)) -> isinstance(A | B), valid on our py310 target Two points from review that were actually true: - vllm_stats: the clock-anchor comment claimed the two-clock offset was "exact"; it's sub-microsecond, not exact. Reworded with the real bound and why it's negligible against the 50ms sampling interval. - test_serving_latency: added the arrival-without-first-token case (TTFT unmeasurable -> no SLO can be met -> goodput 0), which the existing tests didn't lock in. Review points rejected as inaccurate: DeviceCommStats.device_id does exist; the rel_std test matches the real formula; collective_signal's two floors measure different denominators (wall vs busy) and aren't comparable. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
🤖 Claude Code ReviewCode Review🐛 Bugs
if isinstance(val, list | tuple) and len(val) >= 1:
x = as_int(val[0], 1) if len(val) > 0 else 1 # `len(val) > 0` always True hereThe inner
goodput = (len(met) / window_s) if window_s else None
agreement_band=max(ab.rel_std, MIN_NOISE_BAND)
🔒 Security
out_path.parent.mkdir(parents=True, exist_ok=True)
out_path.write_text(...)If ⚡ PerformancePer-iteration live_engine = getattr(applicator, "engine", cfg.engine)
candidate_cfg = dict(getattr(live_engine, "gitm_llm_kwargs", None) or {})This is inside the intervention loop. If
📊 Reproducibility
self._t0_ns = time.perf_counter_ns()
self._t0_wall_ns = time.time_ns()These two calls are not atomic. On a loaded system, context switches between them introduce an unknown offset (potentially milliseconds). The comment claims "sub-microsecond" but that's only a best-case. This offset is then used to align scheduler samples against request timestamps at second-scale latencies — acceptable in practice, but the "sub-microsecond" claim in the comment is misleading and should be softened. 💡 Suggestions
req_records = list(run_out.get("requests") or []) if isinstance(run_out, dict) else []
baseline_cfg = dict(getattr(cfg.engine, "gitm_llm_kwargs", None) or {})If candidate_cfg = {**baseline_cfg, **(c.spec.knobs or {c.spec.knob: c.spec.value})}produces a candidate config that only contains the changed knob — not genuinely useful for reproduction. A warning log when the attribute is missing would surface this silently degraded case.
Missing
|
Code Review by GeminiThe changes introduce significant new features for latency metrics, collective communication analysis, and verification export. The code is generally well-structured, documented, and accompanied by comprehensive tests. However, there is a potential compatibility issue related to Python version syntax. Issue: Python Version Compatibility for Type Union SyntaxThe new Files and Lines to Change: 1. --- a/gitm/importers/torch_trace.py
+++ b/gitm/importers/torch_trace.py
@@ -84,7 +84,7 @@ def _arg_get(args: dict[str, Any] | None, keys: tuple[str, ...]) -> Any:
lower = {str(k).lower(): v for k, v in args.items()}
for k in keys:
if k.lower() in lower:
return lower[k.lower()]
return None
def _as_dims(val: Any) -> tuple[int, int, int]:
if val is None:
return (1, 1, 1)
- if isinstance(val, (list, tuple)) and len(val) >= 1:
+ if isinstance(val, list | tuple) and len(val) >= 1:
x = as_int(val[0], 1) if len(val) > 0 else 1
y = as_int(val[1], 1) if len(val) > 1 else 1
z = as_int(val[2], 1) if len(val) > 2 else 1
return (max(x, 1), max(y, 1), max(z, 1))
if isinstance(val, dict):
return (
max(as_int(val.get("x", val.get("X", 1)), 1), 1),
max(as_int(val.get("y", val.get("Y", 1)), 1), 1),
max(as_int(val.get("z", val.get("Z", 1)), 1), 1),
)Suggested Change: --- a/gitm/importers/torch_trace.py
+++ b/gitm/importers/torch_trace.py
@@ -84,7 +84,7 @@ def _arg_get(args: dict[str, Any] | None, keys: tuple[str, ...]) -> Any:
lower = {str(k).lower(): v for k, v in args.items()}
for k in keys:
if k.lower() in lower:
return lower[k.lower()]
return None
def _as_dims(val: Any) -> tuple[int, int, int]:
if val is None:
return (1, 1, 1)
- if isinstance(val, list | tuple) and len(val) >= 1:
+ if isinstance(val, (list, tuple)) and len(val) >= 1:
x = as_int(val[0], 1) if len(val) > 0 else 1
y = as_int(val[1], 1) if len(val) > 1 else 1
z = as_int(val[2], 1) if len(val) > 2 else 1
return (max(x, 1), max(y, 1), max(z, 1))
if isinstance(val, dict):
return (
max(as_int(val.get("x", val.get("X", 1)), 1), 1),
max(as_int(val.get("y", val.get("Y", 1)), 1), 1),
max(as_int(val.get("z", val.get("Z", 1)), 1), 1),
)2. --- a/tests/test_importers_hypothesis.py
+++ b/tests/test_importers_hypothesis.py
@@ -34,7 +34,7 @@ def test_prop_us_to_ns_monotonic(ts: float, dur: float):
}
)
# Default import path uses LightKernel for scale; --strict uses KernelEvent.
- assert isinstance(ev, (KernelEvent, LightKernel))
+ assert isinstance(ev, KernelEvent | LightKernel)
assert ev.start_ns == int(ts * 1000.0)
assert ev.end_ns == int((ts + dur) * 1000.0)
assert ev.end_ns >= ev.start_ns or dur == 0.0
@@ -67,7 +67,7 @@ def test_prop_missing_args_never_crash(cat, name, has_grid, has_stream, has_devi
# memset names must skip for Memcpy-ish cats
assume("memset" not in name.lower())
ev = event_from_chrome(
{"ph": "X", "cat": cat, "name": name, "ts": 1.0, "dur": 2.0, "args": args}
)
# May be None for weird name/cat combos; must not raise.
if ev is not None:
- assert isinstance(ev, (KernelEvent, MemcpyEvent, LightKernel, LightMemcpy))
+ assert isinstance(ev, KernelEvent | MemcpyEvent | LightKernel | LightMemcpy)
assert getattr(ev, "kind", None) in ("kernel", "memcpy")
assert ev.end_ns >= ev.start_ns
# Light events are not pydantic; only validate the full Trace path
# when the object supports model_dump (strict/full KernelEvent path).
if hasattr(ev, "model_dump"):
Trace.model_validate(
{
"workload_id": "w",
"fingerprint": "f",
"run_id": "r",Suggested Change: --- a/tests/test_importers_hypothesis.py
+++ b/tests/test_importers_hypothesis.py
@@ -34,7 +34,7 @@ def test_prop_us_to_ns_monotonic(ts: float, dur: float):
}
)
# Default import path uses LightKernel for scale; --strict uses KernelEvent.
- assert isinstance(ev, KernelEvent | LightKernel)
+ assert isinstance(ev, (KernelEvent, LightKernel))
assert ev.start_ns == int(ts * 1000.0)
assert ev.end_ns == int((ts + dur) * 1000.0)
assert ev.end_ns >= ev.start_ns or dur == 0.0
@@ -67,7 +67,7 @@ def test_prop_missing_args_never_crash(cat, name, has_grid, has_stream, has_devi
# memset names must skip for Memcpy-ish cats
assume("memset" not in name.lower())
ev = event_from_chrome(
{"ph": "X", "cat": cat, "name": name, "ts": 1.0, "dur": 2.0, "args": args}
)
# May be None for weird name/cat combos; must not raise.
if ev is not None:
- assert isinstance(ev, KernelEvent | MemcpyEvent | LightKernel | LightMemcpy)
+ assert isinstance(ev, (KernelEvent, MemcpyEvent, LightKernel, LightMemcpy))
assert getattr(ev, "kind", None) in ("kernel", "memcpy")
assert ev.end_ns >= ev.start_ns
# Light events are not pydantic; only validate the full Trace path
# when the object supports model_dump (strict/full KernelEvent path).
if hasattr(ev, "model_dump"):
Trace.model_validate(
{
"workload_id": "w",
"fingerprint": "f",
"run_id": "r", |
CI installs `.[dev,bench]` then runs `pytest`. tests/test_importers_hypothesis.py does a plain `from hypothesis import ...`, but hypothesis was never listed in any extra — so collection errored and the whole pytest job failed on every Python version. Pre-existing on main (this branch touched neither pyproject nor that import); it surfaced here because the PR gates on the check. With hypothesis present the full suite is 564 passed on 3.11. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
🤖 Claude Code ReviewReview🐛 Bugs
device_comm_stats(trace.model_copy(update={"events": evs}))
# test_collective_signal.py:108
assert stats.device_id == 0If
# test_serving_latency.py:97
assert s.goodput_rps == 0.0 # window is known (2.0s), zero requests met SLOIn
agreement_band=max(ab.rel_std, MIN_NOISE_BAND),
if isinstance(val, list | tuple) and len(val) >= 1:
x = as_int(val[0], 1) if len(val) > 0 else 1 # `> 0` is always True hereThe 🔒 Security
out_path = Path(out_path)
out_path.parent.mkdir(parents=True, exist_ok=True)
out_path.write_text(...)If ⚡ PerformancePer-iteration live_engine = getattr(applicator, "engine", cfg.engine)
candidate_cfg = dict(getattr(live_engine, "gitm_llm_kwargs", None) or {})This is inside the intervention loop. If
per_device = [
device_comm_stats(trace.model_copy(update={"events": evs}))
for _, evs in sorted(by_dev.items())
]For a 4-GPU trace with millions of kernels, this creates 4 full 📊 Reproducibility
self._t0_ns = time.perf_counter_ns()
self._t0_wall_ns = time.time_ns()These are two separate syscalls. On a loaded host the OS can schedule the process out between them, introducing a skew of milliseconds between the monotonic and wall anchors — potentially larger than the "sub-microsecond" the comment claims. The offset is used to join scheduler samples to request timestamps; a multi-ms skew on a fast prefill could misattribute latency. Consider taking both clocks multiple times and using the pair with the smallest gap, or document the expected error bound more conservatively. Non-deterministic return max(per_device, key=lambda c: c.exposed_comm_share_of_wall)When two devices have equal 💡 Suggestions
req_records = list(run_out.get("requests") or []) if isinstance(run_out, dict) else []If a custom runner accidentally returns a list instead of a dict,
baseline_cfg = dict(getattr(cfg.engine, "gitm_llm_kwargs", None) or {})
result = apply_intervention(...) # mutates engineThe snapshot is taken before No test for
|
Code Review by GeminiThe changes introduce latency metrics, collective communication analysis, and a detailed verification export. The overall structure and new features are well-designed and tested. However, there is a potential bug in how multi-device traces are handled when calculating collective communication causes. Bug: Incorrect multi-device trace splitting in
|
Adds per-request TTFT/TPOT/goodput from vLLM's
RequestOutput.metrics, plus a wall-clock anchor on the schedulersampler so the GPU trace, scheduler samples, and request timestamps share one clock; both land in
scheduler_stats.json.Runs
node_rollup's existing comm math on the live trace and converts it into rankedCollectiveCauseentries inresiduals.json, mirroring how scheduler causes already work.Customer report - Serializes each
EngineABResult(until now read for two numbers and discarded) intoverification.jsonwith the full engine config on both sides, rep count, scatter, and environment.(My bad on last standup, I misinterpreted the task earlier and realised this part of the report wasn't actually done, so its here now in this PR)