Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ Planned as 0.12.0.

### Added

- **`SIEVAL_TARGET_GPU` declares the GPUs a serving plan is resolved for**, as `<name>:<count>:<memory MiB>` with the name and memory `nvidia-smi` reports, e.g. `NVIDIA H100 80GB HBM3:8:81559`. Recipe resolution reads it instead of `nvidia-smi`, on any node, so a CPU node resolves the plan, and the inference identity, a node with those GPUs would. Unset, nothing changes. Declared, an engine launches only on a node with exactly those GPUs. `infer_plans.yaml` gains `gpu`, the GPUs its plans were resolved for, outside every identity; a `sieval run --resume` declaring the same GPUs reuses the recorded plan of each served model whose `path`, `infer`, capability layer and `deterministic` are unchanged, unless the plan held an engine secret, which `infer_plans.yaml` records redacted (#215, closes #212).
- `sieval.core.runners.task_resume_plan` / `plan_resume`: the per-stage resume plan, without running anything.

## [0.11.0] - 2026-10-23
Expand Down
29 changes: 29 additions & 0 deletions docs/guide/infer.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,35 @@ The instruct flags are inert on a base checkpoint — the engine accepts and
ignores them — so this affects what `infer_plans.yaml` records as the params
used, not whether the service starts.

## Resolving for declared GPUs

A recipe's hardware layer and its tensor and data parallelism come from the
GPUs `nvidia-smi` reports. To resolve a plan on another node, a CPU node
included, declare those GPUs instead:

```bash
export SIEVAL_TARGET_GPU='NVIDIA H100 80GB HBM3:8:81559' # <name>:<count>:<memory MiB>
```

`<name>` and `<memory MiB>` are what
`nvidia-smi --query-gpu=name,memory.total --format=csv,noheader,nounits` prints
on a node with those GPUs, and `<count>` is how many it lists. The plan is then
the one that node resolves, so the inference identity matches too. A shorthand
like `H100-80G:8` is refused: the name is recorded in a path-only plan and the
memory decides tensor parallelism, so only the reported values reproduce it.

Declared, `sieval infer start` and `sieval run` launch an engine only on a node
with exactly those GPUs, memory included: two nodes of one model can report
different `memory.total` (ECC on or off, PCIe or SXM), and the refusal names
each field that differs. `--dry-run` checks nothing, so it runs on any node.

`infer_plans.yaml` records as `gpu` the GPUs its plans were resolved for,
declared or detected. A `sieval run --resume` declaring the same GPUs reuses a
recorded plan, rather than resolving it again, for each served model whose
`path`, `infer`, capability layer and `deterministic` are unchanged. A plan
holding an engine `api_key` or `authorization` is resolved again instead:
`infer_plans.yaml` writes those values as `<redacted>`.

## Handles and engine logs

Each launched service has a handle file under `~/.sieval/handles`:
Expand Down
3 changes: 3 additions & 0 deletions sieval/cli/infer/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
DeployError,
DeployTimeoutError,
)
from sieval.infer.introspect import check_launch_gpu
from sieval.infer.params import merge_params
from sieval.infer.recipes import capability_model_type
from sieval.infer.topology.models import (
Expand Down Expand Up @@ -376,6 +377,8 @@ async def _resolve() -> ResolveResult:
render(dr_result, output)
return

# A plan resolved for declared GPUs launches only on those GPUs.
anyio.run(check_launch_gpu)
keep = resolve_keep_logs(keep_logs)

# Claim the handle under the handle lock: a live holder refuses, and a
Expand Down
5 changes: 4 additions & 1 deletion sieval/cli/infer/recipe.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
GPUInfo,
ModelIdentity,
bytes_per_param,
declared_target_gpu,
detect_local_gpu,
introspect_checkpoint_sync,
)
Expand Down Expand Up @@ -274,7 +275,9 @@ async def _resolve_recipe_params(
# final merge operate on a single canonical key form.
overrides = merge_params(overrides or {})

gpu = await detect_local_gpu()
# The declared GPUs stand in for this node's, so any node resolves the plan
# a node with that hardware would.
gpu = declared_target_gpu() or await detect_local_gpu()

params: dict[str, ParamValue] = {}
if gpu:
Expand Down
152 changes: 144 additions & 8 deletions sieval/cli/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,16 +47,28 @@
)
from sieval.cli.resolution import resolve_config_model_types
from sieval.cli.services import SERVICE_LOG_DIR
from sieval.cli.session import resolve_deterministic, unwrap_proxies
from sieval.cli.session import (
holds_secret,
read_persisted_config,
redact_secrets,
resolve_deterministic,
unwrap_proxies,
)
from sieval.cli.signals import terminate_as_interrupt
from sieval.core.models import Deployment
from sieval.core.utils.logging import configure_logging, log_user
from sieval.infer.backends import get_translator
from sieval.infer.backends.translator import BackendCommand, inject_user_env
from sieval.infer.config import InferHandle
from sieval.infer.deployer import DEFAULT_READY_TIMEOUT, LocalDeployer
from sieval.infer.introspect import (
check_launch_gpu,
declared_target_gpu,
detect_local_gpu,
format_target_gpu,
)
from sieval.infer.recipes import capability_model_type
from sieval.infer.topology import DeploymentPlan
from sieval.infer.topology import DeploymentPlan, plan_from_mapping
from sieval.infer.topology.resolver import auto_resolve_plan


