wafer-cli 0.2.8__py3-none-any.whl → 0.2.10__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
wafer/nsys_profile.py ADDED
@@ -0,0 +1,511 @@
1
+ """NSYS Profile - Execute NSYS profiling on local, remote, or workspace targets.
2
+
3
+ This module provides the implementation for the `wafer nvidia nsys profile` command.
4
+ Supports local profiling (when nsys is installed), workspace execution, and direct SSH.
5
+
6
+ Profiling requires an NVIDIA GPU. Analysis can be done locally or remotely.
7
+ """
8
+
9
+ import json
10
+ import os
11
+ import shlex
12
+ import subprocess
13
+ import sys
14
+ from dataclasses import dataclass
15
+ from pathlib import Path
16
+
17
+ from .nsys_analyze import (
18
+ NSYSAnalysisResult,
19
+ _find_nsys,
20
+ _get_install_command,
21
+ _get_platform,
22
+ _parse_target,
23
+ is_macos,
24
+ )
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class NSYSProfileResult:
29
+ """Result of NSYS profiling execution."""
30
+
31
+ success: bool
32
+ output_path: str | None = None
33
+ stdout: str | None = None
34
+ stderr: str | None = None
35
+ error: str | None = None
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class NSYSProfileOptions:
40
+ """Options for NSYS profiling."""
41
+
42
+ command: str
43
+ output: str = "profile"
44
+ trace: list[str] | None = None # cuda, nvtx, osrt, cudnn, cublas
45
+ duration: int | None = None # Max duration in seconds
46
+ extra_args: str | None = None
47
+ working_dir: str | None = None
48
+
49
+
50
+ def _build_nsys_command(
51
+ nsys_path: str,
52
+ options: NSYSProfileOptions,
53
+ ) -> list[str]:
54
+ """Build nsys profile command from options.
55
+
56
+ Args:
57
+ nsys_path: Path to nsys executable
58
+ options: Profiling options
59
+
60
+ Returns:
61
+ Command as list of arguments
62
+ """
63
+ cmd = [nsys_path, "profile"]
64
+
65
+ # Output file (without extension - nsys adds .nsys-rep)
66
+ output_name = options.output
67
+ if output_name.endswith(".nsys-rep"):
68
+ output_name = output_name[:-9]
69
+ cmd.extend(["-o", output_name])
70
+
71
+ # Trace options
72
+ if options.trace:
73
+ cmd.extend(["-t", ",".join(options.trace)])
74
+ else:
75
+ cmd.extend(["-t", "cuda"]) # Default to CUDA tracing
76
+
77
+ # Duration limit
78
+ if options.duration:
79
+ cmd.extend(["--duration", str(options.duration)])
80
+
81
+ # Force overwrite
82
+ cmd.append("--force-overwrite=true")
83
+
84
+ # Extra args
85
+ if options.extra_args:
86
+ cmd.extend(shlex.split(options.extra_args))
87
+
88
+ # Command to profile
89
+ cmd.extend(shlex.split(options.command))
90
+
91
+ return cmd
92
+
93
+
94
+ def profile_local(
95
+ options: NSYSProfileOptions,
96
+ verbose: bool = False,
97
+ ) -> NSYSProfileResult:
98
+ """Execute NSYS profiling locally.
99
+
100
+ Args:
101
+ options: Profiling options
102
+ verbose: If True, print progress messages
103
+
104
+ Returns:
105
+ NSYSProfileResult with success status and output path
106
+
107
+ Raises:
108
+ FileNotFoundError: If nsys not installed
109
+ RuntimeError: If profiling fails
110
+ """
111
+ # Find nsys
112
+ nsys_path = _find_nsys()
113
+ if nsys_path is None:
114
+ if is_macos():
115
+ raise FileNotFoundError(
116
+ "NSYS CLI is not available on macOS. "
117
+ "Use --target to profile on a remote GPU server or workspace."
118
+ )
119
+ raise FileNotFoundError(
120
+ f"NSYS not installed. Install with: {_get_install_command()}"
121
+ )
122
+
123
+ # Build command
124
+ cmd = _build_nsys_command(nsys_path, options)
125
+
126
+ if verbose:
127
+ print(f"[nsys] Running: {' '.join(cmd)}", file=sys.stderr)
128
+
129
+ # Execute
130
+ try:
131
+ cwd = options.working_dir or os.getcwd()
132
+ result = subprocess.run(
133
+ cmd,
134
+ capture_output=True,
135
+ text=True,
136
+ cwd=cwd,
137
+ timeout=options.duration + 60 if options.duration else 660,
138
+ )
139
+
140
+ # Check for output file
141
+ output_name = options.output
142
+ if not output_name.endswith(".nsys-rep"):
143
+ output_name = f"{output_name}.nsys-rep"
144
+
145
+ output_path = Path(cwd) / output_name
146
+
147
+ if result.returncode != 0:
148
+ return NSYSProfileResult(
149
+ success=False,
150
+ stdout=result.stdout,
151
+ stderr=result.stderr,
152
+ error=f"nsys profile failed with exit code {result.returncode}",
153
+ )
154
+
155
+ if not output_path.exists():
156
+ return NSYSProfileResult(
157
+ success=False,
158
+ stdout=result.stdout,
159
+ stderr=result.stderr,
160
+ error=f"Output file not created: {output_path}",
161
+ )
162
+
163
+ return NSYSProfileResult(
164
+ success=True,
165
+ output_path=str(output_path),
166
+ stdout=result.stdout,
167
+ stderr=result.stderr,
168
+ )
169
+
170
+ except subprocess.TimeoutExpired:
171
+ return NSYSProfileResult(
172
+ success=False,
173
+ error=f"Profiling timed out after {options.duration or 600} seconds",
174
+ )
175
+ except OSError as e:
176
+ return NSYSProfileResult(
177
+ success=False,
178
+ error=f"Failed to execute nsys: {e}",
179
+ )
180
+
181
+
182
+ def profile_workspace(
183
+ workspace_id: str,
184
+ options: NSYSProfileOptions,
185
+ verbose: bool = False,
186
+ sync_artifacts: bool = True,
187
+ ) -> NSYSProfileResult:
188
+ """Execute NSYS profiling on a workspace.
189
+
190
+ Args:
191
+ workspace_id: Workspace ID to profile on
192
+ options: Profiling options
193
+ verbose: If True, print progress messages
194
+ sync_artifacts: If True, sync output file back to local
195
+
196
+ Returns:
197
+ NSYSProfileResult with success status and output path
198
+ """
199
+ from .workspaces import exec_command_capture, get_workspace_info
200
+
201
+ # Get workspace info to verify it exists
202
+ try:
203
+ workspace_info = get_workspace_info(workspace_id)
204
+ if not workspace_info:
205
+ return NSYSProfileResult(
206
+ success=False,
207
+ error=f"Workspace not found: {workspace_id}",
208
+ )
209
+ except Exception as e:
210
+ return NSYSProfileResult(
211
+ success=False,
212
+ error=f"Failed to get workspace info: {e}",
213
+ )
214
+
215
+ if verbose:
216
+ print(f"[nsys] Profiling on workspace: {workspace_id}", file=sys.stderr)
217
+
218
+ # Build nsys command for remote execution
219
+ # On workspace, nsys is expected to be in PATH
220
+ nsys_cmd = "nsys profile"
221
+
222
+ # Output file
223
+ output_name = options.output
224
+ if not output_name.endswith(".nsys-rep"):
225
+ output_name_base = output_name
226
+ else:
227
+ output_name_base = output_name[:-9]
228
+
229
+ nsys_cmd += f" -o {output_name_base}"
230
+
231
+ # Trace options
232
+ if options.trace:
233
+ nsys_cmd += f" -t {','.join(options.trace)}"
234
+ else:
235
+ nsys_cmd += " -t cuda"
236
+
237
+ # Duration
238
+ if options.duration:
239
+ nsys_cmd += f" --duration {options.duration}"
240
+
241
+ # Force overwrite
242
+ nsys_cmd += " --force-overwrite=true"
243
+
244
+ # Extra args
245
+ if options.extra_args:
246
+ nsys_cmd += f" {options.extra_args}"
247
+
248
+ # Command to profile
249
+ nsys_cmd += f" {options.command}"
250
+
251
+ if verbose:
252
+ print(f"[nsys] Running: {nsys_cmd}", file=sys.stderr)
253
+
254
+ # Execute on workspace
255
+ exit_code, output = exec_command_capture(workspace_id, nsys_cmd)
256
+
257
+ if exit_code != 0:
258
+ return NSYSProfileResult(
259
+ success=False,
260
+ stdout=output,
261
+ error=f"nsys profile failed on workspace with exit code {exit_code}",
262
+ )
263
+
264
+ # Check if output file was created
265
+ output_file = f"{output_name_base}.nsys-rep"
266
+ check_cmd = f"test -f {output_file} && echo 'exists' || echo 'not found'"
267
+ check_code, check_output = exec_command_capture(workspace_id, check_cmd)
268
+
269
+ if "not found" in check_output:
270
+ return NSYSProfileResult(
271
+ success=False,
272
+ stdout=output,
273
+ error=f"Output file not created on workspace: {output_file}",
274
+ )
275
+
276
+ if verbose:
277
+ print(f"[nsys] Profile created: {output_file}", file=sys.stderr)
278
+
279
+ # Optionally sync back to local
280
+ local_path = None
281
+ if sync_artifacts:
282
+ if verbose:
283
+ print(f"[nsys] Syncing {output_file} to local...", file=sys.stderr)
284
+
285
+ try:
286
+ from .workspaces import sync_workspace_file
287
+
288
+ local_path = sync_workspace_file(workspace_id, output_file, Path.cwd())
289
+ if verbose:
290
+ print(f"[nsys] Synced to: {local_path}", file=sys.stderr)
291
+ except Exception as e:
292
+ if verbose:
293
+ print(f"[nsys] Warning: Failed to sync: {e}", file=sys.stderr)
294
+ # Not a failure - file exists on workspace
295
+ local_path = None
296
+
297
+ return NSYSProfileResult(
298
+ success=True,
299
+ output_path=str(local_path) if local_path else f"workspace:{workspace_id}:{output_file}",
300
+ stdout=output,
301
+ )
302
+
303
+
304
+ def profile_remote_ssh(
305
+ target: str,
306
+ options: NSYSProfileOptions,
307
+ verbose: bool = False,
308
+ ) -> NSYSProfileResult:
309
+ """Execute NSYS profiling on a remote target via SSH.
310
+
311
+ Args:
312
+ target: Target name from ~/.wafer/targets/
313
+ options: Profiling options
314
+ verbose: If True, print progress messages
315
+
316
+ Returns:
317
+ NSYSProfileResult with success status and output path
318
+ """
319
+ from .targets import load_target
320
+ from .targets_ops import TargetExecError, exec_on_target_sync, get_target_ssh_info
321
+
322
+ import trio
323
+
324
+ # Load target
325
+ try:
326
+ target_config = load_target(target)
327
+ except FileNotFoundError as e:
328
+ return NSYSProfileResult(
329
+ success=False,
330
+ error=f"Target not found: {e}",
331
+ )
332
+ except ValueError as e:
333
+ return NSYSProfileResult(
334
+ success=False,
335
+ error=f"Invalid target config: {e}",
336
+ )
337
+
338
+ if verbose:
339
+ print(f"[nsys] Connecting to target: {target}", file=sys.stderr)
340
+
341
+ # Get SSH info
342
+ try:
343
+ ssh_info = trio.run(get_target_ssh_info, target_config)
344
+ except TargetExecError as e:
345
+ return NSYSProfileResult(
346
+ success=False,
347
+ error=f"Failed to connect to target: {e}",
348
+ )
349
+
350
+ if verbose:
351
+ print(
352
+ f"[nsys] Connected: {ssh_info.user}@{ssh_info.host}:{ssh_info.port}",
353
+ file=sys.stderr,
354
+ )
355
+
356
+ # Build nsys command
357
+ output_name = options.output
358
+ if not output_name.endswith(".nsys-rep"):
359
+ output_name_base = output_name
360
+ else:
361
+ output_name_base = output_name[:-9]
362
+
363
+ nsys_cmd = f"nsys profile -o {output_name_base}"
364
+
365
+ if options.trace:
366
+ nsys_cmd += f" -t {','.join(options.trace)}"
367
+ else:
368
+ nsys_cmd += " -t cuda"
369
+
370
+ if options.duration:
371
+ nsys_cmd += f" --duration {options.duration}"
372
+
373
+ nsys_cmd += " --force-overwrite=true"
374
+
375
+ if options.extra_args:
376
+ nsys_cmd += f" {options.extra_args}"
377
+
378
+ nsys_cmd += f" {options.command}"
379
+
380
+ if verbose:
381
+ print(f"[nsys] Running: {nsys_cmd}", file=sys.stderr)
382
+
383
+ # Execute
384
+ try:
385
+ timeout = options.duration + 60 if options.duration else 660
386
+ exit_code = exec_on_target_sync(ssh_info, nsys_cmd, timeout)
387
+
388
+ if exit_code != 0:
389
+ return NSYSProfileResult(
390
+ success=False,
391
+ error=f"nsys profile failed on target with exit code {exit_code}",
392
+ )
393
+
394
+ output_file = f"{output_name_base}.nsys-rep"
395
+ return NSYSProfileResult(
396
+ success=True,
397
+ output_path=f"ssh:{target}:{output_file}",
398
+ )
399
+
400
+ except TargetExecError as e:
401
+ return NSYSProfileResult(
402
+ success=False,
403
+ error=f"Execution failed: {e}",
404
+ )
405
+
406
+
407
+ def profile_and_analyze(
408
+ options: NSYSProfileOptions,
409
+ target: str | None = None,
410
+ json_output: bool = False,
411
+ verbose: bool = False,
412
+ ) -> tuple[NSYSProfileResult, NSYSAnalysisResult | None]:
413
+ """Profile and optionally analyze in one operation.
414
+
415
+ Args:
416
+ options: Profiling options
417
+ target: Optional target (workspace:id or target name)
418
+ json_output: If True, analysis returns JSON
419
+ verbose: If True, print progress messages
420
+
421
+ Returns:
422
+ Tuple of (profile_result, analysis_result or None)
423
+ """
424
+ from .nsys_analyze import analyze_nsys_profile
425
+
426
+ # Profile
427
+ if target:
428
+ target_type, target_id = _parse_target(target)
429
+ if target_type == "workspace":
430
+ profile_result = profile_workspace(
431
+ target_id, options, verbose=verbose, sync_artifacts=True
432
+ )
433
+ else:
434
+ profile_result = profile_remote_ssh(target_id, options, verbose=verbose)
435
+ else:
436
+ profile_result = profile_local(options, verbose=verbose)
437
+
438
+ if not profile_result.success:
439
+ return profile_result, None
440
+
441
+ # Analyze
442
+ if profile_result.output_path:
443
+ # Check if it's a local path we can analyze
444
+ output_path = profile_result.output_path
445
+ if output_path.startswith("workspace:") or output_path.startswith("ssh:"):
446
+ # Remote file - need to analyze on remote
447
+ if verbose:
448
+ print(
449
+ f"[nsys] Analyzing remote file: {output_path}", file=sys.stderr
450
+ )
451
+ # For workspace, we can use workspace analysis
452
+ if target and target.startswith("workspace:"):
453
+ parts = output_path.split(":")
454
+ ws_id = parts[1]
455
+ filepath = parts[2]
456
+ try:
457
+ analysis_output = analyze_nsys_profile(
458
+ Path(filepath),
459
+ json_output=json_output,
460
+ target=f"workspace:{ws_id}",
461
+ )
462
+ # Parse the output if needed
463
+ if json_output:
464
+ analysis_data = json.loads(analysis_output)
465
+ analysis_result = NSYSAnalysisResult(
466
+ success=True,
467
+ kernels=analysis_data.get("kernels"),
468
+ memory_transfers=analysis_data.get("memory_transfers"),
469
+ )
470
+ else:
471
+ analysis_result = NSYSAnalysisResult(
472
+ success=True,
473
+ )
474
+ return profile_result, analysis_result
475
+ except Exception as e:
476
+ return profile_result, NSYSAnalysisResult(
477
+ success=False,
478
+ error=f"Analysis failed: {e}",
479
+ )
480
+ else:
481
+ # For SSH targets, we'd need to implement analysis there
482
+ return profile_result, NSYSAnalysisResult(
483
+ success=False,
484
+ error="Remote analysis for SSH targets not yet implemented. Download the file and analyze locally.",
485
+ )
486
+ else:
487
+ # Local file
488
+ try:
489
+ analysis_output = analyze_nsys_profile(
490
+ Path(output_path),
491
+ json_output=json_output,
492
+ )
493
+ if json_output:
494
+ analysis_data = json.loads(analysis_output)
495
+ analysis_result = NSYSAnalysisResult(
496
+ success=True,
497
+ kernels=analysis_data.get("kernels"),
498
+ memory_transfers=analysis_data.get("memory_transfers"),
499
+ )
500
+ else:
501
+ analysis_result = NSYSAnalysisResult(success=True)
502
+ # Print the analysis
503
+ print(analysis_output)
504
+ return profile_result, analysis_result
505
+ except Exception as e:
506
+ return profile_result, NSYSAnalysisResult(
507
+ success=False,
508
+ error=f"Analysis failed: {e}",
509
+ )
510
+
511
+ return profile_result, None