Coverage for  / home / jenkins / .local / lib / python3.10 / site-packages / hyper_multicore / diagnostics / profiler / api.py: 90%

245 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2026-11-15 05:03 +0800

1# Copyright 2026 Huawei Technologies Co., Ltd 

2# 

3# Licensed under the Apache License, Version 2.0 (the "License"); 

4# you may not use this file except in compliance with the License. 

5# You may obtain a copy of the License at 

6# 

7# http://www.apache.org/licenses/LICENSE-2.0 

8# 

9# Unless required by applicable law or agreed to in writing, software 

10# distributed under the License is distributed on an "AS IS" BASIS, 

11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 

12# See the License for the specific language governing permissions and 

13# limitations under the License. 

14# ============================================================================ 

15"""Standalone Torch MegaKernel profiling API and capture lifecycle.""" 

16 

17from __future__ import annotations 

18 

19__all__ = ["TorchMegaKernelProfiler", "mega_kernel_profile"] 

20 

21from collections.abc import Callable 

22from dataclasses import dataclass, field 

23import json 

24from pathlib import Path 

25from typing import Any 

26 

27import torch 

28 

29from hyper_multicore.diagnostics.common.session import ( 

30 _activate_session, 

31 _deactivate_session, 

32) 

33from hyper_multicore.diagnostics.profiler.cycle_trace import _PreparedProfileRuntime 

34from hyper_multicore.diagnostics.common.call import DiagnosticCall, LaunchArguments, LaunchContext 

35from hyper_multicore.diagnostics.common.schedule import DiagnosticAction 

36 

37 

38@dataclass 

39class _RawInvocation: 

40 """One completed invocation copied from Device to Host.""" 

41 

42 runtime: _PreparedProfileRuntime 

43 data: bytes 

44 direction: str 

45 step: int 

46 invocation_id: int 

47 

48 

49@dataclass 

50class _TraceAccumulator: 

51 """Aggregate parsed invocations into one capture window.""" 

52 

53 global_anchor: int 

54 trace_events: list[dict[str, Any]] = field(default_factory=list) 

55 metadata_keys: set[tuple[Any, Any, Any]] = field(default_factory=set) 

56 dropped_count: int = 0 

57 record_count: int = 0 

58 invocation_metadata: list[dict[str, Any]] = field(default_factory=list) 

59 

60 def append(self, invocation: _RawInvocation, trace: dict[str, Any]) -> None: 

61 """Append one parsed invocation and shift it to the window anchor. 

62 

63 Args: 

64 invocation: Invocation identity and captured bytes. 

65 trace: Parsed standalone trace for the invocation. 

66 """ 

67 metadata = trace["megaKernelCycleTrace"] 

68 shift_us = (metadata["anchorCycle"] - self.global_anchor) / metadata["cycleFrequencyMHz"] 

69 self.dropped_count += metadata["droppedRecordCount"] 

70 self.record_count += metadata["recordCount"] 

71 invocation_metadata = { 

72 "invocationId": invocation.invocation_id, 

73 "step": invocation.step, 

74 "direction": invocation.direction, 

75 "kernelName": invocation.runtime.kernel_name, 

76 "rank": metadata.get("rank", invocation.runtime.rank), 

77 "deviceId": metadata.get("deviceId", invocation.runtime.device_id), 

78 "anchorCycle": metadata["anchorCycle"], 

79 "recordCount": metadata["recordCount"], 

80 "droppedRecordCount": metadata["droppedRecordCount"], 

81 } 

82 for key in ('workerCapacities', 'profileBufferBytes'): 

83 if key in metadata: 

84 invocation_metadata[key] = metadata[key] 

85 self.invocation_metadata.append(invocation_metadata) 

86 self._append_events(trace["traceEvents"], invocation, shift_us) 

87 

88 def _append_events( 

89 self, 

90 events: list[dict[str, Any]], 

91 invocation: _RawInvocation, 

92 shift_us: float, 

93 ) -> None: 

94 """Deduplicate metadata events and shift duration events.""" 

95 for event in events: 

96 if event["ph"] == "M": 

97 key = (event["name"], event["pid"], event["tid"]) 

98 if key not in self.metadata_keys: 

99 self.metadata_keys.add(key) 

100 self.trace_events.append(event) 

