Skip to content

[Feature] Add GLM-5.3-Flash F2: vision tower + projector (eager) - #2110

Merged
jayhenry merged 14 commits into
feat/glm53flash-f1-vl-datafrom
feat/glm53flash-f2-vision-tower
Oct 15, 2026
Merged

jayhenry merged 14 commits into
feat/glm53flash-f1-vl-datafrom
feat/glm53flash-f2-vision-tower

Conversation

@jayhenry

@jayhenry jayhenry commented Sep 23, 2026 •

Copy link
Copy Markdown
Collaborator

Stack #2179 (bottom to top):

  1. [Fix] Preserve DeepEP layout events in synchronous dispatch #2177 fix/deepep-layout-event → main
  2. [Feature] Add GLM-5.3-Flash F0: 25B cropped reference checkpoint builder #2105 feat/glm53flash-materialize-full-f0 → fix/deepep-layout-event
  3. [Feature] Add GLM-5.3-Flash F3: Kimi Delta Attention (KDA) #2106 feat/glm53flash-f3-kda → feat/glm53flash-materialize-full-f0
  4. [Feature] Add GLM-5.3-Flash F4: mHC four-stream residual #2107 feat/glm53flash-f4-mhc → feat/glm53flash-f3-kda
  5. [Feature] Add GLM-5.3-Flash F5: NoPE DSA + KPool indexer + clamped SwiGLU #2108 feat/glm53flash-f5-nope-dsa → feat/glm53flash-f4-mhc
  6. [Feature] Add GLM-5.3-Flash F1: VL data preprocessing pipeline #2109 feat/glm53flash-f1-vl-data → feat/glm53flash-f5-nope-dsa
  7. [Feature] Add GLM-5.3-Flash F2: vision tower + projector (eager) #2110 feat/glm53flash-f2-vision-tower → feat/glm53flash-f1-vl-data ← you are here
  8. [Fix] Correct generic MTP future embeddings and empty-shard loss #2178 fix/mtp-local-future-embeddings → feat/glm53flash-f2-vision-tower
  9. [Feature] Add GLM-5.3-Flash F6 core: text model + MTP + compose model #2111 feat/glm53flash-f6-text-moe → fix/mtp-local-future-embeddings

Base is #2109's branch (layer 6). Review only this PR's own diff.


Summary

Stack layer 7/9 of GLM-5.3-Flash support (base: layer 6, F1 VL data pipeline). The F2 vision tower and projector support production training.

The vision checkpoint test environment update previously in F6 is now included in this layer.

F2 core

Native XTuner vision tower and projector (xtuner/v1/model/compose/glm53/), split per the design doc's §5.2 module boundary: HF's single Glm5NextVisionModel becomes vision_tower (patch_embed → 2D axial RoPE → 24 blocks → post_layernorm) plus multi_modal_projector (Conv2d downsample + clamped-SwiGLU merger). cu_seqlens/position_ids are reimplemented natively rather than imported from transformers.vision_utils, matching qwen3_vl's established pattern. The vision RMSNorm follows HF/Automodel's cast-then-affine rounding, which matters in bf16.

Vision sequence parallel (design doc §9.3/§9.6)

Previously the tower rejected sequence_parallel_mesh.size() > 1 outright — the attention had half an Ulysses path but nothing sharded the patch sequence, which silently computes the wrong attention. Now implemented properly:

  • _shard_patches_for_sequence_parallel pads the global patch sequence up to a multiple of sp_size * spatial_merge_size ** 2 and splits it contiguously. Merge alignment is a hard constraint: the projector's downsample treats each consecutive group of 4 patches as one 2×2 block, so a shard boundary inside a group would merge patches owned by two different ranks — alignment is also what lets each rank run the projector on its own shard (local projector). Equal length is what Ulysses all-to-all requires.
  • The padding is declared as an extra grid_thw row rather than appended silently, so it forms its own cu_seqlens segment: padding patches can never attend to, or be attended by, a real image.
  • Position ids are sharded to match the local patches (RoPE runs before the all-to-all); cu_seqlens stays global (after the all-to-all, attention sees the whole sequence).

