Skip to content

Commit 8bde730

Browse files
committed
feat: Implement TPUPerformanceAnalyzer for enhanced streaming rate validation and reporting
1 parent f092e41 commit 8bde730

1 file changed

Lines changed: 240 additions & 8 deletions

File tree

dags/tpu_observability/tpu_info_streaming_rate.py

Lines changed: 240 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,11 @@
1818

1919
import datetime
2020
import os
21+
import re
2122
import subprocess
2223
import tempfile
2324
from typing import List
25+
import logging
2426

2527
from airflow import models
2628
from airflow.decorators import task
@@ -35,6 +37,178 @@
3537
from dags.tpu_observability.configs.common import MachineConfigMap, GCS_CONFIG_PATH
3638

3739

40+
class TPUPerformanceAnalyzer:
41+
42+
def __init__(self, target_rate: float = 0.1):
43+
"""
44+
Initialize the analyzer with a target sampling rate.
45+
:param target_rate: The expected update interval in seconds (default 0.1s).
46+
"""
47+
self.target_rate = target_rate
48+
self.lower_bound = target_rate * 0.8
49+
self.upper_bound = target_rate * 1.2
50+
self.frames = []
51+
self.update_events = []
52+
53+
# Pre-compile regex patterns for optimized performance
54+
self._frame_start_re = re.compile(r"\[(\d{2}:\d{2}:\d{2}\.\d{3})\].*?\[H")
55+
self._chips_re = re.compile(
56+
r"│\s+(/dev/vfio/\d+)\s+│.*?│\s+\d+\s+│\s+(\d+)\s+│"
57+
)
58+
self._runtime_re = re.compile(
59+
r"│\s+(\d+)\s+│\s+([\d.]+ GiB / [\d.]+ GiB)\s+│\s+([\d.]+)%\s+│"
60+
)
61+
self._tensor_re = re.compile(r"│\s+(\d+)\s+│\s+([\d.]+)%\s+│")
62+
self._latency_re = re.compile(
63+
r"│\s+([\dMB+]+)\s+│\s+([\d.]+) us\s+│\s+([\d.]+) us\s+│\s+([\d.]+) us\s+│\s+([\d.]+) us\s+│"
64+
)
65+
66+
def _init_empty_frame(self):
67+
"""Internal helper to initialize a structure for a single log frame."""
68+
return {"ts": None, "chips": {}, "runtime": {}, "tensor": {}, "latency": {}}
69+
70+
def _extract_metrics(self, line, frame):
71+
"""Internal helper to extract various hardware metrics from a log line."""
72+
m_c = self._chips_re.search(line)
73+
if m_c:
74+
frame["chips"][m_c.group(1)] = m_c.group(2)
75+
76+
m_r = self._runtime_re.search(line)
77+
if m_r:
78+
frame["runtime"][m_r.group(1)] = (m_r.group(2), m_r.group(3))
79+
80+
m_t = self._tensor_re.search(line)
81+
if m_t:
82+
frame["tensor"][m_t.group(1)] = m_t.group(2)
83+
84+
m_l = self._latency_re.search(line)
85+
if m_l:
86+
frame["latency"][m_l.group(1)] = (
87+
m_l.group(2),
88+
m_l.group(3),
89+
m_l.group(4),
90+
m_l.group(5),
91+
)
92+
93+
def parse_log(self, log_content: str):
94+
"""
95+
Parse raw log text into structured data frames.
96+
"""
97+
self.frames = []
98+
lines = log_content.splitlines()
99+
current_frame = self._init_empty_frame()
100+
101+
for line in lines:
102+
fs_match = self._frame_start_re.search(line)
103+
if fs_match:
104+
if current_frame["ts"]:
105+
self.frames.append(current_frame)
106+
current_frame = self._init_empty_frame()
107+
current_frame["ts"] = datetime.strptime(
108+
fs_match.group(1), "%H:%M:%S.%f"
109+
)
110+
continue
111+
self._extract_metrics(line, current_frame)
112+
113+
if current_frame["ts"]:
114+
self.frames.append(current_frame)
115+
116+
def filter_update_events(self):
117+
"""
118+
Compare consecutive frames and retain only those where hardware data changed.
119+
"""
120+
self.update_events = []
121+
last_snapshot = None
122+
for f in self.frames:
123+
# Create a snapshot excluding the timestamp for comparison
124+
snapshot = {k: v for k, v in f.items() if k != "ts"}
125+
if last_snapshot is None or snapshot != last_snapshot:
126+
self.update_events.append(f)
127+
last_snapshot = snapshot
128+
129+
def validate_rate_match(self) -> bool:
130+
"""
131+
Validates if the hardware update frequency aligns with the target rate.
132+
133+
Logic Rationale:
134+
1. Eager Hardware Output: To ensure monitoring data does not lag behind the
135+
specified sampling frequency (Target Rate), the hardware driver implements
136+
an eager refresh strategy. This often results in intervals slightly below
137+
or exactly at the target (e.g., 0.08s - 0.10s).
138+
2. Jitter Tolerance: A +/- 20% buffer (0.08s to 0.12s) is established to
139+
account for system scheduling jitters and network transmission latency.
140+
3. Sensitivity Verification: A 'True' result confirms the system successfully
141+
captured active hardware state changes at a high frequency, rather than
142+
stale cached data.
143+
"""
144+
if len(self.update_events) < 3:
145+
return False
146+
147+
for i in range(2, len(self.update_events)):
148+
delta = (
149+
self.update_events[i]["ts"] - self.update_events[i - 1]["ts"]
150+
).total_seconds()
151+
if self.lower_bound <= delta <= self.upper_bound:
152+
return True
153+
return False
154+
155+
def _format_row(self, no, ev, delta):
156+
"""Helper to format a single row of report data."""
157+
pids = ", ".join(ev["chips"].values())
158+
hbm_str = " | ".join(
159+
[
160+
f"D{k}:{v[0].split('/')[0].strip()}({v[1]}%)"
161+
for k, v in ev["runtime"].items()
162+
]
163+
)
164+
tc_str = ", ".join([f"C{k}:{v}%" for k, v in ev["tensor"].items()])
165+
lat_str = " || ".join(
166+
[f"{k}: {'|'.join(v)}" for k, v in ev["latency"].items()]
167+
)
168+
169+
return (
170+
f"{no:<3} | {ev['ts'].strftime('%H:%M:%S.%f')[:-3]:<12} | {delta:<7.3f}s | "
171+
f"{pids:<12} | {hbm_str:<60} | {tc_str:<30} | {lat_str}"
172+
)
173+
174+
def generate_report(self) -> str:
175+
"""
176+
Generate a complete aligned text report of detected performance updates.
177+
"""
178+
num_events = len(self.update_events)
179+
if num_events < 3:
180+
return "Insufficient data to generate a report (at least 3 unique events required)."
181+
182+
output = []
183+
header = (
184+
f"{'No':<3} | {'Timestamp':<12} | {'Intv (s)':<10} | "
185+
f"{'PIDs':<12} | {'HBM Usage (Device:Used | Duty%)':<60} | "
186+
f"{'TensorCore':<30} | {'Latency Profile (us)'}"
187+
)
188+
output.append(header)
189+
output.append("-" * 210)
190+
191+
intervals = []
192+
for i in range(2, num_events):
193+
ev = self.update_events[i]
194+
prev_ev = self.update_events[i - 1]
195+
delta = (ev["ts"] - prev_ev["ts"]).total_seconds()
196+
intervals.append(delta)
197+
198+
row = self._format_row(i - 1, ev, delta)
199+
output.append(row)
200+
201+
if intervals:
202+
avg_intv = sum(intervals) / len(intervals)
203+
is_matched = self.validate_rate_match()
204+
output.append("-" * 210)
205+
output.append(
206+
f"Average Interval (Stable Phase): {avg_intv:.3f} s | Target Rate Match: {is_matched}"
207+
)
208+
209+
return "\n".join(output)
210+
211+
38212
def execute_tpu_info_cli_command(info, pod_name: str, tpu_args: str) -> str:
39213
"""Helper to handle KUBECONFIG and execute kubectl exec."""
40214
with tempfile.NamedTemporaryFile() as temp_config_file:
@@ -80,6 +254,58 @@ def validate_streaming_rate(info, pod_name: str, rate: float) -> str:
80254
return f"Validated {pod_name} at {rate}s"
81255

