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:
Alpamys 2026-04-27 19:41:54 +05:00
parent 55d1b9312c
commit 66bf0d9242
4 changed files with 480 additions and 22 deletions

View File

@ -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":

View File

@ -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)"

View File

@ -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

253
tests/test_part_b.py Normal file
View File

@ -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