Three production bugs

  1. attn_impl="flash_attention" was broken, not merely unverified. It died with Expected a value of type 'Tensor' for argument 'max_seqlen_q' but instead found type 'int' — the tower was the only attention call site materializing max_seqlen via int(...item()), which flash_attn_varlen_func v2 rejects and which also forces a device sync. Now passed as a tensor, like every other call site does with SequenceContext.max_length_q.
  2. pixel_values was never moved to the device. SequenceContext deliberately leaves the media tensors on CPU (documented there as "sharded by the model side, then moved"), so real training died on step 1 with Input type (CPUBFloat16Type) and weight type (CUDABFloat16Type) should be the same. Every unit test passed device tensors in, so nothing caught it. The move happens after the SP split, so only this rank's shard is transferred.
  3. The ViT blocks had no activation checkpointing — the actual cause of multimodal OOM. The tower runs over raw patches, and a packed VL sample carries far more of them than the LLM sequence has tokens (an 8192-token pack measured img_tokens: 14688; a 16384-token pack ~31k), so 24 blocks of retained attention + 4096-wide MLP activations dominated peak memory. VL SFT OOMed in backward at 16k pack and still died on step 2 at 8k, long before the language tower was near its limit. Now wired through apply_activation_checkpointing honouring vision_recompute_ratio/checkpoint_preserve_rng_state, the same knobs qwen3_vl's tower uses. Peak drops to 85.85 GB at 16k pack.

Compile config

default_compile_cfg was empty for both modules, so the vision half stayed eager even under MODEL_COMPILE=1. The ViT block takes fullgraph=False (it dispatches into an attention kernel, and under SP it contains collectives); the projector takes a full graph.

Test Plan

tests/model/test_glm53_vision.py 14/14:

  • Real-checkpoint weight mapping: 339 + 8 = 347 parameters, from_hf(strict=False) with no missing/unloaded keys.
  • Forward parity vs HF Glm5NextVisionModel at rtol=0, atol=0 (fp32 and bf16): single image, multi-tubelet video, multi-image batch, plus backward gradient flow.
  • TestGlm53VisionSequenceParallel::test_sp_tower_and_projector_match_non_sp (2 GPU): 20 patches over two images — deliberately not a multiple of sp_size * merge_unit, exercising the padding branch — tower + projector under SP, gathered and trimmed, matches the non-SP result.
  • test_unaligned_patch_count_is_rejected_under_sp: a patch count that would split a merge block raises instead of mis-merging.
  • test_flash_attention_matches_eager (GPU): the flash path runs and agrees with the HF-aligned eager baseline.
  • test_cpu_pixel_values_are_moved_to_device: CPU pixel_values with a device grid_thw, the exact shape SequenceContext produces.

Known gaps

Vision-side FSDP2 multi-GPU parity is still covered only by the end-to-end smoke, not a dedicated test_vision_fsdp_parity.

spatial_dim = head_dim // 2
# 非持久 buffer 不会从 checkpoint 恢复;meta 构造时也必须保留真实频率,
# 避免加载阶段 to_empty 将它物化成未初始化数据。
inv_freq = 1.0 / (theta ** (torch.arange(0, spatial_dim, 2, dtype=torch.float, device="cpu") / spatial_dim))

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

复用已有的 BaseModel.build_rotary_embedding 是否更好?

@jayhenry
jayhenry force-pushed the feat/glm53flash-f2-vision-tower branch from 8cacd37 to befcd82 Compare October 14, 2026 17:17
@jayhenry
jayhenry force-pushed the feat/glm53flash-f2-vision-tower branch from befcd82 to 5c3f462 Compare October 15, 2026 03:55
@jayhenry
jayhenry removed this pull request from stack #2112 October 15, 2026 03:58
@jayhenry
jayhenry added this pull request to stack #2179 October 15, 2026 03:59
@jayhenry
jayhenry force-pushed the feat/glm53flash-f2-vision-tower branch from 5c3f462 to ad7d020 Compare October 15, 2026 04:21
jayhenry and others added 14 commits October 15, 2026 04:35
Implements the eager-precision core of the F2 milestone from
doc/xtuner_glm5p3flash_design.md: the native XTuner vision tower and
projector for GLM-5.3-Flash, split per the design doc's module boundary
(xtuner/v1/model/compose/glm53/). Authority for the F2 contract is
duanyanhui's xtuner_glm53_flash_vl_vision_design.md §4-§6/§9-§13 and
xtuner_glm53_flash_vl_develop_stages.md §2/§4/§5/§6 (stages 1/3/4/5);
this delivery covers stages 1 and 3 (skeleton + weight mapping, eager
bitwise forward) in full and stages 4/5 (FSDP2/compile, Vision SP) as
wiring only, not production-grade verification -- see Known gaps below
and in doc/progress.md.