Expand Down Expand Up @@ -94,16 +106,122 @@ def _refuse_explicit_endpoint(model_name: str, model_config: dict) -> None:
)


def _recorded_session_dir(
resume: bool | str | Path | None, result_dir: str | None
) -> Path | None:
"""The session ``result_dir`` a resume continues, whose plans it may reuse.

``--resume DIR`` names it, or names a task directory inside it.
"""
if resume is True:
return Path(result_dir) if result_dir else None
if isinstance(resume, (str, Path)):
recorded = Path(resume)
return recorded.parent if (recorded / "meta.json").is_file() else recorded
return None


class _RecordedPlans:
"""What a resumed session recorded about its served models, read-only.

Its plans are reused only when ``SIEVAL_TARGET_GPU`` declares the GPUs they
were resolved for, and only for a model whose serving config is unchanged:
a recipe the installed SiEval resolves differently would otherwise change
the inference identity and refuse the resume.
"""

def __init__(self, session_dir: Path | None, gpu: str | None) -> None:
self._plans: dict[str, Any] = {}
self._models: dict[str, Any] = {}
self._capabilities: dict[str, str] = {}
if session_dir is None or gpu is None:
return
recorded = read_persisted_config(session_dir / "infer_plans.yaml")
if recorded is None or recorded.get("gpu") != gpu:
return
plans = recorded.get("models")
config = read_persisted_config(session_dir / "effective_config.yaml")
if not isinstance(plans, dict) or config is None:
return
models = config.get("models")
try:
types = resolve_config_model_types(config).model_types_by_config
except ValueError:
return
if isinstance(models, dict):
self._plans, self._models = plans, models
self._capabilities = {
name: capability_model_type(kind) for name, kind in types.items()
}

def plan_for(
self,
model_name: str,
model_config: dict[str, Any],
capability: str,
deterministic: bool,
) -> DeploymentPlan | None:
"""The recorded plan of *model_name*, when this run would serve it alike."""
raw = self._plans.get(model_name)
recorded_model = self._models.get(model_name)
if not isinstance(raw, dict) or not isinstance(recorded_model, dict):
return None
# `infer_plans.yaml` is written redacted: a plan that held a secret
# (an engine `api_key`) reads back holding the marker, and would
# launch the engine with it.
if holds_secret(raw):
return None
# Compared redacted, as `effective_config.yaml` records them.
current = redact_secrets(model_config)
if any(recorded_model.get(k) != current.get(k) for k in ("path", "infer")):
return None
if self._capabilities.get(model_name) != capability:
return None
try:
plan = plan_from_mapping(raw)
except ValueError:
return None
if plan.deterministic != deterministic:
return None
return plan