101 continue 

102 copied_event = dict(event) 

103 copied_event["ts"] += shift_us 

104 copied_event["args"] = { 

105 **event.get("args", {}), 

106 "invocation_id": invocation.invocation_id, 

107 "step": invocation.step, 

108 "direction": invocation.direction, 

109 } 

110 self.trace_events.append(copied_event) 

111 

112 

113class _ProfileCall(DiagnosticCall): 

114 """Internal launch arguments and profiling-buffer ownership for one kernel call.""" 

115 

116 def __init__( 

117 self, 

118 *, 

119 runtime_config: Any, 

120 event_counters: Any, 

121 diagnostic_buffer: Any, 

122 clear_event_counters: Any | None = None, 

123 profiler: TorchMegaKernelProfiler | None = None, 

124 runtime: _PreparedProfileRuntime | None = None, 

125 profile_buffer_key: tuple[int, int] | None = None, 

126 direction: str = "", 

127 step: int = 0, 

128 invocation_id: int = 0, 

129 ) -> None: 

130 """Store launch arguments and optional active-session bookkeeping.""" 

131 super().__init__(LaunchArguments( 

132 runtime_config, event_counters, diagnostic_buffer, 

133 event_counters if clear_event_counters is None else clear_event_counters, 

134 )) 

135 self._profiler = profiler 

136 self.runtime = runtime 

137 self.profile_buffer_key = profile_buffer_key 

138 self.direction = direction 

139 self.step = step 

140 self.invocation_id = invocation_id 

141 self.finished = False 

142 

143 def complete(self) -> None: 

144 """Mark the direct-write profile buffer as complete after a successful launch.""" 

145 if self.finished: 

146 return 

147 if self._profiler is not None: 

148 self._profiler.complete_call(self) 

149 self.finished = True 

150 

151 def cancel(self) -> None: 

152 """Discard an unlaunched call and release its private profile buffer.""" 

153 if self.finished: 

154 return 

155 if self._profiler is not None: 

156 self._profiler.cancel_call(self) 

157 self.finished = True 

158 

159 

160class TorchMegaKernelProfiler: 

161 """Standalone schedule-driven MegaKernel internal profiler for Torch.""" 

162 

163 def __init__( 

164 self, 

165 *, 

166 schedule: Callable[[int], DiagnosticAction] | None, 

167 on_trace_ready: Callable[[Any], None] | None, 

168 detailed_task_names: bool, 

169 max_pending_calls: int, 

170 ) -> None: 

171 # pylint: disable=redefined-outer-name 

172 """Validate options and initialize the schedule-driven capture state.""" 

173 if schedule is not None and not callable(schedule): 

174 raise TypeError("schedule must be callable or None") 

175 if on_trace_ready is not None and not callable(on_trace_ready): 

176 raise TypeError("on_trace_ready must be callable or None") 

177 if not isinstance(detailed_task_names, bool): 

178 raise TypeError("detailed_task_names must be bool") 

179 if not isinstance(max_pending_calls, int) or isinstance(max_pending_calls, bool) or max_pending_calls <= 0: 

180 raise ValueError("max_pending_calls must be a positive integer, " f"got {max_pending_calls!r}") 

181 self._schedule = schedule 

182 self._on_trace_ready = on_trace_ready 

183 self._detailed_task_names = detailed_task_names 

184 self._max_pending_calls = max_pending_calls 

185 self._step = 0 

186 self._action = self._action_for_step(0) 

187 self._entered = False 

188 self._next_invocation_id = 0 

189 self._pending: list[_ProfileCall] = [] 

190 self._profile_buffer_pool: dict[tuple[int, int], list[Any]] = {} 

191 self._profile_runtimes: dict[Any, _PreparedProfileRuntime] = {} 

192 self._raw_invocations: list[_RawInvocation] = [] 

193 self._last_trace: dict[str, Any] | None = None 

194 self._window_index = 0 

195 

196 @property 

197 def step_num(self) -> int: 

198 """Return the zero-based step currently being recorded.""" 

199 return self._step 

200 

201 @property 

202 def current_action(self) -> DiagnosticAction: 

203 """Return the current schedule action.""" 

204 return self._action 

205 

206 def __enter__(self) -> TorchMegaKernelProfiler: 

