Fix Qwen 3.5 35B RL gibberish output issue and show end-to-end RL convergence - #4770
Fix Qwen 3.5 35B RL gibberish output issue and show end-to-end RL convergence#4770AntonyMei wants to merge 12 commits into
Conversation
There was a problem hiding this comment.
Code Review
This pull request adds support for Qwen3.5 post-training reinforcement learning (RL) using GRPO on TPU hardware. Key enhancements include a hybrid cache layout helper for Qwen's Gated DeltaNet (GDN) to prevent silent quantization, scanned-weight unrolling for direct MaxText-to-MaxText weight synchronization, support for partial multi-dimensional rotary position embeddings (MRoPE), and dynamic replication of batch dimensions in the Mixture of Experts (MoE) layer to avoid sharding mismatches. Additionally, answer extraction utilities have been updated to handle prefilled reasoning start tokens. The code review feedback suggests several robustness improvements, including converting all path segments to strings during weight unrolling, adding defensive checks for null values in the MoE layer to prevent AttributeError crashes, and making the answer extraction regex robust to empty reasoning blocks.
| if container_idx == -1 or "dropout" in key or "rngs" in key: | ||
| continue | ||
| if not hasattr(value, "shape") or len(value.shape) <= scan_axis: | ||
| raise ValueError(f"Qwen scanned parameter {'.'.join(key)} has no scan axis {scan_axis}: {value!r}") |
There was a problem hiding this comment.
Using '.'.join(key) can raise a TypeError if any of the path segments in key are not strings (for example, integers representing sequence indices, which are common in Flax/NNX parameter paths). To ensure robustness and consistency with other parts of the codebase (such as line 255), we should convert all path segments to strings using map(str, key).
| raise ValueError(f"Qwen scanned parameter {'.'.join(key)} has no scan axis {scan_axis}: {value!r}") | |
| raise ValueError(f"Qwen scanned parameter {'.'.join(map(str, key))} has no scan axis {scan_axis}: {value!r}") |
| if pre_bias_logits_pspec is not None: | ||
| pre_bias_logits_pspec = remove_incompatible_mesh_axes_from_partition_spec( | ||
| pre_bias_logits_pspec, | ||
| pre_bias_logits.shape, | ||
| self.mesh, | ||
| dims=(0,), | ||
| ) |
There was a problem hiding this comment.
If pre_bias_logits is None (which is the case for non-DeepSeek3/4 models), attempting to access pre_bias_logits.shape will raise an AttributeError. We should defensively check that pre_bias_logits is not None before accessing its shape.
| if pre_bias_logits_pspec is not None: | |
| pre_bias_logits_pspec = remove_incompatible_mesh_axes_from_partition_spec( | |
| pre_bias_logits_pspec, | |
| pre_bias_logits.shape, | |
| self.mesh, | |
| dims=(0,), | |
| ) | |
| if pre_bias_logits_pspec is not None and pre_bias_logits is not None: | |
| pre_bias_logits_pspec = remove_incompatible_mesh_axes_from_partition_spec( | |
| pre_bias_logits_pspec, | |
| pre_bias_logits.shape, | |
| self.mesh, | |
| dims=(0,), | |
| ) |
| if decoder_tokens_pspec is not None: | ||
| decoder_tokens_pspec = remove_incompatible_mesh_axes_from_partition_spec( | ||
| decoder_tokens_pspec, | ||
| input_ids.shape, | ||
| self.mesh, | ||
| dims=(0,), | ||
| ) |
There was a problem hiding this comment.
If input_ids is None, attempting to access input_ids.shape will raise an AttributeError. To prevent potential runtime crashes, we should defensively check that input_ids is not None before accessing its properties.
| if decoder_tokens_pspec is not None: | |
| decoder_tokens_pspec = remove_incompatible_mesh_axes_from_partition_spec( | |
| decoder_tokens_pspec, | |
| input_ids.shape, | |
| self.mesh, | |
| dims=(0,), | |
| ) | |
| if decoder_tokens_pspec is not None and input_ids is not None: | |
| decoder_tokens_pspec = remove_incompatible_mesh_axes_from_partition_spec( | |
| decoder_tokens_pspec, | |
| input_ids.shape, | |
| self.mesh, | |
| dims=(0,), | |
| ) |
| ( | ||
| rf"{tmvp_config.reasoning_start_token}.+{tmvp_config.reasoning_end_token}.*?" | ||
| rf"{tmvp_config.solution_start_token}(.+?){tmvp_config.solution_end_token}" | ||
| rf"{reasoning_start}.+{re.escape(tmvp_config.reasoning_end_token)}.*?" |
There was a problem hiding this comment.
Using .+ requires at least one character before the reasoning_end_token. If reasoning_start_token_in_prompt is True and the model immediately outputs the closing reasoning tag (i.e., an empty reasoning block), the regex will fail to match. Changing .+ to .*? (non-greedy, 0 or more characters) makes the pattern more robust and prevents matching failures on empty reasoning blocks.
| rf"{reasoning_start}.+{re.escape(tmvp_config.reasoning_end_token)}.*?" | |
| rf"{reasoning_start}.*?{re.escape(tmvp_config.reasoning_end_token)}.*??" |
| return jax.sharding.PartitionSpec(*new_spec) | ||
|
|
||
|
|
||
| def remove_incompatible_mesh_axes_from_partition_spec(pspec, shape, mesh, dims=None): |
There was a problem hiding this comment.
I am worried this function gonna silently fallback sharding configs instead of shouting out and throwing errors. I suggested adding a flag allow_remove_axes=False and this fallback happens only when the flag is true.
| maybe_shard_with_pspec, | ||
| remove_expert_from_partition_spec, | ||
| remove_incompatible_mesh_axes_from_partition_spec, | ||
| ) |
There was a problem hiding this comment.
nice clean up, thank you
| inputs.shape, | ||
| self.mesh, | ||
| dims=(0,), | ||
| ) |
There was a problem hiding this comment.
could you move all logics from 1664-1698 to a separate small function, maybe named maybe_remove_incompatible_mesh_axes_from_partition_spec, so that readers don't need to read this logic unless necessary
| return -1, -1 | ||
|
|
||
|
|
||
| def unroll_qwen_scanned_weights(weights, scan_axis: int = 1, pattern_length: Optional[int] = None): |
There was a problem hiding this comment.
can you use
maxtext/src/maxtext/utils/max_utils.py
Line 1064 in 1555e1f
| raise ValueError(f"PartitionSpec rank {len(pspec)} exceeds tensor rank {len(shape)}") | ||
|
|
||
| dims_to_check = None if dims is None else set(dims) | ||
| compatible_pspec = pspec |
| @@ -0,0 +1,159 @@ | |||
| # Copyright 2026 Google LLC | |||
| # Qwen GDN keeps its short convolution history in BF16, but recurrence is | ||
| # accumulated and persisted in FP32. Declaring both caches as the model | ||
| # dtype silently quantizes the recurrent state after every generated token. | ||
| mamba_shapes, mamba_dtypes, unpadded_mamba_page_size = build_qwen_gdn_cache_layout( |
There was a problem hiding this comment.
but this file is general across all models. You probably want to make this build_qwen_gdn_cache_layout conditional on the model
| from typing import Any | ||
|
|
||
|
|
||
| def build_qwen_gdn_cache_layout(cfg: Any, torch_module: Any): |
There was a problem hiding this comment.
is this qwen specific? could other models also need such cache utilities?
| min_timescale: Start of the geometric index (typically 1). | ||
| max_timescale: End of the geometric index (rope_theta, e.g., 1000000). | ||
| embedding_dims: Dimension of the embedding (head_dim). | ||
| embedding_dims: Dimension of the attention head. |
There was a problem hiding this comment.
now that #4764 is merged, please rebase to remove those change from this PR
| ) | ||
| response = "We calculate 6 * 7.</think>\nThe final answer is 42. do do do" | ||
|
|
||
| self.assertEqual(utils_rl.extract_answer(response, config), "42") |
There was a problem hiding this comment.
I know the native final answer helps when the model did utter the answer and it is frustrating to not give it the reward. but it was not within answer tags, but if we allow it to put the answer this way it will not learn the format well, I know in the easy gsm8k dataset it is learning the format finally after ~150 training steps
6567fec to
92c2eca
Compare
Description
This PR fixes the Qwen 3.5 RL gibberish output issue and shows end-to-end RL training convergence with Qwen 3.5 35B + v5p-64 on GSM8K. This is a clean up of the fixes in yixuanm-dev-35b-new branch.
FIXES: b/521604343
FIXES: b/542769108
Tests
On yixuanm-dev-35b-new, Qwen 3.5 35B + v5p-64 on GSM8K shows end-to-end convergence:
gs://yixuanm-maxtext-logs/ConvergenceTesting/ym-qw35-35/tensorboard
Checklist
Before submitting this PR, please make sure (put X in square brackets):
gemini-reviewlabel.