82256

257+
@task
258+
def validate_streaming_rate_iterations(
259+
info, pod_name: str, rate: float, iteration_count: int = 40
260+
) -> str:
261+
"""
262+
Performs 40 iterations of 30s tests.
263+
The task succeeds if at least 50% of the iterations are valid.
264+
"""
265+
duration = 30
266+
analyzer = TPUPerformanceAnalyzer(target_rate=rate)
267+
success_count = 0
268+
pass_threshold = iteration_count / 2 # 50% threshold
269+
270+
for i in range(1, iteration_count + 1):
271+
# Precise command with Perl microsecond timestamping and terminal line export
272+
tpu_args = (
273+
f'sh -c "export LINES=50 && '
274+
f"script -q -c 'timeout {duration}s tpu-info --streaming --rate {rate}' /dev/null\" "
275+
f"| perl -MTime::HiRes=gettimeofday -ne ' "
276+
f"($s, $usec) = gettimeofday; "
277+
f"($sec,$min,$hour) = localtime($s); "
278+
f'printf("[%02d:%02d:%02d.%03d] %s", $hour, $min, $sec, $usec/1000, $_);\' '
279+
f"|| [ ${{PIPESTATUS[0]}} -eq 124 ]"
280+
)
281+
282+
try:
283+
output = execute_tpu_info_cli_command(info, pod_name, tpu_args)
284+
analyzer.parse_log(output)
285+
analyzer.filter_update_events()
286+
logging.info(analyzer.generate_report())
287+
if analyzer.validate_rate_match():
288+
success_count += 1
289+
else:
290+
logging.info(
291+
f"Iteration {i}: Failed validation (Intervals out of range)."
292+
)
293+
except Exception as e:
294+
logging.error(f"Iteration {i}: Command execution error: {str(e)}")
295+
296+
# Evaluation logic: Pass if success_count >= 20
297+
status_msg = f"Pod {pod_name} at {rate}s: {success_count}/{iteration_count} iterations passed."
298+
logging.info(status_msg)
299+
300+
if success_count < pass_threshold:
301+
raise AssertionError(
302+
f"Validation Failed: Only {success_count}/{iteration_count} passed. "
303+
f"Required at least {pass_threshold}."
304+
)
305+
306+
return status_msg
307+
308+
83309
# Keyword arguments are generated dynamically at runtime (pylint does not
84310
# know this signature).
85311
with models.DAG( # pylint: disable=unexpected-keyword-arg
@@ -189,19 +415,25 @@ def generate_second_node_pool_name(
189415
)(cluster_info, pod_name_list=pod_names, job_apply_time=apply_time)
190416

191417
test_rates = [0.1, 0.5, 1.0, 5.0]
418+
rate_test_groups = []
192419
for rate in test_rates:
193420
formatted_rate = str(rate).replace(".", "_")
194421

195-
# Keyword arguments are generated dynamically at runtime (pylint does not
196-
# know this signature).
197-
with TaskGroup( # pylint: disable=unexpected-keyword-arg
422+
with TaskGroup(
198423
group_id=f"verification_group_rate_{formatted_rate}"
199424
) as rate_group:
200-
streaming_validation_results = (
201-
validate_streaming_rate.override(task_id="streaming_rate_test")
202-
.partial(info=cluster_info, rate=rate)
203-
.expand(pod_name=pod_names)
425+
# Fix: Ensure parameters match the task signature exactly
426+
validate_streaming_rate_iterations.override(
427+
task_id="streaming_rate_test",
428+
execution_timeout=datetime.timedelta(minutes=60),
429+
).partial(
430+
info=cluster_info,
431+
rate=rate,
432+
iteration_count=40, # Pass integer directly to partial
433+
).expand(
434+
pod_name=pod_names
204435
)
436+
rate_test_groups.append(rate_group)
205437

206438
cleanup_workload = jobset.end_workload.override(
207439
task_id="cleanup_workload", trigger_rule=TriggerRule.ALL_DONE
@@ -242,7 +474,7 @@ def generate_second_node_pool_name(
242474
>> apply_time
243475
>> pod_names
244476
>> wait_for_job_start
245-
>> verification_group
477+
>> rate_test_groups
246478
>> cleanup_workload
247479
>> cleanup_node_pool
248480
)

0 commit comments

Comments
 (0)