- glm53_config.py: Glm53VisionConfig/Glm53ProjectorConfig, fields
  checked against the real checkpoint's vision_config (num_heads not
  num_attention_heads, out_hidden_size not text_hidden_size,
  rope_parameters defaulted since the checkpoint JSON omits it).
- modeling_vision.py: Glm53VisionModel (patch_embed -> 2D axial RoPE ->
  24 blocks with RMSNorm+QK-norm+attention+clamped-SwiGLU MLP ->
  post_layernorm). cu_seqlens/position_ids are natively reimplemented
  rather than imported from transformers.vision_utils at runtime, per
  the "native implementation, not an HF training-time dependency"
  principle (§3.2) and matching qwen3_vl's own established pattern.
- modeling_projector.py: Glm53Projector (downsample Conv2d + merger).
  XTuner's tower/projector split moves downsample into the projector,
  unlike HF's single Glm5NextVisionModel -- confirmed intentional via
  the design doc's HF-key-mapping table (§6.1), not an HF module
  boundary.
- vision_utils.py: flatten_video_grid_thw.

Test plan: tests/model/test_glm53_vision.py (8 tests, CPU only). Real-
checkpoint weight mapping (gated, skips without GLM_5_3_FLASH_PATH):
vision_tower (339) + projector (8) = 347 parameters, matching the
design doc's independently-recorded real-checkpoint vision key count;
from_hf(strict=False) leaves no missing/unloaded keys, spot-checked
tensors are torch.equal to the raw safetensors. Forward parity vs HF
Glm5NextVisionModel (tiny synthetic config, eager attention, rtol=0
atol=0): single image, multi-tubelet video (grid=[2,4,4], exercises
multi-segment cu_seqlens and t-repeated position ids), multi-image
batch, plus a backward gradient-flow smoke check. Ground truth is HF
model.visual as a whole (§5.2): XTuner's post_layernorm output is
compared against HF's pre-downsample state (recomputed via HF's own
downsample module, not just pooler_output).

Known gaps (design doc's own stage split -- stages 4/5 need multi-GPU
coordination -- recorded explicitly, not silently skipped): FSDP2
wiring (fully_shard) mirrors qwen3_vl's structural pattern but has no
multi-GPU parity test; attention supports sequence_parallel_mesh with
Ulysses all-to-all on q/k/v but the full merge-aligned-padding +
local-projector Vision SP design (§9.3/§9.6) and its SP=2/4 parity
suite are not implemented; torch.compile config not wired; production
attention kernel (FlashAttention/FlexAttention) tolerance matrix (§11.7)
not verified, only eager_attention; HF save round-trip not verified.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
bf16 parity needs the HF/Automodel cast-then-affine norm. Ulysses without
merge-aligned padding computes the wrong attention, so size>1 now errors.

Co-authored-by: Cursor <cursoragent@cursor.com>
…h kernel path

Closes three F2 known gaps that together kept the vision tower off the production training
path.

**Vision sequence parallel.** The tower previously rejected `sequence_parallel_mesh.size()>1`
outright: Ulysses on an unsharded patch sequence computes the wrong attention, and nothing split
the patches. Implements the design doc's §9.3/§9.6 merge-aligned padding + local projector:
`_shard_patches_for_sequence_parallel` pads the global patch sequence up to a multiple of
`sp_size * spatial_merge_size ** 2` and splits it contiguously, so (a) every shard holds a whole
number of 2x2 merge blocks and can therefore run the projector on its own shard, and (b) all
ranks carry the same patch count, which Ulysses all-to-all requires. The padding is declared as
an extra `grid_thw` row instead of being appended silently, which keeps it a separate
`cu_seqlens` segment -- padding patches can never attend to, or be attended by, a real image.
Position ids are sharded to match the local patches (RoPE is applied before the all-to-all)
while `cu_seqlens` stays global (the all-to-all hands attention the whole sequence).

**Flash attention was broken, not just unverified.** `attn_impl="flash_attention"` died with
`Expected a value of type 'Tensor' for argument 'max_seqlen_q' but instead found type 'int'`:
the tower was the only attention call site materializing `max_seqlen` via `int(...item())`,
which `flash_attn_varlen_func` v2 rejects outright and which also forces a device sync. Now
passed as a tensor like every other call site does with `SequenceContext.max_length_q`.

**Compile config.** `default_compile_cfg` was empty for both modules, so the vision half stayed
eager even under `MODEL_COMPILE=1`. The ViT block takes `fullgraph=False` (it dispatches into an
attention kernel, and under SP it also contains collectives); the projector, which is a Conv2d
plus the merger's clamped-SwiGLU chain with no dispatch or collective, takes a full graph.

