Step-5-Preview / inference /generate.py
csy0225's picture
Upload 10 files
43fa7aa verified
Raw History Blame Contribute Delete
36.1 kB
"""Greedy Step5 inference over pre-sharded tensor-parallel weights.
This standalone entry point loads one ``model-r{rank}.safetensors`` file per
``torchrun`` process and uses :meth:`Step5ForCausalLM.generate_greedy`, whose
vocabulary-parallel selection all-gathers every rank's contiguous vocabulary
window before taking argmax.
Example::
torchrun --standalone --nproc-per-node=8 inference/generate.py \
--tp-dir /path/to/step5_tp8/tp8 \
--ep-size 8 \
--checkpoint /path/to/original_step5_checkpoint \
--prompt "请介绍一下张量并行。" \
--max-new-tokens 128
``--prompt-json`` accepts either a JSON list of token ids or an object containing
``prompt_ids``. This bypasses tokenization entirely and is useful for exact
parity runs. ``--prompt-json-batch`` accepts either a JSON list of token-id
lists or ``{"prompts": [{"prompt_ids": [...]}, ...]}`` and sends the packed
batch through one ``generate_greedy`` call. Multi-request output is
``{"batch_size": N, "results": [...]}``; a one-request batch retains the
original single-request JSON shape. The original checkpoint is only a tokenizer
source here; model weights always come from ``--tp-dir``.
"""
from __future__ import annotations
import argparse
import json
import os
from pathlib import Path
import sys
from typing import Any, Sequence
import torch
import torch.distributed as dist
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--tp-dir",
required=True,
help="Directory containing config-rN.json and model-rN.safetensors.",
)
prompt_group = parser.add_mutually_exclusive_group(required=True)
prompt_group.add_argument(
"--prompt",
help="User prompt text. A one-turn chat template is applied by default.",
)
prompt_group.add_argument(
"--prompt-json",
help="JSON list of exact token ids, or an object containing `prompt_ids`.",
)
prompt_group.add_argument(
"--prompt-json-batch",
help="Packed-batch JSON: either a list of token-id lists or an object "
"containing `prompts`, whose entries contain `prompt_ids`.",
)
parser.add_argument(
"--tokenizer",
"--tokenizer-path",
dest="tokenizer",
default=None,
help="Local tokenizer directory. Overrides --checkpoint.",
)
parser.add_argument(
"--checkpoint",
"--ckpt-path",
dest="checkpoint",
default=None,
help="Original local Step5 checkpoint directory used as the tokenizer "
"source. TP model weights are still loaded only from --tp-dir.",
)
parser.add_argument(
"--image-inputs",
help="Local safetensors containing pixel_values, optional patch_pixel_values/num_patches, "
"or already projected image_embeds. Use with exact prompt IDs containing image placeholders.",
)
parser.add_argument(
"--raw-prompt",
action="store_true",
help="Tokenize --prompt as plain text instead of applying the chat template.",
)
parser.add_argument(
"--max-new-tokens",
type=int,
default=128,
help="Maximum number of greedily decoded tokens (default: 128).",
)
parser.add_argument(
"--device",
default="cuda",
help="Device type/string; torchrun normally uses cuda (default: cuda).",
)
parser.add_argument(
"--ep-size",
"--expert-parallel-size",
dest="ep_size",
type=int,
default=8,
help="Routed-expert sharding degree (default: 8). The current "
"co-located topology requires EP size to equal TP/world size.",
)
return parser
def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
parser = build_parser()
args = parser.parse_args(argv)
if args.max_new_tokens < 0:
parser.error("--max-new-tokens must be non-negative")
if args.ep_size < 1:
parser.error("--ep-size must be positive")
if args.raw_prompt and args.prompt is None:
parser.error("--raw-prompt is only valid with --prompt")
if args.image_inputs and args.prompt is not None:
parser.error("--image-inputs requires --prompt-json or --prompt-json-batch")
return args
def validate_parallel_topology(
config: Any,
*,
world: int,
requested_ep_size: int,
rank: int = 0,
) -> int:
"""Validate and return the co-located expert-parallel size.
Older validated TP shards predate the explicit
``expert_parallel_size`` metadata, so its only supported fallback is the
checkpoint TP size. Values read from JSON are checked without coercion:
accepting (for example) ``8.9`` as EP=8 would defeat the fail-closed
topology contract.
"""
config_tp_size = getattr(config, "tp_size", None)
if (
isinstance(config_tp_size, bool)
or not isinstance(config_tp_size, int)
or config_tp_size < 1
):
raise RuntimeError(
f"rank {rank}: checkpoint tp_size must be a positive integer, "
f"got {config_tp_size!r}"
)
if config_tp_size != world:
raise RuntimeError(
f"rank {rank}: checkpoint TP={config_tp_size}, "
f"torchrun world size={world}"
)
config_ep_size = getattr(config, "expert_parallel_size", config_tp_size)
if (
isinstance(config_ep_size, bool)
or not isinstance(config_ep_size, int)
or config_ep_size < 1
):
raise RuntimeError(
"checkpoint expert_parallel_size must be a positive integer, "
f"got {config_ep_size!r}"
)
if config_ep_size != config_tp_size:
raise RuntimeError(
"independent TP and EP groups are not implemented: checkpoint "
f"EP={config_ep_size} must equal TP={config_tp_size}"
)
if requested_ep_size != config_ep_size:
raise RuntimeError(
f"--ep-size={requested_ep_size} does not match checkpoint/runtime "
f"EP={config_ep_size}. This release co-locates TP and EP on the "
"same ranks."
)
return config_ep_size
def load_prompt_json(path: str | os.PathLike[str]) -> list[int]:
"""Load and validate the exact token ids in a parity prompt artifact."""
payload = json.loads(Path(path).read_text(encoding="utf-8"))
values = payload.get("prompt_ids") if isinstance(payload, dict) else payload
if not isinstance(values, list):
raise ValueError(
f"prompt JSON {path} must be a list or an object containing a `prompt_ids` list"
)
if not values:
raise ValueError(f"prompt JSON {path} contains no token ids")
prompt_ids: list[int] = []
for index, value in enumerate(values):
# JSON bool is an int subclass in Python, but never a meaningful token id.
if isinstance(value, bool) or not isinstance(value, int):
raise ValueError(
f"prompt JSON {path} token {index} is not an integer: {value!r}"
)
if value < 0:
raise ValueError(
f"prompt JSON {path} token {index} is negative: {value}"
)
prompt_ids.append(value)
return prompt_ids
def load_prompt_json_batch(path: str | os.PathLike[str]) -> list[list[int]]:
"""Load exact token ids for a packed batch.
Accepted schemas are either ``[[1, 2], [3, 4, 5]]`` or
``{"prompts": [{"prompt_ids": [1, 2]}, {"prompt_ids": [3, 4, 5]}]}``.
Empty batches and empty requests are rejected because neither can be
represented by the packed model/cache contract.
"""
payload = json.loads(Path(path).read_text(encoding="utf-8"))
object_schema = isinstance(payload, dict)
prompts = payload.get("prompts") if object_schema else payload
if not isinstance(prompts, list):
raise ValueError(
f"prompt batch JSON {path} must be a list of lists or an object "
"containing a `prompts` list"
)
if not prompts:
raise ValueError(f"prompt batch JSON {path} contains no prompts")
batch: list[list[int]] = []
for prompt_index, prompt in enumerate(prompts):
if object_schema:
if not isinstance(prompt, dict):
raise ValueError(
f"prompt batch JSON {path} prompt {prompt_index} must be "
"an object containing `prompt_ids`"
)
values = prompt.get("prompt_ids")
else:
values = prompt
if not isinstance(values, list):
raise ValueError(
f"prompt batch JSON {path} prompt {prompt_index} must contain "
f"a token-id list"
)
if not values:
raise ValueError(
f"prompt batch JSON {path} prompt {prompt_index} contains no token ids"
)
prompt_ids: list[int] = []
for token_index, value in enumerate(values):
if isinstance(value, bool) or not isinstance(value, int):
raise ValueError(
f"prompt batch JSON {path} prompt {prompt_index} token "
f"{token_index} is not an integer: {value!r}"
)
if value < 0:
raise ValueError(
f"prompt batch JSON {path} prompt {prompt_index} token "
f"{token_index} is negative: {value}"
)
prompt_ids.append(value)
batch.append(prompt_ids)
return batch
def resolve_tokenizer_source(
*,
tp_dir: str | os.PathLike[str],
tokenizer: str | None,
checkpoint: str | None,
) -> str | None:
"""Resolve explicit tokenizer options, then the pre-shard default layout.
``convert.py`` writes weights under ``OUT/tp8`` and tokenizer assets under
``OUT/tokenizer_files``. Explicit values are local directories and are
returned here before the loader validates their tokenizer files.
"""
if tokenizer:
return tokenizer
if checkpoint:
return checkpoint
tp_path = Path(tp_dir).expanduser()
candidates = (
tp_path / "tokenizer_files",
tp_path.parent / "tokenizer_files",
)
for candidate in candidates:
if candidate.is_dir():
return str(candidate)
return None
def tokenize_text(tokenizer: Any, text: str, *, raw_prompt: bool) -> list[int]:
"""Tokenize one prompt using the checkpoint's chat template or raw text."""
if raw_prompt:
encoded = tokenizer(text, add_special_tokens=True)
values = encoded.input_ids
else:
values = tokenizer.apply_chat_template(
[{"role": "user", "content": text}],
tokenize=True,
add_generation_prompt=True,
return_dict=False,
)
if hasattr(values, "input_ids"):
values = values.input_ids
elif isinstance(values, dict) and "input_ids" in values:
values = values["input_ids"]
if hasattr(values, "ids"):
values = values.ids
if isinstance(values, list) and len(values) == 1 and isinstance(values[0], list):
values = values[0]
if isinstance(values, torch.Tensor):
values = values.reshape(-1).tolist()
if not isinstance(values, list):
raise TypeError(
"tokenizer returned unsupported token IDs: "
f"{type(values).__name__}"
)
prompt_ids = [int(token) for token in values]
if not prompt_ids:
raise ValueError("tokenizer produced an empty prompt")
if any(token < 0 for token in prompt_ids):
raise ValueError("tokenizer produced a negative token id")
return prompt_ids
def validate_prompt_ids(
prompt_ids: Sequence[int],
*,
vocab_size: int,
max_position_embeddings: int,
max_new_tokens: int,
image_token_id: int | None = None,
) -> None:
if not prompt_ids:
raise ValueError("prompt must contain at least one token")
for index, token in enumerate(prompt_ids):
if token < 0 or (token >= vocab_size and token != image_token_id):
raise ValueError(
f"prompt token {index}={token} is outside vocabulary [0, {vocab_size})"
)
requested = len(prompt_ids) + max_new_tokens
if requested > max_position_embeddings:
raise ValueError(
f"prompt ({len(prompt_ids)} tokens) + completion ({max_new_tokens}) "
f"exceeds max_position_embeddings={max_position_embeddings}"
)
def _load_rank_config(tp_dir: str, rank: int):
# Lazy imports keep parser/helper tests independent of transformers and Triton.
from model import config_from_dict
path = Path(tp_dir) / f"config-r{rank}.json"
if not path.is_file():
raise FileNotFoundError(f"rank {rank}: missing TP config {path}")
payload = json.loads(path.read_text(encoding="utf-8"))
return config_from_dict(payload)
def _load_tokenizer(source: str, *, require_chat_template: bool = False):
source_path = Path(source).expanduser()
if not source_path.is_dir():
raise FileNotFoundError(
f"tokenizer source must be a local directory: {source_path}"
)
required_files = (
"tokenizer.json",
"tokenizer_config.json",
)
missing = [
name for name in required_files if not (source_path / name).is_file()
]
if missing:
raise FileNotFoundError(
f"tokenizer source is missing required file(s): "
f"{', '.join(missing)}"
)
from transformers import PreTrainedTokenizerFast
# Load Step-5's serialized fast tokenizer directly. Automatic class selection
# in Transformers 5.15 can collapse long byte-level BPE prompts. The generic
# fast loader preserves the tokenizer.json model, pre-tokenizer, and decoder
# and is ID-identical to the validated Transformers 4.57.6 loader.
tokenizer = PreTrainedTokenizerFast.from_pretrained(
str(source_path),
trust_remote_code=True,
local_files_only=True,
)
if require_chat_template and not getattr(tokenizer, "chat_template", None):
raise ValueError(
"tokenizer has no chat template; define `chat_template` in "
"tokenizer_config.json or provide chat_template.jinja"
)
return tokenizer
def _load_exact_id_tokenizer(
source: str | None,
*,
explicit_source: bool,
):
"""Optionally load the tokenizer used only to decode exact-ID completions."""
if source is None:
return None
try:
return _load_tokenizer(source, require_chat_template=False)
except FileNotFoundError:
if explicit_source:
raise
return None
def resolve_generation_eos_token_ids(
config_eos_token_id: Any,
tokenizer: Any | None,
) -> set[int]:
"""Union checkpoint and tokenizer EOS ids for production stopping.
Step5's converted TP config can retain base-model EOS ids while the chat
tokenizer terminates assistant turns with a different ``<|im_end|>`` id.
Passing only the config values to ``generate_greedy`` would continue past
the tokenizer's real end-of-turn marker. Keep both sets rather than
replacing either source.
"""
result: set[int] = set()
def add(value: Any, *, label: str) -> None:
if value is None:
return
values = value if isinstance(value, (list, tuple, set)) else [value]
for token in values:
if isinstance(token, bool) or not isinstance(token, int) or token < 0:
raise ValueError(
f"{label} must contain non-negative integer token ids, "
f"got {token!r}"
)
result.add(int(token))
add(config_eos_token_id, label="config eos_token_id")
if tokenizer is not None:
add(
getattr(tokenizer, "eos_token_id", None),
label="tokenizer eos_token_id",
)
if not result:
raise ValueError("generation has no EOS token ids")
return result
def _broadcast_error(error: str | None, *, device: torch.device) -> None:
"""Make a rank-0 prompt preparation error visible before any peer blocks."""
payload: list[str | None] = [error]
kwargs: dict[str, Any] = {}
if device.type == "cuda":
kwargs["device"] = device
dist.broadcast_object_list(payload, src=0, **kwargs)
if payload[0] is not None:
raise RuntimeError(f"rank 0 failed to prepare the prompt: {payload[0]}")
def broadcast_eos_token_ids(
eos_token_ids: set[int] | None,
*,
rank: int,
device: torch.device,
) -> set[int]:
"""Broadcast rank 0's effective config+tokenizer EOS set to every TP rank."""
if rank == 0:
if not eos_token_ids:
raise ValueError("rank 0 must provide at least one EOS token id")
values = sorted(eos_token_ids)
length = torch.tensor([len(values)], dtype=torch.int64, device=device)
else:
values = []
length = torch.zeros(1, dtype=torch.int64, device=device)
dist.broadcast(length, src=0)
count = int(length.item())
if count <= 0:
raise ValueError(f"broadcast EOS set has invalid length {count}")
if rank == 0:
tokens = torch.tensor(values, dtype=torch.int64, device=device)
else:
tokens = torch.empty(count, dtype=torch.int64, device=device)
dist.broadcast(tokens, src=0)
result = {int(token) for token in tokens.cpu().tolist()}
if len(result) != count or any(token < 0 for token in result):
raise ValueError(f"broadcast EOS set is invalid: {sorted(result)}")
return result
def broadcast_prompt_ids(
prompt_ids: Sequence[int] | None,
*,
rank: int,
device: torch.device,
) -> list[int]:
"""Broadcast a variable-length rank-0 prompt with tensor collectives."""
if rank == 0:
if prompt_ids is None:
raise ValueError("rank 0 must provide prompt ids")
length = torch.tensor([len(prompt_ids)], dtype=torch.int64, device=device)
else:
length = torch.zeros(1, dtype=torch.int64, device=device)
dist.broadcast(length, src=0)
token_count = int(length.item())
if token_count <= 0:
raise ValueError(f"broadcast prompt has invalid length {token_count}")
if rank == 0:
tokens = torch.tensor(prompt_ids, dtype=torch.long, device=device)
else:
tokens = torch.empty(token_count, dtype=torch.long, device=device)
dist.broadcast(tokens, src=0)
return [int(token) for token in tokens.cpu().tolist()]
def broadcast_prompt_batch(
prompt_batch: Sequence[Sequence[int]] | None,
*,
rank: int,
device: torch.device,
) -> list[list[int]]:
"""Broadcast rank 0's packed batch as lengths plus one flat token tensor."""
if rank == 0:
if prompt_batch is None:
raise ValueError("rank 0 must provide a prompt batch")
batch_size = len(prompt_batch)
batch_size_tensor = torch.tensor(
[batch_size], dtype=torch.int64, device=device
)
else:
batch_size_tensor = torch.zeros(1, dtype=torch.int64, device=device)
dist.broadcast(batch_size_tensor, src=0)
batch_size = int(batch_size_tensor.item())
if batch_size <= 0:
raise ValueError(f"broadcast prompt batch has invalid size {batch_size}")
if rank == 0:
assert prompt_batch is not None
lengths = torch.tensor(
[len(prompt) for prompt in prompt_batch],
dtype=torch.int64,
device=device,
)
else:
lengths = torch.empty(batch_size, dtype=torch.int64, device=device)
dist.broadcast(lengths, src=0)
if bool((lengths <= 0).any().item()):
raise ValueError(
f"broadcast prompt batch has invalid lengths {lengths.cpu().tolist()}"
)
token_count = int(lengths.sum().item())
if rank == 0:
assert prompt_batch is not None
flat_tokens = torch.tensor(
[token for prompt in prompt_batch for token in prompt],
dtype=torch.long,
device=device,
)
if flat_tokens.numel() != token_count:
raise AssertionError(
f"rank 0 prompt batch has {flat_tokens.numel()} flat tokens, "
f"but lengths sum to {token_count}"
)
else:
flat_tokens = torch.empty(token_count, dtype=torch.long, device=device)
dist.broadcast(flat_tokens, src=0)
host_lengths = [int(length) for length in lengths.cpu().tolist()]
host_tokens = [int(token) for token in flat_tokens.cpu().tolist()]
result: list[list[int]] = []
offset = 0
for length in host_lengths:
result.append(host_tokens[offset : offset + length])
offset += length
if offset != len(host_tokens):
raise AssertionError(
f"broadcast prompt batch consumed {offset} of {len(host_tokens)} tokens"
)
return result
def _resolve_device(device_arg: str, local_rank: int) -> torch.device:
requested = torch.device(device_arg)
if requested.type == "cuda":
if not torch.cuda.is_available():
raise RuntimeError("CUDA was requested but torch.cuda.is_available() is false")
# An explicit index remains useful for single-rank debugging. The normal
# torchrun spelling is simply --device cuda, which maps LOCAL_RANK here.
device = (
torch.device("cuda", local_rank)
if requested.index is None
else requested
)
torch.cuda.set_device(device)
return device
return requested
def _load_rank_model(tp_dir: str, rank: int, config: Any, device: torch.device):
from convert import validate_rank_shard
from model import model_class_for_config
shard = Path(tp_dir) / f"model-r{rank}.safetensors"
if not shard.is_file():
raise FileNotFoundError(f"rank {rank}: missing TP weight shard {shard}")
validate_rank_shard(shard, config, rank)
model, loading_info = model_class_for_config(config).from_pretrained(
str(shard), config=config, output_loading_info=True,
)
failures = {key: loading_info.get(key) for key in (
"missing_keys", "unexpected_keys", "mismatched_keys", "error_msgs",
) if loading_info.get(key)}
if failures:
raise RuntimeError(f"rank {rank}: incomplete checkpoint loading: {failures}")
# TP shards intentionally keep a full replicated lm_head on disk. Transformers 4
# sliced it through ``_load_from_state_dict``; Transformers 5 injects tensors directly,
# so the module must be narrowed explicitly after strict loading.
model.lm_head.shard_(rank)
model = model.to(device).eval()
model.pack_qkv_()
model.model.build_rope(device)
return model
def generate_completion(
model: Any,
prompt_ids: list[int],
*,
max_new_tokens: int,
device: torch.device,
) -> list[int]:
"""Run the model's TP-safe greedy path for one request.
``Step5ForCausalLM.generate_greedy`` calls ``vocab_parallel_argmax`` at
every step. It therefore reconstructs the full vocabulary and returns a
global token id, rather than treating a rank-local column as a token id.
"""
completions = generate_completions(
model,
[prompt_ids],
max_new_tokens=max_new_tokens,
device=device,
)
return completions[0]
def generate_completions(
model: Any,
prompt_batch: list[list[int]],
*,
max_new_tokens: int,
device: torch.device,
eos_token_ids: set[int] | None = None,
image_inputs: dict[str, Any] | None = None,
) -> list[list[int]]:
"""Run one packed batch through the model's TP-safe greedy path."""
if not prompt_batch:
raise ValueError("prompt batch must contain at least one request")
generation_error: BaseException | None = None
try:
generation_kwargs: dict[str, Any] = {
"max_new_tokens": max_new_tokens,
"device": device,
}
if eos_token_ids is not None:
generation_kwargs["eos_token_ids"] = eos_token_ids
if image_inputs:
generation_kwargs.update(image_inputs)
completions = model.generate_greedy(prompt_batch, **generation_kwargs)
if not isinstance(completions, list):
raise TypeError(
"generate_greedy must return a list of token-ID lists; "
f"got {type(completions).__name__}"
)
if len(completions) != len(prompt_batch):
raise RuntimeError(
f"expected {len(prompt_batch)} completions, got {len(completions)}"
)
for completion_index, completion in enumerate(completions):
if not isinstance(completion, list):
raise TypeError(
f"completion {completion_index} must be a token-ID list; "
f"got {type(completion).__name__}"
)
for token_index, token in enumerate(completion):
if isinstance(token, bool) or not isinstance(token, int):
raise TypeError(
f"completion {completion_index} token {token_index} "
f"must be an integer; got {type(token).__name__}"
)
if token < 0:
raise ValueError(
f"completion {completion_index} token {token_index} "
f"must be non-negative; got {token}"
)
return completions
except BaseException as exc:
generation_error = exc
raise
finally:
if getattr(device, "type", None) == "cuda":
try:
torch.cuda.empty_cache()
except BaseException as cleanup_error:
if generation_error is None:
raise
print(
"[generate] CUDA allocator cleanup failed after generation "
f"error: {type(cleanup_error).__name__}: {cleanup_error}",
file=sys.stderr,
flush=True,
)
def format_output(
*,
prompt_token_count: int,
completion_ids: Sequence[int],
tokenizer: Any | None,
eos_token_ids: Sequence[int] | None = None,
) -> str:
completion = (
tokenizer.decode(list(completion_ids), skip_special_tokens=False)
if tokenizer is not None
else None
)
payload = {
"prompt_token_count": prompt_token_count,
"completion_token_ids": list(completion_ids),
"completion": completion,
}
if eos_token_ids is not None:
payload["eos_token_ids"] = sorted(int(token) for token in eos_token_ids)
return json.dumps(payload, ensure_ascii=False)
def format_batch_output(
*,
prompt_batch: Sequence[Sequence[int]],
completion_batch: Sequence[Sequence[int]],
tokenizer: Any | None,
eos_token_ids: Sequence[int] | None = None,
) -> str:
"""Format one request exactly as before, or a multi-request result object."""
if len(prompt_batch) != len(completion_batch):
raise ValueError(
f"prompt/completion batch mismatch: {len(prompt_batch)} != "
f"{len(completion_batch)}"
)
if not prompt_batch:
raise ValueError("cannot format an empty batch")
if len(prompt_batch) == 1:
return format_output(
prompt_token_count=len(prompt_batch[0]),
completion_ids=completion_batch[0],
tokenizer=tokenizer,
eos_token_ids=eos_token_ids,
)
results = []
for prompt_ids, completion_ids in zip(prompt_batch, completion_batch):
completion_list = list(completion_ids)
completion = (
tokenizer.decode(completion_list, skip_special_tokens=False)
if tokenizer is not None
else None
)
results.append(
{
"prompt_token_count": len(prompt_ids),
"completion_token_ids": completion_list,
"completion": completion,
}
)
return json.dumps(
{
"batch_size": len(results),
**(
{"eos_token_ids": sorted(int(token) for token in eos_token_ids)}
if eos_token_ids is not None
else {}
),
"results": results,
},
ensure_ascii=False,
)
def load_image_inputs(path, config):
"""Load the same preprocessed inputs on every rank; propagate failures together."""
inputs, error = {}, None
if path is not None:
try:
from model import Step5VLConfig
from safetensors.torch import load_file
if not isinstance(config, Step5VLConfig):
raise ValueError("--image-inputs requires a step5_vl checkpoint")
inputs = load_file(path, device="cpu")
allowed = {"pixel_values", "patch_pixel_values", "num_patches", "image_embeds"}
if not inputs or set(inputs) - allowed:
raise ValueError(f"Image input keys must be a nonempty subset of {sorted(allowed)}")
if ("pixel_values" in inputs) == ("image_embeds" in inputs):
raise ValueError("Provide exactly one of pixel_values or image_embeds")
if "image_embeds" in inputs and len(inputs) != 1:
raise ValueError("image_embeds cannot be combined with crop inputs")
except Exception as exc:
error = f"{type(exc).__name__}: {exc}"
errors = [error]
if dist.is_initialized():
errors = [None] * dist.get_world_size()
dist.all_gather_object(errors, error)
if any(errors):
raise ValueError(f"Unable to load image inputs: {next(e for e in errors if e)}")
return inputs
def _run(args: argparse.Namespace, *, rank: int, world: int, device: torch.device) -> int:
model_config = _load_rank_config(args.tp_dir, rank)
config = model_config.get_text_config()
image_token_id = getattr(model_config, "image_token_id", None) if args.image_inputs else None
config_ep_size = validate_parallel_topology(
config,
world=world,
requested_ep_size=args.ep_size,
rank=rank,
)
tokenizer_source = resolve_tokenizer_source(
tp_dir=args.tp_dir,
tokenizer=args.tokenizer,
checkpoint=args.checkpoint,
)
tokenizer = None
explicit_tokenizer_source = (
args.tokenizer is not None or args.checkpoint is not None
)
rank0_prompt_batch: list[list[int]] | None = None
rank0_eos_token_ids: set[int] | None = None
prompt_error: str | None = None
if rank == 0:
try:
if args.prompt_json_batch is not None:
rank0_prompt_batch = load_prompt_json_batch(args.prompt_json_batch)
# A tokenizer is optional for exact-id input; it is used only to
# decode completions when one is available.
tokenizer = _load_exact_id_tokenizer(
tokenizer_source,
explicit_source=explicit_tokenizer_source,
)
elif args.prompt_json is not None:
rank0_prompt_batch = [load_prompt_json(args.prompt_json)]
# A tokenizer is optional for exact-id input; it is used only to
# decode the completion when one was explicitly or automatically found.
tokenizer = _load_exact_id_tokenizer(
tokenizer_source,
explicit_source=explicit_tokenizer_source,
)
else:
if tokenizer_source is None:
raise ValueError(
"--prompt requires --tokenizer/--checkpoint, or tokenizer files "
"at <tp-dir>/../tokenizer_files"
)
tokenizer = _load_tokenizer(
tokenizer_source,
require_chat_template=not args.raw_prompt,
)
rank0_prompt_batch = [
tokenize_text(tokenizer, args.prompt, raw_prompt=args.raw_prompt)
]
assert rank0_prompt_batch is not None
for prompt_ids in rank0_prompt_batch:
validate_prompt_ids(
prompt_ids,
vocab_size=config.vocab_size,
max_position_embeddings=config.max_position_embeddings,
max_new_tokens=args.max_new_tokens,
image_token_id=image_token_id,
)
rank0_eos_token_ids = resolve_generation_eos_token_ids(
config.eos_token_id,
tokenizer,
)
except Exception as exc:
prompt_error = f"{type(exc).__name__}: {exc}"
_broadcast_error(prompt_error, device=device)
eos_token_ids = broadcast_eos_token_ids(
rank0_eos_token_ids,
rank=rank,
device=device,
)
prompt_batch = broadcast_prompt_batch(
rank0_prompt_batch, rank=rank, device=device
)
# Check again on all ranks so a corrupted/mismatched rank config fails before
# loading hundreds of gigabytes of weights.
for prompt_ids in prompt_batch:
validate_prompt_ids(
prompt_ids,
vocab_size=config.vocab_size,
max_position_embeddings=config.max_position_embeddings,
max_new_tokens=args.max_new_tokens,
image_token_id=image_token_id,
)
if rank == 0:
prompt_lengths = [len(prompt_ids) for prompt_ids in prompt_batch]
prompt_description = (
f"{prompt_lengths[0]} prompt tokens"
if len(prompt_lengths) == 1
else f"batch={len(prompt_lengths)}, prompt tokens={prompt_lengths}"
)
print(
f"loading Step5 TP={world}, co-located EP={config_ep_size} "
f"({prompt_description})",
file=sys.stderr,
flush=True,
)
image_inputs = load_image_inputs(args.image_inputs, model_config)
model = _load_rank_model(args.tp_dir, rank, model_config, device)
completion_batch = generate_completions(
model,
prompt_batch,
max_new_tokens=args.max_new_tokens,
device=device,
eos_token_ids=eos_token_ids,
image_inputs=image_inputs,
)
if rank == 0:
print(
format_batch_output(
prompt_batch=prompt_batch,
completion_batch=completion_batch,
tokenizer=tokenizer,
eos_token_ids=eos_token_ids,
),
flush=True,
)
return 0
def main(argv: Sequence[str] | None = None) -> int:
args = parse_args(argv)
if not dist.is_available():
raise SystemExit("torch.distributed is unavailable; launch this CLI with torchrun")
if not {"RANK", "WORLD_SIZE", "LOCAL_RANK"}.issubset(os.environ):
raise SystemExit(
"missing torchrun environment (RANK/WORLD_SIZE/LOCAL_RANK); "
"launch with torchrun --nproc-per-node=<TP>"
)
local_rank = int(os.environ["LOCAL_RANK"])
device = _resolve_device(args.device, local_rank)
backend = "nccl" if device.type == "cuda" else "gloo"
owns_process_group = False
try:
if not dist.is_initialized():
dist.init_process_group(backend=backend)
owns_process_group = True
return _run(
args,
rank=dist.get_rank(),
world=dist.get_world_size(),
device=device,
)
finally:
if owns_process_group and dist.is_available() and dist.is_initialized():
dist.destroy_process_group()
if __name__ == "__main__":
raise SystemExit(main())