diff --git a/megatron/core/extensions/transformer_engine.py b/megatron/core/extensions/transformer_engine.py index 808ac14a2e4..85732c0f7ea 100644 --- a/megatron/core/extensions/transformer_engine.py +++ b/megatron/core/extensions/transformer_engine.py @@ -1021,6 +1021,14 @@ def __init__( self.kept_packed_seq_params.discard("cu_seqlens_q_padded") self.kept_packed_seq_params.discard("cu_seqlens_kv_padded") + if config.qk_clip or config.log_max_attention_logit: + # qk-clip is only supported in TE 2.9.0 and later + assert is_te_min_version("2.9.0"), "qk-clip is only supported in TE 2.9.0 and later" + + # TE 2.9.0 introduces return_max_logit for qk-clip getting the max attention logits + extra_kwargs["return_max_logit"] = True + self.current_max_attn_logits = None + super().__init__( num_attention_heads=self.config.num_attention_heads, kv_channels=kv_channels, @@ -1090,6 +1098,22 @@ def forward( **attention_bias_kwargs, **packed_seq_kwargs, ) + + if self.config.qk_clip or self.config.log_max_attention_logit: + # qk-clip is only supported in TE 2.9.0 and later + assert is_te_min_version("2.9.0"), "qk-clip is only supported in TE 2.9.0 and later" + + # Update Q K outside of TE Attention API + core_attn_out, batch_max_attention_logits = core_attn_out + + # Update QK_Clip balancing eta + if self.current_max_attn_logits is None: + self.current_max_attn_logits = batch_max_attention_logits + else: + self.current_max_attn_logits = torch.max( + self.current_max_attn_logits, batch_max_attention_logits + ) + else: core_attn_out = super().forward( query, key, value, attention_mask, **attention_bias_kwargs, **packed_seq_kwargs diff --git a/megatron/core/optimizer/qk_clip.py b/megatron/core/optimizer/qk_clip.py new file mode 100644 index 00000000000..f5b34a8216b --- /dev/null +++ b/megatron/core/optimizer/qk_clip.py @@ -0,0 +1,39 @@ +# Copyright (c) 2024, NVIDIA CORPORATION. All rights reserved. + +import torch + +from megatron.core import mpu + + +def clip_qk(model, log_max_only=False) -> float: + """ + Clip the QK attention logits to the threshold, recommended for Muon optimizer. + + Args: + model: The model to clip the QK attention logits, a list of model chunks. + log_only: Whether to only log the max attention logit, without updating the weights. + + Returns: + The maximum attention logit, a float. + """ + + with torch.no_grad(): + log_max_attention_logit = 0 + for model_chunk in model: + for transformer_layer in model_chunk.module.module.decoder.layers: + if hasattr(transformer_layer.self_attention, 'clip_qk'): + torch.distributed.all_reduce( + transformer_layer.self_attention.core_attention.current_max_attn_logits, + op=torch.distributed.ReduceOp.MAX, + group=mpu.get_data_parallel_group(with_context_parallel=True), + ) + log_max_attention_logit = max( + log_max_attention_logit, + torch.max( + transformer_layer.self_attention.core_attention.current_max_attn_logits + ).item(), + ) + if not log_max_only: + transformer_layer.self_attention.clip_qk() + + return log_max_attention_logit diff --git a/megatron/core/transformer/attention.py b/megatron/core/transformer/attention.py index be52fe10d20..606befa1066 100644 --- a/megatron/core/transformer/attention.py +++ b/megatron/core/transformer/attention.py @@ -1004,6 +1004,13 @@ def set_for_recompute_input_layernorm(self): """Set the attention layer for recompute input_layernorm. Only needed for fp8.""" raise NotImplementedError("set_for_recompute_input_layernorm is not implemented.") + def clip_qk(self): + """ + QK Clipping is a technique to clip the query and key attention logits to prevent the + attention logits from exploding. + """ + raise NotImplementedError("clip_qk is not implemented.") + class SelfAttention(Attention): """Self-attention layer class @@ -1239,6 +1246,103 @@ def set_for_recompute_input_layernorm(self): set_save_original_input(self.linear_qkv) + def clip_qk(self): + """ + QK Clipping is a technique to clip the query and key attention logits to prevent the + attention logits from exploding. This function is experimental on GQA. + """ + if not self.config.qk_clip: + raise ValueError("qk_clip option needs to be enabled") + + if self.core_attention.current_max_attn_logits is None: + raise ValueError("current_max_attn_logits is None") + + assert self.core_attention.current_max_attn_logits.shape == ( + self.num_attention_heads_per_partition, + ), f"current_max_attn_logits shape is not ({self.num_attention_heads_per_partition}, ) \ + but {self.core_attention.current_max_attn_logits.shape}" + + grouped_max_attn_logits = torch.max( + self.core_attention.current_max_attn_logits.view( + self.num_query_groups_per_partition, -1 + ), + dim=1, + ).values + + # only update the weight if any head has + # current_max_attn_logits > qk_clip_threshold + if torch.any(grouped_max_attn_logits > self.config.qk_clip_threshold): + # Use num_query_groups_per_partition for tensor parallel scenarios + + # qk_clip_balancing_eta (g, 1, 1) + assert grouped_max_attn_logits.shape == ( + self.num_query_groups_per_partition, + ), f"current_max_attn_logits shape is not ({self.num_query_groups_per_partition},) \ + but {grouped_max_attn_logits.shape}" + self.qk_clip_balancing_eta = torch.clamp( + self.config.qk_clip_threshold / grouped_max_attn_logits, max=1.0 + ).view(self.num_query_groups_per_partition, 1, 1) + assert torch.all(self.qk_clip_balancing_eta <= 1.0) + + # Handle different weight access patterns (main_param vs direct access) + if hasattr(self.linear_qkv.weight, 'main_param'): + self.linear_qkv.weight.main_param.data.copy_( + self._clip_linear_qkv(self.linear_qkv.weight.main_param.data) + ) + + self.linear_qkv.weight.data.copy_(self._clip_linear_qkv(self.linear_qkv.weight.data)) + + # reset current_max_attn_logits + self.core_attention.current_max_attn_logits = None + + def _clip_linear_qkv(self, weight): + """Apply qkclip to linear_qkv layer""" + # Reshape to (g, query_projection_size + 2 * kv_projection_size, -1) + weight_reshaped = weight.view( + self.num_query_groups_per_partition, + (self.query_projection_size + 2 * self.kv_projection_size) + // self.num_query_groups_per_partition, + -1, + ) + + # Split into query_projection_size and 2 * kv_projection_size parts: + # (n, a, -1) and (n, b, -1) + weight_q = weight_reshaped[ + :, : self.query_projection_size // self.num_query_groups_per_partition, : + ] + weight_k = weight_reshaped[ + :, + self.query_projection_size + // self.num_query_groups_per_partition : ( + self.query_projection_size + self.kv_projection_size + ) + // self.num_query_groups_per_partition, + :, + ] + weight_v = weight_reshaped[ + :, + (self.query_projection_size + self.kv_projection_size) + // self.num_query_groups_per_partition :, + :, + ] + + # extend the qk_clip_balancing_eta to the same shape as weight_q and weight_k + self.qk_clip_balancing_eta_extended = self.qk_clip_balancing_eta.repeat( + 1, weight_q.size(1), 1 + ) + + # Clipping + weight_q.mul_(torch.pow(self.qk_clip_balancing_eta_extended, self.config.qk_clip_alpha)) + weight_k.mul_(torch.pow(self.qk_clip_balancing_eta, 1 - self.config.qk_clip_alpha)) + + # Concatenate back and reshape to original shape + weight_updated = torch.cat([weight_q, weight_k, weight_v], dim=1) + weight_updated = weight_updated.view( + self.query_projection_size + 2 * self.kv_projection_size, -1 + ) + + return weight_updated + class CrossAttention(Attention): """Cross-attention layer class diff --git a/megatron/core/transformer/multi_latent_attention.py b/megatron/core/transformer/multi_latent_attention.py index 5d3f16c1041..46e09daa873 100644 --- a/megatron/core/transformer/multi_latent_attention.py +++ b/megatron/core/transformer/multi_latent_attention.py @@ -937,3 +937,123 @@ def set_for_recompute_input_layernorm(self): if self.config.q_lora_rank is not None: set_save_original_input(self.linear_q_down_proj) set_save_original_input(self.linear_kv_down_proj) + + def clip_qk(self): + """ + QK Clipping is a technique to clip the query and key attention logits to prevent the + attention logits from exploding. Per MuonClip usage, we update the weight by calling this + function after Muon optimizer step. + """ + + if not self.config.qk_clip: + raise ValueError("qk_clip option needs to be enabled") + + if self.core_attention.current_max_attn_logits is None: + raise ValueError("current_max_attn_logits is None") + + # Check if we're in absorption mode + if self.cache_mla_latents and not hasattr(self, 'linear_kv_up_proj'): + raise ValueError( + "qk_clip is not supported when cache_mla_latents is enabled and absorption is " + "active. The linear_kv_up_proj layer has been deleted during absorption " + "preparation." + ) + + assert self.core_attention.current_max_attn_logits.shape == ( + self.num_attention_heads_per_partition, + ), f"current_max_attn_logits shape is not ({self.num_attention_heads_per_partition}, ) \ + but {self.core_attention.current_max_attn_logits.shape}" + + # only update the weight if any head has + # current_max_attn_logits > qk_clip_threshold + if torch.any(self.core_attention.current_max_attn_logits > self.config.qk_clip_threshold): + # Use num_attention_heads_per_partition for tensor parallel scenarios + + # qk_clip_balancing_eta (n, 1, 1) + assert self.core_attention.current_max_attn_logits.shape == ( + self.num_attention_heads_per_partition, + ), f"current_max_attn_logits shape is not ({self.num_attention_heads_per_partition},) \ + but {self.core_attention.current_max_attn_logits.shape}" + self.qk_clip_balancing_eta = torch.clamp( + self.config.qk_clip_threshold / self.core_attention.current_max_attn_logits, max=1.0 + ).view(self.num_attention_heads_per_partition, 1, 1) + assert torch.all(self.qk_clip_balancing_eta <= 1.0) + + # Update q side weight, keep qk_pos_emb_head_dim side weight unchanged + if self.config.q_lora_rank is None: + q_proj_weight = self.linear_q_proj.weight + else: + q_proj_weight = self.linear_q_up_proj.weight + + # Handle different weight access patterns (main_param vs direct access) + if hasattr(q_proj_weight, 'main_param'): + q_proj_weight.main_param.data.copy_( + self._clip_q_proj_weight(q_proj_weight.main_param.data) + ) + q_proj_weight.data.copy_(self._clip_q_proj_weight(q_proj_weight.data)) + + # Update k side weight, keep v side weight unchanged + kv_proj_weight = self.linear_kv_up_proj.weight + + # Handle different weight access patterns + if hasattr(kv_proj_weight, 'main_param'): + kv_proj_weight.main_param.data.copy_( + self._clip_kv_proj_weight(kv_proj_weight.main_param.data) + ) + kv_proj_weight.data.copy_(self._clip_kv_proj_weight(kv_proj_weight.data)) + + # reset current_max_attn_logits + self.core_attention.current_max_attn_logits = None + + def _clip_q_proj_weight(self, weight): + """Clip q_proj_weight""" + # Reshape to (n, a + b, -1) + weight_reshaped = weight.view( + self.num_attention_heads_per_partition, + self.config.qk_head_dim + self.config.qk_pos_emb_head_dim, + -1, + ) + + # Split into qk_head_dim and qk_pos_emb_head_dim parts: (n, a, -1) and (n, b, -1) + weight_q_nope = weight_reshaped[:, : self.config.qk_head_dim, :] + weight_q_pe = weight_reshaped[:, self.config.qk_head_dim :, :] + + # Clipping + weight_q_nope.mul_(torch.pow(self.qk_clip_balancing_eta, self.config.qk_clip_alpha)) + weight_q_pe.mul_(self.qk_clip_balancing_eta) + + # Concatenate back and reshape to original shape + weight_q_updated = torch.cat([weight_q_nope, weight_q_pe], dim=1) + weight_q_updated = weight_q_updated.view( + self.num_attention_heads_per_partition + * (self.config.qk_head_dim + self.config.qk_pos_emb_head_dim), + -1, + ) + + return weight_q_updated + + def _clip_kv_proj_weight(self, weight): + """Clip kv_proj_weight""" + # shape: (n, qk_head_dim + v_head_dim, kv_lora_rank) + weight_reshaped = weight.view( + self.num_attention_heads_per_partition, + self.config.qk_head_dim + self.config.v_head_dim, + -1, + ) + + # Split into qk_head_dim and v_head_dim parts: (n, a, -1) and (n, b, -1) + weight_k = weight_reshaped[:, : self.config.qk_head_dim, :] + weight_v = weight_reshaped[:, self.config.qk_head_dim :, :] + + # Clipping + weight_k.mul_(torch.pow(self.qk_clip_balancing_eta, 1 - self.config.qk_clip_alpha)) + + # Concatenate back and reshape to original shape + weight_kv_updated = torch.cat([weight_k, weight_v], dim=1) + weight_kv_updated = weight_kv_updated.view( + self.num_attention_heads_per_partition + * (self.config.qk_head_dim + self.config.v_head_dim), + -1, + ) + + return weight_kv_updated diff --git a/megatron/core/transformer/transformer_config.py b/megatron/core/transformer/transformer_config.py index aab137b6430..83a38cb3b95 100644 --- a/megatron/core/transformer/transformer_config.py +++ b/megatron/core/transformer/transformer_config.py @@ -192,6 +192,19 @@ class TransformerConfig(ModelParallelConfig): qk_layernorm: bool = False """Whether to apply `normalization` type of normalization to the query and key embeddings.""" + qk_clip: bool = False + """Whether to clip the query and key weights. Needed for Muon MLA Model training.""" + + qk_clip_alpha: float = 0.5 + """The balancing alpha for qk-clip. Q = Q * (eta ** alpha)""" + + qk_clip_threshold: float = 100 + """The balancing threshold for qk-clip. eta = min(threshold / max_attention_logits, 1.0)""" + + log_max_attention_logit: bool = False + """Whether to log the max attention logit across whole model. Decoupled from qk_clip, + defualts to False. Setting qk_clip will automatically log the max logit""" + attention_output_gate: bool = False """Whether to apply output gate to the attention layers.""" diff --git a/megatron/training/arguments.py b/megatron/training/arguments.py index 507c21e6883..a06aed8455a 100644 --- a/megatron/training/arguments.py +++ b/megatron/training/arguments.py @@ -1003,6 +1003,19 @@ def validate_args(args, defaults={}): if args.add_bias_linear: args.add_qkv_bias = True + if args.qk_clip: + assert is_te_min_version("2.9.0"), \ + '--qk-clip is only supported with TE >= 2.9.0.' + assert 0.0 < args.qk_clip_alpha < 1.0, \ + '--qk-clip-alpha must be between 0.0 and 1.0 when using --qk-clip.' + assert args.qk_clip_threshold > 0, \ + '--qk-clip-threshold must be greater than 0 when using --qk-clip.' + + # decoupled log max attention logit check + if args.log_max_attention_logit: + assert is_te_min_version("2.9.0"), \ + '--log-max-attention-logit is only supported with TE >= 2.9.0.' + # Retro checks. if args.retro_add_retriever: @@ -1245,6 +1258,9 @@ def validate_args(args, defaults={}): assert ( args.recompute_granularity != 'full' ), 'recompute_granularity must not be full when CUDA Graphs are enabled.' + + if args.multi_latent_attention: + assert not args.group_query_attention, "Group query attention is mutually exclusive with multi latent attention." # Print arguments. _print_args("arguments", args) @@ -1911,6 +1927,8 @@ def _add_logging_args(parser): group.add_argument('--log-world-size-to-tensorboard', action='store_true', help='Enable world size logging to tensorboard.') + group.add_argument('--log-max-attention-logit', action='store_true', + help='Enable max attention logit logging to tensorboard.') group.add_argument('--wandb-project', type=str, default='', help='The wandb project name. Ignore wandb by default.') group.add_argument('--wandb-entity', type=str, default='', @@ -2280,6 +2298,12 @@ def _add_training_args(parser): group.add_argument('--add-qkv-bias', action='store_true', help='Enable bias only in the QKV linear layers', dest='add_qkv_bias') + group.add_argument('--qk-clip', action='store_true', + help='Whether to use qk-clip for training stabilization, strongly recommended for Muon.') + group.add_argument('--qk-clip-alpha', type=float, default=0.5, + help='The balancing alpha for qk-clip.') + group.add_argument('--qk-clip-threshold', type=float, default=100, + help='The balancing threshold for qk-clip.') group.add_argument('--optimizer', type=str, default='adam', choices=['adam', 'sgd', 'muon', 'dist_muon'], help='Optimizer function') diff --git a/megatron/training/training.py b/megatron/training/training.py index 06dad540fed..83142b95891 100644 --- a/megatron/training/training.py +++ b/megatron/training/training.py @@ -64,6 +64,7 @@ from megatron.core.distributed import DistributedDataParallel as DDP from megatron.core.distributed.fsdp.mcore_fsdp_adapter import FullyShardedDataParallel as megatron_FSDP from megatron.core.optimizer.optimizer import param_group_identifier_keys +from megatron.core.optimizer.qk_clip import clip_qk try: from megatron.core.distributed import TorchFullyShardedDataParallel as torch_FSDP @@ -1390,7 +1391,7 @@ def train_step(forward_step_func, data_iterator, model, optimizer, opt_param_sch ) should_checkpoint, should_exit, exit_code = rerun_state_machine.should_checkpoint_and_exit() if should_exit: - return {}, True, should_checkpoint, should_exit, exit_code, None, None + return {}, True, should_checkpoint, should_exit, exit_code, None, None, 0 # Empty unused memory. if args.empty_unused_memory_level >= 1: @@ -1405,6 +1406,13 @@ def train_step(forward_step_func, data_iterator, model, optimizer, opt_param_sch timers('optimizer', log_level=1).start(barrier=args.barrier_with_L1_time) update_successful, grad_norm, num_zeros_in_grad = optimizer.step() + + # get max attention logit for logging and run clip_qk() + # Part of MuonClip Optimizer step + log_max_attention_logit = 0 + if args.qk_clip or args.log_max_attention_logit: + log_max_attention_logit = clip_qk(model, log_max_only=not args.qk_clip) + timers('optimizer').stop() # when freezing sub-models we may have a mixture of successful and unsucessful ranks, @@ -1476,8 +1484,9 @@ def train_step(forward_step_func, data_iterator, model, optimizer, opt_param_sch exit_code, grad_norm, num_zeros_in_grad, + log_max_attention_logit, ) - return {}, skipped_iter, should_checkpoint, should_exit, exit_code, grad_norm, num_zeros_in_grad + return {}, skipped_iter, should_checkpoint, should_exit, exit_code, grad_norm, num_zeros_in_grad, log_max_attention_logit def training_log( @@ -1492,6 +1501,7 @@ def training_log( grad_norm, params_norm, num_zeros_in_grad, + max_attention_logit, ): """Log training information such as losses, timing, ....""" args = get_args() @@ -1654,6 +1664,10 @@ def training_log( "mem-max-allocated-bytes", mem_stats["allocated_bytes.all.peak"], iteration ) writer.add_scalar("mem-allocated-count", mem_stats["allocation.all.current"], iteration) + if args.log_max_attention_logit: + writer.add_scalar('max_attention_logit', max_attention_logit, iteration) + if wandb_writer: + wandb_writer.log({'max_attention_logit': max_attention_logit}, iteration) if args.num_experts is not None: moe_loss_scale = 1 / get_num_microbatches() track_names = [] @@ -2421,6 +2435,7 @@ def get_e2e_base_metrics(): exit_code, grad_norm, num_zeros_in_grad, + max_attention_logit, ) = train_step( forward_step_func, train_data_iterator, model, optimizer, opt_param_scheduler, config, forward_backward_func ) @@ -2525,6 +2540,7 @@ def get_e2e_base_metrics(): grad_norm, params_norm, num_zeros_in_grad, + max_attention_logit, ) # Evaluation. diff --git a/tests/unit_tests/transformer/test_attention.py b/tests/unit_tests/transformer/test_attention.py index 23858937c72..d7771d0920d 100644 --- a/tests/unit_tests/transformer/test_attention.py +++ b/tests/unit_tests/transformer/test_attention.py @@ -165,6 +165,192 @@ def test_checkpointed_gpu_forward(self): assert bias.shape[0] == config.hidden_size +@pytest.mark.skipif(not is_te_min_version("2.9.0"), reason="QK clipping requires TE >= 2.9.0") +class TestClipQK: + + def setup_method(self, method): + Utils.initialize_model_parallel(1, 1) + model_parallel_cuda_manual_seed(123) + + def teardown_method(self, method): + Utils.destroy_model_parallel() + + def test_clip_qk_disabled_raises_error(self): + """Test that clip_qk raises ValueError when qk_clip is not enabled.""" + transformer_config = TransformerConfig( + num_layers=2, + hidden_size=128, + num_attention_heads=4, + use_cpu_initialization=True, + qk_clip=False, + ) + attention = SelfAttention( + transformer_config, + get_gpt_layer_with_transformer_engine_spec().submodules.self_attention.submodules, + layer_number=1, + ) + + with pytest.raises(ValueError, match="qk_clip option needs to be enabled"): + attention.clip_qk() + + def test_clip_qk_none_logits_raises_error(self): + """Test that clip_qk raises ValueError when current_max_attn_logits is None.""" + transformer_config = TransformerConfig( + num_layers=2, + hidden_size=128, + num_attention_heads=4, + use_cpu_initialization=True, + qk_clip=True, + qk_clip_threshold=100.0, + qk_clip_alpha=0.5, + ) + attention = SelfAttention( + transformer_config, + get_gpt_layer_with_transformer_engine_spec().submodules.self_attention.submodules, + layer_number=1, + ) + + with pytest.raises(ValueError, match="current_max_attn_logits is None"): + attention.clip_qk() + + def test_clip_qk_below_threshold_no_update(self): + """Test that weights are not updated when max logits are below threshold.""" + transformer_config = TransformerConfig( + num_layers=2, + hidden_size=128, + num_attention_heads=4, + use_cpu_initialization=True, + qk_clip=True, + qk_clip_threshold=100.0, + qk_clip_alpha=0.5, + ) + attention = SelfAttention( + transformer_config, + get_gpt_layer_with_transformer_engine_spec().submodules.self_attention.submodules, + layer_number=1, + ) + attention.cuda() + + # Save original weights + original_weight = attention.linear_qkv.weight.data.clone() + + # Set current_max_attn_logits below threshold + attention.core_attention.current_max_attn_logits = torch.tensor( + [50.0, 60.0, 70.0, 80.0], device='cuda' + ) + + # Call clip_qk + attention.clip_qk() + + # Weights should not be updated + assert torch.equal(attention.linear_qkv.weight.data, original_weight) + # current_max_attn_logits should be reset + assert attention.core_attention.current_max_attn_logits is None + + def test_clip_qk_above_threshold_updates_weights(self): + """Test that weights are updated when max logits exceed threshold.""" + transformer_config = TransformerConfig( + num_layers=2, + hidden_size=128, + num_attention_heads=4, + use_cpu_initialization=True, + qk_clip=True, + qk_clip_threshold=100.0, + qk_clip_alpha=0.5, + ) + attention = SelfAttention( + transformer_config, + get_gpt_layer_with_transformer_engine_spec().submodules.self_attention.submodules, + layer_number=1, + ) + attention.cuda() + + # Save original weights + original_weight = attention.linear_qkv.weight.data.clone() + + # Set current_max_attn_logits above threshold + attention.core_attention.current_max_attn_logits = torch.tensor( + [150.0, 160.0, 170.0, 180.0], device='cuda' + ) + + # Call clip_qk + attention.clip_qk() + + # Weights should be updated + assert not torch.equal(attention.linear_qkv.weight.data, original_weight) + # current_max_attn_logits should be reset + assert attention.core_attention.current_max_attn_logits is None + + def test_clip_qk_gqa_configuration(self): + """Test clip_qk with GQA (Grouped Query Attention) configuration.""" + transformer_config = TransformerConfig( + num_layers=2, + hidden_size=128, + num_attention_heads=8, + num_query_groups=4, # GQA with 2 heads per group + use_cpu_initialization=True, + qk_clip=True, + qk_clip_threshold=100.0, + qk_clip_alpha=0.5, + ) + attention = SelfAttention( + transformer_config, + get_gpt_layer_with_transformer_engine_spec().submodules.self_attention.submodules, + layer_number=1, + ) + attention.cuda() + + # Save original weights + original_weight = attention.linear_qkv.weight.data.clone() + + # Set current_max_attn_logits for all heads (8 heads) + attention.core_attention.current_max_attn_logits = torch.tensor( + [150.0, 160.0, 170.0, 180.0, 190.0, 200.0, 210.0, 220.0], device='cuda' + ) + + # Call clip_qk + attention.clip_qk() + + # Weights should be updated + assert not torch.equal(attention.linear_qkv.weight.data, original_weight) + # current_max_attn_logits should be reset + assert attention.core_attention.current_max_attn_logits is None + + def test_clip_qk_mixed_logits(self): + """Test clip_qk with mixed logits (some above, some below threshold).""" + transformer_config = TransformerConfig( + num_layers=2, + hidden_size=128, + num_attention_heads=4, + use_cpu_initialization=True, + qk_clip=True, + qk_clip_threshold=100.0, + qk_clip_alpha=0.5, + ) + attention = SelfAttention( + transformer_config, + get_gpt_layer_with_transformer_engine_spec().submodules.self_attention.submodules, + layer_number=1, + ) + attention.cuda() + + # Save original weights + original_weight = attention.linear_qkv.weight.data.clone() + + # Set mixed current_max_attn_logits (some above, some below threshold) + attention.core_attention.current_max_attn_logits = torch.tensor( + [80.0, 150.0, 90.0, 200.0], device='cuda' + ) + + # Call clip_qk + attention.clip_qk() + + # Weights should be updated since at least one head exceeds threshold + assert not torch.equal(attention.linear_qkv.weight.data, original_weight) + # current_max_attn_logits should be reset + assert attention.core_attention.current_max_attn_logits is None + + @pytest.mark.parametrize("output_gate", [False, True]) class TestSelfAttention: diff --git a/tests/unit_tests/transformer/test_multi_latent_attention.py b/tests/unit_tests/transformer/test_multi_latent_attention.py index 8ade4b6bcb8..ad156dfda13 100644 --- a/tests/unit_tests/transformer/test_multi_latent_attention.py +++ b/tests/unit_tests/transformer/test_multi_latent_attention.py @@ -1034,6 +1034,234 @@ def test_gpu_forward_thd_precision(self): os.environ.update(_environ) +@pytest.mark.skipif(not is_te_min_version("2.9.0"), reason="QK clipping requires TE >= 2.9.0") +@pytest.mark.parametrize("rope_type", ('yarn', 'rope')) +class TestMLAClipQK: + + @pytest.fixture(scope='function', autouse=True) + def setup_and_teardown(self, rope_type): + Utils.initialize_model_parallel(1, 1) + model_parallel_cuda_manual_seed(123) + self.transformer_config = MLATransformerConfig( + num_layers=2, + hidden_size=12, + num_attention_heads=4, + use_cpu_initialization=True, + q_lora_rank=32, + kv_lora_rank=32, + qk_head_dim=128, + v_head_dim=128, + qk_pos_emb_head_dim=64, + rope_type=rope_type, + rotary_base=10000, + original_max_position_embeddings=32, + qk_clip=True, + qk_clip_threshold=100.0, + qk_clip_alpha=0.5, + ) + + def teardown_method(self, method): + Utils.destroy_model_parallel() + + def test_clip_qk_disabled_raises_error(self): + """Test that clip_qk raises ValueError when qk_clip is not enabled.""" + if is_te_min_version("1.10.0"): + # Create config without qk_clip + config = MLATransformerConfig( + num_layers=2, + hidden_size=12, + num_attention_heads=4, + use_cpu_initialization=True, + q_lora_rank=32, + kv_lora_rank=32, + qk_head_dim=128, + v_head_dim=128, + qk_pos_emb_head_dim=64, + rotary_base=10000, + original_max_position_embeddings=32, + qk_clip=False, + ) + attention = MLASelfAttention( + config, + get_mla_self_attn_submodules(), + layer_number=1, + attn_mask_type=AttnMaskType.causal, + ) + + with pytest.raises(ValueError, match="qk_clip option needs to be enabled"): + attention.clip_qk() + + def test_clip_qk_none_logits_raises_error(self): + """Test that clip_qk raises ValueError when current_max_attn_logits is None.""" + if is_te_min_version("1.10.0"): + attention = MLASelfAttention( + self.transformer_config, + get_mla_self_attn_submodules(), + layer_number=1, + attn_mask_type=AttnMaskType.causal, + ) + + with pytest.raises(ValueError, match="current_max_attn_logits is None"): + attention.clip_qk() + + def test_clip_qk_below_threshold_no_update(self): + """Test that weights are not updated when max logits are below threshold.""" + if not is_te_min_version("1.10.0"): + pytest.skip("MLA requires TransformerEngine >= 1.10.0") + + attention = MLASelfAttention( + self.transformer_config, + get_mla_self_attn_submodules(), + layer_number=1, + attn_mask_type=AttnMaskType.causal, + ) + attention.cuda() + + # Save original weights + if self.transformer_config.q_lora_rank is None: + original_q_weight = attention.linear_q_proj.weight.data.clone() + else: + original_q_weight = attention.linear_q_up_proj.weight.data.clone() + original_kv_weight = attention.linear_kv_up_proj.weight.data.clone() + + # Set current_max_attn_logits below threshold + attention.core_attention.current_max_attn_logits = torch.tensor( + [50.0, 60.0, 70.0, 80.0], device='cuda' + ) + + # Call clip_qk + attention.clip_qk() + + # Weights should not be updated + if self.transformer_config.q_lora_rank is None: + assert torch.equal(attention.linear_q_proj.weight.data, original_q_weight) + else: + assert torch.equal(attention.linear_q_up_proj.weight.data, original_q_weight) + assert torch.equal(attention.linear_kv_up_proj.weight.data, original_kv_weight) + # current_max_attn_logits should be reset + assert attention.core_attention.current_max_attn_logits is None + + def test_clip_qk_above_threshold_updates_weights(self): + """Test that weights are updated when max logits exceed threshold.""" + if not is_te_min_version("1.10.0"): + pytest.skip("MLA requires TransformerEngine >= 1.10.0") + + attention = MLASelfAttention( + self.transformer_config, + get_mla_self_attn_submodules(), + layer_number=1, + attn_mask_type=AttnMaskType.causal, + ) + attention.cuda() + + # Save original weights + if self.transformer_config.q_lora_rank is None: + original_q_weight = attention.linear_q_proj.weight.data.clone() + else: + original_q_weight = attention.linear_q_up_proj.weight.data.clone() + original_kv_weight = attention.linear_kv_up_proj.weight.data.clone() + + # Set current_max_attn_logits above threshold + attention.core_attention.current_max_attn_logits = torch.tensor( + [150.0, 160.0, 170.0, 180.0], device='cuda' + ) + + # Call clip_qk + attention.clip_qk() + + # Weights should be updated + if self.transformer_config.q_lora_rank is None: + assert not torch.equal(attention.linear_q_proj.weight.data, original_q_weight) + else: + assert not torch.equal(attention.linear_q_up_proj.weight.data, original_q_weight) + assert not torch.equal(attention.linear_kv_up_proj.weight.data, original_kv_weight) + # current_max_attn_logits should be reset + assert attention.core_attention.current_max_attn_logits is None + + def test_clip_qk_mixed_logits(self): + """Test clip_qk with mixed logits (some above, some below threshold).""" + if not is_te_min_version("1.10.0"): + pytest.skip("MLA requires TransformerEngine >= 1.10.0") + + attention = MLASelfAttention( + self.transformer_config, + get_mla_self_attn_submodules(), + layer_number=1, + attn_mask_type=AttnMaskType.causal, + ) + attention.cuda() + + # Save original weights + if self.transformer_config.q_lora_rank is None: + original_q_weight = attention.linear_q_proj.weight.data.clone() + else: + original_q_weight = attention.linear_q_up_proj.weight.data.clone() + original_kv_weight = attention.linear_kv_up_proj.weight.data.clone() + + # Set mixed current_max_attn_logits (some above, some below threshold) + attention.core_attention.current_max_attn_logits = torch.tensor( + [80.0, 150.0, 90.0, 200.0], device='cuda' + ) + + # Call clip_qk + attention.clip_qk() + + # Weights should be updated since at least one head exceeds threshold + if self.transformer_config.q_lora_rank is None: + assert not torch.equal(attention.linear_q_proj.weight.data, original_q_weight) + else: + assert not torch.equal(attention.linear_q_up_proj.weight.data, original_q_weight) + assert not torch.equal(attention.linear_kv_up_proj.weight.data, original_kv_weight) + # current_max_attn_logits should be reset + assert attention.core_attention.current_max_attn_logits is None + + def test_clip_qk_with_absorption_raises_error(self): + """Test that clip_qk raises ValueError when in absorption mode.""" + if not is_te_min_version("1.10.0"): + pytest.skip("MLA requires TransformerEngine >= 1.10.0") + + # Create config with cache_mla_latents enabled + config = MLATransformerConfig( + num_layers=2, + hidden_size=12, + num_attention_heads=4, + use_cpu_initialization=True, + q_lora_rank=32, + kv_lora_rank=32, + qk_head_dim=128, + v_head_dim=128, + qk_pos_emb_head_dim=64, + rotary_base=10000, + original_max_position_embeddings=32, + qk_clip=True, + qk_clip_threshold=100.0, + qk_clip_alpha=0.5, + ) + attention = MLASelfAttention( + config, + get_mla_self_attn_submodules(), + layer_number=1, + attn_mask_type=AttnMaskType.causal, + ) + attention.cuda() + + # Simulate absorption mode by setting cache_mla_latents and deleting linear_kv_up_proj + attention.cache_mla_latents = True + if hasattr(attention, 'linear_kv_up_proj'): + delattr(attention, 'linear_kv_up_proj') + + # Set current_max_attn_logits + attention.core_attention.current_max_attn_logits = torch.tensor( + [150.0, 160.0, 170.0, 180.0], device='cuda' + ) + + with pytest.raises( + ValueError, + match="qk_clip is not supported when cache_mla_latents is enabled and absorption is active", + ): + attention.clip_qk() + + @pytest.mark.experimental @pytest.mark.parametrize( ("rope_type", "apply_rope_fusion"),