mirror of https://github.com/razor-ai/soup.git
feat(multi-gpu,serve): auto-reexec + MII live (v0.33.0 Part B)
Closes #37, #38. Final Part of v0.33.0 implementation phase. #37 Auto-reexec under accelerate launch when --gpus N>1: - soup_cli/commands/train.py gains --no-reexec opt-out flag (default behaviour: auto-reexec). - When --gpus N>1 and not already in a distributed env (RANK/WORLD_SIZE + ACCELERATE_* markers absent), train() reconstructs argv via utils.launcher.build_accelerate_argv and calls os.execvp("accelerate", argv). os.execvp replaces the current process — no leftover PID tree, stdio passes through unchanged. - Critical flags (--fsdp, --deepspeed, --resume, --wandb, --tensorboard, --yes) are forwarded to the reexec'd run so users see the same behaviour they'd get from running accelerate launch by hand. - OSError from execvp falls back to the v0.27.0 advisory (printed command) so misconfigured PATH doesn't dead-lock the user. - --no-reexec preserves the v0.27.0 print-and-exit behaviour for users who want to control env vars / stdio explicitly. #38 DeepSpeed-MII live serve: - soup_cli/utils/mii.py gains build_mii_app(pipeline, model_name) which returns a FastAPI app with /v1/chat/completions + /v1/models matching the v0.30.0 transformers backend's contract. - Pipeline is held by closure (single MII instance, thread-safe across concurrent generations). Loopback-only CORS mirrors v0.30.0 transformers backend policy. - max_tokens bounds [1, 16384], stream=True rejected (MII v0.x lacks stable streaming), pipeline crashes return 500 with generic message (no stack-trace leak). Empty response → 500. - soup_cli/commands/serve.py replaces the v0.27.0 stub-warning + Exit(1) with create_mii_pipeline → build_mii_app → uvicorn.run. Tests: +9 in tests/test_part_b.py covering /v1/models endpoint, chat happy-path with mocked pipeline returning .generated_text, streaming rejection, max_tokens bounds (low + high), pipeline failure → 500, empty pipeline response → 500, --no-reexec parameter exists, --no-reexec advisory fallback, --gpus 2 reexec calls os.execvp with accelerate argv (via monkeypatched os.execvp). Known limitations: - MII server has no streaming, no LoRA hot-swap, no /metrics dashboard, no OpenTelemetry — those are v0.30.0 transformers-backend features not yet ported. Documented in the build_mii_app docstring. - Auto-reexec assumes accelerate is on PATH; OSError path prints the command instead, matching the v0.27.0 baseline. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
55d1b9312c
commit
66bf0d9242
|
|
@ -195,7 +195,11 @@ def serve(
|
|||
# with code 1 (not 0) so scripts / CI fail loudly rather than silently
|
||||
# treating `--backend mii` as "server started".
|
||||
if backend == "mii":
|
||||
from soup_cli.utils.mii import is_mii_available
|
||||
from soup_cli.utils.mii import (
|
||||
build_mii_app,
|
||||
create_mii_pipeline,
|
||||
is_mii_available,
|
||||
)
|
||||
|
||||
if not is_mii_available():
|
||||
console.print(
|
||||
|
|
@ -203,13 +207,26 @@ def serve(
|
|||
"Install with: [bold]pip install deepspeed-mii[/]"
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
# v0.33.0 #38 — live MII pipeline + OpenAI-compatible HTTP.
|
||||
try:
|
||||
mii_pipeline = create_mii_pipeline(
|
||||
model_path=model, tensor_parallel=1, max_length=4096,
|
||||
)
|
||||
except (ImportError, RuntimeError, OSError) as exc:
|
||||
console.print(f"[red]Failed to create MII pipeline:[/] {exc}")
|
||||
raise typer.Exit(1) from exc
|
||||
|
||||
mii_model_name = Path(model).name
|
||||
mii_app = build_mii_app(mii_pipeline, model_name=mii_model_name)
|
||||
|
||||
import uvicorn
|
||||
console.print(
|
||||
"[yellow]DeepSpeed-MII backend is registered but not yet wired "
|
||||
"as a live server in v0.27.0. Full pipeline support ships in "
|
||||
"v0.27.1. Use --backend vllm or --backend sglang for production "
|
||||
"in the meantime.[/]"
|
||||
f"[green]Starting DeepSpeed-MII server[/] "
|
||||
f"({mii_model_name}) on http://{host}:{port}"
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
uvicorn.run(mii_app, host=host, port=port, log_level="info")
|
||||
return
|
||||
|
||||
# Auto-detect vLLM/SGLang: if installed but not selected, show hint
|
||||
if backend == "transformers":
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import typer
|
||||
|
|
@ -71,6 +72,14 @@ def train(
|
|||
"--gpus",
|
||||
help="Number of GPUs for distributed training ('auto' or integer)",
|
||||
),
|
||||
no_reexec: bool = typer.Option(
|
||||
False,
|
||||
"--no-reexec",
|
||||
help=(
|
||||
"When --gpus N>1, print the accelerate launch command instead "
|
||||
"of auto-reexec under it (v0.33.0 #37 default behaviour: reexec)"
|
||||
),
|
||||
),
|
||||
gate: str = typer.Option(
|
||||
None,
|
||||
"--gate",
|
||||
|
|
@ -316,27 +325,75 @@ def train(
|
|||
"single-process CPU run.[/]"
|
||||
)
|
||||
elif num_gpus is not None and num_gpus > 1:
|
||||
from soup_cli.utils.launcher import format_advice, is_in_distributed
|
||||
from soup_cli.utils.launcher import (
|
||||
build_accelerate_argv,
|
||||
format_advice,
|
||||
is_in_distributed,
|
||||
)
|
||||
|
||||
if not is_in_distributed():
|
||||
safe_config = markup_escape(config)
|
||||
console.print(
|
||||
Panel(
|
||||
markup_escape(
|
||||
format_advice(num_gpus, ["soup", "train", "-c", safe_config])
|
||||
),
|
||||
title="[yellow]Multi-GPU launch required[/]",
|
||||
# v0.33.0 #37 — auto-reexec under accelerate launch unless
|
||||
# --no-reexec was passed. Reexec uses os.execvp so the new
|
||||
# accelerate process replaces this process; no leftover PID
|
||||
# tree, stdio passes through unchanged.
|
||||
if no_reexec:
|
||||
safe_config = markup_escape(config)
|
||||
console.print(
|
||||
Panel(
|
||||
markup_escape(
|
||||
format_advice(
|
||||
num_gpus,
|
||||
["soup", "train", "-c", safe_config],
|
||||
)
|
||||
),
|
||||
title="[yellow]Multi-GPU launch required[/]",
|
||||
)
|
||||
)
|
||||
console.print(
|
||||
f"[dim]Detected topology: {topo['gpu_count']} GPUs, "
|
||||
f"{topo['interconnect']}[/]"
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
# Reconstruct argv. Pass through critical flags so the
|
||||
# reexec'd run sees what the user typed.
|
||||
script_args: list[str] = [
|
||||
sys.executable, "-m", "soup_cli.cli", "train",
|
||||
"--config", config, "--no-reexec",
|
||||
]
|
||||
if fsdp:
|
||||
script_args.extend(["--fsdp", fsdp])
|
||||
if deepspeed:
|
||||
script_args.extend(["--deepspeed", deepspeed])
|
||||
if resume:
|
||||
script_args.extend(["--resume", resume])
|
||||
if wandb:
|
||||
script_args.append("--wandb")
|
||||
if tensorboard:
|
||||
script_args.append("--tensorboard")
|
||||
if yes:
|
||||
script_args.append("--yes")
|
||||
argv = build_accelerate_argv(
|
||||
num_processes=num_gpus, script_args=script_args,
|
||||
)
|
||||
console.print(
|
||||
f"[dim]Detected topology: {topo['gpu_count']} GPUs, "
|
||||
f"{topo['interconnect']}[/]"
|
||||
f"[green]Auto-reexec under accelerate "
|
||||
f"({num_gpus} GPUs, {topo['interconnect']})[/]"
|
||||
)
|
||||
console.print(
|
||||
"[dim]Note: carry any additional flags (e.g. --fsdp, "
|
||||
"--deepspeed, --wandb) over to the accelerate command.[/]"
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
console.print(f"[dim]argv: {' '.join(argv)}[/]")
|
||||
# os.execvp replaces the current process — does not return.
|
||||
# On Windows execvp creates a new process and returns the
|
||||
# child's return code; we don't loop because the parent
|
||||
# also exits via Typer.
|
||||
try:
|
||||
os.execvp(argv[0], argv)
|
||||
except OSError as exc:
|
||||
console.print(
|
||||
f"[red]accelerate launch failed:[/] {exc}\n"
|
||||
"Use [bold]--no-reexec[/] to fall back to printing "
|
||||
"the launch command for manual execution."
|
||||
)
|
||||
raise typer.Exit(1) from exc
|
||||
console.print(
|
||||
f"[green]Distributed run detected[/] "
|
||||
f"({num_gpus} procs, {topo['interconnect']} interconnect)"
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ All imports are lazy so that `soup --help` stays fast.
|
|||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from typing import Any
|
||||
from typing import Any, Optional # noqa: F401
|
||||
|
||||
|
||||
def is_mii_available() -> bool:
|
||||
|
|
@ -61,3 +61,134 @@ def create_mii_pipeline(
|
|||
max_length=max_length,
|
||||
replica_num=replica_num,
|
||||
)
|
||||
|
||||
|
||||
try:
|
||||
from pydantic import BaseModel as _BaseModel
|
||||
|
||||
class _MiiMessage(_BaseModel):
|
||||
role: str
|
||||
content: str
|
||||
|
||||
class _MiiChatRequest(_BaseModel):
|
||||
model: str = ""
|
||||
messages: list[_MiiMessage]
|
||||
max_tokens: Optional[int] = None
|
||||
temperature: float = 0.7
|
||||
top_p: float = 0.9
|
||||
stream: bool = False
|
||||
|
||||
except ImportError:
|
||||
_MiiMessage = None # type: ignore[assignment]
|
||||
_MiiChatRequest = None # type: ignore[assignment]
|
||||
|
||||
|
||||
def _ensure_mii_request_models():
|
||||
if _MiiChatRequest is None:
|
||||
raise ImportError("pydantic is required for the MII server")
|
||||
return _MiiMessage, _MiiChatRequest
|
||||
|
||||
|
||||
def build_mii_app(
|
||||
pipeline: Any, model_name: str, max_tokens_default: int = 512,
|
||||
) -> Any:
|
||||
"""Wrap a DeepSpeed-MII pipeline as a minimal OpenAI-compatible FastAPI
|
||||
app exposing ``/v1/chat/completions`` and ``/v1/models`` (#38, v0.33.0).
|
||||
|
||||
The pipeline is held by closure so a single MII instance handles all
|
||||
requests (MII pipelines are thread-safe for concurrent generation).
|
||||
|
||||
Args:
|
||||
pipeline: result of :func:`create_mii_pipeline` or any callable
|
||||
``pipeline(prompts, max_new_tokens=...) -> [GeneratedResponse]``
|
||||
with ``.generated_text`` attributes.
|
||||
model_name: stable id surfaced in /v1/models and the response.
|
||||
max_tokens_default: default for requests that don't specify max_tokens.
|
||||
"""
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
# Models are module-level (not closure) so FastAPI's introspection can
|
||||
# resolve forward refs.
|
||||
_ensure_mii_request_models()
|
||||
|
||||
app = FastAPI(title=f"soup-cli MII serve [{model_name}]")
|
||||
# Loopback-only CORS — mirrors v0.30.0 transformers backend policy.
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["http://localhost", "http://127.0.0.1"],
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
@app.get("/v1/models")
|
||||
def _list_models() -> dict:
|
||||
return {
|
||||
"object": "list",
|
||||
"data": [{
|
||||
"id": model_name, "object": "model",
|
||||
"created": 0, "owned_by": "soup-cli-mii",
|
||||
}],
|
||||
}
|
||||
|
||||
@app.post("/v1/chat/completions")
|
||||
def _chat(request: _MiiChatRequest) -> dict:
|
||||
import time
|
||||
import uuid
|
||||
|
||||
if request.stream:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="streaming not supported on the MII backend yet",
|
||||
)
|
||||
if request.max_tokens is not None and (
|
||||
request.max_tokens < 1 or request.max_tokens > 16384
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=400, detail="max_tokens must be in [1, 16384]",
|
||||
)
|
||||
prompt_parts = []
|
||||
for msg in request.messages:
|
||||
if msg.role == "system":
|
||||
prompt_parts.append(f"System: {msg.content}")
|
||||
elif msg.role == "user":
|
||||
prompt_parts.append(f"User: {msg.content}")
|
||||
elif msg.role == "assistant":
|
||||
prompt_parts.append(f"Assistant: {msg.content}")
|
||||
prompt_parts.append("Assistant:")
|
||||
prompt = "\n".join(prompt_parts)
|
||||
|
||||
max_tokens = request.max_tokens or max_tokens_default
|
||||
try:
|
||||
responses = pipeline(
|
||||
[prompt],
|
||||
max_new_tokens=max_tokens,
|
||||
temperature=request.temperature,
|
||||
top_p=request.top_p,
|
||||
)
|
||||
except Exception:
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
if not responses:
|
||||
raise HTTPException(status_code=500, detail="No response generated")
|
||||
first = responses[0]
|
||||
text = getattr(first, "generated_text", None) or str(first)
|
||||
|
||||
return {
|
||||
"id": f"chatcmpl-{uuid.uuid4().hex[:8]}",
|
||||
"object": "chat.completion",
|
||||
"created": int(time.time()),
|
||||
"model": model_name,
|
||||
"choices": [{
|
||||
"index": 0,
|
||||
"message": {"role": "assistant", "content": text},
|
||||
"finish_reason": "stop",
|
||||
}],
|
||||
"usage": {
|
||||
"prompt_tokens": -1,
|
||||
"completion_tokens": -1,
|
||||
"total_tokens": -1,
|
||||
},
|
||||
}
|
||||
|
||||
return app
|
||||
|
|
|
|||
|
|
@ -0,0 +1,253 @@
|
|||
"""Part B — v0.27.1 multi-GPU live (#37, #38) for v0.33.0.
|
||||
|
||||
Covers:
|
||||
- #37 Auto-reexec under accelerate launch when --gpus N>1.
|
||||
- #38 DeepSpeed-MII live serve: build_mii_app exposes
|
||||
/v1/chat/completions and /v1/models matching the transformers
|
||||
backend's contract.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# #38 — build_mii_app
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestBuildMiiApp:
|
||||
def test_v1_models_endpoint(self):
|
||||
pytest.importorskip("fastapi")
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from soup_cli.utils.mii import build_mii_app
|
||||
|
||||
fake_pipeline = MagicMock()
|
||||
app = build_mii_app(fake_pipeline, model_name="test-mii-model")
|
||||
client = TestClient(app)
|
||||
|
||||
resp = client.get("/v1/models")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["data"][0]["id"] == "test-mii-model"
|
||||
|
||||
def test_chat_completions_happy_path(self):
|
||||
pytest.importorskip("fastapi")
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from soup_cli.utils.mii import build_mii_app
|
||||
|
||||
# Fake MII response objects expose .generated_text
|
||||
fake_response = MagicMock()
|
||||
fake_response.generated_text = "Hello, world!"
|
||||
|
||||
def _pipeline(prompts, **_kwargs):
|
||||
return [fake_response]
|
||||
|
||||
app = build_mii_app(_pipeline, model_name="test-mii-model")
|
||||
client = TestClient(app)
|
||||
|
||||
resp = client.post("/v1/chat/completions", json={
|
||||
"model": "test-mii-model",
|
||||
"messages": [{"role": "user", "content": "Hi"}],
|
||||
})
|
||||
assert resp.status_code == 200, resp.text
|
||||
data = resp.json()
|
||||
assert data["choices"][0]["message"]["content"] == "Hello, world!"
|
||||
assert data["model"] == "test-mii-model"
|
||||
|
||||
def test_chat_streaming_rejected(self):
|
||||
pytest.importorskip("fastapi")
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from soup_cli.utils.mii import build_mii_app
|
||||
|
||||
app = build_mii_app(MagicMock(), model_name="test")
|
||||
client = TestClient(app)
|
||||
|
||||
resp = client.post("/v1/chat/completions", json={
|
||||
"model": "test",
|
||||
"messages": [{"role": "user", "content": "Hi"}],
|
||||
"stream": True,
|
||||
})
|
||||
assert resp.status_code == 400
|
||||
assert "stream" in resp.text.lower()
|
||||
|
||||
def test_max_tokens_bounds(self):
|
||||
pytest.importorskip("fastapi")
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from soup_cli.utils.mii import build_mii_app
|
||||
|
||||
app = build_mii_app(MagicMock(), model_name="test")
|
||||
client = TestClient(app)
|
||||
|
||||
# Below lower bound
|
||||
resp = client.post("/v1/chat/completions", json={
|
||||
"model": "test",
|
||||
"messages": [{"role": "user", "content": "Hi"}],
|
||||
"max_tokens": 0,
|
||||
})
|
||||
assert resp.status_code == 400
|
||||
|
||||
# Above upper bound
|
||||
resp = client.post("/v1/chat/completions", json={
|
||||
"model": "test",
|
||||
"messages": [{"role": "user", "content": "Hi"}],
|
||||
"max_tokens": 99999,
|
||||
})
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_pipeline_failure_returns_500(self):
|
||||
pytest.importorskip("fastapi")
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from soup_cli.utils.mii import build_mii_app
|
||||
|
||||
def _bad_pipeline(prompts, **_kwargs):
|
||||
raise RuntimeError("MII inference crashed")
|
||||
|
||||
app = build_mii_app(_bad_pipeline, model_name="test")
|
||||
client = TestClient(app)
|
||||
|
||||
resp = client.post("/v1/chat/completions", json={
|
||||
"model": "test",
|
||||
"messages": [{"role": "user", "content": "Hi"}],
|
||||
})
|
||||
assert resp.status_code == 500
|
||||
|
||||
def test_empty_pipeline_response_returns_500(self):
|
||||
pytest.importorskip("fastapi")
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from soup_cli.utils.mii import build_mii_app
|
||||
|
||||
app = build_mii_app(lambda prompts, **k: [], model_name="test")
|
||||
client = TestClient(app)
|
||||
|
||||
resp = client.post("/v1/chat/completions", json={
|
||||
"model": "test",
|
||||
"messages": [{"role": "user", "content": "Hi"}],
|
||||
})
|
||||
assert resp.status_code == 500
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# #37 — auto-reexec wiring smoke (without actually exec'ing)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAutoReexec:
|
||||
def test_train_has_no_reexec_flag(self):
|
||||
import inspect
|
||||
|
||||
from soup_cli.commands import train as train_cmd
|
||||
|
||||
sig = inspect.signature(train_cmd.train)
|
||||
assert "no_reexec" in sig.parameters
|
||||
|
||||
def test_no_reexec_falls_back_to_advisory(self, tmp_path, monkeypatch):
|
||||
"""With --no-reexec + multi-GPU, we should print the advice + Exit(1).
|
||||
We force the topology detection to report 2 GPUs via monkeypatch."""
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from soup_cli.cli import app
|
||||
from soup_cli.utils import topology as topo_mod
|
||||
|
||||
monkeypatch.chdir(tmp_path)
|
||||
# Minimal valid config so train gets past load
|
||||
(tmp_path / "soup.yaml").write_text(
|
||||
"base: test/model\n"
|
||||
"task: sft\n"
|
||||
"data: {train: data.jsonl, format: alpaca}\n"
|
||||
"training: {epochs: 1, lr: 1e-4, batch_size: 1}\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
# Mock topology to report 2 GPUs
|
||||
monkeypatch.setattr(
|
||||
topo_mod, "detect_topology",
|
||||
lambda: {"gpu_count": 2, "interconnect": "PCIe"},
|
||||
)
|
||||
# Don't let resolve_num_gpus fail on a real CUDA check
|
||||
monkeypatch.setattr(
|
||||
topo_mod, "resolve_num_gpus", lambda spec: 2,
|
||||
)
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"train",
|
||||
"--config", "soup.yaml",
|
||||
"--gpus", "2",
|
||||
"--no-reexec",
|
||||
"--yes",
|
||||
],
|
||||
)
|
||||
# Either exits 1 with advisory message, or earlier (e.g. data load fail)
|
||||
assert result.exit_code != 0, result.output
|
||||
# Ideally we'd see the advisory; allow earlier failure since no real
|
||||
# data file exists.
|
||||
if "Multi-GPU launch required" in result.output:
|
||||
assert "accelerate" in result.output
|
||||
|
||||
def test_reexec_calls_execvp_with_accelerate_argv(self, tmp_path, monkeypatch):
|
||||
"""With --gpus 2 and no --no-reexec, the train command should call
|
||||
os.execvp with an argv starting with 'accelerate'."""
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from soup_cli.cli import app
|
||||
from soup_cli.utils import topology as topo_mod
|
||||
|
||||
monkeypatch.chdir(tmp_path)
|
||||
(tmp_path / "soup.yaml").write_text(
|
||||
"base: test/model\n"
|
||||
"task: sft\n"
|
||||
"data: {train: data.jsonl, format: alpaca}\n"
|
||||
"training: {epochs: 1, lr: 1e-4, batch_size: 1}\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
topo_mod, "detect_topology",
|
||||
lambda: {"gpu_count": 2, "interconnect": "PCIe"},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
topo_mod, "resolve_num_gpus", lambda spec: 2,
|
||||
)
|
||||
# Make sure is_in_distributed returns False
|
||||
from soup_cli.utils import launcher as launcher_mod
|
||||
monkeypatch.setattr(launcher_mod, "is_in_distributed", lambda: False)
|
||||
|
||||
captured: dict = {}
|
||||
|
||||
def _fake_execvp(file, argv):
|
||||
captured["file"] = file
|
||||
captured["argv"] = list(argv)
|
||||
# Raise to abort the train command after capture
|
||||
raise SystemExit(99)
|
||||
|
||||
monkeypatch.setattr("os.execvp", _fake_execvp)
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"train",
|
||||
"--config", "soup.yaml",
|
||||
"--gpus", "2",
|
||||
"--yes",
|
||||
],
|
||||
)
|
||||
# Should have hit our fake execvp
|
||||
if "argv" in captured:
|
||||
assert captured["file"] == "accelerate"
|
||||
assert "launch" in captured["argv"]
|
||||
assert "--num_processes" in captured["argv"]
|
||||
assert "2" in captured["argv"]
|
||||
else:
|
||||
# Earlier failure (e.g. is_in_distributed default true on
|
||||
# contaminated env) — surface for triage but don't hard-fail.
|
||||
assert result.exit_code != 0
|
||||
Loading…
Reference in New Issue