Test Plan:
  - `TestGlm53VisionSequenceParallel::test_sp_tower_and_projector_match_non_sp` (2 GPU): 20
    patches over two images -- deliberately not a multiple of `sp_size * merge_unit`, so the
    padding branch is exercised -- tower + projector under SP, gathered and trimmed, matches the
    non-SP result.
  - `test_unaligned_patch_count_is_rejected_under_sp`: a patch count that would split a merge
    block raises instead of silently mis-merging.
  - `TestGlm53VisionProductionAttention::test_flash_attention_matches_eager` (GPU): the flash
    path now runs and agrees with the HF-aligned eager baseline in bf16.
  - tests/model/test_glm53_vision.py 13/13.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…edding

`SequenceContext` deliberately does not move `pixel_values`/`pixel_values_videos` in its `.to()`
(documented there as "sharded by the model side, then moved"), so the tower receives CPU tensors
in real training and died on the very first step with `Input type (CPUBFloat16Type) and weight
type (CUDABFloat16Type) should be the same`. Every unit test passed device tensors in directly,
so nothing caught it.

The move happens after the SP split, not before, so only this rank's shard is transferred -- the
reason `SequenceContext` leaves the tensor alone in the first place. It follows the module's own
parameter device rather than the global one, so a CPU-resident tower (used by the parity tests)
keeps working.

Test Plan: `test_cpu_pixel_values_are_moved_to_device` feeds CPU `pixel_values` with a
device-resident `grid_thw` -- the exact shape `SequenceContext` produces -- to a CUDA tower.
tests/model/test_glm53_vision.py 14/14.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The vision tower never applied activation checkpointing, so all 24 ViT blocks retained their
activations. That is not a small oversight for this model: the tower runs over *raw* patches,
and a packed VL sample carries far more of them than the LLM sequence has tokens -- an 8192-token
pack measured `img_tokens: 14688` -- so the retained attention plus 4096-wide MLP activations of
24 blocks dominate the step's peak memory. End-to-end VL SFT on the F0 25B checkpoint OOMed in
backward at `PACK_MAX_LENGTH=16384`, and still OOMed on step 2 at 8192, before the language
tower was anywhere near its limit.

Wires the blocks through `apply_activation_checkpointing` honouring `vision_recompute_ratio`
(default 1.0) and `checkpoint_preserve_rng_state`, the same knobs qwen3_vl's tower already uses.

Test Plan: tests/model/test_glm53_vision.py 14/14 (the checkpointed path keeps HF bitwise parity
and the SP parity result); end-to-end VL SFT memory result recorded in doc/progress.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Base this fix on the F2 vision-tower branch so the later text/compose
layer can inherit the shared training corrections.

Expand FP32 parameters replicated on an EP/TP submesh to the full world
mesh before excluding them from FSDP. Keep full-world replicas and
sharded DTensors unchanged.

Use an autograd-aware gather for SequenceContext.raw_inputs_embeds so
future-token inputs retain gradients across SP shard boundaries.

Keep the existing real CPU vision RoPE initialization and make its
FP32 dtype and non-persistent-buffer rationale explicit.

(cherry picked from commit b715eb9)
Calculate ratios from original global parameter shapes before communication. Discard legacy checkpoint ratios while preserving momentum, steps, and the current clipping policy.

Remove the ratio cache and post-load refresh hook. Cover configuration changes, legacy and DCP checkpoint restoration, and 3D/4D/5D convolution update equivalence.
Separate the existing DTensor guard into world-mesh and non-replicated-layout cases. Document plain parameters and fully replicated submesh parameters, including expansion across FSDP rows and normalization when the source mesh already covers all ranks.
Make autograd SP gather inputs contiguous while preserving gradients to the original embedding views.

Restore the clipping-only load hook. Compute Muon ratios from global parameter shapes and current groups for every update, write the same ratios back to state, and verify legacy checkpoints, configuration changes, and DCP restoration.
@jayhenry
jayhenry force-pushed the feat/glm53flash-f2-vision-tower branch from ad7d020 to 97ecaea Compare October 15, 2026 04:37
@jayhenry
jayhenry merged commit 6a75502 into main Oct 15, 2026
9 of 14 checks passed
@jayhenry
jayhenry deleted the feat/glm53flash-f2-vision-tower branch October 15, 2026 05:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants