scripts/profile_common.py
scripts/profile_common.pyBrowse 18 files
9,073 tokens
40,177 bytes
Token encoding: o200k_base
Snapshot a9fb1c3
← Back to SKILL.md
1"""Shared helpers for unified LLM torch-profiler skill scripts."""2 3from __future__ import annotations4 5import gzip6import json7import re8import shutil9import sys10import tempfile11import time12from collections import Counter, defaultdict13from dataclasses import dataclass14from functools import lru_cache15from pathlib import Path16from typing import Callable, Dict, Iterable, List, Optional, Sequence, Tuple17from urllib import request18 19STAGE_ORDER = {"extend": 0, "prefill": 0, "decode": 1, "all": 2}20FRAMEWORK_LABELS = {21 "auto": "auto",22 "sglang": "SGLang",23 "vllm": "vLLM",24 "trtllm": "TensorRT-LLM",25 "tokenspeed": "TokenSpeed",26}27TRACE_FILE_PATTERNS = (28 "*.trace.json",29 "*.trace.json.gz",30 "*.pt.trace.json",31 "*.pt.trace.json.gz",32 "*.json",33 "*.json.gz",34)35TRACE_FILE_IGNORE_NAMES = {36 "server_args.json",37 "metadata.json",38 "config.json",39}40TRACE_METADATA_NAMES = {41 "process_name",42 "thread_name",43 "process_sort_index",44 "thread_sort_index",45}46NON_KERNEL_TRACE_CATEGORIES = ("python_function", "cpu_op", "trace")47PYTHON_SCOPE_NAME_PREFIXES = ("python/", "nn.module:")48PROFILE_WORKLOAD_CHOICES = ("legacy", "prefill", "decode", "both")49DEFAULT_PREFILL_INPUT_LEN = 409050DEFAULT_PREFILL_OUTPUT_LEN = 151DEFAULT_DECODE_INPUT_LEN = 152DEFAULT_DECODE_OUTPUT_LEN = 204853DEFAULT_WARMUP_STEPS = 1054 55 56@dataclass(frozen=True)57class ProbePlan:58 prompt: str59 capture_max_new_tokens: int60 capture_requests: int61 warmup_max_new_tokens: int62 warmup_requests: int63 64 65@lru_cache(maxsize=65536)66def _normalize_text_cached(text: str) -> str:67 text = text.strip()68 if not text:69 return ""70 for token in (" ", "\t", "\n", "\r", "\v", "\f"):71 if token in text:72 return " ".join(text.split())73 return text74 75 76def normalize_text(value: object) -> str:77 return _normalize_text_cached(value if isinstance(value, str) else str(value))78 79 80def canonicalize_framework(value: object) -> str:81 lowered = normalize_text(value).lower().replace("_", "-")82 aliases = {83 "": "auto",84 "auto": "auto",85 "sglang": "sglang",86 "sgl": "sglang",87 "vllm": "vllm",88 "trt": "trtllm",89 "tllm": "trtllm",90 "trtllm": "trtllm",91 "tensorrt-llm": "trtllm",92 "tensorrtllm": "trtllm",93 "tokenspeed": "tokenspeed",94 "token-speed": "tokenspeed",95 "ts": "tokenspeed",96 }97 return aliases.get(lowered, "auto")98 99 100def framework_display_name(value: object) -> str:101 return FRAMEWORK_LABELS.get(canonicalize_framework(value), str(value))102 103 104@lru_cache(maxsize=65536)105def _normalize_repo_relative_path_cached(text: str) -> str:106 text = text.replace("\\", "/")107 lowered = text.lower()108 for marker, normalized_marker in (109 ("python/sglang/", "python/sglang/"),110 ("sgl_kernel/", "sgl_kernel/"),111 ("vllm/", "vllm/"),112 ("python/tokenspeed/", "python/tokenspeed/"),113 ("tokenspeed/", "tokenspeed/"),114 ("tensorrt_llm/", "tensorrt_llm/"),115 ("tensorrt-llm/", "tensorrt_llm/"),116 ):117 idx = lowered.find(marker)118 if idx != -1:119 suffix = text[idx + len(marker) :].lstrip("/")120 return f"{normalized_marker}{suffix}".lstrip("/")121 idx = lowered.find("sglang/")122 if idx != -1:123 return ("python/" + text[idx:]).lstrip("/")124 return text.lstrip("/")125 126 127def normalize_repo_relative_path(path: object) -> str:128 return _normalize_repo_relative_path_cached(normalize_text(path))129 130 131def contains_any_keyword(text: str, keywords: Iterable[str]) -> bool:132 return any(keyword in text for keyword in keywords)133 134 135def coerce_optional_int(value: object) -> Optional[int]:136 if value in (None, "", "None"):137 return None138 if isinstance(value, int):139 return value140 if isinstance(value, float):141 return int(value) if value.is_integer() else None142 try:143 return int(str(value))144 except (TypeError, ValueError):145 return None146 147 148def extract_trace_events(trace: object) -> Sequence[dict]:149 if isinstance(trace, dict):150 events = trace.get("traceEvents", [])151 return events if isinstance(events, list) else []152 if isinstance(trace, list):153 return trace154 return []155 156 157def is_trace_metadata_name(name: object) -> bool:158 return str(name) in TRACE_METADATA_NAMES159 160 161def is_complete_duration_event(event: dict) -> bool:162 if event.get("ph") != "X":163 return False164 dur = event.get("dur")165 ts = event.get("ts")166 if dur is None or ts is None:167 return False168 try:169 return float(dur) > 0170 except (TypeError, ValueError):171 return False172 173 174def is_annotation_event(name: object, category: object) -> bool:175 lowered_name = normalize_text(name).lower()176 lowered_category = normalize_text(category).lower()177 return "annotation" in lowered_category or lowered_name.startswith("## call ")178 179 180def is_non_kernel_trace_category(category: object) -> bool:181 lowered_category = normalize_text(category).lower()182 return any(token in lowered_category for token in NON_KERNEL_TRACE_CATEGORIES)183 184 185def looks_like_python_scope_name(name: object) -> bool:186 lowered_name = normalize_text(name).lower()187 return ".py(" in lowered_name or lowered_name.startswith(PYTHON_SCOPE_NAME_PREFIXES)188 189 190def has_stream_marker(args: Optional[dict]) -> bool:191 trace_args = args or {}192 return "stream" in trace_args or "cuda_stream" in trace_args193 194 195def load_trace_json(path: Path) -> dict:196 if path.suffix == ".gz":197 with gzip.open(path, "rt", encoding="utf-8") as handle:198 return json.load(handle)199 with open(path, "r", encoding="utf-8") as handle:200 return json.load(handle)201 202 203def load_server_args(path: Path) -> Optional[dict]:204 resolved = path.resolve()205 candidate_dirs: List[Path] = []206 if resolved.is_file():207 candidate_dirs.extend([resolved.parent, resolved.parent.parent])208 else:209 candidate_dirs.extend([resolved, resolved.parent])210 211 seen: set[Path] = set()212 for candidate_dir in candidate_dirs:213 if candidate_dir in seen:214 continue215 seen.add(candidate_dir)216 candidate = candidate_dir / "server_args.json"217 if candidate.exists():218 with open(candidate, "r", encoding="utf-8") as handle:219 return json.load(handle)220 return None221 222 223def try_get_json(url: str, timeout: float = 60.0) -> Optional[object]:224 try:225 with request.urlopen(url, timeout=timeout) as response:226 raw = response.read()227 except Exception:228 return None229 if not raw:230 return None231 try:232 return json.loads(raw.decode("utf-8"))233 except json.JSONDecodeError:234 return None235 236 237def _flatten_chat_text_parts(value: object) -> List[str]:238 if value is None:239 return []240 if isinstance(value, str):241 text = value.strip()242 return [text] if text else []243 if isinstance(value, list):244 parts: List[str] = []245 for item in value:246 parts.extend(_flatten_chat_text_parts(item))247 return parts248 if isinstance(value, dict):249 parts: List[str] = []250 text_keys = (251 "text",252 "content",253 "reasoning_content",254 "reasoning",255 "output_text",256 )257 if any(key in value for key in text_keys):258 for key in text_keys:259 parts.extend(_flatten_chat_text_parts(value.get(key)))260 if parts:261 return parts262 item_type = normalize_text(value.get("type")).lower()263 if item_type in {"text", "output_text", "input_text"}:264 for key in ("text", "content", "value"):265 parts.extend(_flatten_chat_text_parts(value.get(key)))266 elif item_type in {"reasoning", "thinking"}:267 for key in ("text", "content", "reasoning_content", "reasoning"):268 parts.extend(_flatten_chat_text_parts(value.get(key)))269 return parts270 return []271 272 273def flatten_chat_text(value: object) -> str:274 return "\n".join(_flatten_chat_text_parts(value)).strip()275 276 277def extract_openai_chat_text(body: object) -> Tuple[str, str]:278 if not isinstance(body, dict):279 return "", "invalid_body"280 281 choices = body.get("choices")282 if not isinstance(choices, list) or not choices:283 fallback = flatten_chat_text(body.get("output_text"))284 if fallback:285 return fallback, "body.output_text"286 return "", "missing_choices"287 288 first_choice = choices[0]289 if not isinstance(first_choice, dict):290 return "", "invalid_choice"291 292 message = first_choice.get("message")293 if isinstance(message, dict):294 for key in ("content", "reasoning_content", "reasoning"):295 text = flatten_chat_text(message.get(key))296 if text:297 return text, f"message.{key}"298 299 for key in ("text", "content", "reasoning_content", "reasoning"):300 text = flatten_chat_text(first_choice.get(key))301 if text:302 return text, f"choice.{key}"303 304 delta = first_choice.get("delta")305 if isinstance(delta, dict):306 for key in ("content", "reasoning_content", "reasoning"):307 text = flatten_chat_text(delta.get(key))308 if text:309 return text, f"delta.{key}"310 311 fallback = flatten_chat_text(body.get("output_text"))312 if fallback:313 return fallback, "body.output_text"314 return "", "empty"315 316 317def detect_framework_from_text(text: object) -> Optional[str]:318 lowered = normalize_text(text).lower()319 if not lowered:320 return None321 if any(token in lowered for token in ("tokenspeed", "token-speed", "/ts/")):322 return "tokenspeed"323 if any(324 token in lowered325 for token in (326 "tensorrt_llm",327 "tensorrt-llm",328 "trtllm",329 "pyexecutor",330 )331 ):332 return "trtllm"333 if "vllm" in lowered:334 return "vllm"335 if any(token in lowered for token in ("python/sglang/", "sgl_kernel/", "sglang/")):336 return "sglang"337 return None338 339 340def detect_framework_from_server_args(server_args: Optional[dict]) -> Optional[str]:341 if not isinstance(server_args, dict) or not server_args:342 return None343 lowered_keys = {normalize_text(key).lower() for key in server_args}344 text = json.dumps(server_args, sort_keys=True)345 if any(token in text.lower() for token in ("tokenspeed", "token-speed")):346 return "tokenspeed"347 if lowered_keys & {348 "attn_tp_size",349 "dense_tp_size",350 "moe_tp_size",351 "enable_mla_l1_5_cache",352 "mla_chunk_multiplier",353 "comm_fusion_max_num_tokens",354 "enable_allreduce_fusion",355 }:356 return "tokenspeed"357 text_hint = detect_framework_from_text(text)358 if text_hint:359 return text_hint360 if lowered_keys & {361 "attention_backend",362 "sampling_backend",363 "disable_cuda_graph",364 "disable_piecewise_cuda_graph",365 "chunked_prefill_size",366 "schedule_policy",367 }:368 return "sglang"369 return None370 371 372def detect_framework_from_trace(trace: object) -> Optional[str]:373 text_samples: List[str] = []374 for event in extract_trace_events(trace)[:256]:375 text_samples.extend(376 [377 str(event.get("name", "")),378 str(event.get("cat", "")),379 str(event.get("pid", "")),380 ]381 )382 trace_args = event.get("args")383 if isinstance(trace_args, dict):384 for key, value in list(trace_args.items())[:8]:385 text_samples.append(str(key))386 if isinstance(value, str):387 text_samples.append(value)388 return detect_framework_from_text(" ".join(text_samples))389 390 391def detect_framework_from_path(path: Path) -> Optional[str]:392 hint = detect_framework_from_text(str(path))393 if hint:394 return hint395 server_args = load_server_args(path)396 hint = detect_framework_from_server_args(server_args)397 if hint:398 return hint399 if path.is_file():400 try:401 return detect_framework_from_trace(load_trace_json(path))402 except Exception:403 return None404 trace_files = discover_trace_files(path, recursive=True, limit=3)405 for trace_file in trace_files:406 try:407 hint = detect_framework_from_trace(load_trace_json(trace_file))408 except Exception:409 hint = None410 if hint:411 return hint412 return None413 414 415def detect_framework_from_url(416 url: str, output_dir: Optional[str] = None417) -> Optional[str]:418 hint = detect_framework_from_text(output_dir or "")419 if hint:420 return hint421 server_info = try_get_json(url.rstrip("/") + "/server_info")422 if isinstance(server_info, dict) and (423 "internal_states" in server_info424 or "tokenizer_path" in server_info425 or "prefill" in server_info426 or "decode" in server_info427 ):428 return "sglang"429 readiness = try_get_json(url.rstrip("/") + "/readiness", timeout=5.0)430 if readiness is not None:431 return "tokenspeed"432 models = try_get_json(url.rstrip("/") + "/v1/models")433 if isinstance(models, dict) and isinstance(models.get("data"), list):434 return "vllm"435 return None436 437 438def resolve_framework(439 requested: object,440 *,441 input_path: Optional[Path] = None,442 url: Optional[str] = None,443 server_args: Optional[dict] = None,444) -> str:445 explicit = canonicalize_framework(requested)446 if explicit != "auto":447 return explicit448 for hint in (449 detect_framework_from_server_args(server_args),450 detect_framework_from_path(input_path) if input_path else None,451 (452 detect_framework_from_url(url, str(input_path) if input_path else None)453 if url454 else None455 ),456 ):457 if hint:458 return hint459 return "sglang"460 461 462def parse_stage(path: Path) -> str:463 parts = [part.lower() for part in path.parts[-6:]]464 name = " ".join(parts)465 segment_path = "/" + "/".join(parts) + "/"466 if any(marker in name for marker in ("-extend", "-prefill", "_extend", "_prefill")):467 return "extend"468 if any(f"/{segment}/" in segment_path for segment in ("extend", "prefill")):469 return "extend"470 if any(marker in name for marker in ("-decode", "_decode")):471 return "decode"472 if "/decode/" in segment_path:473 return "decode"474 return "all"475 476 477def parse_tp_rank(path: Path) -> Optional[int]:478 for pattern in (479 r"(?:^|[_-])tp(\d+)(?:[_.-]|$)",480 r"TP-(\d+)",481 r"(?:^|[_-])rank(\d+)(?:[_.-]|$)",482 r"(?:^|[_-])worker(\d+)(?:[_.-]|$)",483 ):484 match = re.search(pattern, path.name, re.IGNORECASE)485 if match:486 return int(match.group(1))487 return None488 489 490def file_looks_like_trace(path: Path) -> bool:491 name = path.name.lower()492 if name in TRACE_FILE_IGNORE_NAMES:493 return False494 if path.is_dir():495 return False496 if any(name.endswith(suffix) for suffix in (".trace.json", ".trace.json.gz")):497 return True498 if ".pt.trace.json" in name:499 return True500 if not any(name.endswith(suffix) for suffix in (".json", ".json.gz")):501 return False502 try:503 trace = load_trace_json(path)504 except Exception:505 return False506 if isinstance(trace, dict):507 return isinstance(trace.get("traceEvents"), list)508 if isinstance(trace, list):509 return bool(trace) and all(isinstance(item, dict) for item in trace[:8])510 return False511 512 513def discover_trace_files(514 path: Path,515 *,516 recursive: bool,517 limit: Optional[int] = None,518) -> List[Path]:519 if path.is_file():520 return [path] if file_looks_like_trace(path) else []521 522 candidates: List[Path] = []523 seen: set[Path] = set()524 for pattern in TRACE_FILE_PATTERNS:525 iterator = path.rglob(pattern) if recursive else path.glob(pattern)526 for candidate in iterator:527 resolved = candidate.resolve()528 if resolved in seen:529 continue530 seen.add(resolved)531 candidates.append(resolved)532 candidates = [533 candidate534 for candidate in candidates535 if candidate.exists() and file_looks_like_trace(candidate)536 ]537 candidates.sort(key=lambda item: item.stat().st_mtime)538 if limit is not None and limit >= 0:539 return candidates[-limit:] if limit else []540 return candidates541 542 543def newest_trace_dir(path: Path) -> Path:544 if path.is_file():545 return path.parent546 direct = discover_trace_files(path, recursive=False)547 if direct:548 return path549 traces = discover_trace_files(path, recursive=True)550 trace_dirs = list({trace.parent for trace in traces})551 if not trace_dirs:552 raise FileNotFoundError(f"No trace files found under {path}")553 trace_dirs.sort(554 key=lambda item: max(555 trace.stat().st_mtime for trace in traces if trace.parent == item556 )557 )558 return trace_dirs[-1]559 560 561def discover_trace_targets(562 path: Path, all_traces: bool563) -> Tuple[List[Path], Optional[dict]]:564 if path.is_file():565 return [path], load_server_args(path)566 567 direct_traces = discover_trace_files(path, recursive=False)568 recursive_traces = discover_trace_files(path, recursive=True)569 recursive_stages = {parse_stage(trace) for trace in recursive_traces}570 if (571 not direct_traces572 and recursive_traces573 and any(stage != "all" for stage in recursive_stages)574 ):575 traces = recursive_traces576 trace_dir = path577 else:578 trace_dir = newest_trace_dir(path)579 traces = discover_trace_files(trace_dir, recursive=False)580 if not traces:581 raise FileNotFoundError(f"No trace files found under {trace_dir}")582 583 non_merged = [trace for trace in traces if not trace.name.startswith("merged-")]584 selected = non_merged or traces585 if not all_traces:586 ranks = sorted(587 {588 rank589 for rank in (parse_tp_rank(trace) for trace in selected)590 if rank is not None591 }592 )593 if ranks:594 rank = 0 if 0 in ranks else ranks[0]595 selected = [trace for trace in selected if parse_tp_rank(trace) == rank]596 grouped: Dict[str, List[Path]] = defaultdict(list)597 for trace in selected:598 grouped[parse_stage(trace)].append(trace)599 selected = [600 sorted(group, key=lambda item: item.stat().st_mtime)[-1]601 for group in grouped.values()602 ]603 604 selected.sort(key=lambda item: (STAGE_ORDER.get(parse_stage(item), 99), item.name))605 return selected, load_server_args(trace_dir)606 607 608def post_json(609 url: str, payload: Optional[dict] = None, timeout: float = 60.0610) -> Optional[dict]:611 req = request.Request(612 url=url,613 data=(None if payload is None else json.dumps(payload).encode("utf-8")),614 headers={"Content-Type": "application/json"},615 method="POST",616 )617 with request.urlopen(req, timeout=timeout) as response:618 raw = response.read()619 return json.loads(raw.decode("utf-8")) if raw else None620 621 622def send_probe_request(623 url: str,624 prompt: str,625 max_new_tokens: int,626 sampling_seed: int,627 framework: str,628 model: Optional[str] = None,629) -> None:630 framework = canonicalize_framework(framework)631 if framework == "sglang":632 payload = {633 "text": prompt,634 "sampling_params": {635 "sampling_seed": sampling_seed,636 "temperature": 0.0,637 "max_new_tokens": max_new_tokens,638 },639 "stream": False,640 }641 post_json(url.rstrip("/") + "/generate", payload, timeout=300.0)642 return643 644 resolved_model = model or discover_openai_model(url)645 chat_payload = {646 "model": resolved_model,647 "messages": [{"role": "user", "content": prompt}],648 "temperature": 0.0,649 "max_tokens": max_new_tokens,650 "stream": False,651 }652 try:653 post_json(url.rstrip("/") + "/v1/chat/completions", chat_payload, timeout=300.0)654 return655 except Exception:656 completion_payload = {657 "model": resolved_model,658 "prompt": prompt,659 "temperature": 0.0,660 "max_tokens": max_new_tokens,661 "stream": False,662 }663 post_json(664 url.rstrip("/") + "/v1/completions",665 completion_payload,666 timeout=300.0,667 )668 669 670def unique_probe_prompt(prompt: str, probe_index: int) -> str:671 marker = f"profile_probe_{max(0, int(probe_index))}"672 parts = prompt.split(maxsplit=1)673 suffix = parts[1] if len(parts) == 2 else prompt674 return f"{marker} {suffix}".strip()675 676 677def send_probe_requests(678 *,679 url: str,680 prompt: str,681 max_new_tokens: int,682 request_count: int,683 framework: str,684 model: Optional[str] = None,685 sampling_seed_offset: int = 0,686) -> None:687 request_count = max(0, int(request_count))688 seed_offset = max(0, int(sampling_seed_offset))689 for request_idx in range(request_count):690 probe_index = seed_offset + request_idx691 send_probe_request(692 url=url,693 prompt=unique_probe_prompt(prompt, probe_index),694 max_new_tokens=max_new_tokens,695 sampling_seed=probe_index,696 framework=framework,697 model=model,698 )699 700 701def synthetic_prompt(input_len: int) -> str:702 token_count = max(1, int(input_len))703 return " ".join(["profile"] * token_count)704 705 706def workload_probe(707 stage: str,708 *,709 prefill_input_len: int,710 prefill_output_len: int,711 decode_input_len: int,712 decode_output_len: int,713) -> Tuple[str, int]:714 if stage == "prefill":715 return synthetic_prompt(prefill_input_len), max(1, int(prefill_output_len))716 if stage == "decode":717 return synthetic_prompt(decode_input_len), max(1, int(decode_output_len))718 raise ValueError(f"unknown profile workload stage: {stage}")719 720 721def build_probe_plan(722 stage: str,723 *,724 prompt: str,725 max_new_tokens: int,726 num_steps: int,727 probe_requests: int,728 warmup_steps: int,729) -> ProbePlan:730 active_steps = max(1, int(num_steps))731 requested_probes = max(1, int(probe_requests))732 warmup_steps = max(0, int(warmup_steps))733 max_new_tokens = max(1, int(max_new_tokens))734 735 if stage == "prefill":736 return ProbePlan(737 prompt=prompt,738 capture_max_new_tokens=max_new_tokens,739 capture_requests=max(requested_probes, active_steps),740 warmup_max_new_tokens=max_new_tokens,741 warmup_requests=warmup_steps,742 )743 if stage == "decode":744 return ProbePlan(745 prompt=prompt,746 capture_max_new_tokens=max_new_tokens,747 capture_requests=requested_probes,748 warmup_max_new_tokens=max(1, warmup_steps),749 warmup_requests=1 if warmup_steps else 0,750 )751 return ProbePlan(752 prompt=prompt,753 capture_max_new_tokens=max_new_tokens,754 capture_requests=requested_probes,755 warmup_max_new_tokens=max_new_tokens,756 warmup_requests=warmup_steps,757 )758 759 760def expand_profile_workload(profile_workload: str) -> List[str]:761 workload = normalize_text(profile_workload).lower()762 if workload not in PROFILE_WORKLOAD_CHOICES:763 raise ValueError(764 f"--profile-workload must be one of {', '.join(PROFILE_WORKLOAD_CHOICES)}"765 )766 if workload == "both":767 return ["prefill", "decode"]768 if workload == "legacy":769 return ["legacy"]770 return [workload]771 772 773def discover_openai_model(url: str) -> str:774 payload = try_get_json(url.rstrip("/") + "/v1/models", timeout=60.0)775 if not isinstance(payload, dict):776 raise RuntimeError(f"Could not read {url.rstrip('/')}/v1/models")777 data = payload.get("data")778 if not isinstance(data, list) or not data:779 raise RuntimeError(f"No models returned by {url.rstrip('/')}/v1/models")780 first = data[0]781 if isinstance(first, dict) and first.get("id"):782 return str(first["id"])783 raise RuntimeError(f"Malformed /v1/models payload from {url.rstrip('/')}")784 785 786def ensure_remote_profiler_output_path(787 output_dir: Optional[str], framework: str788) -> Path:789 if not output_dir:790 raise ValueError(791 f"{framework_display_name(framework)} live capture requires --output-dir "792 "to point at the server-side torch profiler trace path that is visible "793 "from this machine."794 )795 output_path = Path(output_dir).expanduser().resolve()796 if output_path.suffix in {".json", ".gz"}:797 output_path.parent.mkdir(parents=True, exist_ok=True)798 else:799 output_path.mkdir(parents=True, exist_ok=True)800 return output_path801 802 803def wait_for_profiler_artifact(path: Path, timeout_s: float = 60.0) -> Path:804 deadline = time.time() + timeout_s805 while time.time() < deadline:806 if path.is_file() and file_looks_like_trace(path):807 return path808 if path.exists():809 trace_files = discover_trace_files(path, recursive=True)810 if trace_files:811 return newest_trace_dir(path)812 if path.is_dir():813 child_dirs = [item for item in path.iterdir() if item.is_dir()]814 if child_dirs:815 child_dirs.sort(key=lambda item: item.stat().st_mtime)816 newest_child = child_dirs[-1]817 child_traces = discover_trace_files(newest_child, recursive=True)818 if child_traces:819 return newest_child820 time.sleep(0.5)821 return path822 823 824def start_remote_profiler(825 url: str, framework: str, payload: Optional[dict] = None826) -> None:827 try:828 post_json(url.rstrip("/") + "/start_profile", payload=payload, timeout=60.0)829 except Exception as exc:830 if framework == "vllm":831 raise RuntimeError(832 "vLLM live torch profiling requires the server to be launched with "833 '--profiler-config \'{"profiler":"torch","torch_profiler_dir":"..."}\' '834 "and to expose POST /start_profile."835 ) from exc836 if framework == "trtllm":837 raise RuntimeError(838 "TensorRT-LLM live torch profiling requires "839 "a server build that exposes POST /start_profile plus the env vars "840 "TLLM_PROFILE_START_STOP=<start>-<stop> and "841 "TLLM_TORCH_PROFILE_TRACE=/shared/path."842 ) from exc843 if framework == "tokenspeed":844 raise RuntimeError(845 "TokenSpeed live torch profiling requires a server build that "846 "exposes POST /start_profile and POST /stop_profile. The helper "847 "passes output_dir, activities, and profile_id in the start payload."848 ) from exc849 raise850 851 852def build_remote_profiler_start_payload(853 framework: str,854 output_path: Path,855 profile_prefix: Optional[str],856 stage: Optional[str],857) -> Optional[dict]:858 if framework != "tokenspeed":859 return None860 861 profile_id = profile_prefix or "triage-trace"862 if stage:863 profile_id = f"{profile_id}-{stage}"864 865 return {866 "output_dir": str(output_path),867 "activities": ["CPU", "GPU"],868 "with_stack": True,869 "record_shapes": False,870 "profile_id": profile_id,871 }872 873 874def stop_remote_profiler(url: str, framework: str) -> None:875 try:876 post_json(url.rstrip("/") + "/stop_profile", timeout=300.0)877 except Exception as exc:878 raise RuntimeError(879 f"Failed to stop {framework_display_name(framework)} profiler via "880 f"{url.rstrip('/')}/stop_profile"881 ) from exc882 883 884def run_remote_profiler(885 url: str,886 output_dir: Optional[str],887 framework: str,888 probe_plan: ProbePlan,889 probe_delay: float,890 profile_prefix: Optional[str] = None,891 stage: Optional[str] = None,892) -> Path:893 framework = canonicalize_framework(framework)894 output_path = ensure_remote_profiler_output_path(output_dir, framework)895 if stage and output_path.is_file():896 raise ValueError(897 "--profile-workload both requires a directory output path for "898 f"{framework_display_name(framework)} so each stage trace can be labeled."899 )900 before_traces = (901 set(discover_trace_files(output_path, recursive=True))902 if output_path.exists()903 else set()904 )905 model = (906 discover_openai_model(url)907 if framework in {"vllm", "trtllm", "tokenspeed"}908 else None909 )910 if probe_plan.warmup_requests > 0:911 send_probe_requests(912 url=url,913 prompt=probe_plan.prompt,914 max_new_tokens=probe_plan.warmup_max_new_tokens,915 request_count=probe_plan.warmup_requests,916 framework=framework,917 model=model,918 )919 920 start_payload = build_remote_profiler_start_payload(921 framework=framework,922 output_path=output_path,923 profile_prefix=profile_prefix,924 stage=stage,925 )926 start_remote_profiler(url, framework, payload=start_payload)927 stop_error: Optional[BaseException] = None928 try:929 if probe_plan.capture_requests > 0:930 # Server-side profilers may do setup work after POST /start_profile.931 # A very short delay can send probes too early and miss the window.932 time.sleep(max(5.0, probe_delay))933 send_probe_requests(934 url=url,935 prompt=probe_plan.prompt,936 max_new_tokens=probe_plan.capture_max_new_tokens,937 request_count=probe_plan.capture_requests,938 framework=framework,939 model=model,940 sampling_seed_offset=probe_plan.warmup_requests,941 )942 finally:943 try:944 stop_remote_profiler(url, framework)945 except BaseException as exc: # pragma: no cover - preserve original failure946 stop_error = exc947 if stop_error is not None:948 raise stop_error949 artifact = wait_for_profiler_artifact(output_path)950 if stage and output_path.is_dir():951 after_traces = set(discover_trace_files(output_path, recursive=True))952 new_traces = sorted(after_traces - before_traces, key=lambda item: item.name)953 if new_traces:954 stage_dir = output_path / stage955 stage_dir.mkdir(parents=True, exist_ok=True)956 for trace in new_traces:957 if stage_dir in trace.parents:958 continue959 target = stage_dir / trace.name960 if target.exists():961 target = stage_dir / f"{time.time_ns()}-{trace.name}"962 shutil.move(str(trace), str(target))963 return stage_dir964 return artifact965 966 967def run_sglang_profiler(968 url: str,969 output_dir: Optional[str],970 num_steps: int,971 profile_by_stage: bool,972 merge_profiles: bool,973 profile_prefix: Optional[str],974 probe_plan: ProbePlan,975 probe_delay: float,976 start_step: Optional[int] = None,977) -> Path:978 if output_dir is None:979 output_dir = tempfile.mkdtemp(prefix="sglang-torch-profile-")980 output_root = Path(output_dir).resolve()981 output_root.mkdir(parents=True, exist_ok=True)982 output_path = output_root / str(time.time())983 output_path.mkdir(parents=True, exist_ok=True)984 985 server_args = try_get_json(url.rstrip("/") + "/server_info", timeout=60.0)986 if server_args is not None:987 with open(output_path / "server_args.json", "w", encoding="utf-8") as handle:988 json.dump(server_args, handle)989 990 payload = {991 "output_dir": str(output_path),992 "num_steps": str(num_steps),993 "activities": ["CPU", "GPU"],994 "profile_by_stage": profile_by_stage,995 "merge_profiles": merge_profiles,996 "profile_prefix": profile_prefix,997 }998 if start_step is not None:999 payload["start_step"] = str(start_step)1000 1001 if probe_plan.warmup_requests > 0:1002 send_probe_requests(1003 url=url,1004 prompt=probe_plan.prompt,1005 max_new_tokens=probe_plan.warmup_max_new_tokens,1006 request_count=probe_plan.warmup_requests,1007 framework="sglang",1008 )1009 1010 req = request.Request(1011 url.rstrip("/") + "/start_profile",1012 data=json.dumps(payload).encode("utf-8"),1013 headers={"Content-Type": "application/json"},1014 )1015 with request.urlopen(req, timeout=300.0):1016 pass1017 1018 if probe_plan.capture_requests > 0:1019 time.sleep(max(0.0, probe_delay))1020 send_probe_requests(1021 url=url,1022 prompt=probe_plan.prompt,1023 max_new_tokens=probe_plan.capture_max_new_tokens,1024 request_count=probe_plan.capture_requests,1025 framework="sglang",1026 sampling_seed_offset=probe_plan.warmup_requests,1027 )1028 try:1029 stop_remote_profiler(url, "sglang")1030 except RuntimeError:1031 pass1032 1033 return wait_for_profiler_artifact(output_path, timeout_s=180.0)1034 1035 1036def run_profiler(1037 url: str,1038 output_dir: Optional[str],1039 num_steps: int,1040 profile_by_stage: bool,1041 merge_profiles: bool,1042 profile_prefix: Optional[str],1043 probe_requests: int,1044 probe_prompt: str,1045 probe_max_new_tokens: Optional[int],1046 probe_delay: float,1047 warmup_steps: int = DEFAULT_WARMUP_STEPS,1048 start_step: Optional[int] = None,1049 framework: str = "auto",1050 framework_hint_path: Optional[str] = None,1051 profile_workload: str = "both",1052 prefill_input_len: int = DEFAULT_PREFILL_INPUT_LEN,1053 prefill_output_len: int = DEFAULT_PREFILL_OUTPUT_LEN,1054 decode_input_len: int = DEFAULT_DECODE_INPUT_LEN,1055 decode_output_len: int = DEFAULT_DECODE_OUTPUT_LEN,1056) -> Path:1057 resolved_framework = resolve_framework(1058 framework,1059 url=url,1060 input_path=(1061 Path(framework_hint_path).expanduser().resolve()1062 if framework_hint_path1063 else None1064 ),1065 )1066 if resolved_framework == "sglang":1067 stages = expand_profile_workload(profile_workload)1068 if stages != ["legacy"]:1069 output_root = (1070 Path(output_dir).expanduser().resolve()1071 if output_dir1072 else Path(tempfile.mkdtemp(prefix="sglang-torch-profile-"))1073 )1074 output_root.mkdir(parents=True, exist_ok=True)1075 for stage in stages:1076 prompt, max_new_tokens = workload_probe(1077 stage,1078 prefill_input_len=prefill_input_len,1079 prefill_output_len=prefill_output_len,1080 decode_input_len=decode_input_len,1081 decode_output_len=decode_output_len,1082 )1083 probe_plan = build_probe_plan(1084 stage,1085 prompt=prompt,1086 max_new_tokens=max_new_tokens,1087 num_steps=num_steps,1088 probe_requests=probe_requests,1089 warmup_steps=warmup_steps,1090 )1091 # SGLang increments `forward_ct` before checking whether the1092 # profiler reached its target. Ask for one extra step so the1093 # requested stage forward is captured instead of stopping just1094 # before it runs.1095 stage_num_steps = max(1, int(num_steps)) + 11096 run_sglang_profiler(1097 url=url,1098 output_dir=str(output_root / stage),1099 num_steps=stage_num_steps,1100 profile_by_stage=False,1101 merge_profiles=merge_profiles,1102 profile_prefix=(1103 f"{profile_prefix}-{stage}" if profile_prefix else stage1104 ),1105 probe_plan=probe_plan,1106 probe_delay=probe_delay,1107 start_step=start_step,1108 )1109 return output_root1110 legacy_max_new_tokens = probe_max_new_tokens or max(64, num_steps * 8)1111 legacy_plan = build_probe_plan(1112 "legacy",1113 prompt=probe_prompt,1114 max_new_tokens=legacy_max_new_tokens,1115 num_steps=num_steps,1116 probe_requests=probe_requests,1117 warmup_steps=warmup_steps,1118 )1119 return run_sglang_profiler(1120 url=url,1121 output_dir=output_dir,1122 num_steps=num_steps,1123 profile_by_stage=profile_by_stage,1124 merge_profiles=merge_profiles,1125 profile_prefix=profile_prefix,1126 probe_plan=legacy_plan,1127 probe_delay=probe_delay,1128 start_step=start_step,1129 )1130 if start_step is not None:1131 raise ValueError("--start-step is only supported for SGLang live capture.")1132 if profile_by_stage:1133 raise ValueError(1134 "--profile-by-stage is only supported for SGLang live capture. "1135 "Disable it when profiling vLLM, TensorRT-LLM, or TokenSpeed."1136 )1137 if merge_profiles:1138 raise ValueError(1139 "--merge-profiles is only supported for SGLang live capture. "1140 "Disable it when profiling vLLM, TensorRT-LLM, or TokenSpeed."1141 )1142 if profile_prefix and resolved_framework in {"vllm", "trtllm"}:1143 print(1144 f"Note: {framework_display_name(resolved_framework)} ignores "1145 "--profile-prefix on the HTTP profiler control path.",1146 file=sys.stderr,1147 )1148 stages = expand_profile_workload(profile_workload)1149 if stages == ["legacy"]:1150 legacy_max_new_tokens = probe_max_new_tokens or max(64, num_steps * 8)1151 return run_remote_profiler(1152 url=url,1153 output_dir=output_dir,1154 framework=resolved_framework,1155 probe_plan=build_probe_plan(1156 "legacy",1157 prompt=probe_prompt,1158 max_new_tokens=legacy_max_new_tokens,1159 num_steps=num_steps,1160 probe_requests=probe_requests,1161 warmup_steps=warmup_steps,1162 ),1163 probe_delay=probe_delay,1164 profile_prefix=profile_prefix,1165 )1166 output_root = ensure_remote_profiler_output_path(output_dir, resolved_framework)1167 for stage in stages:1168 prompt, max_new_tokens = workload_probe(1169 stage,1170 prefill_input_len=prefill_input_len,1171 prefill_output_len=prefill_output_len,1172 decode_input_len=decode_input_len,1173 decode_output_len=decode_output_len,1174 )1175 run_remote_profiler(1176 url=url,1177 output_dir=str(output_root),1178 framework=resolved_framework,1179 probe_plan=build_probe_plan(1180 stage,1181 prompt=prompt,1182 max_new_tokens=max_new_tokens,1183 num_steps=num_steps,1184 probe_requests=probe_requests,1185 warmup_steps=warmup_steps,1186 ),1187 probe_delay=probe_delay,1188 profile_prefix=profile_prefix,1189 stage=stage,1190 )1191 return output_root1192 1193 1194def select_heaviest_pid(1195 events: Sequence[dict],1196 event_filter: Callable[[dict], bool],1197 pid_substring: Optional[str] = None,1198 preferred_substrings: Iterable[str] = (),1199) -> Optional[str]:1200 durations: Counter = Counter()1201 for event in events:1202 if not event_filter(event):1203 continue1204 pid = str(event.get("pid"))1205 if pid_substring and pid_substring not in pid:1206 continue1207 durations[pid] += float(event["dur"])1208 if not durations:1209 return None1210 1211 for substring in preferred_substrings:1212 preferred = [pid for pid in durations if substring in pid]1213 if preferred:1214 return max(preferred, key=lambda pid: durations[pid])1215 return max(durations, key=lambda pid: durations[pid])1216