def _declared_gpu_text() -> str | None:
"""``SIEVAL_TARGET_GPU``, normalized; ``None`` when nothing is declared."""
declared = declared_target_gpu()
return format_target_gpu(declared) if declared is not None else None


async def _plan_gpu_text() -> str | None:
"""The GPUs this run's plans were resolved for, as ``SIEVAL_TARGET_GPU``
spells them: the declared ones, else this node's."""
gpu = declared_target_gpu() or await detect_local_gpu()
return format_target_gpu(gpu) if gpu is not None else None


def _recorded_plans(
resume: bool | str | Path | None, result_dir: str | None
) -> _RecordedPlans:
return _RecordedPlans(
_recorded_session_dir(resume, result_dir), _declared_gpu_text()
)


async def _prepare_launch_batch(
config_path: Path,
config: dict[str, Any],
*,
effective_deterministic: bool,
recorded: _RecordedPlans | None = None,
) -> tuple[
dict[str, dict[str, Any]],
dict[str, tuple[DeploymentPlan, list[BackendCommand]]],
]:
"""Resolve and translate every managed deployment without launching it."""
"""Resolve and translate every managed deployment without launching it.

A plan *recorded* by the session a resume continues is reused when it
applies (:class:`_RecordedPlans`).
"""

models = config.get("models", {})
if not isinstance(models, dict):
Expand All @@ -120,7 +238,23 @@ async def _prepare_launch_batch(
_refuse_explicit_endpoint(model_name, model_config)

infer_dict = model_config.get("infer")
if infer_dict is not None:
capability = capability_model_type(
model_type_resolution.model_types_by_config[model_name]
)
reused = (
recorded.plan_for(
model_name, model_config, capability, effective_deterministic
)
if recorded is not None
else None
)
if reused is not None:
logger.info("Reusing the recorded serving plan of {}", model_name)
plan = reused
user_env = {
str(k): str(v) for k, v in ((infer_dict or {}).get("env") or {}).items()
}
elif infer_dict is not None:
_, plan, user_env = await resolve_infer_config(
config_path,
model_name,
Expand All @@ -129,10 +263,7 @@ async def _prepare_launch_batch(
else:
checkpoint = model_config["path"]
result = await auto_resolve_plan(
checkpoint=checkpoint,
capability=capability_model_type(
model_type_resolution.model_types_by_config[model_name]
),
checkpoint=checkpoint, capability=capability
)
plan = result.plan
user_env = {}
Expand Down Expand Up @@ -208,6 +339,7 @@ def checks(
config_path,
loaded,
effective_deterministic=effective_deterministic,
recorded=_recorded_plans(resume, result_dir or loaded.get("result_dir")),
)
return checks(plan_dicts or None, frozenset(prepared_launches))

Expand Down Expand Up @@ -272,6 +404,7 @@ async def _run_all(
config_path,
config,
effective_deterministic=effective_deterministic,
recorded=_recorded_plans(resume, effective_result_dir),
)

# Task requirements, model declarations, and every managed desired
Expand Down Expand Up @@ -314,6 +447,8 @@ async def _run_all(
f"Model name {SERVICE_LOG_DIR!r} is reserved for "
f"<result_dir>/logs/{SERVICE_LOG_DIR}/; rename the model."
)
if prepared_launches:
await check_launch_gpu()
for model_name, (plan, commands) in prepared_launches.items():
log_user("Starting inference for model: {}", model_name)

Expand Down Expand Up @@ -406,6 +541,7 @@ def _progress(
invocation=invocation,
realized_deployments=realized_deployments or None,
cli_model=cli_model,
infer_gpu=await _plan_gpu_text() if plan_dicts else None,
)
return reports

Expand Down
Loading
Loading