diff --git a/docs/assets/gemma4/gemma4-12b-cp1-200steps.png b/docs/assets/gemma4/gemma4-12b-cp1-200steps.png new file mode 100644 index 00000000000..1d5cec883b4 Binary files /dev/null and b/docs/assets/gemma4/gemma4-12b-cp1-200steps.png differ diff --git a/docs/assets/gemma4/gemma4-26ba4b-cp1-vs-cp2-100steps.png b/docs/assets/gemma4/gemma4-26ba4b-cp1-vs-cp2-100steps.png new file mode 100644 index 00000000000..56ada3e23e9 Binary files /dev/null and b/docs/assets/gemma4/gemma4-26ba4b-cp1-vs-cp2-100steps.png differ diff --git a/docs/assets/gemma4/gemma4-31b-cp1-vs-cp2-100steps.png b/docs/assets/gemma4/gemma4-31b-cp1-vs-cp2-100steps.png new file mode 100644 index 00000000000..571cf4f3ab1 Binary files /dev/null and b/docs/assets/gemma4/gemma4-31b-cp1-vs-cp2-100steps.png differ diff --git a/docs/assets/gemma4/gemma4-e2b-cp1-vs-cp2-100steps.png b/docs/assets/gemma4/gemma4-e2b-cp1-vs-cp2-100steps.png new file mode 100644 index 00000000000..84fb1757e53 Binary files /dev/null and b/docs/assets/gemma4/gemma4-e2b-cp1-vs-cp2-100steps.png differ diff --git a/docs/guides/models/gemma/gemma4.md b/docs/guides/models/gemma/gemma4.md new file mode 100644 index 00000000000..b9dbbfdc848 --- /dev/null +++ b/docs/guides/models/gemma/gemma4.md @@ -0,0 +1,148 @@ +# Gemma 4 + +NeMo RL supports post-training the Gemma 4 family with the AutoModel training +backend and vLLM generation. The reference configurations cover text-only DAPO +for the E2B, 12B, 26B-A4B, and 31B variants and multimodal GRPO for the E4B +variant. + +> [!IMPORTANT] +> **Status: Functionally Ready.** The listed configurations provide short-run +> functional and CI coverage. They are starting points for post-training, not a +> claim of long-run convergence on every model and parallel layout. + +## Support Matrix + +| Model | Task | Training backend | Training parallelism | Generation backend | Status | +| --- | --- | --- | --- | --- | --- | +| `google/gemma-4-E2B-it` | Text-only DAPO | AutoModel (FSDP2) | CP1 or CP2 | vLLM | Functionally Ready | +| `google/gemma-4-E4B-it` | VLM GRPO | AutoModel (FSDP2) | CP1 | vLLM TP4 | Functionally Ready | +| `google/gemma-4-12B-it` | Text-only DAPO | AutoModel (FSDP2) | CP1 | vLLM TP2 | Functionally Ready | +| `google/gemma-4-26B-A4B-it` | Text-only DAPO | AutoModel (FSDP2) | EP32 with CP1; EP16 with CP2 | vLLM TP4 | Functionally Ready | +| `google/gemma-4-31B-it` | Text-only DAPO | AutoModel (FSDP2) | CP1 or CP2 | vLLM TP4 | Functionally Ready | + +## Reference Recipes + +Recipe YAML files under `examples/configs/recipes/` are the source of truth. + +| Model | Layout | Nodes and GPUs | Recipe | +| --- | --- | --- | --- | +| E2B | FSDP2, CP1 | 1n8g | [`dapo-gemma4-e2b-it-1n8g-fsdp2-automodel.yaml`](../../../../examples/configs/recipes/llm/dapo-gemma4-e2b-it-1n8g-fsdp2-automodel.yaml) | +| E2B | FSDP2, CP2 | 1n8g | [`dapo-gemma4-e2b-it-1n8g-fsdp2cp2-automodel.yaml`](../../../../examples/configs/recipes/llm/dapo-gemma4-e2b-it-1n8g-fsdp2cp2-automodel.yaml) | +| E4B | VLM FSDP2, CP1 | 1n8g | [`vlm_grpo-gemma4-e4b-geo3k-1n8g-automodel.yaml`](../../../../examples/configs/recipes/vlm/vlm_grpo-gemma4-e4b-geo3k-1n8g-automodel.yaml) | +| 12B | FSDP2, CP1 | 2n8g | [`dapo-gemma4-12b-it-2n8g-fsdp2-automodel.yaml`](../../../../examples/configs/recipes/llm/dapo-gemma4-12b-it-2n8g-fsdp2-automodel.yaml) | +| 26B-A4B | FSDP2, EP32, CP1 | 4n8g | [`dapo-gemma4-26ba4b-it-4n8g-fsdp2-automodel.yaml`](../../../../examples/configs/recipes/llm/dapo-gemma4-26ba4b-it-4n8g-fsdp2-automodel.yaml) | +| 26B-A4B | FSDP2, EP16, CP2 | 4n8g | [`dapo-gemma4-26ba4b-it-4n8g-fsdp2ep16cp2-automodel.yaml`](../../../../examples/configs/recipes/llm/dapo-gemma4-26ba4b-it-4n8g-fsdp2ep16cp2-automodel.yaml) | +| 31B | FSDP2, CP1 | 4n8g | [`dapo-gemma4-31b-it-4n8g-fsdp2-automodel.yaml`](../../../../examples/configs/recipes/llm/dapo-gemma4-31b-it-4n8g-fsdp2-automodel.yaml) | +| 31B | FSDP2, CP2 | 4n8g | [`dapo-gemma4-31b-it-4n8g-fsdp2cp2-automodel.yaml`](../../../../examples/configs/recipes/llm/dapo-gemma4-31b-it-4n8g-fsdp2cp2-automodel.yaml) | + +## Run a Recipe + +From an allocation matching the recipe, launch the standard GRPO entry point. +For example, run the 12B configuration with: + +```bash +uv run examples/run_grpo.py \ + --config examples/configs/recipes/llm/dapo-gemma4-12b-it-2n8g-fsdp2-automodel.yaml +``` + +See the [GRPO guide](../../grpo.md) for algorithm and launch details. + +## Context Parallel + +Context Parallel support uses the refactored AutoModel CP interface introduced +by [PR #3498](https://github.com/NVIDIA-NeMo/RL/pull/3498). The E2B and 31B CP2 +recipes are thin overrides of their CP1 parents that change +`policy.dtensor_cfg.context_parallel_size` to `2`. The 26B-A4B CP2 recipe also +changes expert parallelism from EP32 to EP16 so the model-parallel product fits +on the same four-node allocation. Other model, optimizer, sequence-length, and +generation settings remain identical to their CP1 parents. + +Context Parallel currently applies to the text-only E2B, 26B-A4B, and 31B +recipes backed by AutoModel's Gemma 4 model-owned attention. Do not enable it +for the E4B VLM recipe or the 12B unified checkpoint. The text-only recipes +freeze the vision and audio towers, disable sequence packing, and configure +vLLM with `language_model_only: true` where the checkpoint requires it. + +Training and generation parallelism are independent. CP partitions training +sequences; the `tensor_parallel_size` under `policy.generation.vllm_cfg` +controls vLLM. The AutoModel model-parallel product must divide the training +world size. The 26B-A4B CP2 recipe uses EP16 × CP2 across 32 GPUs, provided by +four nodes with eight GPUs per node. + +## Validation Curves + +The following plots show raw step-level metrics captured from completed +validation runs. Validation accuracy is plotted only at validation steps; the +other metrics are plotted at every recorded training step. + +### E2B Context Parallel Parity + +The E2B CP1 and CP2 runs use the same 100-step training configuration except +for `policy.dtensor_cfg.context_parallel_size`. Their trajectories remain close +across the six metrics: the mean absolute difference in validation accuracy is +0.0083 across six aligned validation points, while the mean absolute difference +in generation KL error is 1.6e-5 across 100 aligned training points. + +![Gemma 4 E2B CP1 versus CP2 curves for train/loss, train/mean_gen_tokens_per_sample, train/reward, validation/accuracy, train/gen_kl_error, and train/approx_entropy](../../../assets/gemma4/gemma4-e2b-cp1-vs-cp2-100steps.png) + +### 26B-A4B Context Parallel Parity + +The 26B-A4B pair keeps the four-node, 32-GPU training world size fixed while +changing the model-parallel layout from EP32 × CP1 to EP16 × CP2. Both runs +complete 100 steps. The mean absolute difference in validation accuracy is +0.0114 across 20 aligned validation points, while the mean absolute difference +in generation KL error is 5.6e-5 across 100 aligned training points. The final +validation accuracies are 0.741 for EP32 × CP1 and 0.751 for EP16 × CP2. + +![Gemma 4 26B-A4B EP32 CP1 versus EP16 CP2 curves for train/loss, train/mean_gen_tokens_per_sample, train/reward, validation/accuracy, train/gen_kl_error, and train/approx_entropy](../../../assets/gemma4/gemma4-26ba4b-cp1-vs-cp2-100steps.png) + +### 31B Context Parallel Parity + +The 31B CP1 and CP2 runs use the same four-node training configuration except +for `policy.dtensor_cfg.context_parallel_size`. Both runs complete 100 steps. +The mean absolute difference in validation accuracy is 0.0099 across 20 aligned +validation points, while the mean absolute difference in generation KL error is +2.2e-5 across 100 aligned training points. The final validation accuracies are +0.823 for CP1 and 0.818 for CP2. + +![Gemma 4 31B CP1 versus CP2 curves for train/loss, train/mean_gen_tokens_per_sample, train/reward, validation/accuracy, train/gen_kl_error, and train/approx_entropy](../../../assets/gemma4/gemma4-31b-cp1-vs-cp2-100steps.png) + +### 12B Long Run + +The 12B CP1 run completed 200 steps. Validation accuracy increases from 0.540 +at step 5 to 0.617 at step 200 and reaches a maximum of 0.631. Training loss +decreases from 0.166 at step 1 to 0.054 at step 200, while generation KL error +stays below 6.9e-4. The recorded history contains 199 training-metric rows and +39 validation rows; step 125 has no values for the requested metrics, so the +plot does not interpolate that missing row. + +![Gemma 4 12B CP1 curves for train/loss, train/mean_gen_tokens_per_sample, train/reward, validation/accuracy, train/gen_kl_error, and train/approx_entropy](../../../assets/gemma4/gemma4-12b-cp1-200steps.png) + +## 12B Unified Checkpoint + +The 12B checkpoint reports `model_type: gemma4_unified` and architecture +`Gemma4UnifiedForConditionalGeneration`. NeMo RL routes it through the +image-text AutoModel class so the complete checkpoint can load, while the +reference recipe trains only the language path: + +- The vision and audio towers are frozen. +- vLLM generation uses `language_model_only: true` and TP2. +- Weight refits omit the frozen vision/audio tensors because vLLM's text-only + unified model uses encoder-free multimodal stubs with a different layout. +- vLLM tokenizer initialization is enabled for the unified architecture. +- Activation checkpointing and optimizer offload during log-probability + computation are enabled. +- Sequence packing and Liger kernels are disabled. +- The total sequence length is 4,096 tokens, with up to 3,072 generated tokens. + +## Limitations and Tracking + +- Context Parallel is not supported by the Gemma 4 VLM recipe. +- The 12B `gemma4_unified` checkpoint currently supports CP1 only; unlike the + other text variants, it does not use AutoModel's Gemma 4 model-owned CP + attention implementation. +- The recipes disable sequence packing; validate any packing change separately. +- The functional status does not imply long-run convergence for every variant. +- Gemma 4 CP support is tracked by + [#2914](https://github.com/NVIDIA-NeMo/RL/issues/2914), and 12B support by + [#2913](https://github.com/NVIDIA-NeMo/RL/issues/2913). diff --git a/docs/guides/models/gemma/index.md b/docs/guides/models/gemma/index.md new file mode 100644 index 00000000000..e24b33fbbbf --- /dev/null +++ b/docs/guides/models/gemma/index.md @@ -0,0 +1,20 @@ +# Gemma + +This is the landing page for Gemma model guidance in NeMo RL. It links to +version-specific pages covering reference recipes, supported parallel layouts, +and model-specific configuration requirements. + +For the full list of supported models, see +[Model Support](../../../about/model-support.md). + +## Version Guides + +- **[Gemma 4](gemma4.md)** — text-only DAPO recipes for the E2B, 12B, 26B-A4B, + and 31B variants, including AutoModel Context Parallel recipes, plus an E4B + VLM GRPO recipe. + +```{toctree} +:hidden: + +gemma4.md +``` diff --git a/docs/guides/models/index.md b/docs/guides/models/index.md index 5fdc0796e0e..3f688c5683c 100644 --- a/docs/guides/models/index.md +++ b/docs/guides/models/index.md @@ -12,6 +12,8 @@ For the full list of supported models, see - **[GLM](glm/index.md)** — GLM-5.1 and GLM-5.2 GRPO recipes on the Megatron backend, colocated and non-colocated with vLLM. +- **[Gemma](gemma/index.md)** — Gemma 4 text-only DAPO and VLM GRPO recipes on + AutoModel, including Context Parallel layouts for the text-only variants. - **[Nemotron](nemotron/index.md)** — post-training recipes for Nemotron 3 Nano, Nano Omni, Super, Ultra, and Nemotron 3.5 Lightning, spanning the Megatron and AutoModel backends. @@ -27,6 +29,7 @@ migrated into this hub as their guidance grows. :hidden: glm/index.md +gemma/index.md nemotron/index.md qwen/index.md ``` diff --git a/examples/configs/recipes/llm/dapo-gemma4-12b-it-2n8g-fsdp2-automodel.yaml b/examples/configs/recipes/llm/dapo-gemma4-12b-it-2n8g-fsdp2-automodel.yaml new file mode 100644 index 00000000000..ae53898eaeb --- /dev/null +++ b/examples/configs/recipes/llm/dapo-gemma4-12b-it-2n8g-fsdp2-automodel.yaml @@ -0,0 +1,98 @@ +defaults: ../../grpo_math_1B.yaml +grpo: + batch_multiplier: 3 + val_period: 5 + max_val_samples: 960 + val_batch_size: 960 + use_leave_one_out_baseline: false + use_dynamic_sampling: true + reward_scaling: + enabled: true + target_min: -1.0 + reward_shaping: + enabled: true + overlong_buffer_length: 384 + max_response_length: 3072 +loss_fn: + reference_policy_kl_penalty: 0.0 + use_importance_sampling_correction: true + truncated_importance_sampling_type: tis + truncated_importance_sampling_ratio: 2 + ratio_clip_max: 0.28 + ratio_clip_c: 10 +checkpointing: + checkpoint_dir: results/dapo-gemma4-12b-it-2n8g-fsdp2-automodel + save_period: 5 +policy: + model_name: google/gemma-4-12B-it + tokenizer: + name: google/gemma-4-12B-it + train_micro_batch_size: 1 + logprob_batch_size: 1 + max_total_sequence_length: 4096 + logprob_chunk_size: 4096 + offload_optimizer_for_logprob: true + optimizer: + name: transformer_engine.pytorch.optimizers.fused_adam.FusedAdam + kwargs: + lr: 1.0e-06 + weight_decay: 0.1 + master_weights: true + store_param_remainders: true + exp_avg_dtype: torch.bfloat16 + exp_avg_sq_dtype: torch.bfloat16 + scheduler: + - name: torch.optim.lr_scheduler.LinearLR + kwargs: + start_factor: 1.0e-08 + end_factor: 1.0 + total_iters: 10 + - name: torch.optim.lr_scheduler.ConstantLR + kwargs: + factor: 1.0 + total_iters: 10000000000 + - milestones: + - 10 + dtensor_cfg: + activation_checkpointing: true + automodel_kwargs: + use_liger_kernel: false + use_sdpa_patching: false + freeze_config: + freeze_vision_tower: true + freeze_audio_tower: true + freeze_language_model: false + sequence_packing: + enabled: false + dynamic_batching: + enabled: true + make_sequence_length_divisible_by: 1 + generation: + max_new_tokens: 3072 + vllm_cfg: + tensor_parallel_size: 2 + gpu_memory_utilization: 0.5 + vllm_kwargs: + language_model_only: true +data: + max_input_seq_length: 2048 + train: + dataset_name: DAPOMath17K + validation: + dataset_name: DAPOMathAIME2024 + default: + prompt_file: null +env: + math: + num_workers: 16 + math_verify_impl: dapo_math_verify +logger: + log_dir: logs/dapo-gemma4-12b-it-2n8g-fsdp2-automodel + wandb_enabled: true + tensorboard_enabled: true + wandb: + project: nemorl-gemma4 + name: dapo-gemma4-12b-it-2n8g-fsdp2-automodel +cluster: + gpus_per_node: 8 + num_nodes: 2 diff --git a/examples/configs/recipes/llm/dapo-gemma4-26ba4b-it-4n8g-fsdp2ep16cp2-automodel.yaml b/examples/configs/recipes/llm/dapo-gemma4-26ba4b-it-4n8g-fsdp2ep16cp2-automodel.yaml new file mode 100644 index 00000000000..0ae369f9f15 --- /dev/null +++ b/examples/configs/recipes/llm/dapo-gemma4-26ba4b-it-4n8g-fsdp2ep16cp2-automodel.yaml @@ -0,0 +1,11 @@ +defaults: ./dapo-gemma4-26ba4b-it-4n8g-fsdp2-automodel.yaml +policy: + dtensor_cfg: + expert_parallel_size: 16 + context_parallel_size: 2 +checkpointing: + checkpoint_dir: results/dapo-gemma4-26ba4b-it-4n8g-fsdp2ep16cp2-automodel +logger: + log_dir: logs/dapo-gemma4-26ba4b-it-4n8g-fsdp2ep16cp2-automodel + wandb: + name: dapo-gemma4-26ba4b-it-4n8g-fsdp2ep16cp2-automodel diff --git a/examples/configs/recipes/llm/dapo-gemma4-31b-it-4n8g-fsdp2cp2-automodel.yaml b/examples/configs/recipes/llm/dapo-gemma4-31b-it-4n8g-fsdp2cp2-automodel.yaml new file mode 100644 index 00000000000..d0c013581c8 --- /dev/null +++ b/examples/configs/recipes/llm/dapo-gemma4-31b-it-4n8g-fsdp2cp2-automodel.yaml @@ -0,0 +1,10 @@ +defaults: ./dapo-gemma4-31b-it-4n8g-fsdp2-automodel.yaml +policy: + dtensor_cfg: + context_parallel_size: 2 +checkpointing: + checkpoint_dir: results/dapo-gemma4-31b-it-4n8g-fsdp2cp2-automodel +logger: + log_dir: logs/dapo-gemma4-31b-it-4n8g-fsdp2cp2-automodel + wandb: + name: dapo-gemma4-31b-it-4n8g-fsdp2cp2-automodel diff --git a/examples/configs/recipes/llm/dapo-gemma4-e2b-it-1n8g-fsdp2cp2-automodel.yaml b/examples/configs/recipes/llm/dapo-gemma4-e2b-it-1n8g-fsdp2cp2-automodel.yaml new file mode 100644 index 00000000000..660beca59c2 --- /dev/null +++ b/examples/configs/recipes/llm/dapo-gemma4-e2b-it-1n8g-fsdp2cp2-automodel.yaml @@ -0,0 +1,10 @@ +defaults: ./dapo-gemma4-e2b-it-1n8g-fsdp2-automodel.yaml +policy: + dtensor_cfg: + context_parallel_size: 2 +checkpointing: + checkpoint_dir: results/dapo-gemma4-e2b-it-1n8g-fsdp2cp2-automodel +logger: + log_dir: logs/dapo-gemma4-e2b-it-1n8g-fsdp2cp2-automodel + wandb: + name: dapo-gemma4-e2b-it-1n8g-fsdp2cp2-automodel diff --git a/examples/configs/recipes/vlm/vlm_grpo-gemma4-e4b-geo3k-1n8g-automodel.yaml b/examples/configs/recipes/vlm/vlm_grpo-gemma4-e4b-geo3k-1n8g-automodel.yaml index bed343a4c7d..8793c8a5512 100644 --- a/examples/configs/recipes/vlm/vlm_grpo-gemma4-e4b-geo3k-1n8g-automodel.yaml +++ b/examples/configs/recipes/vlm/vlm_grpo-gemma4-e4b-geo3k-1n8g-automodel.yaml @@ -24,6 +24,8 @@ policy: exp_avg_sq_dtype: torch.bfloat16 dtensor_cfg: activation_checkpointing: true + env_vars: + PYTORCH_CUDA_ALLOC_CONF: expandable_segments:True automodel_kwargs: backend: _target_: nemo_automodel.components.models.common.utils.BackendConfig diff --git a/nemo_rl/models/automodel/setup.py b/nemo_rl/models/automodel/setup.py index e043e69c3a0..fc3b16ed26f 100644 --- a/nemo_rl/models/automodel/setup.py +++ b/nemo_rl/models/automodel/setup.py @@ -585,6 +585,15 @@ def setup_model_and_optimizer( "Please refer to https://github.com/NVIDIA/NeMo-RL/blob/main/docs/model-quirks.md#context-parallel-with-fsdp2 for more details." ) + if model_config.model_type == "gemma4_unified": + raise AssertionError( + "Context parallel is not supported for the Gemma 4 unified " + "checkpoint (model_type='gemma4_unified'). Its global-attention " + "GQA uses head_dim=512, for which no CP SDPA kernel is available. " + "Set policy.dtensor_cfg.context_parallel_size = 1. See " + "docs/guides/models/gemma/gemma4.md." + ) + if tp_size > 1 and sequence_parallel_enabled: raise AssertionError( "It's a known issue that context parallel can't be used together with sequence parallel in DTensor worker. " diff --git a/nemo_rl/models/generation/vllm/vllm_backend.py b/nemo_rl/models/generation/vllm/vllm_backend.py index 4ca276f8966..09024a798c4 100644 --- a/nemo_rl/models/generation/vllm/vllm_backend.py +++ b/nemo_rl/models/generation/vllm/vllm_backend.py @@ -201,6 +201,33 @@ def fix_gemma3_vision_weight_name(key: str) -> str: ) +_GEMMA4_UNIFIED_MULTIMODAL_WEIGHT_MARKERS = ( + "model.embed_vision.", + "model.embed_audio.", +) + + +def _is_gemma4_unified_text_only(model_config: Any) -> bool: + """Return whether a vLLM model is a text-only Gemma 4 Unified model.""" + multimodal_config = getattr(model_config, "multimodal_config", None) + return ( + "Gemma4UnifiedForConditionalGeneration" in model_config.architectures + and multimodal_config is not None + and multimodal_config.language_model_only + ) + + +def _filter_gemma4_unified_multimodal_weights( + weights: Iterable[tuple[str, torch.Tensor]], +) -> Iterator[tuple[str, torch.Tensor]]: + """Drop frozen Gemma 4 Unified vision and audio embedder weights.""" + return ( + (key, weight) + for key, weight in weights + if not key.startswith(_GEMMA4_UNIFIED_MULTIMODAL_WEIGHT_MARKERS) + ) + + def _read_mtp_layer_weights_from_checkpoint( model_path: str, mtp_layer_indices: set[int] ) -> list[tuple[str, torch.Tensor]]: @@ -253,6 +280,8 @@ class VllmInternalWorkerExtension: # True once the MTP drafter has been served by a one-time disk load (see # load_mtp_weights_from_disk); refit then leaves those static weights alone. _mtp_drafter_from_disk: bool = False + # Each worker logs the Gemma 4 Unified multimodal filtering at most once. + _logged_gemma4_unified_drop: bool = False _sparse_delta_applier: Any = None _nrl_named_parameters: dict[str, torch.nn.Parameter] _nrl_layerwise_reload_active: bool = False @@ -316,14 +345,14 @@ def _prepare_reload_weight_iterator( self, weights: Iterable[tuple[str, torch.Tensor]] ) -> Iterable[tuple[str, torch.Tensor]]: """Prepare checkpoint-format weights for vLLM's native reload API.""" - if ( - "Gemma3ForConditionalGeneration" - in self.model_runner.vllm_config.model_config.architectures - ): + model_config = self.model_runner.vllm_config.model_config + if "Gemma3ForConditionalGeneration" in model_config.architectures: weights = ( (fix_gemma3_vision_weight_name(name), weight) for name, weight in weights ) + if _is_gemma4_unified_text_only(model_config): + weights = _filter_gemma4_unified_multimodal_weights(weights) from nemo_rl.models.generation.vllm.quantization import fp8 @@ -785,19 +814,32 @@ def load_mtp_weights_from_disk(self, model_path: str) -> bool: return True def _load_weights(self, weights): - """Load weights with Gemma3 vision-tower weight name fix, FP8, and draft-weight support. + """Apply model-specific transforms and load policy and draft weights. - Applies Gemma3 vision-tower weight name fix if needed, splits policy/draft - weights, dispatches policy weights through the configured refit loader, - and loads draft weights into the drafter model. + Checkpoint-format weights are normalized for the target vLLM model, then + routed to the policy model and any supported speculative drafter. """ - if ( - "Gemma3ForConditionalGeneration" - in self.model_runner.vllm_config.model_config.architectures - ): + model_config = self.model_runner.vllm_config.model_config + if "Gemma3ForConditionalGeneration" in model_config.architectures: for idx, (key, weight) in enumerate(weights): weights[idx] = (fix_gemma3_vision_weight_name(key), weight) + if _is_gemma4_unified_text_only(model_config): + # HF loads the full unified checkpoint, while text-only vLLM uses + # encoder-free multimodal stubs with a different parameter layout. + # The recipe freezes these towers and never invokes them, so only + # refit the language path and leave the unused vLLM stubs untouched. + num_weights = len(weights) + weights = list(_filter_gemma4_unified_multimodal_weights(weights)) + num_dropped = num_weights - len(weights) + if num_dropped and not getattr(self, "_logged_gemma4_unified_drop", False): + self._logged_gemma4_unified_drop = True + logger.info( + "Gemma4 Unified text-only refit dropped %d frozen " + "vision/audio weights", + num_dropped, + ) + policy_weights, draft_weights = self._split_policy_and_draft_weights(weights) self._load_hf_weights(policy_weights) # Eagle3 draft weights are exported with the `draft.` prefix. diff --git a/nemo_rl/models/generation/vllm/vllm_worker.py b/nemo_rl/models/generation/vllm/vllm_worker.py index f503a89c3b5..f82c6dd905e 100644 --- a/nemo_rl/models/generation/vllm/vllm_worker.py +++ b/nemo_rl/models/generation/vllm/vllm_worker.py @@ -56,6 +56,7 @@ register_torchcodec_vllm_video_loader, ) from nemo_rl.models.generation.vllm.worker_utils import ( + find_tokenizer_required_architectures, resolve_data_parallel_local_rank, resolve_distributed_executor_backend, ) @@ -616,28 +617,9 @@ def _load_model(self, bundle_indices, seed): ) # disable quantization vllm_kwargs["hf_overrides"]["quantization_config"] = {} - elif any( - arch in getattr(hf_config, "architectures", []) - for arch in ( - "Gemma3ForConditionalGeneration", - "Gemma4ForConditionalGeneration", - "Mistral3ForConditionalGeneration", - "Qwen3_5ForConditionalGeneration", - "Qwen3_5MoeForConditionalGeneration", - ) + elif detected_arch := find_tokenizer_required_architectures( + getattr(hf_config, "architectures", None) ): - detected_arch = [ - arch - for arch in getattr(hf_config, "architectures", []) - if arch - in ( - "Gemma3ForConditionalGeneration", - "Gemma4ForConditionalGeneration", - "Mistral3ForConditionalGeneration", - "Qwen3_5ForConditionalGeneration", - "Qwen3_5MoeForConditionalGeneration", - ) - ] if self.cfg["vllm_cfg"]["skip_tokenizer_init"]: print( f"Detected {detected_arch} which may crash when skip_tokenizer_init is True. " diff --git a/nemo_rl/models/generation/vllm/worker_utils.py b/nemo_rl/models/generation/vllm/worker_utils.py index 831fbde0dd5..b3ec271a06a 100644 --- a/nemo_rl/models/generation/vllm/worker_utils.py +++ b/nemo_rl/models/generation/vllm/worker_utils.py @@ -12,6 +12,30 @@ # See the License for the specific language governing permissions and # limitations under the License. +from collections.abc import Iterable + +TOKENIZER_REQUIRED_ARCHITECTURES = frozenset( + { + "Gemma3ForConditionalGeneration", + "Gemma4ForConditionalGeneration", + "Gemma4UnifiedForConditionalGeneration", + "Mistral3ForConditionalGeneration", + "Qwen3_5ForConditionalGeneration", + "Qwen3_5MoeForConditionalGeneration", + } +) + + +def find_tokenizer_required_architectures( + architectures: Iterable[str] | None, +) -> list[str]: + """Return architectures for which vLLM must initialize a tokenizer.""" + return [ + architecture + for architecture in architectures or () + if architecture in TOKENIZER_REQUIRED_ARCHITECTURES + ] + def resolve_distributed_executor_backend( tensor_parallel_size: int, diff --git a/nemo_rl/models/policy/utils.py b/nemo_rl/models/policy/utils.py index 02220b21363..96ddd6ebd43 100644 --- a/nemo_rl/models/policy/utils.py +++ b/nemo_rl/models/policy/utils.py @@ -76,6 +76,7 @@ "internvl": AutoModelForImageTextToText, "gemma3": AutoModelForImageTextToText, "gemma4": AutoModelForImageTextToText, + "gemma4_unified": AutoModelForImageTextToText, "smolvlm": AutoModelForImageTextToText, "mistral3": AutoModelForImageTextToText, "llama4": AutoModelForImageTextToText, @@ -95,6 +96,7 @@ "internvl": NeMoAutoModelForImageTextToText, "gemma3": NeMoAutoModelForImageTextToText, "gemma4": NeMoAutoModelForImageTextToText, + "gemma4_unified": NeMoAutoModelForImageTextToText, "smolvlm": NeMoAutoModelForImageTextToText, "mistral3": NeMoAutoModelForImageTextToText, "llama4": NeMoAutoModelForImageTextToText, diff --git a/tests/test_suites/disabled.txt b/tests/test_suites/disabled.txt index fcbfd7e18e1..c4dd9afcfa9 100644 --- a/tests/test_suites/disabled.txt +++ b/tests/test_suites/disabled.txt @@ -9,6 +9,13 @@ tests/test_suites/vlm/vlm_grpo-qwen3.5-35ba3b-geo3k-2n8g-megatron-ep16.sh # Long-context-capable GLM validation is manual-only because it exceeds the recurring-suite GPU-hour budget. tests/test_suites/llm/grpo-glm5.2-72n8g-megatron-noncolocated.sh +# Gemma 4 keeps one CP configuration and one non-CP configuration in release. +# These additional validated layouts remain available for manual testing without +# adding their allocations to the recurring nightly or release GPU-hour budget. +tests/test_suites/llm/dapo-gemma4-e2b-it-1n8g-fsdp2cp2-automodel.sh +tests/test_suites/llm/dapo-gemma4-26ba4b-it-4n8g-fsdp2-automodel.sh +tests/test_suites/llm/dapo-gemma4-31b-it-4n8g-fsdp2cp2-automodel.sh + # The async video GRPO regression is self-contained, but its 16-node topology # is too large for nightly. Run it manually before changes to the async video # rollout/training path are merged. diff --git a/tests/test_suites/llm/dapo-gemma4-12b-it-2n8g-fsdp2-automodel.sh b/tests/test_suites/llm/dapo-gemma4-12b-it-2n8g-fsdp2-automodel.sh new file mode 100755 index 00000000000..b2dc781d0f4 --- /dev/null +++ b/tests/test_suites/llm/dapo-gemma4-12b-it-2n8g-fsdp2-automodel.sh @@ -0,0 +1,48 @@ +#!/bin/bash +SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd) +source $SCRIPT_DIR/common.env + +# ===== BEGIN CONFIG ===== +NUM_NODES=2 +STEPS_PER_RUN=20 +MAX_STEPS=20 +NUM_RUNS=$(( (MAX_STEPS + STEPS_PER_RUN - 1) / STEPS_PER_RUN )) # Round up +NUM_MINUTES=240 +# ===== END CONFIG ===== + +exit_if_max_steps_reached + +# Run the experiment +cd $PROJECT_ROOT +uv run examples/run_grpo.py \ + --config $CONFIG_PATH \ + grpo.max_num_steps=$MAX_STEPS \ + logger.log_dir=$LOG_DIR \ + logger.wandb_enabled=True \ + logger.wandb.project=nemo-rl \ + logger.wandb.name=$EXP_NAME \ + logger.monitor_gpus=True \ + logger.tensorboard_enabled=True \ + checkpointing.enabled=True \ + checkpointing.checkpoint_dir=$CKPT_DIR \ + $@ \ + 2>&1 | tee $RUN_LOG + +# Convert tensorboard logs to json +uv run tests/json_dump_tb_logs.py $LOG_DIR --output_path $JSON_METRICS + +# Only run metrics if the target step is reached +if [[ $(jq 'to_entries | .[] | select(.key == "train/loss") | .value | keys | map(tonumber) | max' $JSON_METRICS) -ge $MAX_STEPS ]]; then + # Calibrated from W&B runs spg9333n and g412b200. + uv run tests/check_metrics.py $JSON_METRICS \ + 'all_finite(data["train/loss"])' \ + 'all_finite(data["train/grad_norm"])' \ + 'all_finite(data["train/token_mult_prob_error"])' \ + 'median(data["train/token_mult_prob_error"]) < 1.05' \ + 'mean(data["train/gen_kl_error"]) < 0.001' \ + 'mean(data["train/reward"]) > 0.1' \ + 'mean(data["train/filtered_reward"]) > -0.15' + + # Clean up checkpoint directory after successful run to save space. + rm -rf "$CKPT_DIR" +fi diff --git a/tests/test_suites/llm/dapo-gemma4-26ba4b-it-4n8g-fsdp2ep16cp2-automodel.sh b/tests/test_suites/llm/dapo-gemma4-26ba4b-it-4n8g-fsdp2ep16cp2-automodel.sh new file mode 100755 index 00000000000..4e51e80f5e6 --- /dev/null +++ b/tests/test_suites/llm/dapo-gemma4-26ba4b-it-4n8g-fsdp2ep16cp2-automodel.sh @@ -0,0 +1,48 @@ +#!/bin/bash +SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd) +source $SCRIPT_DIR/common.env + +# ===== BEGIN CONFIG ===== +NUM_NODES=4 +STEPS_PER_RUN=20 +MAX_STEPS=20 +NUM_RUNS=$(( (MAX_STEPS + STEPS_PER_RUN - 1) / STEPS_PER_RUN )) # Round up +NUM_MINUTES=240 +# ===== END CONFIG ===== + +exit_if_max_steps_reached + +# Run the experiment +cd $PROJECT_ROOT +uv run examples/run_grpo.py \ + --config $CONFIG_PATH \ + grpo.max_num_steps=$MAX_STEPS \ + logger.log_dir=$LOG_DIR \ + logger.wandb_enabled=True \ + logger.wandb.project=nemo-rl \ + logger.wandb.name=$EXP_NAME \ + logger.monitor_gpus=True \ + logger.tensorboard_enabled=True \ + checkpointing.enabled=True \ + checkpointing.checkpoint_dir=$CKPT_DIR \ + $@ \ + 2>&1 | tee $RUN_LOG + +# Convert tensorboard logs to json +uv run tests/json_dump_tb_logs.py $LOG_DIR --output_path $JSON_METRICS + +# Only run metrics if the target step is reached +if [[ $(jq 'to_entries | .[] | select(.key == "train/loss") | .value | keys | map(tonumber) | max' $JSON_METRICS) -ge $MAX_STEPS ]]; then + # Calibrated from W&B runs g26e16c2 and g26e2p93 and recurring CP1 runs. + uv run tests/check_metrics.py $JSON_METRICS \ + 'all_finite(data["train/loss"])' \ + 'all_finite(data["train/grad_norm"])' \ + 'all_finite(data["train/token_mult_prob_error"])' \ + 'median(data["train/token_mult_prob_error"]) < 1.05' \ + 'mean(data["train/gen_kl_error"]) < 0.0015' \ + 'mean(data["train/reward"]) > 0.25' \ + 'mean(data["train/filtered_reward"]) > -0.15' + + # Clean up checkpoint directory after successful run to save space. + rm -rf "$CKPT_DIR" +fi diff --git a/tests/test_suites/llm/dapo-gemma4-31b-it-4n8g-fsdp2-automodel.sh b/tests/test_suites/llm/dapo-gemma4-31b-it-4n8g-fsdp2-automodel.sh index 0f323f9c69c..aa48b00e4b2 100755 --- a/tests/test_suites/llm/dapo-gemma4-31b-it-4n8g-fsdp2-automodel.sh +++ b/tests/test_suites/llm/dapo-gemma4-31b-it-4n8g-fsdp2-automodel.sh @@ -33,13 +33,15 @@ uv run tests/json_dump_tb_logs.py $LOG_DIR --output_path $JSON_METRICS # Only run metrics if the target step is reached if [[ $(jq 'to_entries | .[] | select(.key == "train/loss") | .value | keys | map(tonumber) | max' $JSON_METRICS) -ge $MAX_STEPS ]]; then - # Thresholds calibrated from the offpolicy baseline run (wandb project - # nemorl-gemma4-support, display name dapo-gemma4-31b-it-4n8g-fsdp2-automodel-offpolicy). + # Calibrated from recent nvidia/nemo-rl release runs and W&B run g31c1s91. uv run tests/check_metrics.py $JSON_METRICS \ + 'all_finite(data["train/loss"])' \ + 'all_finite(data["train/grad_norm"])' \ + 'all_finite(data["train/token_mult_prob_error"])' \ 'median(data["train/token_mult_prob_error"]) < 1.05' \ - 'mean(data["train/gen_kl_error"]) < 0.002' \ - 'data["train/reward"]["20"] > 0.1' \ - 'data["train/filtered_reward"]["20"] > -0.35' + 'mean(data["train/gen_kl_error"]) < 0.001' \ + 'mean(data["train/reward"]) > 0.4' \ + 'mean(data["train/filtered_reward"]) > 0.05' # Clean up checkpoint directory after successful run to save space. rm -rf "$CKPT_DIR" diff --git a/tests/test_suites/llm/dapo-gemma4-31b-it-4n8g-fsdp2cp2-automodel.sh b/tests/test_suites/llm/dapo-gemma4-31b-it-4n8g-fsdp2cp2-automodel.sh new file mode 100755 index 00000000000..651a7b4855b --- /dev/null +++ b/tests/test_suites/llm/dapo-gemma4-31b-it-4n8g-fsdp2cp2-automodel.sh @@ -0,0 +1,44 @@ +#!/bin/bash +SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd) +source $SCRIPT_DIR/common.env + +# ===== BEGIN CONFIG ===== +NUM_NODES=4 +STEPS_PER_RUN=20 +MAX_STEPS=20 +NUM_RUNS=$(( (MAX_STEPS + STEPS_PER_RUN - 1) / STEPS_PER_RUN )) # Round up +NUM_MINUTES=240 +# ===== END CONFIG ===== + +exit_if_max_steps_reached + +# Run the experiment +cd $PROJECT_ROOT +uv run examples/run_grpo.py \ + --config $CONFIG_PATH \ + grpo.max_num_steps=$MAX_STEPS \ + logger.log_dir=$LOG_DIR \ + logger.wandb_enabled=True \ + logger.wandb.project=nemo-rl \ + logger.wandb.name=$EXP_NAME \ + logger.monitor_gpus=True \ + logger.tensorboard_enabled=True \ + checkpointing.enabled=True \ + checkpointing.checkpoint_dir=$CKPT_DIR \ + $@ \ + 2>&1 | tee $RUN_LOG + +# Convert tensorboard logs to json +uv run tests/json_dump_tb_logs.py $LOG_DIR --output_path $JSON_METRICS + +# Only run metrics if the target step is reached +if [[ $(jq 'to_entries | .[] | select(.key == "train/loss") | .value | keys | map(tonumber) | max' $JSON_METRICS) -ge $MAX_STEPS ]]; then + uv run tests/check_metrics.py $JSON_METRICS \ + 'median(data["train/token_mult_prob_error"]) < 1.05' \ + 'mean(data["train/gen_kl_error"]) < 0.002' \ + 'data["train/reward"]["20"] > 0.1' \ + 'data["train/filtered_reward"]["20"] > -0.35' + + # Clean up checkpoint directory after successful run to save space. + rm -rf "$CKPT_DIR" +fi diff --git a/tests/test_suites/llm/dapo-gemma4-e2b-it-1n8g-fsdp2-automodel.sh b/tests/test_suites/llm/dapo-gemma4-e2b-it-1n8g-fsdp2-automodel.sh index d46a0bb6098..93b45f47732 100755 --- a/tests/test_suites/llm/dapo-gemma4-e2b-it-1n8g-fsdp2-automodel.sh +++ b/tests/test_suites/llm/dapo-gemma4-e2b-it-1n8g-fsdp2-automodel.sh @@ -33,11 +33,15 @@ uv run tests/json_dump_tb_logs.py $LOG_DIR --output_path $JSON_METRICS # Only run metrics if the target step is reached if [[ $(jq 'to_entries | .[] | select(.key == "train/loss") | .value | keys | map(tonumber) | max' $JSON_METRICS) -ge $MAX_STEPS ]]; then + # Calibrated from recent nvidia/nemo-rl nightly runs and W&B run e2c1a830. uv run tests/check_metrics.py $JSON_METRICS \ - 'median(data["train/token_mult_prob_error"]) < 1.1' \ - 'data["train/reward"]["20"] > -1.15' \ - 'data["train/filtered_reward"]["20"] > -1.10' \ - 'data["train/gen_kl_error"]["20"] < 0.001' + 'all_finite(data["train/loss"])' \ + 'all_finite(data["train/grad_norm"])' \ + 'all_finite(data["train/token_mult_prob_error"])' \ + 'median(data["train/token_mult_prob_error"]) < 1.05' \ + 'mean(data["train/gen_kl_error"]) < 0.001' \ + 'mean(data["train/reward"]) > -1.15' \ + 'mean(data["train/filtered_reward"]) > -0.75' # Clean up checkpoint directory after successful run to save space. rm -rf "$CKPT_DIR" diff --git a/tests/test_suites/llm/dapo-gemma4-e2b-it-1n8g-fsdp2cp2-automodel.sh b/tests/test_suites/llm/dapo-gemma4-e2b-it-1n8g-fsdp2cp2-automodel.sh new file mode 100755 index 00000000000..d46a0bb6098 --- /dev/null +++ b/tests/test_suites/llm/dapo-gemma4-e2b-it-1n8g-fsdp2cp2-automodel.sh @@ -0,0 +1,44 @@ +#!/bin/bash +SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd) +source $SCRIPT_DIR/common.env + +# ===== BEGIN CONFIG ===== +NUM_NODES=1 +STEPS_PER_RUN=20 +MAX_STEPS=20 +NUM_RUNS=$(( (MAX_STEPS + STEPS_PER_RUN - 1) / STEPS_PER_RUN )) # Round up +NUM_MINUTES=90 +# ===== END CONFIG ===== + +exit_if_max_steps_reached + +# Run the experiment +cd $PROJECT_ROOT +uv run examples/run_grpo.py \ + --config $CONFIG_PATH \ + grpo.max_num_steps=$MAX_STEPS \ + logger.log_dir=$LOG_DIR \ + logger.wandb_enabled=True \ + logger.wandb.project=nemo-rl \ + logger.wandb.name=$EXP_NAME \ + logger.monitor_gpus=True \ + logger.tensorboard_enabled=True \ + checkpointing.enabled=True \ + checkpointing.checkpoint_dir=$CKPT_DIR \ + $@ \ + 2>&1 | tee $RUN_LOG + +# Convert tensorboard logs to json +uv run tests/json_dump_tb_logs.py $LOG_DIR --output_path $JSON_METRICS + +# Only run metrics if the target step is reached +if [[ $(jq 'to_entries | .[] | select(.key == "train/loss") | .value | keys | map(tonumber) | max' $JSON_METRICS) -ge $MAX_STEPS ]]; then + uv run tests/check_metrics.py $JSON_METRICS \ + 'median(data["train/token_mult_prob_error"]) < 1.1' \ + 'data["train/reward"]["20"] > -1.15' \ + 'data["train/filtered_reward"]["20"] > -1.10' \ + 'data["train/gen_kl_error"]["20"] < 0.001' + + # Clean up checkpoint directory after successful run to save space. + rm -rf "$CKPT_DIR" +fi diff --git a/tests/test_suites/nightly.txt b/tests/test_suites/nightly.txt index f8ed7a2f69e..f214277fef8 100644 --- a/tests/test_suites/nightly.txt +++ b/tests/test_suites/nightly.txt @@ -199,6 +199,7 @@ tests/test_suites/llm/ppo-qwen2.5-1.5b-gsm8k-2n8g-megatron-valuetp2sp-dynbatch-n # Gemma 4 functional runs tests/test_suites/llm/dapo-gemma4-e2b-it-1n8g-fsdp2-automodel.sh +tests/test_suites/llm/dapo-gemma4-12b-it-2n8g-fsdp2-automodel.sh # Functional Gemma 4 E4B VLM GRPO run tests/test_suites/vlm/vlm_grpo-gemma4-e4b-geo3k-1n8g-automodel.sh diff --git a/tests/test_suites/release.txt b/tests/test_suites/release.txt index 0100881731a..f84ada484f6 100644 --- a/tests/test_suites/release.txt +++ b/tests/test_suites/release.txt @@ -37,7 +37,7 @@ tests/test_suites/llm/grpo-glm5.1-64n8g-megatron.sh tests/test_suites/llm/grpo-glm5.2-64n8g-megatron-6K-colocated.sh # Gemma 4 DAPO runs -tests/test_suites/llm/dapo-gemma4-26ba4b-it-4n8g-fsdp2-automodel.sh +tests/test_suites/llm/dapo-gemma4-26ba4b-it-4n8g-fsdp2ep16cp2-automodel.sh tests/test_suites/llm/dapo-gemma4-31b-it-4n8g-fsdp2-automodel.sh # Mistral Medium 3.5 (128B, FP8, text-only) DAPO run diff --git a/tests/test_suites/vlm/vlm_grpo-gemma4-e4b-geo3k-1n8g-automodel.sh b/tests/test_suites/vlm/vlm_grpo-gemma4-e4b-geo3k-1n8g-automodel.sh index a35c543943b..7f89d05dc04 100755 --- a/tests/test_suites/vlm/vlm_grpo-gemma4-e4b-geo3k-1n8g-automodel.sh +++ b/tests/test_suites/vlm/vlm_grpo-gemma4-e4b-geo3k-1n8g-automodel.sh @@ -33,9 +33,14 @@ uv run tests/json_dump_tb_logs.py $LOG_DIR --output_path $JSON_METRICS # Only run metrics if the target step is reached if [[ $(jq 'to_entries | .[] | select(.key == "train/loss") | .value | keys | map(tonumber) | max' $JSON_METRICS) -ge $MAX_STEPS ]]; then + # Calibrated from recent nvidia/nemo-rl nightly runs through 2026-09-04. uv run tests/check_metrics.py $JSON_METRICS \ - 'max(data["train/reward"]) > 0.45' \ - 'median(data["train/token_mult_prob_error"]) < 1.05' + 'all_finite(data["train/loss"])' \ + 'all_finite(data["train/grad_norm"])' \ + 'all_finite(data["train/token_mult_prob_error"])' \ + 'median(data["train/token_mult_prob_error"]) < 1.05' \ + 'mean(data["train/gen_kl_error"]) < 0.001' \ + 'mean(data["train/reward"]) > 0.4' # Clean up checkpoint directory after successful run to save space. rm -rf "$CKPT_DIR" diff --git a/tests/unit/models/automodel/test_automodel_setup.py b/tests/unit/models/automodel/test_automodel_setup.py index 9aba2d99fc7..0c7ed5e99cf 100644 --- a/tests/unit/models/automodel/test_automodel_setup.py +++ b/tests/unit/models/automodel/test_automodel_setup.py @@ -1631,6 +1631,48 @@ def test_setup_model_with_cp_raises_for_gemma3( checkpoint_manager=mock_checkpoint_manager, ) + @patch("nemo_rl.models.automodel.setup.torch.distributed.get_rank") + @patch("nemo_rl.models.automodel.setup.get_class") + def test_setup_model_with_cp_raises_for_gemma4_unified( + self, + mock_get_class, + mock_get_rank, + mock_config, + mock_runtime_config, + mock_checkpoint_manager, + mock_tokenizer, + ): + """Test that Gemma 4 unified checkpoints reject context parallel.""" + mock_get_rank.return_value = 0 + mock_fsdp2_config = MagicMock() + mock_fsdp2_config.sequence_parallel = False + distributed_context = DistributedContext( + device_mesh=MagicMock(), + moe_mesh=MagicMock(), + fsdp2_config=mock_fsdp2_config, + moe_config=MagicMock(), + dp_size=1, + tp_size=1, + cp_size=2, + ) + + mock_runtime_config.model_config.model_type = "gemma4_unified" + mock_runtime_config.model_config.architectures = [ + "Gemma4UnifiedForConditionalGeneration" + ] + + with pytest.raises( + AssertionError, + match="Context parallel is not supported for the Gemma 4 unified checkpoint", + ): + setup_model_and_optimizer( + config=mock_config, + tokenizer=mock_tokenizer, + runtime_config=mock_runtime_config, + distributed_context=distributed_context, + checkpoint_manager=mock_checkpoint_manager, + ) + @patch("nemo_rl.models.automodel.setup.torch.optim.lr_scheduler.LambdaLR") @patch("nemo_rl.models.automodel.setup.torch.distributed.get_rank") @patch("nemo_rl.models.automodel.setup.get_class") diff --git a/tests/unit/models/generation/test_vllm_backend.py b/tests/unit/models/generation/test_vllm_backend.py index f7a7230f3e9..056762d81f3 100644 --- a/tests/unit/models/generation/test_vllm_backend.py +++ b/tests/unit/models/generation/test_vllm_backend.py @@ -1501,6 +1501,166 @@ def test_load_weights_routes_only_policy_weights_to_mtp_drafter(monkeypatch): ext._maybe_refit_mtp_drafter.assert_called_once_with(policy_weights) +@pytest.mark.vllm +@pytest.mark.parametrize( + ("architecture", "language_model_only", "expected_keys"), + [ + ( + "Gemma4UnifiedForConditionalGeneration", + True, + ["model.language_model.layers.0.self_attn.q_proj.weight"], + ), + ( + "Gemma4UnifiedForConditionalGeneration", + False, + [ + "model.language_model.layers.0.self_attn.q_proj.weight", + "model.embed_vision.pos_embedding", + "model.embed_audio.embedding_projection.weight", + ], + ), + ( + "Gemma4ForConditionalGeneration", + True, + [ + "model.language_model.layers.0.self_attn.q_proj.weight", + "model.embed_vision.pos_embedding", + "model.embed_audio.embedding_projection.weight", + ], + ), + ], +) +def test_gemma4_refit_drops_multimodal_weights_only_for_unified_text_generation( + monkeypatch, architecture, language_model_only, expected_keys +): + from nemo_rl.models.generation.vllm.quantization import fp8 + from nemo_rl.models.generation.vllm.vllm_backend import ( + VllmInternalWorkerExtension, + ) + + loaded = [] + ext = VllmInternalWorkerExtension.__new__(VllmInternalWorkerExtension) + ext.model_runner = SimpleNamespace( + model=SimpleNamespace(load_weights=lambda *, weights: loaded.extend(weights)), + vllm_config=SimpleNamespace( + model_config=SimpleNamespace( + architectures=[architecture], + multimodal_config=SimpleNamespace( + language_model_only=language_model_only + ), + ) + ), + ) + ext._load_draft_weights = MagicMock() + ext._maybe_refit_mtp_drafter = MagicMock() + monkeypatch.setattr(fp8, "is_fp8_model", lambda _: False) + weights = [ + ("model.language_model.layers.0.self_attn.q_proj.weight", "language"), + ("model.embed_vision.pos_embedding", "vision"), + ("model.embed_audio.embedding_projection.weight", "audio"), + ] + + ext._load_weights(weights) + + assert [key for key, _ in loaded] == expected_keys + + +@pytest.mark.vllm +@pytest.mark.parametrize( + ("architecture", "language_model_only", "expected_keys"), + [ + ( + "Gemma4UnifiedForConditionalGeneration", + True, + ["model.language_model.layers.0.self_attn.q_proj.weight"], + ), + ( + "Gemma4UnifiedForConditionalGeneration", + False, + [ + "model.language_model.layers.0.self_attn.q_proj.weight", + "model.embed_vision.pos_embedding", + "model.embed_audio.embedding_projection.weight", + ], + ), + ( + "Gemma4ForConditionalGeneration", + True, + [ + "model.language_model.layers.0.self_attn.q_proj.weight", + "model.embed_vision.pos_embedding", + "model.embed_audio.embedding_projection.weight", + ], + ), + ], +) +def test_prepare_reload_weight_iterator_filters_only_unified_text_generation( + monkeypatch, architecture, language_model_only, expected_keys +): + from nemo_rl.models.generation.vllm.quantization import fp8 + from nemo_rl.models.generation.vllm.vllm_backend import ( + VllmInternalWorkerExtension, + ) + + ext = VllmInternalWorkerExtension.__new__(VllmInternalWorkerExtension) + ext.model_runner = SimpleNamespace( + vllm_config=SimpleNamespace( + model_config=SimpleNamespace( + architectures=[architecture], + multimodal_config=SimpleNamespace( + language_model_only=language_model_only + ), + ) + ) + ) + monkeypatch.setattr(fp8, "is_fp8_model", lambda _: False) + weights = iter( + [ + ("model.language_model.layers.0.self_attn.q_proj.weight", "language"), + ("model.embed_vision.pos_embedding", "vision"), + ("model.embed_audio.embedding_projection.weight", "audio"), + ] + ) + + result = list(ext._prepare_reload_weight_iterator(weights)) + + assert [key for key, _ in result] == expected_keys + + +@pytest.mark.vllm +def test_gemma4_unified_refit_logs_dropped_weights_once(monkeypatch): + from nemo_rl.models.generation.vllm import vllm_backend + + ext = vllm_backend.VllmInternalWorkerExtension.__new__( + vllm_backend.VllmInternalWorkerExtension + ) + ext.model_runner = SimpleNamespace( + vllm_config=SimpleNamespace( + model_config=SimpleNamespace( + architectures=["Gemma4UnifiedForConditionalGeneration"], + multimodal_config=SimpleNamespace(language_model_only=True), + ) + ) + ) + ext._load_hf_weights = MagicMock() + ext._load_draft_weights = MagicMock() + ext._maybe_refit_mtp_drafter = MagicMock() + log_info = MagicMock() + monkeypatch.setattr(vllm_backend.logger, "info", log_info) + weights = [ + ("model.language_model.layers.0.self_attn.q_proj.weight", "language"), + ("model.embed_vision.pos_embedding", "vision"), + ("model.embed_audio.embedding_projection.weight", "audio"), + ] + + ext._load_weights(weights) + ext._load_weights(weights) + + log_info.assert_called_once_with( + "Gemma4 Unified text-only refit dropped %d frozen vision/audio weights", 2 + ) + + @pytest.mark.vllm @pytest.mark.parametrize( "method, from_disk, has_drafter, expected", diff --git a/tests/unit/models/generation/test_vllm_worker_helpers.py b/tests/unit/models/generation/test_vllm_worker_helpers.py index 29d61934447..23130494677 100644 --- a/tests/unit/models/generation/test_vllm_worker_helpers.py +++ b/tests/unit/models/generation/test_vllm_worker_helpers.py @@ -17,11 +17,39 @@ import pytest from nemo_rl.models.generation.vllm.worker_utils import ( + find_tokenizer_required_architectures, resolve_data_parallel_local_rank, resolve_distributed_executor_backend, ) +@pytest.mark.parametrize( + ("architectures", "expected"), + [ + (None, []), + ([], []), + (["Gemma4ForCausalLM"], []), + ( + ["Gemma4ForConditionalGeneration"], + ["Gemma4ForConditionalGeneration"], + ), + ( + [ + "Gemma4ForCausalLM", + "Gemma4UnifiedForConditionalGeneration", + "Mistral3ForConditionalGeneration", + ], + [ + "Gemma4UnifiedForConditionalGeneration", + "Mistral3ForConditionalGeneration", + ], + ), + ], +) +def test_find_tokenizer_required_architectures(architectures, expected): + assert find_tokenizer_required_architectures(architectures) == expected + + @pytest.mark.parametrize( ("tp", "pp", "ep", "expected"), [ diff --git a/tests/unit/models/policy/test_utils.py b/tests/unit/models/policy/test_utils.py index b30cf653f4d..b5a59cbd7ec 100644 --- a/tests/unit/models/policy/test_utils.py +++ b/tests/unit/models/policy/test_utils.py @@ -100,6 +100,10 @@ def test_resolve_model_class_selects_requested_loader( assert resolve_model_class(model_type) is expected_default +def test_resolve_model_class_routes_gemma4_unified_to_image_text_model(): + assert "ImageTextToText" in resolve_model_class("gemma4_unified").__name__ + + class TestGetMegatronCheckpointDir: """Test cases for the get_megatron_checkpoint_dir function.""" diff --git a/tests/unit/test_recipes_and_test_suites.py b/tests/unit/test_recipes_and_test_suites.py index a6d4d4032ef..84d0a6707b8 100644 --- a/tests/unit/test_recipes_and_test_suites.py +++ b/tests/unit/test_recipes_and_test_suites.py @@ -257,7 +257,7 @@ def test_all_recipe_yamls_accounted_for_in_test_suites( ) -def test_nightly_compute_stays_below_4280_hours(nightly_test_suite, tracker): +def test_nightly_compute_stays_below_4344_hours(nightly_test_suite, tracker): command = f"DRYRUN=1 HF_HOME=... HF_DATASETS_CACHE=... CONTAINER= ACCOUNT= PARTITION= ./tools/launch {' '.join(nightly_test_suite)}" print(f"Running command: {command}") @@ -289,8 +289,8 @@ def test_nightly_compute_stays_below_4280_hours(nightly_test_suite, tracker): f"Last line of output was not as expected: '{last_line}'" ) total_gpu_hours = float(last_line.split(":")[-1].strip()) - assert total_gpu_hours <= 4280, ( - f"Total GPU hours exceeded 4280: {last_line}. We should revisit the test suites to reduce the total GPU hours." + assert total_gpu_hours <= 4344, ( + f"Total GPU hours exceeded 4344: {last_line}. We should revisit the test suites to reduce the total GPU hours." ) tracker.track("total_nightly_gpu_hours", total_gpu_hours)