207 """Activate this profiler as the process-local MegaKernel session.""" 

208 _activate_session("profiler", self) 

209 self._entered = True 

210 return self 

211 

212 def __exit__(self, exc_type: Any, exc_value: Any, traceback: Any) -> None: 

213 """Finalize successful captures or discard pending data on failure.""" 

214 try: 

215 if exc_type is None: 

216 self._finish_context() 

217 else: 

218 self._drain(keep=False) 

219 finally: 

220 self._raw_invocations.clear() 

221 for call in self._pending: 

222 self._release_profile_buffer(call, cache=False) 

223 self._pending.clear() 

224 self._release_profile_buffer_pool() 

225 _deactivate_session(self) 

226 self._profile_runtimes.clear() 

227 self._entered = False 

228 

229 def step(self) -> None: 

230 """Finish the current schedule step and advance to the next one.""" 

231 if not self._entered: 

232 raise RuntimeError("profiler.step() must be called inside its context") 

233 next_action = self._action_for_step(self._step + 1) 

234 if self._action is DiagnosticAction.WARMUP: 

235 self._drain(keep=False) 

236 elif self._action is DiagnosticAction.RECORD_AND_SAVE: 

237 self._drain(keep=True) 

238 self._finalize_window() 

239 elif self._action is DiagnosticAction.RECORD and next_action not in ( 

240 DiagnosticAction.RECORD, 

241 DiagnosticAction.RECORD_AND_SAVE, 

242 ): 

243 self._drain(keep=True) 

244 self._finalize_window() 

245 self._step += 1 

246 self._action = next_action 

247 

248 def export_chrome_trace(self, path: str | Path) -> dict[str, Any]: 

249 """Write the most recently completed internal capture window. 

250 

251 Args: 

252 path: Destination path for the Chrome Trace JSON object. 

253 """ 

254 if self._last_trace is None: 

255 raise RuntimeError( 

256 "no completed MegaKernel capture window is available; " 

257 "call step() at the window boundary or leave the context first" 

258 ) 

259 output_path = Path(path) 

260 output_path.parent.mkdir(parents=True, exist_ok=True) 

261 output_path.write_text( 

262 json.dumps(self._last_trace, ensure_ascii=False), 

263 encoding="utf-8", 

264 ) 

265 return self._last_trace 

266 

267 def _action_for_step(self, step: int) -> DiagnosticAction: 

268 if self._schedule is None: 

269 return DiagnosticAction.RECORD 

270 action = self._schedule(step) 

271 if not isinstance(action, DiagnosticAction): 

272 raise TypeError("MegaKernel schedule must return DiagnosticAction, " f"got {type(action).__name__}") 

273 return action 

274 

275 def is_capture_enabled(self) -> bool: 

276 """Return whether the current schedule action records Device cycles.""" 

277 return self._action is not DiagnosticAction.NONE 

278 

279 def prepare_call(self, context: LaunchContext) -> DiagnosticCall: 

280 """Select profiling independently, without constructing any dump state. 

281 

282 Args: 

283 context: Selected invocation or operand context supplied by the diagnostic framework. 

284 """ 

285 if not self.is_capture_enabled(): 

286 return DiagnosticCall(context.ordinary_arguments()) 

287 runtime = self._profile_runtimes.get(context.runtime) 

288 if runtime is None: 

289 runtime = _PreparedProfileRuntime(context.runtime) 

290 self._profile_runtimes[context.runtime] = runtime 

291 return self.acquire_call(runtime, context.fallback_event_counters, context.direction) 

292 

293 def acquire_call( 

294 self, 

295 runtime: _PreparedProfileRuntime, 

296 event_counters: Any, 

297 direction: str, 

298 ) -> _ProfileCall: 

299 """Reserve a profiling buffer and register one pending kernel call. 

300 

301 Args: 

302 runtime: Prepared runtime configuration and profiling metadata. 

303 event_counters: Device event-counter storage. 

304 direction: Kernel direction label included in trace metadata. 

305 """ 

306 if len(self._pending) >= self._max_pending_calls: 

307 self._drain(keep=self._action is not DiagnosticAction.WARMUP) 

308 invocation_id = self._next_invocation_id 

309 profile_buffer, profile_buffer_key = self._take_profile_buffer(runtime) 

