alloc 0.0.5__tar.gz → 0.0.6__tar.gz

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.
Files changed (56) hide show
  1. {alloc-0.0.5 → alloc-0.0.6}/PKG-INFO +1 -1
  2. {alloc-0.0.5 → alloc-0.0.6}/pyproject.toml +1 -1
  3. {alloc-0.0.5 → alloc-0.0.6}/src/alloc/__init__.py +6 -1
  4. {alloc-0.0.5 → alloc-0.0.6}/src/alloc/cli.py +32 -8
  5. {alloc-0.0.5 → alloc-0.0.6}/src/alloc/extractor_runner.py +3 -1
  6. {alloc-0.0.5 → alloc-0.0.6}/src/alloc/model_extractor.py +7 -0
  7. {alloc-0.0.5 → alloc-0.0.6}/src/alloc/probe.py +149 -8
  8. {alloc-0.0.5 → alloc-0.0.6}/src/alloc.egg-info/PKG-INFO +1 -1
  9. {alloc-0.0.5 → alloc-0.0.6}/tests/test_auth.py +12 -1
  10. {alloc-0.0.5 → alloc-0.0.6}/tests/test_probe_multi.py +79 -1
  11. {alloc-0.0.5 → alloc-0.0.6}/README.md +0 -0
  12. {alloc-0.0.5 → alloc-0.0.6}/setup.cfg +0 -0
  13. {alloc-0.0.5 → alloc-0.0.6}/src/alloc/artifact_loader.py +0 -0
  14. {alloc-0.0.5 → alloc-0.0.6}/src/alloc/artifact_writer.py +0 -0
  15. {alloc-0.0.5 → alloc-0.0.6}/src/alloc/browser_auth.py +0 -0
  16. {alloc-0.0.5 → alloc-0.0.6}/src/alloc/callbacks.py +0 -0
  17. {alloc-0.0.5 → alloc-0.0.6}/src/alloc/catalog/__init__.py +0 -0
  18. {alloc-0.0.5 → alloc-0.0.6}/src/alloc/catalog/default_rate_card.json +0 -0
  19. {alloc-0.0.5 → alloc-0.0.6}/src/alloc/catalog/gpus.v1.json +0 -0
  20. {alloc-0.0.5 → alloc-0.0.6}/src/alloc/code_analyzer.py +0 -0
  21. {alloc-0.0.5 → alloc-0.0.6}/src/alloc/config.py +0 -0
  22. {alloc-0.0.5 → alloc-0.0.6}/src/alloc/context.py +0 -0
  23. {alloc-0.0.5 → alloc-0.0.6}/src/alloc/diagnosis_display.py +0 -0
  24. {alloc-0.0.5 → alloc-0.0.6}/src/alloc/diagnosis_engine.py +0 -0
  25. {alloc-0.0.5 → alloc-0.0.6}/src/alloc/diagnosis_rules.py +0 -0
  26. {alloc-0.0.5 → alloc-0.0.6}/src/alloc/display.py +0 -0
  27. {alloc-0.0.5 → alloc-0.0.6}/src/alloc/ghost.py +0 -0
  28. {alloc-0.0.5 → alloc-0.0.6}/src/alloc/model_registry.py +0 -0
  29. {alloc-0.0.5 → alloc-0.0.6}/src/alloc/stability.py +0 -0
  30. {alloc-0.0.5 → alloc-0.0.6}/src/alloc/upload.py +0 -0
  31. {alloc-0.0.5 → alloc-0.0.6}/src/alloc/yaml_config.py +0 -0
  32. {alloc-0.0.5 → alloc-0.0.6}/src/alloc.egg-info/SOURCES.txt +0 -0
  33. {alloc-0.0.5 → alloc-0.0.6}/src/alloc.egg-info/dependency_links.txt +0 -0
  34. {alloc-0.0.5 → alloc-0.0.6}/src/alloc.egg-info/entry_points.txt +0 -0
  35. {alloc-0.0.5 → alloc-0.0.6}/src/alloc.egg-info/requires.txt +0 -0
  36. {alloc-0.0.5 → alloc-0.0.6}/src/alloc.egg-info/top_level.txt +0 -0
  37. {alloc-0.0.5 → alloc-0.0.6}/tests/test_artifact.py +0 -0
  38. {alloc-0.0.5 → alloc-0.0.6}/tests/test_artifact_loader.py +0 -0
  39. {alloc-0.0.5 → alloc-0.0.6}/tests/test_callbacks.py +0 -0
  40. {alloc-0.0.5 → alloc-0.0.6}/tests/test_catalog.py +0 -0
  41. {alloc-0.0.5 → alloc-0.0.6}/tests/test_cli.py +0 -0
  42. {alloc-0.0.5 → alloc-0.0.6}/tests/test_code_analyzer.py +0 -0
  43. {alloc-0.0.5 → alloc-0.0.6}/tests/test_context.py +0 -0
  44. {alloc-0.0.5 → alloc-0.0.6}/tests/test_diagnose_cli.py +0 -0
  45. {alloc-0.0.5 → alloc-0.0.6}/tests/test_diagnosis_engine.py +0 -0
  46. {alloc-0.0.5 → alloc-0.0.6}/tests/test_diagnosis_rules.py +0 -0
  47. {alloc-0.0.5 → alloc-0.0.6}/tests/test_extractor_activation.py +0 -0
  48. {alloc-0.0.5 → alloc-0.0.6}/tests/test_ghost.py +0 -0
  49. {alloc-0.0.5 → alloc-0.0.6}/tests/test_init_from_org.py +0 -0
  50. {alloc-0.0.5 → alloc-0.0.6}/tests/test_interconnect.py +0 -0
  51. {alloc-0.0.5 → alloc-0.0.6}/tests/test_model_extractor.py +0 -0
  52. {alloc-0.0.5 → alloc-0.0.6}/tests/test_probe_hw.py +0 -0
  53. {alloc-0.0.5 → alloc-0.0.6}/tests/test_stability.py +0 -0
  54. {alloc-0.0.5 → alloc-0.0.6}/tests/test_upload.py +0 -0
  55. {alloc-0.0.5 → alloc-0.0.6}/tests/test_verdict.py +0 -0
  56. {alloc-0.0.5 → alloc-0.0.6}/tests/test_yaml_config.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alloc
