Upload 10 files
Browse files- inference/README.md +388 -0
- inference/README.zh.md +371 -0
- inference/convert.py +599 -0
- inference/generate.py +976 -0
- inference/kernel.py +0 -0
- inference/model.py +0 -0
- inference/requirements.txt +5 -0
- inference/test_vision.py +390 -0
- inference/validate_vision.py +119 -0
- inference/vision_encoder.py +500 -0
inference/README.md
ADDED
|
@@ -0,0 +1,388 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Step-5 minimal inference
|
| 2 |
+
|
| 3 |
+
> 中文版本:[README.zh.md](README.zh.md)
|
| 4 |
+
|
| 5 |
+
This directory contains the standalone Step-5 inference path. Full-model text
|
| 6 |
+
and image-text generation have been validated with FP8 on **one 8 x H200 node**
|
| 7 |
+
at **TP=EP=8**, and BF16 on **two 8 x H200 nodes** at **TP=EP=16**.
|
| 8 |
+
The vision encoder uses PyTorch and BF16 weights. All commands below run from
|
| 9 |
+
this directory; the inference files can be used independently.
|
| 10 |
+
|
| 11 |
+
The default sliding-attention path is PyTorch SDPA. QKNorm/RoPE, DSA sparse
|
| 12 |
+
attention, block-scaled FP8 expert GEMM, and the critical MoE primitives use
|
| 13 |
+
Triton/CUDA; model orchestration and NCCL collectives remain PyTorch.
|
| 14 |
+
|
| 15 |
+
The same ranks provide tensor parallelism for the dense backbone and expert
|
| 16 |
+
parallelism for the 352 routed experts. TP=EP=8 places 44 experts on each rank;
|
| 17 |
+
TP=EP=16 places 22. TP and EP share a process group; there is no independent
|
| 18 |
+
`TP × EP` process grid.
|
| 19 |
+
|
| 20 |
+
## Contents
|
| 21 |
+
|
| 22 |
+
| File | What it is |
|
| 23 |
+
|------|------------|
|
| 24 |
+
| `convert.py` | Converts an original Step-5 checkpoint into per-rank TP shards: `tpN/model-r{rank}.safetensors` + matching `config-r{rank}.json`, plus a shared `tokenizer_files/`. |
|
| 25 |
+
| `generate.py` | Greedy text or image-text inference. Loads one shard per `torchrun` rank; accepts a text `--prompt`, or exact prompt IDs with optional prepared `--image-inputs`. |
|
| 26 |
+
| `model.py` | Text/VL configuration, decoder layers, TP/EP execution, and image-feature injection into packed prefill. Imports compute kernels from `kernel.py`. |
|
| 27 |
+
| `vision_encoder.py` | PyTorch perception encoder with 2D RoPE, absolute positions and convolutional downsamplers. |
|
| 28 |
+
| `kernel.py` | The Triton/CUDA compute kernels: the sparse-attention (DSA) indexer + decode/prefill, block-scaled FP8 expert GEMM, the MoE primitives, and fused QKNorm/RoPE. |
|
| 29 |
+
| `test_vision.py` | Configuration, checkpoint-loading and multimodal inference regressions. |
|
| 30 |
+
| `requirements.txt` | Compatible runtime ranges: torch `>=2.10,<2.14`, transformers `>=4.57.6,<6`, safetensors `>=0.7,<1`, and triton `>=3.6,<3.8`. |
|
| 31 |
+
|
| 32 |
+
Typical flow: `convert.py` (once, to shard a checkpoint) → `generate.py` (to run it).
|
| 33 |
+
`generate.py` loads the model implemented by `model.py`, `kernel.py` and `vision_encoder.py`.
|
| 34 |
+
|
| 35 |
+
Expert storage follows the source configuration: absent or empty
|
| 36 |
+
`quantization_config` selects BF16; `quant_method="fp8"` selects FP8 with BF16
|
| 37 |
+
exclusions from `modules_to_not_convert`. Conversion preserves weight dtypes.
|
| 38 |
+
|
| 39 |
+
## Install
|
| 40 |
+
|
| 41 |
+
Use a CUDA-enabled PyTorch build matching the host driver. FP8 checkpoint inference
|
| 42 |
+
at TP=8 requires eight visible GPUs with FP8 E4M3 support. The validated vision runtime
|
| 43 |
+
uses PyTorch 2.13.0+cu129, Triton 3.7.1, Transformers 5.15.0, and Safetensors 0.8.0.
|
| 44 |
+
The compatible version ranges are listed in `requirements.txt`.
|
| 45 |
+
|
| 46 |
+
Create a virtual environment and install the dependencies:
|
| 47 |
+
|
| 48 |
+
```bash
|
| 49 |
+
python3 -m venv --system-site-packages .venv
|
| 50 |
+
source .venv/bin/activate
|
| 51 |
+
python -m pip install -r requirements.txt
|
| 52 |
+
```
|
| 53 |
+
|
| 54 |
+
If the host does not already provide a compatible CUDA-enabled PyTorch, install
|
| 55 |
+
it from the CUDA wheel index matching the driver before installing the remaining
|
| 56 |
+
requirements:
|
| 57 |
+
|
| 58 |
+
```bash
|
| 59 |
+
python -m pip install \
|
| 60 |
+
--index-url "<matching-pytorch-cuda-wheel-index>" \
|
| 61 |
+
"torch>=2.10,<2.14"
|
| 62 |
+
python -m pip install -r requirements.txt
|
| 63 |
+
```
|
| 64 |
+
|
| 65 |
+
The release loads the fast tokenizer serialized in local `tokenizer.json` directly.
|
| 66 |
+
Automatic tokenizer class selection in Transformers 5.15 is incompatible with these
|
| 67 |
+
Step-5 assets and can collapse long prompts. Direct loading preserves the
|
| 68 |
+
byte-level BPE input IDs and output decoding validated with Transformers 4.57.6.
|
| 69 |
+
|
| 70 |
+
## Long-output stability
|
| 71 |
+
|
| 72 |
+
Generation uses the following long-output execution contract:
|
| 73 |
+
|
| 74 |
+
- TP partial results are added in rank order with FP32 accumulation.
|
| 75 |
+
- Sliding-window token-by-token decode uses PyTorch's default SDPA backend
|
| 76 |
+
selection. This keeps the production path on the backend supported by the
|
| 77 |
+
installed PyTorch/CUDA runtime instead of forcing the slower math backend.
|
| 78 |
+
- A request that emits EOS is removed from later decode steps and its cache row
|
| 79 |
+
is compacted.
|
| 80 |
+
|
| 81 |
+
The checks cover row-position stability inside one active batch. They do not
|
| 82 |
+
promise bitwise-identical output across different batch sizes, schedulers, GPU
|
| 83 |
+
models, or software versions.
|
| 84 |
+
|
| 85 |
+
## Checkpoints and launch modes
|
| 86 |
+
|
| 87 |
+
Run all commands from this directory. Choose the FP8 or BF16 section for your
|
| 88 |
+
source checkpoint and use a separate output directory for each format.
|
| 89 |
+
Conversion partitions weights and preserves their dtype; it does not quantize
|
| 90 |
+
a BF16 checkpoint into FP8.
|
| 91 |
+
|
| 92 |
+
The source model directory supplies the authoritative `config.json`.
|
| 93 |
+
`convert.py` writes `tpN/model-r{rank}.safetensors` and matching
|
| 94 |
+
`config-r{rank}.json` files, with `gqa-provider-shared-tp-v2` and
|
| 95 |
+
`expert_parallel_size=N`, plus shared tokenizer assets in `tokenizer_files/`.
|
| 96 |
+
`generate.py` reads these configs from `--tp-dir` and discovers the copied
|
| 97 |
+
tokenizer automatically. The inference directory does not supply a model config.
|
| 98 |
+
Use the weights and configs from the same conversion. If a config omits
|
| 99 |
+
`expert_parallel_size`, the runtime uses `tp_size`.
|
| 100 |
+
|
| 101 |
+
<a id="fp8"></a>
|
| 102 |
+
|
| 103 |
+
## FP8: one node with 8 GPUs
|
| 104 |
+
|
| 105 |
+
The FP8 path has been validated on **8 x H200**, with **TP=EP=8** and 44 routed
|
| 106 |
+
experts per rank. The source config must declare `quant_method="fp8"`;
|
| 107 |
+
excluded expert layers and the vision tower retain their BF16 weights.
|
| 108 |
+
|
| 109 |
+
Set the source and output directories:
|
| 110 |
+
|
| 111 |
+
```bash
|
| 112 |
+
export STEP5_FP8_CHECKPOINT="<fp8-checkpoint-directory>"
|
| 113 |
+
export STEP5_FP8_OUT="<fp8-shard-output-directory>"
|
| 114 |
+
```
|
| 115 |
+
|
| 116 |
+
Check checkpoint keys and shard shapes, then convert once to a fresh output
|
| 117 |
+
directory. `--check-only` reads headers without loading weights or writing shards:
|
| 118 |
+
|
| 119 |
+
```bash
|
| 120 |
+
python3 convert.py \
|
| 121 |
+
--checkpoint "$STEP5_FP8_CHECKPOINT" \
|
| 122 |
+
--tp-size 8 --ep-size 8 --check-only
|
| 123 |
+
|
| 124 |
+
python3 convert.py \
|
| 125 |
+
--checkpoint "$STEP5_FP8_CHECKPOINT" \
|
| 126 |
+
--out-dir "$STEP5_FP8_OUT" \
|
| 127 |
+
--tp-size 8 --ep-size 8
|
| 128 |
+
```
|
| 129 |
+
|
| 130 |
+
Launch text generation on the node with eight visible GPUs:
|
| 131 |
+
|
| 132 |
+
```bash
|
| 133 |
+
torchrun --standalone --nproc-per-node=8 generate.py \
|
| 134 |
+
--tp-dir "$STEP5_FP8_OUT/tp8" --ep-size 8 \
|
| 135 |
+
--prompt "请介绍一下张量并行。" \
|
| 136 |
+
--max-new-tokens 128
|
| 137 |
+
```
|
| 138 |
+
|
| 139 |
+
For image-text generation, prepare the matching checkpoint's prompt IDs and
|
| 140 |
+
image tensors according to [Vision inputs](#vision-inputs), then run:
|
| 141 |
+
|
| 142 |
+
```bash
|
| 143 |
+
torchrun --standalone --nproc-per-node=8 generate.py \
|
| 144 |
+
--tp-dir "$STEP5_FP8_OUT/tp8" --ep-size 8 \
|
| 145 |
+
--prompt-json prompt-ids.json --image-inputs image-inputs.safetensors \
|
| 146 |
+
--max-new-tokens 128
|
| 147 |
+
```
|
| 148 |
+
|
| 149 |
+
<a id="bf16"></a>
|
| 150 |
+
|
| 151 |
+
## BF16: 16 GPUs across two nodes
|
| 152 |
+
|
| 153 |
+
The full 92-layer BF16 VL model needs approximately **144.41 GiB per rank** at
|
| 154 |
+
TP/EP=8 for parameters alone, exceeding the validated H200 node's 140.40 GiB per
|
| 155 |
+
GPU. At **TP=EP=16**, loaded parameters occupy **74.93 GiB per rank**, with 22 routed
|
| 156 |
+
experts per rank. These figures include replicated vision weights and a sharded
|
| 157 |
+
output head, exclude MTP, and leave KV caches, RoPE, activations and communication
|
| 158 |
+
as additional memory requirements.
|
| 159 |
+
|
| 160 |
+
The complete BF16 decoder, vision encoder and projector passed two-node text
|
| 161 |
+
and single-image greedy generation on **16 H200 GPUs**. All ranks produced
|
| 162 |
+
identical tokens and reached EOS, with finite logits throughout. The measured
|
| 163 |
+
peak PyTorch allocation was **76.37 GiB per GPU** for batch size 1 and a
|
| 164 |
+
943-token image prompt, including 898 visual tokens. This excludes allocations
|
| 165 |
+
outside PyTorch, such as NCCL and the CUDA context. Communication used **NCCL TCP
|
| 166 |
+
Socket**; RDMA, long-context workloads and throughput have not been validated.
|
| 167 |
+
|
| 168 |
+
The full-model run streamed and sliced the original weights using the converter's
|
| 169 |
+
TP rules and executed the released model forward path. The complete BF16
|
| 170 |
+
`convert.py` → `generate.py --tp-dir` file-based workflow below has not been
|
| 171 |
+
separately run end to end; checkpoint key/shape checks and small-model BF16
|
| 172 |
+
conversion/loading/generation tests have passed.
|
| 173 |
+
|
| 174 |
+
Use two nodes with eight visible H200 GPUs each. Both nodes need the same code
|
| 175 |
+
and compatible runtime, NCCL connectivity, and access to the same converted
|
| 176 |
+
weights and tokenizer files at the configured paths. Image inputs must also be
|
| 177 |
+
available with identical contents on both nodes.
|
| 178 |
+
|
| 179 |
+
Set these paths on both nodes. The source checkpoint must use BF16 weights and
|
| 180 |
+
have no quantization config, or an empty one:
|
| 181 |
+
|
| 182 |
+
```bash
|
| 183 |
+
export STEP5_BF16_CHECKPOINT="<bf16-checkpoint-directory>"
|
| 184 |
+
export STEP5_BF16_OUT="<bf16-shard-output-directory>"
|
| 185 |
+
```
|
| 186 |
+
|
| 187 |
+
On **one node only**, check the source and convert once into 16 complete shards.
|
| 188 |
+
Make the resulting `tp16/` and `tokenizer_files/` directories accessible to both
|
| 189 |
+
nodes before launching:
|
| 190 |
+
|
| 191 |
+
```bash
|
| 192 |
+
python3 convert.py \
|
| 193 |
+
--checkpoint "$STEP5_BF16_CHECKPOINT" \
|
| 194 |
+
--tp-size 16 --ep-size 16 --check-only
|
| 195 |
+
|
| 196 |
+
python3 convert.py \
|
| 197 |
+
--checkpoint "$STEP5_BF16_CHECKPOINT" \
|
| 198 |
+
--out-dir "$STEP5_BF16_OUT" \
|
| 199 |
+
--tp-size 16 --ep-size 16
|
| 200 |
+
```
|
| 201 |
+
|
| 202 |
+
On **both nodes**, set the same reachable node-0 address and free rendezvous port:
|
| 203 |
+
|
| 204 |
+
```bash
|
| 205 |
+
export MASTER_ADDR="<node-0-reachable-IP-or-hostname>"
|
| 206 |
+
export MASTER_PORT=29500
|
| 207 |
+
```
|
| 208 |
+
|
| 209 |
+
On **node 0**:
|
| 210 |
+
|
| 211 |
+
```bash
|
| 212 |
+
export NODE_RANK=0
|
| 213 |
+
```
|
| 214 |
+
|
| 215 |
+
On **node 1**:
|
| 216 |
+
|
| 217 |
+
```bash
|
| 218 |
+
export NODE_RANK=1
|
| 219 |
+
```
|
| 220 |
+
|
| 221 |
+
Run the following command on **both nodes**, with their respective `NODE_RANK`,
|
| 222 |
+
for the same launch. It starts eight workers per node and 16 workers in total:
|
| 223 |
+
|
| 224 |
+
```bash
|
| 225 |
+
torchrun --nnodes=2 --nproc-per-node=8 \
|
| 226 |
+
--node-rank="$NODE_RANK" \
|
| 227 |
+
--master-addr="$MASTER_ADDR" --master-port="$MASTER_PORT" \
|
| 228 |
+
generate.py \
|
| 229 |
+
--tp-dir "$STEP5_BF16_OUT/tp16" --ep-size 16 \
|
| 230 |
+
--prompt "请介绍一下张量并行。" \
|
| 231 |
+
--max-new-tokens 128
|
| 232 |
+
```
|
| 233 |
+
|
| 234 |
+
Alternatively, for image-text generation, prepare the inputs according to
|
| 235 |
+
[Vision inputs](#vision-inputs) and run this command on **both nodes**:
|
| 236 |
+
|
| 237 |
+
```bash
|
| 238 |
+
torchrun --nnodes=2 --nproc-per-node=8 \
|
| 239 |
+
--node-rank="$NODE_RANK" \
|
| 240 |
+
--master-addr="$MASTER_ADDR" --master-port="$MASTER_PORT" \
|
| 241 |
+
generate.py \
|
| 242 |
+
--tp-dir "$STEP5_BF16_OUT/tp16" --ep-size 16 \
|
| 243 |
+
--prompt-json prompt-ids.json --image-inputs image-inputs.safetensors \
|
| 244 |
+
--max-new-tokens 128
|
| 245 |
+
```
|
| 246 |
+
|
| 247 |
+
The launcher sets `WORLD_SIZE=2 x 8=16`; shard configs and `--ep-size` must both
|
| 248 |
+
use 16. `--standalone` is only used by the single-node FP8 example.
|
| 249 |
+
|
| 250 |
+
For exact text token IDs in either launch mode, replace `--prompt` and its value
|
| 251 |
+
with `--prompt-json <prompt-json-file>`. The file accepts a JSON list or
|
| 252 |
+
`{"prompt_ids": [...]}`. `--prompt-json-batch` accepts a batch of requests.
|
| 253 |
+
|
| 254 |
+
## Limitations
|
| 255 |
+
|
| 256 |
+
- Full-model generation has been validated with FP8 on one 8 x H200 node and
|
| 257 |
+
BF16 on two 8 x H200 nodes using streamed weights. The full BF16 file-based
|
| 258 |
+
conversion/loading workflow remains unvalidated end to end.
|
| 259 |
+
- `--ep-size` defaults to 8 and currently must equal TP/world size; independent
|
| 260 |
+
TP and EP process groups such as `TP=4, EP=2` are not implemented.
|
| 261 |
+
`generate.py` reads TP from the shard config (and checks it against
|
| 262 |
+
`WORLD_SIZE`); it intentionally has no separate `--tp-size` switch.
|
| 263 |
+
- Sliding attention uses PyTorch SDPA.
|
| 264 |
+
- Generation is greedy. Continuous dynamic batching, paged KV cache, prefix
|
| 265 |
+
caching and CUDA graphs are not provided. BF16 cross-node validation covers
|
| 266 |
+
short text and single-image requests over NCCL TCP Socket.
|
| 267 |
+
- MTP layers are not loaded, so speculative decoding is unavailable.
|
| 268 |
+
- The checkpoint's factory FP8/BF16 expert layout is consumed directly; the
|
| 269 |
+
converter and generated TP shards are checkpoint-layout specific.
|
| 270 |
+
|
| 271 |
+
## Vision checkpoints (`step5_vl`)
|
| 272 |
+
|
| 273 |
+
`Step5ForConditionalGeneration` uses `Step5VLConfig` with top-level
|
| 274 |
+
`model_type=step5_vl`, `text_config.model_type=step5`, and
|
| 275 |
+
`vision_config.model_type=perception_encoder`. Text-only checkpoints use
|
| 276 |
+
`Step5Config` and `Step5ForCausalLM`.
|
| 277 |
+
|
| 278 |
+
The Step-5 perception encoder uses PyTorch operations, non-causal SDPA,
|
| 279 |
+
2D RoPE, absolute position embeddings, LayerScale and two convolutional
|
| 280 |
+
downsamplers. Projected image features enter the packed text decoder during
|
| 281 |
+
prefill; text decoding uses the DSA cache and co-located TP/EP execution.
|
| 282 |
+
|
| 283 |
+
The validated VL checkpoint has the following geometry:
|
| 284 |
+
|
| 285 |
+
| Component | Configuration |
|
| 286 |
+
| --- | --- |
|
| 287 |
+
| Text decoder | 92 layers, hidden size 4096; 3 MTP layers excluded from ordinary generation |
|
| 288 |
+
| Vision Transformer | 47 layers, width 1536, 16 attention heads, MLP size 8960 |
|
| 289 |
+
| Patch embedding | RGB, 14×14 kernel and stride 14 |
|
| 290 |
+
| Activation and normalization | QuickGELU, pre-LN, no post-LN, LayerScale 0.1 |
|
| 291 |
+
| Position encoding | Absolute positions and 2D RoPE; no CLS token |
|
| 292 |
+
| Downsamplers | Two 3×3 convolutions, stride 2, padding 1; 1536→3072→6144 |
|
| 293 |
+
| Projector | 6144→4096, no bias |
|
| 294 |
+
| Image placeholder | Token ID 128001 |
|
| 295 |
+
| Vision weights | 667 BF16 tensors; approximately 3.96 GB per TP rank |
|
| 296 |
+
|
| 297 |
+
Checkpoint keys are `model.*`, `lm_head.*`, `vision_model.*` and
|
| 298 |
+
`vit_large_projector.*`. Vision and projector tensors are replicated on each
|
| 299 |
+
TP rank. The checkpoint index determines the files and dtypes to load.
|
| 300 |
+
The converter excludes MTP tensors at indices `>= text_config.num_hidden_layers`.
|
| 301 |
+
|
| 302 |
+
Use the [FP8](#fp8) or [BF16](#bf16) checkpoint-check and conversion
|
| 303 |
+
commands above for VL checkpoints as well. Complete shards include the nested
|
| 304 |
+
config, `Step5ForConditionalGeneration` identity, text, vision and projector weights.
|
| 305 |
+
|
| 306 |
+
Both resumed conversion and generation verify each shard's complete key set,
|
| 307 |
+
shapes and TP/EP/rank metadata before reading weight data. Generation also rejects
|
| 308 |
+
any missing or unexpected keys reported by the Hugging Face loader; incomplete
|
| 309 |
+
vision weights cannot fall back to random initialization.
|
| 310 |
+
|
| 311 |
+
<a id="vision-inputs"></a>
|
| 312 |
+
|
| 313 |
+
### Vision inputs
|
| 314 |
+
|
| 315 |
+
The model API accepts preprocessed tensors:
|
| 316 |
+
|
| 317 |
+
| Argument | Meaning |
|
| 318 |
+
| --- | --- |
|
| 319 |
+
| `pixel_values` | Overview images, `[images, 3, H, W]`; additional leading batch dimensions are flattened. |
|
| 320 |
+
| `patch_pixel_values` | Optional crops in image order, with the same layout. |
|
| 321 |
+
| `num_patches` | One nonnegative crop count per overview image. Defaults to zero crops. |
|
| 322 |
+
| `image_embeds` | Alternative already projected features, `[..., text_hidden_size]`; excludes all pixel/crop arguments. |
|
| 323 |
+
|
| 324 |
+
The released geometry produces 169 tokens for each 728×728 overview and 81
|
| 325 |
+
for each 504×504 crop. Each image contributes **its crops first, then its
|
| 326 |
+
overview**. Concatenate images in prompt order across packed requests. The
|
| 327 |
+
number of `image_token_id` placeholders must equal the total feature-token
|
| 328 |
+
count; `<im_start>` / `<im_end>` remain ordinary text embeddings. Image
|
| 329 |
+
encoding runs only during prefill. Decode uses the existing cache.
|
| 330 |
+
|
| 331 |
+
Image resizing, normalization, crop selection and prompt construction must come
|
| 332 |
+
from the matching checkpoint processor. This standalone path consumes those
|
| 333 |
+
prepared results; it does not define a new PIL/JPEG processing policy. Save a
|
| 334 |
+
prepared batch with `safetensors.torch.save_file`, for example:
|
| 335 |
+
|
| 336 |
+
```python
|
| 337 |
+
import torch
|
| 338 |
+
from safetensors.torch import save_file
|
| 339 |
+
|
| 340 |
+
save_file({
|
| 341 |
+
"pixel_values": pixel_values.contiguous(),
|
| 342 |
+
"patch_pixel_values": patch_pixel_values.contiguous(),
|
| 343 |
+
"num_patches": torch.tensor(num_patches, dtype=torch.int64),
|
| 344 |
+
}, "image-inputs.safetensors")
|
| 345 |
+
```
|
| 346 |
+
|
| 347 |
+
Use the image-text launch command in the [FP8](#fp8) or [BF16](#bf16)
|
| 348 |
+
section with `prompt-ids.json` (including image placeholders) and
|
| 349 |
+
`image-inputs.safetensors`.
|
| 350 |
+
|
| 351 |
+
`--prompt-json-batch` supports a mixture of text and image requests. Every rank
|
| 352 |
+
reads the same prepared image file. The Python API accepts these arguments in
|
| 353 |
+
`Step5ForConditionalGeneration.forward` and `generate_greedy` as well.
|
| 354 |
+
|
| 355 |
+
Run the multimodal integration regression with the optional test dependency:
|
| 356 |
+
|
| 357 |
+
```bash
|
| 358 |
+
python -m pip install pytest
|
| 359 |
+
python -m pytest -q test_vision.py
|
| 360 |
+
```
|
| 361 |
+
|
| 362 |
+
`vision_select_layer=-1` and language-frequency 2D RoPE are supported.
|
| 363 |
+
Other layer-selection or frequency modes fail explicitly.
|
| 364 |
+
|
| 365 |
+
### Validation results
|
| 366 |
+
|
| 367 |
+
The following results were obtained on H200 with PyTorch 2.13.0+cu129,
|
| 368 |
+
Triton 3.7.1 and Transformers 5.15.0:
|
| 369 |
+
|
| 370 |
+
| Check | Result |
|
| 371 |
+
| --- | --- |
|
| 372 |
+
| Vision integration, checkpoint identity and BF16 loading tests | 20 passed |
|
| 373 |
+
| Configuration, generation, serving, evaluation and cache regressions | 203 passed, 93 subtests passed |
|
| 374 |
+
| GPU operator gates | All 10 passed |
|
| 375 |
+
| Batch and NCCL TP stability | Passed |
|
| 376 |
+
| FP8 checkpoint schema, TP8 | 2653 runtime tensors validated; 51 MTP tensors excluded |
|
| 377 |
+
| BF16 checkpoint schema, TP8 and TP16 | 2398 runtime tensors validated; 51 MTP tensors excluded |
|
| 378 |
+
| BF16 small-model generation | TP1/TP8 pixel and projected-embedding inputs produced identical output token IDs |
|
| 379 |
+
| Full vision numerical validation | Overview and crop outputs matched exactly; maximum absolute error 0 |
|
| 380 |
+
| TP8 mixed-precision fixture | Pixel and projected-embedding inputs produced identical output token IDs |
|
| 381 |
+
| HF file-shard loading, complete real rank 0 | 85.4 GB shard loaded; real-image features `[898,4096]` matched exactly |
|
| 382 |
+
| Full real FP8 checkpoint TP8 image generation, streamed weights | Correctly described a white Samoyed on grass; 27 generated tokens, normal EOS, identical output on all ranks |
|
| 383 |
+
| Full real BF16 checkpoint, two-node TP16/EP16, streamed weights | Text and single-image generation passed; finite logits, normal EOS and identical tokens on all 16 ranks; 74.93 GiB parameters/rank, 76.37 GiB peak PyTorch allocation/GPU |
|
| 384 |
+
|
| 385 |
+
The image case used one 728×728 overview and nine 504×504 crops, totaling
|
| 386 |
+
898 visual tokens. Image encoding runs in PyTorch with fused GPU attention.
|
| 387 |
+
These results cover functional and numerical correctness for the tested inputs;
|
| 388 |
+
they do not constitute a multimodal benchmark or OCR/video quality evaluation.
|
inference/README.zh.md
ADDED
|
@@ -0,0 +1,371 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Step-5 最小推理
|
| 2 |
+
|
| 3 |
+
> English version: [README.md](README.md)
|
| 4 |
+
|
| 5 |
+
本目录包含独立的 Step-5 推理路径。完整模型的文本和图文生成已分别通过
|
| 6 |
+
FP8 **单节点 8 × H200、TP=EP=8** 与 BF16 **双节点共 16 × H200、TP=EP=16** 验证。
|
| 7 |
+
视觉编码器使用 PyTorch 和 BF16 权重。以下命令均在本目录执行,推理文件可独立使用。
|
| 8 |
+
|
| 9 |
+
默认的滑窗注意力走 PyTorch SDPA。QKNorm/RoPE、DSA 稀疏注意力、分块缩放 FP8 专家
|
| 10 |
+
矩阵乘法,以及关键的 MoE 基础算子用 Triton/CUDA 实现;模型编排与 NCCL 集合通信仍是
|
| 11 |
+
PyTorch。
|
| 12 |
+
|
| 13 |
+
同一组进程(rank)同时承担稠密主干的张量并行和 352 个路由专家的专家并行。
|
| 14 |
+
TP=EP=8 时每个 rank 放置 44 个专家,TP=EP=16 时放置 22 个。
|
| 15 |
+
TP 与 EP 共用进程组,没有独立的 `TP × EP` 进程网格。
|
| 16 |
+
|
| 17 |
+
常用术语:
|
| 18 |
+
|
| 19 |
+
| 缩写 | 含义 |
|
| 20 |
+
|------|------|
|
| 21 |
+
| TP | Tensor Parallelism,张量并行,把同一层计算拆到多张卡上。 |
|
| 22 |
+
| EP | Expert Parallelism,专家并行,把 MoE 专家分布到多张卡上。 |
|
| 23 |
+
| MoE | Mixture of Experts,混合专家网络,每个 token 只选择部分专家参与计算。 |
|
| 24 |
+
| FP8 | 8 位浮点数格式,用于降低模型权重和计算开销。 |
|
| 25 |
+
| BF16 | Brain Floating Point 16,16 位浮点数格式。 |
|
| 26 |
+
| SDPA | PyTorch 的缩放点积注意力实现。 |
|
| 27 |
+
| DSA | 稀疏注意力实现,先选出最相关的历史区域,再计算注意力。 |
|
| 28 |
+
| QKNorm | 对注意力中的 query 和 key 向量做归一化。 |
|
| 29 |
+
| RoPE | Rotary Position Embedding,旋转位置编码,用于表示 token 的相对位置。 |
|
| 30 |
+
| NCCL | NVIDIA 集合通信库,用于多张 GPU 之间的数据交换。 |
|
| 31 |
+
| KV cache | 保存历史 token 的注意力 key/value,避免解码时重复计算。 |
|
| 32 |
+
| MTP | Multi-Token Prediction,多 token 预测层,可用于辅助投机解码。 |
|
| 33 |
+
| token | 模型处理文本时使用的基本单位。 |
|
| 34 |
+
|
| 35 |
+
## 目录内容
|
| 36 |
+
|
| 37 |
+
| 文件 | 是什么 |
|
| 38 |
+
|------|--------|
|
| 39 |
+
| `convert.py` | 把原始 Step-5 模型权重转换成按进程分片的 TP 权重:`tpN/model-r{rank}.safetensors` + 对应的 `config-r{rank}.json`,外加一份共享的 `tokenizer_files/`。 |
|
| 40 |
+
| `generate.py` | 文本或图文贪心推理。每个 `torchrun` 进程加载一个分片;可传文本 `--prompt`,或准确 prompt IDs 与预处理后的 `--image-inputs`。 |
|
| 41 |
+
| `model.py` | 文本/VL 配置、解码层、TP/EP 执行,以及 packed prefill 中的图像特征注入。计算算子从 `kernel.py` 导入。 |
|
| 42 |
+
| `vision_encoder.py` | PyTorch 视觉编码器,包含 2D RoPE、绝对位置编码及卷积下采样。 |
|
| 43 |
+
| `kernel.py` | Triton/CUDA 计算算子:稀疏注意力(DSA)索引器 + 解码/预填充、分块缩放 FP8 专家矩阵乘法、MoE 基础算子,以及融合的 QKNorm/RoPE。 |
|
| 44 |
+
| `test_vision.py` | 配置、权重加载和多模态推理回归。 |
|
| 45 |
+
| `requirements.txt` | 兼容版本范围:torch `>=2.10,<2.14`、transformers `>=4.57.6,<6`、safetensors `>=0.7,<1`、triton `>=3.6,<3.8`。 |
|
| 46 |
+
|
| 47 |
+
典型流程:`convert.py`(一次性,把模型权重分片)→ `generate.py`(运行)。
|
| 48 |
+
`generate.py` 加载由 `model.py`、`kernel.py` 和 `vision_encoder.py` 实现的模型。
|
| 49 |
+
|
| 50 |
+
专家权重格式由源配置决定:缺少或为空的 `quantization_config` 使用 BF16;
|
| 51 |
+
`quant_method="fp8"` 使用 FP8,并按 `modules_to_not_convert` 保留指定层的 BF16
|
| 52 |
+
专家。转换过程保留权重 dtype。
|
| 53 |
+
|
| 54 |
+
## 安装
|
| 55 |
+
|
| 56 |
+
使用与主机驱动匹配、启用了 CUDA 的 PyTorch。FP8 模型的 TP=8 推理需要 8 张支持
|
| 57 |
+
FP8 E4M3 的可见 GPU。已验证的视觉运行环境为 PyTorch 2.13.0+cu129、Triton 3.7.1、
|
| 58 |
+
Transformers 5.15.0 和 Safetensors 0.8.0;兼容版本范围见 `requirements.txt`。
|
| 59 |
+
|
| 60 |
+
创建虚拟环境并安装依赖:
|
| 61 |
+
|
| 62 |
+
```bash
|
| 63 |
+
python3 -m venv --system-site-packages .venv
|
| 64 |
+
source .venv/bin/activate
|
| 65 |
+
python -m pip install -r requirements.txt
|
| 66 |
+
```
|
| 67 |
+
|
| 68 |
+
如果主机尚未提供兼容的 CUDA 版 PyTorch,先从与驱动匹配的 CUDA 软件包地址安装,
|
| 69 |
+
再安装其余依赖:
|
| 70 |
+
|
| 71 |
+
```bash
|
| 72 |
+
python -m pip install \
|
| 73 |
+
--index-url "<匹配的-pytorch-cuda-软件包地址>" \
|
| 74 |
+
"torch>=2.10,<2.14"
|
| 75 |
+
python -m pip install -r requirements.txt
|
| 76 |
+
```
|
| 77 |
+
|
| 78 |
+
发布版直接读取本地 `tokenizer.json` 中保存的快速分词规则。Transformers 5.15
|
| 79 |
+
若只按分词器类别信息自动选择实现,会错误地把长文本压缩成很少的 token。
|
| 80 |
+
字节级 BPE 会先把文本表示成字节,再把常见的连续字节合并成文本片段;直接读取
|
| 81 |
+
`tokenizer.json` 可以同时保留 Transformers 4.57.6 下验证过的输入 token 编号和
|
| 82 |
+
正常的输出文本还原。
|
| 83 |
+
|
| 84 |
+
## 长输出稳定性
|
| 85 |
+
|
| 86 |
+
生成过程采用以下长输出计算约定:
|
| 87 |
+
|
| 88 |
+
- 多卡张量并行的部分结果按 rank 顺序逐个相加,相加时使用 FP32。
|
| 89 |
+
- 滑窗逐 token 解码使用 PyTorch 默认选择的 SDPA 注意力后端,也就是由当前
|
| 90 |
+
PyTorch 和 CUDA 环境选择其支持的实现,不再强制使用较慢的通用数学实现。
|
| 91 |
+
- 请求生成 EOS(���束标记)后,会从后续解码批次中移除,对应缓存行也会同步整理。
|
| 92 |
+
|
| 93 |
+
这些检查覆盖同一活动批次内不同行位置的稳定性,但不承诺不同批大小、调度方式、
|
| 94 |
+
GPU 型号或软件版本之间逐位完全相同。
|
| 95 |
+
|
| 96 |
+
## 模型权重与启动方式
|
| 97 |
+
|
| 98 |
+
以下命令均在本目录执行。按源权重格式选择 FP8 或 BF16 小节,两种格式使用不同的
|
| 99 |
+
输出目录。转换只切分权重并保留 dtype,不会把 BF16 权重量化成 FP8。
|
| 100 |
+
|
| 101 |
+
以源模型目录中的 `config.json` 为准。`convert.py` 写出
|
| 102 |
+
`tpN/model-r{rank}.safetensors` 及对应的 `config-r{rank}.json`,包含
|
| 103 |
+
`gqa-provider-shared-tp-v2` 布局标记和 `expert_parallel_size=N`;分词器文件保存在
|
| 104 |
+
共享的 `tokenizer_files/` 下。`generate.py` 从 `--tp-dir` 读取配置,并自动发现
|
| 105 |
+
转换时复制的分词器。推理目录不附带模型配置;分片配置必须与同一次转换产生的
|
| 106 |
+
权重配套使用。配置省略 `expert_parallel_size` 时,运行时使用 `tp_size`。
|
| 107 |
+
|
| 108 |
+
<a id="fp8"></a>
|
| 109 |
+
|
| 110 |
+
## FP8:单节点 8 卡
|
| 111 |
+
|
| 112 |
+
FP8 路径已在 **8 × H200** 上验证,使用 **TP=EP=8**,每个 rank 放置 44 个路由
|
| 113 |
+
专家。源配置需声明 `quant_method="fp8"`;排除量化的专家层和视觉编码器保留 BF16
|
| 114 |
+
权重。
|
| 115 |
+
|
| 116 |
+
设置源权重和输出目录:
|
| 117 |
+
|
| 118 |
+
```bash
|
| 119 |
+
export STEP5_FP8_CHECKPOINT="<fp8-checkpoint-directory>"
|
| 120 |
+
export STEP5_FP8_OUT="<fp8-shard-output-directory>"
|
| 121 |
+
```
|
| 122 |
+
|
| 123 |
+
先检查权重名称与分片形状,再转换到新的输出目录,转换只需执行一次。
|
| 124 |
+
`--check-only` 只读取文件头,不加载权重数据、不写分片:
|
| 125 |
+
|
| 126 |
+
```bash
|
| 127 |
+
python3 convert.py \
|
| 128 |
+
--checkpoint "$STEP5_FP8_CHECKPOINT" \
|
| 129 |
+
--tp-size 8 --ep-size 8 --check-only
|
| 130 |
+
|
| 131 |
+
python3 convert.py \
|
| 132 |
+
--checkpoint "$STEP5_FP8_CHECKPOINT" \
|
| 133 |
+
--out-dir "$STEP5_FP8_OUT" \
|
| 134 |
+
--tp-size 8 --ep-size 8
|
| 135 |
+
```
|
| 136 |
+
|
| 137 |
+
在可见 8 张 GPU 的节点上启动文本生成:
|
| 138 |
+
|
| 139 |
+
```bash
|
| 140 |
+
torchrun --standalone --nproc-per-node=8 generate.py \
|
| 141 |
+
--tp-dir "$STEP5_FP8_OUT/tp8" --ep-size 8 \
|
| 142 |
+
--prompt "请介绍一下张量并行。" \
|
| 143 |
+
--max-new-tokens 128
|
| 144 |
+
```
|
| 145 |
+
|
| 146 |
+
图文生成先按[图像输入约定](#vision-inputs),使用对应 checkpoint 的 processor
|
| 147 |
+
准备 prompt IDs 和图像张量,再运行:
|
| 148 |
+
|
| 149 |
+
```bash
|
| 150 |
+
torchrun --standalone --nproc-per-node=8 generate.py \
|
| 151 |
+
--tp-dir "$STEP5_FP8_OUT/tp8" --ep-size 8 \
|
| 152 |
+
--prompt-json prompt-ids.json --image-inputs image-inputs.safetensors \
|
| 153 |
+
--max-new-tokens 128
|
| 154 |
+
```
|
| 155 |
+
|
| 156 |
+
<a id="bf16"></a>
|
| 157 |
+
|
| 158 |
+
## BF16:双节点共 16 卡
|
| 159 |
+
|
| 160 |
+
完整 92 层 BF16 图文模型在 TP/EP=8 下,仅参数就约需 **144.41 GiB/卡**,超过
|
| 161 |
+
已验证 H200 节点的 **140.40 GiB/卡**。采用 **TP=EP=16** 时,实测加载参数约为
|
| 162 |
+
**74.93 GiB/卡**,每个 rank 放置 22 个路由专家。参数占用包含每卡复制的视觉权重和
|
| 163 |
+
已切分的输出层,不包含 MTP;KV 缓存、RoPE、激活和通信还需要额外显存。
|
| 164 |
+
|
| 165 |
+
完整 BF16 解码器、视觉编码器与投影层已在**双节点 16 张 H200** 上通过文本与单图
|
| 166 |
+
贪心生成验证。所有 rank 输出 token 一致、正常 EOS,全程 logits 均为有限值。
|
| 167 |
+
batch size 为 1、图文 prompt 共 943 个 token(含 898 个视觉 token)时,
|
| 168 |
+
实测 PyTorch 显存分配峰值为 **76.37 GiB/卡**;此数值不包含 NCCL、CUDA context
|
| 169 |
+
等 PyTorch 之外的分配。通信使用 **NCCL TCP Socket**;尚未验证 RDMA、长上下文
|
| 170 |
+
负载或吞吐性能。
|
| 171 |
+
|
| 172 |
+
完整模型实测使用转换器的 TP 规则流式读取、切分原始权重,并执行发布版模型的
|
| 173 |
+
forward。下方完整 BF16 `convert.py` → `generate.py --tp-dir` 文件分片流程尚未
|
| 174 |
+
单独端到端执行;权重名称与形状检查、BF16 小模型转换/加载/生成测试已通过。
|
| 175 |
+
|
| 176 |
+
使用两台各有 8 张可见 H200 的节点。两台机器需要相同代码和兼容环境、可用的 NCCL
|
| 177 |
+
网络,以及在配置路径下访问同一套转换后权重和分词器文件的能力。
|
| 178 |
+
图文生成时,两台机器还需能够读取内容相同的图像输入文件。
|
| 179 |
+
|
| 180 |
+
在两台节点上设置以下路径。源权重应为 BF16,且没有量化配置或量化配置为空:
|
| 181 |
+
|
| 182 |
+
```bash
|
| 183 |
+
export STEP5_BF16_CHECKPOINT="<bf16-checkpoint-directory>"
|
| 184 |
+
export STEP5_BF16_OUT="<bf16-shard-output-directory>"
|
| 185 |
+
```
|
| 186 |
+
|
| 187 |
+
**只在一台节点上执行一次**检查与转换,生成完整的 16 份分片。启动前确保两台节点
|
| 188 |
+
都能访问生成的 `tp16/` 和 `tokenizer_files/` 目录:
|
| 189 |
+
|
| 190 |
+
```bash
|
| 191 |
+
python3 convert.py \
|
| 192 |
+
--checkpoint "$STEP5_BF16_CHECKPOINT" \
|
| 193 |
+
--tp-size 16 --ep-size 16 --check-only
|
| 194 |
+
|
| 195 |
+
python3 convert.py \
|
| 196 |
+
--checkpoint "$STEP5_BF16_CHECKPOINT" \
|
| 197 |
+
--out-dir "$STEP5_BF16_OUT" \
|
| 198 |
+
--tp-size 16 --ep-size 16
|
| 199 |
+
```
|
| 200 |
+
|
| 201 |
+
在**两台节点上**设置相同、可访问的节点 0 地址,以及空闲的 rendezvous 端口:
|
| 202 |
+
|
| 203 |
+
```bash
|
| 204 |
+
export MASTER_ADDR="<node-0-reachable-IP-or-hostname>"
|
| 205 |
+
export MASTER_PORT=29500
|
| 206 |
+
```
|
| 207 |
+
|
| 208 |
+
在**节点 0**上设置:
|
| 209 |
+
|
| 210 |
+
```bash
|
| 211 |
+
export NODE_RANK=0
|
| 212 |
+
```
|
| 213 |
+
|
| 214 |
+
在**节点 1**上设置:
|
| 215 |
+
|
| 216 |
+
```bash
|
| 217 |
+
export NODE_RANK=1
|
| 218 |
+
```
|
| 219 |
+
|
| 220 |
+
在**两台节点上分别执行**下面的文本生成命令,使用各自的 `NODE_RANK` 加入同一次
|
| 221 |
+
启动。每台运行 8 个进程,总进程数为 16:
|
| 222 |
+
|
| 223 |
+
```bash
|
| 224 |
+
torchrun --nnodes=2 --nproc-per-node=8 \
|
| 225 |
+
--node-rank="$NODE_RANK" \
|
| 226 |
+
--master-addr="$MASTER_ADDR" --master-port="$MASTER_PORT" \
|
| 227 |
+
generate.py \
|
| 228 |
+
--tp-dir "$STEP5_BF16_OUT/tp16" --ep-size 16 \
|
| 229 |
+
--prompt "请介绍一下张量并行。" \
|
| 230 |
+
--max-new-tokens 128
|
| 231 |
+
```
|
| 232 |
+
|
| 233 |
+
图文生成则按[图像输入约定](#vision-inputs)准备数据,在**两台节点上分别执行**:
|
| 234 |
+
|
| 235 |
+
```bash
|
| 236 |
+
torchrun --nnodes=2 --nproc-per-node=8 \
|
| 237 |
+
--node-rank="$NODE_RANK" \
|
| 238 |
+
--master-addr="$MASTER_ADDR" --master-port="$MASTER_PORT" \
|
| 239 |
+
generate.py \
|
| 240 |
+
--tp-dir "$STEP5_BF16_OUT/tp16" --ep-size 16 \
|
| 241 |
+
--prompt-json prompt-ids.json --image-inputs image-inputs.safetensors \
|
| 242 |
+
--max-new-tokens 128
|
| 243 |
+
```
|
| 244 |
+
|
| 245 |
+
启动器设置 `WORLD_SIZE=2 × 8=16`,分片配置和 `--ep-size` 都必须为 16。
|
| 246 |
+
`--standalone` 仅用于上面的 FP8 单节点示例。
|
| 247 |
+
|
| 248 |
+
两种启动方式如需传入精确文本 token IDs,可将 `--prompt` 及其值替换为
|
| 249 |
+
`--prompt-json <prompt-json-file>`。文件接受 JSON 列表或
|
| 250 |
+
`{"prompt_ids": [...]}`;批量请求使用 `--prompt-json-batch`。
|
| 251 |
+
|
| 252 |
+
## 限制
|
| 253 |
+
|
| 254 |
+
- 完整模型生成已验证 FP8 单节点 8 × H200 和 BF16 双节点共 16 × H200,使用流式
|
| 255 |
+
权重加载;完整 BF16 文件分片转换/加载流程尚未端到端验证。
|
| 256 |
+
- `--ep-size` 默认 8,且当前必须等于 TP / 进程总数;独立的 TP 与 EP 进程组(例如
|
| 257 |
+
`TP=4, EP=2`)未实现。`generate.py` 从分片配置读取 TP(并对进程总数 `WORLD_SIZE` 做校验),
|
| 258 |
+
刻意没有单独的 `--tp-size` 开关。
|
| 259 |
+
- 滑窗注意力使用 PyTorch SDPA。
|
| 260 |
+
- 只支持贪心生成。连续动态批处理、分页 KV 缓存、前缀缓存和 CUDA Graph
|
| 261 |
+
均未提供。BF16 跨节点验证覆盖 NCCL TCP Socket 下的短文本与单图请求。
|
| 262 |
+
- MTP 层不加载,因此没有投机解码。
|
| 263 |
+
- 直接使用原始模型权重中的 FP8/BF16 专家布局;转换器与生成的 TP 分片和该权重布局绑定。
|
| 264 |
+
|
| 265 |
+
## 多模态 checkpoint(`step5_vl`)
|
| 266 |
+
|
| 267 |
+
`Step5ForConditionalGeneration` 使用 `Step5VLConfig`,顶层类型为
|
| 268 |
+
`model_type=step5_vl`,嵌套配置为 `text_config.model_type=step5` 和
|
| 269 |
+
`vision_config.model_type=perception_encoder`。纯文本 checkpoint 使用
|
| 270 |
+
`Step5Config` 和 `Step5ForCausalLM`。
|
| 271 |
+
|
| 272 |
+
Step-5 视觉编码器使用 PyTorch 算子,包含非因果 SDPA、二维 RoPE、绝对位置编码、
|
| 273 |
+
LayerScale 和两次卷积下采样。投影后的图像特征在 prefill 注入 packed 文本输入,
|
| 274 |
+
文本解码使用 DSA cache 和共置 TP/EP 执行。
|
| 275 |
+
|
| 276 |
+
已验证的 VL checkpoint 参数如下:
|
| 277 |
+
|
| 278 |
+
| 组件 | 配置 |
|
| 279 |
+
| --- | --- |
|
| 280 |
+
| 文本解码器 | 92 层、hidden size 4096;普通生成不加载 3 层 MTP |
|
| 281 |
+
| Vision Transformer | 47 层、width 1536、16 个注意力头、MLP size 8960 |
|
| 282 |
+
| Patch embedding | RGB,14×14 卷积核,stride 14 |
|
| 283 |
+
| 激活与归一化 | QuickGELU、pre-LN、无 post-LN、LayerScale 0.1 |
|
| 284 |
+
| 位置编码 | 绝对位置编码和二维 RoPE;无 CLS token |
|
| 285 |
+
| 下采样 | 两个 3×3 卷积,stride 2、padding 1;1536→3072→6144 |
|
| 286 |
+
| Projector | 6144→4096,无 bias |
|
| 287 |
+
| 图像占位符 | Token ID 128001 |
|
| 288 |
+
| 视觉权重 | 667 个 BF16 张量;每个 TP rank 约 3.96 GB |
|
| 289 |
+
|
| 290 |
+
Checkpoint 权重名为 `model.*`、`lm_head.*`、`vision_model.*` 和
|
| 291 |
+
`vit_large_projector.*`。Vision 与 projector 在每个 TP rank 上完整复制,
|
| 292 |
+
加载文件及 dtype 以 checkpoint 索引为准。转换器排除层编号
|
| 293 |
+
`>= text_config.num_hidden_layers` 的 MTP 权重。
|
| 294 |
+
|
| 295 |
+
VL checkpoint 同样使用上面的 [FP8](#fp8) 或 [BF16](#bf16) 检查与转换命令。
|
| 296 |
+
完整分片包含嵌套配置、`Step5ForConditionalGeneration` 架构名,以及文本、
|
| 297 |
+
vision 和 projector 权重。
|
| 298 |
+
|
| 299 |
+
断点恢复和正式加载都会先核对分片的完整 key、shape 以及 TP/EP/rank 元数据。
|
| 300 |
+
正式加载还会拒绝 Hugging Face 报告的缺失或异常 key,避免视觉权重缺失时
|
| 301 |
+
被默认初始化后继续推理。
|
| 302 |
+
|
| 303 |
+
<a id="vision-inputs"></a>
|
| 304 |
+
|
| 305 |
+
### 图像输入约定
|
| 306 |
+
|
| 307 |
+
图像接口如下:
|
| 308 |
+
|
| 309 |
+
| 参数 | 含义 |
|
| 310 |
+
| --- | --- |
|
| 311 |
+
| `pixel_values` | 预处理后的主图 `[图片数, 3, H, W]`,额外的前导 batch 维会被展平。 |
|
| 312 |
+
| `patch_pixel_values` | 可选裁剪图,按图片顺序排列。 |
|
| 313 |
+
| `num_patches` | 每张主图对应的非负整数裁剪数;省略时全部为 0。 |
|
| 314 |
+
| `image_embeds` | 已完成 projector 的 `[..., text_hidden_size]` 特征,与所有像素/裁剪参数互斥。 |
|
| 315 |
+
|
| 316 |
+
728×728 主图输出 169 个 token,504×504 裁剪图输出 81 个 token。
|
| 317 |
+
每张图片的顺序是**先全部裁剪图,再主图**;多请求按 packed prompt 顺序拼接。
|
| 318 |
+
`image_token_id` 的占位数量必须与特征 token 总数相同,`<im_start>` / `<im_end>`
|
| 319 |
+
仍使用文本 embedding。只在 prefill 编码图片,后续 decode 复用 cache。
|
| 320 |
+
|
| 321 |
+
缩放、归一化、裁剪和 prompt 构造使用对应 checkpoint 的 processor;这里接收
|
| 322 |
+
其预处理结果,没有另行定义 JPEG/PIL 预处理规则。可将结果保存为 safetensors:
|
| 323 |
+
|
| 324 |
+
```python
|
| 325 |
+
import torch
|
| 326 |
+
from safetensors.torch import save_file
|
| 327 |
+
|
| 328 |
+
save_file({
|
| 329 |
+
"pixel_values": pixel_values.contiguous(),
|
| 330 |
+
"patch_pixel_values": patch_pixel_values.contiguous(),
|
| 331 |
+
"num_patches": torch.tensor(num_patches, dtype=torch.int64),
|
| 332 |
+
}, "image-inputs.safetensors")
|
| 333 |
+
```
|
| 334 |
+
|
| 335 |
+
准备好包含图像占位 token 的 `prompt-ids.json` 和 `image-inputs.safetensors` 后,
|
| 336 |
+
使用 [FP8](#fp8) 或 [BF16](#bf16) 小节中对应的图文启动命令。
|
| 337 |
+
|
| 338 |
+
`--prompt-json-batch` 可混合文本和图像请求,各 rank 读取相同的图像输入文件。
|
| 339 |
+
Python 的 `forward` / `generate_greedy` 也接受这些图像参数。
|
| 340 |
+
|
| 341 |
+
安装可选测试依赖并运行多模态集成回归:
|
| 342 |
+
|
| 343 |
+
```bash
|
| 344 |
+
python -m pip install pytest
|
| 345 |
+
python -m pytest -q test_vision.py
|
| 346 |
+
```
|
| 347 |
+
|
| 348 |
+
支持 `vision_select_layer=-1` 和 `lang` 模式的二维 RoPE,其他层选择或频率模式会显式报错。
|
| 349 |
+
|
| 350 |
+
### 验证结果
|
| 351 |
+
|
| 352 |
+
以下结果来自 H200,运行环境为 PyTorch 2.13.0+cu129、Triton 3.7.1、Transformers 5.15.0:
|
| 353 |
+
|
| 354 |
+
| 检查 | 结果 |
|
| 355 |
+
| --- | --- |
|
| 356 |
+
| 视觉集成、配置身份与 BF16 加载测试 | 20 passed |
|
| 357 |
+
| 配置、生成、服务、评测和缓存回归 | 203 passed,93 subtests passed |
|
| 358 |
+
| GPU 算子检查 | 10 项全部通过 |
|
| 359 |
+
| 批次与 NCCL TP 稳定性 | 通过 |
|
| 360 |
+
| FP8 checkpoint 结构,TP8 | 2653 个 runtime 张量通过,排除 51 个 MTP 张量 |
|
| 361 |
+
| BF16 checkpoint 结构,TP8 与 TP16 | 2398 个 runtime 张量通过,排除 51 个 MTP 张量 |
|
| 362 |
+
| BF16 小模型生成 | TP1/TP8 像素与投影后 embedding 输入生成相同 token IDs |
|
| 363 |
+
| 完整视觉数值验证 | 主图和裁剪图输出精确匹配,最大绝对误差 0 |
|
| 364 |
+
| TP8 混合精度测试模型 | 像素与投影后 embedding 输入生成相同 token IDs |
|
| 365 |
+
| HF 文件分片加载,完整真实 rank 0 | 85.4 GB 分片加载通过,真实图像特征 `[898,4096]` 精确匹配 |
|
| 366 |
+
| 完整真实 FP8 checkpoint TP8 图像生成,流式权重加载 | 正确描述草地上的白色萨摩耶犬;生成 27 个 token,正常 EOS,各 rank 输出相同 |
|
| 367 |
+
| 完整真实 BF16 checkpoint,双节点 TP16/EP16,流式权重加载 | 文本与单图生成通过;logits 均为有限值、正常 EOS、16 个 rank 的 token 一致;参数 74.93 GiB/卡,PyTorch 分配峰值 76.37 GiB/卡 |
|
| 368 |
+
|
| 369 |
+
单图用例包含一张 728×728 主图和九张 504×504 裁剪图,共 898 个视觉 token。
|
| 370 |
+
视觉编码使用 PyTorch 和融合 GPU 注意力。这些结果覆盖所测输入的功能和数值正确性,
|
| 371 |
+
不构成多模态基准或 OCR/视频质量评测。
|
inference/convert.py
ADDED
|
@@ -0,0 +1,599 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Convert the original Step-5 checkpoint into validated TP shards.
|
| 2 |
+
|
| 3 |
+
The converter writes ``OUT/tpN/model-r{rank}.safetensors`` and matching
|
| 4 |
+
``config-r{rank}.json`` files, plus one shared ``OUT/tokenizer_files`` directory.
|
| 5 |
+
The only supported production layout is ``gqa-provider-shared-tp-v2``.
|
| 6 |
+
|
| 7 |
+
Example::
|
| 8 |
+
|
| 9 |
+
python3 convert.py --checkpoint /path/to/Step-5 \
|
| 10 |
+
--out-dir /path/to/Step-5-TP --tp-size 8 --ep-size 8
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
import argparse
|
| 16 |
+
import json
|
| 17 |
+
import os
|
| 18 |
+
import shutil
|
| 19 |
+
from typing import Any, Callable, TYPE_CHECKING
|
| 20 |
+
|
| 21 |
+
import torch
|
| 22 |
+
from safetensors import safe_open
|
| 23 |
+
from safetensors.torch import save_file
|
| 24 |
+
|
| 25 |
+
if TYPE_CHECKING:
|
| 26 |
+
from model import Step5Config
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
STEP5_TP_LAYOUT_VERSION = "gqa-provider-shared-tp-v2"
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
# Each rule callable maps (full_tensor, layer_idx_or_None, tp_size, rank, config) to the
|
| 33 |
+
# per-rank tensor. Rules are matched by attempting them in order; the first hit wins.
|
| 34 |
+
Rule = Callable[[torch.Tensor, int | None, int, int, Any], torch.Tensor]
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def _slice_dim(dim: int) -> Rule:
|
| 38 |
+
"""Column or Row: slice tensor along ``dim`` by rank's window, then clone."""
|
| 39 |
+
|
| 40 |
+
def apply(t, _layer, tp, rank, _config):
|
| 41 |
+
size = t.shape[dim]
|
| 42 |
+
assert size % tp == 0, f"{size} not divisible by tp={tp}"
|
| 43 |
+
per = size // tp
|
| 44 |
+
return t.narrow(dim, rank * per, per).clone()
|
| 45 |
+
|
| 46 |
+
return apply
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def _group_window(num_groups: int, tp: int, rank: int) -> tuple[int, int]:
|
| 50 |
+
"""Return ``(first_group, count)`` consumed by rank's contiguous Q-head slice.
|
| 51 |
+
|
| 52 |
+
For TP below the group count, groups partition across ranks. For TP above it, one group
|
| 53 |
+
is duplicated across ``tp / num_groups`` adjacent ranks. The latter is the production
|
| 54 |
+
TP=8 case: ``_group_window(4, 8, r) == (r // 2, 1)``.
|
| 55 |
+
"""
|
| 56 |
+
if not 0 <= rank < tp:
|
| 57 |
+
raise ValueError(f"rank {rank} outside tp_size={tp}")
|
| 58 |
+
if num_groups % tp == 0:
|
| 59 |
+
count = num_groups // tp
|
| 60 |
+
return rank * count, count
|
| 61 |
+
if tp % num_groups == 0:
|
| 62 |
+
replicas = tp // num_groups
|
| 63 |
+
return rank // replicas, 1
|
| 64 |
+
raise ValueError(
|
| 65 |
+
f"tp_size={tp} and group count {num_groups} are incompatible: "
|
| 66 |
+
"one must divide the other"
|
| 67 |
+
)
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def _slice_grouped_dim(dim: int, config_attr: str) -> Rule:
|
| 71 |
+
"""Slice whole GQA/provider groups, duplicating a group when TP exceeds group count."""
|
| 72 |
+
|
| 73 |
+
def apply(t, _layer, tp, rank, config):
|
| 74 |
+
owner = config
|
| 75 |
+
for component in config_attr.split("."):
|
| 76 |
+
owner = getattr(owner, component)
|
| 77 |
+
num_groups = int(owner)
|
| 78 |
+
size = t.shape[dim]
|
| 79 |
+
if size % num_groups:
|
| 80 |
+
raise ValueError(
|
| 81 |
+
f"tensor dim {dim} size {size} is not divisible by {num_groups} groups"
|
| 82 |
+
)
|
| 83 |
+
group_width = size // num_groups
|
| 84 |
+
first, count = _group_window(num_groups, tp, rank)
|
| 85 |
+
return t.narrow(dim, first * group_width, count * group_width).clone()
|
| 86 |
+
|
| 87 |
+
return apply
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def _replicate(t, _layer, _tp, _rank, _config):
|
| 91 |
+
return t.clone()
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
# First matching key substring selects the rule. Attention and dense/shared
|
| 95 |
+
# experts use TP dimension slices; routed experts use EP slices of the expert
|
| 96 |
+
# axis. Routers, normalization parameters and vision weights are replicated.
|
| 97 |
+
_SHARD_TABLE: list[tuple[str, Rule]] = [
|
| 98 |
+
# Attention: q_proj/g_proj ColumnParallel (slice head dim, dim 0); o_proj RowParallel
|
| 99 |
+
# (slice head dim on dim 1 of [hidden, num_heads*head_dim] = the in dim). K/V follow
|
| 100 |
+
# the GQA group consumed by the local Q slice. At TP=8 each rank gets one 192-d head,
|
| 101 |
+
# duplicated across adjacent rank pairs rather than replicating all four heads.
|
| 102 |
+
(".self_attn.q_proj.weight", _slice_dim(0)),
|
| 103 |
+
(".self_attn.g_proj.weight", _slice_dim(0)),
|
| 104 |
+
(".self_attn.o_proj.weight", _slice_dim(1)),
|
| 105 |
+
(
|
| 106 |
+
".self_attn.k_proj.weight",
|
| 107 |
+
_slice_grouped_dim(0, "num_attention_groups"),
|
| 108 |
+
),
|
| 109 |
+
(
|
| 110 |
+
".self_attn.v_proj.weight",
|
| 111 |
+
_slice_grouped_dim(0, "num_attention_groups"),
|
| 112 |
+
),
|
| 113 |
+
# Dense MLP: gate/up ColumnParallel (slice intermediate, dim 0); down RowParallel
|
| 114 |
+
# (slice intermediate on dim 1 of [hidden, intermediate] = the in dim).
|
| 115 |
+
(".mlp.gate_proj.weight", _slice_dim(0)),
|
| 116 |
+
(".mlp.up_proj.weight", _slice_dim(0)),
|
| 117 |
+
(".mlp.down_proj.weight", _slice_dim(1)),
|
| 118 |
+
# MoE routed experts -- EP, not TP-of-inner-dim. Each rank owns n_experts // tp_size
|
| 119 |
+
# contiguous experts (full inner dim per expert). Slicing the experts dim on dim 0. Block
|
| 120 |
+
# scales follow their weight onto the experts dim. ``moe.gate_proj.weight`` matches
|
| 121 |
+
# ``model.layers.N.moe.gate_proj.weight``; the leading ``.`` excludes ``share_expert`` keys.
|
| 122 |
+
(".moe.gate_proj.weight", _slice_dim(0)),
|
| 123 |
+
(".moe.up_proj.weight", _slice_dim(0)),
|
| 124 |
+
(".moe.down_proj.weight", _slice_dim(0)),
|
| 125 |
+
(".moe.gate_proj.weight_scale_inv", _slice_dim(0)),
|
| 126 |
+
(".moe.up_proj.weight_scale_inv", _slice_dim(0)),
|
| 127 |
+
(".moe.down_proj.weight_scale_inv", _slice_dim(0)),
|
| 128 |
+
# ``moe.gate.weight`` (the router) and ``moe.router_bias`` are replicated so every rank
|
| 129 |
+
# selects over all experts and sees the same router scores.
|
| 130 |
+
(".moe.gate.weight", _replicate),
|
| 131 |
+
(".moe.router_bias", _replicate),
|
| 132 |
+
# Shared expert is ordinary TP, not a replicate: gate/up slice their output dim and
|
| 133 |
+
# down slices its input dim. The runtime deliberately leaves the down result local,
|
| 134 |
+
# combines it with the local EP-routed result in FP32, then all-reduces once.
|
| 135 |
+
(".share_expert.gate_proj.weight", _slice_dim(0)),
|
| 136 |
+
(".share_expert.up_proj.weight", _slice_dim(0)),
|
| 137 |
+
(".share_expert.down_proj.weight", _slice_dim(1)),
|
| 138 |
+
# The indexer's Q/W heads are grouped by DSA provider group. Provider groups align with
|
| 139 |
+
# GQA KV groups, so TP=8 gives every rank one group (four Q/W heads), duplicated across
|
| 140 |
+
# the same adjacent rank pair. K/Z are single-head MQA projections and remain replicated.
|
| 141 |
+
# These exact matches must precede the generic sparse-indexer replicate rule.
|
| 142 |
+
(
|
| 143 |
+
".sparse_indexer_q.weight",
|
| 144 |
+
_slice_grouped_dim(0, "sparse_config.num_provider_groups"),
|
| 145 |
+
),
|
| 146 |
+
(
|
| 147 |
+
".sparse_indexer_w.weight",
|
| 148 |
+
_slice_grouped_dim(0, "sparse_config.num_provider_groups"),
|
| 149 |
+
),
|
| 150 |
+
(".sparse_indexer_", _replicate),
|
| 151 |
+
(".ssmax_s", _replicate),
|
| 152 |
+
# Attention + indexer RMSNorms: each is a small per-head scale on the routed tensor
|
| 153 |
+
# (the residual stream is full, so the norm acts on the full vector). Replicated.
|
| 154 |
+
(".q_norm.weight", _replicate),
|
| 155 |
+
(".k_norm.weight", _replicate),
|
| 156 |
+
(".k_norm.bias", _replicate),
|
| 157 |
+
# Per-layer input/post-attention RMSNorms and the model's final RMSNorm: replicated.
|
| 158 |
+
(".input_layernorm.weight", _replicate),
|
| 159 |
+
(".post_attention_layernorm.weight", _replicate),
|
| 160 |
+
# Embedding, lm_head: replicated (vocab is split at runtime by VocabParallelLinear).
|
| 161 |
+
("model.embed_tokens.weight", _replicate),
|
| 162 |
+
("model.norm.weight", _replicate),
|
| 163 |
+
("lm_head.weight", _replicate),
|
| 164 |
+
]
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
def _shard_for(key: str) -> Rule:
|
| 168 |
+
if key.startswith(("vision_model.", "vit_large_projector.")):
|
| 169 |
+
return _replicate
|
| 170 |
+
for substr, rule in _SHARD_TABLE:
|
| 171 |
+
if substr in key:
|
| 172 |
+
return rule
|
| 173 |
+
raise KeyError(f"no sharding rule for {key}")
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
def _layer_idx_of(key: str) -> int | None:
|
| 177 |
+
"""``model.layers.N.`` -- extract N if present and in-range, else None."""
|
| 178 |
+
marker = "model.layers."
|
| 179 |
+
if key.startswith(marker):
|
| 180 |
+
rest = key[len(marker):]
|
| 181 |
+
layer_str = rest.split(".", 1)[0]
|
| 182 |
+
try:
|
| 183 |
+
return int(layer_str)
|
| 184 |
+
except ValueError:
|
| 185 |
+
return None
|
| 186 |
+
return None
|
| 187 |
+
|
| 188 |
+
|
| 189 |
+
def _order_for_inspection(state: dict[str, torch.Tensor]) -> dict[str, torch.Tensor]:
|
| 190 |
+
"""Put embed / lm_head / norm first, then layers, then any stragglers."""
|
| 191 |
+
ordered: dict[str, torch.Tensor] = {}
|
| 192 |
+
for key in ("model.embed_tokens.weight", "model.norm.weight", "lm_head.weight"):
|
| 193 |
+
if key in state:
|
| 194 |
+
ordered[key] = state[key]
|
| 195 |
+
for key in sorted(state.keys()):
|
| 196 |
+
if key.startswith("model.layers."):
|
| 197 |
+
ordered[key] = state[key]
|
| 198 |
+
for key in sorted(state.keys()):
|
| 199 |
+
if key not in ordered:
|
| 200 |
+
ordered[key] = state[key]
|
| 201 |
+
return ordered
|
| 202 |
+
|
| 203 |
+
|
| 204 |
+
def _load_config_for(checkpoint: str, tp_size: int) -> Step5Config:
|
| 205 |
+
from model import config_from_dict
|
| 206 |
+
|
| 207 |
+
with open(os.path.join(checkpoint, "config.json")) as handle:
|
| 208 |
+
raw = json.load(handle)
|
| 209 |
+
raw["tp_size"] = tp_size
|
| 210 |
+
raw["expert_parallel_size"] = tp_size
|
| 211 |
+
raw["tp_layout_version"] = STEP5_TP_LAYOUT_VERSION
|
| 212 |
+
return config_from_dict(raw)
|
| 213 |
+
|
| 214 |
+
|
| 215 |
+
def _write_config(
|
| 216 |
+
existing_config_path: str,
|
| 217 |
+
out_path: str,
|
| 218 |
+
tp_size: int,
|
| 219 |
+
expert_parallel_size: int,
|
| 220 |
+
) -> None:
|
| 221 |
+
with open(existing_config_path) as handle:
|
| 222 |
+
cfg = json.load(handle)
|
| 223 |
+
cfg["tp_size"] = tp_size
|
| 224 |
+
cfg["expert_parallel_size"] = expert_parallel_size
|
| 225 |
+
cfg["tp_layout_version"] = STEP5_TP_LAYOUT_VERSION
|
| 226 |
+
cfg.pop("use_triton_sliding_attention", None)
|
| 227 |
+
from model import Step5VLConfig, config_from_dict
|
| 228 |
+
config = config_from_dict(cfg)
|
| 229 |
+
cfg = config.to_dict()
|
| 230 |
+
if isinstance(config, Step5VLConfig):
|
| 231 |
+
cfg["architectures"] = ["Step5ForConditionalGeneration"]
|
| 232 |
+
cfg["auto_map"] = {
|
| 233 |
+
"AutoConfig": "model.Step5VLConfig",
|
| 234 |
+
"AutoModelForCausalLM": "model.Step5ForConditionalGeneration",
|
| 235 |
+
"AutoModelForImageTextToText": "model.Step5ForConditionalGeneration",
|
| 236 |
+
}
|
| 237 |
+
else:
|
| 238 |
+
cfg["architectures"] = ["Step5ForCausalLM"]
|
| 239 |
+
cfg["auto_map"] = {
|
| 240 |
+
"AutoConfig": "model.Step5Config",
|
| 241 |
+
"AutoModelForCausalLM": "model.Step5ForCausalLM",
|
| 242 |
+
}
|
| 243 |
+
# ``num_attention_heads`` stays at the full value -- the model derives the per-rank view via
|
| 244 |
+
# ``num_attention_heads // tp_size``. ``tp_layout_version`` deliberately makes shards
|
| 245 |
+
# produced by either the old replicated-KV/provider rule or the v1 replicated-shared
|
| 246 |
+
# rule fail closed instead of loading with plausible but wrong shapes/semantics.
|
| 247 |
+
with open(out_path, "w") as handle:
|
| 248 |
+
json.dump(cfg, handle, indent=2)
|
| 249 |
+
handle.write("\n")
|
| 250 |
+
|
| 251 |
+
|
| 252 |
+
def _write_readme(
|
| 253 |
+
out_path: str,
|
| 254 |
+
tp_size: int,
|
| 255 |
+
expert_parallel_size: int,
|
| 256 |
+
checkpoint: str,
|
| 257 |
+
config: Step5Config,
|
| 258 |
+
) -> None:
|
| 259 |
+
local_kv = config.num_kv_groups_per_rank
|
| 260 |
+
local_index_heads = config.sparse_indexer_num_heads_per_rank
|
| 261 |
+
text = f"""# step5 TP-{tp_size} shards
|
| 262 |
+
|
| 263 |
+
This directory holds one `model-r{{0..{tp_size-1}}}.safetensors` and one
|
| 264 |
+
`config-r{{0..{tp_size-1}}}.json` file per rank. The sharding matches the tensor-parallel convention in
|
| 265 |
+
`model.py`. Layout marker: `{STEP5_TP_LAYOUT_VERSION}`. Shards without this
|
| 266 |
+
marker use an obsolete replicated-KV/provider or replicated-shared layout and must not be
|
| 267 |
+
resumed or loaded.
|
| 268 |
+
|
| 269 |
+
The same {tp_size} ranks form the dense tensor-parallel group and the routed-expert
|
| 270 |
+
placement group (`expert_parallel_size={expert_parallel_size}`). Independent TP and EP
|
| 271 |
+
process-group axes are not implemented.
|
| 272 |
+
|
| 273 |
+
| Tensor | Rule | Per-rank shape (tp={tp_size}) |
|
| 274 |
+
|--------|------|-------------------------------|
|
| 275 |
+
| `self_attn.q_proj.weight` | ColumnParallel, slice dim 0 by `num_heads / tp_size` | `[{64 // tp_size} * 192, 4096]` |
|
| 276 |
+
| `self_attn.g_proj.weight` | ColumnParallel, slice dim 0 by `num_heads / tp_size` | `[{64 // tp_size}, 4096]` |
|
| 277 |
+
| `self_attn.k_proj.weight` | GQA group slice; duplicate only for ranks consuming the same group | `[{local_kv} * 192, 4096]` |
|
| 278 |
+
| `self_attn.v_proj.weight` | GQA group slice; duplicate only for ranks consuming the same group | `[{local_kv} * 192, 4096]` |
|
| 279 |
+
| `self_attn.o_proj.weight` | RowParallel, slice dim 1 by `num_heads * head_dim / tp_size` | `[4096, {64 // tp_size} * 192]` |
|
| 280 |
+
| `mlp.gate_proj.weight` | ColumnParallel, slice dim 0 by `intermediate / tp_size` | `[{13824 // tp_size}, 4096]` |
|
| 281 |
+
| `mlp.up_proj.weight` | ColumnParallel, slice dim 0 by `intermediate / tp_size` | `[{13824 // tp_size}, 4096]` |
|
| 282 |
+
| `mlp.down_proj.weight` | RowParallel, slice dim 1 by `intermediate / tp_size` | `[4096, {13824 // tp_size}]` |
|
| 283 |
+
| `moe.gate.weight` | replicated (router, every rank selects over all experts) | `[352, 4096]` |
|
| 284 |
+
| `moe.gate_proj.weight` | EP, slice dim 0 (experts) by `n_experts / tp_size` | `[{352 // tp_size}, 1536, 4096]` |
|
| 285 |
+
| `moe.up_proj.weight` | EP, slice dim 0 (experts) by `n_experts / tp_size` | `[{352 // tp_size}, 1536, 4096]` |
|
| 286 |
+
| `moe.down_proj.weight` | EP, slice dim 0 (experts) by `n_experts / tp_size` | `[{352 // tp_size}, 4096, 1536]` |
|
| 287 |
+
| `moe.*.weight_scale_inv` | slice the experts dim (dim 0), inner block grid unchanged | `[{352 // tp_size}, ceil(out/128), ceil(in/128)]` |
|
| 288 |
+
| `moe.router_bias` | replicated | `[352]` |
|
| 289 |
+
| `share_expert.gate/up_proj.weight` | ColumnParallel, slice dim 0 by `share_expert_dim / tp_size` | `[{1536 // tp_size}, 4096]` |
|
| 290 |
+
| `share_expert.down_proj.weight` | RowParallel, slice dim 1; reduce deferred until shared+routed FP32 combine | `[4096, {1536 // tp_size}]` |
|
| 291 |
+
| `sparse_indexer_q.weight` | provider-group slice (4 heads/group) | `[{local_index_heads} * 256, 4096]` |
|
| 292 |
+
| `sparse_indexer_w.weight` | provider-group slice (4 heads/group) | `[{local_index_heads}, 4096]` |
|
| 293 |
+
| `sparse_indexer_k/z`, `ssmax_s`, `*_norm.weight`, `k_norm.bias` | replicated | full |
|
| 294 |
+
| `model.embed_tokens.weight` | replicated | `[128896, 4096]` |
|
| 295 |
+
| `lm_head.weight` | slices the vocab dim at runtime (VocabParallelLinear) | `[128896, 4096]` full per rank |
|
| 296 |
+
| `model.norm.weight` | replicated | `[4096]` |
|
| 297 |
+
|
| 298 |
+
Reference geometry: `num_attention_heads={64}`, `num_attention_groups={4}`, `head_dim={192}`,
|
| 299 |
+
`hidden={4096}`, `intermediate={13824}`, `moe_intermediate={1536}`, `vocab={128896}`,
|
| 300 |
+
`moe_num_experts={352}`, `topk={8}`. Produced from `{checkpoint}` by
|
| 301 |
+
`inference/convert.py`.
|
| 302 |
+
"""
|
| 303 |
+
with open(out_path, "w") as handle:
|
| 304 |
+
handle.write(text)
|
| 305 |
+
|
| 306 |
+
|
| 307 |
+
_TOKENIZER_FILES = (
|
| 308 |
+
"tokenizer.json",
|
| 309 |
+
"tokenizer_config.json",
|
| 310 |
+
"special_tokens_map.json",
|
| 311 |
+
"chat_template.jinja",
|
| 312 |
+
"vocab.json",
|
| 313 |
+
"merges.txt",
|
| 314 |
+
"added_tokens.json",
|
| 315 |
+
"preprocessor_config.json",
|
| 316 |
+
"processor_config.json",
|
| 317 |
+
)
|
| 318 |
+
|
| 319 |
+
|
| 320 |
+
def _build_rank_state(
|
| 321 |
+
checkpoint: str,
|
| 322 |
+
keys: list[str],
|
| 323 |
+
weight_map: dict[str, str],
|
| 324 |
+
tp_size: int,
|
| 325 |
+
rank: int,
|
| 326 |
+
config: Step5Config,
|
| 327 |
+
) -> dict[str, torch.Tensor]:
|
| 328 |
+
state: dict[str, torch.Tensor] = {}
|
| 329 |
+
chunks_by_shard: dict[str, list[str]] = {}
|
| 330 |
+
for key in keys:
|
| 331 |
+
chunks_by_shard.setdefault(weight_map[key], []).append(key)
|
| 332 |
+
for shard in sorted(chunks_by_shard):
|
| 333 |
+
print(f" rank {rank}: shard {shard}", flush=True)
|
| 334 |
+
with safe_open(os.path.join(checkpoint, shard), framework="pt", device="cpu") as handle:
|
| 335 |
+
for key in chunks_by_shard[shard]:
|
| 336 |
+
full = handle.get_tensor(key)
|
| 337 |
+
rule = _shard_for(key)
|
| 338 |
+
state[key] = rule(full, _layer_idx_of(key), tp_size, rank, config)
|
| 339 |
+
return state
|
| 340 |
+
|
| 341 |
+
|
| 342 |
+
def validate_rank_shard(path, config, rank, *, expected=None) -> None:
|
| 343 |
+
"""Reject incompatible or incomplete shards before HF can initialize missing weights.
|
| 344 |
+
|
| 345 |
+
Inspect headers only. In particular, text-only v2 shards must not load under
|
| 346 |
+
a VL config just because their parallel-layout metadata is still current.
|
| 347 |
+
"""
|
| 348 |
+
from model import model_class_for_config
|
| 349 |
+
|
| 350 |
+
if expected is None:
|
| 351 |
+
with torch.device("meta"):
|
| 352 |
+
expected = model_class_for_config(config)(config).state_dict()
|
| 353 |
+
text_config = config.get_text_config()
|
| 354 |
+
required_metadata = {
|
| 355 |
+
"tp_size": str(text_config.tp_size),
|
| 356 |
+
"expert_parallel_size": str(getattr(text_config, "expert_parallel_size", text_config.tp_size)),
|
| 357 |
+
"rank": str(rank),
|
| 358 |
+
}
|
| 359 |
+
with safe_open(str(path), framework="pt", device="cpu") as handle:
|
| 360 |
+
metadata = handle.metadata() or {}
|
| 361 |
+
# Tensor layout is unchanged; accept either serialized spelling, but
|
| 362 |
+
# reject conflicting markers rather than silently choosing one.
|
| 363 |
+
layout_fields = [name for name in ("step5_tp_layout", "step4_tp_layout") if name in metadata]
|
| 364 |
+
if not layout_fields:
|
| 365 |
+
raise ValueError(f"{path}: missing step5_tp_layout metadata")
|
| 366 |
+
for field in layout_fields:
|
| 367 |
+
if metadata[field] != STEP5_TP_LAYOUT_VERSION:
|
| 368 |
+
raise ValueError(
|
| 369 |
+
f"{path}: {field} metadata is {metadata[field]!r}, "
|
| 370 |
+
f"expected {STEP5_TP_LAYOUT_VERSION!r}"
|
| 371 |
+
)
|
| 372 |
+
for field, value in required_metadata.items():
|
| 373 |
+
if metadata.get(field) != value:
|
| 374 |
+
raise ValueError(f"{path}: {field} metadata is {metadata.get(field)!r}, expected {value!r}")
|
| 375 |
+
keys = set(handle.keys())
|
| 376 |
+
missing, unexpected = set(expected) - keys, keys - set(expected)
|
| 377 |
+
if missing or unexpected:
|
| 378 |
+
raise ValueError(
|
| 379 |
+
f"{path}: shard key mismatch: missing={sorted(missing)[:8]}, "
|
| 380 |
+
f"unexpected={sorted(unexpected)[:8]}; regenerate complete TP shards"
|
| 381 |
+
)
|
| 382 |
+
for key, tensor in expected.items():
|
| 383 |
+
shape = tuple(handle.get_slice(key).get_shape())
|
| 384 |
+
if shape != tuple(tensor.shape):
|
| 385 |
+
raise ValueError(f"{path}: {key} shape {shape} != model {tuple(tensor.shape)}")
|
| 386 |
+
|
| 387 |
+
|
| 388 |
+
def _validate_resume_shards(tp_dir, start_rank, *, config, expected) -> None:
|
| 389 |
+
"""Validate retained ranks with the same contract as the runtime loader."""
|
| 390 |
+
for rank in range(start_rank):
|
| 391 |
+
path = os.path.join(tp_dir, f"model-r{rank}.safetensors")
|
| 392 |
+
if not os.path.isfile(path):
|
| 393 |
+
raise FileNotFoundError(
|
| 394 |
+
f"cannot resume at rank {start_rank}: missing completed shard {path}"
|
| 395 |
+
)
|
| 396 |
+
validate_rank_shard(path, config, rank, expected=expected)
|
| 397 |
+
|
| 398 |
+
|
| 399 |
+
def pre_shard(
|
| 400 |
+
checkpoint: str,
|
| 401 |
+
out_dir: str,
|
| 402 |
+
tp_size: int,
|
| 403 |
+
*,
|
| 404 |
+
expert_parallel_size: int | None = None,
|
| 405 |
+
dry_run: bool = False,
|
| 406 |
+
start_rank: int = 0,
|
| 407 |
+
check_only: bool = False,
|
| 408 |
+
) -> int:
|
| 409 |
+
"""Write ``tp_size`` per-rank safetensors (plus configs and a README) to ``out_dir``.
|
| 410 |
+
|
| 411 |
+
``start_rank`` resumes a partial conversion after checking earlier ranks for
|
| 412 |
+
matching layout, topology and complete parameter keys/shapes.
|
| 413 |
+
"""
|
| 414 |
+
expert_parallel_size = (
|
| 415 |
+
tp_size if expert_parallel_size is None else expert_parallel_size
|
| 416 |
+
)
|
| 417 |
+
if tp_size < 1 or expert_parallel_size < 1:
|
| 418 |
+
raise ValueError(
|
| 419 |
+
f"parallel sizes must be positive, got TP={tp_size}, "
|
| 420 |
+
f"EP={expert_parallel_size}"
|
| 421 |
+
)
|
| 422 |
+
if expert_parallel_size != tp_size:
|
| 423 |
+
raise ValueError(
|
| 424 |
+
"independent TP and EP groups are not implemented: "
|
| 425 |
+
f"expert_parallel_size={expert_parallel_size} must equal "
|
| 426 |
+
f"tp_size={tp_size}. The same ranks shard the dense backbone and "
|
| 427 |
+
"the routed experts."
|
| 428 |
+
)
|
| 429 |
+
if not 0 <= start_rank < tp_size:
|
| 430 |
+
raise ValueError(
|
| 431 |
+
f"start_rank must be in [0, {tp_size}), got {start_rank}"
|
| 432 |
+
)
|
| 433 |
+
index_path = os.path.join(checkpoint, "model.safetensors.index.json")
|
| 434 |
+
with open(index_path) as handle:
|
| 435 |
+
weight_map = json.load(handle)["weight_map"]
|
| 436 |
+
|
| 437 |
+
config = _load_config_for(checkpoint, tp_size)
|
| 438 |
+
text_config = config.get_text_config()
|
| 439 |
+
keys = sorted(k for k in weight_map if (
|
| 440 |
+
_layer_idx_of(k) is None or _layer_idx_of(k) < text_config.num_hidden_layers
|
| 441 |
+
))
|
| 442 |
+
from model import model_class_for_config
|
| 443 |
+
|
| 444 |
+
with torch.device("meta"):
|
| 445 |
+
expected = model_class_for_config(config)(config).state_dict()
|
| 446 |
+
missing = set(expected) - set(keys)
|
| 447 |
+
unexpected = set(keys) - set(expected)
|
| 448 |
+
if missing or unexpected:
|
| 449 |
+
raise ValueError(
|
| 450 |
+
f"checkpoint key mismatch: missing={sorted(missing)[:8]}, "
|
| 451 |
+
f"unexpected={sorted(unexpected)[:8]}"
|
| 452 |
+
)
|
| 453 |
+
# Check every shape before reading weight data or creating outputs. This also
|
| 454 |
+
# detects vision geometry/configuration drift on the original checkpoint.
|
| 455 |
+
by_shard = {}
|
| 456 |
+
for key in keys:
|
| 457 |
+
by_shard.setdefault(weight_map[key], []).append(key)
|
| 458 |
+
for shard, shard_keys in sorted(by_shard.items()):
|
| 459 |
+
with safe_open(os.path.join(checkpoint, shard), framework="pt", device="cpu") as handle:
|
| 460 |
+
for key in shard_keys:
|
| 461 |
+
shape = handle.get_slice(key).get_shape()
|
| 462 |
+
full = torch.empty(shape, device="meta")
|
| 463 |
+
actual = _shard_for(key)(full, _layer_idx_of(key), tp_size, 0, text_config)
|
| 464 |
+
if actual.shape != expected[key].shape:
|
| 465 |
+
raise ValueError(f"{key}: shard shape {tuple(actual.shape)} != model {tuple(expected[key].shape)}")
|
| 466 |
+
print(f"validated {len(keys)} tensors; excluded {len(weight_map) - len(keys)} MTP tensors", flush=True)
|
| 467 |
+
if check_only:
|
| 468 |
+
print("PASS (checkpoint keys and TP shapes; no weight data read or files written)")
|
| 469 |
+
return 0
|
| 470 |
+
|
| 471 |
+
tp_dir = os.path.join(out_dir, f"tp{tp_size}")
|
| 472 |
+
os.makedirs(tp_dir, exist_ok=True)
|
| 473 |
+
if start_rank:
|
| 474 |
+
_validate_resume_shards(
|
| 475 |
+
tp_dir,
|
| 476 |
+
start_rank,
|
| 477 |
+
config=config,
|
| 478 |
+
expected=expected,
|
| 479 |
+
)
|
| 480 |
+
tokenizer_dst = os.path.join(out_dir, "tokenizer_files")
|
| 481 |
+
if not dry_run and not os.path.isdir(tokenizer_dst):
|
| 482 |
+
os.makedirs(tokenizer_dst, exist_ok=True)
|
| 483 |
+
for name in _TOKENIZER_FILES:
|
| 484 |
+
src = os.path.join(checkpoint, name)
|
| 485 |
+
if os.path.exists(src):
|
| 486 |
+
shutil.copy(src, os.path.join(tokenizer_dst, name))
|
| 487 |
+
|
| 488 |
+
sample_keys = [
|
| 489 |
+
"model.embed_tokens.weight",
|
| 490 |
+
"model.norm.weight",
|
| 491 |
+
"lm_head.weight",
|
| 492 |
+
"model.layers.0.self_attn.q_proj.weight",
|
| 493 |
+
"model.layers.0.self_attn.k_proj.weight",
|
| 494 |
+
"model.layers.0.self_attn.o_proj.weight",
|
| 495 |
+
"model.layers.0.mlp.gate_proj.weight",
|
| 496 |
+
"model.layers.0.mlp.down_proj.weight",
|
| 497 |
+
"model.layers.10.moe.gate_proj.weight",
|
| 498 |
+
"model.layers.10.moe.gate_proj.weight_scale_inv",
|
| 499 |
+
"model.layers.10.moe.down_proj.weight",
|
| 500 |
+
"model.layers.10.moe.down_proj.weight_scale_inv",
|
| 501 |
+
"model.layers.3.self_attn.sparse_indexer_q.weight",
|
| 502 |
+
"model.layers.3.self_attn.sparse_indexer_w.weight",
|
| 503 |
+
]
|
| 504 |
+
for rank in range(start_rank, tp_size):
|
| 505 |
+
print(f"=== rank {rank} ===", flush=True)
|
| 506 |
+
state = _build_rank_state(checkpoint, keys, weight_map, tp_size, rank, text_config)
|
| 507 |
+
if dry_run:
|
| 508 |
+
print(f"rank {rank}:")
|
| 509 |
+
for key in sample_keys:
|
| 510 |
+
if key in state:
|
| 511 |
+
print(f" {key}: {tuple(state[key].shape)} {state[key].dtype}")
|
| 512 |
+
else:
|
| 513 |
+
ordered = _order_for_inspection(state)
|
| 514 |
+
path = os.path.join(tp_dir, f"model-r{rank}.safetensors")
|
| 515 |
+
save_file(
|
| 516 |
+
ordered,
|
| 517 |
+
path,
|
| 518 |
+
metadata={
|
| 519 |
+
"format": "pt",
|
| 520 |
+
"format_version": "1",
|
| 521 |
+
"step5_tp_layout": STEP5_TP_LAYOUT_VERSION,
|
| 522 |
+
"tp_size": str(tp_size),
|
| 523 |
+
"expert_parallel_size": str(expert_parallel_size),
|
| 524 |
+
"rank": str(rank),
|
| 525 |
+
},
|
| 526 |
+
)
|
| 527 |
+
print(f" wrote {path} ({os.path.getsize(path) / 1e9:.2f} GB)")
|
| 528 |
+
|
| 529 |
+
if dry_run:
|
| 530 |
+
print(f"(dry_run: no files written, would produce tp{tp_size}/ in {out_dir})")
|
| 531 |
+
return 0
|
| 532 |
+
|
| 533 |
+
config_path = os.path.join(tp_dir, "config-r0.json")
|
| 534 |
+
if start_rank > 0:
|
| 535 |
+
print(f"resuming from rank {start_rank} (ranks 0..{start_rank - 1} skipped)", flush=True)
|
| 536 |
+
|
| 537 |
+
_write_config(
|
| 538 |
+
os.path.join(checkpoint, "config.json"),
|
| 539 |
+
config_path,
|
| 540 |
+
tp_size,
|
| 541 |
+
expert_parallel_size,
|
| 542 |
+
)
|
| 543 |
+
for rank in range(tp_size):
|
| 544 |
+
dst = os.path.join(tp_dir, f"config-r{rank}.json")
|
| 545 |
+
if dst != config_path:
|
| 546 |
+
shutil.copy(config_path, dst)
|
| 547 |
+
_write_readme(
|
| 548 |
+
os.path.join(tp_dir, "README.md"),
|
| 549 |
+
tp_size,
|
| 550 |
+
expert_parallel_size,
|
| 551 |
+
checkpoint,
|
| 552 |
+
text_config,
|
| 553 |
+
)
|
| 554 |
+
print(f" wrote {len(_TOKENIZER_FILES)} tokenizer files to {tokenizer_dst}")
|
| 555 |
+
print(f" wrote {tp_size} per-rank configs to {tp_dir}/config-r{{0..{tp_size-1}}}.json")
|
| 556 |
+
print("PASS")
|
| 557 |
+
return 0
|
| 558 |
+
|
| 559 |
+
|
| 560 |
+
def main() -> int:
|
| 561 |
+
parser = argparse.ArgumentParser(description=__doc__)
|
| 562 |
+
parser.add_argument("--checkpoint", required=True)
|
| 563 |
+
parser.add_argument("--out-dir", "--out_dir", dest="out_dir", default=None)
|
| 564 |
+
parser.add_argument("--tp-size", "--tp_size", dest="tp_size", type=int, default=8)
|
| 565 |
+
parser.add_argument(
|
| 566 |
+
"--ep-size",
|
| 567 |
+
"--expert-parallel-size",
|
| 568 |
+
dest="ep_size",
|
| 569 |
+
type=int,
|
| 570 |
+
default=8,
|
| 571 |
+
help="Routed-expert sharding degree (default: 8). The current "
|
| 572 |
+
"co-located topology requires EP size to equal TP size.",
|
| 573 |
+
)
|
| 574 |
+
parser.add_argument("--dry-run", "--dry_run", dest="dry_run", action="store_true")
|
| 575 |
+
parser.add_argument(
|
| 576 |
+
"--start-rank",
|
| 577 |
+
"--start_rank",
|
| 578 |
+
dest="start_rank",
|
| 579 |
+
type=int,
|
| 580 |
+
default=0,
|
| 581 |
+
help="Resume from this rank; ranks 0..start_rank-1 are assumed already on disk.",
|
| 582 |
+
)
|
| 583 |
+
parser.add_argument("--check-only", action="store_true", help="Validate all checkpoint keys and TP shapes without reading tensor data or writing shards")
|
| 584 |
+
args = parser.parse_args()
|
| 585 |
+
if not args.check_only and args.out_dir is None:
|
| 586 |
+
parser.error("--out-dir is required unless --check-only is set")
|
| 587 |
+
return pre_shard(
|
| 588 |
+
checkpoint=args.checkpoint,
|
| 589 |
+
out_dir=args.out_dir,
|
| 590 |
+
tp_size=args.tp_size,
|
| 591 |
+
expert_parallel_size=args.ep_size,
|
| 592 |
+
dry_run=args.dry_run,
|
| 593 |
+
start_rank=args.start_rank,
|
| 594 |
+
check_only=args.check_only,
|
| 595 |
+
)
|
| 596 |
+
|
| 597 |
+
|
| 598 |
+
if __name__ == "__main__":
|
| 599 |
+
raise SystemExit(main())
|
inference/generate.py
ADDED
|
@@ -0,0 +1,976 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Greedy Step5 inference over pre-sharded tensor-parallel weights.
|
| 2 |
+
|
| 3 |
+
This standalone entry point loads one ``model-r{rank}.safetensors`` file per
|
| 4 |
+
``torchrun`` process and uses :meth:`Step5ForCausalLM.generate_greedy`, whose
|
| 5 |
+
vocabulary-parallel selection all-gathers every rank's contiguous vocabulary
|
| 6 |
+
window before taking argmax.
|
| 7 |
+
|
| 8 |
+
Example::
|
| 9 |
+
|
| 10 |
+
torchrun --standalone --nproc-per-node=8 inference/generate.py \
|
| 11 |
+
--tp-dir /path/to/step5_tp8/tp8 \
|
| 12 |
+
--ep-size 8 \
|
| 13 |
+
--checkpoint /path/to/original_step5_checkpoint \
|
| 14 |
+
--prompt "请介绍一下张量并行。" \
|
| 15 |
+
--max-new-tokens 128
|
| 16 |
+
|
| 17 |
+
``--prompt-json`` accepts either a JSON list of token ids or an object containing
|
| 18 |
+
``prompt_ids``. This bypasses tokenization entirely and is useful for exact
|
| 19 |
+
parity runs. ``--prompt-json-batch`` accepts either a JSON list of token-id
|
| 20 |
+
lists or ``{"prompts": [{"prompt_ids": [...]}, ...]}`` and sends the packed
|
| 21 |
+
batch through one ``generate_greedy`` call. Multi-request output is
|
| 22 |
+
``{"batch_size": N, "results": [...]}``; a one-request batch retains the
|
| 23 |
+
original single-request JSON shape. The original checkpoint is only a tokenizer
|
| 24 |
+
source here; model weights always come from ``--tp-dir``.
|
| 25 |
+
"""
|
| 26 |
+
|
| 27 |
+
from __future__ import annotations
|
| 28 |
+
|
| 29 |
+
import argparse
|
| 30 |
+
import json
|
| 31 |
+
import os
|
| 32 |
+
from pathlib import Path
|
| 33 |
+
import sys
|
| 34 |
+
from typing import Any, Sequence
|
| 35 |
+
|
| 36 |
+
import torch
|
| 37 |
+
import torch.distributed as dist
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def build_parser() -> argparse.ArgumentParser:
|
| 41 |
+
parser = argparse.ArgumentParser(description=__doc__)
|
| 42 |
+
parser.add_argument(
|
| 43 |
+
"--tp-dir",
|
| 44 |
+
required=True,
|
| 45 |
+
help="Directory containing config-rN.json and model-rN.safetensors.",
|
| 46 |
+
)
|
| 47 |
+
prompt_group = parser.add_mutually_exclusive_group(required=True)
|
| 48 |
+
prompt_group.add_argument(
|
| 49 |
+
"--prompt",
|
| 50 |
+
help="User prompt text. A one-turn chat template is applied by default.",
|
| 51 |
+
)
|
| 52 |
+
prompt_group.add_argument(
|
| 53 |
+
"--prompt-json",
|
| 54 |
+
help="JSON list of exact token ids, or an object containing `prompt_ids`.",
|
| 55 |
+
)
|
| 56 |
+
prompt_group.add_argument(
|
| 57 |
+
"--prompt-json-batch",
|
| 58 |
+
help="Packed-batch JSON: either a list of token-id lists or an object "
|
| 59 |
+
"containing `prompts`, whose entries contain `prompt_ids`.",
|
| 60 |
+
)
|
| 61 |
+
parser.add_argument(
|
| 62 |
+
"--tokenizer",
|
| 63 |
+
"--tokenizer-path",
|
| 64 |
+
dest="tokenizer",
|
| 65 |
+
default=None,
|
| 66 |
+
help="Local tokenizer directory. Overrides --checkpoint.",
|
| 67 |
+
)
|
| 68 |
+
parser.add_argument(
|
| 69 |
+
"--checkpoint",
|
| 70 |
+
"--ckpt-path",
|
| 71 |
+
dest="checkpoint",
|
| 72 |
+
default=None,
|
| 73 |
+
help="Original local Step5 checkpoint directory used as the tokenizer "
|
| 74 |
+
"source. TP model weights are still loaded only from --tp-dir.",
|
| 75 |
+
)
|
| 76 |
+
parser.add_argument(
|
| 77 |
+
"--image-inputs",
|
| 78 |
+
help="Local safetensors containing pixel_values, optional patch_pixel_values/num_patches, "
|
| 79 |
+
"or already projected image_embeds. Use with exact prompt IDs containing image placeholders.",
|
| 80 |
+
)
|
| 81 |
+
parser.add_argument(
|
| 82 |
+
"--raw-prompt",
|
| 83 |
+
action="store_true",
|
| 84 |
+
help="Tokenize --prompt as plain text instead of applying the chat template.",
|
| 85 |
+
)
|
| 86 |
+
parser.add_argument(
|
| 87 |
+
"--max-new-tokens",
|
| 88 |
+
type=int,
|
| 89 |
+
default=128,
|
| 90 |
+
help="Maximum number of greedily decoded tokens (default: 128).",
|
| 91 |
+
)
|
| 92 |
+
parser.add_argument(
|
| 93 |
+
"--device",
|
| 94 |
+
default="cuda",
|
| 95 |
+
help="Device type/string; torchrun normally uses cuda (default: cuda).",
|
| 96 |
+
)
|
| 97 |
+
parser.add_argument(
|
| 98 |
+
"--ep-size",
|
| 99 |
+
"--expert-parallel-size",
|
| 100 |
+
dest="ep_size",
|
| 101 |
+
type=int,
|
| 102 |
+
default=8,
|
| 103 |
+
help="Routed-expert sharding degree (default: 8). The current "
|
| 104 |
+
"co-located topology requires EP size to equal TP/world size.",
|
| 105 |
+
)
|
| 106 |
+
return parser
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
|
| 110 |
+
parser = build_parser()
|
| 111 |
+
args = parser.parse_args(argv)
|
| 112 |
+
if args.max_new_tokens < 0:
|
| 113 |
+
parser.error("--max-new-tokens must be non-negative")
|
| 114 |
+
if args.ep_size < 1:
|
| 115 |
+
parser.error("--ep-size must be positive")
|
| 116 |
+
if args.raw_prompt and args.prompt is None:
|
| 117 |
+
parser.error("--raw-prompt is only valid with --prompt")
|
| 118 |
+
if args.image_inputs and args.prompt is not None:
|
| 119 |
+
parser.error("--image-inputs requires --prompt-json or --prompt-json-batch")
|
| 120 |
+
return args
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
def validate_parallel_topology(
|
| 124 |
+
config: Any,
|
| 125 |
+
*,
|
| 126 |
+
world: int,
|
| 127 |
+
requested_ep_size: int,
|
| 128 |
+
rank: int = 0,
|
| 129 |
+
) -> int:
|
| 130 |
+
"""Validate and return the co-located expert-parallel size.
|
| 131 |
+
|
| 132 |
+
Older validated TP shards predate the explicit
|
| 133 |
+
``expert_parallel_size`` metadata, so its only supported fallback is the
|
| 134 |
+
checkpoint TP size. Values read from JSON are checked without coercion:
|
| 135 |
+
accepting (for example) ``8.9`` as EP=8 would defeat the fail-closed
|
| 136 |
+
topology contract.
|
| 137 |
+
"""
|
| 138 |
+
config_tp_size = getattr(config, "tp_size", None)
|
| 139 |
+
if (
|
| 140 |
+
isinstance(config_tp_size, bool)
|
| 141 |
+
or not isinstance(config_tp_size, int)
|
| 142 |
+
or config_tp_size < 1
|
| 143 |
+
):
|
| 144 |
+
raise RuntimeError(
|
| 145 |
+
f"rank {rank}: checkpoint tp_size must be a positive integer, "
|
| 146 |
+
f"got {config_tp_size!r}"
|
| 147 |
+
)
|
| 148 |
+
if config_tp_size != world:
|
| 149 |
+
raise RuntimeError(
|
| 150 |
+
f"rank {rank}: checkpoint TP={config_tp_size}, "
|
| 151 |
+
f"torchrun world size={world}"
|
| 152 |
+
)
|
| 153 |
+
|
| 154 |
+
config_ep_size = getattr(config, "expert_parallel_size", config_tp_size)
|
| 155 |
+
if (
|
| 156 |
+
isinstance(config_ep_size, bool)
|
| 157 |
+
or not isinstance(config_ep_size, int)
|
| 158 |
+
or config_ep_size < 1
|
| 159 |
+
):
|
| 160 |
+
raise RuntimeError(
|
| 161 |
+
"checkpoint expert_parallel_size must be a positive integer, "
|
| 162 |
+
f"got {config_ep_size!r}"
|
| 163 |
+
)
|
| 164 |
+
if config_ep_size != config_tp_size:
|
| 165 |
+
raise RuntimeError(
|
| 166 |
+
"independent TP and EP groups are not implemented: checkpoint "
|
| 167 |
+
f"EP={config_ep_size} must equal TP={config_tp_size}"
|
| 168 |
+
)
|
| 169 |
+
if requested_ep_size != config_ep_size:
|
| 170 |
+
raise RuntimeError(
|
| 171 |
+
f"--ep-size={requested_ep_size} does not match checkpoint/runtime "
|
| 172 |
+
f"EP={config_ep_size}. This release co-locates TP and EP on the "
|
| 173 |
+
"same ranks."
|
| 174 |
+
)
|
| 175 |
+
return config_ep_size
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
def load_prompt_json(path: str | os.PathLike[str]) -> list[int]:
|
| 179 |
+
"""Load and validate the exact token ids in a parity prompt artifact."""
|
| 180 |
+
payload = json.loads(Path(path).read_text(encoding="utf-8"))
|
| 181 |
+
values = payload.get("prompt_ids") if isinstance(payload, dict) else payload
|
| 182 |
+
if not isinstance(values, list):
|
| 183 |
+
raise ValueError(
|
| 184 |
+
f"prompt JSON {path} must be a list or an object containing a `prompt_ids` list"
|
| 185 |
+
)
|
| 186 |
+
if not values:
|
| 187 |
+
raise ValueError(f"prompt JSON {path} contains no token ids")
|
| 188 |
+
|
| 189 |
+
prompt_ids: list[int] = []
|
| 190 |
+
for index, value in enumerate(values):
|
| 191 |
+
# JSON bool is an int subclass in Python, but never a meaningful token id.
|
| 192 |
+
if isinstance(value, bool) or not isinstance(value, int):
|
| 193 |
+
raise ValueError(
|
| 194 |
+
f"prompt JSON {path} token {index} is not an integer: {value!r}"
|
| 195 |
+
)
|
| 196 |
+
if value < 0:
|
| 197 |
+
raise ValueError(
|
| 198 |
+
f"prompt JSON {path} token {index} is negative: {value}"
|
| 199 |
+
)
|
| 200 |
+
prompt_ids.append(value)
|
| 201 |
+
return prompt_ids
|
| 202 |
+
|
| 203 |
+
|
| 204 |
+
def load_prompt_json_batch(path: str | os.PathLike[str]) -> list[list[int]]:
|
| 205 |
+
"""Load exact token ids for a packed batch.
|
| 206 |
+
|
| 207 |
+
Accepted schemas are either ``[[1, 2], [3, 4, 5]]`` or
|
| 208 |
+
``{"prompts": [{"prompt_ids": [1, 2]}, {"prompt_ids": [3, 4, 5]}]}``.
|
| 209 |
+
Empty batches and empty requests are rejected because neither can be
|
| 210 |
+
represented by the packed model/cache contract.
|
| 211 |
+
"""
|
| 212 |
+
payload = json.loads(Path(path).read_text(encoding="utf-8"))
|
| 213 |
+
object_schema = isinstance(payload, dict)
|
| 214 |
+
prompts = payload.get("prompts") if object_schema else payload
|
| 215 |
+
if not isinstance(prompts, list):
|
| 216 |
+
raise ValueError(
|
| 217 |
+
f"prompt batch JSON {path} must be a list of lists or an object "
|
| 218 |
+
"containing a `prompts` list"
|
| 219 |
+
)
|
| 220 |
+
if not prompts:
|
| 221 |
+
raise ValueError(f"prompt batch JSON {path} contains no prompts")
|
| 222 |
+
|
| 223 |
+
batch: list[list[int]] = []
|
| 224 |
+
for prompt_index, prompt in enumerate(prompts):
|
| 225 |
+
if object_schema:
|
| 226 |
+
if not isinstance(prompt, dict):
|
| 227 |
+
raise ValueError(
|
| 228 |
+
f"prompt batch JSON {path} prompt {prompt_index} must be "
|
| 229 |
+
"an object containing `prompt_ids`"
|
| 230 |
+
)
|
| 231 |
+
values = prompt.get("prompt_ids")
|
| 232 |
+
else:
|
| 233 |
+
values = prompt
|
| 234 |
+
if not isinstance(values, list):
|
| 235 |
+
raise ValueError(
|
| 236 |
+
f"prompt batch JSON {path} prompt {prompt_index} must contain "
|
| 237 |
+
f"a token-id list"
|
| 238 |
+
)
|
| 239 |
+
if not values:
|
| 240 |
+
raise ValueError(
|
| 241 |
+
f"prompt batch JSON {path} prompt {prompt_index} contains no token ids"
|
| 242 |
+
)
|
| 243 |
+
|
| 244 |
+
prompt_ids: list[int] = []
|
| 245 |
+
for token_index, value in enumerate(values):
|
| 246 |
+
if isinstance(value, bool) or not isinstance(value, int):
|
| 247 |
+
raise ValueError(
|
| 248 |
+
f"prompt batch JSON {path} prompt {prompt_index} token "
|
| 249 |
+
f"{token_index} is not an integer: {value!r}"
|
| 250 |
+
)
|
| 251 |
+
if value < 0:
|
| 252 |
+
raise ValueError(
|
| 253 |
+
f"prompt batch JSON {path} prompt {prompt_index} token "
|
| 254 |
+
f"{token_index} is negative: {value}"
|
| 255 |
+
)
|
| 256 |
+
prompt_ids.append(value)
|
| 257 |
+
batch.append(prompt_ids)
|
| 258 |
+
return batch
|
| 259 |
+
|
| 260 |
+
|
| 261 |
+
def resolve_tokenizer_source(
|
| 262 |
+
*,
|
| 263 |
+
tp_dir: str | os.PathLike[str],
|
| 264 |
+
tokenizer: str | None,
|
| 265 |
+
checkpoint: str | None,
|
| 266 |
+
) -> str | None:
|
| 267 |
+
"""Resolve explicit tokenizer options, then the pre-shard default layout.
|
| 268 |
+
|
| 269 |
+
``convert.py`` writes weights under ``OUT/tp8`` and tokenizer assets under
|
| 270 |
+
``OUT/tokenizer_files``. Explicit values are local directories and are
|
| 271 |
+
returned here before the loader validates their tokenizer files.
|
| 272 |
+
"""
|
| 273 |
+
if tokenizer:
|
| 274 |
+
return tokenizer
|
| 275 |
+
if checkpoint:
|
| 276 |
+
return checkpoint
|
| 277 |
+
|
| 278 |
+
tp_path = Path(tp_dir).expanduser()
|
| 279 |
+
candidates = (
|
| 280 |
+
tp_path / "tokenizer_files",
|
| 281 |
+
tp_path.parent / "tokenizer_files",
|
| 282 |
+
)
|
| 283 |
+
for candidate in candidates:
|
| 284 |
+
if candidate.is_dir():
|
| 285 |
+
return str(candidate)
|
| 286 |
+
return None
|
| 287 |
+
|
| 288 |
+
|
| 289 |
+
def tokenize_text(tokenizer: Any, text: str, *, raw_prompt: bool) -> list[int]:
|
| 290 |
+
"""Tokenize one prompt using the checkpoint's chat template or raw text."""
|
| 291 |
+
if raw_prompt:
|
| 292 |
+
encoded = tokenizer(text, add_special_tokens=True)
|
| 293 |
+
values = encoded.input_ids
|
| 294 |
+
else:
|
| 295 |
+
values = tokenizer.apply_chat_template(
|
| 296 |
+
[{"role": "user", "content": text}],
|
| 297 |
+
tokenize=True,
|
| 298 |
+
add_generation_prompt=True,
|
| 299 |
+
return_dict=False,
|
| 300 |
+
)
|
| 301 |
+
if hasattr(values, "input_ids"):
|
| 302 |
+
values = values.input_ids
|
| 303 |
+
elif isinstance(values, dict) and "input_ids" in values:
|
| 304 |
+
values = values["input_ids"]
|
| 305 |
+
if hasattr(values, "ids"):
|
| 306 |
+
values = values.ids
|
| 307 |
+
if isinstance(values, list) and len(values) == 1 and isinstance(values[0], list):
|
| 308 |
+
values = values[0]
|
| 309 |
+
if isinstance(values, torch.Tensor):
|
| 310 |
+
values = values.reshape(-1).tolist()
|
| 311 |
+
if not isinstance(values, list):
|
| 312 |
+
raise TypeError(
|
| 313 |
+
"tokenizer returned unsupported token IDs: "
|
| 314 |
+
f"{type(values).__name__}"
|
| 315 |
+
)
|
| 316 |
+
prompt_ids = [int(token) for token in values]
|
| 317 |
+
if not prompt_ids:
|
| 318 |
+
raise ValueError("tokenizer produced an empty prompt")
|
| 319 |
+
if any(token < 0 for token in prompt_ids):
|
| 320 |
+
raise ValueError("tokenizer produced a negative token id")
|
| 321 |
+
return prompt_ids
|
| 322 |
+
|
| 323 |
+
|
| 324 |
+
def validate_prompt_ids(
|
| 325 |
+
prompt_ids: Sequence[int],
|
| 326 |
+
*,
|
| 327 |
+
vocab_size: int,
|
| 328 |
+
max_position_embeddings: int,
|
| 329 |
+
max_new_tokens: int,
|
| 330 |
+
image_token_id: int | None = None,
|
| 331 |
+
) -> None:
|
| 332 |
+
if not prompt_ids:
|
| 333 |
+
raise ValueError("prompt must contain at least one token")
|
| 334 |
+
for index, token in enumerate(prompt_ids):
|
| 335 |
+
if token < 0 or (token >= vocab_size and token != image_token_id):
|
| 336 |
+
raise ValueError(
|
| 337 |
+
f"prompt token {index}={token} is outside vocabulary [0, {vocab_size})"
|
| 338 |
+
)
|
| 339 |
+
requested = len(prompt_ids) + max_new_tokens
|
| 340 |
+
if requested > max_position_embeddings:
|
| 341 |
+
raise ValueError(
|
| 342 |
+
f"prompt ({len(prompt_ids)} tokens) + completion ({max_new_tokens}) "
|
| 343 |
+
f"exceeds max_position_embeddings={max_position_embeddings}"
|
| 344 |
+
)
|
| 345 |
+
|
| 346 |
+
|
| 347 |
+
def _load_rank_config(tp_dir: str, rank: int):
|
| 348 |
+
# Lazy imports keep parser/helper tests independent of transformers and Triton.
|
| 349 |
+
from model import config_from_dict
|
| 350 |
+
|
| 351 |
+
path = Path(tp_dir) / f"config-r{rank}.json"
|
| 352 |
+
if not path.is_file():
|
| 353 |
+
raise FileNotFoundError(f"rank {rank}: missing TP config {path}")
|
| 354 |
+
payload = json.loads(path.read_text(encoding="utf-8"))
|
| 355 |
+
return config_from_dict(payload)
|
| 356 |
+
|
| 357 |
+
|
| 358 |
+
def _load_tokenizer(source: str, *, require_chat_template: bool = False):
|
| 359 |
+
source_path = Path(source).expanduser()
|
| 360 |
+
if not source_path.is_dir():
|
| 361 |
+
raise FileNotFoundError(
|
| 362 |
+
f"tokenizer source must be a local directory: {source_path}"
|
| 363 |
+
)
|
| 364 |
+
required_files = (
|
| 365 |
+
"tokenizer.json",
|
| 366 |
+
"tokenizer_config.json",
|
| 367 |
+
)
|
| 368 |
+
missing = [
|
| 369 |
+
name for name in required_files if not (source_path / name).is_file()
|
| 370 |
+
]
|
| 371 |
+
if missing:
|
| 372 |
+
raise FileNotFoundError(
|
| 373 |
+
f"tokenizer source is missing required file(s): "
|
| 374 |
+
f"{', '.join(missing)}"
|
| 375 |
+
)
|
| 376 |
+
|
| 377 |
+
from transformers import PreTrainedTokenizerFast
|
| 378 |
+
|
| 379 |
+
# Load Step-5's serialized fast tokenizer directly. Automatic class selection
|
| 380 |
+
# in Transformers 5.15 can collapse long byte-level BPE prompts. The generic
|
| 381 |
+
# fast loader preserves the tokenizer.json model, pre-tokenizer, and decoder
|
| 382 |
+
# and is ID-identical to the validated Transformers 4.57.6 loader.
|
| 383 |
+
tokenizer = PreTrainedTokenizerFast.from_pretrained(
|
| 384 |
+
str(source_path),
|
| 385 |
+
trust_remote_code=True,
|
| 386 |
+
local_files_only=True,
|
| 387 |
+
)
|
| 388 |
+
if require_chat_template and not getattr(tokenizer, "chat_template", None):
|
| 389 |
+
raise ValueError(
|
| 390 |
+
"tokenizer has no chat template; define `chat_template` in "
|
| 391 |
+
"tokenizer_config.json or provide chat_template.jinja"
|
| 392 |
+
)
|
| 393 |
+
return tokenizer
|
| 394 |
+
|
| 395 |
+
|
| 396 |
+
def _load_exact_id_tokenizer(
|
| 397 |
+
source: str | None,
|
| 398 |
+
*,
|
| 399 |
+
explicit_source: bool,
|
| 400 |
+
):
|
| 401 |
+
"""Optionally load the tokenizer used only to decode exact-ID completions."""
|
| 402 |
+
if source is None:
|
| 403 |
+
return None
|
| 404 |
+
try:
|
| 405 |
+
return _load_tokenizer(source, require_chat_template=False)
|
| 406 |
+
except FileNotFoundError:
|
| 407 |
+
if explicit_source:
|
| 408 |
+
raise
|
| 409 |
+
return None
|
| 410 |
+
|
| 411 |
+
|
| 412 |
+
def resolve_generation_eos_token_ids(
|
| 413 |
+
config_eos_token_id: Any,
|
| 414 |
+
tokenizer: Any | None,
|
| 415 |
+
) -> set[int]:
|
| 416 |
+
"""Union checkpoint and tokenizer EOS ids for production stopping.
|
| 417 |
+
|
| 418 |
+
Step5's converted TP config can retain base-model EOS ids while the chat
|
| 419 |
+
tokenizer terminates assistant turns with a different ``<|im_end|>`` id.
|
| 420 |
+
Passing only the config values to ``generate_greedy`` would continue past
|
| 421 |
+
the tokenizer's real end-of-turn marker. Keep both sets rather than
|
| 422 |
+
replacing either source.
|
| 423 |
+
"""
|
| 424 |
+
|
| 425 |
+
result: set[int] = set()
|
| 426 |
+
|
| 427 |
+
def add(value: Any, *, label: str) -> None:
|
| 428 |
+
if value is None:
|
| 429 |
+
return
|
| 430 |
+
values = value if isinstance(value, (list, tuple, set)) else [value]
|
| 431 |
+
for token in values:
|
| 432 |
+
if isinstance(token, bool) or not isinstance(token, int) or token < 0:
|
| 433 |
+
raise ValueError(
|
| 434 |
+
f"{label} must contain non-negative integer token ids, "
|
| 435 |
+
f"got {token!r}"
|
| 436 |
+
)
|
| 437 |
+
result.add(int(token))
|
| 438 |
+
|
| 439 |
+
add(config_eos_token_id, label="config eos_token_id")
|
| 440 |
+
if tokenizer is not None:
|
| 441 |
+
add(
|
| 442 |
+
getattr(tokenizer, "eos_token_id", None),
|
| 443 |
+
label="tokenizer eos_token_id",
|
| 444 |
+
)
|
| 445 |
+
if not result:
|
| 446 |
+
raise ValueError("generation has no EOS token ids")
|
| 447 |
+
return result
|
| 448 |
+
|
| 449 |
+
|
| 450 |
+
def _broadcast_error(error: str | None, *, device: torch.device) -> None:
|
| 451 |
+
"""Make a rank-0 prompt preparation error visible before any peer blocks."""
|
| 452 |
+
payload: list[str | None] = [error]
|
| 453 |
+
kwargs: dict[str, Any] = {}
|
| 454 |
+
if device.type == "cuda":
|
| 455 |
+
kwargs["device"] = device
|
| 456 |
+
dist.broadcast_object_list(payload, src=0, **kwargs)
|
| 457 |
+
if payload[0] is not None:
|
| 458 |
+
raise RuntimeError(f"rank 0 failed to prepare the prompt: {payload[0]}")
|
| 459 |
+
|
| 460 |
+
|
| 461 |
+
def broadcast_eos_token_ids(
|
| 462 |
+
eos_token_ids: set[int] | None,
|
| 463 |
+
*,
|
| 464 |
+
rank: int,
|
| 465 |
+
device: torch.device,
|
| 466 |
+
) -> set[int]:
|
| 467 |
+
"""Broadcast rank 0's effective config+tokenizer EOS set to every TP rank."""
|
| 468 |
+
|
| 469 |
+
if rank == 0:
|
| 470 |
+
if not eos_token_ids:
|
| 471 |
+
raise ValueError("rank 0 must provide at least one EOS token id")
|
| 472 |
+
values = sorted(eos_token_ids)
|
| 473 |
+
length = torch.tensor([len(values)], dtype=torch.int64, device=device)
|
| 474 |
+
else:
|
| 475 |
+
values = []
|
| 476 |
+
length = torch.zeros(1, dtype=torch.int64, device=device)
|
| 477 |
+
dist.broadcast(length, src=0)
|
| 478 |
+
count = int(length.item())
|
| 479 |
+
if count <= 0:
|
| 480 |
+
raise ValueError(f"broadcast EOS set has invalid length {count}")
|
| 481 |
+
if rank == 0:
|
| 482 |
+
tokens = torch.tensor(values, dtype=torch.int64, device=device)
|
| 483 |
+
else:
|
| 484 |
+
tokens = torch.empty(count, dtype=torch.int64, device=device)
|
| 485 |
+
dist.broadcast(tokens, src=0)
|
| 486 |
+
result = {int(token) for token in tokens.cpu().tolist()}
|
| 487 |
+
if len(result) != count or any(token < 0 for token in result):
|
| 488 |
+
raise ValueError(f"broadcast EOS set is invalid: {sorted(result)}")
|
| 489 |
+
return result
|
| 490 |
+
|
| 491 |
+
|
| 492 |
+
def broadcast_prompt_ids(
|
| 493 |
+
prompt_ids: Sequence[int] | None,
|
| 494 |
+
*,
|
| 495 |
+
rank: int,
|
| 496 |
+
device: torch.device,
|
| 497 |
+
) -> list[int]:
|
| 498 |
+
"""Broadcast a variable-length rank-0 prompt with tensor collectives."""
|
| 499 |
+
if rank == 0:
|
| 500 |
+
if prompt_ids is None:
|
| 501 |
+
raise ValueError("rank 0 must provide prompt ids")
|
| 502 |
+
length = torch.tensor([len(prompt_ids)], dtype=torch.int64, device=device)
|
| 503 |
+
else:
|
| 504 |
+
length = torch.zeros(1, dtype=torch.int64, device=device)
|
| 505 |
+
dist.broadcast(length, src=0)
|
| 506 |
+
token_count = int(length.item())
|
| 507 |
+
if token_count <= 0:
|
| 508 |
+
raise ValueError(f"broadcast prompt has invalid length {token_count}")
|
| 509 |
+
|
| 510 |
+
if rank == 0:
|
| 511 |
+
tokens = torch.tensor(prompt_ids, dtype=torch.long, device=device)
|
| 512 |
+
else:
|
| 513 |
+
tokens = torch.empty(token_count, dtype=torch.long, device=device)
|
| 514 |
+
dist.broadcast(tokens, src=0)
|
| 515 |
+
return [int(token) for token in tokens.cpu().tolist()]
|
| 516 |
+
|
| 517 |
+
|
| 518 |
+
def broadcast_prompt_batch(
|
| 519 |
+
prompt_batch: Sequence[Sequence[int]] | None,
|
| 520 |
+
*,
|
| 521 |
+
rank: int,
|
| 522 |
+
device: torch.device,
|
| 523 |
+
) -> list[list[int]]:
|
| 524 |
+
"""Broadcast rank 0's packed batch as lengths plus one flat token tensor."""
|
| 525 |
+
if rank == 0:
|
| 526 |
+
if prompt_batch is None:
|
| 527 |
+
raise ValueError("rank 0 must provide a prompt batch")
|
| 528 |
+
batch_size = len(prompt_batch)
|
| 529 |
+
batch_size_tensor = torch.tensor(
|
| 530 |
+
[batch_size], dtype=torch.int64, device=device
|
| 531 |
+
)
|
| 532 |
+
else:
|
| 533 |
+
batch_size_tensor = torch.zeros(1, dtype=torch.int64, device=device)
|
| 534 |
+
dist.broadcast(batch_size_tensor, src=0)
|
| 535 |
+
batch_size = int(batch_size_tensor.item())
|
| 536 |
+
if batch_size <= 0:
|
| 537 |
+
raise ValueError(f"broadcast prompt batch has invalid size {batch_size}")
|
| 538 |
+
|
| 539 |
+
if rank == 0:
|
| 540 |
+
assert prompt_batch is not None
|
| 541 |
+
lengths = torch.tensor(
|
| 542 |
+
[len(prompt) for prompt in prompt_batch],
|
| 543 |
+
dtype=torch.int64,
|
| 544 |
+
device=device,
|
| 545 |
+
)
|
| 546 |
+
else:
|
| 547 |
+
lengths = torch.empty(batch_size, dtype=torch.int64, device=device)
|
| 548 |
+
dist.broadcast(lengths, src=0)
|
| 549 |
+
if bool((lengths <= 0).any().item()):
|
| 550 |
+
raise ValueError(
|
| 551 |
+
f"broadcast prompt batch has invalid lengths {lengths.cpu().tolist()}"
|
| 552 |
+
)
|
| 553 |
+
token_count = int(lengths.sum().item())
|
| 554 |
+
|
| 555 |
+
if rank == 0:
|
| 556 |
+
assert prompt_batch is not None
|
| 557 |
+
flat_tokens = torch.tensor(
|
| 558 |
+
[token for prompt in prompt_batch for token in prompt],
|
| 559 |
+
dtype=torch.long,
|
| 560 |
+
device=device,
|
| 561 |
+
)
|
| 562 |
+
if flat_tokens.numel() != token_count:
|
| 563 |
+
raise AssertionError(
|
| 564 |
+
f"rank 0 prompt batch has {flat_tokens.numel()} flat tokens, "
|
| 565 |
+
f"but lengths sum to {token_count}"
|
| 566 |
+
)
|
| 567 |
+
else:
|
| 568 |
+
flat_tokens = torch.empty(token_count, dtype=torch.long, device=device)
|
| 569 |
+
dist.broadcast(flat_tokens, src=0)
|
| 570 |
+
|
| 571 |
+
host_lengths = [int(length) for length in lengths.cpu().tolist()]
|
| 572 |
+
host_tokens = [int(token) for token in flat_tokens.cpu().tolist()]
|
| 573 |
+
result: list[list[int]] = []
|
| 574 |
+
offset = 0
|
| 575 |
+
for length in host_lengths:
|
| 576 |
+
result.append(host_tokens[offset : offset + length])
|
| 577 |
+
offset += length
|
| 578 |
+
if offset != len(host_tokens):
|
| 579 |
+
raise AssertionError(
|
| 580 |
+
f"broadcast prompt batch consumed {offset} of {len(host_tokens)} tokens"
|
| 581 |
+
)
|
| 582 |
+
return result
|
| 583 |
+
|
| 584 |
+
|
| 585 |
+
def _resolve_device(device_arg: str, local_rank: int) -> torch.device:
|
| 586 |
+
requested = torch.device(device_arg)
|
| 587 |
+
if requested.type == "cuda":
|
| 588 |
+
if not torch.cuda.is_available():
|
| 589 |
+
raise RuntimeError("CUDA was requested but torch.cuda.is_available() is false")
|
| 590 |
+
# An explicit index remains useful for single-rank debugging. The normal
|
| 591 |
+
# torchrun spelling is simply --device cuda, which maps LOCAL_RANK here.
|
| 592 |
+
device = (
|
| 593 |
+
torch.device("cuda", local_rank)
|
| 594 |
+
if requested.index is None
|
| 595 |
+
else requested
|
| 596 |
+
)
|
| 597 |
+
torch.cuda.set_device(device)
|
| 598 |
+
return device
|
| 599 |
+
return requested
|
| 600 |
+
|
| 601 |
+
|
| 602 |
+
def _load_rank_model(tp_dir: str, rank: int, config: Any, device: torch.device):
|
| 603 |
+
from convert import validate_rank_shard
|
| 604 |
+
from model import model_class_for_config
|
| 605 |
+
|
| 606 |
+
shard = Path(tp_dir) / f"model-r{rank}.safetensors"
|
| 607 |
+
if not shard.is_file():
|
| 608 |
+
raise FileNotFoundError(f"rank {rank}: missing TP weight shard {shard}")
|
| 609 |
+
validate_rank_shard(shard, config, rank)
|
| 610 |
+
model, loading_info = model_class_for_config(config).from_pretrained(
|
| 611 |
+
str(shard), config=config, output_loading_info=True,
|
| 612 |
+
)
|
| 613 |
+
failures = {key: loading_info.get(key) for key in (
|
| 614 |
+
"missing_keys", "unexpected_keys", "mismatched_keys", "error_msgs",
|
| 615 |
+
) if loading_info.get(key)}
|
| 616 |
+
if failures:
|
| 617 |
+
raise RuntimeError(f"rank {rank}: incomplete checkpoint loading: {failures}")
|
| 618 |
+
# TP shards intentionally keep a full replicated lm_head on disk. Transformers 4
|
| 619 |
+
# sliced it through ``_load_from_state_dict``; Transformers 5 injects tensors directly,
|
| 620 |
+
# so the module must be narrowed explicitly after strict loading.
|
| 621 |
+
model.lm_head.shard_(rank)
|
| 622 |
+
model = model.to(device).eval()
|
| 623 |
+
model.pack_qkv_()
|
| 624 |
+
model.model.build_rope(device)
|
| 625 |
+
return model
|
| 626 |
+
|
| 627 |
+
|
| 628 |
+
def generate_completion(
|
| 629 |
+
model: Any,
|
| 630 |
+
prompt_ids: list[int],
|
| 631 |
+
*,
|
| 632 |
+
max_new_tokens: int,
|
| 633 |
+
device: torch.device,
|
| 634 |
+
) -> list[int]:
|
| 635 |
+
"""Run the model's TP-safe greedy path for one request.
|
| 636 |
+
|
| 637 |
+
``Step5ForCausalLM.generate_greedy`` calls ``vocab_parallel_argmax`` at
|
| 638 |
+
every step. It therefore reconstructs the full vocabulary and returns a
|
| 639 |
+
global token id, rather than treating a rank-local column as a token id.
|
| 640 |
+
"""
|
| 641 |
+
completions = generate_completions(
|
| 642 |
+
model,
|
| 643 |
+
[prompt_ids],
|
| 644 |
+
max_new_tokens=max_new_tokens,
|
| 645 |
+
device=device,
|
| 646 |
+
)
|
| 647 |
+
return completions[0]
|
| 648 |
+
|
| 649 |
+
|
| 650 |
+
def generate_completions(
|
| 651 |
+
model: Any,
|
| 652 |
+
prompt_batch: list[list[int]],
|
| 653 |
+
*,
|
| 654 |
+
max_new_tokens: int,
|
| 655 |
+
device: torch.device,
|
| 656 |
+
eos_token_ids: set[int] | None = None,
|
| 657 |
+
image_inputs: dict[str, Any] | None = None,
|
| 658 |
+
) -> list[list[int]]:
|
| 659 |
+
"""Run one packed batch through the model's TP-safe greedy path."""
|
| 660 |
+
if not prompt_batch:
|
| 661 |
+
raise ValueError("prompt batch must contain at least one request")
|
| 662 |
+
generation_error: BaseException | None = None
|
| 663 |
+
try:
|
| 664 |
+
generation_kwargs: dict[str, Any] = {
|
| 665 |
+
"max_new_tokens": max_new_tokens,
|
| 666 |
+
"device": device,
|
| 667 |
+
}
|
| 668 |
+
if eos_token_ids is not None:
|
| 669 |
+
generation_kwargs["eos_token_ids"] = eos_token_ids
|
| 670 |
+
if image_inputs:
|
| 671 |
+
generation_kwargs.update(image_inputs)
|
| 672 |
+
completions = model.generate_greedy(prompt_batch, **generation_kwargs)
|
| 673 |
+
if not isinstance(completions, list):
|
| 674 |
+
raise TypeError(
|
| 675 |
+
"generate_greedy must return a list of token-ID lists; "
|
| 676 |
+
f"got {type(completions).__name__}"
|
| 677 |
+
)
|
| 678 |
+
if len(completions) != len(prompt_batch):
|
| 679 |
+
raise RuntimeError(
|
| 680 |
+
f"expected {len(prompt_batch)} completions, got {len(completions)}"
|
| 681 |
+
)
|
| 682 |
+
for completion_index, completion in enumerate(completions):
|
| 683 |
+
if not isinstance(completion, list):
|
| 684 |
+
raise TypeError(
|
| 685 |
+
f"completion {completion_index} must be a token-ID list; "
|
| 686 |
+
f"got {type(completion).__name__}"
|
| 687 |
+
)
|
| 688 |
+
for token_index, token in enumerate(completion):
|
| 689 |
+
if isinstance(token, bool) or not isinstance(token, int):
|
| 690 |
+
raise TypeError(
|
| 691 |
+
f"completion {completion_index} token {token_index} "
|
| 692 |
+
f"must be an integer; got {type(token).__name__}"
|
| 693 |
+
)
|
| 694 |
+
if token < 0:
|
| 695 |
+
raise ValueError(
|
| 696 |
+
f"completion {completion_index} token {token_index} "
|
| 697 |
+
f"must be non-negative; got {token}"
|
| 698 |
+
)
|
| 699 |
+
return completions
|
| 700 |
+
except BaseException as exc:
|
| 701 |
+
generation_error = exc
|
| 702 |
+
raise
|
| 703 |
+
finally:
|
| 704 |
+
if getattr(device, "type", None) == "cuda":
|
| 705 |
+
try:
|
| 706 |
+
torch.cuda.empty_cache()
|
| 707 |
+
except BaseException as cleanup_error:
|
| 708 |
+
if generation_error is None:
|
| 709 |
+
raise
|
| 710 |
+
print(
|
| 711 |
+
"[generate] CUDA allocator cleanup failed after generation "
|
| 712 |
+
f"error: {type(cleanup_error).__name__}: {cleanup_error}",
|
| 713 |
+
file=sys.stderr,
|
| 714 |
+
flush=True,
|
| 715 |
+
)
|
| 716 |
+
|
| 717 |
+
|
| 718 |
+
def format_output(
|
| 719 |
+
*,
|
| 720 |
+
prompt_token_count: int,
|
| 721 |
+
completion_ids: Sequence[int],
|
| 722 |
+
tokenizer: Any | None,
|
| 723 |
+
eos_token_ids: Sequence[int] | None = None,
|
| 724 |
+
) -> str:
|
| 725 |
+
completion = (
|
| 726 |
+
tokenizer.decode(list(completion_ids), skip_special_tokens=False)
|
| 727 |
+
if tokenizer is not None
|
| 728 |
+
else None
|
| 729 |
+
)
|
| 730 |
+
payload = {
|
| 731 |
+
"prompt_token_count": prompt_token_count,
|
| 732 |
+
"completion_token_ids": list(completion_ids),
|
| 733 |
+
"completion": completion,
|
| 734 |
+
}
|
| 735 |
+
if eos_token_ids is not None:
|
| 736 |
+
payload["eos_token_ids"] = sorted(int(token) for token in eos_token_ids)
|
| 737 |
+
return json.dumps(payload, ensure_ascii=False)
|
| 738 |
+
|
| 739 |
+
|
| 740 |
+
def format_batch_output(
|
| 741 |
+
*,
|
| 742 |
+
prompt_batch: Sequence[Sequence[int]],
|
| 743 |
+
completion_batch: Sequence[Sequence[int]],
|
| 744 |
+
tokenizer: Any | None,
|
| 745 |
+
eos_token_ids: Sequence[int] | None = None,
|
| 746 |
+
) -> str:
|
| 747 |
+
"""Format one request exactly as before, or a multi-request result object."""
|
| 748 |
+
if len(prompt_batch) != len(completion_batch):
|
| 749 |
+
raise ValueError(
|
| 750 |
+
f"prompt/completion batch mismatch: {len(prompt_batch)} != "
|
| 751 |
+
f"{len(completion_batch)}"
|
| 752 |
+
)
|
| 753 |
+
if not prompt_batch:
|
| 754 |
+
raise ValueError("cannot format an empty batch")
|
| 755 |
+
if len(prompt_batch) == 1:
|
| 756 |
+
return format_output(
|
| 757 |
+
prompt_token_count=len(prompt_batch[0]),
|
| 758 |
+
completion_ids=completion_batch[0],
|
| 759 |
+
tokenizer=tokenizer,
|
| 760 |
+
eos_token_ids=eos_token_ids,
|
| 761 |
+
)
|
| 762 |
+
|
| 763 |
+
results = []
|
| 764 |
+
for prompt_ids, completion_ids in zip(prompt_batch, completion_batch):
|
| 765 |
+
completion_list = list(completion_ids)
|
| 766 |
+
completion = (
|
| 767 |
+
tokenizer.decode(completion_list, skip_special_tokens=False)
|
| 768 |
+
if tokenizer is not None
|
| 769 |
+
else None
|
| 770 |
+
)
|
| 771 |
+
results.append(
|
| 772 |
+
{
|
| 773 |
+
"prompt_token_count": len(prompt_ids),
|
| 774 |
+
"completion_token_ids": completion_list,
|
| 775 |
+
"completion": completion,
|
| 776 |
+
}
|
| 777 |
+
)
|
| 778 |
+
return json.dumps(
|
| 779 |
+
{
|
| 780 |
+
"batch_size": len(results),
|
| 781 |
+
**(
|
| 782 |
+
{"eos_token_ids": sorted(int(token) for token in eos_token_ids)}
|
| 783 |
+
if eos_token_ids is not None
|
| 784 |
+
else {}
|
| 785 |
+
),
|
| 786 |
+
"results": results,
|
| 787 |
+
},
|
| 788 |
+
ensure_ascii=False,
|
| 789 |
+
)
|
| 790 |
+
|
| 791 |
+
|
| 792 |
+
def load_image_inputs(path, config):
|
| 793 |
+
"""Load the same preprocessed inputs on every rank; propagate failures together."""
|
| 794 |
+
inputs, error = {}, None
|
| 795 |
+
if path is not None:
|
| 796 |
+
try:
|
| 797 |
+
from model import Step5VLConfig
|
| 798 |
+
from safetensors.torch import load_file
|
| 799 |
+
if not isinstance(config, Step5VLConfig):
|
| 800 |
+
raise ValueError("--image-inputs requires a step5_vl checkpoint")
|
| 801 |
+
inputs = load_file(path, device="cpu")
|
| 802 |
+
allowed = {"pixel_values", "patch_pixel_values", "num_patches", "image_embeds"}
|
| 803 |
+
if not inputs or set(inputs) - allowed:
|
| 804 |
+
raise ValueError(f"Image input keys must be a nonempty subset of {sorted(allowed)}")
|
| 805 |
+
if ("pixel_values" in inputs) == ("image_embeds" in inputs):
|
| 806 |
+
raise ValueError("Provide exactly one of pixel_values or image_embeds")
|
| 807 |
+
if "image_embeds" in inputs and len(inputs) != 1:
|
| 808 |
+
raise ValueError("image_embeds cannot be combined with crop inputs")
|
| 809 |
+
except Exception as exc:
|
| 810 |
+
error = f"{type(exc).__name__}: {exc}"
|
| 811 |
+
errors = [error]
|
| 812 |
+
if dist.is_initialized():
|
| 813 |
+
errors = [None] * dist.get_world_size()
|
| 814 |
+
dist.all_gather_object(errors, error)
|
| 815 |
+
if any(errors):
|
| 816 |
+
raise ValueError(f"Unable to load image inputs: {next(e for e in errors if e)}")
|
| 817 |
+
return inputs
|
| 818 |
+
|
| 819 |
+
|
| 820 |
+
def _run(args: argparse.Namespace, *, rank: int, world: int, device: torch.device) -> int:
|
| 821 |
+
model_config = _load_rank_config(args.tp_dir, rank)
|
| 822 |
+
config = model_config.get_text_config()
|
| 823 |
+
image_token_id = getattr(model_config, "image_token_id", None) if args.image_inputs else None
|
| 824 |
+
config_ep_size = validate_parallel_topology(
|
| 825 |
+
config,
|
| 826 |
+
world=world,
|
| 827 |
+
requested_ep_size=args.ep_size,
|
| 828 |
+
rank=rank,
|
| 829 |
+
)
|
| 830 |
+
|
| 831 |
+
tokenizer_source = resolve_tokenizer_source(
|
| 832 |
+
tp_dir=args.tp_dir,
|
| 833 |
+
tokenizer=args.tokenizer,
|
| 834 |
+
checkpoint=args.checkpoint,
|
| 835 |
+
)
|
| 836 |
+
tokenizer = None
|
| 837 |
+
explicit_tokenizer_source = (
|
| 838 |
+
args.tokenizer is not None or args.checkpoint is not None
|
| 839 |
+
)
|
| 840 |
+
rank0_prompt_batch: list[list[int]] | None = None
|
| 841 |
+
rank0_eos_token_ids: set[int] | None = None
|
| 842 |
+
prompt_error: str | None = None
|
| 843 |
+
if rank == 0:
|
| 844 |
+
try:
|
| 845 |
+
if args.prompt_json_batch is not None:
|
| 846 |
+
rank0_prompt_batch = load_prompt_json_batch(args.prompt_json_batch)
|
| 847 |
+
# A tokenizer is optional for exact-id input; it is used only to
|
| 848 |
+
# decode completions when one is available.
|
| 849 |
+
tokenizer = _load_exact_id_tokenizer(
|
| 850 |
+
tokenizer_source,
|
| 851 |
+
explicit_source=explicit_tokenizer_source,
|
| 852 |
+
)
|
| 853 |
+
elif args.prompt_json is not None:
|
| 854 |
+
rank0_prompt_batch = [load_prompt_json(args.prompt_json)]
|
| 855 |
+
# A tokenizer is optional for exact-id input; it is used only to
|
| 856 |
+
# decode the completion when one was explicitly or automatically found.
|
| 857 |
+
tokenizer = _load_exact_id_tokenizer(
|
| 858 |
+
tokenizer_source,
|
| 859 |
+
explicit_source=explicit_tokenizer_source,
|
| 860 |
+
)
|
| 861 |
+
else:
|
| 862 |
+
if tokenizer_source is None:
|
| 863 |
+
raise ValueError(
|
| 864 |
+
"--prompt requires --tokenizer/--checkpoint, or tokenizer files "
|
| 865 |
+
"at <tp-dir>/../tokenizer_files"
|
| 866 |
+
)
|
| 867 |
+
tokenizer = _load_tokenizer(
|
| 868 |
+
tokenizer_source,
|
| 869 |
+
require_chat_template=not args.raw_prompt,
|
| 870 |
+
)
|
| 871 |
+
rank0_prompt_batch = [
|
| 872 |
+
tokenize_text(tokenizer, args.prompt, raw_prompt=args.raw_prompt)
|
| 873 |
+
]
|
| 874 |
+
assert rank0_prompt_batch is not None
|
| 875 |
+
for prompt_ids in rank0_prompt_batch:
|
| 876 |
+
validate_prompt_ids(
|
| 877 |
+
prompt_ids,
|
| 878 |
+
vocab_size=config.vocab_size,
|
| 879 |
+
max_position_embeddings=config.max_position_embeddings,
|
| 880 |
+
max_new_tokens=args.max_new_tokens,
|
| 881 |
+
image_token_id=image_token_id,
|
| 882 |
+
)
|
| 883 |
+
rank0_eos_token_ids = resolve_generation_eos_token_ids(
|
| 884 |
+
config.eos_token_id,
|
| 885 |
+
tokenizer,
|
| 886 |
+
)
|
| 887 |
+
except Exception as exc:
|
| 888 |
+
prompt_error = f"{type(exc).__name__}: {exc}"
|
| 889 |
+
|
| 890 |
+
_broadcast_error(prompt_error, device=device)
|
| 891 |
+
eos_token_ids = broadcast_eos_token_ids(
|
| 892 |
+
rank0_eos_token_ids,
|
| 893 |
+
rank=rank,
|
| 894 |
+
device=device,
|
| 895 |
+
)
|
| 896 |
+
prompt_batch = broadcast_prompt_batch(
|
| 897 |
+
rank0_prompt_batch, rank=rank, device=device
|
| 898 |
+
)
|
| 899 |
+
# Check again on all ranks so a corrupted/mismatched rank config fails before
|
| 900 |
+
# loading hundreds of gigabytes of weights.
|
| 901 |
+
for prompt_ids in prompt_batch:
|
| 902 |
+
validate_prompt_ids(
|
| 903 |
+
prompt_ids,
|
| 904 |
+
vocab_size=config.vocab_size,
|
| 905 |
+
max_position_embeddings=config.max_position_embeddings,
|
| 906 |
+
max_new_tokens=args.max_new_tokens,
|
| 907 |
+
image_token_id=image_token_id,
|
| 908 |
+
)
|
| 909 |
+
|
| 910 |
+
if rank == 0:
|
| 911 |
+
prompt_lengths = [len(prompt_ids) for prompt_ids in prompt_batch]
|
| 912 |
+
prompt_description = (
|
| 913 |
+
f"{prompt_lengths[0]} prompt tokens"
|
| 914 |
+
if len(prompt_lengths) == 1
|
| 915 |
+
else f"batch={len(prompt_lengths)}, prompt tokens={prompt_lengths}"
|
| 916 |
+
)
|
| 917 |
+
print(
|
| 918 |
+
f"loading Step5 TP={world}, co-located EP={config_ep_size} "
|
| 919 |
+
f"({prompt_description})",
|
| 920 |
+
file=sys.stderr,
|
| 921 |
+
flush=True,
|
| 922 |
+
)
|
| 923 |
+
image_inputs = load_image_inputs(args.image_inputs, model_config)
|
| 924 |
+
model = _load_rank_model(args.tp_dir, rank, model_config, device)
|
| 925 |
+
completion_batch = generate_completions(
|
| 926 |
+
model,
|
| 927 |
+
prompt_batch,
|
| 928 |
+
max_new_tokens=args.max_new_tokens,
|
| 929 |
+
device=device,
|
| 930 |
+
eos_token_ids=eos_token_ids,
|
| 931 |
+
image_inputs=image_inputs,
|
| 932 |
+
)
|
| 933 |
+
if rank == 0:
|
| 934 |
+
print(
|
| 935 |
+
format_batch_output(
|
| 936 |
+
prompt_batch=prompt_batch,
|
| 937 |
+
completion_batch=completion_batch,
|
| 938 |
+
tokenizer=tokenizer,
|
| 939 |
+
eos_token_ids=eos_token_ids,
|
| 940 |
+
),
|
| 941 |
+
flush=True,
|
| 942 |
+
)
|
| 943 |
+
return 0
|
| 944 |
+
|
| 945 |
+
|
| 946 |
+
def main(argv: Sequence[str] | None = None) -> int:
|
| 947 |
+
args = parse_args(argv)
|
| 948 |
+
if not dist.is_available():
|
| 949 |
+
raise SystemExit("torch.distributed is unavailable; launch this CLI with torchrun")
|
| 950 |
+
if not {"RANK", "WORLD_SIZE", "LOCAL_RANK"}.issubset(os.environ):
|
| 951 |
+
raise SystemExit(
|
| 952 |
+
"missing torchrun environment (RANK/WORLD_SIZE/LOCAL_RANK); "
|
| 953 |
+
"launch with torchrun --nproc-per-node=<TP>"
|
| 954 |
+
)
|
| 955 |
+
|
| 956 |
+
local_rank = int(os.environ["LOCAL_RANK"])
|
| 957 |
+
device = _resolve_device(args.device, local_rank)
|
| 958 |
+
backend = "nccl" if device.type == "cuda" else "gloo"
|
| 959 |
+
owns_process_group = False
|
| 960 |
+
try:
|
| 961 |
+
if not dist.is_initialized():
|
| 962 |
+
dist.init_process_group(backend=backend)
|
| 963 |
+
owns_process_group = True
|
| 964 |
+
return _run(
|
| 965 |
+
args,
|
| 966 |
+
rank=dist.get_rank(),
|
| 967 |
+
world=dist.get_world_size(),
|
| 968 |
+
device=device,
|
| 969 |
+
)
|
| 970 |
+
finally:
|
| 971 |
+
if owns_process_group and dist.is_available() and dist.is_initialized():
|
| 972 |
+
dist.destroy_process_group()
|
| 973 |
+
|
| 974 |
+
|
| 975 |
+
if __name__ == "__main__":
|
| 976 |
+
raise SystemExit(main())
|
inference/kernel.py
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
inference/model.py
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
inference/requirements.txt
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Install a CUDA-enabled PyTorch build that matches the host driver.
|
| 2 |
+
torch>=2.10,<2.14
|
| 3 |
+
transformers>=4.57.6,<6
|
| 4 |
+
safetensors>=0.7,<1
|
| 5 |
+
triton>=3.6,<3.8
|
inference/test_vision.py
ADDED
|
@@ -0,0 +1,390 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Focused regressions for the standalone Step-5 vision integration.
|
| 2 |
+
|
| 3 |
+
Run from inference/: python -m pytest -q test_vision.py
|
| 4 |
+
"""
|
| 5 |
+
import json
|
| 6 |
+
from unittest.mock import patch
|
| 7 |
+
|
| 8 |
+
import pytest
|
| 9 |
+
import torch
|
| 10 |
+
from safetensors import safe_open
|
| 11 |
+
from safetensors.torch import load_file, save_file
|
| 12 |
+
|
| 13 |
+
from convert import _shard_for, pre_shard
|
| 14 |
+
from generate import _load_rank_config, _load_rank_model, load_image_inputs, parse_args, validate_prompt_ids
|
| 15 |
+
from model import (
|
| 16 |
+
STEP5_TP_LAYOUT_VERSION, Step5Config, Step5ForCausalLM,
|
| 17 |
+
Step5ForConditionalGeneration, Step5VLConfig, config_from_dict,
|
| 18 |
+
)
|
| 19 |
+
from vision_encoder import EncoderRope2D, Step5VisionEncoder
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def tiny_config(**kwargs):
|
| 23 |
+
text = dict(
|
| 24 |
+
hidden_size=32, intermediate_size=64, num_hidden_layers=1,
|
| 25 |
+
num_nextn_predict_layers=3, vocab_size=64, num_attention_heads=4,
|
| 26 |
+
num_attention_groups=2, head_dim=8, max_position_embeddings=128,
|
| 27 |
+
layer_types=["sliding_attention"] * 4, sliding_window=16, yarn_only_types=["full_attention"],
|
| 28 |
+
use_moe=False, sparse_config={"enabled": False, "num_provider_groups": 2},
|
| 29 |
+
tp_layout_version=STEP5_TP_LAYOUT_VERSION,
|
| 30 |
+
)
|
| 31 |
+
text.update(kwargs.pop("text", {}))
|
| 32 |
+
vision = dict(width=16, layers=2, heads=2, image_size=32, patch_size=4,
|
| 33 |
+
mlp_ratio=2, use_ln_pre=True, use_ln_post=False)
|
| 34 |
+
vision.update(kwargs.pop("vision", {}))
|
| 35 |
+
return Step5VLConfig(text_config=text, vision_config=vision,
|
| 36 |
+
image_token_id=70, image_token_len=4, patch_token_len=1, **kwargs)
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def initialized_model(config):
|
| 40 |
+
model = Step5ForConditionalGeneration(config)
|
| 41 |
+
# TP linear layers allocate empty checkpoint storage; seed every parameter
|
| 42 |
+
# explicitly because these tests exercise random fixtures, not trained weights.
|
| 43 |
+
with torch.no_grad():
|
| 44 |
+
for name, parameter in model.named_parameters():
|
| 45 |
+
if "norm" in name and name.endswith("weight"):
|
| 46 |
+
parameter.zero_()
|
| 47 |
+
elif name.endswith("weight_scale_inv"):
|
| 48 |
+
parameter.uniform_(0.5, 1.5)
|
| 49 |
+
elif parameter.dtype == torch.float8_e4m3fn:
|
| 50 |
+
parameter.copy_(torch.randn(parameter.shape) * 0.02)
|
| 51 |
+
else:
|
| 52 |
+
parameter.normal_(0, 0.02)
|
| 53 |
+
return model
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def test_configuration_and_quantization_roundtrip():
|
| 57 |
+
quant = {"quant_method": "fp8", "modules_to_not_convert": [
|
| 58 |
+
"vision_model.transformer.resblocks.3.attn.q_proj", "model.layers.4.moe.gate",
|
| 59 |
+
"model.layers.88.moe.experts.w13_weight", "model.layers.89.moe.up_proj",
|
| 60 |
+
]}
|
| 61 |
+
config = tiny_config(quantization_config=quant, tp_size=2)
|
| 62 |
+
assert config.text_config.bf16_expert_layers == [88, 89]
|
| 63 |
+
assert not hasattr(config, "quantization_config")
|
| 64 |
+
assert not hasattr(config.text_config, "quantization_config")
|
| 65 |
+
serialized = json.loads(json.dumps(config.to_dict()))
|
| 66 |
+
restored = config_from_dict(serialized)
|
| 67 |
+
assert restored.model_type == "step5_vl"
|
| 68 |
+
assert restored.architectures == ["Step5ForConditionalGeneration"]
|
| 69 |
+
assert restored.text_config.tp == 2
|
| 70 |
+
assert restored.text_config.num_nextn_predict_layers == 3
|
| 71 |
+
assert len(restored.text_config.to_dict()["layer_types"]) == 4
|
| 72 |
+
assert restored.text_config.bf16_expert_layers == [88, 89]
|
| 73 |
+
assert restored.to_dict()["quantization_config"] == quant
|
| 74 |
+
assert isinstance(config_from_dict(restored.text_config.to_dict()), Step5Config)
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def test_vision_shape_rectangular_cls_and_optional_layer_scale():
|
| 78 |
+
config = tiny_config(vision={"use_cls_token": True, "ls_init_value": None}).vision_config
|
| 79 |
+
encoder = Step5VisionEncoder(config).eval()
|
| 80 |
+
output = encoder(torch.randn(2, 3, 16, 24))
|
| 81 |
+
assert output.shape == (2, 24, 16)
|
| 82 |
+
assert torch.isfinite(output).all()
|
| 83 |
+
with pytest.raises(ValueError, match="positive multiples"):
|
| 84 |
+
encoder(torch.randn(1, 3, 15, 16))
|
| 85 |
+
with pytest.raises(ValueError, match="exceeds configured"):
|
| 86 |
+
encoder(torch.randn(1, 3, 36, 32))
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def test_rope_coordinates_and_cls_subset():
|
| 90 |
+
rope = EncoderRope2D(8, 8, 8, use_cls_token=True)
|
| 91 |
+
q = torch.ones(1, 1, 7, 8)
|
| 92 |
+
result, _ = rope(q, q, (2, 3))
|
| 93 |
+
assert torch.equal(result[:, :, 0], q[:, :, 0])
|
| 94 |
+
inv = 10000 ** (-torch.arange(0, 4, 2).float() / 4)
|
| 95 |
+
coords = [(0, 0)] + [(x + 1, y + 1) for y in range(2) for x in range(3)]
|
| 96 |
+
angles = torch.stack([torch.cat([x * inv, y * inv]).repeat_interleave(2) for x, y in coords])
|
| 97 |
+
sign = torch.tensor([-1., 1.] * 4)
|
| 98 |
+
expected = angles.cos() + sign * angles.sin()
|
| 99 |
+
torch.testing.assert_close(result[0, 0], expected)
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def test_crop_order_and_precomputed_embeddings():
|
| 103 |
+
model = Step5ForConditionalGeneration(tiny_config()).eval()
|
| 104 |
+
overview = torch.randn(2, 4, 32)
|
| 105 |
+
crops = torch.randn(3, 1, 32)
|
| 106 |
+
with patch.object(model, "_encode_images", side_effect=[overview, crops]):
|
| 107 |
+
actual = model.get_multimodal_embeddings(
|
| 108 |
+
pixel_values=torch.zeros(2, 3, 32, 32),
|
| 109 |
+
patch_pixel_values=torch.zeros(3, 3, 16, 16), num_patches=[2, 1],
|
| 110 |
+
)
|
| 111 |
+
expected = torch.cat([crops[0], crops[1], overview[0], crops[2], overview[1]])
|
| 112 |
+
torch.testing.assert_close(actual, expected)
|
| 113 |
+
assert model.get_multimodal_embeddings(image_embeds=overview).shape == (8, 32)
|
| 114 |
+
with pytest.raises(ValueError, match="mutually exclusive"):
|
| 115 |
+
model.get_multimodal_embeddings(pixel_values=torch.empty(1), image_embeds=overview)
|
| 116 |
+
with pytest.raises(ValueError, match="sum\\(num_patches\\)"):
|
| 117 |
+
model.get_multimodal_embeddings(pixel_values=torch.zeros(2, 3, 32, 32), num_patches=[1, 0])
|
| 118 |
+
with pytest.raises(ValueError, match="nonnegative integer"):
|
| 119 |
+
model.get_multimodal_embeddings(pixel_values=torch.zeros(2, 3, 32, 32), num_patches=[0.5, 0])
|
| 120 |
+
with pytest.raises(ValueError, match="overview images"):
|
| 121 |
+
model.get_multimodal_embeddings(num_patches=[0])
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
def make_checkpoint(path, config):
|
| 125 |
+
path.mkdir()
|
| 126 |
+
model = initialized_model(config)
|
| 127 |
+
state = model.state_dict()
|
| 128 |
+
for i in range(config.text_config.num_hidden_layers,
|
| 129 |
+
config.text_config.num_hidden_layers + config.text_config.num_nextn_predict_layers):
|
| 130 |
+
state[f"model.layers.{i}.mtp_block.unused.weight"] = torch.ones(1)
|
| 131 |
+
save_file(state, str(path / "model.safetensors"))
|
| 132 |
+
(path / "config.json").write_text(json.dumps(config.to_dict()))
|
| 133 |
+
(path / "model.safetensors.index.json").write_text(json.dumps({
|
| 134 |
+
"weight_map": {key: "model.safetensors" for key in state},
|
| 135 |
+
}))
|
| 136 |
+
return model
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
def test_conversion_and_strict_loader(tmp_path):
|
| 140 |
+
original = make_checkpoint(tmp_path / "source", tiny_config())
|
| 141 |
+
assert pre_shard(str(tmp_path / "source"), str(tmp_path / "unwritten"), 1, check_only=True) == 0
|
| 142 |
+
assert not (tmp_path / "unwritten").exists()
|
| 143 |
+
assert pre_shard(str(tmp_path / "source"), str(tmp_path / "converted"), 1) == 0
|
| 144 |
+
directory = str(tmp_path / "converted" / "tp1")
|
| 145 |
+
config = _load_rank_config(directory, 0)
|
| 146 |
+
loaded = _load_rank_model(directory, 0, config, torch.device("cpu"))
|
| 147 |
+
assert isinstance(loaded, Step5ForConditionalGeneration)
|
| 148 |
+
for key, tensor in original.state_dict().items():
|
| 149 |
+
torch.testing.assert_close(loaded.state_dict()[key], tensor)
|
| 150 |
+
with torch.inference_mode():
|
| 151 |
+
pixels = torch.randn(1, 3, 32, 32)
|
| 152 |
+
# Nonpersistent 2D RoPE buffers must be rebuilt after HF meta loading.
|
| 153 |
+
expected_features = original.get_multimodal_embeddings(pixel_values=pixels)
|
| 154 |
+
actual_features = loaded.get_multimodal_embeddings(pixel_values=pixels)
|
| 155 |
+
assert torch.isfinite(actual_features).all()
|
| 156 |
+
torch.testing.assert_close(actual_features, expected_features, rtol=0, atol=0)
|
| 157 |
+
weight = original.vision_model.transformer.resblocks[0].attn.out_proj.weight
|
| 158 |
+
assert _shard_for("vision_model.transformer.resblocks.0.attn.out_proj.weight")(weight, None, 2, 1, config).shape == weight.shape
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
@pytest.mark.parametrize("multimodal", [False, True])
|
| 162 |
+
def test_checkpoint_identity_normalizes_to_step5(tmp_path, multimodal):
|
| 163 |
+
config = tiny_config()
|
| 164 |
+
payload = config.to_dict() if multimodal else config.text_config.to_dict()
|
| 165 |
+
payload["model_type"] = "step4_vl" if multimodal else "step4"
|
| 166 |
+
payload["architectures"] = ["Step4ForConditionalGeneration" if multimodal else "Step4ForCausalLM"]
|
| 167 |
+
payload["auto_map"] = {"AutoConfig": "old_config.Step4Config"}
|
| 168 |
+
if multimodal:
|
| 169 |
+
payload["text_config"]["model_type"] = "step4"
|
| 170 |
+
payload["text_config"]["architectures"] = ["Step4ForCausalLM"]
|
| 171 |
+
payload["text_config"]["auto_map"] = {"AutoConfig": "old_config.Step4Config"}
|
| 172 |
+
restored = config_from_dict(payload)
|
| 173 |
+
assert restored.model_type == ("step5_vl" if multimodal else "step5")
|
| 174 |
+
encoded = json.dumps(restored.to_dict())
|
| 175 |
+
assert "step4" not in encoded.lower()
|
| 176 |
+
restored.save_pretrained(tmp_path)
|
| 177 |
+
reloaded = config_from_dict(json.loads((tmp_path / "config.json").read_text()))
|
| 178 |
+
assert reloaded.to_dict()["auto_map"] == restored.to_dict()["auto_map"]
|
| 179 |
+
assert reloaded.to_dict()["architectures"] == restored.to_dict()["architectures"]
|
| 180 |
+
assert reloaded.get_text_config().num_nextn_predict_layers == 3
|
| 181 |
+
with pytest.raises(ValueError, match="unsupported model_type"):
|
| 182 |
+
config_from_dict(dict(payload, model_type="unrelated_model"))
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
def test_legacy_checkpoint_conversion_and_shard_loading(tmp_path):
|
| 186 |
+
original = make_checkpoint(tmp_path / "source", tiny_config())
|
| 187 |
+
source_config = tmp_path / "source" / "config.json"
|
| 188 |
+
source_config.write_text(source_config.read_text().replace("step5", "step4").replace("Step5", "Step4"))
|
| 189 |
+
pre_shard(str(tmp_path / "source"), str(tmp_path / "converted"), 1)
|
| 190 |
+
directory = tmp_path / "converted" / "tp1"
|
| 191 |
+
emitted = (directory / "config-r0.json").read_text()
|
| 192 |
+
assert "step4" not in emitted.lower()
|
| 193 |
+
shard = directory / "model-r0.safetensors"
|
| 194 |
+
state = load_file(str(shard))
|
| 195 |
+
with safe_open(str(shard), framework="pt") as handle:
|
| 196 |
+
metadata = handle.metadata()
|
| 197 |
+
assert metadata["step5_tp_layout"] == STEP5_TP_LAYOUT_VERSION
|
| 198 |
+
assert "step4_tp_layout" not in metadata
|
| 199 |
+
metadata["step4_tp_layout"] = metadata.pop("step5_tp_layout")
|
| 200 |
+
save_file(state, str(shard), metadata=metadata)
|
| 201 |
+
# Existing shards and their per-rank configs must remain loadable together.
|
| 202 |
+
(directory / "config-r0.json").write_text(emitted.replace("step5", "step4").replace("Step5", "Step4"))
|
| 203 |
+
config = _load_rank_config(str(directory), 0)
|
| 204 |
+
loaded = _load_rank_model(str(directory), 0, config, torch.device("cpu"))
|
| 205 |
+
for name, tensor in original.state_dict().items():
|
| 206 |
+
torch.testing.assert_close(loaded.state_dict()[name], tensor, rtol=0, atol=0)
|
| 207 |
+
metadata["step5_tp_layout"] = "incompatible-layout"
|
| 208 |
+
save_file(state, str(shard), metadata=metadata)
|
| 209 |
+
with pytest.raises(ValueError, match="step5_tp_layout metadata"):
|
| 210 |
+
_load_rank_model(str(directory), 0, config, torch.device("cpu"))
|
| 211 |
+
metadata["step5_tp_layout"] = STEP5_TP_LAYOUT_VERSION
|
| 212 |
+
metadata["step4_tp_layout"] = "incompatible-layout"
|
| 213 |
+
save_file(state, str(shard), metadata=metadata)
|
| 214 |
+
with pytest.raises(ValueError, match="step4_tp_layout metadata"):
|
| 215 |
+
_load_rank_model(str(directory), 0, config, torch.device("cpu"))
|
| 216 |
+
metadata.pop("step5_tp_layout")
|
| 217 |
+
metadata.pop("step4_tp_layout")
|
| 218 |
+
save_file(state, str(shard), metadata=metadata)
|
| 219 |
+
with pytest.raises(ValueError, match="missing step5_tp_layout"):
|
| 220 |
+
_load_rank_model(str(directory), 0, config, torch.device("cpu"))
|
| 221 |
+
|
| 222 |
+
|
| 223 |
+
def tiny_moe_config(*, quantized=True):
|
| 224 |
+
return tiny_config(
|
| 225 |
+
text=dict(hidden_size=256, intermediate_size=256, num_hidden_layers=2,
|
| 226 |
+
layer_types=["sliding_attention"] * 5, num_attention_heads=8,
|
| 227 |
+
head_dim=32, vocab_size=128, eos_token_id=[127], use_moe=True,
|
| 228 |
+
moe_layer_list=[0, 1], moe_num_experts=8, moe_top_k=2,
|
| 229 |
+
moe_intermediate_size=256, share_expert_dim=256),
|
| 230 |
+
quantization_config={"quant_method": "fp8", "modules_to_not_convert": [
|
| 231 |
+
"model.layers.1.moe.experts.w13_weight",
|
| 232 |
+
"model.layers.1.moe.experts.w2_weight",
|
| 233 |
+
"vision_model.transformer.resblocks.0.attn.q_proj",
|
| 234 |
+
]} if quantized else None,
|
| 235 |
+
)
|
| 236 |
+
|
| 237 |
+
|
| 238 |
+
@pytest.mark.parametrize("quantized", [False, True], ids=["bf16", "mixed_fp8_bf16"])
|
| 239 |
+
def test_experts_conversion_and_hf_loading(tmp_path, quantized):
|
| 240 |
+
original = make_checkpoint(tmp_path / "source", tiny_moe_config(quantized=quantized))
|
| 241 |
+
pre_shard(str(tmp_path / "source"), str(tmp_path / "converted"), 2)
|
| 242 |
+
directory = str(tmp_path / "converted" / "tp2")
|
| 243 |
+
config = _load_rank_config(directory, 0)
|
| 244 |
+
assert config.text_config.bf16_expert_layers == ([1] if quantized else [0, 1])
|
| 245 |
+
loaded = _load_rank_model(directory, 0, config, torch.device("cpu"))
|
| 246 |
+
stored = load_file(str(tmp_path / "converted" / "tp2" / "model-r0.safetensors"))
|
| 247 |
+
for key, actual in loaded.state_dict().items():
|
| 248 |
+
expected = stored[key]
|
| 249 |
+
if key == "lm_head.weight":
|
| 250 |
+
expected = expected[:64]
|
| 251 |
+
assert actual.dtype == expected.dtype, key
|
| 252 |
+
torch.testing.assert_close(actual.float(), expected.float(), rtol=0, atol=0, msg=key)
|
| 253 |
+
if quantized:
|
| 254 |
+
assert loaded.model.layers[0].moe.gate_proj.weight.dtype == torch.float8_e4m3fn
|
| 255 |
+
assert loaded.model.layers[0].moe.gate_proj.weight_scale_inv.dtype == torch.float32
|
| 256 |
+
else:
|
| 257 |
+
assert loaded.model.layers[0].moe.gate_proj.weight.dtype == torch.bfloat16
|
| 258 |
+
assert not any("weight_scale" in key for key in stored)
|
| 259 |
+
assert loaded.model.layers[0].moe.gate_proj.weight_scale_inv is None
|
| 260 |
+
assert loaded.model.layers[1].moe.gate_proj.weight.dtype == torch.bfloat16
|
| 261 |
+
assert loaded.model.layers[1].moe.gate_proj.weight_scale_inv is None
|
| 262 |
+
# Independently verify that only the expert axis was partitioned.
|
| 263 |
+
torch.testing.assert_close(loaded.model.layers[0].moe.gate_proj.weight.float(),
|
| 264 |
+
original.model.layers[0].moe.gate_proj.weight[:4].float(), rtol=0, atol=0)
|
| 265 |
+
with torch.inference_mode():
|
| 266 |
+
pixels = torch.randn(1, 3, 32, 32)
|
| 267 |
+
torch.testing.assert_close(loaded.get_multimodal_embeddings(pixel_values=pixels),
|
| 268 |
+
original.get_multimodal_embeddings(pixel_values=pixels), rtol=0, atol=0)
|
| 269 |
+
|
| 270 |
+
|
| 271 |
+
@pytest.mark.parametrize("quantization", [None, {}])
|
| 272 |
+
def test_unquantized_moe_configuration_roundtrip(quantization):
|
| 273 |
+
raw = tiny_moe_config(quantized=False).to_dict()
|
| 274 |
+
raw["text_config"]["quantization_config"] = quantization
|
| 275 |
+
# Derived fields from another checkpoint must not override its quantization metadata.
|
| 276 |
+
raw["text_config"]["bf16_expert_layers"] = []
|
| 277 |
+
config = config_from_dict(raw)
|
| 278 |
+
for restored in (config, config_from_dict(config.to_dict()),
|
| 279 |
+
config_from_dict(config.text_config.to_dict())):
|
| 280 |
+
text = restored.get_text_config()
|
| 281 |
+
assert text.bf16_expert_layers == [0, 1]
|
| 282 |
+
assert not any(text.is_fp8_expert_layer(i) for i in range(2))
|
| 283 |
+
assert not hasattr(text, "quantization_config")
|
| 284 |
+
|
| 285 |
+
|
| 286 |
+
def test_unsupported_expert_quantization_is_rejected():
|
| 287 |
+
with pytest.raises(ValueError, match="quant_method='fp8'"):
|
| 288 |
+
tiny_config(quantization_config={"quant_method": "int8"})
|
| 289 |
+
|
| 290 |
+
|
| 291 |
+
@pytest.mark.parametrize("corruption", ["missing", "unexpected", "shape", "rank"])
|
| 292 |
+
def test_loader_rejects_incomplete_or_wrong_rank_shards(tmp_path, corruption):
|
| 293 |
+
make_checkpoint(tmp_path / "source", tiny_config())
|
| 294 |
+
pre_shard(str(tmp_path / "source"), str(tmp_path / "converted"), 1)
|
| 295 |
+
directory = tmp_path / "converted" / "tp1"
|
| 296 |
+
shard = directory / "model-r0.safetensors"
|
| 297 |
+
with safe_open(str(shard), framework="pt") as handle:
|
| 298 |
+
metadata = handle.metadata()
|
| 299 |
+
state = load_file(str(shard))
|
| 300 |
+
key = "vision_model.conv1.weight"
|
| 301 |
+
if corruption == "missing":
|
| 302 |
+
del state[key]
|
| 303 |
+
elif corruption == "unexpected":
|
| 304 |
+
state["vision_model.unexpected.weight"] = torch.ones(1)
|
| 305 |
+
elif corruption == "shape":
|
| 306 |
+
state[key] = state[key][:1].clone()
|
| 307 |
+
else:
|
| 308 |
+
metadata["rank"] = "1"
|
| 309 |
+
save_file(state, str(shard), metadata=metadata)
|
| 310 |
+
config = _load_rank_config(str(directory), 0)
|
| 311 |
+
with patch.object(Step5ForConditionalGeneration, "from_pretrained") as load:
|
| 312 |
+
with pytest.raises(ValueError, match="key mismatch|shape|rank metadata"):
|
| 313 |
+
_load_rank_model(str(directory), 0, config, torch.device("cpu"))
|
| 314 |
+
load.assert_not_called()
|
| 315 |
+
|
| 316 |
+
|
| 317 |
+
def test_resume_rejects_old_text_only_shard(tmp_path):
|
| 318 |
+
make_checkpoint(tmp_path / "source", tiny_config())
|
| 319 |
+
pre_shard(str(tmp_path / "source"), str(tmp_path / "converted"), 2)
|
| 320 |
+
# A matching shard must resume successfully when converting from a TP=1 config.
|
| 321 |
+
assert pre_shard(str(tmp_path / "source"), str(tmp_path / "converted"), 2, start_rank=1) == 0
|
| 322 |
+
shard = tmp_path / "converted" / "tp2" / "model-r0.safetensors"
|
| 323 |
+
with safe_open(str(shard), framework="pt") as handle:
|
| 324 |
+
metadata = handle.metadata()
|
| 325 |
+
state = {key: tensor for key, tensor in load_file(str(shard)).items()
|
| 326 |
+
if not key.startswith(("vision_model.", "vit_large_projector."))}
|
| 327 |
+
save_file(state, str(shard), metadata=metadata)
|
| 328 |
+
with patch("convert._build_rank_state") as build:
|
| 329 |
+
with pytest.raises(ValueError, match="shard key mismatch"):
|
| 330 |
+
pre_shard(str(tmp_path / "source"), str(tmp_path / "converted"), 2, start_rank=1)
|
| 331 |
+
build.assert_not_called()
|
| 332 |
+
|
| 333 |
+
|
| 334 |
+
def test_cli_inputs(tmp_path):
|
| 335 |
+
path = tmp_path / "images.safetensors"
|
| 336 |
+
save_file({"image_embeds": torch.randn(4, 32)}, str(path))
|
| 337 |
+
result = load_image_inputs(str(path), tiny_config())
|
| 338 |
+
assert result["image_embeds"].shape == (4, 32)
|
| 339 |
+
with pytest.raises(ValueError, match="step5_vl"):
|
| 340 |
+
load_image_inputs(str(path), tiny_config().text_config)
|
| 341 |
+
with pytest.raises(SystemExit):
|
| 342 |
+
parse_args(["--tp-dir", ".", "--prompt", "test", "--image-inputs", str(path)])
|
| 343 |
+
limits = dict(vocab_size=64, max_position_embeddings=128, max_new_tokens=1)
|
| 344 |
+
validate_prompt_ids([5, 70, 6], image_token_id=70, **limits)
|
| 345 |
+
with pytest.raises(ValueError, match="outside vocabulary"):
|
| 346 |
+
validate_prompt_ids([5, 70, 6], **limits)
|
| 347 |
+
with pytest.raises(ValueError, match="outside vocabulary"):
|
| 348 |
+
validate_prompt_ids([5, 71, 6], image_token_id=70, **limits)
|
| 349 |
+
|
| 350 |
+
|
| 351 |
+
@pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA required for packed text inference")
|
| 352 |
+
def test_cuda_prefill_decode_text_parity_and_single_vision_call():
|
| 353 |
+
torch.manual_seed(11)
|
| 354 |
+
device = torch.device("cuda")
|
| 355 |
+
config = tiny_config()
|
| 356 |
+
model = initialized_model(config).to(device).eval()
|
| 357 |
+
text = Step5ForCausalLM(config.text_config).to(device).eval()
|
| 358 |
+
text.load_state_dict({key: value for key, value in model.state_dict().items()
|
| 359 |
+
if key.startswith(("model.", "lm_head."))}, strict=True)
|
| 360 |
+
pixels = torch.randn(1, 3, 32, 32, device=device)
|
| 361 |
+
ids = torch.tensor([5, 70, 70, 70, 70, 6, 7], device=device)
|
| 362 |
+
lengths = torch.tensor([7], dtype=torch.int32, device=device)
|
| 363 |
+
with torch.inference_mode():
|
| 364 |
+
cache_a = model.allocate_cache(batch=1, max_tokens=16, device=device)
|
| 365 |
+
cache_b = text.allocate_cache(batch=1, max_tokens=16, device=device)
|
| 366 |
+
features = model.get_multimodal_embeddings(pixel_values=pixels)
|
| 367 |
+
embeds = model.model.embed_tokens(ids.masked_fill(ids == 70, 0))
|
| 368 |
+
embeds[ids == 70] = features
|
| 369 |
+
actual = model(ids, cache_a, lengths, pixel_values=pixels).logits
|
| 370 |
+
assert torch.isfinite(actual).all()
|
| 371 |
+
expected = text(ids, cache_b, lengths, inputs_embeds=embeds).logits
|
| 372 |
+
torch.testing.assert_close(actual, expected, rtol=0, atol=0)
|
| 373 |
+
token = actual.argmax(-1)
|
| 374 |
+
one = torch.ones(1, dtype=torch.int32, device=device)
|
| 375 |
+
torch.testing.assert_close(model(token, cache_a, one, is_decode=True).logits,
|
| 376 |
+
text(token, cache_b, one, is_decode=True).logits, rtol=0, atol=0)
|
| 377 |
+
before = cache_a.lengths.clone()
|
| 378 |
+
with pytest.raises(ValueError, match="placeholders"):
|
| 379 |
+
model(ids, cache_a, lengths, image_embeds=features[:1])
|
| 380 |
+
assert torch.equal(cache_a.lengths, before)
|
| 381 |
+
with pytest.raises(ValueError, match="only accepted during prefill"):
|
| 382 |
+
model(token, cache_a, one, is_decode=True, pixel_values=pixels)
|
| 383 |
+
with pytest.raises(ValueError, match="require image inputs"):
|
| 384 |
+
model(ids, cache_a, lengths)
|
| 385 |
+
with patch.object(model.vision_model, "forward", wraps=model.vision_model.forward) as encode:
|
| 386 |
+
output = model.generate_greedy([ids.tolist(), [8, 9]], max_new_tokens=3,
|
| 387 |
+
device=device, eos_token_ids={-1}, pixel_values=pixels)
|
| 388 |
+
assert [len(row) for row in output] == [3, 3]
|
| 389 |
+
assert encode.call_count == 1
|
| 390 |
+
assert text.generate_greedy([[5, 6]], max_new_tokens=2, device=device, eos_token_ids={-1}) == model.generate_greedy([[5, 6]], max_new_tokens=2, device=device, eos_token_ids={-1})
|
inference/validate_vision.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Validate complete Step-5 vision and projector outputs.
|
| 2 |
+
|
| 3 |
+
Only vision/projector tensors are loaded. Text generation is covered separately
|
| 4 |
+
by test_vision.py and the TP fixture smoke. No serving stack is required.
|
| 5 |
+
"""
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
import argparse
|
| 9 |
+
import copy
|
| 10 |
+
import hashlib
|
| 11 |
+
import importlib.util
|
| 12 |
+
import json
|
| 13 |
+
from pathlib import Path
|
| 14 |
+
import sys
|
| 15 |
+
import time
|
| 16 |
+
import types
|
| 17 |
+
|
| 18 |
+
import torch
|
| 19 |
+
from safetensors import safe_open
|
| 20 |
+
|
| 21 |
+
from model import Step5ForConditionalGeneration, config_from_dict
|
| 22 |
+
from vision_encoder import Step5VisionConfig
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def load_reference(path):
|
| 26 |
+
# Supply the reference's config import without installing the full HF model.
|
| 27 |
+
# Its encoder mathematics are imported unchanged from the supplied file.
|
| 28 |
+
package = types.ModuleType("_step5_vision_reference")
|
| 29 |
+
package.__path__ = [str(Path(path).parent)]
|
| 30 |
+
sys.modules[package.__name__] = package
|
| 31 |
+
config_module = types.ModuleType(package.__name__ + ".configuration_step3p7")
|
| 32 |
+
config_module.StepRoboticsVisionEncoderConfig = Step5VisionConfig
|
| 33 |
+
sys.modules[config_module.__name__] = config_module
|
| 34 |
+
spec = importlib.util.spec_from_file_location(package.__name__ + ".vision_encoder", path)
|
| 35 |
+
module = importlib.util.module_from_spec(spec)
|
| 36 |
+
sys.modules[spec.name] = module
|
| 37 |
+
spec.loader.exec_module(module)
|
| 38 |
+
return module.StepRoboticsVisionEncoder
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
@torch.inference_mode()
|
| 42 |
+
def validate(checkpoint, reference_encoder, device):
|
| 43 |
+
started = time.monotonic()
|
| 44 |
+
root = Path(checkpoint)
|
| 45 |
+
raw = json.loads((root / "config.json").read_text())
|
| 46 |
+
config = config_from_dict(raw)
|
| 47 |
+
# Keep the complete vision config and projection width. Avoid allocating the
|
| 48 |
+
# 92-layer language model for a vision-only numerical comparison.
|
| 49 |
+
scaffold = copy.deepcopy(raw)
|
| 50 |
+
scaffold["text_config"].update(
|
| 51 |
+
num_hidden_layers=0, num_nextn_predict_layers=0, layer_types=[],
|
| 52 |
+
use_moe=False, moe_layer_list=[], vocab_size=16,
|
| 53 |
+
)
|
| 54 |
+
scaffold_config = config_from_dict(scaffold)
|
| 55 |
+
reference_cls = load_reference(reference_encoder)
|
| 56 |
+
with torch.device(device):
|
| 57 |
+
model = Step5ForConditionalGeneration(scaffold_config).eval()
|
| 58 |
+
reference = reference_cls(config.vision_config).to(dtype=torch.bfloat16).eval()
|
| 59 |
+
index = json.loads((root / "model.safetensors.index.json").read_text())["weight_map"]
|
| 60 |
+
keys = sorted(k for k in index if k.startswith(("vision_model.", "vit_large_projector.")))
|
| 61 |
+
state = {}
|
| 62 |
+
for shard in sorted({index[k] for k in keys}):
|
| 63 |
+
with safe_open(root / shard, framework="pt", device="cpu") as handle:
|
| 64 |
+
for key in keys:
|
| 65 |
+
if index[key] == shard:
|
| 66 |
+
state[key] = handle.get_tensor(key)
|
| 67 |
+
vision_state = {k.removeprefix("vision_model."): v for k, v in state.items() if k.startswith("vision_model.")}
|
| 68 |
+
projector_state = {k.removeprefix("vit_large_projector."): v for k, v in state.items() if k.startswith("vit_large_projector.")}
|
| 69 |
+
model.vision_model.load_state_dict(vision_state, strict=True)
|
| 70 |
+
model.vit_large_projector.load_state_dict(projector_state, strict=True)
|
| 71 |
+
reference.load_state_dict(vision_state, strict=True)
|
| 72 |
+
print(f"Loaded {len(keys)} real vision/projector tensors", flush=True)
|
| 73 |
+
del state, vision_state, projector_state
|
| 74 |
+
torch.manual_seed(1709)
|
| 75 |
+
results = []
|
| 76 |
+
for label, side, batch, expected_tokens in (
|
| 77 |
+
("overview", config.vision_config.image_size, 1, config.image_token_len),
|
| 78 |
+
("crops", 504, 2, config.patch_token_len),
|
| 79 |
+
):
|
| 80 |
+
pixels = torch.randn(batch, 3, side, side, device=device, dtype=torch.bfloat16)
|
| 81 |
+
expected = reference(pixels)
|
| 82 |
+
actual = model.vision_model(pixels)
|
| 83 |
+
encoder_error = float((actual.float() - expected.float()).abs().max())
|
| 84 |
+
torch.testing.assert_close(actual, expected, rtol=0, atol=0)
|
| 85 |
+
grid = side // config.vision_config.patch_size
|
| 86 |
+
expected = expected.transpose(1, 2).reshape(batch, -1, grid, grid)
|
| 87 |
+
expected = reference.vit_downsampler2(reference.vit_downsampler1(expected))
|
| 88 |
+
expected = model.vit_large_projector(expected.flatten(2).transpose(1, 2))
|
| 89 |
+
actual = model._encode_images(pixels)
|
| 90 |
+
assert actual.shape == (batch, expected_tokens, config.text_config.hidden_size)
|
| 91 |
+
torch.testing.assert_close(actual, expected, rtol=0, atol=0)
|
| 92 |
+
assert torch.isfinite(actual).all()
|
| 93 |
+
row = dict(case=label, input_shape=list(pixels.shape), output_shape=list(actual.shape),
|
| 94 |
+
encoder_max_abs_error=encoder_error,
|
| 95 |
+
projected_max_abs_error=float((actual.float() - expected.float()).abs().max()))
|
| 96 |
+
results.append(row)
|
| 97 |
+
print(json.dumps(row), flush=True)
|
| 98 |
+
torch.cuda.synchronize(device)
|
| 99 |
+
return dict(status="PASS", scope="complete real vision tower and projector; no full language-model generation",
|
| 100 |
+
checkpoint=str(root), config_sha256=hashlib.sha256((root / "config.json").read_bytes()).hexdigest(),
|
| 101 |
+
reference_sha256=hashlib.sha256(Path(reference_encoder).read_bytes()).hexdigest(),
|
| 102 |
+
tensor_count=len(keys), dtype="bfloat16", device=torch.cuda.get_device_name(device),
|
| 103 |
+
cases=results, elapsed_seconds=time.monotonic() - started)
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
def main():
|
| 107 |
+
parser = argparse.ArgumentParser(description=__doc__)
|
| 108 |
+
parser.add_argument("--checkpoint", required=True)
|
| 109 |
+
parser.add_argument("--reference-encoder", required=True)
|
| 110 |
+
parser.add_argument("--device", default="cuda:0")
|
| 111 |
+
parser.add_argument("--output", required=True)
|
| 112 |
+
args = parser.parse_args()
|
| 113 |
+
result = validate(args.checkpoint, args.reference_encoder, torch.device(args.device))
|
| 114 |
+
Path(args.output).write_text(json.dumps(result, indent=2) + "\n")
|
| 115 |
+
print(json.dumps(result, indent=2))
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
if __name__ == "__main__":
|
| 119 |
+
main()
|
inference/vision_encoder.py
ADDED
|
@@ -0,0 +1,500 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Copyright 2025 The StepFun and HuggingFace Inc. team. All rights reserved.
|
| 2 |
+
# Licensed under the Apache License, Version 2.0.
|
| 3 |
+
"""Step-5 perception encoder; parameter names match the released checkpoint."""
|
| 4 |
+
from __future__ import annotations
|
| 5 |
+
|
| 6 |
+
from typing import Optional, Union
|
| 7 |
+
|
| 8 |
+
import torch
|
| 9 |
+
import torch.nn as nn
|
| 10 |
+
import torch.nn.functional as F
|
| 11 |
+
from transformers.activations import ACT2FN
|
| 12 |
+
from transformers.configuration_utils import PretrainedConfig
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class Step5VisionConfig(PretrainedConfig):
|
| 16 |
+
model_type = "perception_encoder"
|
| 17 |
+
|
| 18 |
+
def __init__(
|
| 19 |
+
self,
|
| 20 |
+
width=1536,
|
| 21 |
+
layers=47,
|
| 22 |
+
heads=16,
|
| 23 |
+
num_channels=3,
|
| 24 |
+
image_size=728,
|
| 25 |
+
mlp_ratio=8960 / 1536,
|
| 26 |
+
patch_size=14,
|
| 27 |
+
hidden_act="quick_gelu",
|
| 28 |
+
layer_norm_eps=1e-5,
|
| 29 |
+
ues_cls_token=False,
|
| 30 |
+
use_cls_token: Optional[bool] = None,
|
| 31 |
+
use_ln_pre=True,
|
| 32 |
+
use_ln_post=False,
|
| 33 |
+
use_abs_posemb=True,
|
| 34 |
+
use_rope2d=True,
|
| 35 |
+
ls_init_value=0.1,
|
| 36 |
+
**kwargs,
|
| 37 |
+
):
|
| 38 |
+
self.width = width
|
| 39 |
+
self.layers = layers
|
| 40 |
+
self.heads = heads
|
| 41 |
+
self.num_channels = num_channels
|
| 42 |
+
self.patch_size = patch_size
|
| 43 |
+
self.image_size = image_size
|
| 44 |
+
self.mlp_ratio = mlp_ratio
|
| 45 |
+
self.layer_norm_eps = layer_norm_eps
|
| 46 |
+
self.hidden_act = hidden_act
|
| 47 |
+
if use_cls_token is None:
|
| 48 |
+
use_cls_token = ues_cls_token
|
| 49 |
+
self.ues_cls_token = use_cls_token
|
| 50 |
+
self.use_cls_token = use_cls_token
|
| 51 |
+
self.use_ln_pre = use_ln_pre
|
| 52 |
+
self.ls_init_value = ls_init_value
|
| 53 |
+
self.use_ln_post = use_ln_post
|
| 54 |
+
self.use_abs_posemb = use_abs_posemb
|
| 55 |
+
self.use_rope2d = use_rope2d
|
| 56 |
+
super().__init__(**kwargs)
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def rotate_half(x: torch.Tensor) -> torch.Tensor:
|
| 60 |
+
"""Rotate adjacent coordinate pairs for the encoder's interleaved RoPE."""
|
| 61 |
+
x = x.reshape(*x.shape[:-1], -1, 2)
|
| 62 |
+
x1, x2 = x.unbind(dim=-1)
|
| 63 |
+
x = torch.stack((-x2, x1), dim=-1)
|
| 64 |
+
return x.reshape(*x.shape[:-2], -1)
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def apply_rotary_emb(freqs: torch.Tensor,
|
| 68 |
+
t: torch.Tensor,
|
| 69 |
+
start_index: int = 0,
|
| 70 |
+
scale: float = 1.0,
|
| 71 |
+
seq_dim: int = -2) -> torch.Tensor:
|
| 72 |
+
"""Apply 2D rotary embeddings to queries / keys."""
|
| 73 |
+
dtype = t.dtype
|
| 74 |
+
|
| 75 |
+
if t.ndim == 3:
|
| 76 |
+
seq_len = t.shape[seq_dim]
|
| 77 |
+
freqs = freqs[-seq_len:]
|
| 78 |
+
|
| 79 |
+
rot_dim = freqs.shape[-1]
|
| 80 |
+
end_index = start_index + rot_dim
|
| 81 |
+
assert rot_dim <= t.shape[-1], (
|
| 82 |
+
f"feature dimension {t.shape[-1]} is too small for rot_dim {rot_dim}")
|
| 83 |
+
|
| 84 |
+
t_left, t, t_right = (
|
| 85 |
+
t[..., :start_index],
|
| 86 |
+
t[..., start_index:end_index],
|
| 87 |
+
t[..., end_index:],
|
| 88 |
+
)
|
| 89 |
+
t = (t * freqs.cos() * scale) + (rotate_half(t) * freqs.sin() * scale)
|
| 90 |
+
out = torch.cat((t_left, t, t_right), dim=-1)
|
| 91 |
+
return out.type(dtype)
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
class EncoderRope2D(nn.Module):
|
| 95 |
+
"""Cacheable 2D rotary positional embedding."""
|
| 96 |
+
|
| 97 |
+
def __init__(
|
| 98 |
+
self,
|
| 99 |
+
dim: int,
|
| 100 |
+
max_grid_height: int,
|
| 101 |
+
max_grid_width: int,
|
| 102 |
+
use_cls_token: bool = False,
|
| 103 |
+
theta: Union[int, float] = 10000,
|
| 104 |
+
theta_rescale_factor: float = 1.0,
|
| 105 |
+
):
|
| 106 |
+
super().__init__()
|
| 107 |
+
self.dim = dim
|
| 108 |
+
self.max_grid_height = max_grid_height
|
| 109 |
+
self.max_grid_width = max_grid_width
|
| 110 |
+
self.use_cls_token = use_cls_token
|
| 111 |
+
self.theta = theta * theta_rescale_factor**(dim / (dim - 2))
|
| 112 |
+
cache = self._compute_2d_freqs()
|
| 113 |
+
self.register_buffer("freqs_cache", cache, persistent=False)
|
| 114 |
+
self._cache_signature = None
|
| 115 |
+
|
| 116 |
+
def _compute_inv_freq(self, base: Union[int, float],
|
| 117 |
+
dim: int, device=None) -> torch.Tensor:
|
| 118 |
+
|
| 119 |
+
freqs = 1.0 / (base**(
|
| 120 |
+
torch.arange(0, dim, 2, device=device)[:(dim // 2)].float() / dim))
|
| 121 |
+
return freqs
|
| 122 |
+
|
| 123 |
+
def _compute_freqs(self, t: torch.Tensor, inv_freq: torch.Tensor):
|
| 124 |
+
freqs = torch.einsum("..., f -> ... f", t.type(inv_freq.dtype),
|
| 125 |
+
inv_freq)
|
| 126 |
+
freqs = freqs.repeat_interleave(2, dim=-1)
|
| 127 |
+
return freqs
|
| 128 |
+
|
| 129 |
+
def _compute_2d_freqs(self, device=None) -> torch.Tensor:
|
| 130 |
+
grid_h_range = torch.arange(self.max_grid_height, dtype=torch.float, device=device)
|
| 131 |
+
grid_w_range = torch.arange(self.max_grid_width, dtype=torch.float, device=device)
|
| 132 |
+
if self.use_cls_token:
|
| 133 |
+
grid_h_range += 1
|
| 134 |
+
grid_w_range += 1
|
| 135 |
+
inv_freq = self._compute_inv_freq(self.theta, self.dim // 2, device=device)
|
| 136 |
+
freqs_h = self._compute_freqs(grid_h_range, inv_freq)[:, None].expand(
|
| 137 |
+
self.max_grid_height, self.max_grid_width, -1)
|
| 138 |
+
freqs_w = self._compute_freqs(grid_w_range, inv_freq)[None, :].expand(
|
| 139 |
+
self.max_grid_height, self.max_grid_width, -1)
|
| 140 |
+
freqs = torch.cat([freqs_w, freqs_h], dim=-1).reshape(
|
| 141 |
+
self.max_grid_height * self.max_grid_width, -1)
|
| 142 |
+
if self.use_cls_token:
|
| 143 |
+
freqs = torch.cat([torch.zeros(1, freqs.shape[-1], device=device), freqs], dim=0)
|
| 144 |
+
freqs = freqs[None, None, ...]
|
| 145 |
+
return freqs
|
| 146 |
+
|
| 147 |
+
def forward(self, q: torch.Tensor, k: torch.Tensor,
|
| 148 |
+
grid_hw: tuple[int, int]):
|
| 149 |
+
# HF meta loading may materialize nonpersistent buffers with empty
|
| 150 |
+
# storage. Rebuild from coordinates on first use and after device/dtype
|
| 151 |
+
# changes, independently of how checkpoint parameters were loaded.
|
| 152 |
+
signature = (q.device, q.dtype)
|
| 153 |
+
if self._cache_signature != signature:
|
| 154 |
+
self.freqs_cache = self._compute_2d_freqs(device=q.device).to(q.dtype)
|
| 155 |
+
self._cache_signature = signature
|
| 156 |
+
if not (0 < grid_hw[0] <= self.max_grid_height and 0 < grid_hw[1] <= self.max_grid_width):
|
| 157 |
+
raise ValueError(f"RoPE grid {grid_hw} exceeds configured grid {(self.max_grid_height, self.max_grid_width)}")
|
| 158 |
+
# If grid matches cached shape we reuse directly to avoid recomputation.
|
| 159 |
+
if grid_hw[0] != self.max_grid_height or grid_hw[1] != self.max_grid_width:
|
| 160 |
+
rows = torch.arange(grid_hw[0], device=q.device).view(-1, 1)
|
| 161 |
+
cols = torch.arange(grid_hw[1], device=q.device).view(1, -1)
|
| 162 |
+
positions = (rows * self.max_grid_width + cols).reshape(-1).to(
|
| 163 |
+
torch.long)
|
| 164 |
+
if self.use_cls_token:
|
| 165 |
+
positions = torch.cat(
|
| 166 |
+
[torch.zeros(1, device=q.device, dtype=torch.long), positions + 1], dim=0)
|
| 167 |
+
freqs = self.freqs_cache.index_select(2, positions)
|
| 168 |
+
else:
|
| 169 |
+
freqs = self.freqs_cache
|
| 170 |
+
q = apply_rotary_emb(freqs, q)
|
| 171 |
+
k = apply_rotary_emb(freqs, k)
|
| 172 |
+
return q, k
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
class EncoderLayerScale(nn.Module):
|
| 176 |
+
"""Per-channel residual scaling used when ls_init_value is set."""
|
| 177 |
+
|
| 178 |
+
def __init__(self, dim: int, init_values: float):
|
| 179 |
+
super().__init__()
|
| 180 |
+
self.gamma = nn.Parameter(torch.full((dim,), init_values))
|
| 181 |
+
|
| 182 |
+
def forward(self, hidden_states: torch.Tensor) -> torch.Tensor: # (B, L, D)
|
| 183 |
+
return hidden_states * self.gamma
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
class EncoderMLP(nn.Module):
|
| 187 |
+
"""Feed-forward network used inside each transformer block."""
|
| 188 |
+
|
| 189 |
+
def __init__(self, hidden_size: int, intermediate_size: int,
|
| 190 |
+
hidden_act: str):
|
| 191 |
+
super().__init__()
|
| 192 |
+
self.c_fc = nn.Linear(hidden_size, intermediate_size, bias=True)
|
| 193 |
+
self.act_fn = ACT2FN[hidden_act]
|
| 194 |
+
self.c_proj = nn.Linear(intermediate_size, hidden_size, bias=True)
|
| 195 |
+
|
| 196 |
+
def forward(self, hidden_states: torch.Tensor) -> torch.Tensor:
|
| 197 |
+
|
| 198 |
+
hidden_states = self.c_proj(self.act_fn(self.c_fc(hidden_states)))
|
| 199 |
+
return hidden_states
|
| 200 |
+
|
| 201 |
+
|
| 202 |
+
class EncoderVisionAttention(nn.Module):
|
| 203 |
+
"""Multi-head self attention with optional 2D RoPE."""
|
| 204 |
+
|
| 205 |
+
def __init__(
|
| 206 |
+
self,
|
| 207 |
+
hidden_size: int,
|
| 208 |
+
num_heads: int,
|
| 209 |
+
max_grid_height: int,
|
| 210 |
+
max_grid_width: int,
|
| 211 |
+
use_cls_token: bool = False,
|
| 212 |
+
use_rope2d: bool = True,
|
| 213 |
+
rope_theta: Union[int, float] = 10000,
|
| 214 |
+
rope_theta_rescale_factor: float = 1.0,
|
| 215 |
+
rope_freqs_for: str = "lang",
|
| 216 |
+
):
|
| 217 |
+
super().__init__()
|
| 218 |
+
if hidden_size % num_heads != 0:
|
| 219 |
+
raise ValueError(
|
| 220 |
+
f"hidden_size ({hidden_size}) must be divisible by num_heads ({num_heads})."
|
| 221 |
+
)
|
| 222 |
+
if use_rope2d and (hidden_size // num_heads) % 4:
|
| 223 |
+
raise ValueError("2D RoPE requires head_dim divisible by four")
|
| 224 |
+
if use_rope2d and rope_freqs_for != "lang":
|
| 225 |
+
raise ValueError("Only rope_freqs_for='lang' is implemented")
|
| 226 |
+
self.num_heads = num_heads
|
| 227 |
+
self.head_dim = hidden_size // num_heads
|
| 228 |
+
self.scale = self.head_dim**-0.5
|
| 229 |
+
self.in_proj_weight = nn.Parameter(torch.zeros(hidden_size * 3, hidden_size))
|
| 230 |
+
self.in_proj_bias = nn.Parameter(torch.zeros(hidden_size * 3))
|
| 231 |
+
self.out_proj = nn.Linear(hidden_size, hidden_size, bias=True)
|
| 232 |
+
|
| 233 |
+
self.rope = None
|
| 234 |
+
if use_rope2d:
|
| 235 |
+
self.rope = EncoderRope2D(
|
| 236 |
+
dim=self.head_dim,
|
| 237 |
+
max_grid_height=max_grid_height,
|
| 238 |
+
max_grid_width=max_grid_width,
|
| 239 |
+
use_cls_token=use_cls_token,
|
| 240 |
+
theta=rope_theta,
|
| 241 |
+
theta_rescale_factor=rope_theta_rescale_factor,
|
| 242 |
+
)
|
| 243 |
+
|
| 244 |
+
def forward(self, hidden_states: torch.Tensor, grid_hw: tuple[int, int]) -> torch.Tensor:
|
| 245 |
+
bsz, seq_len, _ = hidden_states.shape
|
| 246 |
+
qkv = F.linear(
|
| 247 |
+
hidden_states,
|
| 248 |
+
self.in_proj_weight,
|
| 249 |
+
self.in_proj_bias,
|
| 250 |
+
)
|
| 251 |
+
q, k, v = qkv.chunk(3, dim=-1)
|
| 252 |
+
|
| 253 |
+
q = q.view(bsz, seq_len, self.num_heads,
|
| 254 |
+
self.head_dim).transpose(1, 2)
|
| 255 |
+
k = k.view(bsz, seq_len, self.num_heads,
|
| 256 |
+
self.head_dim).transpose(1, 2)
|
| 257 |
+
if self.rope is not None:
|
| 258 |
+
q, k = self.rope(q, k, grid_hw=grid_hw)
|
| 259 |
+
v = v.view(bsz, seq_len, self.num_heads,
|
| 260 |
+
self.head_dim).transpose(1, 2)
|
| 261 |
+
|
| 262 |
+
attn_output = F.scaled_dot_product_attention(
|
| 263 |
+
q, k, v, is_causal=False, scale=self.scale)
|
| 264 |
+
attn_output = attn_output.transpose(1, 2).reshape(
|
| 265 |
+
bsz, seq_len, self.num_heads * self.head_dim)
|
| 266 |
+
return self.out_proj(attn_output)
|
| 267 |
+
|
| 268 |
+
|
| 269 |
+
class EncoderVisionBlock(nn.Module):
|
| 270 |
+
"""A single Vision Transformer block (self-attention + MLP)."""
|
| 271 |
+
|
| 272 |
+
def __init__(
|
| 273 |
+
self,
|
| 274 |
+
hidden_size: int,
|
| 275 |
+
num_heads: int,
|
| 276 |
+
mlp_ratio: float,
|
| 277 |
+
hidden_act: str,
|
| 278 |
+
layer_norm_eps: float,
|
| 279 |
+
ls_init_value: Optional[float] = None,
|
| 280 |
+
max_grid_height: Optional[int] = None,
|
| 281 |
+
max_grid_width: Optional[int] = None,
|
| 282 |
+
use_cls_token: bool = False,
|
| 283 |
+
use_rope2d: bool = True,
|
| 284 |
+
rope_kwargs: Optional[dict] = None,
|
| 285 |
+
):
|
| 286 |
+
super().__init__()
|
| 287 |
+
rope_kwargs = rope_kwargs or {}
|
| 288 |
+
self.attn = EncoderVisionAttention(
|
| 289 |
+
hidden_size,
|
| 290 |
+
num_heads,
|
| 291 |
+
max_grid_height=max_grid_height,
|
| 292 |
+
max_grid_width=max_grid_width,
|
| 293 |
+
use_cls_token=use_cls_token,
|
| 294 |
+
use_rope2d=use_rope2d,
|
| 295 |
+
**rope_kwargs,
|
| 296 |
+
)
|
| 297 |
+
self.ln_1 = nn.LayerNorm(hidden_size, eps=layer_norm_eps)
|
| 298 |
+
self.ln_2 = nn.LayerNorm(hidden_size, eps=layer_norm_eps)
|
| 299 |
+
|
| 300 |
+
intermediate = int(hidden_size * mlp_ratio)
|
| 301 |
+
self.mlp = EncoderMLP(hidden_size, intermediate, hidden_act)
|
| 302 |
+
|
| 303 |
+
self.ls_1 = EncoderLayerScale(hidden_size, ls_init_value) if ls_init_value is not None else nn.Identity()
|
| 304 |
+
self.ls_2 = EncoderLayerScale(hidden_size, ls_init_value) if ls_init_value is not None else nn.Identity()
|
| 305 |
+
|
| 306 |
+
def forward(self, hidden_states: torch.Tensor,
|
| 307 |
+
grid_hw: tuple[int, int]) -> torch.Tensor:
|
| 308 |
+
residual = hidden_states
|
| 309 |
+
hidden_states = self.ln_1(hidden_states)
|
| 310 |
+
hidden_states = self.attn(hidden_states, grid_hw=grid_hw)
|
| 311 |
+
hidden_states = residual + self.ls_1(hidden_states)
|
| 312 |
+
|
| 313 |
+
residual = hidden_states
|
| 314 |
+
hidden_states = self.ln_2(hidden_states)
|
| 315 |
+
hidden_states = self.mlp(hidden_states)
|
| 316 |
+
hidden_states = residual + self.ls_2(hidden_states)
|
| 317 |
+
return hidden_states
|
| 318 |
+
|
| 319 |
+
|
| 320 |
+
class EncoderVisionTransformer(nn.Module):
|
| 321 |
+
"""Stack of encoder blocks parameterised by Step5VisionConfig."""
|
| 322 |
+
|
| 323 |
+
def __init__(
|
| 324 |
+
self,
|
| 325 |
+
embed_dim: int,
|
| 326 |
+
depth: int,
|
| 327 |
+
num_heads: int,
|
| 328 |
+
mlp_ratio: float,
|
| 329 |
+
hidden_act: str,
|
| 330 |
+
layer_norm_eps: float,
|
| 331 |
+
ls_init_value: Optional[float] = None,
|
| 332 |
+
max_grid_height: Optional[int] = None,
|
| 333 |
+
max_grid_width: Optional[int] = None,
|
| 334 |
+
use_cls_token: bool = False,
|
| 335 |
+
use_rope2d: bool = True,
|
| 336 |
+
rope_kwargs: Optional[dict] = None,
|
| 337 |
+
):
|
| 338 |
+
super().__init__()
|
| 339 |
+
rope_kwargs = rope_kwargs or {}
|
| 340 |
+
self.resblocks = nn.ModuleList([
|
| 341 |
+
EncoderVisionBlock(embed_dim, num_heads, mlp_ratio, hidden_act,
|
| 342 |
+
layer_norm_eps,
|
| 343 |
+
max_grid_height=max_grid_height,
|
| 344 |
+
max_grid_width=max_grid_width,
|
| 345 |
+
use_cls_token=use_cls_token,
|
| 346 |
+
use_rope2d=use_rope2d,
|
| 347 |
+
ls_init_value=ls_init_value,
|
| 348 |
+
rope_kwargs=rope_kwargs)
|
| 349 |
+
for _ in range(depth)
|
| 350 |
+
])
|
| 351 |
+
|
| 352 |
+
def forward(self,
|
| 353 |
+
hidden_states: torch.Tensor,
|
| 354 |
+
grid_hw: tuple[int, int]) -> torch.Tensor:
|
| 355 |
+
for block in self.resblocks:
|
| 356 |
+
hidden_states = block(hidden_states, grid_hw=grid_hw)
|
| 357 |
+
return hidden_states
|
| 358 |
+
|
| 359 |
+
|
| 360 |
+
class Step5VisionEncoder(nn.Module):
|
| 361 |
+
"""
|
| 362 |
+
Vision encoder built from Step5VisionConfig.
|
| 363 |
+
|
| 364 |
+
The encoder performs patch embedding followed by a stack of transformer
|
| 365 |
+
blocks. Only the config fields defined in Step5VisionConfig (and
|
| 366 |
+
Step5VLConfig.vision_config) are expected.
|
| 367 |
+
"""
|
| 368 |
+
|
| 369 |
+
def __init__(self, config: Step5VisionConfig):
|
| 370 |
+
super().__init__()
|
| 371 |
+
self.config = config
|
| 372 |
+
|
| 373 |
+
self.hidden_size = config.width
|
| 374 |
+
self.num_heads = config.heads
|
| 375 |
+
self.num_hidden_layers = config.layers
|
| 376 |
+
self.patch_size = config.patch_size
|
| 377 |
+
self.image_size = config.image_size
|
| 378 |
+
self.use_cls_token = getattr(config, "use_cls_token", False)
|
| 379 |
+
self.use_rope2d = getattr(config, "use_rope2d", True)
|
| 380 |
+
self.use_abs_posemb = getattr(config, "use_abs_posemb", True)
|
| 381 |
+
self.layer_norm_eps = config.layer_norm_eps
|
| 382 |
+
self.mlp_ratio = getattr(config, "mlp_ratio", 8960 / 1536)
|
| 383 |
+
self.ls_init_value = getattr(config, "ls_init_value", None)
|
| 384 |
+
self.hidden_act = config.hidden_act
|
| 385 |
+
self.use_ln_pre = getattr(config, "use_ln_pre", False)
|
| 386 |
+
self.use_ln_post = getattr(config, "use_ln_post", True)
|
| 387 |
+
|
| 388 |
+
# Patch embedding.
|
| 389 |
+
self.conv1 = nn.Conv2d(in_channels=config.num_channels,
|
| 390 |
+
out_channels=self.hidden_size,
|
| 391 |
+
kernel_size=self.patch_size,
|
| 392 |
+
stride=self.patch_size,
|
| 393 |
+
bias=False)
|
| 394 |
+
|
| 395 |
+
self.ln_pre = nn.LayerNorm(self.hidden_size, eps=self.layer_norm_eps) if self.use_ln_pre else nn.Identity()
|
| 396 |
+
self.ln_post = nn.LayerNorm(self.hidden_size, eps=self.layer_norm_eps) if self.use_ln_post else nn.Identity()
|
| 397 |
+
|
| 398 |
+
grid_size = self.image_size // self.patch_size
|
| 399 |
+
self.base_grid = (grid_size, grid_size)
|
| 400 |
+
|
| 401 |
+
if self.use_cls_token:
|
| 402 |
+
self.class_embedding = nn.Parameter(
|
| 403 |
+
torch.randn(self.hidden_size) * (self.hidden_size**-0.5))
|
| 404 |
+
else:
|
| 405 |
+
self.class_embedding = None
|
| 406 |
+
|
| 407 |
+
if self.use_abs_posemb:
|
| 408 |
+
self.posemb_grid_size = self.image_size // self.patch_size
|
| 409 |
+
self.positional_embedding = nn.Parameter(
|
| 410 |
+
(self.hidden_size**-0.5) * torch.randn(
|
| 411 |
+
int(self.use_cls_token) + self.posemb_grid_size**2,
|
| 412 |
+
self.hidden_size,
|
| 413 |
+
))
|
| 414 |
+
|
| 415 |
+
self.transformer = EncoderVisionTransformer(
|
| 416 |
+
embed_dim=self.hidden_size,
|
| 417 |
+
depth=self.num_hidden_layers,
|
| 418 |
+
num_heads=self.num_heads,
|
| 419 |
+
mlp_ratio=self.mlp_ratio,
|
| 420 |
+
hidden_act=self.hidden_act,
|
| 421 |
+
layer_norm_eps=self.layer_norm_eps,
|
| 422 |
+
ls_init_value=self.ls_init_value,
|
| 423 |
+
max_grid_height=self.base_grid[0],
|
| 424 |
+
max_grid_width=self.base_grid[1],
|
| 425 |
+
use_cls_token=self.use_cls_token,
|
| 426 |
+
use_rope2d=self.use_rope2d,
|
| 427 |
+
rope_kwargs={
|
| 428 |
+
"rope_theta": getattr(config, "rope_theta", 10000),
|
| 429 |
+
"rope_theta_rescale_factor":
|
| 430 |
+
getattr(config, "rope_theta_rescale_factor", 1.0),
|
| 431 |
+
"rope_freqs_for": getattr(config, "rope_freqs_for", "lang"),
|
| 432 |
+
},
|
| 433 |
+
)
|
| 434 |
+
self.vit_downsampler1 = nn.Conv2d(self.hidden_size,
|
| 435 |
+
self.hidden_size * 2,
|
| 436 |
+
kernel_size=3,
|
| 437 |
+
stride=2,
|
| 438 |
+
padding=1)
|
| 439 |
+
self.vit_downsampler2 = nn.Conv2d(self.hidden_size * 2,
|
| 440 |
+
self.hidden_size * 4,
|
| 441 |
+
kernel_size=3,
|
| 442 |
+
stride=2,
|
| 443 |
+
padding=1)
|
| 444 |
+
|
| 445 |
+
|
| 446 |
+
def sample_abs_posemb(self, grid_h: int, grid_w: int):
|
| 447 |
+
if self.posemb_grid_size == grid_h and self.posemb_grid_size == grid_w:
|
| 448 |
+
return self.positional_embedding[None, ...]
|
| 449 |
+
|
| 450 |
+
pos_embed = self.positional_embedding
|
| 451 |
+
if self.use_cls_token:
|
| 452 |
+
cls_token_embed, pos_embed = pos_embed[:1], pos_embed[1:]
|
| 453 |
+
|
| 454 |
+
pos_embed = (pos_embed.reshape(1, self.posemb_grid_size,
|
| 455 |
+
self.posemb_grid_size,
|
| 456 |
+
-1).permute(0, 3, 1, 2).contiguous())
|
| 457 |
+
pos_embed = F.interpolate(pos_embed,
|
| 458 |
+
size=(grid_h, grid_w),
|
| 459 |
+
mode="bilinear",
|
| 460 |
+
align_corners=False)
|
| 461 |
+
pos_embed = pos_embed.permute(0, 2, 3, 1).reshape(-1, self.hidden_size)
|
| 462 |
+
|
| 463 |
+
if self.use_cls_token:
|
| 464 |
+
pos_embed = torch.cat([cls_token_embed, pos_embed], dim=0)
|
| 465 |
+
|
| 466 |
+
return pos_embed[None, ...]
|
| 467 |
+
|
| 468 |
+
def forward(self, pixel_values: torch.Tensor) -> torch.Tensor:
|
| 469 |
+
"""
|
| 470 |
+
Args:
|
| 471 |
+
pixel_values: Image tensor of shape (B, C, H, W).
|
| 472 |
+
"""
|
| 473 |
+
if pixel_values.ndim != 4 or pixel_values.shape[1] != self.config.num_channels:
|
| 474 |
+
raise ValueError("pixel_values must have shape [images, channels, height, width]")
|
| 475 |
+
bsz, _, height, width = pixel_values.shape
|
| 476 |
+
if not bsz or min(height, width) < self.patch_size or height % self.patch_size or width % self.patch_size:
|
| 477 |
+
raise ValueError("Image dimensions must be positive multiples of patch_size, with a nonempty batch")
|
| 478 |
+
grid_h, grid_w = height // self.patch_size, width // self.patch_size
|
| 479 |
+
|
| 480 |
+
hidden_state = self.conv1(pixel_values) # (B, D, Gh, Gw)
|
| 481 |
+
hidden_state = hidden_state.flatten(2).transpose(1, 2) # (B, Gh*Gw, D)
|
| 482 |
+
|
| 483 |
+
if self.use_cls_token:
|
| 484 |
+
cls_token = self.class_embedding.view(1, 1,
|
| 485 |
+
-1).expand(bsz, -1, -1)
|
| 486 |
+
hidden_state = torch.cat([cls_token, hidden_state], dim=1)
|
| 487 |
+
|
| 488 |
+
if self.use_abs_posemb:
|
| 489 |
+
pos_emb = self.sample_abs_posemb(grid_h, grid_w)
|
| 490 |
+
hidden_state = hidden_state + pos_emb
|
| 491 |
+
hidden_state = self.ln_pre(hidden_state)
|
| 492 |
+
hidden_state = self.transformer(hidden_state, grid_hw=(grid_h, grid_w))
|
| 493 |
+
|
| 494 |
+
if self.use_ln_post:
|
| 495 |
+
hidden_state = self.ln_post(hidden_state)
|
| 496 |
+
|
| 497 |
+
if self.use_cls_token:
|
| 498 |
+
hidden_state = hidden_state[:, 1:, :]
|
| 499 |
+
|
| 500 |
+
return hidden_state
|