已合并
【msserviceprofiler】【需求】【Tracing 2/3】接入vLLM Hook Tracing埋点并支持Jaeger链路展示 #447
ChaseChe77创建于 7 天前
【msserviceprofiler】【需求】【Tracing 2/3】接入vLLM Hook Tracing埋点并支持Jaeger链路展示 #447
已合并
共 13 个文件变更+837-52
| @@ -120,6 +120,11 @@ def _metrics_noop_handler(original_func, *args, **kwargs): | |||
| 120 | return original_func(*args, **kwargs) | 120 | return original_func(*args, **kwargs) |
| 121 | 121 | ||
| 122 | 122 | ||
| 123 | +def _profiling_noop_handler(original_func, *args, **kwargs): | ||
| 124 | + """Trace-only passthrough; it keeps profiling handlers out of the process.""" | ||
| 125 | + return original_func(*args, **kwargs) | ||
| 126 | + | ||
| 127 | + | ||
| 123 | def _resolve_metrics_handler_func(symbol_info: dict, method_name: str) -> Callable: | 128 | def _resolve_metrics_handler_func(symbol_info: dict, method_name: str) -> Callable: |
| 124 | """解析 metrics 配置的 handler:无 handler_path 时用 wrap_handler_with_metrics 封装透传函数; | 129 | """解析 metrics 配置的 handler:无 handler_path 时用 wrap_handler_with_metrics 封装透传函数; |
| 125 | 有 handler_path 且为 "module:func" 时导入并直接返回该函数(不再包装)。 | 130 | 有 handler_path 且为 "module:func" 时导入并直接返回该函数(不再包装)。 |
| @@ -158,6 +163,30 @@ class PatternEntry: | |||
| 158 | caller_filter: Optional[str] = None | 163 | caller_filter: Optional[str] = None |
| 159 | need_locals: bool = False | 164 | need_locals: bool = False |
| 160 | pattern_id: str = "" | 165 | pattern_id: str = "" |
| 166 | + around_hook_factory: Optional[Callable] = None | ||
| 167 | + | ||
| 168 | + | ||
| 169 | +def _make_trace_factory(item, method_name, symbol_path, traced_symbols, enable_tracing): | ||
| 170 | + """Build one tracing wrapper while keeping profiling parsing shallow.""" | ||
| 171 | + if not enable_tracing: | ||
| 172 | + return None | ||
| 173 | + | ||
| 174 | + from .trace_hook import make_trace_around_factory, parse_hook_trace_spec | ||
| 175 | + | ||
| 176 | + try: | ||
| 177 | + trace_spec = parse_hook_trace_spec(item, method_name) | ||
| 178 | + except ValueError as exc: | ||
| 179 | + logger.warning("Skip invalid tracing metadata for %s: %s", symbol_path, exc) | ||
| 180 | + return None | ||
| 181 | + | ||
| 182 | + if trace_spec is None: | ||
| 183 | + return None | ||
| 184 | + if symbol_path in traced_symbols: | ||
| 185 | + logger.warning("Ignore duplicate tracing definition for %s", symbol_path) | ||
| 186 | + return None | ||
| 187 | + | ||
| 188 | + traced_symbols.add(symbol_path) | ||
| 189 | + return make_trace_around_factory(trace_spec) | ||
| 161 | 190 | ||
| 162 | 191 | ||
| 163 | def _merge_config_impl( | 192 | def _merge_config_impl( |
| @@ -228,7 +257,7 @@ class ConfigLoader: | |||
| 228 | self._config_path = config_path | 257 | self._config_path = config_path |
| 229 | self._framework_version = framework_version | 258 | self._framework_version = framework_version |
| 230 | 259 | ||
| 231 | - def load_profiling(self) -> ProfilingConfig: | 260 | + def load_profiling(self, enable_profiling: bool = True, enable_tracing: bool = False) -> ProfilingConfig: |
| 232 | """加载 profiling yml 配置并解析为 Handler 列表与模式列表。 | 261 | """加载 profiling yml 配置并解析为 Handler 列表与模式列表。 |
| 233 | 262 | ||
| 234 | Returns: | 263 | Returns: |
| @@ -244,21 +273,28 @@ class ConfigLoader: | |||
| 244 | 273 | ||
| 245 | result: Dict[str, List[ConfigHooker]] = {} | 274 | result: Dict[str, List[ConfigHooker]] = {} |
| 246 | pattern_entries: List[PatternEntry] = [] | 275 | pattern_entries: List[PatternEntry] = [] |
| 247 | - | 276 | + traced_symbols = set() |
| 248 | for item in raw_config: | 277 | for item in raw_config: |
| 249 | if not isinstance(item, dict) or 'symbol' not in item: | 278 | if not isinstance(item, dict) or 'symbol' not in item: |
| 250 | logger.warning("Skip invalid config item: missing 'symbol'") | 279 | logger.warning("Skip invalid config item: missing 'symbol'") |
| 251 | continue | 280 | continue |
| 252 | 281 | ||
| 253 | symbol_path = item['symbol'] | 282 | symbol_path = item['symbol'] |
| 254 | - need_locals = "expr" in json.dumps(item) or "handler" in json.dumps(item) | 283 | + # Trace adapters use their independent around-call wrapper. Keep the |
| 284 | + # bytecode/local-variable path exclusive to existing profiling handlers. | ||
| 285 | + need_locals = enable_profiling and ("expr" in json.dumps(item) or "handler" in json.dumps(item)) | ||
| 255 | 286 | ||
| 256 | if _is_pattern_symbol(symbol_path): | 287 | if _is_pattern_symbol(symbol_path): |
| 257 | parsed = _parse_symbol_pattern(symbol_path) | 288 | parsed = _parse_symbol_pattern(symbol_path) |
| 258 | if not parsed: | 289 | if not parsed: |
| 259 | continue | 290 | continue |
| 260 | module_pattern, class_pattern, method_name = parsed | 291 | module_pattern, class_pattern, method_name = parsed |
| 261 | - handler_func = _resolve_handler_func(item, method_name) | 292 | + handler_func = _resolve_handler_func(item, method_name) if enable_profiling else _profiling_noop_handler |
| 293 | + around_hook_factory = _make_trace_factory( | ||
| 294 | + item, method_name, symbol_path, traced_symbols, enable_tracing | ||
| 295 | + ) | ||
| 296 | + if not enable_profiling and around_hook_factory is None: | ||
| 297 | + continue | ||
| 262 | name = item.get('name', method_name) | 298 | name = item.get('name', method_name) |
| 263 | domain = item.get('domain', 'Default') | 299 | domain = item.get('domain', 'Default') |
| 264 | pattern_entries.append( | 300 | pattern_entries.append( |
| @@ -274,6 +310,7 @@ class ConfigLoader: | |||
| 274 | caller_filter=item.get('caller_filter'), | 310 | caller_filter=item.get('caller_filter'), |
| 275 | need_locals=need_locals, | 311 | need_locals=need_locals, |
| 276 | pattern_id=symbol_path, | 312 | pattern_id=symbol_path, |
| 313 | + around_hook_factory=around_hook_factory, | ||
| 277 | ) | 314 | ) |
| 278 | ) | 315 | ) |
| 279 | continue | 316 | continue |
| @@ -283,7 +320,10 @@ class ConfigLoader: | |||
| 283 | continue | 320 | continue |
| 284 | 321 | ||
| 285 | hook_points = _build_hook_points(module_path, method_name, class_name) | 322 | hook_points = _build_hook_points(module_path, method_name, class_name) |
| 286 | - handler_func = _resolve_handler_func(item, method_name) | 323 | + handler_func = _resolve_handler_func(item, method_name) if enable_profiling else _profiling_noop_handler |
| 324 | + around_hook_factory = _make_trace_factory(item, method_name, symbol_path, traced_symbols, enable_tracing) | ||
| 325 | + if not enable_profiling and around_hook_factory is None: | ||
| 326 | + continue | ||
| 287 | 327 | ||
| 288 | handler_instance = ConfigHooker( | 328 | handler_instance = ConfigHooker( |
| 289 | hook_list=hook_points, | 329 | hook_list=hook_points, |
| @@ -294,6 +334,7 @@ class ConfigLoader: | |||
| 294 | caller_filter=item.get('caller_filter'), | 334 | caller_filter=item.get('caller_filter'), |
| 295 | need_locals=need_locals, | 335 | need_locals=need_locals, |
| 296 | framework_version=self._framework_version, | 336 | framework_version=self._framework_version, |
| 337 | + around_hook_factory=around_hook_factory, | ||
| 297 | ) | 338 | ) |
| 298 | 339 | ||
| 299 | if symbol_path not in result: | 340 | if symbol_path not in result: |
| @@ -88,6 +88,7 @@ class ConfigHooker: | |||
| 88 | caller_filter: Optional[str], | 88 | caller_filter: Optional[str], |
| 89 | need_locals: bool = False, | 89 | need_locals: bool = False, |
| 90 | framework_version: Optional[str] = None, | 90 | framework_version: Optional[str] = None, |
| 91 | + around_hook_factory: Optional[Callable] = None, | ||
| 91 | ): | 92 | ): |
| 92 | """初始化 ConfigHooker。 | 93 | """初始化 ConfigHooker。 |
| 93 | 94 | ||
| @@ -108,6 +109,7 @@ class ConfigHooker: | |||
| 108 | self.caller_filter = caller_filter | 109 | self.caller_filter = caller_filter |
| 109 | self.need_locals = need_locals | 110 | self.need_locals = need_locals |
| 110 | self.framework_version = framework_version | 111 | self.framework_version = framework_version |
| 112 | + self.around_hook_factory = around_hook_factory | ||
| 111 | 113 | ||
| 112 | # hook_func 改为支持多个,一个hook点位支持多个hook函数 | 114 | # hook_func 改为支持多个,一个hook点位支持多个hook函数 |
| 113 | hook_funcs = hook_func if isinstance(hook_func, list) else [hook_func] | 115 | hook_funcs = hook_func if isinstance(hook_func, list) else [hook_func] |
| @@ -295,6 +297,18 @@ class MultiHandlerDynamicHooker(DynamicHooker): | |||
| 295 | super().__init__(hook_list, hook_func, min_version, max_version, caller_filter, need_locals) | 297 | super().__init__(hook_list, hook_func, min_version, max_version, caller_filter, need_locals) |
| 296 | self.handlers = set() | 298 | self.handlers = set() |
| 297 | 299 | ||
| 300 | + def select_around_hook_factory(self, handlers): | ||
| 301 | + factories = [ | ||
| 302 | + factory | ||
| 303 | + for factory in (getattr(handler, "around_hook_factory", None) for handler in handlers) | ||
| 304 | + if factory is not None | ||
| 305 | + ] | ||
| 306 | + if len(factories) > 1: | ||
| 307 | + logger.warning("Multiple tracing definitions resolved for one symbol; only one will be applied") | ||
| 308 | + if self.around_hook_factory in factories: | ||
| 309 | + return self.around_hook_factory | ||
| 310 | + return factories[0] if factories else None | ||
| 311 | + | ||
| 298 | def build_wrap_hook_func(self, handler_wrap_hook_funcs): | 312 | def build_wrap_hook_func(self, handler_wrap_hook_funcs): |
| 299 | handler_wrap_hook_funcs = [ | 313 | handler_wrap_hook_funcs = [ |
| 300 | x for x in handler_wrap_hook_funcs if x is not None and x != VLLMHookerBase.default_hook_func | 314 | x for x in handler_wrap_hook_funcs if x is not None and x != VLLMHookerBase.default_hook_func |
| @@ -338,6 +352,7 @@ class MultiHandlerDynamicHooker(DynamicHooker): | |||
| 338 | self.handlers.add(handler) | 352 | self.handlers.add(handler) |
| 339 | self.wrap_hook_func = self.build_wrap_hook_func(x.wrap_hook_func for x in self.handlers) | 353 | self.wrap_hook_func = self.build_wrap_hook_func(x.wrap_hook_func for x in self.handlers) |
| 340 | self.context_hook_funcs = sum((x.context_hook_funcs for x in self.handlers), []) | 354 | self.context_hook_funcs = sum((x.context_hook_funcs for x in self.handlers), []) |
| 355 | + self.around_hook_factory = self.select_around_hook_factory(self.handlers) | ||
| 341 | self.need_locals = any((x.need_locals for x in self.handlers)) | 356 | self.need_locals = any((x.need_locals for x in self.handlers)) |
| 342 | self.init() | 357 | self.init() |
| 343 | 358 | ||
| @@ -352,6 +367,7 @@ class MultiHandlerDynamicHooker(DynamicHooker): | |||
| 352 | 367 | ||
| 353 | self.wrap_hook_func = self.build_wrap_hook_func(x.wrap_hook_func for x in self.handlers) | 368 | self.wrap_hook_func = self.build_wrap_hook_func(x.wrap_hook_func for x in self.handlers) |
| 354 | self.context_hook_funcs = sum((x.context_hook_funcs for x in self.handlers), []) | 369 | self.context_hook_funcs = sum((x.context_hook_funcs for x in self.handlers), []) |
| 370 | + self.around_hook_factory = self.select_around_hook_factory(self.handlers) | ||
| 355 | self.need_locals = any((x.need_locals for x in self.handlers)) | 371 | self.need_locals = any((x.need_locals for x in self.handlers)) |
| 356 | self.init() | 372 | self.init() |
| 357 | 373 | ||
| @@ -41,9 +41,8 @@ def _get_metric_hook_chain_getter(): | |||
| 41 | return _GET_CHAIN_FUNC | 41 | return _GET_CHAIN_FUNC |
| 42 | 42 | ||
| 43 | try: | 43 | try: |
| 44 | - from ms_service_metric.core.hook.hook_chain import get_chain as imported_get_chain | 44 | + hook_chain_module = importlib.import_module("ms_service_metric.core.hook.hook_chain") |
| 45 | - | 45 | + _GET_CHAIN_FUNC = hook_chain_module.get_chain |
| 46 | - _GET_CHAIN_FUNC = imported_get_chain | ||
| 47 | return _GET_CHAIN_FUNC | 46 | return _GET_CHAIN_FUNC |
| 48 | except Exception: | 47 | except Exception: |
| 49 | return None | 48 | return None |
| @@ -461,6 +460,10 @@ class VLLMHookerBase(ABC): | |||
| 461 | # 对应的 hook 处理函数,用于配置化时复用 | 460 | # 对应的 hook 处理函数,用于配置化时复用 |
| 462 | self.wrap_hook_func: Optional[Callable] = None | 461 | self.wrap_hook_func: Optional[Callable] = None |
| 463 | self.context_hook_funcs: List[Callable] = [] | 462 | self.context_hook_funcs: List[Callable] = [] |
| 463 | + # Optional outer wrapper used by independent instrumentation such as | ||
| 464 | + # Hook tracing. It is applied after the existing profiling callable | ||
| 465 | + # is built, so the profiling/context-hook execution model is unchanged. | ||
| 466 | + self.around_hook_factory: Optional[Callable] = None | ||
| 464 | self.need_locals = False | 467 | self.need_locals = False |
| 465 | 468 | ||
| 466 | 469 | ||
| @@ -600,11 +603,16 @@ class VLLMHookerBase(ABC): | |||
| 600 | 603 | ||
| 601 | if self.wrap_hook_func == VLLMHookerBase.default_hook_func: | 604 | if self.wrap_hook_func == VLLMHookerBase.default_hook_func: |
| 602 | # 如果都没有原始的 wrap_hook_func, 就直接使用原函数,拜托一层一层的封装 | 605 | # 如果都没有原始的 wrap_hook_func, 就直接使用原函数,拜托一层一层的封装 |
| 603 | - profiler_func = ori_func | 606 | + # An around hook must call the trackable wrapper so a business |
| 607 | + # exception is never retried as an instrumentation failure. | ||
| 608 | + profiler_func = trackable_ori_func if self.around_hook_factory else ori_func | ||
| 604 | else: | 609 | else: |
| 605 | # 如果有原始的 wrap_hook_func, 就使用修改前的方式 | 610 | # 如果有原始的 wrap_hook_func, 就使用修改前的方式 |
| 606 | profiler_func = profiler_func_maker(trackable_ori_func) | 611 | profiler_func = profiler_func_maker(trackable_ori_func) |
| 607 | 612 | ||
| 613 | + if self.around_hook_factory is not None: | ||
| 614 | + profiler_func = self.around_hook_factory(profiler_func, ori_func) | ||
| 615 | + | ||
| 608 | if hook_node is not None: | 616 | if hook_node is not None: |
| 609 | 617 | ||
| 610 | def _recover_current(cur_hook_ref=hook_node): | 618 | def _recover_current(cur_hook_ref=hook_node): |
| @@ -772,6 +780,8 @@ class VLLMHookerBase(ABC): | |||
| 772 | logger.debug(f"calling profiler_func={self.applied_hook_func_name} for {ori_func}") | 780 | logger.debug(f"calling profiler_func={self.applied_hook_func_name} for {ori_func}") |
| 773 | return await profiler_func(*args, **kwargs) | 781 | return await profiler_func(*args, **kwargs) |
| 774 | except Exception as e: | 782 | except Exception as e: |
| 783 | + if trackable_ori_func.executed and trackable_ori_func.cached_exception is e: | ||
| 784 | + raise | ||
| 775 | failures += 1 | 785 | failures += 1 |
| 776 | self._log_hook_exception(trackable_ori_func, e, failures) | 786 | self._log_hook_exception(trackable_ori_func, e, failures) |
| 777 | 787 | ||
| @@ -835,6 +845,8 @@ class VLLMHookerBase(ABC): | |||
| 835 | logger.debug(f"calling profiler_func={self.applied_hook_func_name} for {ori_func}") | 845 | logger.debug(f"calling profiler_func={self.applied_hook_func_name} for {ori_func}") |
| 836 | return profiler_func(*args, **kwargs) | 846 | return profiler_func(*args, **kwargs) |
| 837 | except Exception as e: | 847 | except Exception as e: |
| 848 | + if trackable_ori_func.executed and trackable_ori_func.cached_exception is e: | ||
| 849 | + raise | ||
| 838 | failures += 1 | 850 | failures += 1 |
| 839 | self._log_hook_exception(trackable_ori_func, e, failures) | 851 | self._log_hook_exception(trackable_ori_func, e, failures) |
| 840 | 852 | ||
| @@ -51,10 +51,7 @@ def discover_classes_with_method(module: Any, method_name: str, module_fullname: | |||
| 51 | meth = getattr(cls, method_name, None) | 51 | meth = getattr(cls, method_name, None) |
| 52 | if not callable(meth): | 52 | if not callable(meth): |
| 53 | continue | 53 | continue |
| 54 | - try: | 54 | + if getattr(meth, "__module__", None) != module_fullname: |
| 55 | - if getattr(meth, "__module__", None) != module_fullname: | ||
| 56 | - continue | ||
| 57 | - except Exception: | ||
| 58 | continue | 55 | continue |
| 59 | out.append((cls.__name__, method_name)) | 56 | out.append((cls.__name__, method_name)) |
| 60 | return out | 57 | return out |
| @@ -164,6 +161,7 @@ class SymbolWatchFinder(importlib.abc.MetaPathFinder): | |||
| 164 | max_version=entry.max_version, | 161 | max_version=entry.max_version, |
| 165 | caller_filter=entry.caller_filter, | 162 | caller_filter=entry.caller_filter, |
| 166 | need_locals=entry.need_locals, | 163 | need_locals=entry.need_locals, |
| 164 | + around_hook_factory=entry.around_hook_factory, | ||
| 167 | ) | 165 | ) |
| 168 | handler.register() | 166 | handler.register() |
| 169 | self._prepared_hookers.add(handler) | 167 | self._prepared_hookers.add(handler) |
| @@ -305,6 +303,9 @@ class SymbolWatchFinder(importlib.abc.MetaPathFinder): | |||
| 305 | class LoaderWrapper(importlib.abc.Loader): | 303 | class LoaderWrapper(importlib.abc.Loader): |
| 306 | _vllm_profiler_wrapped = True | 304 | _vllm_profiler_wrapped = True |
| 307 | 305 | ||
| 306 | + def __init__(self, finder): | ||
| 307 | + self._finder = finder | ||
| 308 | + | ||
| 308 | def create_module(self, spec): | 309 | def create_module(self, spec): |
| 309 | if hasattr(orig_loader, "create_module"): | 310 | if hasattr(orig_loader, "create_module"): |
| 310 | return orig_loader.create_module(spec) | 311 | return orig_loader.create_module(spec) |
| @@ -314,8 +315,7 @@ class SymbolWatchFinder(importlib.abc.MetaPathFinder): | |||
| 314 | orig_loader.exec_module(module) | 315 | orig_loader.exec_module(module) |
| 315 | self._finder.on_symbol_module_loaded(fullname) | 316 | self._finder.on_symbol_module_loaded(fullname) |
| 316 | 317 | ||
| 317 | - wrapper = LoaderWrapper() | 318 | + wrapper = LoaderWrapper(self) |
| 318 | - wrapper._finder = self | ||
| 319 | spec.loader = wrapper | 319 | spec.loader = wrapper |
| 320 | return spec | 320 | return spec |
| 321 | 321 | ||
| @@ -335,7 +335,12 @@ class SymbolWatchFinder(importlib.abc.MetaPathFinder): | |||
| 335 | matching = [e for e in all_pattern_entries if _pattern_matches_module(e.module_pattern, fullname)] | 335 | matching = [e for e in all_pattern_entries if _pattern_matches_module(e.module_pattern, fullname)] |
| 336 | if concrete_match or matching: | 336 | if concrete_match or matching: |
| 337 | self._module_matching_pattern_cache[fullname] = [ | 337 | self._module_matching_pattern_cache[fullname] = [ |
| 338 | - (e, self._pattern_to_concrete_profiling if e in self._pattern_handlers_profiling else self._pattern_to_concrete_metrics) | 338 | + ( |
| 339 | + e, | ||
| 340 | + self._pattern_to_concrete_profiling | ||
| 341 | + if e in self._pattern_handlers_profiling | ||
| 342 | + else self._pattern_to_concrete_metrics, | ||
| 343 | + ) | ||
| 339 | for e in matching | 344 | for e in matching |
| 340 | ] | 345 | ] |
| 341 | return True | 346 | return True |
| @@ -343,7 +348,7 @@ class SymbolWatchFinder(importlib.abc.MetaPathFinder): | |||
| 343 | 348 | ||
| 344 | def on_symbol_module_loaded(self, fullname: str): | 349 | def on_symbol_module_loaded(self, fullname: str): |
| 345 | """当 symbol 模块加载完成时的回调。""" | 350 | """当 symbol 模块加载完成时的回调。""" |
| 346 | - logger.debug(f"SymbolWatchFinder: Module loaded callback for {fullname}") | 351 | + logger.debug("SymbolWatchFinder: Module loaded callback for %s", fullname) |
| 347 | self._prepare_hooks_for_module(fullname) | 352 | self._prepare_hooks_for_module(fullname) |
| 348 | 353 | ||
| 349 | def _prepare_hooks_for_module(self, fullname: str): | 354 | def _prepare_hooks_for_module(self, fullname: str): |
| @@ -363,12 +368,12 @@ class SymbolWatchFinder(importlib.abc.MetaPathFinder): | |||
| 363 | continue | 368 | continue |
| 364 | discovered = discover_classes_with_method(module_obj, entry.method_name, fullname) | 369 | discovered = discover_classes_with_method(module_obj, entry.method_name, fullname) |
| 365 | for class_name, method_name in discovered: | 370 | for class_name, method_name in discovered: |
| 366 | - self._apply_one_pattern_hook( | 371 | + self._apply_one_pattern_hook(fullname, class_name, method_name, entry, pattern_to_concrete) |
| 367 | - fullname, class_name, method_name, entry, pattern_to_concrete | ||
| 368 | - ) | ||
| 369 | 372 | ||
| 370 | if module_handlers: | 373 | if module_handlers: |
| 371 | - logger.debug(f"Detected symbol module loaded: {fullname}, preparing {len(module_handlers)} handler groups") | 374 | + logger.debug( |
| 375 | + "Detected symbol module loaded: %s, preparing %d handler groups", fullname, len(module_handlers) | ||
| 376 | + ) | ||
| 372 | self._prepare_handlers_for_module(fullname, module_handlers) | 377 | self._prepare_handlers_for_module(fullname, module_handlers) |
| 373 | 378 | ||
| 374 | def _prepare_handlers_for_module(self, module_name: str, module_handlers: List[Tuple[str, List]]): | 379 | def _prepare_handlers_for_module(self, module_name: str, module_handlers: List[Tuple[str, List]]): |
| @@ -386,19 +391,19 @@ class SymbolWatchFinder(importlib.abc.MetaPathFinder): | |||
| 386 | with self._lock: | 391 | with self._lock: |
| 387 | if handler not in self._applied_hookers: | 392 | if handler not in self._applied_hookers: |
| 388 | self._applied_hookers.append(handler) | 393 | self._applied_hookers.append(handler) |
| 389 | - logger.debug(f"Auto-applied handler for symbol {symbol_path}") | 394 | + logger.debug("Auto-applied handler for symbol %s", symbol_path) |
| 390 | except Exception as e: | 395 | except Exception as e: |
| 391 | - logger.error(f"Failed to auto-apply handler for {symbol_path}: {e}") | 396 | + logger.error("Failed to auto-apply handler for %s: %s", symbol_path, e) |
| 392 | self._symbol_to_hooker[symbol_path] = hookers_for_symbol | 397 | self._symbol_to_hooker[symbol_path] = hookers_for_symbol |
| 393 | self._applied_hooks.add(symbol_path) | 398 | self._applied_hooks.add(symbol_path) |
| 394 | - logger.debug(f"Prepared {len(handler_list)} handler(s) for symbol {symbol_path}") | 399 | + logger.debug("Prepared %d handler(s) for symbol %s", len(handler_list), symbol_path) |
| 395 | except Exception as e: | 400 | except Exception as e: |
| 396 | - logger.error(f"Failed to prepare handlers for module {module_name}: {e}") | 401 | + logger.error("Failed to prepare handlers for module %s: %s", module_name, e) |
| 397 | 402 | ||
| 398 | def apply_all_hooks(self): | 403 | def apply_all_hooks(self): |
| 399 | """应用所有准备好的 hooks。""" | 404 | """应用所有准备好的 hooks。""" |
| 400 | self.set_auto_apply(True) | 405 | self.set_auto_apply(True) |
| 401 | - logger.info(f"Applying {len(self._prepared_hookers)} prepared hooks...") | 406 | + logger.info("Applying %d prepared hooks...", len(self._prepared_hookers)) |
| 402 | applied_now = 0 | 407 | applied_now = 0 |
| 403 | for hooker in list(self._prepared_hookers): | 408 | for hooker in list(self._prepared_hookers): |
| 404 | try: | 409 | try: |
| @@ -407,10 +412,10 @@ class SymbolWatchFinder(importlib.abc.MetaPathFinder): | |||
| 407 | if hooker not in self._applied_hookers: | 412 | if hooker not in self._applied_hookers: |
| 408 | self._applied_hookers.append(hooker) | 413 | self._applied_hookers.append(hooker) |
| 409 | applied_now += 1 | 414 | applied_now += 1 |
| 410 | - logger.debug(f"Applied hooker: {hooker.applied_hook_func_name}") | 415 | + logger.debug("Applied hooker: %s", hooker.applied_hook_func_name) |
| 411 | except Exception as e: | 416 | except Exception as e: |
| 412 | - logger.error(f"Failed to apply hooker {hooker.applied_hook_func_name}: {e}") | 417 | + logger.error("Failed to apply hooker %s: %s", hooker.applied_hook_func_name, e) |
| 413 | - logger.info(f"Successfully applied {applied_now} hooks") | 418 | + logger.info("Successfully applied %d hooks", applied_now) |
| 414 | return self.get_applied_hookers() | 419 | return self.get_applied_hookers() |
| 415 | 420 | ||
| 416 | def check_and_apply_existing_modules(self) -> bool: | 421 | def check_and_apply_existing_modules(self) -> bool: |
| @@ -422,7 +427,7 @@ class SymbolWatchFinder(importlib.abc.MetaPathFinder): | |||
| 422 | module_path = symbol_path.split(":")[0] | 427 | module_path = symbol_path.split(":")[0] |
| 423 | if module_path in sys.modules and module_path not in seen: | 428 | if module_path in sys.modules and module_path not in seen: |
| 424 | seen.add(module_path) | 429 | seen.add(module_path) |
| 425 | - logger.debug(f"Module {module_path} already loaded, preparing handlers") | 430 | + logger.debug("Module %s already loaded, preparing handlers", module_path) |
| 426 | self.on_symbol_module_loaded(module_path) | 431 | self.on_symbol_module_loaded(module_path) |
| 427 | # pattern symbol处理逻辑 | 432 | # pattern symbol处理逻辑 |
| 428 | for fullname in list(sys.modules.keys()): | 433 | for fullname in list(sys.modules.keys()): |
| @@ -430,6 +435,6 @@ class SymbolWatchFinder(importlib.abc.MetaPathFinder): | |||
| 430 | continue | 435 | continue |
| 431 | if self._is_target_symbol(fullname): | 436 | if self._is_target_symbol(fullname): |
| 432 | seen.add(fullname) | 437 | seen.add(fullname) |
| 433 | - logger.debug(f"Module {fullname} matches pattern, preparing handlers") | 438 | + logger.debug("Module %s matches pattern, preparing handlers", fullname) |
| 434 | self.on_symbol_module_loaded(fullname) | 439 | self.on_symbol_module_loaded(fullname) |
| 435 | return True | 440 | return True |
| @@ -0,0 +1,209 @@ | |||
| 1 | +# ------------------------------------------------------------------------- | ||
| 2 | +# This file is part of the MindStudio project. | ||
| 3 | +# Copyright (c) 2026 Huawei Technologies Co.,Ltd. | ||
| 4 | +# | ||
| 5 | +# MindStudio is licensed under Mulan PSL v2. | ||
| 6 | +# ------------------------------------------------------------------------- | ||
| 7 | + | ||
| 8 | +"""Thin around-call adapters that add business semantics to vLLM OTel spans.""" | ||
| 9 | + | ||
| 10 | +import functools | ||
| 11 | +import inspect | ||
| 12 | +import time | ||
| 13 | +from contextlib import contextmanager | ||
| 14 | +from dataclasses import dataclass | ||
| 15 | +from typing import Any, Dict, Iterable | ||
| 16 | + | ||
| 17 | +from ms_service_profiler.tracer.hook_runtime import get_hook_trace_runtime | ||
| 18 | + | ||
| 19 | + | ||
| 20 | +SUPPORTED_KINDS = {"INTERNAL", "SERVER", "CLIENT", "PRODUCER", "CONSUMER"} | ||
| 21 | +SUPPORTED_ADAPTERS = {"call", "request", "request_context", "schedule", "model", "output"} | ||
| 22 | + | ||
| 23 | + | ||
| 24 | + | ||
| 25 | +class HookTraceSpec: | ||
| 26 | + name: str | ||
| 27 | + domain: str | ||
| 28 | + kind: str = "INTERNAL" | ||
| 29 | + adapter: str = "call" | ||
| 30 | + | ||
| 31 | + | ||
| 32 | + | ||
| 33 | +class _TraceInvocation: | ||
| 34 | + args: tuple | ||
| 35 | + kwargs: dict | ||
| 36 | + return_value: Any = None | ||
| 37 | + | ||
| 38 | + | ||
| 39 | +def parse_hook_trace_spec(item: Dict[str, Any], method_name: str): | ||
| 40 | + raw = item.get("trace") | ||
| 41 | + if raw is None or raw is False: | ||
| 42 | + return None | ||
| 43 | + if raw is True: | ||
| 44 | + raw = {} | ||
| 45 | + if not isinstance(raw, dict): | ||
| 46 | + raise ValueError("trace must be a mapping, true, false, or omitted") | ||
| 47 | + name = raw.get("name", item.get("name", method_name)) | ||
| 48 | + domain = raw.get("domain", item.get("domain", "Tracing")) | ||
| 49 | + kind = str(raw.get("kind", "INTERNAL")).upper() | ||
| 50 | + adapter = raw.get("adapter", "call") | ||
| 51 | + if not isinstance(name, str) or not name: | ||
| 52 | + raise ValueError("trace.name must be a non-empty string") | ||
| 53 | + if not isinstance(domain, str) or not domain: | ||
| 54 | + raise ValueError("trace.domain must be a non-empty string") | ||
| 55 | + if kind not in SUPPORTED_KINDS: | ||
| 56 | + raise ValueError("trace.kind is unsupported") | ||
| 57 | + if adapter not in SUPPORTED_ADAPTERS: | ||
| 58 | + raise ValueError("trace.adapter is unsupported") | ||
| 59 | + return HookTraceSpec(name, domain, kind, adapter) | ||
| 60 | + | ||
| 61 | + | ||
| 62 | +def _request_id_from_call(invocation: _TraceInvocation) -> str: | ||
| 63 | + if "request_id" in invocation.kwargs: | ||
| 64 | + return str(invocation.kwargs["request_id"]) | ||
| 65 | + if len(invocation.args) > 1: | ||
| 66 | + candidate = invocation.args[1] | ||
| 67 | + request_id = getattr(candidate, "request_id", candidate) | ||
| 68 | + if isinstance(request_id, (str, int)): | ||
| 69 | + return str(request_id) | ||
| 70 | + return "" | ||
| 71 | + | ||
| 72 | + | ||
| 73 | +def _trace_headers_from_call(invocation: _TraceInvocation): | ||
| 74 | + headers = invocation.kwargs.get("trace_headers") | ||
| 75 | + if headers: | ||
| 76 | + return headers | ||
| 77 | + for candidate in invocation.args[1:]: | ||
| 78 | + headers = getattr(candidate, "trace_headers", None) | ||
| 79 | + if headers: | ||
| 80 | + return headers | ||
| 81 | + return None | ||
| 82 | + | ||
| 83 | + | ||
| 84 | +def _request_ids_from_scheduler_output(output) -> Iterable[str]: | ||
| 85 | + return list((getattr(output, "num_scheduled_tokens", None) or {}).keys()) | ||
| 86 | + | ||
| 87 | + | ||
| 88 | +def _request_ids_from_engine_outputs(outputs) -> Iterable[str]: | ||
| 89 | + result = [] | ||
| 90 | + for output in outputs or []: | ||
| 91 | + request_id = getattr(output, "request_id", None) | ||
| 92 | + if request_id is not None: | ||
| 93 | + result.append(str(request_id)) | ||
| 94 | + return result | ||
| 95 | + | ||
| 96 | + | ||
| 97 | +def _request_ids_on_enter(spec: HookTraceSpec, invocation: _TraceInvocation) -> Iterable[str]: | ||
| 98 | + if spec.adapter == "model" and len(invocation.args) > 1: | ||
| 99 | + return _request_ids_from_scheduler_output(invocation.args[1]) | ||
| 100 | + if spec.adapter == "output" and len(invocation.args) > 1: | ||
| 101 | + return _request_ids_from_engine_outputs(invocation.args[1]) | ||
| 102 | + return [] | ||
| 103 | + | ||
| 104 | + | ||
| 105 | +def _add_exit_semantics(runtime, span, spec: HookTraceSpec, invocation: _TraceInvocation) -> None: | ||
| 106 | + if spec.adapter == "schedule": | ||
| 107 | + request_ids = list(_request_ids_from_scheduler_output(invocation.return_value)) | ||
| 108 | + span.set_attribute("batch.request_count", len(request_ids)) | ||
| 109 | + total_tokens = getattr(invocation.return_value, "total_num_scheduled_tokens", None) | ||
| 110 | + if total_tokens is not None: | ||
| 111 | + span.set_attribute("batch.scheduled_tokens", total_tokens) | ||
| 112 | + | ||
| 113 | + if spec.adapter == "output" and len(invocation.args) > 1: | ||
| 114 | + for output in invocation.args[1] or []: | ||
| 115 | + if getattr(output, "finish_reason", None) is not None: | ||
| 116 | + runtime.finish_request(getattr(output, "request_id", ""), True) | ||
| 117 | + | ||
| 118 | + | ||
| 119 | + | ||
| 120 | +def _trace_scope(spec: HookTraceSpec, invocation: _TraceInvocation): | ||
| 121 | + """Surround one call without using the profiling context-hook engine.""" | ||
| 122 | + runtime = get_hook_trace_runtime() | ||
| 123 | + if not runtime.enabled: | ||
| 124 | + yield | ||
| 125 | + return | ||
| 126 | + | ||
| 127 | + if spec.adapter == "request": | ||
| 128 | + request_id = _request_id_from_call(invocation) | ||
| 129 | + if request_id: | ||
| 130 | + runtime.start_request(request_id, _trace_headers_from_call(invocation)) | ||
| 131 | + try: | ||
| 132 | + yield | ||
| 133 | + except BaseException: | ||
| 134 | + if request_id: | ||
| 135 | + runtime.finish_request(request_id) | ||
| 136 | + raise | ||
| 137 | + return | ||
| 138 | + | ||
| 139 | + if spec.adapter == "request_context": | ||
| 140 | + request_id = _request_id_from_call(invocation) | ||
| 141 | + if request_id: | ||
| 142 | + runtime.register_request_context(request_id, _trace_headers_from_call(invocation)) | ||
| 143 | + yield | ||
| 144 | + return | ||
| 145 | + | ||
| 146 | + # OTel links are immutable. Scheduler request IDs are available only in | ||
| 147 | + # its return value, so create the completed span after the call. | ||
| 148 | + if spec.adapter == "schedule": | ||
| 149 | + start_time_ns = time.time_ns() | ||
| 150 | + try: | ||
| 151 | + yield | ||
| 152 | + except BaseException as exception: | ||
| 153 | + span = runtime.start_span(spec.name, spec.domain, spec.kind, start_time_ns=start_time_ns) | ||
| 154 | + span.end(False, str(exception), time.time_ns()) | ||
| 155 | + raise | ||
| 156 | + else: | ||
| 157 | + request_ids = list(_request_ids_from_scheduler_output(invocation.return_value)) | ||
| 158 | + span = runtime.start_span( | ||
| 159 | + spec.name, | ||
| 160 | + spec.domain, | ||
| 161 | + spec.kind, | ||
| 162 | + request_ids=request_ids, | ||
| 163 | + start_time_ns=start_time_ns, | ||
| 164 | + ) | ||
| 165 | + _add_exit_semantics(runtime, span, spec, invocation) | ||
| 166 | + span.end(True, end_time_ns=time.time_ns()) | ||
| 167 | + return | ||
| 168 | + | ||
| 169 | + request_ids = list(_request_ids_on_enter(spec, invocation)) | ||
| 170 | + span = runtime.start_span(spec.name, spec.domain, spec.kind, request_ids=request_ids) | ||
| 171 | + token = runtime.activate(span) | ||
| 172 | + try: | ||
| 173 | + yield | ||
| 174 | + except BaseException as exception: | ||
| 175 | + span.end(False, str(exception)) | ||
| 176 | + raise | ||
| 177 | + else: | ||
| 178 | + _add_exit_semantics(runtime, span, spec, invocation) | ||
| 179 | + span.end(True) | ||
| 180 | + finally: | ||
| 181 | + runtime.deactivate(token) | ||
| 182 | + | ||
| 183 | + | ||
| 184 | +def make_trace_around_factory(spec: HookTraceSpec): | ||
| 185 | + """Create one outer wrapper factory; it never changes profiling handlers.""" | ||
| 186 | + | ||
| 187 | + def factory(next_func, original_func): | ||
| 188 | + if inspect.iscoroutinefunction(original_func): | ||
| 189 | + | ||
| 190 | + | ||
| 191 | + async def async_wrapper(*args, **kwargs): | ||
| 192 | + invocation = _TraceInvocation(args, kwargs) | ||
| 193 | + with _trace_scope(spec, invocation): | ||
| 194 | + invocation.return_value = await next_func(*args, **kwargs) | ||
| 195 | + return invocation.return_value | ||
| 196 | + | ||
| 197 | + return async_wrapper | ||
| 198 | + | ||
| 199 | + | ||
| 200 | + def sync_wrapper(*args, **kwargs): | ||
| 201 | + invocation = _TraceInvocation(args, kwargs) | ||
| 202 | + with _trace_scope(spec, invocation): | ||
| 203 | + invocation.return_value = next_func(*args, **kwargs) | ||
| 204 | + return invocation.return_value | ||
| 205 | + | ||
| 206 | + return sync_wrapper | ||
| 207 | + | ||
| 208 | + factory.__name__ = "trace_around_{}_{}".format(spec.domain, spec.name).replace(".", "_") | ||
| 209 | + return factory | ||
| @@ -36,13 +36,19 @@ def register_service_profiler(): | |||
| 36 | if not ok: | 36 | if not ok: |
| 37 | return | 37 | return |
| 38 | 38 | ||
| 39 | + # Trace-only mode is a Python Hook feature and does not register profiling | ||
| 40 | + # callbacks or initialize any C++/torch profiling facility. | ||
| 41 | + if not _vllm_profiler._profiling_requested: | ||
| 42 | + logger.info("VLLM Hook tracing initialized (trace-only mode)") | ||
| 43 | + return | ||
| 44 | + | ||
| 39 | # 2. 获取回调函数 | 45 | # 2. 获取回调函数 |
| 40 | on_start, on_stop = _vllm_profiler.get_callbacks() | 46 | on_start, on_stop = _vllm_profiler.get_callbacks() |
| 41 | 47 | ||
| 42 | # 3. 注册回调到 mstx | 48 | # 3. 注册回调到 mstx |
| 43 | start_result = mstx_profiler.register_profiler_start_callback(on_start) | 49 | start_result = mstx_profiler.register_profiler_start_callback(on_start) |
| 44 | stop_result = mstx_profiler.register_profiler_stop_callback(on_stop) | 50 | stop_result = mstx_profiler.register_profiler_stop_callback(on_stop) |
| 45 | - | 51 | + |
| 46 | # 4. 根据结果处理 | 52 | # 4. 根据结果处理 |
| 47 | if start_result.is_dynamic and stop_result.is_dynamic: | 53 | if start_result.is_dynamic and stop_result.is_dynamic: |
| 48 | logger.info("Successfully registered VLLM profiler callbacks (dynamic mode)") | 54 | logger.info("Successfully registered VLLM profiler callbacks (dynamic mode)") |
| @@ -53,6 +59,4 @@ def register_service_profiler(): | |||
| 53 | try: | 59 | try: |
| 54 | register_torch_profiler() | 60 | register_torch_profiler() |
| 55 | except Exception as e: | 61 | except Exception as e: |
| 56 | - logger.warning(f"[Torch Profiler] Unexpected error in patch_model_runner_with_torch_profiler_register: {e}") | 62 | + logger.warning("[Torch Profiler] Unexpected error in patch_model_runner_with_torch_profiler_register: %s", e) |
| 57 | - | ||
| 58 | - | ||
| @@ -4,15 +4,25 @@ | |||
| 4 | min_version: "0.9.1" | 4 | min_version: "0.9.1" |
| 5 | handler: ms_service_profiler.patcher.vllm.handlers.v1.batch_handlers:schedule | 5 | handler: ms_service_profiler.patcher.vllm.handlers.v1.batch_handlers:schedule |
| 6 | name: batchFrameworkProcessing | 6 | name: batchFrameworkProcessing |
| 7 | + domain: Schedule | ||
| 8 | + trace: | ||
| 9 | + name: vllm.scheduler.schedule | ||
| 10 | + adapter: schedule | ||
| 7 | 11 | ||
| 8 | - symbol: vllm_ascend.core.scheduler:AscendScheduler.schedule | 12 | - symbol: vllm_ascend.core.scheduler:AscendScheduler.schedule |
| 9 | min_version: "0.9.1" | 13 | min_version: "0.9.1" |
| 10 | handler: ms_service_profiler.patcher.vllm.handlers.v1.batch_handlers:schedule | 14 | handler: ms_service_profiler.patcher.vllm.handlers.v1.batch_handlers:schedule |
| 11 | name: batchFrameworkProcessing | 15 | name: batchFrameworkProcessing |
| 16 | + domain: Schedule | ||
| 17 | + trace: | ||
| 18 | + name: vllm.scheduler.schedule | ||
| 19 | + adapter: schedule | ||
| 12 | 20 | ||
| 13 | - symbol: vllm.v1.core.sched.scheduler:Scheduler.add_request | 21 | - symbol: vllm.v1.core.sched.scheduler:Scheduler.add_request |
| 14 | min_version: "0.9.1" | 22 | min_version: "0.9.1" |
| 15 | handler: ms_service_profiler.patcher.vllm.handlers.v1.batch_handlers:add_request | 23 | handler: ms_service_profiler.patcher.vllm.handlers.v1.batch_handlers:add_request |
| 24 | + trace: | ||
| 25 | + adapter: request_context | ||
| 16 | 26 | ||
| 17 | # ===== KV Cache ===== | 27 | # ===== KV Cache ===== |
| 18 | - symbol: vllm.v1.core.kv_cache_manager:KVCacheManager.free | 28 | - symbol: vllm.v1.core.kv_cache_manager:KVCacheManager.free |
| @@ -38,21 +48,36 @@ | |||
| 38 | min_version: "0.9.1" | 48 | min_version: "0.9.1" |
| 39 | handler: ms_service_profiler.patcher.vllm.handlers.v1.model_handlers:execute_model | 49 | handler: ms_service_profiler.patcher.vllm.handlers.v1.model_handlers:execute_model |
| 40 | name: modelExec | 50 | name: modelExec |
| 51 | + domain: Execute | ||
| 52 | + trace: | ||
| 53 | + name: vllm.model.execute | ||
| 54 | + adapter: model | ||
| 41 | 55 | ||
| 42 | - symbol: vllm.v1.executor.multiproc_executor:MultiprocExecutor.execute_model | 56 | - symbol: vllm.v1.executor.multiproc_executor:MultiprocExecutor.execute_model |
| 43 | min_version: "0.9.1" | 57 | min_version: "0.9.1" |
| 44 | handler: ms_service_profiler.patcher.vllm.handlers.v1.model_handlers:execute_model | 58 | handler: ms_service_profiler.patcher.vllm.handlers.v1.model_handlers:execute_model |
| 45 | name: modelExec | 59 | name: modelExec |
| 60 | + domain: Execute | ||
| 61 | + trace: | ||
| 62 | + name: vllm.model.execute | ||
| 63 | + adapter: model | ||
| 46 | 64 | ||
| 47 | - symbol: vllm.v1.executor.uniproc_executor:UniProcExecutor.execute_model | 65 | - symbol: vllm.v1.executor.uniproc_executor:UniProcExecutor.execute_model |
| 48 | min_version: "0.9.1" | 66 | min_version: "0.9.1" |
| 49 | handler: ms_service_profiler.patcher.vllm.handlers.v1.model_handlers:execute_model | 67 | handler: ms_service_profiler.patcher.vllm.handlers.v1.model_handlers:execute_model |
| 50 | name: modelExec | 68 | name: modelExec |
| 69 | + domain: Execute | ||
| 70 | + trace: | ||
| 71 | + name: vllm.model.execute | ||
| 72 | + adapter: model | ||
| 51 | 73 | ||
| 52 | - symbol: vllm_ascend.worker.model_runner_v1:NPUModelRunner.execute_model | 74 | - symbol: vllm_ascend.worker.model_runner_v1:NPUModelRunner.execute_model |
| 53 | name: modelRunnerExec | 75 | name: modelRunnerExec |
| 54 | handler: ms_service_profiler.patcher.vllm.handlers.v1.model_handlers:execute_model_runner | 76 | handler: ms_service_profiler.patcher.vllm.handlers.v1.model_handlers:execute_model_runner |
| 55 | domain: Execute | 77 | domain: Execute |
| 78 | + trace: | ||
| 79 | + name: vllm_ascend.model_runner.execute | ||
| 80 | + adapter: model | ||
| 56 | 81 | ||
| 57 | - symbol: vllm_ascend.worker.model_runner_v1:NPUModelRunner._update_states | 82 | - symbol: vllm_ascend.worker.model_runner_v1:NPUModelRunner._update_states |
| 58 | name: _update_states | 83 | name: _update_states |
| @@ -88,6 +113,11 @@ | |||
| 88 | - symbol: vllm.v1.engine.async_llm:AsyncLLM.add_request | 113 | - symbol: vllm.v1.engine.async_llm:AsyncLLM.add_request |
| 89 | min_version: "0.9.1" | 114 | min_version: "0.9.1" |
| 90 | handler: ms_service_profiler.patcher.vllm.handlers.v1.request_handlers:add_request_async | 115 | handler: ms_service_profiler.patcher.vllm.handlers.v1.request_handlers:add_request_async |
| 116 | + name: vllm.request | ||
| 117 | + domain: Request | ||
| 118 | + trace: | ||
| 119 | + kind: SERVER | ||
| 120 | + adapter: request | ||
| 91 | 121 | ||
| 92 | - symbol: vllm.engine.async_llm_engine:AsyncLLMEngine.add_request | 122 | - symbol: vllm.engine.async_llm_engine:AsyncLLMEngine.add_request |
| 93 | min_version: "0.9.1" | 123 | min_version: "0.9.1" |
| @@ -97,6 +127,10 @@ | |||
| 97 | - symbol: vllm.v1.engine.output_processor:OutputProcessor.process_outputs | 127 | - symbol: vllm.v1.engine.output_processor:OutputProcessor.process_outputs |
| 98 | min_version: "0.9.1" | 128 | min_version: "0.9.1" |
| 99 | handler: ms_service_profiler.patcher.vllm.handlers.v1.request_handlers:process_outputs | 129 | handler: ms_service_profiler.patcher.vllm.handlers.v1.request_handlers:process_outputs |
| 130 | + name: vllm.output.process | ||
| 131 | + domain: Request | ||
| 132 | + trace: | ||
| 133 | + adapter: output | ||
| 100 | 134 | ||
| 101 | # ===== Meta ===== | 135 | # ===== Meta ===== |
| 102 | 136 | ||
| @@ -50,6 +50,12 @@ def _is_registry_subprocess() -> bool: | |||
| 50 | return False | 50 | return False |
| 51 | 51 | ||
| 52 | 52 | ||
| 53 | +def _clear_hook_trace_runtime() -> None: | ||
| 54 | + from ms_service_profiler.tracer.hook_runtime import get_hook_trace_runtime | ||
| 55 | + | ||
| 56 | + get_hook_trace_runtime().clear() | ||
| 57 | + | ||
| 58 | + | ||
| 53 | class VLLMProfiler: | 59 | class VLLMProfiler: |
| 54 | """vLLM 框架适配器。 | 60 | """vLLM 框架适配器。 |
| 55 | 61 | ||
| @@ -67,6 +73,9 @@ class VLLMProfiler: | |||
| 67 | self._vllm_version = VLLMProfiler._get_vllm_version() | 73 | self._vllm_version = VLLMProfiler._get_vllm_version() |
| 68 | self._controller: Optional[HookController] = None | 74 | self._controller: Optional[HookController] = None |
| 69 | self._initialized = False | 75 | self._initialized = False |
| 76 | + self._profiling_requested = True | ||
| 77 | + self._profiling_active = True | ||
| 78 | + self._tracing_requested = False | ||
| 70 | 79 | ||
| 71 | # ------------------------------------------------------------------------- | 80 | # ------------------------------------------------------------------------- |
| 72 | # 版本检测 | 81 | # 版本检测 |
| @@ -184,6 +193,12 @@ class VLLMProfiler: | |||
| 184 | def _load_profiling_config(self) -> Optional[ProfilingConfig]: | 193 | def _load_profiling_config(self) -> Optional[ProfilingConfig]: |
| 185 | """加载 profiling 配置文件并返回 ProfilingConfig(concrete + patterns)。""" | 194 | """加载 profiling 配置文件并返回 ProfilingConfig(concrete + patterns)。""" |
| 186 | 195 | ||
| 196 | + def _load_symbols(path: str) -> ProfilingConfig: | ||
| 197 | + return ConfigLoader(path, self._vllm_version).load_profiling( | ||
| 198 | + enable_profiling=self._profiling_active, | ||
| 199 | + enable_tracing=self._tracing_requested, | ||
| 200 | + ) | ||
| 201 | + | ||
| 187 | def _write_profiling_symbols(env_path: str, default_cfg: str) -> Optional[ProfilingConfig]: | 202 | def _write_profiling_symbols(env_path: str, default_cfg: str) -> Optional[ProfilingConfig]: |
| 188 | try: | 203 | try: |
| 189 | parent_dir = os.path.dirname(env_path) or '.' | 204 | parent_dir = os.path.dirname(env_path) or '.' |
| @@ -192,7 +207,7 @@ class VLLMProfiler: | |||
| 192 | dst.write(src.read()) | 207 | dst.write(src.read()) |
| 193 | logger.debug(f"Wrote profiling symbols to env path: {env_path}") | 208 | logger.debug(f"Wrote profiling symbols to env path: {env_path}") |
| 194 | logger.info("Loading vLLM profiling symbols from: %s", env_path) | 209 | logger.info("Loading vLLM profiling symbols from: %s", env_path) |
| 195 | - return ConfigLoader(env_path, self._vllm_version).load_profiling() | 210 | + return _load_symbols(env_path) |
| 196 | except Exception as e: | 211 | except Exception as e: |
| 197 | logger.warning(f"Failed to write profiling symbols to env path {env_path}: {e}") | 212 | logger.warning(f"Failed to write profiling symbols to env path {env_path}: {e}") |
| 198 | return None | 213 | return None |
| @@ -202,7 +217,7 @@ class VLLMProfiler: | |||
| 202 | if env_path and str(env_path).lower().endswith(('.yaml', '.yml')): | 217 | if env_path and str(env_path).lower().endswith(('.yaml', '.yml')): |
| 203 | if os.path.isfile(env_path): | 218 | if os.path.isfile(env_path): |
| 204 | logger.info("Loading vLLM profiling symbols from: %s", env_path) | 219 | logger.info("Loading vLLM profiling symbols from: %s", env_path) |
| 205 | - return ConfigLoader(env_path, self._vllm_version).load_profiling() | 220 | + return _load_symbols(env_path) |
| 206 | if default_cfg: | 221 | if default_cfg: |
| 207 | return _write_profiling_symbols(env_path, default_cfg) | 222 | return _write_profiling_symbols(env_path, default_cfg) |
| 208 | logger.warning("No default config file found to populate PROFILING_SYMBOLS_PATH") | 223 | logger.warning("No default config file found to populate PROFILING_SYMBOLS_PATH") |
| @@ -211,7 +226,7 @@ class VLLMProfiler: | |||
| 211 | 226 | ||
| 212 | if default_cfg: | 227 | if default_cfg: |
| 213 | logger.info("Loading vLLM profiling symbols from: %s", default_cfg) | 228 | logger.info("Loading vLLM profiling symbols from: %s", default_cfg) |
| 214 | - return ConfigLoader(default_cfg, self._vllm_version).load_profiling() | 229 | + return _load_symbols(default_cfg) |
| 215 | logger.warning("No config file found") | 230 | logger.warning("No config file found") |
| 216 | return None | 231 | return None |
| 217 | 232 | ||
| @@ -258,15 +273,21 @@ class VLLMProfiler: | |||
| 258 | bool: 初始化是否成功 | 273 | bool: 初始化是否成功 |
| 259 | """ | 274 | """ |
| 260 | try: | 275 | try: |
| 261 | - if not check_profiling_enabled(): | 276 | + self._profiling_requested = check_profiling_enabled() |
| 277 | + self._profiling_active = self._profiling_requested | ||
| 278 | + self._tracing_requested = os.environ.get("MS_TRACE_ENABLE") == "1" | ||
| 279 | + if not self._profiling_requested and not self._tracing_requested: | ||
| 262 | return False | 280 | return False |
| 263 | logger.debug("Initializing VLLM Service Profiler") | 281 | logger.debug("Initializing VLLM Service Profiler") |
| 264 | 282 | ||
| 265 | # 初始化metrics模块逻辑,后续有metrics独立开关后从此处移出 | 283 | # 初始化metrics模块逻辑,后续有metrics独立开关后从此处移出 |
| 266 | - setup_vllm_metrics() | 284 | + if self._profiling_requested: |
| 285 | + setup_vllm_metrics() | ||
| 267 | 286 | ||
| 268 | - # 导入 handlers | 287 | + # Trace-only startup consumes only YAML metadata and must not import |
| 269 | - self._import_handlers() | 288 | + # or execute the existing business profiling handlers. |
| 289 | + if self._profiling_requested: | ||
| 290 | + self._import_handlers() | ||
| 270 | # 创建 SymbolWatchFinder(未加载配置)并安装;registry 子进程内不安装,避免破坏 import 顺序 | 291 | # 创建 SymbolWatchFinder(未加载配置)并安装;registry 子进程内不安装,避免破坏 import 顺序 |
| 271 | watcher = SymbolWatchFinder() | 292 | watcher = SymbolWatchFinder() |
| 272 | if not _is_registry_subprocess(): | 293 | if not _is_registry_subprocess(): |
| @@ -315,8 +336,12 @@ class VLLMProfiler: | |||
| 315 | """禁用所有 hooks。""" | 336 | """禁用所有 hooks。""" |
| 316 | if self._controller is None: | 337 | if self._controller is None: |
| 317 | logger.warning("Profiler not initialized, cannot disable hooks") | 338 | logger.warning("Profiler not initialized, cannot disable hooks") |
| 339 | + if self._tracing_requested: | ||
| 340 | + _clear_hook_trace_runtime() | ||
| 318 | return | 341 | return |
| 319 | self._controller.disable() | 342 | self._controller.disable() |
| 343 | + if self._tracing_requested: | ||
| 344 | + _clear_hook_trace_runtime() | ||
| 320 | 345 | ||
| 321 | # ------------------------------------------------------------------------- | 346 | # ------------------------------------------------------------------------- |
| 322 | # C++ 回调(委托给 HookController) | 347 | # C++ 回调(委托给 HookController) |
| @@ -334,7 +359,26 @@ class VLLMProfiler: | |||
| 334 | logger.warning("Profiler not initialized, callback ignored") | 359 | logger.warning("Profiler not initialized, callback ignored") |
| 335 | 360 | ||
| 336 | return noop, noop | 361 | return noop, noop |
| 337 | - return self._controller.get_callbacks(self._load_config) | 362 | + if not self._tracing_requested: |
| 363 | + return self._controller.get_callbacks(self._load_config) | ||
| 364 | + | ||
| 365 | + def on_start(): | ||
| 366 | + try: | ||
| 367 | + self._profiling_active = self._profiling_requested | ||
| 368 | + profiling, metrics = self._load_config() | ||
| 369 | + self._controller.enable(profiling_handlers=profiling, metrics_handlers=metrics) | ||
| 370 | + except Exception as exc: | ||
| 371 | + logger.exception("Failed to handle profiler start with tracing enabled: %s", exc) | ||
| 372 | + | ||
| 373 | + def on_stop(): | ||
| 374 | + try: | ||
| 375 | + self._profiling_active = False | ||
| 376 | + tracing, metrics = self._load_config() | ||
| 377 | + self._controller.enable(profiling_handlers=tracing, metrics_handlers=metrics) | ||
| 378 | + except Exception as exc: | ||
| 379 | + logger.exception("Failed to keep tracing hooks after profiler stop: %s", exc) | ||
| 380 | + | ||
| 381 | + return on_start, on_stop | ||
| 338 | 382 | ||
| 339 | def get_metric_callbacks(self) -> Tuple[Callable[[], None], Callable[[], None]]: | 383 | def get_metric_callbacks(self) -> Tuple[Callable[[], None], Callable[[], None]]: |
| 340 | """返回可注册到 C++ 的 metric 回调函数对(on_start_metric, on_stop_metric)。 | 384 | """返回可注册到 C++ 的 metric 回调函数对(on_start_metric, on_stop_metric)。 |
| @@ -11,6 +11,7 @@ import os | |||
| 11 | import threading | 11 | import threading |
| 12 | from collections import OrderedDict | 12 | from collections import OrderedDict |
| 13 | from dataclasses import dataclass | 13 | from dataclasses import dataclass |
| 14 | +from itertools import islice | ||
| 14 | from typing import Iterable, Optional | 15 | from typing import Iterable, Optional |
| 15 | 16 | ||
| 16 | from .otel_hook import HookSpanContext, HookTraceSpan, get_hook_tracer_backend, new_noop_hook_span | 17 | from .otel_hook import HookSpanContext, HookTraceSpan, get_hook_tracer_backend, new_noop_hook_span |
| @@ -46,7 +47,7 @@ class HookTraceRuntime: | |||
| 46 | ) -> HookTraceSpan: | 47 | ) -> HookTraceSpan: |
| 47 | if not self.enabled: | 48 | if not self.enabled: |
| 48 | return new_noop_hook_span() | 49 | return new_noop_hook_span() |
| 49 | - normalized_request_ids = [str(item) for item in list(request_ids or [])[:MAX_LINKS_PER_SPAN]] | 50 | + normalized_request_ids = [str(item) for item in islice(request_ids or (), MAX_LINKS_PER_SPAN)] |
| 50 | links = self.request_links(normalized_request_ids) | 51 | links = self.request_links(normalized_request_ids) |
| 51 | span = self._backend.start_span(name, domain, kind, links=links, start_time_ns=start_time_ns) | 52 | span = self._backend.start_span(name, domain, kind, links=links, start_time_ns=start_time_ns) |
| 52 | if span.is_recording: | 53 | if span.is_recording: |
| @@ -97,7 +98,7 @@ class HookTraceRuntime: | |||
| 97 | with self._lock: | 98 | with self._lock: |
| 98 | request_traces = [ | 99 | request_traces = [ |
| 99 | (str(request_id), self._requests.get(str(request_id))) | 100 | (str(request_id), self._requests.get(str(request_id))) |
| 100 | - for request_id in list(request_ids)[:MAX_LINKS_PER_SPAN] | 101 | + for request_id in islice(request_ids, MAX_LINKS_PER_SPAN) |
| 101 | ] | 102 | ] |
| 102 | return [ | 103 | return [ |
| 103 | (request_trace.context, request_id) | 104 | (request_trace.context, request_id) |
| @@ -9,6 +9,7 @@ | |||
| 9 | 9 | ||
| 10 | import os | 10 | import os |
| 11 | import threading | 11 | import threading |
| 12 | +import time | ||
| 12 | from dataclasses import dataclass | 13 | from dataclasses import dataclass |
| 13 | from typing import Any, Iterable, Optional, Tuple | 14 | from typing import Any, Iterable, Optional, Tuple |
| 14 | 15 | ||
| @@ -19,6 +20,7 @@ from ms_service_profiler.utils.log import logger | |||
| 19 | MAX_ATTRIBUTE_COUNT = 32 | 20 | MAX_ATTRIBUTE_COUNT = 32 |
| 20 | MAX_ATTRIBUTE_KEY_LENGTH = 128 | 21 | MAX_ATTRIBUTE_KEY_LENGTH = 128 |
| 21 | MAX_ATTRIBUTE_VALUE_LENGTH = 1024 | 22 | MAX_ATTRIBUTE_VALUE_LENGTH = 1024 |
| 23 | +PERFETTO_REGISTRATION_RETRY_INTERVAL_SECONDS = 5.0 | ||
| 22 | 24 | ||
| 23 | 25 | ||
| 24 | try: | 26 | try: |
| @@ -150,6 +152,7 @@ class OpenTelemetryHookBackend: | |||
| 150 | def __init__(self): | 152 | def __init__(self): |
| 151 | self._lock = threading.RLock() | 153 | self._lock = threading.RLock() |
| 152 | self._perfetto_providers = set() | 154 | self._perfetto_providers = set() |
| 155 | + self._perfetto_retry_after = {} | ||
| 153 | self._warned_unavailable = False | 156 | self._warned_unavailable = False |
| 154 | self._warned_provider_missing = False | 157 | self._warned_provider_missing = False |
| 155 | 158 | ||
| @@ -193,18 +196,25 @@ class OpenTelemetryHookBackend: | |||
| 193 | logger.debug("Failed to probe Perfetto forwarder: %s", exc) | 196 | logger.debug("Failed to probe Perfetto forwarder: %s", exc) |
| 194 | perfetto_available = False | 197 | perfetto_available = False |
| 195 | if not perfetto_registered and perfetto_available: | 198 | if not perfetto_registered and perfetto_available: |
| 199 | + now = time.monotonic() | ||
| 196 | with self._lock: | 200 | with self._lock: |
| 197 | - if identity not in self._perfetto_providers: | 201 | + retry_after = self._perfetto_retry_after.get(identity, 0.0) |
| 198 | - processor = PerfettoSpanProcessor() | 202 | + if identity not in self._perfetto_providers and now >= retry_after: |
| 203 | + processor = None | ||
| 199 | try: | 204 | try: |
| 205 | + processor = PerfettoSpanProcessor() | ||
| 200 | provider.add_span_processor(processor) | 206 | provider.add_span_processor(processor) |
| 201 | except Exception as exc: | 207 | except Exception as exc: |
| 202 | - try: | 208 | + self._perfetto_retry_after[identity] = now + PERFETTO_REGISTRATION_RETRY_INTERVAL_SECONDS |
| 203 | - processor.shutdown() | 209 | + if processor is not None: |
| 204 | - except Exception as shutdown_exc: | 210 | + try: |
| 205 | - logger.debug("Failed to shut down Perfetto span processor: %s", shutdown_exc) | 211 | + processor.shutdown() |
| 212 | + except Exception as shutdown_exc: | ||
| 213 | + logger.debug("Failed to shut down Perfetto span processor: %s", shutdown_exc) | ||
| 206 | logger.debug("Failed to register Perfetto span processor: %s", exc) | 214 | logger.debug("Failed to register Perfetto span processor: %s", exc) |
| 207 | - self._perfetto_providers.add(identity) | 215 | + else: |
| 216 | + self._perfetto_providers.add(identity) | ||
| 217 | + self._perfetto_retry_after.pop(identity, None) | ||
| 208 | return provider | 218 | return provider |
| 209 | 219 | ||
| 210 | 220 | ||
| @@ -0,0 +1,324 @@ | |||
| 1 | +# ------------------------------------------------------------------------- | ||
| 2 | +# This file is part of the MindStudio project. | ||
| 3 | +# Copyright (c) 2026 Huawei Technologies Co.,Ltd. | ||
| 4 | +# | ||
| 5 | +# MindStudio is licensed under Mulan PSL v2. | ||
| 6 | +# ------------------------------------------------------------------------- | ||
| 7 | + | ||
| 8 | +"""Unit tests for vLLM Hook tracing without importing vLLM handlers.""" | ||
| 9 | + | ||
| 10 | +import asyncio | ||
| 11 | +from types import SimpleNamespace | ||
| 12 | +from unittest.mock import MagicMock, patch | ||
| 13 | + | ||
| 14 | +from ms_service_profiler.patcher.core.config_loader import ConfigLoader | ||
| 15 | +from ms_service_profiler.patcher.core.trace_hook import HookTraceSpec, make_trace_around_factory | ||
| 16 | +from ms_service_profiler.patcher.vllm import register_service_profiler | ||
| 17 | +from ms_service_profiler.patcher.vllm.service_patcher import VLLMProfiler | ||
| 18 | +from ms_service_profiler.tracer.hook_runtime import MAX_LINKS_PER_SPAN, HookTraceRuntime | ||
| 19 | +from ms_service_profiler.tracer.otel_hook import HookSpanContext | ||
| 20 | + | ||
| 21 | + | ||
| 22 | +class FakeNativeContext: | ||
| 23 | + is_valid = True | ||
| 24 | + trace_id = int("1" * 32, 16) | ||
| 25 | + span_id = int("2" * 16, 16) | ||
| 26 | + | ||
| 27 | + | ||
| 28 | +class FakeSpan: | ||
| 29 | + def __init__(self, context=None): | ||
| 30 | + self.context = context or HookSpanContext(FakeNativeContext()) | ||
| 31 | + self.attributes = {} | ||
| 32 | + self.end_calls = [] | ||
| 33 | + | ||
| 34 | + | ||
| 35 | + def is_recording(self): | ||
| 36 | + return not self.end_calls | ||
| 37 | + | ||
| 38 | + def set_attribute(self, key, value): | ||
| 39 | + self.attributes[key] = value | ||
| 40 | + | ||
| 41 | + def activate(self): | ||
| 42 | + return "token" | ||
| 43 | + | ||
| 44 | + | ||
| 45 | + def deactivate(token): | ||
| 46 | + return None | ||
| 47 | + | ||
| 48 | + def end(self, success=True, message="", end_time_ns=None): | ||
| 49 | + self.end_calls.append((success, message, end_time_ns)) | ||
| 50 | + | ||
| 51 | + | ||
| 52 | +class FakeBackend: | ||
| 53 | + enabled = True | ||
| 54 | + | ||
| 55 | + def __init__(self): | ||
| 56 | + self.calls = [] | ||
| 57 | + | ||
| 58 | + | ||
| 59 | + def context_from_headers(headers): | ||
| 60 | + return HookSpanContext(FakeNativeContext()) if headers else HookSpanContext() | ||
| 61 | + | ||
| 62 | + | ||
| 63 | + def current_context(): | ||
| 64 | + return HookSpanContext(FakeNativeContext()) | ||
| 65 | + | ||
| 66 | + def start_span(self, name, domain, kind, **kwargs): | ||
| 67 | + span = FakeSpan() | ||
| 68 | + self.calls.append((name, domain, kind, kwargs, span)) | ||
| 69 | + return span | ||
| 70 | + | ||
| 71 | + def shutdown(self): | ||
| 72 | + return None | ||
| 73 | + | ||
| 74 | + | ||
| 75 | +def _wrap(spec, original): | ||
| 76 | + return make_trace_around_factory(spec)(original, original) | ||
| 77 | + | ||
| 78 | + | ||
| 79 | +def test_trace_only_config_does_not_resolve_profiling_handler(): | ||
| 80 | + raw = [ | ||
| 81 | + { | ||
| 82 | + "symbol": "vllm.v1.engine.async_llm:AsyncLLM.add_request", | ||
| 83 | + "handler": "ms_service_profiler.patcher.vllm.handlers.v1.request_handlers:add_request_async", | ||
| 84 | + "trace": {"adapter": "request", "kind": "SERVER"}, | ||
| 85 | + } | ||
| 86 | + ] | ||
| 87 | + with ( | ||
| 88 | + patch("ms_service_profiler.patcher.core.config_loader.load_yaml_config", return_value=raw), | ||
| 89 | + patch("ms_service_profiler.patcher.core.config_loader._resolve_handler_func") as resolve_handler, | ||
| 90 | + ): | ||
| 91 | + config = ConfigLoader("unused.yaml", "0.15.0").load_profiling( | ||
| 92 | + enable_profiling=False, | ||
| 93 | + enable_tracing=True, | ||
| 94 | + ) | ||
| 95 | + | ||
| 96 | + hooker = config.concrete[raw[0]["symbol"]][0] | ||
| 97 | + resolve_handler.assert_not_called() | ||
| 98 | + assert hooker.need_locals is False | ||
| 99 | + assert hooker.context_hook_funcs == [] | ||
| 100 | + assert hooker.around_hook_factory is not None | ||
| 101 | + | ||
| 102 | + | ||
| 103 | +def test_builtin_yaml_supplies_trace_hooks_without_vllm_ascend_provider(): | ||
| 104 | + profiler = VLLMProfiler() | ||
| 105 | + profiler._profiling_active = False | ||
| 106 | + profiler._tracing_requested = True | ||
| 107 | + | ||
| 108 | + config = profiler._load_profiling_config() | ||
| 109 | + | ||
| 110 | + symbols = set(config.concrete) | ||
| 111 | + assert "vllm.v1.engine.async_llm:AsyncLLM.add_request" in symbols | ||
| 112 | + assert "vllm_ascend.core.scheduler:AscendScheduler.schedule" in symbols | ||
| 113 | + assert "vllm.engine.async_llm_engine:AsyncLLMEngine.add_request" not in symbols | ||
| 114 | + assert all(hooker.around_hook_factory is not None for hookers in config.concrete.values() for hooker in hookers) | ||
| 115 | + | ||
| 116 | + | ||
| 117 | +def test_trace_only_plugin_registration_does_not_start_profiling_facilities(): | ||
| 118 | + profiler = MagicMock() | ||
| 119 | + profiler.initialize.return_value = True | ||
| 120 | + profiler._profiling_requested = False | ||
| 121 | + profiler.get_callbacks.return_value = (MagicMock(), MagicMock()) | ||
| 122 | + dynamic_result = SimpleNamespace(is_dynamic=True) | ||
| 123 | + with ( | ||
| 124 | + patch("ms_service_profiler.patcher.vllm._vllm_profiler", profiler), | ||
| 125 | + patch( | ||
| 126 | + "ms_service_profiler.patcher.vllm.mstx_profiler.register_profiler_start_callback", | ||
| 127 | + return_value=dynamic_result, | ||
| 128 | + ) as start_callback, | ||
| 129 | + patch( | ||
| 130 | + "ms_service_profiler.patcher.vllm.mstx_profiler.register_profiler_stop_callback", | ||
| 131 | + return_value=dynamic_result, | ||
| 132 | + ) as stop_callback, | ||
| 133 | + patch("ms_service_profiler.patcher.vllm.register_torch_profiler") as torch_profiler, | ||
| 134 | + ): | ||
| 135 | + register_service_profiler() | ||
| 136 | + | ||
| 137 | + start_callback.assert_not_called() | ||
| 138 | + stop_callback.assert_not_called() | ||
| 139 | + torch_profiler.assert_not_called() | ||
| 140 | + | ||
| 141 | + | ||
| 142 | +def test_trace_only_initialize_skips_metrics_and_profiling_handlers(): | ||
| 143 | + profiler = VLLMProfiler() | ||
| 144 | + with ( | ||
| 145 | + patch.dict("os.environ", {"MS_TRACE_ENABLE": "1"}, clear=True), | ||
| 146 | + patch("ms_service_profiler.patcher.vllm.service_patcher.check_profiling_enabled", return_value=False), | ||
| 147 | + patch("ms_service_profiler.patcher.vllm.service_patcher.setup_vllm_metrics") as setup_metrics, | ||
| 148 | + patch.object(profiler, "_import_handlers") as import_handlers, | ||
| 149 | + patch.object(profiler, "enable_hooks") as enable_hooks, | ||
| 150 | + patch("ms_service_profiler.patcher.vllm.service_patcher.install_symbol_watcher", return_value=True), | ||
| 151 | + ): | ||
| 152 | + assert profiler.initialize() is True | ||
| 153 | + | ||
| 154 | + assert profiler._tracing_requested is True | ||
| 155 | + setup_metrics.assert_not_called() | ||
| 156 | + import_handlers.assert_not_called() | ||
| 157 | + enable_hooks.assert_called_once() | ||
| 158 | + | ||
| 159 | + | ||
| 160 | +def test_profiling_stop_keeps_trace_only_hooks_enabled(): | ||
| 161 | + profiler = VLLMProfiler() | ||
| 162 | + profiler._profiling_requested = True | ||
| 163 | + profiler._profiling_active = True | ||
| 164 | + profiler._tracing_requested = True | ||
| 165 | + profiler._controller = MagicMock() | ||
| 166 | + trace_config = MagicMock() | ||
| 167 | + with patch.object(profiler, "_load_config", return_value=(trace_config, None)): | ||
| 168 | + _, on_stop = profiler.get_callbacks() | ||
| 169 | + on_stop() | ||
| 170 | + | ||
| 171 | + assert profiler._profiling_active is False | ||
| 172 | + profiler._controller.enable.assert_called_once_with( | ||
| 173 | + profiling_handlers=trace_config, | ||
| 174 | + metrics_handlers=None, | ||
| 175 | + ) | ||
| 176 | + profiler._controller.disable.assert_not_called() | ||
| 177 | + | ||
| 178 | + | ||
| 179 | +def test_profiling_only_keeps_original_controller_callbacks(): | ||
| 180 | + profiler = VLLMProfiler() | ||
| 181 | + profiler._tracing_requested = False | ||
| 182 | + profiler._controller = MagicMock() | ||
| 183 | + expected = (MagicMock(), MagicMock()) | ||
| 184 | + profiler._controller.get_callbacks.return_value = expected | ||
| 185 | + | ||
| 186 | + assert profiler.get_callbacks() == expected | ||
| 187 | + profiler._controller.get_callbacks.assert_called_once_with(profiler._load_config) | ||
| 188 | + | ||
| 189 | + | ||
| 190 | +def test_combined_config_uses_one_hooker_with_profiling_and_trace(): | ||
| 191 | + profiling_handler = MagicMock() | ||
| 192 | + raw = [{"symbol": "vllm.module:Engine.run", "handler": "allowed:handler", "trace": True}] | ||
| 193 | + with ( | ||
| 194 | + patch("ms_service_profiler.patcher.core.config_loader.load_yaml_config", return_value=raw), | ||
| 195 | + patch("ms_service_profiler.patcher.core.config_loader._resolve_handler_func", return_value=profiling_handler), | ||
| 196 | + ): | ||
| 197 | + config = ConfigLoader("unused.yaml").load_profiling(enable_profiling=True, enable_tracing=True) | ||
| 198 | + | ||
| 199 | + hookers = config.concrete[raw[0]["symbol"]] | ||
| 200 | + assert len(hookers) == 1 | ||
| 201 | + assert hookers[0].wrap_hook_func is profiling_handler | ||
| 202 | + assert hookers[0].context_hook_funcs == [] | ||
| 203 | + assert hookers[0].around_hook_factory is not None | ||
| 204 | + | ||
| 205 | + | ||
| 206 | +def test_duplicate_trace_for_one_symbol_keeps_one_effective_hook(): | ||
| 207 | + raw = [ | ||
| 208 | + {"symbol": "vllm.module:Engine.run", "trace": {"name": "first"}}, | ||
| 209 | + {"symbol": "vllm.module:Engine.run", "trace": {"name": "second"}}, | ||
| 210 | + ] | ||
| 211 | + with patch("ms_service_profiler.patcher.core.config_loader.load_yaml_config", return_value=raw): | ||
| 212 | + config = ConfigLoader("unused.yaml").load_profiling(enable_profiling=False, enable_tracing=True) | ||
| 213 | + | ||
| 214 | + assert len(config.concrete[raw[0]["symbol"]]) == 1 | ||
| 215 | + | ||
| 216 | + | ||
| 217 | +def test_runtime_registers_existing_context_and_links_child_span(): | ||
| 218 | + backend = FakeBackend() | ||
| 219 | + runtime = HookTraceRuntime(backend) | ||
| 220 | + | ||
| 221 | + assert runtime.start_request("request-1", {"traceparent": "valid"}) is True | ||
| 222 | + schedule_span = runtime.start_span( | ||
| 223 | + "vllm.scheduler.schedule", | ||
| 224 | + "Schedule", | ||
| 225 | + request_ids=["request-1", "request-without-local-state"], | ||
| 226 | + ) | ||
| 227 | + | ||
| 228 | + links = backend.calls[0][3]["links"] | ||
| 229 | + assert [request_id for _, request_id in links] == ["request-1"] | ||
| 230 | + assert runtime.finish_request("request-1") is True | ||
| 231 | + assert schedule_span.is_recording | ||
| 232 | + | ||
| 233 | + | ||
| 234 | +def test_runtime_limits_iterable_consumption_before_materializing_request_ids(): | ||
| 235 | + backend = FakeBackend() | ||
| 236 | + runtime = HookTraceRuntime(backend) | ||
| 237 | + start_consumed = [] | ||
| 238 | + link_consumed = [] | ||
| 239 | + | ||
| 240 | + def request_ids(consumed): | ||
| 241 | + for index in range(MAX_LINKS_PER_SPAN + 10): | ||
| 242 | + consumed.append(index) | ||
| 243 | + yield "request-{}".format(index) | ||
| 244 | + | ||
| 245 | + span = runtime.start_span("schedule", "Schedule", request_ids=request_ids(start_consumed)) | ||
| 246 | + runtime.request_links(request_ids(link_consumed)) | ||
| 247 | + | ||
| 248 | + assert len(start_consumed) == MAX_LINKS_PER_SPAN | ||
| 249 | + assert len(link_consumed) == MAX_LINKS_PER_SPAN | ||
| 250 | + assert len(span.attributes["request.ids"]) == MAX_LINKS_PER_SPAN | ||
| 251 | + | ||
| 252 | + | ||
| 253 | +def test_schedule_adapter_adds_request_links_and_batch_attributes(): | ||
| 254 | + runtime = MagicMock(enabled=True) | ||
| 255 | + span = FakeSpan() | ||
| 256 | + runtime.start_span.return_value = span | ||
| 257 | + scheduler_output = SimpleNamespace( | ||
| 258 | + num_scheduled_tokens={"request-1": 4, "request-2": 2}, | ||
| 259 | + total_num_scheduled_tokens=6, | ||
| 260 | + ) | ||
| 261 | + | ||
| 262 | + def original(): | ||
| 263 | + return scheduler_output | ||
| 264 | + | ||
| 265 | + with ( | ||
| 266 | + patch("ms_service_profiler.patcher.core.trace_hook.get_hook_trace_runtime", return_value=runtime), | ||
| 267 | + patch("ms_service_profiler.patcher.core.trace_hook.time.time_ns", side_effect=[100, 200]), | ||
| 268 | + ): | ||
| 269 | + assert _wrap(HookTraceSpec("schedule", "Schedule", adapter="schedule"), original)() is scheduler_output | ||
| 270 | + | ||
| 271 | + runtime.start_span.assert_called_once_with( | ||
| 272 | + "schedule", "Schedule", "INTERNAL", request_ids=["request-1", "request-2"], start_time_ns=100 | ||
| 273 | + ) | ||
| 274 | + assert span.attributes == {"batch.request_count": 2, "batch.scheduled_tokens": 6} | ||
| 275 | + assert span.end_calls == [(True, "", 200)] | ||
| 276 | + | ||
| 277 | + | ||
| 278 | +def test_request_context_adapter_registers_w3c_headers_without_creating_span(): | ||
| 279 | + runtime = MagicMock(enabled=True) | ||
| 280 | + request = SimpleNamespace(request_id="request-1", trace_headers={"traceparent": "valid"}) | ||
| 281 | + | ||
| 282 | + def original(_, req): | ||
| 283 | + return req.request_id | ||
| 284 | + | ||
| 285 | + with patch("ms_service_profiler.patcher.core.trace_hook.get_hook_trace_runtime", return_value=runtime): | ||
| 286 | + result = _wrap(HookTraceSpec("add_request", "Request", adapter="request_context"), original)(object(), request) | ||
| 287 | + | ||
| 288 | + assert result == "request-1" | ||
| 289 | + runtime.register_request_context.assert_called_once_with("request-1", {"traceparent": "valid"}) | ||
| 290 | + runtime.start_span.assert_not_called() | ||
| 291 | + | ||
| 292 | + | ||
| 293 | +def test_output_adapter_finishes_only_completed_requests(): | ||
| 294 | + runtime = MagicMock(enabled=True) | ||
| 295 | + span = FakeSpan() | ||
| 296 | + runtime.start_span.return_value = span | ||
| 297 | + outputs = [ | ||
| 298 | + SimpleNamespace(request_id="running", finish_reason=None), | ||
| 299 | + SimpleNamespace(request_id="finished", finish_reason="stop"), | ||
| 300 | + ] | ||
| 301 | + | ||
| 302 | + def original(_, result): | ||
| 303 | + return result | ||
| 304 | + | ||
| 305 | + with patch("ms_service_profiler.patcher.core.trace_hook.get_hook_trace_runtime", return_value=runtime): | ||
| 306 | + assert _wrap(HookTraceSpec("output", "Request", adapter="output"), original)(object(), outputs) is outputs | ||
| 307 | + | ||
| 308 | + runtime.finish_request.assert_called_once_with("finished", True) | ||
| 309 | + assert span.end_calls == [(True, "", None)] | ||
| 310 | + | ||
| 311 | + | ||
| 312 | +def test_async_around_hook_preserves_awaitable_result(): | ||
| 313 | + runtime = MagicMock(enabled=True) | ||
| 314 | + span = FakeSpan() | ||
| 315 | + runtime.start_span.return_value = span | ||
| 316 | + | ||
| 317 | + async def original(value): | ||
| 318 | + return value + 1 | ||
| 319 | + | ||
| 320 | + with patch("ms_service_profiler.patcher.core.trace_hook.get_hook_trace_runtime", return_value=runtime): | ||
| 321 | + result = asyncio.run(_wrap(HookTraceSpec("execute", "Execute"), original)(1)) | ||
| 322 | + | ||
| 323 | + assert result == 2 | ||
| 324 | + assert span.end_calls == [(True, "", None)] | ||
| @@ -100,6 +100,34 @@ def test_perfetto_processor_registration_failure_does_not_disable_jaeger_provide | |||
| 100 | assert backend._get_provider() is provider | 100 | assert backend._get_provider() is provider |
| 101 | 101 | ||
| 102 | processor.shutdown.assert_called_once() | 102 | processor.shutdown.assert_called_once() |
| 103 | + assert id(provider) not in backend._perfetto_providers | ||
| 104 | + | ||
| 105 | + | ||
| 106 | +def test_perfetto_processor_registration_retries_after_backoff(): | ||
| 107 | + backend = OpenTelemetryHookBackend() | ||
| 108 | + provider = MagicMock() | ||
| 109 | + provider.add_span_processor.side_effect = [RuntimeError("registration failed"), None] | ||
| 110 | + first_processor = MagicMock() | ||
| 111 | + second_processor = MagicMock() | ||
| 112 | + | ||
| 113 | + with ( | ||
| 114 | + patch.dict("os.environ", {"MS_TRACE_ENABLE": "1"}, clear=True), | ||
| 115 | + patch.object(backend, "_active_global_provider", return_value=provider), | ||
| 116 | + patch("ms_service_profiler.tracer.otel_hook.PerfettoSocketSender.is_available", return_value=True), | ||
| 117 | + patch( | ||
| 118 | + "ms_service_profiler.tracer.otel_hook.PerfettoSpanProcessor", | ||
| 119 | + side_effect=[first_processor, second_processor], | ||
| 120 | + ), | ||
| 121 | + patch("ms_service_profiler.tracer.otel_hook.time.monotonic", side_effect=[100.0, 101.0, 106.0]), | ||
| 122 | + ): | ||
| 123 | + assert backend._get_provider() is provider | ||
| 124 | + assert backend._get_provider() is provider | ||
| 125 | + assert backend._get_provider() is provider | ||
| 126 | + | ||
| 127 | + assert provider.add_span_processor.call_count == 2 | ||
| 128 | + first_processor.shutdown.assert_called_once() | ||
| 129 | + second_processor.shutdown.assert_not_called() | ||
| 130 | + assert id(provider) in backend._perfetto_providers | ||
| 103 | 131 | ||
| 104 | 132 | ||
| 105 | def test_backend_missing_otel_dependency_is_fail_open(): | 133 | def test_backend_missing_otel_dependency_is_fail_open(): |
| @@ -234,6 +234,63 @@ def test_vllmhookerbase_do_hook_given_hook_points_when_applying_then_functions_r | |||
| 234 | hooker.hooks[0].recover() | 234 | hooker.hooks[0].recover() |
| 235 | 235 | ||
| 236 | 236 | ||
| 237 | +def test_vllmhookerbase_do_hook_given_around_hook_when_calling_then_wraps_target(cleanup_hook_registry): | ||
| 238 | + """The optional tracing layer surrounds the existing callable.""" | ||
| 239 | + events = [] | ||
| 240 | + | ||
| 241 | + def around_factory(next_func, original_func): | ||
| 242 | + def wrapper(*args, **kwargs): | ||
| 243 | + events.append(("enter", args)) | ||
| 244 | + result = next_func(*args, **kwargs) | ||
| 245 | + events.append(("exit", result)) | ||
| 246 | + return result | ||
| 247 | + | ||
| 248 | + return wrapper | ||
| 249 | + | ||
| 250 | + class AroundHooker(VLLMHookerBase): | ||
| 251 | + def init(self): | ||
| 252 | + self.around_hook_factory = around_factory | ||
| 253 | + self.do_hook([sample_function], lambda ori_func: ori_func) | ||
| 254 | + | ||
| 255 | + hooker = AroundHooker() | ||
| 256 | + hooker.init() | ||
| 257 | + try: | ||
| 258 | + assert sample_function() == "original function" | ||
| 259 | + assert events == [("enter", ()), ("exit", "original function")] | ||
| 260 | + finally: | ||
| 261 | + hooker.hooks[0].recover() | ||
| 262 | + | ||
| 263 | + | ||
| 264 | +def test_vllmhookerbase_around_hook_does_not_retry_business_exception(cleanup_hook_registry): | ||
| 265 | + calls = [] | ||
| 266 | + expected = RuntimeError("business failed") | ||
| 267 | + | ||
| 268 | + def business_function(): | ||
| 269 | + calls.append("called") | ||
| 270 | + raise expected | ||
| 271 | + | ||
| 272 | + def around_factory(next_func, original_func): | ||
| 273 | + def wrapper(*args, **kwargs): | ||
| 274 | + return next_func(*args, **kwargs) | ||
| 275 | + | ||
| 276 | + return wrapper | ||
| 277 | + | ||
| 278 | + class ExceptionHooker(VLLMHookerBase): | ||
| 279 | + def init(self): | ||
| 280 | + return None | ||
| 281 | + | ||
| 282 | + hooker = ExceptionHooker() | ||
| 283 | + hooker.around_hook_factory = around_factory | ||
| 284 | + hooker.do_hook([business_function], lambda ori_func: ori_func) | ||
| 285 | + try: | ||
| 286 | + with pytest.raises(RuntimeError) as error: | ||
| 287 | + hooker.hooks[0].new_function() | ||
| 288 | + assert error.value is expected | ||
| 289 | + assert calls == ["called"] | ||
| 290 | + finally: | ||
| 291 | + hooker.hooks[0].recover() | ||
| 292 | + | ||
| 293 | + | ||
| 237 | # Test cases for vllm_hook decorator | 294 | # Test cases for vllm_hook decorator |
| 238 | 295 | ||
| 239 | hook_points=[("patcher.core.module_hook", "sample_function")], | 296 | hook_points=[("patcher.core.module_hook", "sample_function")], |