3
- Version: 0.0.5
3
+ Version: 0.0.6
4
4
  Summary: Engineer-first training calibration: estimate VRAM fit, profile short runs, and pick GPU configs under real budget constraints.
5
5
  Author-email: Alloc Labs <hello@alloclabs.com>
6
6
  License-Expression: Apache-2.0
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "alloc"
7
- version = "0.0.5"
7
+ version = "0.0.6"
8
8
  description = "Engineer-first training calibration: estimate VRAM fit, profile short runs, and pick GPU configs under real budget constraints."
9
9
  readme = "README.md"
10
10
  license = "Apache-2.0"
@@ -2,7 +2,12 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- __version__ = "0.0.5"
5
+ import warnings as _warnings
6
+ _warnings.filterwarnings("ignore", category=FutureWarning, module="pynvml")
7
+ _warnings.filterwarnings("ignore", category=DeprecationWarning, module="pynvml")
8
+ del _warnings
9
+
10
+ __version__ = "0.0.6"
6
11
 
7
12
  from alloc.ghost import ghost, GhostReport
8
13
  from alloc.callbacks import AllocCallback as HuggingFaceCallback
@@ -16,8 +16,15 @@ from __future__ import annotations
16
16
 
17
17
  import os
18
18
  import sys
19
+ import warnings
19
20
  from typing import List, Optional
20
21
 
22
+ # Suppress noisy third-party warnings globally — pynvml deprecation and
23
+ # urllib3 LibreSSL warnings clutter every CLI command on affected systems.
24
+ warnings.filterwarnings("ignore", category=FutureWarning, module="pynvml")
25
+ warnings.filterwarnings("ignore", category=DeprecationWarning, module="pynvml")
26
+ warnings.filterwarnings("ignore", message=".*LibreSSL.*", module="urllib3")
27
+
21
28
  import typer
22
29
  from rich.console import Console
23
30
 
