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.
- {alloc-0.0.5 → alloc-0.0.6}/PKG-INFO +1 -1
- {alloc-0.0.5 → alloc-0.0.6}/pyproject.toml +1 -1
- {alloc-0.0.5 → alloc-0.0.6}/src/alloc/__init__.py +6 -1
- {alloc-0.0.5 → alloc-0.0.6}/src/alloc/cli.py +32 -8
- {alloc-0.0.5 → alloc-0.0.6}/src/alloc/extractor_runner.py +3 -1
- {alloc-0.0.5 → alloc-0.0.6}/src/alloc/model_extractor.py +7 -0
- {alloc-0.0.5 → alloc-0.0.6}/src/alloc/probe.py +149 -8
- {alloc-0.0.5 → alloc-0.0.6}/src/alloc.egg-info/PKG-INFO +1 -1
- {alloc-0.0.5 → alloc-0.0.6}/tests/test_auth.py +12 -1
- {alloc-0.0.5 → alloc-0.0.6}/tests/test_probe_multi.py +79 -1
- {alloc-0.0.5 → alloc-0.0.6}/README.md +0 -0
- {alloc-0.0.5 → alloc-0.0.6}/setup.cfg +0 -0
- {alloc-0.0.5 → alloc-0.0.6}/src/alloc/artifact_loader.py +0 -0
- {alloc-0.0.5 → alloc-0.0.6}/src/alloc/artifact_writer.py +0 -0
- {alloc-0.0.5 → alloc-0.0.6}/src/alloc/browser_auth.py +0 -0
- {alloc-0.0.5 → alloc-0.0.6}/src/alloc/callbacks.py +0 -0
- {alloc-0.0.5 → alloc-0.0.6}/src/alloc/catalog/__init__.py +0 -0
- {alloc-0.0.5 → alloc-0.0.6}/src/alloc/catalog/default_rate_card.json +0 -0
- {alloc-0.0.5 → alloc-0.0.6}/src/alloc/catalog/gpus.v1.json +0 -0
- {alloc-0.0.5 → alloc-0.0.6}/src/alloc/code_analyzer.py +0 -0
- {alloc-0.0.5 → alloc-0.0.6}/src/alloc/config.py +0 -0
- {alloc-0.0.5 → alloc-0.0.6}/src/alloc/context.py +0 -0
- {alloc-0.0.5 → alloc-0.0.6}/src/alloc/diagnosis_display.py +0 -0
- {alloc-0.0.5 → alloc-0.0.6}/src/alloc/diagnosis_engine.py +0 -0
- {alloc-0.0.5 → alloc-0.0.6}/src/alloc/diagnosis_rules.py +0 -0
- {alloc-0.0.5 → alloc-0.0.6}/src/alloc/display.py +0 -0
- {alloc-0.0.5 → alloc-0.0.6}/src/alloc/ghost.py +0 -0
- {alloc-0.0.5 → alloc-0.0.6}/src/alloc/model_registry.py +0 -0
- {alloc-0.0.5 → alloc-0.0.6}/src/alloc/stability.py +0 -0
- {alloc-0.0.5 → alloc-0.0.6}/src/alloc/upload.py +0 -0
- {alloc-0.0.5 → alloc-0.0.6}/src/alloc/yaml_config.py +0 -0
- {alloc-0.0.5 → alloc-0.0.6}/src/alloc.egg-info/SOURCES.txt +0 -0
- {alloc-0.0.5 → alloc-0.0.6}/src/alloc.egg-info/dependency_links.txt +0 -0
- {alloc-0.0.5 → alloc-0.0.6}/src/alloc.egg-info/entry_points.txt +0 -0
- {alloc-0.0.5 → alloc-0.0.6}/src/alloc.egg-info/requires.txt +0 -0
- {alloc-0.0.5 → alloc-0.0.6}/src/alloc.egg-info/top_level.txt +0 -0
- {alloc-0.0.5 → alloc-0.0.6}/tests/test_artifact.py +0 -0
- {alloc-0.0.5 → alloc-0.0.6}/tests/test_artifact_loader.py +0 -0
- {alloc-0.0.5 → alloc-0.0.6}/tests/test_callbacks.py +0 -0
- {alloc-0.0.5 → alloc-0.0.6}/tests/test_catalog.py +0 -0
- {alloc-0.0.5 → alloc-0.0.6}/tests/test_cli.py +0 -0
- {alloc-0.0.5 → alloc-0.0.6}/tests/test_code_analyzer.py +0 -0
- {alloc-0.0.5 → alloc-0.0.6}/tests/test_context.py +0 -0
- {alloc-0.0.5 → alloc-0.0.6}/tests/test_diagnose_cli.py +0 -0
- {alloc-0.0.5 → alloc-0.0.6}/tests/test_diagnosis_engine.py +0 -0
- {alloc-0.0.5 → alloc-0.0.6}/tests/test_diagnosis_rules.py +0 -0
- {alloc-0.0.5 → alloc-0.0.6}/tests/test_extractor_activation.py +0 -0
- {alloc-0.0.5 → alloc-0.0.6}/tests/test_ghost.py +0 -0
- {alloc-0.0.5 → alloc-0.0.6}/tests/test_init_from_org.py +0 -0
- {alloc-0.0.5 → alloc-0.0.6}/tests/test_interconnect.py +0 -0
- {alloc-0.0.5 → alloc-0.0.6}/tests/test_model_extractor.py +0 -0
- {alloc-0.0.5 → alloc-0.0.6}/tests/test_probe_hw.py +0 -0
- {alloc-0.0.5 → alloc-0.0.6}/tests/test_stability.py +0 -0
- {alloc-0.0.5 → alloc-0.0.6}/tests/test_upload.py +0 -0
- {alloc-0.0.5 → alloc-0.0.6}/tests/test_verdict.py +0 -0
- {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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
-
#
|
|
460
|
-
#
|
|
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.
|
|
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
|
|
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
|
|
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
|