csy0225 commited on
Commit
43fa7aa
·
verified ·
1 Parent(s): f4db8d6

Upload 10 files

Browse files
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