@@ -2107,7 +2114,12 @@ def scan(
2107
2114
  _print_scan_result(result, gpu, strategy)
2108
2115
  except httpx.HTTPStatusError as e:
2109
2116
  if json_output:
2110
- _print_json({"error": f"API error {e.response.status_code}"})
2117
+ detail = ""
2118
+ try:
2119
+ detail = e.response.json().get("detail", "")
2120
+ except Exception:
2121
+ pass
2122
+ _print_json({"error": f"API error {e.response.status_code}", "detail": detail})
2111
2123
  elif e.response.status_code == 403:
2112
2124
  console.print("[yellow]AI analysis requires a Pro or Enterprise plan.[/yellow]")
2113
2125
  console.print("[dim]The scan still works — just without AI-powered analysis.[/dim]")
@@ -2147,18 +2159,30 @@ def login(
2147
2159
  ),
2148
2160
  ):
2149
2161
  """Authenticate with Alloc dashboard."""
2150
- # Suppress noisy third-party warnings (urllib3 LibreSSL, pynvml deprecation)
2151
- # that clutter the auth flow output.
2152
- import warnings
2153
- warnings.filterwarnings("ignore", category=DeprecationWarning, module="pynvml")
2154
- warnings.filterwarnings("ignore", message=".*LibreSSL.*", module="urllib3")
2155
- warnings.filterwarnings("ignore", message=".*pynvml.*", category=FutureWarning)
2156
-
2157
2162
  import httpx
2158
2163
  from alloc.config import get_supabase_url, get_supabase_anon_key, load_config, save_config
2159
2164
 
2160
2165
  # --- Browser OAuth flow ---
2161
2166
  if browser:
2167
+ # Detect headless/SSH environments where browser login won't work
2168
+ is_headless = (
2169
+ not os.environ.get("DISPLAY")
2170
+ and not os.environ.get("WAYLAND_DISPLAY")
2171
+ and sys.platform != "darwin"
2172
+ and sys.platform != "win32"
2173
+ )
2174
+ is_ssh = bool(os.environ.get("SSH_CLIENT") or os.environ.get("SSH_TTY"))
2175
+ if is_headless or is_ssh:
2176
+ console.print("[yellow]Headless/SSH environment detected — browser login won't work here.[/yellow]")
2177
+ console.print()
2178
+ console.print("Use token login instead:")
2179
+ console.print(" 1. Log in at [cyan]https://alloclabs.com[/cyan] in your local browser")
2180
+ console.print(" 2. Open DevTools → Application → Local Storage → copy your access_token")
2181
+ console.print(" 3. Run: [green]alloc login --method token --token <paste-token>[/green]")
2182
+ console.print()
2183
+ console.print("[dim]Or set ALLOC_TOKEN=<token> in your environment.[/dim]")
2184
+ raise typer.Exit(1)
2185
+
2162
2186
  provider = (provider or "").strip().lower()
2163
2187
  if provider not in ("google", "azure"):
2164
2188
  console.print("[red]Invalid --provider. Use: google or azure[/red]")
@@ -217,7 +217,9 @@ def main():
217
217
  try:
218
218
  obj = getattr(module, attr_name)
219
219
  if isinstance(obj, nn.Module):
220
- count, dtype_str = _count_params(obj)
220
+ # Unwrap DDP/FSDP wrappers to get the underlying model
221
+ unwrapped = getattr(obj, "module", obj)
222
+ count, dtype_str = _count_params(unwrapped)
221
223
  if count > 0:
222
224
  models.append((count, dtype_str, attr_name))
223
225
  except Exception:
@@ -99,6 +99,13 @@ def _extract_via_subprocess(
99
99
 
100
100
  env = os.environ.copy()
101
101
  env["CUDA_VISIBLE_DEVICES"] = "" # prevent GPU allocation
102
+ # Set distributed env vars so DDP scripts don't crash on
103
+ # torch.distributed.init_process_group() during model extraction.
104
+ env.setdefault("RANK", "0")
105
+ env.setdefault("LOCAL_RANK", "0")
106
+ env.setdefault("WORLD_SIZE", "1")
107
+ env.setdefault("MASTER_ADDR", "127.0.0.1")
108
+ env.setdefault("MASTER_PORT", "29500")
102
109
 
103
110
  subprocess.run(
104
111
  [sys.executable, "-m", "alloc.extractor_runner", sidecar_path, script_abs],
@@ -83,6 +83,85 @@ def _try_import_pynvml():
83
83
 
84
84
 
85
85
 
86
+ def _parse_launcher_gpu_count(command):
87
+ # type: (list) -> Optional[int]
88
+ """Extract expected GPU count from launcher command line.
89
+
90
+ Recognizes: torchrun, torch.distributed.launch, accelerate launch,
91
+ deepspeed, mpirun/mpiexec.
92
+ """
93
+ args = [str(a) for a in command]
94
+ cmd_str = " ".join(args)
95
+
96
+ # torchrun --nproc_per_node=N or --nproc-per-node=N
97
+ for i, a in enumerate(args):
98
+ for flag in ("--nproc_per_node", "--nproc-per-node"):
99
+ if a.startswith(flag + "="):
100
+ try:
101
+ return int(a.split("=", 1)[1])
102
+ except ValueError:
103
+ pass
104
+ elif a == flag and i + 1 < len(args):
105
+ try:
106
+ return int(args[i + 1])
107
+ except ValueError:
108
+ pass
109
+
110
+ # accelerate launch --num_processes=N or --num-processes=N
111
+ for i, a in enumerate(args):
112
+ for flag in ("--num_processes", "--num-processes"):
113
+ if a.startswith(flag + "="):
114
+ try:
115
+ return int(a.split("=", 1)[1])
116
+ except ValueError:
117
+ pass
118
+ elif a == flag and i + 1 < len(args):
119
+ try:
120
+ return int(args[i + 1])
121
+ except ValueError:
122
+ pass
123
+
124
+ # deepspeed --num_gpus=N or --num-gpus=N
125
+ for i, a in enumerate(args):
126
+ for flag in ("--num_gpus", "--num-gpus"):
127
+ if a.startswith(flag + "="):
128
+ try:
129
+ return int(a.split("=", 1)[1])
130
+ except ValueError:
131
+ pass
132
+ elif a == flag and i + 1 < len(args):
133
+ try:
134
+ return int(args[i + 1])
135
+ except ValueError:
136
+ pass
137
+
138
+ # mpirun/mpiexec -np N or -n N
139
+ for i, a in enumerate(args):
140
+ if a in ("-np", "-n") and i + 1 < len(args):
141
+ if any(x in cmd_str for x in ("mpirun", "mpiexec")):
142
+ try:
143
+ return int(args[i + 1])
144
+ except ValueError:
145
+ pass
146
+
147
+ return None
148
+
149
+
150
+ def _read_child_env(pid, var_name):
151
+ # type: (int, str) -> Optional[str]
152
+ """Read an environment variable from a child process via /proc (Linux only)."""
153
+ try:
154
+ env_path = "/proc/{}/environ".format(pid)
155
+ with open(env_path, "rb") as f:
156
+ env_data = f.read()
157
+ for entry in env_data.split(b"\x00"):
158
+ if entry.startswith(var_name.encode() + b"="):
159
+ return entry.split(b"=", 1)[1].decode("utf-8")
160
+ except Exception:
161
+ pass
162
+ return None
163
+
164
+
86
165
  def _get_child_pids(pid):
87
166
  # type: (int) -> List[int]
88
167
  """Get child PIDs of a process. Returns empty list on failure."""
@@ -101,11 +180,15 @@ def _get_child_pids(pid):
101
180
  return []
102
181
 
103
182
 
104
- def _discover_gpu_indices(proc_pid, pynvml, fallback_index=0):
105
- # type: (int, ..., int) -> List[int]
183
+ def _discover_gpu_indices(proc_pid, pynvml, fallback_index=0, expected_gpus=None):
184
+ # type: (int, ..., int, Optional[int]) -> List[int]
106
185
  """Discover which GPUs a process (and its children) are using.
107
186
 
108
- Iterates all GPU devices and checks running compute processes.
187
+ Two strategies:
188
+ 1. PID-matching: walks process tree, matches PIDs against NVML compute processes.
189
+ 2. Active-GPU counting: counts GPUs with ANY compute processes. Used when PID
190
+ matching finds fewer GPUs than expected (common for DDP launchers).
191
+
109
192
  Falls back to [fallback_index] if discovery fails or finds nothing.
110
193
  """
111
194
  try:
@@ -143,11 +226,48 @@ def _discover_gpu_indices(proc_pid, pynvml, fallback_index=0):
143
226
  for ggchild in _get_child_pids(grandchild):
144
227
  target_pids.add(ggchild)
145
228
 
229
+ # Also try reading WORLD_SIZE from child process environments (Linux)
230
+ child_world_size = None
231
+ for child in _get_child_pids(proc_pid):
232
+ ws = _read_child_env(child, "WORLD_SIZE")
233
+ if ws:
234
+ try:
235
+ child_world_size = int(ws)
236
+ except ValueError:
237
+ pass
238
+ break
239
+ for grandchild in _get_child_pids(child):
240
+ ws = _read_child_env(grandchild, "WORLD_SIZE")
241
+ if ws:
242
+ try:
243
+ child_world_size = int(ws)
244
+ except ValueError:
245
+ pass
246
+ break
247
+ for ggchild in _get_child_pids(grandchild):
248
+ ws = _read_child_env(ggchild, "WORLD_SIZE")
249
+ if ws:
250
+ try:
251
+ child_world_size = int(ws)
252
+ except ValueError:
253
+ pass
254
+ break
255
+
256
+ # Determine how many GPUs we expect
257
+ effective_expected = expected_gpus
258
+ if child_world_size is not None:
259
+ if effective_expected is None or child_world_size > effective_expected:
260
+ effective_expected = child_world_size
261
+
262
+ # Strategy 1: PID-based matching
146
263
  found_indices = []
264
+ active_indices = [] # GPUs with ANY compute processes
147
265
  for idx in search_indices:
148
266
  try:
149
267
  handle = pynvml.nvmlDeviceGetHandleByIndex(idx)
150
268
  procs = pynvml.nvmlDeviceGetComputeRunningProcesses(handle)
269
+ if procs:
270
+ active_indices.append(idx)
151
271
  for p in procs:
152
272
  if p.pid in target_pids:
153
273
  found_indices.append(idx)
@@ -155,6 +275,16 @@ def _discover_gpu_indices(proc_pid, pynvml, fallback_index=0):
155
275
  except Exception:
156
276
  continue
157
277
 
278
+ # Strategy 2: If PID matching found fewer than expected, use active GPU count.
279
+ # This handles the common case where DDP workers are running but their PIDs
280
+ # weren't in our process tree (e.g., process group isolation, timing issues).
281
+ if effective_expected is not None and len(found_indices) < effective_expected:
282
+ if len(active_indices) >= effective_expected:
283
+ return active_indices[:effective_expected]
284
+ # Even if active < expected, active is better than found
285
+ if len(active_indices) > len(found_indices):
286
+ return active_indices
287
+
158
288
  return found_indices if found_indices else [fallback_index]
159
289
 
160
290
 
@@ -310,12 +440,17 @@ def probe_command(
310
440
  discovery_attempts = 0
311
441
  max_discovery_attempts = 3 # Retry at samples 5, 15, 30
312
442
 
313
- # Determine expected GPU count from environment for retry logic
443
+ # Determine expected GPU count from command line + environment
314
444
  expected_gpus = 1
445
+ launcher_gpus = _parse_launcher_gpu_count(command)
446
+ if launcher_gpus is not None and launcher_gpus > 1:
447
+ expected_gpus = launcher_gpus
315
448
  ws = os.environ.get("WORLD_SIZE", "").strip()
316
449
  if ws:
317
450
  try:
318
- expected_gpus = max(1, int(ws))
451
+ ws_int = max(1, int(ws))
452
+ if ws_int > expected_gpus:
453
+ expected_gpus = ws_int
319
454
  except ValueError:
320
455
  pass
321
456
 
@@ -329,7 +464,10 @@ def probe_command(
329
464
  and proc.pid):
330
465
  discovery_attempts += 1
331
466
  try:
332
- discovered = _discover_gpu_indices(proc.pid, pynvml, fallback_index=gpu_index)
467
+ discovered = _discover_gpu_indices(
468
+ proc.pid, pynvml, fallback_index=gpu_index,
469
+ expected_gpus=expected_gpus if expected_gpus > 1 else None,
470
+ )
333
471
  if len(discovered) > 1:
334
472
  handles = []
335
473
  pmap = []
@@ -456,9 +594,12 @@ def probe_command(
456
594
  if calibration_time_ref[0] is not None:
457
595
  cal_duration = round(calibration_time_ref[0] - start_time, 2)
458
596
 
459
- # Environment-based fallback: if NVML discovery found fewer GPUs than
460
- # WORLD_SIZE indicates, trust the environment. The probe may miss GPUs
597
+ # Final fallback: if NVML discovery found fewer GPUs than expected,
598
+ # trust the command-line / environment signals. The probe may miss GPUs
461
599
  # due to DDP per-rank CVD isolation or timing races.
600
+ launcher_count = _parse_launcher_gpu_count(command)
601
+ if launcher_count is not None and launcher_count > num_gpus_ref[0]:
602
+ num_gpus_ref[0] = launcher_count
462
603
  env_world = os.environ.get("WORLD_SIZE", "").strip()
463
604
  if env_world:
464
605
  try:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alloc
3
- Version: 0.0.5
3
+ Version: 0.0.6
4
4
  Summary: Engineer-first training calibration: estimate VRAM fit, profile short runs, and pick GPU configs under real budget constraints.
5
5
  Author-email: Alloc Labs <hello@alloclabs.com>
6
6
  License-Expression: Apache-2.0
@@ -223,6 +223,7 @@ def test_browser_login_saves_tokens(tmp_path: Path):
223
223
  env = {
224
224
  "HOME": str(tmp_path),
225
225
  "ALLOC_API_URL": "https://api.example.com",
226
+ "DISPLAY": ":0", # prevent headless detection in CI
226
227
  }
227
228
 
228
229
  with patch("alloc.browser_auth.browser_login", return_value=mock_result):
@@ -248,6 +249,7 @@ def test_browser_login_with_azure_provider(tmp_path: Path):
248
249
  env = {
249
250
  "HOME": str(tmp_path),
250
251
  "ALLOC_API_URL": "https://api.example.com",
252
+ "DISPLAY": ":0",
251
253
  }
252
254
 
253
255
  with patch("alloc.browser_auth.browser_login", return_value=mock_result) as mock_bl:
@@ -263,7 +265,7 @@ def test_browser_login_with_azure_provider(tmp_path: Path):
263
265
 
264
266
 
265
267
  def test_browser_login_invalid_provider(tmp_path: Path):
266
- env = {"HOME": str(tmp_path)}
268
+ env = {"HOME": str(tmp_path), "DISPLAY": ":0"}
267
269
  result = runner.invoke(
268
270
  app, ["login", "--browser", "--provider", "facebook"], env=env
269
271
  )
@@ -271,10 +273,19 @@ def test_browser_login_invalid_provider(tmp_path: Path):
271
273
  assert "Invalid --provider" in result.output
272
274
 
273
275
 
276
+ def test_browser_login_headless_detection(tmp_path: Path):
277
+ """In headless/SSH environment, browser login should fail with guidance."""
278
+ env = {"HOME": str(tmp_path), "SSH_CLIENT": "1.2.3.4 1234 22", "DISPLAY": ":0"}
279
+ result = runner.invoke(app, ["login", "--browser"], env=env)
280
+ assert result.exit_code != 0
281
+ assert "Headless" in result.output or "token" in result.output
282
+
283
+
274
284
  def test_browser_login_timeout(tmp_path: Path):
275
285
  env = {
276
286
  "HOME": str(tmp_path),
277
287
  "ALLOC_API_URL": "https://api.example.com",
288
+ "DISPLAY": ":0",
278
289
  }
279
290
 
280
291
  with patch(
@@ -4,7 +4,12 @@ from __future__ import annotations
4
4
 
5
5
  from unittest.mock import MagicMock, patch
6
6
 
7
- from alloc.probe import _discover_gpu_indices, _get_child_pids, ProbeResult
7
+ from alloc.probe import (
8
+ _discover_gpu_indices,
9
+ _get_child_pids,
10
+ _parse_launcher_gpu_count,
11
+ ProbeResult,
12
+ )
8
13
 
9
14
 
10
15
  def _mock_pynvml_multi_gpu(proc_pid, gpu_process_map):
@@ -112,3 +117,76 @@ def test_probe_result_defaults_single_gpu():
112
117
  result = ProbeResult()
113
118
  assert result.num_gpus_detected == 1
114
119
  assert result.process_map is None
120
+
121
+
122
+ # ── Launcher command-line parsing ──
123
+
124
+
125
+ def test_parse_torchrun_equals():
126
+ assert _parse_launcher_gpu_count(["torchrun", "--nproc_per_node=2", "train.py"]) == 2
127
+
128
+
129
+ def test_parse_torchrun_space():
130
+ assert _parse_launcher_gpu_count(["torchrun", "--nproc_per_node", "4", "train.py"]) == 4
131
+
132
+
133
+ def test_parse_torchrun_hyphen():
134
+ assert _parse_launcher_gpu_count(["torchrun", "--nproc-per-node=8", "train.py"]) == 8
135
+
136
+
137
+ def test_parse_accelerate_equals():
138
+ assert _parse_launcher_gpu_count(["accelerate", "launch", "--num_processes=4", "train.py"]) == 4
139
+
140
+
141
+ def test_parse_accelerate_hyphen_space():
142
+ assert _parse_launcher_gpu_count(["accelerate", "launch", "--num-processes", "8", "train.py"]) == 8
143
+
144
+
145
+ def test_parse_deepspeed_equals():
146
+ assert _parse_launcher_gpu_count(["deepspeed", "--num_gpus=4", "train.py"]) == 4
147
+
148
+
149
+ def test_parse_deepspeed_hyphen_space():
150
+ assert _parse_launcher_gpu_count(["deepspeed", "--num-gpus", "2", "train.py"]) == 2
151
+
152
+
153
+ def test_parse_mpirun():
154
+ assert _parse_launcher_gpu_count(["mpirun", "-np", "8", "python", "train.py"]) == 8
155
+
156
+
157
+ def test_parse_plain_python():
158
+ assert _parse_launcher_gpu_count(["python", "train.py"]) is None
159
+
160
+
161
+ def test_parse_torch_distributed_launch():
162
+ assert _parse_launcher_gpu_count([
163
+ "python", "-m", "torch.distributed.launch", "--nproc_per_node=2", "train.py"
164
+ ]) == 2
165
+
166
+
167
+ # ── Active-GPU fallback discovery ──
168
+
169
+
170
+ def test_active_gpu_fallback_when_pid_mismatch():
171
+ """When PID matching fails but GPUs have active compute processes,
172
+ and expected_gpus matches, use active GPUs."""
173
+ mock = _mock_pynvml_multi_gpu(
174
+ proc_pid=1000,
175
+ gpu_process_map={0: [9999], 1: [8888]}, # PIDs don't match 1000
176
+ )
177
+ with patch("alloc.probe._get_child_pids", return_value=[]):
178
+ with patch("alloc.probe._read_child_env", return_value=None):
179
+ result = _discover_gpu_indices(1000, mock, fallback_index=0, expected_gpus=2)
180
+ assert result == [0, 1]
181
+
182
+
183
+ def test_active_gpu_fallback_not_used_without_expected():
184
+ """Without expected_gpus, active GPU fallback is not used."""
185
+ mock = _mock_pynvml_multi_gpu(
186
+ proc_pid=1000,
187
+ gpu_process_map={0: [9999], 1: [8888]},
188
+ )
189
+ with patch("alloc.probe._get_child_pids", return_value=[]):
190
+ with patch("alloc.probe._read_child_env", return_value=None):
191
+ result = _discover_gpu_indices(1000, mock, fallback_index=0)
192
+ assert result == [0] # Falls back to default
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes