Repository navigation
feat(models): add native Qwen3.5 family implementation #3429
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 3 commits
Commits
Show all changes
13 commits
Select commit
Hold shift + click to select a range
7178a5a
feat(models): add native Qwen3.5 family implementation
S1ro1 71c3311
Merge main into native Qwen3.5 implementation
S1ro1 0e48de1
fix(qwen3.5): clean native model and honor shared runtime fusions
S1ro1 a3eeb04
fix(qwen3.5): retain existing vision compiler boundary
S1ro1 1902f60
test(qwen3.5): fix expert choices when checking packed boundaries
S1ro1 9983e7b
fix(qwen3.5): remove vision attention compiler disable
S1ro1 a217360
refactor(qwen3.5): use Prime attention configuration directly
S1ro1 133107b
chore: restore mini_moe script to main
S1ro1 dcdf770
fix: allow VLM RL training with FP32 optimizer state
S1ro1 70d1445
test(qwen3.5): remove Transformers reference models
S1ro1 c48531b
test(qwen3.5): use public model factory and automatic attention
S1ro1 5ba7db5
test(qwen3.5): use public helper names consistently
S1ro1 d9162ce
test: consolidate native Qwen3.5 model coverage
S1ro1 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -56,7 +56,6 @@ | |
| from prime_rl.trainer.parallel_dims import ParallelDims | ||
| from prime_rl.trainer.world import get_world | ||
| from prime_rl.utils.logger import get_logger | ||
| from prime_rl.utils.sequence import get_cu_seqlens_from_position_ids | ||
| from prime_rl.utils.utils import format_time | ||
| from prime_rl.utils.vlm import get_language_model, get_vision_encoder, is_vlm_architecture | ||
| from prime_rl.utils.weights import ( | ||
|
|
@@ -88,277 +87,6 @@ def pre_download_model(model_name: str, *, skip_weights: bool = False) -> None: | |
| ) | ||
|
|
||
|
|
||
| def _patch_qwen3_5_moe_conversion_mapping(): | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Great. Love deleting patches |
||
| """Fix Qwen3.5 MoE conversion mapping incorrectly applying qwen2_moe expert weight splitting. | ||
|
|
||
| Qwen3.5 MoE stores expert weights as fused 3D tensors natively in the checkpoint | ||
| (e.g. experts.gate_up_proj [num_experts, 2*intermediate, hidden]). The upstream mapping | ||
| incorrectly maps qwen3_5_moe → qwen2_moe, which assumes per-expert 2D checkpoint weights, | ||
| causing revert_weight_conversion to produce wrong shapes during weight broadcasting. | ||
|
|
||
| Remove once an official Transformers release fixes this. | ||
| """ | ||
| from transformers.conversion_mapping import ( | ||
| get_checkpoint_conversion_mapping, | ||
| register_checkpoint_conversion_mapping, | ||
| ) | ||
|
|
||
| # qwen3_5_moe_text: keep only the qwen3_5_text renaming, remove qwen2_moe expert conversion | ||
| qwen3_5_text_mapping = get_checkpoint_conversion_mapping("qwen3_5_text") | ||
| if qwen3_5_text_mapping is not None: | ||
| register_checkpoint_conversion_mapping("qwen3_5_moe_text", qwen3_5_text_mapping, overwrite=True) | ||
|
|
||
| # qwen3_5_moe: remove the qwen2_moe fallback entirely | ||
| register_checkpoint_conversion_mapping("qwen3_5_moe", [], overwrite=True) | ||
|
|
||
|
|
||
| def _patch_qwen3_5_text_position_ids(): | ||
| """Fix Qwen3.5 passing 3D MRoPE position_ids to decoder layers instead of 2D text_position_ids. | ||
|
|
||
| Upstream fix: https://github.com/huggingface/transformers/pull/44399 | ||
| Remove once an official Transformers release includes this fix. | ||
| """ | ||
| import inspect | ||
|
|
||
| from transformers.models.qwen3_5.modeling_qwen3_5 import Qwen3_5DecoderLayer, Qwen3_5TextModel | ||
| from transformers.models.qwen3_5_moe.modeling_qwen3_5_moe import Qwen3_5MoeDecoderLayer, Qwen3_5MoeTextModel | ||
|
|
||
| for text_model_cls, decoder_layer_cls in [ | ||
| (Qwen3_5TextModel, Qwen3_5DecoderLayer), | ||
| (Qwen3_5MoeTextModel, Qwen3_5MoeDecoderLayer), | ||
| ]: | ||
| source = inspect.getsource(text_model_cls.forward) | ||
| if "decoder_layer" in source and "position_ids=text_position_ids" in source.split("decoder_layer")[-1]: | ||
| continue # already fixed upstream | ||
|
|
||
| _original_forward = decoder_layer_cls.forward | ||
|
|
||
| def _make_patched_forward(original): | ||
| def _patched_forward(self, hidden_states, position_ids=None, **kwargs): | ||
| if position_ids is not None and position_ids.ndim == 3: | ||
| position_ids = position_ids[0] | ||
| return original(self, hidden_states, position_ids=position_ids, **kwargs) | ||
|
|
||
| return _patched_forward | ||
|
|
||
| decoder_layer_cls.forward = _make_patched_forward(_original_forward) | ||
|
|
||
|
|
||
| def _patch_qwen3_5_linear_attn_varlen(): | ||
| """Thread cu_seqlens through Qwen3.5 GatedDeltaNet so packed batches don't | ||
| leak conv/SSM state across sequences. | ||
|
|
||
| HF's forward hardcodes seq_idx=None for causal_conv1d and omits cu_seqlens | ||
| for chunk_gated_delta_rule, so packed RL training sees ~0.23 Mismatch KL vs | ||
| vLLM (target <0.01). | ||
| """ | ||
| import torch.nn.functional as F | ||
| from transformers.models.qwen3_5.modeling_qwen3_5 import ( | ||
| Qwen3_5DecoderLayer, | ||
| Qwen3_5GatedDeltaNet, | ||
| Qwen3_5TextModel, | ||
| apply_mask_to_padding_states, | ||
| ) | ||
|
|
||
| try: | ||
| from fla.modules.convolution import causal_conv1d as fla_causal_conv1d | ||
| from fla.ops.cp import build_cp_context | ||
| except ImportError: | ||
| build_cp_context = None | ||
| fla_causal_conv1d = None | ||
|
|
||
| if fla_causal_conv1d is not None: | ||
| # The CP boundary-state exchange inside fla's conv is not dynamo-traceable; | ||
| # graph-break deliberately so the rest of the layer still compiles. | ||
| fla_causal_conv1d = torch.compiler.disable(fla_causal_conv1d) | ||
|
|
||
| if getattr(Qwen3_5GatedDeltaNet.forward, "_prl_varlen_patched", False): | ||
| return | ||
|
|
||
| _gdn_orig = Qwen3_5GatedDeltaNet.forward | ||
|
|
||
| def _build_cp_context(self, local_seq_len: int, device: torch.device, cu_seqlens=None): | ||
| cp_group = getattr(self, "cp_group", None) | ||
| if cp_group is None or build_cp_context is None: | ||
| return None | ||
| global_seq_len = local_seq_len * self.cp_world_size | ||
| if cu_seqlens is not None and int(cu_seqlens[-1].item()) == global_seq_len: | ||
| global_cu_seqlens = cu_seqlens.to(device=device, dtype=torch.int32) | ||
| else: | ||
| global_cu_seqlens = torch.tensor([0, global_seq_len], dtype=torch.int32, device=device) | ||
| return build_cp_context( | ||
| cu_seqlens=global_cu_seqlens, | ||
| group=cp_group, | ||
| conv1d_kernel_size=self.conv_kernel_size, | ||
| ) | ||
|
|
||
| def _gdn_forward(self, hidden_states, cache_params=None, attention_mask=None, cu_seqlens=None): | ||
| if cu_seqlens is None or cache_params is not None: | ||
| return _gdn_orig(self, hidden_states, cache_params=cache_params, attention_mask=attention_mask) | ||
|
|
||
| hidden_states = apply_mask_to_padding_states(hidden_states, attention_mask) | ||
| batch_size, seq_len, _ = hidden_states.shape | ||
|
|
||
| mixed_qkv = self.in_proj_qkv(hidden_states).transpose(1, 2) | ||
| z = self.in_proj_z(hidden_states).reshape(batch_size, seq_len, -1, self.head_v_dim) | ||
| b = self.in_proj_b(hidden_states) | ||
| a = self.in_proj_a(hidden_states) | ||
|
|
||
| cp_context = _build_cp_context(self, seq_len, hidden_states.device, cu_seqlens) | ||
|
|
||
| if cp_context is not None and fla_causal_conv1d is not None: | ||
| mixed_qkv, _ = fla_causal_conv1d( | ||
| x=mixed_qkv.transpose(1, 2), | ||
| weight=self.conv1d.weight.squeeze(1), | ||
| bias=self.conv1d.bias, | ||
| activation=self.activation, | ||
| cp_context=cp_context, | ||
| ) | ||
| mixed_qkv = mixed_qkv.transpose(1, 2) | ||
| elif self.causal_conv1d_fn is not None: | ||
| seg_lens = cu_seqlens[1:] - cu_seqlens[:-1] | ||
| seq_idx = torch.repeat_interleave( | ||
| torch.arange(seg_lens.numel(), dtype=torch.int32, device=hidden_states.device), | ||
| seg_lens, | ||
| ).unsqueeze(0) | ||
| mixed_qkv = self.causal_conv1d_fn( | ||
| x=mixed_qkv, | ||
| weight=self.conv1d.weight.squeeze(1), | ||
| bias=self.conv1d.bias, | ||
| activation=self.activation, | ||
| seq_idx=seq_idx, | ||
| ) | ||
| else: | ||
| # Per-segment conv1d so the kernel-1 left pad only draws from within each sequence. | ||
| cu = cu_seqlens.tolist() | ||
| conv_outs = [] | ||
| for i in range(len(cu) - 1): | ||
| s, e = cu[i], cu[i + 1] | ||
| if s == e: | ||
| continue | ||
| conv_outs.append(self.conv1d(mixed_qkv[:, :, s:e])[:, :, : e - s]) | ||
| mixed_qkv = F.silu(torch.cat(conv_outs, dim=-1)) | ||
|
|
||
| mixed_qkv = mixed_qkv.transpose(1, 2) | ||
| query, key, value = torch.split(mixed_qkv, [self.key_dim, self.key_dim, self.value_dim], dim=-1) | ||
| query = query.reshape(batch_size, seq_len, -1, self.head_k_dim) | ||
| key = key.reshape(batch_size, seq_len, -1, self.head_k_dim) | ||
| value = value.reshape(batch_size, seq_len, -1, self.head_v_dim) | ||
|
|
||
| beta = b.sigmoid() | ||
| g = -self.A_log.float().exp() * F.softplus(a.float() + self.dt_bias) | ||
| if self.num_v_heads // self.num_k_heads > 1: | ||
| query = query.repeat_interleave(self.num_v_heads // self.num_k_heads, dim=2) | ||
| key = key.repeat_interleave(self.num_v_heads // self.num_k_heads, dim=2) | ||
|
|
||
| if cp_context is not None: | ||
| core_attn_out, _ = self.chunk_gated_delta_rule( | ||
| query, | ||
| key, | ||
| value, | ||
| g=g, | ||
| beta=beta, | ||
| use_qk_l2norm_in_kernel=True, | ||
| cu_seqlens=cp_context.cu_seqlens, | ||
| cp_context=cp_context, | ||
| ) | ||
| else: | ||
| core_attn_out, _ = self.chunk_gated_delta_rule( | ||
| query, | ||
| key, | ||
| value, | ||
| g=g, | ||
| beta=beta, | ||
| initial_state=None, | ||
| output_final_state=False, | ||
| use_qk_l2norm_in_kernel=True, | ||
| cu_seqlens=cu_seqlens, | ||
| ) | ||
|
|
||
| core_attn_out = core_attn_out.reshape(-1, self.head_v_dim) | ||
| z = z.reshape(-1, self.head_v_dim) | ||
| core_attn_out = self.norm(core_attn_out, z) | ||
| core_attn_out = core_attn_out.reshape(batch_size, seq_len, -1) | ||
| return self.out_proj(core_attn_out) | ||
|
|
||
| _gdn_forward._prl_varlen_patched = True | ||
| Qwen3_5GatedDeltaNet.forward = _gdn_forward | ||
|
|
||
| _dec_orig = Qwen3_5DecoderLayer.forward | ||
|
|
||
| def _dec_forward( | ||
| self, | ||
| hidden_states, | ||
| position_embeddings, | ||
| attention_mask=None, | ||
| position_ids=None, | ||
| past_key_values=None, | ||
| cu_seqlens=None, | ||
| **kwargs, | ||
| ): | ||
| if position_ids is not None and position_ids.ndim == 3: | ||
| position_ids = position_ids[0] | ||
| if self.layer_type != "linear_attention": | ||
| return _dec_orig( | ||
| self, | ||
| hidden_states, | ||
| position_embeddings=position_embeddings, | ||
| attention_mask=attention_mask, | ||
| position_ids=position_ids, | ||
| past_key_values=past_key_values, | ||
| **kwargs, | ||
| ) | ||
|
|
||
| residual = hidden_states | ||
| hidden_states = self.input_layernorm(hidden_states) | ||
| hidden_states = self.linear_attn( | ||
| hidden_states=hidden_states, | ||
| cache_params=past_key_values, | ||
| attention_mask=attention_mask, | ||
| cu_seqlens=cu_seqlens, | ||
| ) | ||
| hidden_states = residual + hidden_states | ||
| residual = hidden_states | ||
| hidden_states = self.post_attention_layernorm(hidden_states) | ||
| hidden_states = self.mlp(hidden_states) | ||
| return residual + hidden_states | ||
|
|
||
| Qwen3_5DecoderLayer.forward = _dec_forward | ||
|
|
||
| _text_orig = Qwen3_5TextModel.forward | ||
|
|
||
| def _text_forward( | ||
| self, | ||
| input_ids=None, | ||
| attention_mask=None, | ||
| position_ids=None, | ||
| past_key_values=None, | ||
| inputs_embeds=None, | ||
| use_cache=None, | ||
| **kwargs, | ||
| ): | ||
| attn_impl = getattr(self.config, "_attn_implementation", None) | ||
| cu_seqlens = None | ||
| if attn_impl in ("flash_attention_2", "flash_attention_3", "flash_attention_4") and position_ids is not None: | ||
| pids = position_ids | ||
| if pids.ndim == 3: | ||
| pids = pids[0] | ||
| cu_seqlens, _ = get_cu_seqlens_from_position_ids(pids) | ||
| kwargs["cu_seqlens"] = cu_seqlens | ||
| return _text_orig( | ||
| self, | ||
| input_ids=input_ids, | ||
| attention_mask=attention_mask, | ||
| position_ids=position_ids, | ||
| past_key_values=past_key_values, | ||
| inputs_embeds=inputs_embeds, | ||
| use_cache=use_cache, | ||
| **kwargs, | ||
| ) | ||
|
|
||
| Qwen3_5TextModel.forward = _text_forward | ||
|
|
||
|
|
||
| # Add filter to the standard logging module for transformers.modeling_utils to supress the | ||
| # flash attention dtype warnings since FSDP is used to handle mixed precision. | ||
| transformers_modeling_utils_logger = logging.getLogger("transformers.modeling_utils") | ||
|
|
@@ -569,11 +297,6 @@ def get_model( | |
| "VLM models must use optimization_dtype='bfloat16' and reduce_dtype='bfloat16' to match vLLM inference." | ||
| ) | ||
|
|
||
| # Qwen3.6 and Qwen3.8 reuse the Qwen3.5 architecture, so match on model_type, not repo name. | ||
| if getattr(model_config, "model_type", "").startswith("qwen3_5"): | ||
| _patch_qwen3_5_text_position_ids() | ||
| _patch_qwen3_5_moe_conversion_mapping() | ||
| _patch_qwen3_5_linear_attn_varlen() | ||
| if is_vlm_arch and config.cp > 1 and config.cp_style == "ulysses": | ||
| vision_config = getattr(model_config, "vision_config", None) | ||
| if vision_config is not None: | ||
|
|
||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.