[CI] Sample GPU utilization and memory alongside test timelines (#56541)
Signed-off-by: khluu <khluu000@gmail.com> Co-authored-by: Codex <noreply@openai.com>
Kevin H. Luu committed
Sep 12, 2026 at 02:41 UTC
33fa95a0cf9ed6bf384a0a51667a01a4f0bb02d5
5 files changed
+366
-5
.buildkite/scripts/ci-otel/README.md
new
+43
@@ -0,0 +1,43 @@
1
+# CI timing and GPU samples
2
+
3
+The pipeline generator activates these helpers for trusted, traced CI commands.
4
+`ci_otel.sh` records command spans and `ci_pytest.sh` injects the pytest timing
5
+plugin. `ci_gpu.py` runs once per command, outside pytest workers, when
6
+`nvidia-smi` is available. It queries up to 16 container-visible NVIDIA devices
7
+once per second without importing PyTorch or creating a CUDA context.
8
+Set `CI_INFRA_GPU_SAMPLING=0` in the job environment to disable sampling.
9
+
10
+Samples contain device UUID, index, name, utilization percent, and used/total
11
+device memory in bytes. They are `ci.gpu.sample` OTLP events inside
12
+`ci.gpu.samples` spans, parented to the command span and authenticated using the
13
+existing job-scoped Buildkite OIDC identity. Batches upload every 30 seconds;
14
+the final batch is spooled for the shell's normal job-end upload. No host agent
15
+or long-lived dashboard token is needed. The dashboard companion change adds
16
+GPU controls alongside jobs, commands, and pytest rows in the Builds timeline.
17
+
18
+Device queries and periodic uploads each have a two-second timeout. Three
19
+consecutive query failures stop sampling. Failed periodic uploads are dropped;
20
+memory stays bounded, and the dashboard shows the missing intervals as gaps.
21
+Uploads can delay the next poll. The shell stops and joins the sampler before
22
+flushing traces, allows at most three seconds for shutdown, and preserves the
23
+original command exit status. Samples already uploaded survive an interrupted
24
+job; the last unflushed batch may be lost on forced termination.
25
+
26
+These are device readings during each test's time interval, not process-level
27
+kernel attribution. Other processes and overlapping tests can contribute to
28
+the readings. Subsecond tests may have no sample. Missing readings never become
29
+zero utilization. MIG parent-device memory and utilization are omitted because
30
+they cannot be attributed to the job's partition. The initial implementation
31
+covers NVIDIA commands in the existing tracing rollout; CPU jobs and AMD
32
+mirrors do not collect GPU samples.
33
+
34
+Run the helper tests without loading vLLM or requiring a GPU:
35
+
36
+```bash
37
+PATH="$PWD/.venv/bin:$PATH" .venv/bin/python -m pytest \
38
+ .buildkite/scripts/ci-otel/tests/test_ci_otel.py -q
39
+```
40
+
41
+The suite uses a fake `nvidia-smi` to exercise the shell lifecycle, device-query
42
+failure handling, disable switch, event encoding, and bounded uploads. Real
43
+hardware sampling overhead still needs a canary run before broad deployment.
.buildkite/scripts/ci-otel/ci_gpu.py
new
+145
@@ -0,0 +1,145 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+
4
+"""Best-effort, container-visible NVIDIA device samples for command timelines."""
5
+
6
+from __future__ import annotations
7
+
8
+import csv
9
+import io
10
+import math
11
+import os
12
+import secrets
13
+import signal
14
+import subprocess
15
+import sys
16
+import threading
17
+import time
18
+
19
+from ci_otel import Span, export_spans, record_spans
20
+
21
+INTERVAL_SECONDS = 1.0
22
+BATCH_SECONDS = 30
23
+MAX_DEVICES = 16
24
+QUERY_TIMEOUT_SECONDS = 2
25
+QUERY = "index,uuid,name,utilization.gpu,memory.used,memory.total,mig.mode.current"
26
+
27
+
28
+def _number(value: str, scale: int = 1, maximum: int | None = None) -> int | None:
29
+ try:
30
+ number = float(value)
31
+ if not math.isfinite(number) or number < 0:
32
+ return None
33
+ if maximum is not None and number > maximum:
34
+ return None
35
+ return round(number * scale)
36
+ except ValueError:
37
+ return None
38
+
39
+
40
+def parse_samples(output: str, timestamp_ns: int) -> list[dict]:
41
+ events = []
42
+ for row in csv.reader(io.StringIO(output)):
43
+ if len(row) != 7 or len(events) >= MAX_DEVICES:
44
+ continue
45
+ index, uuid, name, util, used, total, mig = (value.strip() for value in row)
46
+ if not index.isdigit() or not uuid.startswith("GPU-"):
47
+ continue
48
+ attributes: dict[str, str | int | bool] = {
49
+ "gpu.uuid": uuid,
50
+ "gpu.index": int(index),
51
+ "gpu.name": name,
52
+ }
53
+ # Parent-device memory on a MIG GPU is not the job's allocated memory.
54
+ if mig.lower() == "enabled":
55
+ attributes["gpu.status"] = "unsupported_mig"
56
+ else:
57
+ for key, value in (
58
+ ("gpu.utilization", _number(util, maximum=100)),
59
+ ("gpu.memory.used", _number(used, 1024**2)),
60
+ ("gpu.memory.total", _number(total, 1024**2)),
61
+ ):
62
+ if value is not None:
63
+ attributes[key] = value
64
+ events.append(
65
+ {"time_ns": timestamp_ns, "name": "ci.gpu.sample", "attributes": attributes}
66
+ )
67
+ return events
68
+
69
+
70
+def sample_span(events: list[dict]) -> Span:
71
+ return Span(
72
+ trace_id=os.environ["CI_INFRA_TRACE_ID"],
73
+ span_id=secrets.token_hex(8),
74
+ parent_span_id=os.environ["CI_INFRA_COMMAND_SPAN_ID"],
75
+ name="ci.gpu.samples",
76
+ start_ns=events[0]["time_ns"],
77
+ end_ns=events[-1]["time_ns"],
78
+ attributes={
79
+ "ci.span.kind": "gpu-samples",
80
+ "gpu.sample.interval_ms": int(INTERVAL_SECONDS * 1000),
81
+ "gpu.scope": "container-visible-devices",
82
+ },
83
+ events=events,
84
+ )
85
+
86
+
87
+def collect(parent_pid: int, stop: threading.Event) -> None:
88
+ events: list[dict] = []
89
+ batch_started = time.monotonic()
90
+ failures = 0
91
+ try:
92
+ while not stop.is_set() and os.getppid() == parent_pid:
93
+ started = time.monotonic()
94
+ try:
95
+ result = subprocess.run(
96
+ [
97
+ "nvidia-smi",
98
+ f"--query-gpu={QUERY}",
99
+ "--format=csv,noheader,nounits",
100
+ ],
101
+ capture_output=True,
102
+ text=True,
103
+ timeout=QUERY_TIMEOUT_SECONDS,
104
+ check=True,
105
+ )
106
+ if stop.is_set():
107
+ break
108
+ samples = parse_samples(result.stdout, time.time_ns())
109
+ if not samples:
110
+ break
111
+ events.extend(samples)
112
+ failures = 0
113
+ except (OSError, subprocess.SubprocessError):
114
+ failures += 1
115
+ if failures >= 3:
116
+ print(
117
+ "CI GPU sampling stopped: device query unavailable",
118
+ file=sys.stderr,
119
+ )
120
+ break
121
+ if events and time.monotonic() - batch_started >= BATCH_SECONDS:
122
+ # Bounded memory and upload time even if the receiver is down.
123
+ # Dropped batches appear as gaps, never as zero utilization.
124
+ export_spans([sample_span(events)], timeout_seconds=2)
125
+ events = []
126
+ batch_started = time.monotonic()
127
+ stop.wait(max(0, INTERVAL_SECONDS - (time.monotonic() - started)))
128
+ finally:
129
+ # The shell joins this process before the ordinary job-end spool flush.
130
+ if events:
131
+ record_spans([sample_span(events)])
132
+
133
+
134
+def main() -> None:
135
+ stop = threading.Event()
136
+ signal.signal(signal.SIGTERM, lambda *_: stop.set())
137
+ signal.signal(signal.SIGINT, lambda *_: stop.set())
138
+ try:
139
+ collect(int(sys.argv[1]), stop)
140
+ except Exception:
141
+ print("CI GPU sampling skipped", file=sys.stderr)
142
+
143
+
144
+if __name__ == "__main__":
145
+ main()
.buildkite/scripts/ci-otel/ci_otel.py
+25
-3
@@ -14,12 +14,13 @@ import sys
14
import time
15
import urllib.request
16
from collections.abc import Iterable
17
-from dataclasses import asdict, dataclass
17
+from dataclasses import asdict, dataclass, field
18
from pathlib import Path
19
20
ENDPOINT = os.getenv("CI_INFRA_OTEL_ENDPOINT", "https://ci.vllm.ai/api/otel/v1/traces")
21
AUDIENCE = os.getenv("CI_INFRA_OTEL_AUDIENCE", "https://ci.vllm.ai/api/otel")
22
MAX_BATCH_SIZE = 2_000
23
+MAX_BATCH_BYTES = 1024 * 1024
24
TEST_SPOOL_BATCH_SIZE = 100
25
26
@@ -56,6 +57,7 @@ class Span:
57
end_ns: int
58
attributes: dict[str, str | int | bool]
59
status_code: int = 1
60
+ events: list[dict] = field(default_factory=list)
61
62
63
def _varint(value: int) -> bytes:
@@ -137,6 +139,12 @@ def _encode_span(span: Span) -> bytes:
139
)
140
for name, value in attributes.items():
141
encoded += _bytes_field(9, _attribute(name, value))
142
+ for event in span.events:
143
+ encoded_event = _fixed64_field(1, int(event["time_ns"]))
144
+ encoded_event += _string_field(2, event["name"])
145
+ for name, value in event["attributes"].items():
146
+ encoded_event += _bytes_field(3, _attribute(name, value))
147
+ encoded += _bytes_field(11, encoded_event)
148
return encoded + _bytes_field(15, _varint_field(3, span.status_code))
149
150
@@ -482,10 +490,24 @@ def export_spans(spans: list[Span], timeout_seconds: float = 3.0) -> bool:
490
try:
491
deadline = time.monotonic() + max(timeout_seconds, 0.1)
492
token = _oidc_token(deadline)
485
- for offset in range(0, len(spans), MAX_BATCH_SIZE):
493
+ batches: list[list[Span]] = []
494
+ batch: list[Span] = []
495
+ size = 0
496
+ for span in spans:
497
+ span_size = len(_encode_span(span))
498
+ if batch and (
499
+ size + span_size > MAX_BATCH_BYTES or len(batch) >= MAX_BATCH_SIZE
500
+ ):
501
+ batches.append(batch)
502
+ batch, size = [], 0
503
+ batch.append(span)
504
+ size += span_size
505
+ if batch:
506
+ batches.append(batch)
507
+ for batch in batches:
508
request = urllib.request.Request(
509
ENDPOINT,
488
- data=encode_request(spans[offset : offset + MAX_BATCH_SIZE]),
510
+ data=encode_request(batch),
511
method="POST",
512
headers={
513
"Authorization": f"Bearer {token}",
.buildkite/scripts/ci-otel/ci_otel.sh
+19
@@ -82,6 +82,13 @@ ci_otel_start() {
82
CI_INFRA_COMMAND_SPAN_ID="${_CI_INFRA_OTEL_COMMAND_SPAN_ID}"
83
export CI_INFRA_TRACE_ID CI_INFRA_COMMAND_SPAN_ID
84
_CI_INFRA_OTEL_ACTIVE=1
85
+ _CI_INFRA_GPU_PID=""
86
+ if [ "${CI_INFRA_GPU_SAMPLING:-1}" != "0" ] &&
87
+ [ -f "${CI_INFRA_OTEL_DIR}/ci_gpu.py" ] &&
88
+ command -v nvidia-smi >/dev/null 2>&1; then
89
+ "${_CI_INFRA_OTEL_PYTHON}" "${CI_INFRA_OTEL_DIR}/ci_gpu.py" "$$" </dev/null &
90
+ _CI_INFRA_GPU_PID=$!
91
+ fi
92
}
93
94
ci_otel_finish() {
@@ -93,6 +100,18 @@ ci_otel_finish() {
100
"${_CI_INFRA_OTEL_PARENT_SPAN_ID}" "${_CI_INFRA_OTEL_START_NS}" \
101
"${_CI_INFRA_OTEL_COMMAND_INDEX}" "${_CI_INFRA_OTEL_COMMAND_STATUS}" \
102
"${_CI_INFRA_OTEL_COMMAND_LABEL}" || :
103
+ if [ -n "${_CI_INFRA_GPU_PID:-}" ]; then
104
+ kill -TERM "${_CI_INFRA_GPU_PID}" 2>/dev/null || :
105
+ _CI_INFRA_GPU_WAIT=0
106
+ while kill -0 "${_CI_INFRA_GPU_PID}" 2>/dev/null &&
107
+ [ "${_CI_INFRA_GPU_WAIT}" -lt 30 ]; do
108
+ sleep 0.1
109
+ _CI_INFRA_GPU_WAIT=$((_CI_INFRA_GPU_WAIT + 1))
110
+ done
111
+ kill -KILL "${_CI_INFRA_GPU_PID}" 2>/dev/null || :
112
+ wait "${_CI_INFRA_GPU_PID}" 2>/dev/null || :
113
+ _CI_INFRA_GPU_PID=""
114
+ fi
115
CI_INFRA_TRACE_ID=""
116
CI_INFRA_COMMAND_SPAN_ID=""
117
export CI_INFRA_TRACE_ID CI_INFRA_COMMAND_SPAN_ID
.buildkite/scripts/ci-otel/tests/test_ci_otel.py
+134
-2
@@ -8,6 +8,7 @@ import os
8
import shlex
9
import subprocess
10
import sys
11
+import threading
12
import time
13
from pathlib import Path
14
@@ -16,6 +17,7 @@ import pytest
17
SCRIPTS_DIR = Path(__file__).resolve().parents[1]
18
sys.path.insert(0, str(SCRIPTS_DIR))
19
20
+import ci_gpu # noqa: E402
21
import ci_otel # noqa: E402
22
from ci_otel import Span # noqa: E402
23
@@ -251,9 +253,15 @@ def test_export_failure_is_soft_and_bounded(monkeypatch):
253
assert time.monotonic() - started < 0.5
254
255
254
-def test_export_batches_with_one_oidc_token(monkeypatch):
256
+@pytest.mark.parametrize("byte_limited", [False, True])
257
+def test_export_batches_with_one_oidc_token(monkeypatch, byte_limited):
258
monkeypatch.setenv("BUILDKITE", "true")
256
- monkeypatch.setattr(ci_otel, "MAX_BATCH_SIZE", 2)
259
+ if byte_limited:
260
+ monkeypatch.setattr(
261
+ ci_otel, "MAX_BATCH_BYTES", len(ci_otel._encode_span(_span())) * 2
262
+ )
263
+ else:
264
+ monkeypatch.setattr(ci_otel, "MAX_BATCH_SIZE", 2)
265
token_calls = []
266
requests = []
267
@@ -786,3 +794,127 @@ def test_pytest_plugin_failure_cannot_fail_pytest(tmp_path):
794
795
assert result.returncode == 0, result.stderr
796
assert "1 passed" in result.stdout
797
+
798
+
799
+def test_gpu_samples_preserve_zero_and_omit_unsupported_metrics():
800
+ events = ci_gpu.parse_samples(
801
+ "0, GPU-a, H200, 0, 1024, 2048, Disabled\n"
802
+ "1, GPU-b, H200, [N/A], [N/A], 2048, Disabled\n"
803
+ "2, GPU-c, H200, 50, 1024, 2048, Enabled\n"
804
+ "3, GPU-d, H200, NaN, -1, inf, Disabled\n",
805
+ 1234,
806
+ )
807
+ assert len(events) == 4
808
+ assert events[0]["attributes"]["gpu.utilization"] == 0
809
+ assert events[0]["attributes"]["gpu.memory.used"] == 1024**3
810
+ for event in events[1:]:
811
+ assert "gpu.utilization" not in event["attributes"]
812
+ assert "gpu.memory.used" not in event["attributes"]
813
+ assert events[2]["attributes"]["gpu.status"] == "unsupported_mig"
814
+ assert "gpu.memory.total" not in events[2]["attributes"]
815
+
816
+
817
+def test_gpu_batches_keep_command_identity_and_survive_spool_roundtrip(
818
+ monkeypatch, tmp_path
819
+):
820
+ monkeypatch.setenv("CI_INFRA_TRACE_ID", "01" * 16)
821
+ monkeypatch.setenv("CI_INFRA_COMMAND_SPAN_ID", "02" * 8)
822
+ monkeypatch.setenv("CI_INFRA_OTEL_SPOOL_DIR", str(tmp_path))
823
+ span = ci_gpu.sample_span(
824
+ ci_gpu.parse_samples("0, GPU-a, H200, 90, 1, 2, Disabled", 1234)
825
+ )
826
+ assert span.parent_span_id == "02" * 8
827
+ assert span.trace_id == "01" * 16
828
+ assert ci_otel.record_spans([span])
829
+ assert ci_otel.load_spans() == [span]
830
+ assert b"ci.gpu.sample" in ci_otel.encode_request([span])
831
+
832
+
833
+def test_gpu_query_failures_are_bounded_and_never_synthesize_idle_samples(monkeypatch):
834
+ calls = []
835
+
836
+ def query(*args, **kwargs):
837
+ calls.append(kwargs["timeout"])
838
+ raise subprocess.TimeoutExpired("nvidia-smi", kwargs["timeout"])
839
+
840
+ monkeypatch.setattr(ci_gpu.subprocess, "run", query)
841
+ monkeypatch.setattr(ci_gpu, "INTERVAL_SECONDS", 0)
842
+ monkeypatch.setattr(
843
+ ci_gpu, "record_spans", lambda _: pytest.fail("fabricated sample")
844
+ )
845
+ ci_gpu.collect(os.getppid(), threading.Event())
846
+ assert calls == [2, 2, 2]
847
+
848
+
849
+def test_gpu_periodic_batches_are_bounded_even_when_upload_fails(monkeypatch):
850
+ monkeypatch.setenv("CI_INFRA_TRACE_ID", "01" * 16)
851
+ monkeypatch.setenv("CI_INFRA_COMMAND_SPAN_ID", "02" * 8)
852
+ monkeypatch.setattr(ci_gpu, "BATCH_SECONDS", 0)
853
+ monkeypatch.setattr(ci_gpu, "INTERVAL_SECONDS", 0)
854
+ monkeypatch.setattr(
855
+ ci_gpu.subprocess,
856
+ "run",
857
+ lambda *a, **k: subprocess.CompletedProcess(
858
+ a, 0, "0, GPU-a, H200, 50, 1, 2, Disabled"
859
+ ),
860
+ )
861
+ stop = threading.Event()
862
+ batches = []
863
+
864
+ def upload(spans, timeout_seconds):
865
+ batches.append(spans)
866
+ assert timeout_seconds == 2
867
+ if len(batches) == 3:
868
+ stop.set()
869
+ return False
870
+
871
+ monkeypatch.setattr(ci_gpu, "export_spans", upload)
872
+ ci_gpu.collect(os.getppid(), stop)
873
+ assert [len(batch[0].events) for batch in batches] == [1, 1, 1]
874
+
875
+
876
+@pytest.mark.parametrize("sampling", ["1", "0"])
877
+def test_gpu_sampler_stops_with_command_and_preserves_failure_status(
878
+ tmp_path, sampling
879
+):
880
+ bin_dir = tmp_path / "bin"
881
+ bin_dir.mkdir()
882
+ smi = bin_dir / "nvidia-smi"
883
+ smi.write_text('#!/bin/sh\necho "0, GPU-a, H200, 70, 1024, 2048, Disabled"\n')
884
+ smi.chmod(0o755)
885
+ # Keep the spool for inspection, and avoid any network upload.
886
+ shell = (
887
+ f'. "{SCRIPTS_DIR / "ci_otel.sh"}"\n'
888
+ "ci_otel_run 1 workload sh -c 'sleep 1.3; exit 7'\n"
889
+ "exit $?"
890
+ )
891
+ result = subprocess.run(
892
+ ["/bin/sh", "-c", shell],
893
+ capture_output=True,
894
+ text=True,
895
+ timeout=8,
896
+ env={
897
+ **os.environ,
898
+ "PATH": f"{bin_dir}:{os.environ['PATH']}",
899
+ "BUILDKITE": "false",
900
+ "CI_INFRA_GPU_SAMPLING": sampling,
901
+ "CI_INFRA_OTEL_DIR": str(SCRIPTS_DIR),
902
+ "CI_INFRA_OTEL_RUNTIME_DIR": str(tmp_path / "runtime"),
903
+ "CI_INFRA_OTEL_SPOOL_DIR": str(tmp_path / "spans"),
904
+ },
905
+ )
906
+ assert result.returncode == 7, result.stderr
907
+ records = [
908
+ json.loads(line)
909
+ for path in (tmp_path / "spans").glob("spans-*.jsonl")
910
+ for line in path.read_text().splitlines()
911
+ ]
912
+ command = next(record for record in records if record["name"] == "ci.command")
913
+ batches = [record for record in records if record["name"] == "ci.gpu.samples"]
914
+ assert bool(batches) == (sampling == "1")
915
+ if batches:
916
+ assert batches[0]["parent_span_id"] == command["span_id"]
917
+ assert all(
918
+ command["start_ns"] <= event["time_ns"] <= command["end_ns"]
919
+ for event in batches[0]["events"]
920
+ )