310 self._next_invocation_id += 1 

311 call = _ProfileCall( 

312 runtime_config=runtime.profile_tensor, 

313 event_counters=event_counters, 

314 clear_event_counters=event_counters, 

315 profiler=self, 

316 diagnostic_buffer=profile_buffer, 

317 runtime=runtime, 

318 direction=direction, 

319 step=self._step, 

320 profile_buffer_key=profile_buffer_key, 

321 invocation_id=invocation_id, 

322 ) 

323 self._pending.append(call) 

324 return call 

325 

326 def complete_call(self, call: _ProfileCall) -> None: 

327 """Validate that a successfully launched call belongs to this session. 

328 

329 Args: 

330 call: Pending kernel call that completed launch. 

331 """ 

332 if call not in self._pending: 

333 raise RuntimeError("completed MegaKernel profiling call is not pending") 

334 

335 def _take_profile_buffer( 

336 self, 

337 runtime: _PreparedProfileRuntime, 

338 ) -> tuple[Any, tuple[int, int]]: 

339 """Take one ordinary NPU buffer that is private to the next invocation.""" 

340 profile_buffer_key = (runtime.device_id, runtime.buffer_size) 

341 available_buffers = self._profile_buffer_pool.get(profile_buffer_key) 

342 if available_buffers: 

343 profile_buffer = available_buffers.pop() 

344 else: 

345 profile_buffer = torch.empty( 

346 (runtime.buffer_size,), 

347 dtype=torch.uint8, 

348 device=runtime.device, 

349 ) 

350 profile_buffer.zero_() 

351 return profile_buffer, profile_buffer_key 

352 

353 def cancel_call(self, call: _ProfileCall) -> None: 

354 """Cancel a pending call and release its profiling buffer. 

355 

356 Args: 

357 call: Pending kernel call to cancel. 

358 """ 

359 if call in self._pending: 

360 self._pending.remove(call) 

361 self._release_profile_buffer(call, cache=False) 

362 

363 def _release_profile_buffer( 

364 self, 

365 call: _ProfileCall, 

366 *, 

367 cache: bool, 

368 ) -> None: 

369 """Return a completed buffer to the session pool or release its storage.""" 

370 profile_buffer = call.diagnostic_buffer 

371 profile_buffer_key = call.profile_buffer_key 

372 if profile_buffer is None or profile_buffer_key is None: 

373 return 

374 if cache: 

375 self._profile_buffer_pool.setdefault(profile_buffer_key, []).append(profile_buffer) 

376 else: 

377 profile_buffer.untyped_storage().resize_(0) 

378 call.arguments.diagnostic_buffer = None 

379 call.profile_buffer_key = None 

380 

381 def _release_profile_buffer_pool(self) -> None: 

382 """Release ordinary NPU buffers retained for reuse by this session.""" 

383 for available_buffers in self._profile_buffer_pool.values(): 

384 for profile_buffer in available_buffers: 

385 profile_buffer.untyped_storage().resize_(0) 

386 self._profile_buffer_pool.clear() 

387 

388 def _drain(self, *, keep: bool) -> None: 

389 """Synchronize pending calls, optionally copy their records to Host.""" 

390 if not self._pending: 

391 if not keep: 

392 self._raw_invocations.clear() 

393 return 

394 unfinished = [call for call in self._pending if not call.finished] 

395 if unfinished: 

396 raise RuntimeError("cannot copy MegaKernel profiling buffers before launches complete") 

397 

398 device_ids = {call.runtime.device_id for call in self._pending if call.runtime is not None} 

399 for device_id in device_ids: 

400 torch.npu.synchronize(device_id) 

401 

402 for call in self._pending: 

403 runtime = call.runtime 

404 profile_buffer = call.diagnostic_buffer 

405 if keep and runtime is not None and profile_buffer is not None: 

406 host_data = profile_buffer.cpu().numpy().tobytes() 

407 self._raw_invocations.append( 

408 _RawInvocation( 

409 runtime=runtime, 

410 data=host_data, 

411 direction=call.direction, 

412 step=call.step, 

413 invocation_id=call.invocation_id, 

414 ) 

415 ) 

416 self._release_profile_buffer(call, cache=True) 

417 self._pending.clear() 

418 if not keep: 

419 self._raw_invocations.clear() 

420 

421 def _finish_context(self) -> None: 

422 if self._action is DiagnosticAction.WARMUP: 

423 self._drain(keep=False) 

424 return 

425 if self._pending or self._raw_invocations: 

426 self._drain(keep=True) 

427 if self._raw_invocations: 

428 self._finalize_window() 

429 

430 def _finalize_window(self) -> None: 

431 try: 

432 self._last_trace = self._build_trace(self._raw_invocations) 

433 finally: 

434 self._raw_invocations.clear() 

435 self._window_index += 1 

436 if self._on_trace_ready is not None: 

437 self._on_trace_ready(self) 

438 

439 def _build_trace(self, invocations: list[_RawInvocation]) -> dict[str, Any]: 

440 """Combine retained invocation data into one standalone Chrome Trace.""" 

441 if not invocations: 

442 return { 

443 "traceEvents": [], 

444 "megaKernelCycleTrace": { 

445 "schemaVersion": 1, 

446 "windowIndex": self._window_index, 

447 "invocationCount": 0, 

448 "recordCount": 0, 

449 "droppedRecordCount": 0, 

450 "detailedTaskNames": self._detailed_task_names, 

451 "invocations": [], 

452 "warnings": [], 

453 }, 

454 } 

455 

456 parsed_invocations = [ 

457 ( 

458 invocation, 

459 invocation.runtime.parse( 

460 invocation.data, 

461 detailed_task_names=self._detailed_task_names, 

462 ), 

463 ) 

464 for invocation in invocations 

465 ] 

466 global_anchor = min(trace["megaKernelCycleTrace"]["anchorCycle"] for _, trace in parsed_invocations) 

467 accumulator = _TraceAccumulator(global_anchor) 

468 for invocation, trace in parsed_invocations: 

469 accumulator.append(invocation, trace) 

470 

471 metadata_events = [event for event in accumulator.trace_events if event["ph"] == "M"] 

472 duration_events = sorted( 

473 (event for event in accumulator.trace_events if event["ph"] != "M"), 

474 key=lambda event: (event["ts"], event["pid"], event["tid"]), 

475 ) 

476 return { 

477 "traceEvents": metadata_events + duration_events, 

478 "megaKernelCycleTrace": { 

479 "schemaVersion": 1, 

480 "windowIndex": self._window_index, 

481 "anchorCycle": global_anchor, 

482 "invocationCount": len(invocations), 

483 "recordCount": accumulator.record_count, 

484 "droppedRecordCount": accumulator.dropped_count, 

485 "detailedTaskNames": self._detailed_task_names, 

486 "invocations": accumulator.invocation_metadata, 

487 "warnings": ( 

488 [] 

489 if accumulator.dropped_count == 0 

490 else [f"Device dropped {accumulator.dropped_count} cycle trace records"] 

491 ), 

492 }, 

493 } 

494 

495 

496# The argument name intentionally mirrors torch.profiler.profile. 

497def mega_kernel_profile( 

498 *, 

499 schedule: Callable[[int], DiagnosticAction] | None = None, 

500 on_trace_ready: Callable[[Any], None] | None = None, 

501 detailed_task_names: bool = False, 

502 max_pending_calls: int = 16, 

503) -> TorchMegaKernelProfiler: 

504 # pylint: disable=redefined-outer-name 

505 """Create the standalone MegaKernel profiler. 

506 

507 The returned context manager records only MegaKernel-internal events. It 

508 does not start an external framework profiler, and its export operation 

509 does not merge with an external trace. 

510 

511 Args: 

512 schedule: Optional step schedule. Without one, all calls are retained. 

513 on_trace_ready: Optional callback invoked after each completed window. 

514 detailed_task_names: Include owner and task numbers in event names. 

515 max_pending_calls: Maximum in-flight invocation buffers before an 

516 intermediate D2H drain and reuse. 

517 

518 Returns: 

519 Standalone MegaKernel profiler context manager. 

520 """ 

521 return TorchMegaKernelProfiler( 

522 schedule=schedule, 

523 on_trace_ready=on_trace_ready, 

524 detailed_task_names=detailed_task_names, 

525 max_pending_calls=max_pending_calls, 

526 )