|
18 | 18 |
|
19 | 19 | import datetime |
20 | 20 | import os |
| 21 | +import re |
21 | 22 | import subprocess |
22 | 23 | import tempfile |
23 | 24 | from typing import List |
| 25 | +import logging |
24 | 26 |
|
25 | 27 | from airflow import models |
26 | 28 | from airflow.decorators import task |
|
35 | 37 | from dags.tpu_observability.configs.common import MachineConfigMap, GCS_CONFIG_PATH |
36 | 38 |
|
37 | 39 |
|
| 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 | + |
38 | 212 | def execute_tpu_info_cli_command(info, pod_name: str, tpu_args: str) -> str: |
39 | 213 | """Helper to handle KUBECONFIG and execute kubectl exec.""" |
40 | 214 | with tempfile.NamedTemporaryFile() as temp_config_file: |
@@ -80,6 +254,58 @@ def validate_streaming_rate(info, pod_name: str, rate: float) -> str: |
80 | 254 | return f"Validated {pod_name} at {rate}s" |
81 | 255 |
|
82 | 256 |
|
| 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 | + |
83 | 309 | # Keyword arguments are generated dynamically at runtime (pylint does not |
84 | 310 | # know this signature). |
85 | 311 | with models.DAG( # pylint: disable=unexpected-keyword-arg |
@@ -189,19 +415,25 @@ def generate_second_node_pool_name( |
189 | 415 | )(cluster_info, pod_name_list=pod_names, job_apply_time=apply_time) |
190 | 416 |
|
191 | 417 | test_rates = [0.1, 0.5, 1.0, 5.0] |
| 418 | + rate_test_groups = [] |
192 | 419 | for rate in test_rates: |
193 | 420 | formatted_rate = str(rate).replace(".", "_") |
194 | 421 |
|
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( |
198 | 423 | group_id=f"verification_group_rate_{formatted_rate}" |
199 | 424 | ) 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 |
204 | 435 | ) |
| 436 | + rate_test_groups.append(rate_group) |
205 | 437 |
|
206 | 438 | cleanup_workload = jobset.end_workload.override( |
207 | 439 | task_id="cleanup_workload", trigger_rule=TriggerRule.ALL_DONE |
@@ -242,7 +474,7 @@ def generate_second_node_pool_name( |
242 | 474 | >> apply_time |
243 | 475 | >> pod_names |
244 | 476 | >> wait_for_job_start |
245 | | - >> verification_group |
| 477 | + >> rate_test_groups |
246 | 478 | >> cleanup_workload |
247 | 479 | >> cleanup_node_pool |
248 | 480 | ) |
|
0 commit comments