diff --git a/pyproject.toml b/pyproject.toml index a9d1197df..57b67da75 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -61,7 +61,7 @@ megatron = [ "nvidia-ml-py==13.580.82", "nvidia-modelopt>=0.42.0a0 ; sys_platform != 'darwin'", "nvidia-resiliency-ext<0.5 ; sys_platform == 'linux'", - "transformers==5.6.2", + "transformers==5.12.1", "ml-dtypes>=0.5.0 ; python_full_version < '3.13'", ] @@ -194,10 +194,8 @@ name = "deep-ep" version = "1.2.1+9af0e0d" requires-dist = [] -# The Megatron Bridge source metadata currently requires Transformers 5.8.x, -# but this branch is validated against Transformers 5.6.2 for Gemma 4. # Keep Bridge's runtime deps explicit here and let ART's megatron extra own the -# Transformers pin. +# Transformers pin validated by model-support handlers in this branch. [[tool.uv.dependency-metadata]] name = "megatron-bridge" version = "0.5.0+e1a207ac" diff --git a/src/art/dev/__init__.py b/src/art/dev/__init__.py index dcc62d3f6..87c0e7aad 100644 --- a/src/art/dev/__init__.py +++ b/src/art/dev/__init__.py @@ -9,10 +9,15 @@ TinkerNativeArgs, TinkerTrainingClientArgs, TrainerArgs, + VllmRuntimeArgs, ) from .openai_server import OpenAIServerConfig, ServerArgs, get_openai_server_config from .train import TrainConfig, TrainSFTConfig -from .validate import is_dedicated_mode, validate_dedicated_config +from .validate import ( + is_dedicated_mode, + is_external_vllm_mode, + validate_dedicated_config, +) __all__ = [ "EngineArgs", @@ -25,8 +30,10 @@ "TinkerNativeArgs", "TinkerTrainingClientArgs", "TrainerArgs", + "VllmRuntimeArgs", "get_openai_server_config", "is_dedicated_mode", + "is_external_vllm_mode", "OpenAIServerConfig", "ServerArgs", "TrainSFTConfig", diff --git a/src/art/dev/get_model_config.py b/src/art/dev/get_model_config.py index 57eda12de..f478a3acc 100644 --- a/src/art/dev/get_model_config.py +++ b/src/art/dev/get_model_config.py @@ -1,4 +1,7 @@ -from ..megatron.model_support import default_target_modules_for_model +from ..megatron.model_support import ( + default_target_modules_for_model, + vllm_lora_config_for_model, +) from .engine import EngineArgs from .model import ( PEFT_ARGS_MIGRATION_MESSAGE, @@ -67,7 +70,11 @@ def get_model_config( if rollout_weights_mode == "lora" and "lora_target_modules" not in config.get( "engine_args", {} ): - engine_args["lora_target_modules"] = merged_lora_config["target_modules"] + engine_args["lora_target_modules"] = vllm_lora_config_for_model( + base_model, + dict(merged_lora_config), + allow_unvalidated_arch=True, + )["target_modules"] trainer_args = TrainerArgs( adam_beta1=0.9, adam_beta2=0.99, @@ -101,4 +108,6 @@ def get_model_config( result["trainer_gpu_ids"] = config["trainer_gpu_ids"] if "inference_gpu_ids" in config: result["inference_gpu_ids"] = config["inference_gpu_ids"] + if "vllm_runtime" in config: + result["vllm_runtime"] = config["vllm_runtime"] return result diff --git a/src/art/dev/model.py b/src/art/dev/model.py index a042c2d47..881a2b3d7 100644 --- a/src/art/dev/model.py +++ b/src/art/dev/model.py @@ -6,6 +6,16 @@ from .engine import EngineArgs RolloutWeightsMode = Literal["lora", "merged"] +VllmRuntimeMode = Literal["managed", "external"] + + +class VllmRuntimeArgs(TypedDict, total=False): + mode: Required[VllmRuntimeMode] + server_url: str + api_key: str | None + local_checkpoint_root: str | None + server_checkpoint_root: str | None + health_timeout_s: float # Vendored from transformers.training_args.OptimizerNames @@ -135,6 +145,8 @@ class InternalModelConfig(TypedDict, total=False): chat_template_content_format: vLLM chat template content format. chat_template_tool_schema_format: Tool schema rendering format used for local training tokenization. + vllm_runtime: vLLM runtime location. Omit for ART-managed local runtime; + set mode="external" to attach to a pre-launched vLLM server. allow_unvalidated_arch: Permit model-support validation workflows to run architectures that are not yet in the supported-model registry. """ @@ -152,6 +164,7 @@ class InternalModelConfig(TypedDict, total=False): chat_template_path: str chat_template_content_format: str chat_template_tool_schema_format: Literal["default", "vllm_openai"] + vllm_runtime: VllmRuntimeArgs allow_unvalidated_arch: bool diff --git a/src/art/dev/validate.py b/src/art/dev/validate.py index 56e91c1df..ae68116fa 100644 --- a/src/art/dev/validate.py +++ b/src/art/dev/validate.py @@ -1,11 +1,30 @@ """Validation functions for model configuration.""" -from .model import InternalModelConfig, RolloutWeightsMode +from collections.abc import Mapping +from typing import cast + +from .model import InternalModelConfig, RolloutWeightsMode, VllmRuntimeMode + + +def _vllm_runtime_mode(config: InternalModelConfig) -> VllmRuntimeMode: + runtime_config = config.get("vllm_runtime", {}) + if not isinstance(runtime_config, Mapping): + raise ValueError("vllm_runtime must be a mapping") + mode = runtime_config.get("mode", "managed") + if mode in {"managed", "external"}: + return cast(VllmRuntimeMode, mode) + raise ValueError("vllm_runtime.mode must be either 'managed' or 'external'") + + +def is_external_vllm_mode(config: InternalModelConfig) -> bool: + return _vllm_runtime_mode(config) == "external" def is_dedicated_mode(config: InternalModelConfig) -> bool: """Return True if the config specifies dedicated mode (separate training and inference GPUs).""" - return "trainer_gpu_ids" in config and "inference_gpu_ids" in config + return is_external_vllm_mode(config) or ( + "trainer_gpu_ids" in config and "inference_gpu_ids" in config + ) def _rollout_weights_mode(config: InternalModelConfig) -> RolloutWeightsMode: @@ -24,6 +43,25 @@ def validate_dedicated_config(config: InternalModelConfig) -> None: has_trainer = "trainer_gpu_ids" in config has_inference = "inference_gpu_ids" in config rollout_weights_mode = _rollout_weights_mode(config) + external = is_external_vllm_mode(config) + + if external: + runtime_config = config.get("vllm_runtime", {}) + assert isinstance(runtime_config, Mapping) + if not runtime_config.get("server_url"): + raise ValueError("vllm_runtime.server_url is required for external mode") + if rollout_weights_mode != "lora": + raise ValueError( + "vllm_runtime.mode='external' requires rollout_weights_mode='lora'" + ) + if has_trainer and not config["trainer_gpu_ids"]: + raise ValueError("trainer_gpu_ids must be non-empty") + if "fast_inference" in config.get("init_args", {}): + raise ValueError( + "fast_inference is no longer supported; ART always uses an external " + "vLLM runtime" + ) + return if has_trainer != has_inference: raise ValueError( diff --git a/src/art/local/backend.py b/src/art/local/backend.py index a874cf22d..a566e7ec7 100644 --- a/src/art/local/backend.py +++ b/src/art/local/backend.py @@ -43,6 +43,10 @@ pull_model_from_s3, push_model_to_s3, ) +from art.vllm_runtime import ( + get_external_vllm_runtime_config, + openai_base_url_from_vllm_server_url, +) from mp_actors import close_proxy, move_to_child_process from .. import dev @@ -111,6 +115,16 @@ def _configured_chat_template_server_arg( return chat_template_path or chat_template +def _model_support_default_chat_template( + base_model: str, + internal_config: dev.InternalModelConfig, +) -> str | None: + handler = _model_support_handler(base_model, internal_config) + if handler is None: + return None + return handler.default_chat_template() + + def _apply_configured_chat_template( tokenizer: PreTrainedTokenizerBase, internal_config: dev.InternalModelConfig, @@ -120,11 +134,37 @@ def _apply_configured_chat_template( tokenizer.chat_template = chat_template +def _model_support_handler( + base_model: str, + internal_config: dev.InternalModelConfig, +) -> Any | None: + from ..megatron.model_support.registry import ( + UnsupportedModelArchitectureError, + get_model_support_handler, + ) + + try: + return get_model_support_handler( + base_model, + allow_unvalidated_arch=bool( + internal_config.get("allow_unvalidated_arch", False) + ), + ) + except UnsupportedModelArchitectureError: + return None + + def _apply_configured_chat_template_server_args( config_dict: dict, internal_config: dev.InternalModelConfig, + *, + base_model: str | None = None, ) -> None: chat_template = _configured_chat_template_server_arg(internal_config) + if chat_template is None and base_model is not None: + chat_template = _model_support_default_chat_template( + base_model, internal_config + ) if chat_template is None: return server_args = dict(config_dict.get("server_args", {})) @@ -300,6 +340,19 @@ def _chat_template_tool_schema_format( self._default_chat_template_tool_schema_format, ) + def _configure_training_tokenizer( + self, + tokenizer: PreTrainedTokenizerBase, + *, + model: AnyTrainableModel, + internal_config: dev.InternalModelConfig, + ) -> PreTrainedTokenizerBase: + _apply_configured_chat_template(tokenizer, internal_config) + handler = _model_support_handler(model.base_model, internal_config) + if handler is None: + return tokenizer + return handler.configure_tokenizer(tokenizer, internal_config=internal_config) + def __enter__(self) -> Self: return self @@ -524,8 +577,11 @@ def _get_packed_tensors( internal_config = cast(dev.InternalModelConfig, model._internal_config or {}) tokenizer_key = _tokenizer_cache_key(model.base_model, internal_config) if tokenizer_key not in self._tokenizers: - tokenizer = AutoTokenizer.from_pretrained(model.base_model) - _apply_configured_chat_template(tokenizer, internal_config) + tokenizer = self._configure_training_tokenizer( + AutoTokenizer.from_pretrained(model.base_model), + model=model, + internal_config=internal_config, + ) self._tokenizers[tokenizer_key] = tokenizer if model.base_model not in self._image_processors: try: @@ -702,8 +758,15 @@ async def _prepare_backend_for_training( service = await self._get_service(model) host, port = await service.start_openai_server(config=resolved_config) - base_url = f"http://{host}:{port}/v1" - api_key = server_args.get("api_key") or "default" + external_runtime = get_external_vllm_runtime_config(internal_config) + if external_runtime is not None: + base_url = openai_base_url_from_vllm_server_url(external_runtime.server_url) + api_key = ( + server_args.get("api_key") or external_runtime.api_key or "default" + ) + else: + base_url = f"http://{host}:{port}/v1" + api_key = server_args.get("api_key") or "default" return base_url, api_key @@ -1130,8 +1193,11 @@ async def _train_sft( internal_config = cast(dev.InternalModelConfig, model._internal_config or {}) tokenizer_key = _tokenizer_cache_key(model.base_model, internal_config) if tokenizer_key not in self._tokenizers: - tokenizer = AutoTokenizer.from_pretrained(model.base_model) - _apply_configured_chat_template(tokenizer, internal_config) + tokenizer = self._configure_training_tokenizer( + AutoTokenizer.from_pretrained(model.base_model), + model=model, + internal_config=internal_config, + ) self._tokenizers[tokenizer_key] = tokenizer tokenizer = self._tokenizers[tokenizer_key] diff --git a/src/art/megatron/dsv4/__init__.py b/src/art/megatron/dsv4/__init__.py new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/src/art/megatron/dsv4/__init__.py @@ -0,0 +1 @@ + diff --git a/src/art/megatron/dsv4/bridge.py b/src/art/megatron/dsv4/bridge.py new file mode 100644 index 000000000..d0eec5609 --- /dev/null +++ b/src/art/megatron/dsv4/bridge.py @@ -0,0 +1,1273 @@ +from functools import lru_cache, partial +import re +from typing import Any, Iterable, Mapping, cast + +from megatron.bridge.models.conversion.mapping_registry import MegatronMappingRegistry +from megatron.bridge.models.conversion.model_bridge import WeightConversionTask +from megatron.bridge.models.conversion.param_mapping import ( + AutoMapping, + GatedMLPMapping, + ReplicatedMapping, + RowParallelMapping, + extract_expert_number_from_param, +) +from megatron.bridge.models.deepseek.deepseek_v3_bridge import DeepSeekV3Bridge +from megatron.bridge.models.mla_provider import MLAModelProvider +from megatron.core.models.gpt.gpt_model import GPTModel +import torch + +from art.megatron.dsv4.spec import get_dsv4_decoder_block_spec + +_DSV4_FP4_TABLE = ( + 0.0, + 0.5, + 1.0, + 1.5, + 2.0, + 3.0, + 4.0, + 6.0, + 0.0, + -0.5, + -1.0, + -1.5, + -2.0, + -3.0, + -4.0, + -6.0, +) + +_DSV4_FUSED_EXPORT_SPECS = ( + ( + ".self_attn.q_a_proj.weight", + ".self_attn.kv_proj.weight", + ".attn.fused_wqa_wkv.weight", + ), + ( + ".self_attn.compressor.kv_proj.weight", + ".self_attn.compressor.gate_proj.weight", + ".attn.compressor.fused_wkv_wgate.weight", + ), + ( + ".self_attn.compressor.indexer.kv_proj.weight", + ".self_attn.compressor.indexer.gate_proj.weight", + ".attn.indexer.compressor.fused_wkv_wgate.weight", + ), + ( + ".mlp.shared_experts.gate_proj.weight", + ".mlp.shared_experts.up_proj.weight", + ".ffn.shared_experts.gate_up_proj.weight", + ), +) + +_DSV4_RENAMED_EXPORT_SUFFIXES = ( + ("lm_head.weight", "head.weight"), + ("model.hc_head.hc_fn", "model.hc_head_fn"), + ("model.hc_head.hc_base", "model.hc_head_base"), + ("model.hc_head.hc_scale", "model.hc_head_scale"), + (".input_layernorm.weight", ".attn_norm.weight"), + (".post_attention_layernorm.weight", ".ffn_norm.weight"), + (".attn_hc.fn", ".hc_attn_fn"), + (".attn_hc.base", ".hc_attn_base"), + (".attn_hc.scale", ".hc_attn_scale"), + (".ffn_hc.fn", ".hc_ffn_fn"), + (".ffn_hc.base", ".hc_ffn_base"), + (".ffn_hc.scale", ".hc_ffn_scale"), + (".mlp.gate.weight", ".ffn.gate.weight"), + (".mlp.gate.tid2eid", ".ffn.gate.tid2eid"), + (".mlp.gate.e_score_correction_bias", ".ffn.gate.bias"), + (".mlp.shared_experts.down_proj.weight", ".ffn.shared_experts.w2.weight"), + (".self_attn.q_a_norm.weight", ".attn.q_norm.weight"), + (".self_attn.q_b_proj.weight", ".attn.wq_b.weight"), + (".self_attn.kv_norm.weight", ".attn.kv_norm.weight"), + (".self_attn.o_a_proj.weight", ".attn.wo_a.weight"), + (".self_attn.o_b_proj.weight", ".attn.wo_b.weight"), + (".self_attn.sinks", ".attn.attn_sink"), + (".self_attn.compressor.position_bias", ".attn.compressor.ape"), + (".self_attn.compressor.kv_norm.weight", ".attn.compressor.norm.weight"), + ( + ".self_attn.compressor.indexer.q_b_proj.weight", + ".attn.indexer.wq_b.weight", + ), + ( + ".self_attn.compressor.indexer.scorer.weights_proj.weight", + ".attn.indexer.weights_proj.weight", + ), + ( + ".self_attn.compressor.indexer.position_bias", + ".attn.indexer.compressor.ape", + ), + ( + ".self_attn.compressor.indexer.kv_norm.weight", + ".attn.indexer.compressor.norm.weight", + ), +) +_DSV4_LAYER_TYPE_TO_COMPRESS_RATIO = { + "sliding_attention": 0, + "compressed_sparse_attention": 4, + "heavily_compressed_attention": 128, +} + + +def _dsv4_compress_ratios_from_hf_config(hf_config: Any) -> list[int] | None: + ratios = getattr(hf_config, "compress_ratios", None) + if ratios is not None: + return [int(ratio) for ratio in ratios] + layer_types = getattr(hf_config, "layer_types", None) + if layer_types is None: + return None + compress_rates = getattr(hf_config, "compress_rates", None) or {} + return [ + int( + compress_rates.get( + layer_type, + _DSV4_LAYER_TYPE_TO_COMPRESS_RATIO[layer_type], + ) + ) + for layer_type in layer_types + ] + + +def _dequant_dsv4_mxfp4(weight: torch.Tensor, scale: torch.Tensor) -> torch.Tensor: + if weight.dtype not in (torch.int8, torch.uint8): + raise ValueError( + f"Expected MXFP4 packed int8/uint8 weight, got {weight.dtype}." + ) + if scale.dtype != torch.float8_e8m0fnu: + raise ValueError(f"Expected MXFP4 E8M0 scale, got {scale.dtype}.") + if weight.ndim != 2 or scale.ndim != 2: + raise ValueError( + f"Expected 2-D MXFP4 weight and scale, got {weight.shape=} {scale.shape=}." + ) + + out_dim, in_bytes = weight.shape + in_dim = in_bytes * 2 + if in_dim % 32 != 0 or tuple(scale.shape) != (out_dim, in_dim // 32): + raise ValueError( + "Unexpected MXFP4 scale shape: " + f"weight={tuple(weight.shape)} scale={tuple(scale.shape)}." + ) + + if torch.cuda.is_available(): + from art.megatron.dsv4.dequant import dequant_mxfp4_cuda + + return dequant_mxfp4_cuda(weight, scale) + + table = torch.tensor(_DSV4_FP4_TABLE, dtype=torch.float32, device=weight.device) + packed = weight.contiguous().view(torch.uint8) + low = (packed & 0x0F).long() + high = ((packed >> 4) & 0x0F).long() + decoded = torch.stack((table[low], table[high]), dim=-1).reshape(out_dim, in_dim) + expanded_scale = scale.float().repeat_interleave(32, dim=1) + return (decoded * expanded_scale).to(torch.bfloat16) + + +def _dequant_dsv4_block_fp8(weight: torch.Tensor, scale: torch.Tensor) -> torch.Tensor: + if weight.dtype != torch.float8_e4m3fn: + raise ValueError(f"Expected block-FP8 E4M3 weight, got {weight.dtype}.") + if scale.dtype != torch.float8_e8m0fnu: + raise ValueError(f"Expected block-FP8 E8M0 scale, got {scale.dtype}.") + if weight.ndim != 2 or scale.ndim != 2: + raise ValueError( + f"Expected 2-D block-FP8 weight and scale, got {weight.shape=} {scale.shape=}." + ) + + block = 128 + out_dim, in_dim = weight.shape + expected_scale_shape = ( + (out_dim + block - 1) // block, + (in_dim + block - 1) // block, + ) + if tuple(scale.shape) != expected_scale_shape: + raise ValueError( + "Unexpected block-FP8 scale shape: " + f"weight={tuple(weight.shape)} scale={tuple(scale.shape)} " + f"expected={expected_scale_shape}." + ) + + if torch.cuda.is_available(): + from art.megatron.dsv4.dequant import dequant_block_fp8_cuda + + return dequant_block_fp8_cuda(weight, scale) + + expanded_scale = ( + scale.float().repeat_interleave(block, dim=0).repeat_interleave(block, dim=1) + ) + expanded_scale = expanded_scale[:out_dim, :in_dim] + return (weight.float() * expanded_scale).to(torch.bfloat16) + + +def _dequant_dsv4_weight(weight: torch.Tensor, scale: torch.Tensor) -> torch.Tensor: + if weight.dtype in (torch.int8, torch.uint8): + return _dequant_dsv4_mxfp4(weight, scale) + if weight.dtype == torch.float8_e4m3fn: + return _dequant_dsv4_block_fp8(weight, scale) + return weight + + +def _quant_dsv4_mxfp4(weight: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]: + if weight.ndim != 2 or weight.shape[1] % 32 != 0: + raise ValueError( + f"Expected 2-D MXFP4 weight with K % 32 == 0, got {weight.shape}." + ) + if weight.device.type == "cuda": + from art.megatron.dsv4.dequant import quant_mxfp4_cuda + + return quant_mxfp4_cuda(weight) + + out_dim, in_dim = weight.shape + blocks = weight.float().contiguous().view(out_dim, in_dim // 32, 32) + amax = blocks.abs().amax(dim=-1).clamp_min(1e-4) + scale = torch.pow(2.0, torch.ceil(torch.log2(amax / 6.0))) + scale_e8m0 = scale.to(torch.float8_e8m0fnu) + + scaled = blocks / scale_e8m0.float().unsqueeze(-1) + thresholds = torch.tensor( + (0.25, 0.75, 1.25, 1.75, 2.5, 3.5, 5.0), + device=weight.device, + dtype=torch.float32, + ) + codes = torch.bucketize(scaled.abs(), thresholds).to(torch.uint8) + codes = codes + ((scaled < 0) & (codes != 0)).to(torch.uint8) * 8 + low = codes[..., 0::2] + high = codes[..., 1::2] << 4 + return (low | high).reshape(out_dim, in_dim // 2), scale_e8m0 + + +def _is_dsv4_routed_expert_weight(name: str) -> bool: + return ".ffn.experts." in name and name.endswith( + (".w1.weight", ".w2.weight", ".w3.weight") + ) + + +def _dsv4_canonical_expert_source_name(name: str) -> str | None: + replacements = { + ".gate_proj.weight": ".w1.weight", + ".up_proj.weight": ".w3.weight", + ".down_proj.weight": ".w2.weight", + } + if ".mlp.experts." not in name: + return None + for canonical, source in replacements.items(): + if name.endswith(canonical): + return ( + name.replace(".mlp.experts.", ".ffn.experts.").removesuffix(canonical) + + source + ) + return None + + +def _is_dsv4_hash_router_table(name: str) -> bool: + return name.endswith(".ffn.gate.tid2eid") + + +def _dsv4_expert_id(name: str) -> int: + match = re.search(r"\.experts\.(\d+)\.", name) + if match is None: + raise ValueError(f"Expected DSV4 expert id in weight name: {name}.") + return int(match.group(1)) + + +def _set_dsv4_expert_id(name: str, expert_id: int) -> str: + return re.sub(r"\.experts\.\d+\.", f".experts.{expert_id}.", name, count=1) + + +def _export_dsv4_mxfp4_weight( + name: str, weight: torch.Tensor +) -> dict[str, torch.Tensor]: + packed, scale = _quant_dsv4_mxfp4(weight) + return { + name: packed.contiguous(), + f"{name.removesuffix('.weight')}.scale": scale.contiguous(), + } + + +def _dsv4_fused_export_key(name: str) -> tuple[str, int] | None: + for first, second, target in _DSV4_FUSED_EXPORT_SPECS: + if name.endswith(first): + return f"{name.removesuffix(first)}{target}", 0 + if name.endswith(second): + return f"{name.removesuffix(second)}{target}", 1 + return None + + +def _concat_dsv4_fused_export_parts( + target: str, parts: dict[int, torch.Tensor] +) -> torch.Tensor: + first, second = parts[0], parts[1] + if first.ndim != second.ndim or first.shape[1:] != second.shape[1:]: + raise ValueError( + f"Cannot fuse DSV4 export parts for {target}: " + f"{tuple(first.shape)} vs {tuple(second.shape)}." + ) + return torch.cat((first, second), dim=0).contiguous() + + +def _dsv4_source_export_name(name: str) -> str: + for canonical, source in _DSV4_RENAMED_EXPORT_SUFFIXES: + if name.endswith(canonical): + return f"{name.removesuffix(canonical)}{source}" + return name + + +def _dsv4_full_parallel_shape(task: WeightConversionTask) -> list[int]: + param_weight = task.param_weight + if param_weight is None: + raise RuntimeError(f"Missing DSV4 export param for {task.global_param_name}") + shape = list(param_weight.shape) + if not bool(getattr(param_weight, "tensor_model_parallel", False)): + tp_size = int(getattr(task.mapping, "tp_size", 1) or 1) + if task.global_param_name in { + "embedding.word_embeddings.weight", + "output_layer.weight", + } or task.global_param_name.endswith(".self_attention.attn_sink"): + shape[0] *= tp_size + elif task.global_param_name.endswith( + ( + ".self_attention.wq_b.weight", + ".self_attention.wo_a.weight", + ".self_attention.indexer.linear_wq_b.weight", + ) + ): + shape[0] *= tp_size + elif task.global_param_name.endswith( + ( + ".self_attention.wo_b.weight", + ".mlp.shared_experts.linear_fc2.weight", + ) + ): + shape[1] *= tp_size + return shape + partition_dim = int(getattr(param_weight, "partition_dim", 0) or 0) + shape[partition_dim] *= int(getattr(task.mapping, "tp_size", 1) or 1) + return shape + + +def _dsv4_gated_shape(task: WeightConversionTask) -> list[int]: + param_weight = task.param_weight + if param_weight is None: + raise RuntimeError(f"Missing DSV4 export param for {task.global_param_name}") + shape = list(param_weight.shape) + shape[0] *= int(getattr(task.mapping, "tp_size", 1) or 1) + if shape[0] % 2 != 0: + raise ValueError( + f"Expected even DSV4 gated export dim for {task.global_param_name}: {shape}" + ) + shape[0] //= 2 + return shape + + +def _dsv4_expert_down_shape(task: WeightConversionTask) -> list[int]: + param_weight = task.param_weight + if param_weight is None: + raise RuntimeError(f"Missing DSV4 export param for {task.global_param_name}") + shape = list(param_weight.shape) + if len(shape) > 1: + shape[1] *= int(getattr(task.mapping, "tp_size", 1) or 1) + return shape + + +def _dsv4_expert_names( + *, + task: WeightConversionTask, + export_name: str, +) -> list[str]: + config = getattr(task.megatron_module, "config", None) + num_experts = int(getattr(config, "num_moe_experts", 0) or 0) + ep_size = int(getattr(task.mapping, "ep_size", 1) or 1) + if num_experts <= 0 or num_experts % ep_size != 0: + raise ValueError( + f"Cannot infer DSV4 expert metadata for {task.global_param_name}: " + f"num_experts={num_experts}, ep_size={ep_size}." + ) + experts_per_rank = num_experts // ep_size + local_expert = ( + extract_expert_number_from_param(task.mapping.megatron_param) % experts_per_rank + ) + return [ + _set_dsv4_expert_id(export_name, local_expert + experts_per_rank * ep_rank) + for ep_rank in range(ep_size) + ] + + +def _dsv4_quantized_expert_metadata( + name: str, + shape: list[int], +) -> list[tuple[str, torch.dtype, list[int]]]: + if len(shape) != 2 or shape[1] % 32 != 0: + raise ValueError(f"Expected 2-D K%32 DSV4 expert weight for {name}: {shape}") + return [ + (name, torch.uint8, [shape[0], shape[1] // 2]), + ( + f"{name.removesuffix('.weight')}.scale", + torch.float8_e8m0fnu, + [shape[0], shape[1] // 32], + ), + ] + + +def _dsv4_modified_metadata( + pending_fused: dict[str, dict[int, tuple[torch.dtype, list[int]]]], + name: str, + dtype: torch.dtype, + shape: list[int], +) -> list[tuple[str, torch.dtype, list[int]]]: + fused_key = _dsv4_fused_export_key(name) + if fused_key is not None: + target, part_index = fused_key + parts = pending_fused.setdefault(target, {}) + if part_index in parts: + raise ValueError( + f"Duplicate DSV4 fused metadata part {part_index}: {name}." + ) + parts[part_index] = (dtype, shape) + if len(parts) < 2: + return [] + pending_fused.pop(target) + first_dtype, first_shape = parts[0] + second_dtype, second_shape = parts[1] + if first_dtype != second_dtype or first_shape[1:] != second_shape[1:]: + raise ValueError( + f"Cannot fuse DSV4 metadata parts for {target}: " + f"{first_dtype}/{first_shape} vs {second_dtype}/{second_shape}." + ) + return [ + (target, first_dtype, [first_shape[0] + second_shape[0], *first_shape[1:]]) + ] + + source_expert = _dsv4_canonical_expert_source_name(name) + source_name = ( + source_expert if source_expert is not None else _dsv4_source_export_name(name) + ) + if _is_dsv4_routed_expert_weight(source_name): + return _dsv4_quantized_expert_metadata(source_name, shape) + if _is_dsv4_hash_router_table(source_name): + return [(source_name, torch.int32, shape)] + return [(source_name, dtype, shape)] + + +def _load_dsv4_hf_tensor( + hf_param: str, hf_state_dict: Mapping[str, torch.Tensor] +) -> torch.Tensor: + weight = hf_state_dict[hf_param] + if not hf_param.endswith(".weight") or weight.dtype not in ( + torch.int8, + torch.uint8, + torch.float8_e4m3fn, + ): + return weight + + scale_param = f"{hf_param.removesuffix('.weight')}.scale" + try: + scale = hf_state_dict[scale_param] + except KeyError as exc: + raise ValueError( + f"Quantized DSV4 weight {hf_param} requires scale {scale_param}." + ) from exc + return _dequant_dsv4_weight(weight, scale) + + +def _resolve_dsv4_hf_param(hf_param: Any, captures: tuple[str, ...]) -> Any: + def resolve_one(pattern: str) -> str: + resolved = pattern + capture_index = 0 + while "**" in resolved and capture_index < len(captures): + resolved = resolved.replace("**", captures[capture_index], 1) + capture_index += 1 + while "*" in resolved and capture_index < len(captures): + resolved = resolved.replace("*", captures[capture_index], 1) + capture_index += 1 + return resolved + + if isinstance(hf_param, str): + return resolve_one(hf_param) + return {key: resolve_one(value) for key, value in hf_param.items()} + + +class _Dsv4AliasStateSource: + def __init__(self, source: Any, aliases: Mapping[str, str]): + self.source = source + self.aliases = dict(aliases) + self._all_keys_cache: list[str] | None = None + + def __getattr__(self, name: str) -> Any: + return getattr(self.source, name) + + def get_all_keys(self) -> list[str]: + if self._all_keys_cache is not None: + return self._all_keys_cache + keys = set(self.source.get_all_keys()) + keys.update(alias for alias, target in self.aliases.items() if target in keys) + self._all_keys_cache = sorted(keys) + return self._all_keys_cache + + def load_tensors(self, keys: list[str]) -> dict[str, torch.Tensor]: + source_keys = [self.aliases.get(key, key) for key in keys] + loaded = self.source.load_tensors(source_keys) + return {key: loaded[self.aliases.get(key, key)] for key in keys} + + def has_glob(self, pattern: str) -> bool: + import fnmatch + + return any(fnmatch.fnmatch(key, pattern) for key in self.get_all_keys()) + + +def _install_dsv4_source_aliases(hf_pretrained: Any) -> None: + state = hf_pretrained.state + source = getattr(state, "source", None) + if source is None or isinstance(source, _Dsv4AliasStateSource): + return + keys = set(source.get_all_keys()) + aliases = {"model.norm.weight": "norm.weight"} + active_aliases = { + alias: target + for alias, target in aliases.items() + if alias not in keys and target in keys + } + if active_aliases: + state.source = _Dsv4AliasStateSource(source, active_aliases) + + +class _Dsv4AutoMapping(AutoMapping): + def __init__( + self, + megatron_param: str, + hf_param: str, + export_hf_param: str | None = None, + permute_dims: tuple[int, ...] | None = None, + ): + super().__init__(megatron_param, hf_param, permute_dims) + self.export_hf_param = export_hf_param or hf_param + + def megatron_to_hf(self, megatron_weights: Any, megatron_module: Any): + converted = super().megatron_to_hf(megatron_weights, megatron_module) + if not converted or self.export_hf_param == self.hf_param: + return converted + return {self.export_hf_param: next(iter(converted.values()))} + + def resolve(self, captures: tuple[str, ...]): + resolved_megatron_param, resolved_hf_param = self._resolve_names(captures) + return type(self)( + resolved_megatron_param, + cast(str, resolved_hf_param), + cast(str, _resolve_dsv4_hf_param(self.export_hf_param, captures)), + self.permute_dims, + ) + + +class _Dsv4ReplicatedMapping(ReplicatedMapping): + def __init__( + self, + megatron_param: str, + hf_param: str, + export_hf_param: str | None = None, + ): + super().__init__(megatron_param, hf_param) + self.export_hf_param = export_hf_param or hf_param + + def megatron_to_hf(self, megatron_weights: Any, megatron_module: Any): + converted = super().megatron_to_hf(megatron_weights, megatron_module) + if not converted or self.export_hf_param == self.hf_param: + return converted + return {self.export_hf_param: next(iter(converted.values()))} + + def resolve(self, captures: tuple[str, ...]): + resolved_megatron_param, resolved_hf_param = self._resolve_names(captures) + return type(self)( + resolved_megatron_param, + cast(str, resolved_hf_param), + cast(str, _resolve_dsv4_hf_param(self.export_hf_param, captures)), + ) + + +class _Dsv4RowParallelMapping(RowParallelMapping): + def __init__( + self, + megatron_param: str, + hf_param: str, + export_hf_param: str | None = None, + ): + super().__init__(megatron_param, hf_param) + self.export_hf_param = export_hf_param or hf_param + + def megatron_to_hf(self, megatron_weights: Any, megatron_module: Any): + converted = super().megatron_to_hf(megatron_weights, megatron_module) + if not converted or self.export_hf_param == self.hf_param: + return converted + return {self.export_hf_param: next(iter(converted.values()))} + + def resolve(self, captures: tuple[str, ...]): + resolved_megatron_param, resolved_hf_param = self._resolve_names(captures) + return type(self)( + resolved_megatron_param, + cast(str, resolved_hf_param), + cast(str, _resolve_dsv4_hf_param(self.export_hf_param, captures)), + ) + + +class _Dsv4GatedMLPMapping(GatedMLPMapping): + def __init__( + self, + megatron_param: str, + gate: str, + up: str, + export_gate: str | None = None, + export_up: str | None = None, + ): + super().__init__(megatron_param, gate, up) + self.export_hf_param = { + "gate": export_gate or gate, + "up": export_up or up, + } + + def megatron_to_hf(self, megatron_weights: Any, megatron_module: Any): + converted = super().megatron_to_hf(megatron_weights, megatron_module) + if not converted or self.export_hf_param == self.hf_param: + return converted + remapped: dict[str, torch.Tensor] = {} + source_hf_param = cast(dict[str, str], self.hf_param) + for source_key, target_key in zip( + source_hf_param.values(), self.export_hf_param.values(), strict=True + ): + if source_key in converted: + remapped[target_key] = converted[source_key] + return remapped + + def resolve(self, captures: tuple[str, ...]): + resolved_megatron_param, resolved_hf_param = self._resolve_names(captures) + resolved_hf_param = cast(dict[str, str], resolved_hf_param) + resolved_export = cast( + dict[str, str], _resolve_dsv4_hf_param(self.export_hf_param, captures) + ) + return type(self)( + resolved_megatron_param, + resolved_hf_param["gate"], + resolved_hf_param["up"], + resolved_export["gate"], + resolved_export["up"], + ) + + +@lru_cache(maxsize=1) +def _art_dsv4_expert_mapping_types() -> tuple[type[Any], type[Any]]: + class _ArtDsv4ExpertGateUpMapping(GatedMLPMapping): + is_grouped_export = False + + def __init__( + self, + megatron_param: str, + gate: str, + up: str, + export_gate: str, + export_up: str, + ): + super().__init__(megatron_param, gate, up) + self.export_hf_param = { + "gate": export_gate, + "up": export_up, + } + + @property + def group_key(self) -> str: + return cast(dict[str, str], self.export_hf_param)["gate"] + + def hf_to_megatron( + self, + hf_weights: Any, + megatron_module: Any, + ) -> torch.Tensor: + from megatron.bridge.models.conversion.param_mapping import ( + _align_expert_weight_to_shape, + ) + from megatron.bridge.models.conversion.utils import ( + get_module_and_param_from_name, + ) + + normalized_param = self._normalize_expert_param_name(self.megatron_param) + target_param = get_module_and_param_from_name( + megatron_module, normalized_param + )[1] + full_target_shape = ( + target_param.shape[0] * self.tp_size, + target_param.shape[1], + ) + if full_target_shape[0] % 2 != 0: + raise ValueError( + "Expected even fused dim for " + f"{self.megatron_param}, got {full_target_shape}." + ) + gate_target_shape = (full_target_shape[0] // 2, full_target_shape[1]) + gate = _align_expert_weight_to_shape( + cast(torch.Tensor, hf_weights["gate"]), + torch.Size(gate_target_shape), + "gate", + transpose_hint=False, + ) + up = _align_expert_weight_to_shape( + cast(torch.Tensor, hf_weights["up"]), + torch.Size(gate_target_shape), + "up", + transpose_hint=False, + ) + return super().hf_to_megatron({"gate": gate, "up": up}, megatron_module) + + def megatron_to_hf(self, megatron_weights: Any, megatron_module: Any): + converted = super().megatron_to_hf(megatron_weights, megatron_module) + if not converted: + return {} + hf_param = cast(dict[str, str], self.hf_param) + gate_suffix = hf_param["gate"].rpartition(".experts.")[2].split(".", 1)[1] + remapped: dict[str, torch.Tensor] = {} + export = cast(dict[str, str], self.export_hf_param) + for gate_key, gate in converted.items(): + if not gate_key.endswith(gate_suffix): + continue + expert_id = _dsv4_expert_id(gate_key) + up_key = _set_dsv4_expert_id(hf_param["up"], expert_id) + up = converted.get(up_key) + if up is None: + raise ValueError( + f"Missing DSV4 gathered expert up weight {up_key} " + f"for gate weight {gate_key}." + ) + remapped[_set_dsv4_expert_id(export["gate"], expert_id)] = gate + remapped[_set_dsv4_expert_id(export["up"], expert_id)] = up + return remapped + + def resolve(self, captures: tuple[str, ...]): + resolved_megatron_param, resolved_hf_param = self._resolve_names(captures) + resolved_hf_param = cast(dict[str, str], resolved_hf_param) + resolved_export = cast( + dict[str, str], _resolve_dsv4_hf_param(self.export_hf_param, captures) + ) + return type(self)( + resolved_megatron_param, + resolved_hf_param["gate"], + resolved_hf_param["up"], + resolved_export["gate"], + resolved_export["up"], + ) + + class _ArtDsv4ExpertDownMapping(AutoMapping): + is_grouped_export = False + + def __init__( + self, + megatron_param: str, + hf_param: str, + export_hf_param: str, + ): + super().__init__(megatron_param, hf_param) + self.weight_hf_param = hf_param + self.hf_param = {"weight": hf_param} + self.export_hf_param = export_hf_param + + @property + def group_key(self) -> str: + return self.export_hf_param + + def hf_to_megatron( + self, + hf_weights: Any, + megatron_module: Any, + ) -> torch.Tensor: + from megatron.bridge.models.conversion.param_mapping import ( + ColumnParallelMapping, + RowParallelMapping, + _align_expert_weight_to_shape, + ) + from megatron.bridge.models.conversion.utils import ( + get_module_and_param_from_name, + ) + + normalized_param = self._normalize_expert_param_name(self.megatron_param) + target_param = get_module_and_param_from_name( + megatron_module, normalized_param + )[1] + if self._mapping is None: + self._detected_type = self._detect_parallelism_type(megatron_module) + hf_param = self.hf_param + self.hf_param = self.weight_hf_param + try: + self._mapping = self._get_or_create_mapping(self._detected_type) + finally: + self.hf_param = hf_param + if isinstance(self._mapping, ColumnParallelMapping): + full_target_shape = ( + target_param.shape[0] * self.tp_size, + target_param.shape[1], + ) + elif isinstance(self._mapping, RowParallelMapping): + full_target_shape = ( + target_param.shape[0], + target_param.shape[1] * self.tp_size, + ) + else: + full_target_shape = tuple(target_param.shape) + hf_weight = ( + cast(dict[str, torch.Tensor], hf_weights)["weight"] + if isinstance(hf_weights, dict) + else cast(torch.Tensor, hf_weights) + ) + aligned = _align_expert_weight_to_shape( + hf_weight, + torch.Size(full_target_shape), + "down_proj", + transpose_hint=False, + ) + return self._mapping.hf_to_megatron(aligned, megatron_module) + + def megatron_to_hf(self, megatron_weights: Any, megatron_module: Any): + hf_param = self.hf_param + self.hf_param = self.weight_hf_param + try: + converted = super().megatron_to_hf(megatron_weights, megatron_module) + finally: + self.hf_param = hf_param + if not converted: + return {} + return { + _set_dsv4_expert_id( + self.export_hf_param, + _dsv4_expert_id(name), + ): weight + for name, weight in converted.items() + } + + def resolve(self, captures: tuple[str, ...]): + resolved_megatron_param, resolved_hf_param = self._resolve_names(captures) + resolved_hf_param = cast(dict[str, str], resolved_hf_param) + return type(self)( + resolved_megatron_param, + resolved_hf_param["weight"], + cast(str, _resolve_dsv4_hf_param(self.export_hf_param, captures)), + ) + + return _ArtDsv4ExpertGateUpMapping, _ArtDsv4ExpertDownMapping + + +def _register_dsv4_module_types() -> None: + AutoMapping.register_module_type("DeepSeekV4Attention", "column") + AutoMapping.register_module_type("DeepSeekV4Compressor", "replicated") + AutoMapping.register_module_type("Dsv4FinalNorm", "replicated") + AutoMapping.register_module_type("Dsv4Router", "replicated") + AutoMapping.register_module_type("Dsv4TransformerLayer", "replicated") + AutoMapping.register_module_type("HCHeadParams", "replicated") + + +def _dsv4_mapping_registry() -> MegatronMappingRegistry: + _register_dsv4_module_types() + expert_gate_up_mapping, expert_down_mapping = _art_dsv4_expert_mapping_types() + mappings: list[Any] = [ + _Dsv4AutoMapping( + "embedding.word_embeddings.weight", + "embed.weight", + "model.embed_tokens.weight", + ), + _Dsv4AutoMapping( + "decoder.layers.*.input_layernorm.weight", + "layers.*.attn_norm.weight", + "model.layers.*.input_layernorm.weight", + ), + _Dsv4AutoMapping( + "decoder.layers.*.pre_mlp_layernorm.weight", + "layers.*.ffn_norm.weight", + "model.layers.*.post_attention_layernorm.weight", + ), + _Dsv4AutoMapping( + "decoder.layers.*.mlp.router.weight", + "layers.*.ffn.gate.weight", + "model.layers.*.mlp.gate.weight", + ), + _Dsv4AutoMapping( + "decoder.layers.*.mlp.router.tid2eid", + "layers.*.ffn.gate.tid2eid", + "model.layers.*.mlp.gate.tid2eid", + ), + _Dsv4AutoMapping( + "decoder.layers.*.mlp.router.e_score_correction_bias", + "layers.*.ffn.gate.bias", + "model.layers.*.mlp.gate.e_score_correction_bias", + ), + expert_down_mapping( + "decoder.layers.*.mlp.experts.linear_fc2.weight*", + "layers.*.ffn.experts.*.w2.weight", + "model.layers.*.mlp.experts.*.down_proj.weight", + ), + _Dsv4RowParallelMapping( + "decoder.layers.*.mlp.shared_experts.linear_fc2.weight", + "layers.*.ffn.shared_experts.w2.weight", + "model.layers.*.mlp.shared_experts.down_proj.weight", + ), + _Dsv4AutoMapping("decoder.final_layernorm.weight", "model.norm.weight"), + _Dsv4AutoMapping( + "decoder.final_layernorm.hc_head_params.hc_head_fn", + "hc_head_fn", + "model.hc_head.hc_fn", + ), + _Dsv4AutoMapping( + "decoder.final_layernorm.hc_head_params.hc_head_base", + "hc_head_base", + "model.hc_head.hc_base", + ), + _Dsv4AutoMapping( + "decoder.final_layernorm.hc_head_params.hc_head_scale", + "hc_head_scale", + "model.hc_head.hc_scale", + ), + _Dsv4AutoMapping("output_layer.weight", "head.weight", "lm_head.weight"), + _Dsv4AutoMapping( + "decoder.layers.*.hc_attn_fn", + "layers.*.hc_attn_fn", + "model.layers.*.attn_hc.fn", + ), + _Dsv4AutoMapping( + "decoder.layers.*.hc_attn_base", + "layers.*.hc_attn_base", + "model.layers.*.attn_hc.base", + ), + _Dsv4AutoMapping( + "decoder.layers.*.hc_attn_scale", + "layers.*.hc_attn_scale", + "model.layers.*.attn_hc.scale", + ), + _Dsv4AutoMapping( + "decoder.layers.*.hc_ffn_fn", + "layers.*.hc_ffn_fn", + "model.layers.*.ffn_hc.fn", + ), + _Dsv4AutoMapping( + "decoder.layers.*.hc_ffn_base", + "layers.*.hc_ffn_base", + "model.layers.*.ffn_hc.base", + ), + _Dsv4AutoMapping( + "decoder.layers.*.hc_ffn_scale", + "layers.*.hc_ffn_scale", + "model.layers.*.ffn_hc.scale", + ), + _Dsv4AutoMapping( + "decoder.layers.*.self_attention.wq_a.weight", + "layers.*.attn.wq_a.weight", + "model.layers.*.self_attn.q_a_proj.weight", + ), + _Dsv4AutoMapping( + "decoder.layers.*.self_attention.q_norm.weight", + "layers.*.attn.q_norm.weight", + "model.layers.*.self_attn.q_a_norm.weight", + ), + _Dsv4AutoMapping( + "decoder.layers.*.self_attention.wq_b.weight", + "layers.*.attn.wq_b.weight", + "model.layers.*.self_attn.q_b_proj.weight", + ), + _Dsv4AutoMapping( + "decoder.layers.*.self_attention.wkv.weight", + "layers.*.attn.wkv.weight", + "model.layers.*.self_attn.kv_proj.weight", + ), + _Dsv4AutoMapping( + "decoder.layers.*.self_attention.kv_norm.weight", + "layers.*.attn.kv_norm.weight", + "model.layers.*.self_attn.kv_norm.weight", + ), + _Dsv4AutoMapping( + "decoder.layers.*.self_attention.wo_a.weight", + "layers.*.attn.wo_a.weight", + "model.layers.*.self_attn.o_a_proj.weight", + ), + _Dsv4RowParallelMapping( + "decoder.layers.*.self_attention.wo_b.weight", + "layers.*.attn.wo_b.weight", + "model.layers.*.self_attn.o_b_proj.weight", + ), + _Dsv4AutoMapping( + "decoder.layers.*.self_attention.attn_sink", + "layers.*.attn.attn_sink", + "model.layers.*.self_attn.sinks", + ), + _Dsv4AutoMapping( + "decoder.layers.*.self_attention.compressor.ape", + "layers.*.attn.compressor.ape", + "model.layers.*.self_attn.compressor.position_bias", + ), + _Dsv4ReplicatedMapping( + "decoder.layers.*.self_attention.compressor.wkv.weight", + "layers.*.attn.compressor.wkv.weight", + "model.layers.*.self_attn.compressor.kv_proj.weight", + ), + _Dsv4ReplicatedMapping( + "decoder.layers.*.self_attention.compressor.wgate.weight", + "layers.*.attn.compressor.wgate.weight", + "model.layers.*.self_attn.compressor.gate_proj.weight", + ), + _Dsv4AutoMapping( + "decoder.layers.*.self_attention.compressor.norm.weight", + "layers.*.attn.compressor.norm.weight", + "model.layers.*.self_attn.compressor.kv_norm.weight", + ), + _Dsv4AutoMapping( + "decoder.layers.*.self_attention.indexer.linear_wq_b.weight", + "layers.*.attn.indexer.wq_b.weight", + "model.layers.*.self_attn.compressor.indexer.q_b_proj.weight", + ), + _Dsv4AutoMapping( + "decoder.layers.*.self_attention.indexer.linear_weights_proj.weight", + "layers.*.attn.indexer.weights_proj.weight", + "model.layers.*.self_attn.compressor.indexer.scorer.weights_proj.weight", + ), + _Dsv4AutoMapping( + "decoder.layers.*.self_attention.indexer.compressor.ape", + "layers.*.attn.indexer.compressor.ape", + "model.layers.*.self_attn.compressor.indexer.position_bias", + ), + _Dsv4ReplicatedMapping( + "decoder.layers.*.self_attention.indexer.compressor.wkv.weight", + "layers.*.attn.indexer.compressor.wkv.weight", + "model.layers.*.self_attn.compressor.indexer.kv_proj.weight", + ), + _Dsv4ReplicatedMapping( + "decoder.layers.*.self_attention.indexer.compressor.wgate.weight", + "layers.*.attn.indexer.compressor.wgate.weight", + "model.layers.*.self_attn.compressor.indexer.gate_proj.weight", + ), + _Dsv4AutoMapping( + "decoder.layers.*.self_attention.indexer.compressor.norm.weight", + "layers.*.attn.indexer.compressor.norm.weight", + "model.layers.*.self_attn.compressor.indexer.kv_norm.weight", + ), + expert_gate_up_mapping( + megatron_param="decoder.layers.*.mlp.experts.linear_fc1.weight*", + gate="layers.*.ffn.experts.*.w1.weight", + up="layers.*.ffn.experts.*.w3.weight", + export_gate="model.layers.*.mlp.experts.*.gate_proj.weight", + export_up="model.layers.*.mlp.experts.*.up_proj.weight", + ), + _Dsv4GatedMLPMapping( + megatron_param="decoder.layers.*.mlp.shared_experts.linear_fc1.weight", + gate="layers.*.ffn.shared_experts.w1.weight", + up="layers.*.ffn.shared_experts.w3.weight", + export_gate="model.layers.*.mlp.shared_experts.gate_proj.weight", + export_up="model.layers.*.mlp.shared_experts.up_proj.weight", + ), + ] + return MegatronMappingRegistry(*mappings) + + +class ArtDeepSeekV4Bridge(DeepSeekV3Bridge): + def _maybe_collect_fused_export( + self, name: str, weight: torch.Tensor + ) -> dict[str, torch.Tensor] | None: + key = _dsv4_fused_export_key(name) + if key is None: + return None + target, part_index = key + pending = getattr(self, "_dsv4_fused_export_parts", None) + if pending is None: + pending = {} + self._dsv4_fused_export_parts = pending + parts = pending.setdefault(target, {}) + if part_index in parts: + raise ValueError(f"Duplicate DSV4 fused export part {part_index}: {name}.") + parts[part_index] = weight + if len(parts) < 2: + return {} + pending.pop(target) + return {target: _concat_dsv4_fused_export_parts(target, parts)} + + def provider_bridge(self, hf_pretrained: Any): + _install_dsv4_source_aliases(hf_pretrained) + hf_config = hf_pretrained.config + if not hasattr(hf_config, "first_k_dense_replace"): + hf_config.first_k_dense_replace = 0 + provider = cast(Any, super().provider_bridge(hf_pretrained)) + provider.transformer_layer_spec = partial(get_dsv4_decoder_block_spec) + provider.num_layers = hf_config.num_hidden_layers + provider.normalization = "RMSNorm" + provider.gated_linear_unit = True + provider.add_bias_linear = False + provider.share_embeddings_and_output_weights = False + provider.multi_latent_attention = False + provider.q_lora_rank = hf_config.q_lora_rank + provider.kv_lora_rank = hf_config.head_dim + provider.qk_pos_emb_head_dim = hf_config.qk_rope_head_dim + provider.num_attention_heads = hf_config.num_attention_heads + provider.num_query_groups = 1 + provider.kv_channels = hf_config.head_dim + provider.num_moe_experts = hf_config.n_routed_experts + provider.moe_router_topk = hf_config.num_experts_per_tok + provider.moe_router_score_function = hf_config.scoring_func + provider.moe_router_topk_scaling_factor = hf_config.routed_scaling_factor + provider.moe_router_enable_expert_bias = False + provider.moe_router_fusion = False + provider.moe_layer_freq = [1] * hf_config.num_hidden_layers + provider.moe_ffn_hidden_size = hf_config.moe_intermediate_size + provider.ffn_hidden_size = hf_config.moe_intermediate_size + provider.moe_shared_expert_intermediate_size = ( + hf_config.moe_intermediate_size * hf_config.n_shared_experts + ) + provider.dsv4_hc_mult = getattr(hf_config, "hc_mult", 4) + provider.dsv4_hc_sinkhorn_iters = getattr(hf_config, "hc_sinkhorn_iters", 20) + provider.dsv4_hc_eps = getattr(hf_config, "hc_eps", 1e-6) + provider.dsv4_compress_ratios = _dsv4_compress_ratios_from_hf_config(hf_config) + provider.dsv4_compress_rope_theta = getattr( + hf_config, "compress_rope_theta", 160000 + ) + rope_scaling = getattr(hf_config, "rope_scaling", None) or {} + provider.rotary_scaling_factor = rope_scaling.get("factor", 16) + provider.original_max_position_embeddings = rope_scaling.get( + "original_max_position_embeddings", 65536 + ) + provider.beta_fast = rope_scaling.get("beta_fast", 32) + provider.beta_slow = rope_scaling.get("beta_slow", 1) + provider.dsv4_swiglu_limit = getattr(hf_config, "swiglu_limit", 0.0) + provider.dsv4_o_groups = getattr(hf_config, "o_groups", 16) + provider.dsv4_o_lora_rank = getattr(hf_config, "o_lora_rank", 1024) + provider.dsv4_n_hash_layers = getattr(hf_config, "n_hash_layers", 3) + provider.dsv4_window_size = getattr(hf_config, "sliding_window", 128) + provider.dsa_indexer_n_heads = getattr(hf_config, "index_n_heads", 64) + provider.dsa_indexer_head_dim = getattr(hf_config, "index_head_dim", 128) + provider.dsa_indexer_topk = getattr(hf_config, "index_topk", 1024) + if provider.dsv4_swiglu_limit > 0: + provider.bias_activation_fusion = False + provider.activation_func_clamp_value = provider.dsv4_swiglu_limit + provider.mtp_num_layers = None + return provider + + def mapping_registry(self) -> MegatronMappingRegistry: + return _dsv4_mapping_registry() + + def art_extra_hf_prefetch_keys( + self, + keys: Iterable[str], + hf_state_dict: Mapping[str, torch.Tensor], + ) -> list[str]: + all_keys = set(hf_state_dict.keys()) + scale_keys: list[str] = [] + for key in keys: + if not key.endswith(".weight"): + continue + scale_key = f"{key.removesuffix('.weight')}.scale" + if scale_key in all_keys: + scale_keys.append(scale_key) + return scale_keys + + def maybe_modify_loaded_hf_weight( + self, + hf_param: str | dict[str, str], + hf_state_dict: Mapping[str, torch.Tensor], + ) -> torch.Tensor: + if isinstance(hf_param, str): + return _load_dsv4_hf_tensor(hf_param, hf_state_dict) + return cast( + torch.Tensor, + { + name: _load_dsv4_hf_tensor(param, hf_state_dict) + for name, param in hf_param.items() + }, + ) + + def maybe_modify_converted_hf_weight( + self, + task: WeightConversionTask, + converted_weights_dict: dict[str, torch.Tensor], + hf_state_dict: Any, + ) -> dict[str, torch.Tensor]: + del task + if isinstance(hf_state_dict, dict): + return converted_weights_dict + remapped: dict[str, torch.Tensor] = {} + for name, weight in converted_weights_dict.items(): + fused = self._maybe_collect_fused_export(name, weight) + if fused is not None: + remapped.update(fused) + else: + source_expert = _dsv4_canonical_expert_source_name(name) + source_name = ( + source_expert + if source_expert is not None + else _dsv4_source_export_name(name) + ) + if _is_dsv4_routed_expert_weight(source_name): + remapped.update(_export_dsv4_mxfp4_weight(source_name, weight)) + elif _is_dsv4_hash_router_table(source_name): + remapped[source_name] = weight.to(torch.int32).contiguous() + else: + remapped[source_name] = weight + return remapped + + def iter_merged_vllm_weight_metadata( + self, + weight_export: Any, + ) -> Iterable[tuple[str, torch.dtype, list[int]]]: + pending_fused: dict[str, dict[int, tuple[torch.dtype, list[int]]]] = {} + for task in weight_export.conversion_tasks: + mapping_name = type(task.mapping).__name__ + dtype = task.param_weight.dtype + if mapping_name == "_ArtDsv4ExpertGateUpMapping": + shape = _dsv4_gated_shape(task) + export = cast(dict[str, str], task.mapping.export_hf_param) + for gate_name, up_name in zip( + _dsv4_expert_names(task=task, export_name=export["gate"]), + _dsv4_expert_names(task=task, export_name=export["up"]), + strict=True, + ): + yield from _dsv4_modified_metadata( + pending_fused, gate_name, dtype, shape + ) + yield from _dsv4_modified_metadata( + pending_fused, up_name, dtype, shape + ) + continue + + if mapping_name == "_ArtDsv4ExpertDownMapping": + shape = _dsv4_expert_down_shape(task) + export_name = cast(str, task.mapping.export_hf_param) + for name in _dsv4_expert_names(task=task, export_name=export_name): + yield from _dsv4_modified_metadata( + pending_fused, name, dtype, shape + ) + continue + + if isinstance(task.mapping.export_hf_param, dict): + shape = _dsv4_gated_shape(task) + export = cast(dict[str, str], task.mapping.export_hf_param) + yield from _dsv4_modified_metadata( + pending_fused, export["gate"], dtype, shape + ) + yield from _dsv4_modified_metadata( + pending_fused, export["up"], dtype, shape + ) + continue + + yield from _dsv4_modified_metadata( + pending_fused, + cast(str, task.mapping.export_hf_param), + dtype, + _dsv4_full_parallel_shape(task), + ) + if pending_fused: + raise ValueError( + f"Incomplete DSV4 fused metadata parts: {sorted(pending_fused)}" + ) + + +_DSV4_BRIDGE_REGISTERED = False + + +def ensure_dsv4_bridge_registered() -> None: + global _DSV4_BRIDGE_REGISTERED + if _DSV4_BRIDGE_REGISTERED: + return + from megatron.bridge.models.conversion.model_bridge import MegatronModelBridge + + from art.megatron.dsv4.hf_config import ensure_dsv4_hf_config_registered + + ensure_dsv4_hf_config_registered() + MegatronModelBridge.register_bridge( + source="DeepseekV4ForCausalLM", + target=GPTModel, + provider=MLAModelProvider, + model_type="deepseek_v4", + )(ArtDeepSeekV4Bridge) + _DSV4_BRIDGE_REGISTERED = True diff --git a/src/art/megatron/dsv4/compressor.py b/src/art/megatron/dsv4/compressor.py new file mode 100644 index 000000000..b677f2521 --- /dev/null +++ b/src/art/megatron/dsv4/compressor.py @@ -0,0 +1,656 @@ +from typing import Any, NamedTuple, cast + +import einops +from megatron.core.transformer.transformer_config import TransformerConfig +from pydantic import BaseModel, ConfigDict, Field +import torch +import torch.nn as nn +from torch.nn import Linear + +from art.megatron.dsv4.kernel.precision_aligned_ops import linear_bf16_fp32 +from art.megatron.dsv4.rope import ( + apply_rotary_emb, + configure_rope_cache, + get_rope_cache, + get_rope_cache_at_positions, +) +from art.megatron.dsv4.utils import rotate_activation + + +class Dsv4CompressionLayout(NamedTuple): + current_indices: torch.Tensor + previous_indices: torch.Tensor + entry_group_ids: torch.Tensor + entry_parent_visible: torch.Tensor + entry_start_positions: torch.Tensor + entry_end_positions: torch.Tensor + entry_valid: torch.Tensor + + +class Dsv4SharedPrefixState(BaseModel): + model_config = ConfigDict(arbitrary_types_allowed=True) + + compression_layouts: dict[int, Dsv4CompressionLayout] + topk_idx_cache: dict[Any, Any] = Field(default_factory=dict) + + +def move_compression_layout_to_device( + layout: Dsv4CompressionLayout, + device: torch.device, +) -> Dsv4CompressionLayout: + return Dsv4CompressionLayout( + *(tensor.to(device=device, non_blocking=True) for tensor in layout) + ) + + +def build_shared_prefix_compression_layouts( + *, + position_ids: torch.Tensor, + group_ids: torch.Tensor, + parent_ids: torch.Tensor, + device: torch.device, +) -> dict[int, Dsv4CompressionLayout]: + return { + ratio: move_compression_layout_to_device( + build_shared_prefix_compression_layout( + position_ids=position_ids, + group_ids=group_ids, + parent_ids=parent_ids, + ratio=ratio, + duplicate_prompt_entries=ratio == 4, + ), + device, + ) + for ratio in (4, 128) + } + + +def _logical_window_indices( + *, + prompt_start: int, + prompt_len: int, + completion_start: int | None, + logical_start: int, + ratio: int, +) -> list[int]: + indices: list[int] = [] + for logical_pos in range(logical_start, logical_start + ratio): + if logical_pos < 0: + indices.append(-1) + elif logical_pos < prompt_len: + indices.append(prompt_start + logical_pos) + elif completion_start is not None: + indices.append(completion_start + logical_pos - prompt_len) + else: + indices.append(-1) + return indices + + +def build_shared_prefix_compression_layout( + *, + position_ids: torch.Tensor, + group_ids: torch.Tensor, + parent_ids: torch.Tensor, + ratio: int, + duplicate_prompt_entries: bool = False, +) -> Dsv4CompressionLayout: + device = position_ids.device + bsz, seqlen = position_ids.shape + position_cpu = position_ids.detach().cpu() + group_cpu = group_ids.detach().cpu() + parent_cpu = parent_ids.detach().cpu() + current_rows: list[list[list[int]]] = [] + previous_rows: list[list[list[int]]] = [] + group_rows: list[list[int]] = [] + parent_visible_rows: list[list[bool]] = [] + start_rows: list[list[int]] = [] + end_rows: list[list[int]] = [] + + for b in range(bsz): + valid_mask = (group_cpu[b] != -1) & (parent_cpu[b] != -1) + padding = torch.nonzero(~valid_mask, as_tuple=False) + valid_len = int(padding[0].item()) if padding.numel() else seqlen + if bool(valid_mask[valid_len:].any().item()): + raise ValueError("DSV4 shared-prefix metadata must pad only at row end.") + segments: list[tuple[int, int, int, int, int, int]] = [] + cursor = 0 + while cursor < valid_len: + group = int(group_cpu[b, cursor].item()) + parent = int(parent_cpu[b, cursor].item()) + start = cursor + cursor += 1 + while cursor < valid_len and int(group_cpu[b, cursor].item()) == group: + cursor += 1 + end = cursor + start_pos = int(position_cpu[b, start].item()) + end_pos = int(position_cpu[b, end - 1].item()) + segments.append((group, parent, start, end, start_pos, end_pos)) + + row_current: list[list[int]] = [] + row_previous: list[list[int]] = [] + row_groups: list[int] = [] + row_parent_visible: list[bool] = [] + row_starts: list[int] = [] + row_ends: list[int] = [] + + def append_entry( + *, + entry_group: int, + parent_visible: bool, + current_indices: list[int], + previous_indices: list[int], + logical_start: int, + ) -> None: + row_current.append(current_indices) + row_previous.append(previous_indices) + row_groups.append(entry_group) + row_parent_visible.append(parent_visible) + row_starts.append(logical_start) + row_ends.append(logical_start + ratio - 1) + + seg_idx = 0 + while seg_idx < len(segments): + group, parent, prompt_start, prompt_end, _, _ = segments[seg_idx] + if group != parent: + seg_idx += 1 + continue + prompt_len = prompt_end - prompt_start + shared_usable = (prompt_len // ratio) * ratio + shared_windows: list[tuple[list[int], list[int], int]] = [] + for logical_start in range(0, shared_usable, ratio): + current_indices = _logical_window_indices( + prompt_start=prompt_start, + prompt_len=prompt_len, + completion_start=None, + logical_start=logical_start, + ratio=ratio, + ) + previous_indices = _logical_window_indices( + prompt_start=prompt_start, + prompt_len=prompt_len, + completion_start=None, + logical_start=logical_start - ratio, + ratio=ratio, + ) + shared_windows.append( + (current_indices, previous_indices, logical_start) + ) + append_entry( + entry_group=group, + parent_visible=not duplicate_prompt_entries, + current_indices=current_indices, + previous_indices=previous_indices, + logical_start=logical_start, + ) + + child_idx = seg_idx + 1 + while child_idx < len(segments): + child_group, child_parent, child_start, _, _, child_end_pos = segments[ + child_idx + ] + if child_group == child_parent or child_parent != group: + break + if duplicate_prompt_entries: + for ( + current_indices, + previous_indices, + logical_start, + ) in shared_windows: + append_entry( + entry_group=child_group, + parent_visible=False, + current_indices=current_indices, + previous_indices=previous_indices, + logical_start=logical_start, + ) + branch_usable = ((child_end_pos + 1) // ratio) * ratio + for logical_start in range(0, branch_usable, ratio): + if logical_start + ratio <= prompt_len: + continue + append_entry( + entry_group=child_group, + parent_visible=False, + current_indices=_logical_window_indices( + prompt_start=prompt_start, + prompt_len=prompt_len, + completion_start=child_start, + logical_start=logical_start, + ratio=ratio, + ), + previous_indices=_logical_window_indices( + prompt_start=prompt_start, + prompt_len=prompt_len, + completion_start=child_start, + logical_start=logical_start - ratio, + ratio=ratio, + ), + logical_start=logical_start, + ) + child_idx += 1 + seg_idx = child_idx + + current_rows.append(row_current) + previous_rows.append(row_previous) + group_rows.append(row_groups) + parent_visible_rows.append(row_parent_visible) + start_rows.append(row_starts) + end_rows.append(row_ends) + + max_entries = max((len(row) for row in current_rows), default=0) + current = torch.full((bsz, max_entries, ratio), -1, dtype=torch.long, device=device) + previous = torch.full_like(current, -1) + entry_groups = torch.full((bsz, max_entries), -1, dtype=torch.long, device=device) + parent_visible = torch.zeros((bsz, max_entries), dtype=torch.bool, device=device) + starts = torch.full_like(entry_groups, -1) + ends = torch.full_like(entry_groups, -1) + valid = torch.zeros((bsz, max_entries), dtype=torch.bool, device=device) + for b, row in enumerate(current_rows): + if not row: + continue + count = len(row) + current[b, :count] = torch.tensor(row, dtype=torch.long, device=device) + previous[b, :count] = torch.tensor( + previous_rows[b], dtype=torch.long, device=device + ) + entry_groups[b, :count] = torch.tensor( + group_rows[b], dtype=torch.long, device=device + ) + parent_visible[b, :count] = torch.tensor( + parent_visible_rows[b], dtype=torch.bool, device=device + ) + starts[b, :count] = torch.tensor(start_rows[b], dtype=torch.long, device=device) + ends[b, :count] = torch.tensor(end_rows[b], dtype=torch.long, device=device) + valid[b, :count] = True + return Dsv4CompressionLayout( + current, + previous, + entry_groups, + parent_visible, + starts, + ends, + valid, + ) + + +def compressed_layout_visibility( + layout: Dsv4CompressionLayout, + *, + position_ids: torch.Tensor, + group_ids: torch.Tensor, + parent_ids: torch.Tensor, + q_start: int = 0, + q_end: int | None = None, +) -> torch.Tensor: + if q_end is None: + q_end = position_ids.shape[1] + q_pos = position_ids[:, q_start:q_end].to(dtype=torch.long).unsqueeze(-1) + q_group = group_ids[:, q_start:q_end].to(dtype=torch.long).unsqueeze(-1) + q_parent = parent_ids[:, q_start:q_end].to(dtype=torch.long).unsqueeze(-1) + entry_group = layout.entry_group_ids.unsqueeze(1) + parent_visible = layout.entry_parent_visible.unsqueeze(1) + return ( + layout.entry_valid.unsqueeze(1) + & (layout.entry_end_positions.unsqueeze(1) <= q_pos) + & ((entry_group == q_group) | (parent_visible & (entry_group == q_parent))) + ) + + +def compressed_layout_topk_idxs( + layout: Dsv4CompressionLayout, + *, + position_ids: torch.Tensor, + group_ids: torch.Tensor, + parent_ids: torch.Tensor, + offset: int, +) -> torch.Tensor: + visibility = compressed_layout_visibility( + layout, + position_ids=position_ids, + group_ids=group_ids, + parent_ids=parent_ids, + ) + entry_ids = torch.arange( + layout.entry_group_ids.shape[1], + device=layout.entry_group_ids.device, + dtype=torch.long, + ).view(1, 1, -1) + return torch.where(visibility, entry_ids + offset, torch.full_like(entry_ids, -1)) + + +class RMSNorm(nn.Module): + """ + Kept in pure PyTorch with FP32 weights to match SGLang's compressor norm. + + Args: + dim: Dimension of the input tensor. + eps: Epsilon for numerical stability. Defaults to ``1e-6``. + """ + + def __init__(self, dim: int, eps: float = 1e-6): + super().__init__() + self.dim = dim + self.eps = eps + self.weight = nn.Parameter(torch.ones(dim, dtype=torch.float32)) + + def forward(self, x: torch.Tensor): + dtype = x.dtype + x = x.float() + var = x.square().mean(-1, keepdim=True) + x = x * torch.rsqrt(var + self.eps) + return (self.weight * x).to(dtype) + + +def _overlap_transform( + tensor: torch.Tensor, *, compress_ratio: int, head_dim: int, value=0 +) -> torch.Tensor: + """Overlap-transform for compress_ratio=4: for each token group of size ``ratio``, + split into (first_half, second_half) halves along ``head_dim`` and re-arrange + them across a doubled ratio axis (`2 * ratio`), shifting the first half by one + group so that adjacent groups overlap by ``ratio`` positions. + """ + b, s, _, _ = tensor.size() + new_tensor = tensor.new_full((b, s, 2 * compress_ratio, head_dim), value) + new_tensor[:, :, compress_ratio:] = tensor[:, :, :, head_dim:] + new_tensor[:, 1:, :compress_ratio] = tensor[:, :-1, :, :head_dim] + return new_tensor + + +def _add_lora_if_present( + owner: nn.Module, + attr_name: str, + base: torch.Tensor, + x: torch.Tensor, +) -> torch.Tensor: + lora = getattr(owner, attr_name, None) + if lora is None: + return base + return base + lora(x) + + +def _gather_projected_tokens( + tensor: torch.Tensor, indices: torch.Tensor +) -> torch.Tensor: + bsz, seqlen, channels = tensor.shape + if indices.numel() == 0: + return tensor.new_empty((*indices.shape, channels)) + batch_offsets = ( + torch.arange(bsz, device=tensor.device, dtype=torch.long).view(bsz, 1, 1) + * seqlen + ) + safe_indices = indices.clamp(0, max(seqlen - 1, 0)) + flat_indices = (safe_indices + batch_offsets).reshape(-1) + gathered = tensor.reshape(bsz * seqlen, channels).index_select(0, flat_indices) + return gathered.view(*indices.shape, channels) + + +class DeepSeekV4Compressor(nn.Module): + def __init__( + self, + config: TransformerConfig, + head_dim: int, + compress_ratio: int, + rotate: bool, + cp_group: Any | None = None, + ): + super().__init__() + + cfg = cast(Any, config) + dim = config.hidden_size + rope_head_dim = int(cfg.qk_pos_emb_head_dim) + norm_eps = config.layernorm_epsilon + + assert head_dim in {128, 512} + assert rope_head_dim == 64 + assert compress_ratio in {4, 128} + assert norm_eps == 1e-6 + + self.config = config + self.dim = dim + self.head_dim = head_dim + self.rope_head_dim = rope_head_dim + self.nope_head_dim = head_dim - rope_head_dim + self.compress_ratio = compress_ratio + self.overlap = compress_ratio == 4 + self.rotate = rotate + coff = 1 + self.overlap + + self.cp_group = cp_group + self.cp_size = cp_group.size() if cp_group is not None else 1 + self.cp_rank = cp_group.rank() if cp_group is not None else 0 + + self.ape = nn.Parameter( + torch.empty(compress_ratio, coff * self.head_dim, dtype=torch.float32) + ) + weight_dtype = config.params_dtype + if weight_dtype not in {torch.bfloat16, torch.float32}: + raise TypeError( + f"DeepSeek-V4 compressor requires bf16/fp32 params, got {weight_dtype}" + ) + self.wkv = Linear( + self.dim, coff * self.head_dim, bias=False, dtype=weight_dtype + ) + self.wgate = Linear( + self.dim, coff * self.head_dim, bias=False, dtype=weight_dtype + ) + self.norm = RMSNorm(self.head_dim, norm_eps) + + self._keep_fp32_parameters = ("ape",) + setattr(self.ape, "_keep_fp32", True) + + base = cfg.dsv4_compress_rope_theta + assert rope_head_dim == 64 + assert base == 160000 + configure_rope_cache(self, config, rope_head_dim=rope_head_dim, base=base) + + @property + def weight(self) -> torch.Tensor: + return self.ape + + def overlap_transform_raw(self, tensor: torch.Tensor, value=0): + """Raw overlap transform without CP handling.""" + return _overlap_transform( + tensor, + compress_ratio=self.compress_ratio, + head_dim=self.head_dim, + value=value, + ) + + def overlap_transform_with_cp(self, tensor: torch.Tensor, value=0) -> torch.Tensor: + """ + Overlap transform with CP support. + + Args: + tensor: [bsz, G_local, ratio, coff*d] + value: Fill value for overlap transform (0 for kv, -inf for score) + + Returns: + [bsz, G_local, ratio, coff*d] + """ + if self.cp_size != 1: + raise RuntimeError( + "DeepSeek-V4 non-CP compressor received context_parallel_size > 1." + ) + return self.overlap_transform_raw(tensor, value) + + def _project_raw(self, x: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]: + if self.ape.dtype != torch.float32: + raise TypeError( + f"DeepSeek-V4 compressor APE must stay fp32, got {self.ape.dtype}." + ) + if self.wkv.weight.dtype not in {torch.bfloat16, torch.float32}: + raise TypeError( + "DeepSeek-V4 compressor KV projection requires bf16/fp32 weights, got " + f"{self.wkv.weight.dtype}." + ) + if self.wgate.weight.dtype not in {torch.bfloat16, torch.float32}: + raise TypeError( + "DeepSeek-V4 compressor gate projection requires bf16/fp32 weights, " + f"got {self.wgate.weight.dtype}." + ) + + kv = _add_lora_if_present( + self, "kv_proj_lora", linear_bf16_fp32(x, self.wkv.weight), x + ) + score = _add_lora_if_present( + self, "gate_proj_lora", linear_bf16_fp32(x, self.wgate.weight), x + ) + return kv, score + + def _compress_projected( + self, + kv: torch.Tensor, + score: torch.Tensor, + *, + freqs_cis: torch.Tensor, + ) -> torch.Tensor: + bsz, seqlen_local, _ = kv.size() + ratio, overlap, _ = self.compress_ratio, self.overlap, self.head_dim + dtype = kv.dtype + + usable = (seqlen_local // ratio) * ratio + if usable == 0: + return kv.new_zeros((bsz, 0, self.head_dim)) + kv = kv[:, :usable] + score = score[:, :usable] + if self.cp_size > 1: + assert usable % (ratio * 2) == 0 + + kv = kv.unflatten(1, (-1, ratio)) + score = score.unflatten(1, (-1, ratio)) + self.ape + + if overlap: + kv = self.overlap_transform_with_cp(kv, 0) + score = self.overlap_transform_with_cp(score, float("-inf")) + + score_softmax = score.softmax(dim=2, dtype=torch.float32).to(kv.dtype) + kv = (kv * score_softmax).sum(dim=2) + + kv = self.norm(kv.to(dtype)) + + apply_rotary_emb(kv[..., -self.rope_head_dim :], freqs_cis) + + if self.rotate: + kv = rotate_activation(kv) + + return kv + + def _compress_shared_prefix_projected( + self, + kv: torch.Tensor, + score: torch.Tensor, + *, + layout: Dsv4CompressionLayout, + ) -> torch.Tensor: + dtype = kv.dtype + ratio = self.compress_ratio + current_valid = layout.current_indices >= 0 + current_kv = _gather_projected_tokens(kv, layout.current_indices) + current_score = _gather_projected_tokens(score, layout.current_indices) + current_kv = torch.where( + current_valid.unsqueeze(-1), current_kv, torch.zeros_like(current_kv) + ) + current_score = torch.where( + current_valid.unsqueeze(-1), + current_score, + torch.full_like(current_score, float("-inf")), + ) + if self.overlap: + previous_valid = layout.previous_indices >= 0 + previous_kv = _gather_projected_tokens(kv, layout.previous_indices) + previous_score = _gather_projected_tokens(score, layout.previous_indices) + previous_kv = torch.where( + previous_valid.unsqueeze(-1), + previous_kv, + torch.zeros_like(previous_kv), + ) + previous_score = torch.where( + previous_valid.unsqueeze(-1), + previous_score, + torch.full_like(previous_score, float("-inf")), + ) + current_score = current_score + self.ape.view(1, 1, ratio, -1) + previous_score = previous_score + self.ape.view(1, 1, ratio, -1) + slots_kv = torch.cat( + [previous_kv[..., : self.head_dim], current_kv[..., self.head_dim :]], + dim=2, + ) + slots_score = torch.cat( + [ + previous_score[..., : self.head_dim], + current_score[..., self.head_dim :], + ], + dim=2, + ) + else: + slots_kv = current_kv + slots_score = current_score + self.ape.view(1, 1, ratio, -1) + + slots_score = torch.where( + layout.entry_valid[:, :, None, None], + slots_score, + torch.zeros_like(slots_score), + ) + score_softmax = slots_score.softmax(dim=2, dtype=torch.float32).to( + slots_kv.dtype + ) + compressed = (slots_kv * score_softmax).sum(dim=2) + compressed = self.norm(compressed.to(dtype)) + freqs_cis = get_rope_cache_at_positions( + self, position_ids=layout.entry_start_positions, device=kv.device + ) + apply_rotary_emb(compressed[..., -self.rope_head_dim :], freqs_cis) + compressed = torch.where( + layout.entry_valid.unsqueeze(-1), + compressed, + torch.zeros_like(compressed), + ) + + if self.rotate: + compressed = rotate_activation(compressed) + return compressed + + def forward_raw( + self, + x: torch.Tensor, + *, + position_ids: torch.Tensor | None = None, + shared_layout: Dsv4CompressionLayout | None = None, + ) -> torch.Tensor: + kv, score = self._project_raw(x) + if shared_layout is not None: + return self._compress_shared_prefix_projected( + kv, score, layout=shared_layout + ) + usable = (x.shape[1] // self.compress_ratio) * self.compress_ratio + freqs_cis = get_rope_cache(self, seqlen=usable, device=x.device)[ + : usable : self.compress_ratio + ] + if position_ids is not None: + freqs_cis = get_rope_cache_at_positions( + self, + position_ids=position_ids[:, : usable : self.compress_ratio], + device=x.device, + ) + return self._compress_projected(kv, score, freqs_cis=freqs_cis) + + def forward( + self, + x: torch.Tensor, + *, + position_ids: torch.Tensor | None = None, + shared_layout: Dsv4CompressionLayout | None = None, + ) -> torch.Tensor: + """ + Args: + x: [seqlen, batch, dim] SBHD layout (Megatron standard) + Returns: + k: [floor(seqlen / compress_ratio), batch, head_dim] SBHD layout + """ + x_bshd = einops.rearrange(x, "s b d -> b s d") + k_bshd = self.forward_raw( + x_bshd, + position_ids=position_ids, + shared_layout=shared_layout, + ) + k = einops.rearrange(k_bshd, "b sc d -> sc b d") + return k diff --git a/src/art/megatron/dsv4/deepseek_v4.py b/src/art/megatron/dsv4/deepseek_v4.py new file mode 100644 index 000000000..85d9d313a --- /dev/null +++ b/src/art/megatron/dsv4/deepseek_v4.py @@ -0,0 +1,679 @@ +import copy +from typing import Any, cast + +import einops +import torch +import torch.nn as nn + +# Enable TF32 for fp32 matmul to match the precision of the TileKernels MHC +# kernels (which use TF32 tensor-core GEMM for the HC fp32 mixer). Without +# this, PyTorch's default ``allow_tf32=False`` keeps fp32 ``F.linear`` on the +# SIMT path, which introduces a ~1e-4 mean-abs gap vs the TileKernels output; +# matching TF32 brings the gap to <=1.5e-5 mean-abs (1 ULP bf16 max-abs). +torch.backends.cuda.matmul.allow_tf32 = True +torch.backends.cudnn.allow_tf32 = True +from megatron.core.dist_checkpointing.mapping import ShardedStateDict +from megatron.core.extensions.transformer_engine import ( + TEColumnParallelLinear, + TELinear, + TENorm, + TERowParallelLinear, +) +from megatron.core.process_groups_config import ProcessGroupCollection +from megatron.core.tensor_parallel.layers import ColumnParallelLinear +from megatron.core.tensor_parallel.mappings import ( + copy_to_tensor_model_parallel_region, + gather_from_sequence_parallel_region, + scatter_to_sequence_parallel_region, +) +from megatron.core.transformer.module import MegatronModule +from megatron.core.transformer.transformer_config import TransformerConfig +from megatron.core.transformer.utils import make_sharded_tensors_for_checkpoint + +from art.megatron.dsv4.compressor import ( + DeepSeekV4Compressor, + Dsv4CompressionLayout, + Dsv4SharedPrefixState, + compressed_layout_topk_idxs, +) +from art.megatron.dsv4.kernel.tilelang_sparse_mla import sparse_attn_tilelang +from art.megatron.dsv4.rope import ( + apply_rotary_emb, + configure_rope_cache, + get_rope_cache, + get_rope_cache_at_positions, +) +from art.megatron.dsv4.v4_indexer import V4Indexer + + +def _window_topk_idxs( + q_positions: torch.Tensor, *, window_size: int, bsz: int +) -> torch.Tensor: + base = q_positions.unsqueeze(1) + offsets = torch.arange( + min(q_positions.numel(), window_size), device=q_positions.device + ) + k_pos = (base - window_size + 1).clamp(0) + offsets + topk = torch.where(k_pos > base, -1, k_pos) + return topk.unsqueeze(0).expand(bsz, -1, -1).to(torch.int32) + + +def _compress_topk_idxs( + q_positions: torch.Tensor, *, ratio: int, bsz: int +) -> torch.Tensor: + seqlen = int(q_positions.numel()) + offset = seqlen + k_group_idx = torch.arange(seqlen // ratio, device=q_positions.device).repeat( + seqlen, 1 + ) + q_first_invalid_group = (q_positions + 1).unsqueeze(1) // ratio + compress = torch.where( + k_group_idx >= q_first_invalid_group, -1, k_group_idx + offset + ) + return compress.unsqueeze(0).expand(bsz, -1, -1).to(torch.int32) + + +def _shared_prefix_tensors( + attention_bias: Any, + *, + bsz: int, + seqlen: int, + device: torch.device, +) -> tuple[torch.Tensor, torch.Tensor] | None: + group_ids = getattr(attention_bias, "group_ids", None) + parent_ids = getattr(attention_bias, "parent_ids", None) + if group_ids is None or parent_ids is None: + return None + group_ids = group_ids.to(device=device, dtype=torch.long) + parent_ids = parent_ids.to(device=device, dtype=torch.long) + if group_ids.shape != (bsz, seqlen) or parent_ids.shape != (bsz, seqlen): + raise ValueError( + "DSV4 shared-prefix metadata must match attention input shape: " + f"group_ids={tuple(group_ids.shape)} parent_ids={tuple(parent_ids.shape)} " + f"expected={(bsz, seqlen)}." + ) + return group_ids, parent_ids + + +def _shared_prefix_window_topk_idxs( + position_ids: torch.Tensor, + group_ids: torch.Tensor, + parent_ids: torch.Tensor, + *, + window_size: int, +) -> torch.Tensor: + bsz, seqlen = group_ids.shape + width = min(seqlen, window_size) + arange = torch.arange(seqlen, device=group_ids.device).expand(bsz, -1) + valid_token = (group_ids != -1) & (parent_ids != -1) + group_start = valid_token.clone() + group_start[:, 1:] &= group_ids[:, 1:] != group_ids[:, :-1] + start_values = torch.where(group_start, arange, torch.zeros_like(arange)) + group_start_idx = torch.cummax(start_values, dim=1).values + prompt_start = group_start & (group_ids == parent_ids) + prompt_values = torch.where(prompt_start, arange, torch.zeros_like(arange)) + prompt_start_idx = torch.cummax(prompt_values, dim=1).values + + q_pos = position_ids.to(device=group_ids.device, dtype=torch.long) + group_start_pos = q_pos.gather(1, group_start_idx) + offsets = torch.arange(width, device=group_ids.device) + target_pos = q_pos.unsqueeze(-1) - width + 1 + offsets + from_group = target_pos >= group_start_pos.unsqueeze(-1) + group_k = group_start_idx.unsqueeze(-1) + target_pos - group_start_pos.unsqueeze(-1) + prompt_k = prompt_start_idx.unsqueeze(-1) + target_pos + k_idx = torch.where(from_group, group_k, prompt_k) + valid = ( + valid_token.unsqueeze(-1) + & (target_pos >= 0) + & (target_pos <= q_pos.unsqueeze(-1)) + & (k_idx >= 0) + & (k_idx < seqlen) + ) + return torch.where(valid, k_idx, torch.full_like(k_idx, -1)).to(torch.int32) + + +def _dsv4_shared_prefix_state(attention_bias: Any) -> Dsv4SharedPrefixState: + model_state = getattr(attention_bias, "model_state", None) + state = model_state.get("dsv4") if isinstance(model_state, dict) else None + if not isinstance(state, Dsv4SharedPrefixState): + raise RuntimeError( + "DSV4 shared-prefix state is missing. Build it once per packed " + "sequence through the model-support shared-prefix state hook." + ) + return state + + +def _dsv4_topk_cache(attention_bias: Any) -> dict[Any, Any]: + return _dsv4_shared_prefix_state(attention_bias).topk_idx_cache + + +def _shared_prefix_window_topk_idxs_cached( + attention_bias: Any, + position_ids: torch.Tensor, + group_ids: torch.Tensor, + parent_ids: torch.Tensor, + *, + window_size: int, +) -> torch.Tensor: + cache = _dsv4_topk_cache(attention_bias) + key = ("raw_swa", int(window_size)) + cached = cache.get(key) + if cached is None: + cached = _shared_prefix_window_topk_idxs( + position_ids, + group_ids, + parent_ids, + window_size=window_size, + ).contiguous() + cache[key] = cached + return cached + + +def _shared_prefix_compressed_topk_idxs_cached( + attention_bias: Any, + layout: Dsv4CompressionLayout, + *, + position_ids: torch.Tensor, + group_ids: torch.Tensor, + parent_ids: torch.Tensor, + ratio: int, + offset: int, +) -> torch.Tensor: + cache = _dsv4_topk_cache(attention_bias) + key = ("compressed", int(ratio), int(offset)) + cached = cache.get(key) + if cached is None: + cached = ( + compressed_layout_topk_idxs( + layout, + position_ids=position_ids, + group_ids=group_ids, + parent_ids=parent_ids, + offset=offset, + ) + .to(torch.int32) + .contiguous() + ) + cache[key] = cached + return cached + + +def _shared_prefix_i32_metadata_cached( + attention_bias: Any, + position_ids: torch.Tensor, + group_ids: torch.Tensor, + parent_ids: torch.Tensor, +) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]: + cache = _dsv4_topk_cache(attention_bias) + key = "indexer_q_metadata_i32" + cached = cache.get(key) + if cached is None: + cached = ( + position_ids.to(torch.int32).contiguous(), + group_ids.to(torch.int32).contiguous(), + parent_ids.to(torch.int32).contiguous(), + ) + cache[key] = cached + return cached + + +def _shared_layout_indexer_metadata_cached( + attention_bias: Any, + layout: Dsv4CompressionLayout, + *, + ratio: int, +) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor]: + cache = _dsv4_topk_cache(attention_bias) + key = ("indexer_layout_i32", int(ratio)) + cached = cache.get(key) + if cached is None: + cached = ( + layout.entry_group_ids.to(torch.int32).contiguous(), + layout.entry_parent_visible.to(torch.int32).contiguous(), + layout.entry_end_positions.to(torch.int32).contiguous(), + layout.entry_valid.to(torch.int32).contiguous(), + ) + cache[key] = cached + return cached + + +def _shared_prefix_compression_layout( + attention_bias: Any, + *, + ratio: int, +) -> Dsv4CompressionLayout: + layouts = _dsv4_shared_prefix_state(attention_bias).compression_layouts + if ratio not in layouts: + raise RuntimeError( + "DSV4 shared-prefix compression layout was not prepared on the " + f"attention state for ratio={ratio}. Build it once per packed " + "sequence through the model-support shared-prefix state hook." + ) + layout = layouts[ratio] + if not isinstance(layout, Dsv4CompressionLayout): + raise TypeError(f"Expected Dsv4CompressionLayout for ratio={ratio}.") + return layout + + +def _add_lora_if_present( + owner: nn.Module, + attr_name: str, + base: torch.Tensor, + x: torch.Tensor, +) -> torch.Tensor: + lora = getattr(owner, attr_name, None) + if lora is None: + return base + return base + lora(x) + + +class DeepSeekV4Attention(MegatronModule): + def __init__( + self, + config: TransformerConfig, + submodules=None, + layer_number: int = 1, + attn_mask_type=None, + attention_type: str | None = None, + cp_comm_type: str | None = None, + pg_collection=None, + ): + super().__init__(config=config) + cfg = cast(Any, config) + init_method = config.init_method + if init_method is None: + raise RuntimeError("DeepSeek-V4 attention requires config.init_method.") + + if pg_collection is None: + pg_collection = ProcessGroupCollection.use_mpu_process_groups( + required_pgs=["tp"] + ) + else: + assert hasattr(pg_collection, "tp") + self.pg_collection = pg_collection + self.tp_group = self.pg_collection.tp + self.cp_group = pg_collection.cp if hasattr(pg_collection, "cp") else None + self.cp_size = self.cp_group.size() if self.cp_group else 1 + if self.cp_size != 1: + raise RuntimeError( + "DeepSeek-V4 non-CP attention received context_parallel_size > 1." + ) + + layer_id = layer_number - 1 + del layer_number + + self.layer_id = layer_id + self.dim = config.hidden_size + self.n_heads = config.num_attention_heads + self.n_local_heads = self.n_heads // config.tensor_model_parallel_size + self.q_lora_rank = int(cfg.q_lora_rank) + self.o_lora_rank = int(cfg.dsv4_o_lora_rank) + self.head_dim = int(cfg.kv_lora_rank) + self.rope_head_dim = int(cfg.qk_pos_emb_head_dim) + self.nope_head_dim = self.head_dim - self.rope_head_dim + self.n_groups = int(cfg.dsv4_o_groups) + self.n_local_groups = self.n_groups // config.tensor_model_parallel_size + self.window_size = int(cfg.dsv4_window_size) + compress_ratios = cfg.dsv4_compress_ratios + self.compress_ratio = int(compress_ratios[layer_id]) if compress_ratios else 0 + self.eps = config.layernorm_epsilon + + assert self.o_lora_rank == 1024 + assert self.head_dim == 512 + assert self.rope_head_dim == 64 + assert self.nope_head_dim == 448 + assert self.window_size == 128 + + config_no_sp = copy.copy(config) + config_no_sp.sequence_parallel = False + + attn_sink = torch.empty(self.n_local_heads, dtype=torch.float32) + self._keep_fp32_parameters = ("attn_sink",) + self._keep_fp32_buffers = ("attn_sink",) + self.attn_sink = nn.Parameter(attn_sink) + setattr(self.attn_sink, "_keep_fp32", True) + + self.wq_a = TELinear( + self.dim, + self.q_lora_rank, + config=config, + init_method=init_method, + bias=False, + skip_bias_add=False, + skip_weight_param_allocation=False, + parallel_mode="duplicated", + ) + self.q_norm = TENorm(config_no_sp, self.q_lora_rank, eps=self.eps) + self.wq_b = TEColumnParallelLinear( + self.q_lora_rank, + self.n_heads * self.head_dim, + config=config_no_sp, + init_method=init_method, + bias=False, + gather_output=False, + skip_bias_add=False, + is_expert=False, + tp_group=self.tp_group, + ) + self.wkv = TELinear( + self.dim, + self.head_dim, + config=config, + init_method=init_method, + bias=False, + skip_bias_add=False, + skip_weight_param_allocation=False, + parallel_mode="duplicated", + ) + self.kv_norm = TENorm(config_no_sp, self.head_dim, eps=self.eps) + + for p in list(self.wq_a.parameters()) + list(self.wkv.parameters()): + setattr(p, "sequence_parallel", False) + + self.wo_a = ColumnParallelLinear( + self.n_heads * self.head_dim // self.n_groups, + self.n_groups * self.o_lora_rank, + config=config_no_sp, + init_method=init_method, + bias=False, + gather_output=False, + ) + self.wo_b = TERowParallelLinear( + self.n_groups * self.o_lora_rank, + self.dim, + config=config_no_sp, + init_method=init_method, + bias=False, + input_is_parallel=True, + skip_bias_add=False, + is_expert=False, + tp_group=self.tp_group, + ) + self.softmax_scale = self.head_dim**-0.5 + self.sequence_parallel = config.sequence_parallel + + if self.compress_ratio: + self.compressor = DeepSeekV4Compressor( + config=config, + head_dim=self.head_dim, + compress_ratio=self.compress_ratio, + rotate=False, + cp_group=self.cp_group, + ) + if self.compress_ratio == 4: + self.indexer = V4Indexer(config=config, pg_collection=pg_collection) + else: + self.indexer = None + + rope_base = ( + cfg.dsv4_compress_rope_theta if self.compress_ratio else cfg.rotary_base + ) + configure_rope_cache( + self, + config, + rope_head_dim=self.rope_head_dim, + base=rope_base, + ) + self._dsv4_position_ids: torch.Tensor | None = None + + def set_position_ids(self, position_ids: torch.Tensor | None) -> None: + self._dsv4_position_ids = position_ids + + def sharded_state_dict( + self, + prefix: str = "", + sharded_offsets: tuple = (), + metadata: dict | None = None, + ) -> ShardedStateDict: + ans = super().sharded_state_dict(prefix, sharded_offsets, metadata) + ans.update( + make_sharded_tensors_for_checkpoint( + state_dict={"attn_sink": self.attn_sink}, + prefix=prefix, + tensor_parallel_layers_axis_map={"attn_sink": 0}, + sharded_offsets=sharded_offsets, + tp_group=self.tp_group, + dp_cp_group=(metadata or {})["dp_cp_group"], + ) + ) + return ans + + @torch.compiler.disable + def forward( + self, + hidden_states: torch.Tensor, + attention_mask=None, + inference_context=None, + rotary_pos_emb=None, + rotary_pos_cos=None, + rotary_pos_sin=None, + rotary_pos_cos_sin=None, + attention_bias=None, + packed_seq_params=None, + sequence_len_offset=None, + ) -> torch.Tensor: + """Run DSV4 attention eager inside compiled transformer layers. + + Torch 2.11 currently miscompiles this TP+SP autograd graph: the + attention module output receives a nonzero gradient, but the + zero-initialized LoRA branches inside the DSV4 attention path get zero + tangents. Keeping only this model-specific attention forward eager + preserves compiled surrounding layer code and correct first-step LoRA + gradients. + """ + if self.sequence_parallel: + hidden_states = gather_from_sequence_parallel_region( + hidden_states, + tensor_parallel_output_grad=False, + group=self.tp_group, + ) + + x = einops.rearrange(hidden_states, "s b d -> b s d") + + bsz, seqlen_local, _ = x.size() + position_ids = self._dsv4_position_ids + if position_ids is not None: + if position_ids.shape != (bsz, seqlen_local): + raise ValueError( + "DSV4 position_ids must match attention input shape: " + f"position_ids={tuple(position_ids.shape)} expected={(bsz, seqlen_local)}." + ) + freqs_cis = get_rope_cache_at_positions( + self, position_ids=position_ids, device=x.device + ) + else: + freqs_cis = get_rope_cache(self, seqlen=seqlen_local, device=x.device) + win = self.window_size + ratio = self.compress_ratio + rd = self.rope_head_dim + shared_prefix = _shared_prefix_tensors( + attention_bias, bsz=bsz, seqlen=seqlen_local, device=x.device + ) + shared_layout: Dsv4CompressionLayout | None = None + if ( + self.compress_ratio + and shared_prefix is not None + and position_ids is not None + ): + shared_layout = _shared_prefix_compression_layout( + attention_bias, + ratio=ratio, + ) + + q_after_wq_a = _add_lora_if_present(self, "wq_a_lora", self.wq_a(x)[0], x) + qr = q = cast(Any, self.q_norm)(q_after_wq_a) + q_after_wq_b = _add_lora_if_present(self, "wq_b_lora", self.wq_b(q)[0], q) + q = q_after_wq_b.unflatten(-1, (self.n_local_heads, self.head_dim)) + q_fp32 = q.float() + q = ( + q_fp32 * torch.rsqrt(q_fp32.square().mean(-1, keepdim=True) + self.eps) + ).to(q.dtype) + q = q.clone() + apply_rotary_emb(q[..., -rd:], freqs_cis) + + kv_after_wkv = _add_lora_if_present(self, "wkv_lora", self.wkv(x)[0], x) + kv_vanilla = cast(Any, self.kv_norm)(kv_after_wkv) + kv_vanilla = kv_vanilla.clone() + apply_rotary_emb(kv_vanilla[..., -rd:], freqs_cis) + + seqlen_global = seqlen_local + q_positions = torch.arange(seqlen_local, device=x.device) + + if shared_prefix is not None and position_ids is not None: + group_ids, parent_ids = shared_prefix + topk_idxs = _shared_prefix_window_topk_idxs_cached( + attention_bias, + position_ids, + group_ids, + parent_ids, + window_size=win, + ) + else: + topk_idxs = _window_topk_idxs(q_positions, window_size=win, bsz=bsz) + + if self.compress_ratio: + kv_compress_offset = seqlen_global + if self.indexer is not None: + x_sbd = einops.rearrange(x, "b s d -> s b d") + qr_sbd = einops.rearrange(qr, "b s d -> s b d") + if self.sequence_parallel: + x_sbd = scatter_to_sequence_parallel_region( + x_sbd, group=self.tp_group + ) + qr_sbd = scatter_to_sequence_parallel_region( + qr_sbd, group=self.tp_group + ) + if isinstance(self.indexer, V4Indexer): + group_ids = parent_ids = None + shared_layout_i32 = None + index_position_ids = position_ids + if shared_prefix is not None: + group_ids, parent_ids = shared_prefix + if position_ids is not None and shared_layout is not None: + index_position_ids, group_ids, parent_ids = ( + _shared_prefix_i32_metadata_cached( + attention_bias, + position_ids, + group_ids, + parent_ids, + ) + ) + shared_layout_i32 = _shared_layout_indexer_metadata_cached( + attention_bias, + shared_layout, + ratio=ratio, + ) + compress_topk_idxs = self.indexer( + x_sbd, + qr_sbd, + position_ids=index_position_ids, + shared_layout=shared_layout, + group_ids=group_ids, + parent_ids=parent_ids, + shared_layout_i32=shared_layout_i32, + ) + else: + indexer_mask = self._compute_indexer_mask( + q_positions=q_positions, seqlen_global=seqlen_global + ) + compress_topk_idxs = self.indexer( + x_sbd, qr_sbd, mask=indexer_mask, packed_seq_params=None + ) + if shared_layout is None: + q_first_invalid_group = (q_positions + 1).unsqueeze(1) // ratio + topk_idx_mask = (compress_topk_idxs >= q_first_invalid_group) | ( + compress_topk_idxs < 0 + ) + compress_topk_idxs = torch.where( + topk_idx_mask, -1, compress_topk_idxs + kv_compress_offset + ) + else: + compress_topk_idxs = torch.where( + compress_topk_idxs < 0, + -1, + compress_topk_idxs + kv_compress_offset, + ) + else: + if ( + shared_layout is None + or shared_prefix is None + or position_ids is None + ): + compress_topk_idxs = _compress_topk_idxs( + q_positions, ratio=ratio, bsz=bsz + ) + else: + group_ids, parent_ids = shared_prefix + compress_topk_idxs = _shared_prefix_compressed_topk_idxs_cached( + attention_bias, + shared_layout, + position_ids=position_ids, + group_ids=group_ids, + parent_ids=parent_ids, + ratio=ratio, + offset=kv_compress_offset, + ) + topk_idxs = torch.cat([topk_idxs, compress_topk_idxs], dim=-1) + topk_idxs = topk_idxs.to(torch.int32) + + kv_compress = None + if self.compress_ratio: + x_sbd = einops.rearrange(x, "b s d -> s b d") + kv_compress_sbd = self.compressor( + x_sbd, + position_ids=position_ids, + shared_layout=shared_layout, + ) + if kv_compress_sbd is not None: + kv_compress = einops.rearrange(kv_compress_sbd, "s b d -> b s d") + + if self.attn_sink.dtype != torch.float32: + raise TypeError( + "DeepSeek-V4 attention sink must stay fp32, got " + f"{self.attn_sink.dtype}." + ) + + if kv_compress is not None: + kv = torch.cat([kv_vanilla, kv_compress], dim=1) + if kv_compress_offset != kv_vanilla.size(1): + raise RuntimeError( + "DeepSeek-V4 compressed KV offset must equal raw KV length, got " + f"{kv_compress_offset} and {kv_vanilla.size(1)}." + ) + else: + kv = kv_vanilla + + kv = copy_to_tensor_model_parallel_region(kv, group=self.tp_group) + + o = sparse_attn_tilelang(q, kv, self.attn_sink, topk_idxs, self.softmax_scale) + + apply_rotary_emb(o[..., -rd:], freqs_cis, inverse=True) + + o = o.view(bsz, seqlen_local, self.n_local_groups, -1) + wo_a_input = o + wo_a = cast(torch.Tensor, self.wo_a.weight).view( + self.n_local_groups, self.o_lora_rank, -1 + ) + o = torch.einsum("bsgd,grd->bsgr", o, wo_a) + o = _add_lora_if_present(self, "wo_a_lora", o, wo_a_input) + wo_b_input = o.flatten(2) + x, _ = self.wo_b(wo_b_input) + x = _add_lora_if_present(self, "wo_b_lora", x, wo_b_input) + + output = einops.rearrange(x, "b s d -> s b d") + + if self.sequence_parallel: + output = scatter_to_sequence_parallel_region(output, group=self.tp_group) + + return output + + def _compute_indexer_mask( + self, *, q_positions: torch.Tensor, seqlen_global: int + ) -> torch.Tensor: + """Dense causal mask for legacy DSAIndexer path.""" + ratio = 4 + device = q_positions.device + k_group_idx = torch.arange(seqlen_global // ratio, device=device).unsqueeze(0) + q_first_invalid_group = (q_positions.unsqueeze(1) + 1) // ratio + invalid_mask = k_group_idx >= q_first_invalid_group + return torch.where(invalid_mask, float("-inf"), 0.0) diff --git a/src/art/megatron/dsv4/dequant.py b/src/art/megatron/dsv4/dequant.py new file mode 100644 index 000000000..992928a4e --- /dev/null +++ b/src/art/megatron/dsv4/dequant.py @@ -0,0 +1,222 @@ +from __future__ import annotations + +import torch +import triton +import triton.language as tl + +_DSV4_FP4_TABLE = ( + 0.0, + 0.5, + 1.0, + 1.5, + 2.0, + 3.0, + 4.0, + 6.0, + 0.0, + -0.5, + -1.0, + -1.5, + -2.0, + -3.0, + -4.0, + -6.0, +) +_TABLE_CACHE: dict[int, torch.Tensor] = {} + + +@triton.jit +def _mxfp4_dequant_kernel( + weight_ptr, + scale_ptr, + table_ptr, + out_ptr, + total: tl.constexpr, + in_dim: tl.constexpr, + in_bytes: tl.constexpr, + scale_cols: tl.constexpr, + block: tl.constexpr, +): + offsets = tl.program_id(0) * block + tl.arange(0, block) + mask = offsets < total + col = offsets % in_dim + row = offsets // in_dim + packed = tl.load(weight_ptr + row * in_bytes + col // 2, mask=mask, other=0) + nibble = tl.where((col & 1) == 0, packed & 0x0F, (packed >> 4) & 0x0F) + fp4 = tl.load(table_ptr + nibble) + raw_scale = tl.load(scale_ptr + row * scale_cols + col // 32, mask=mask, other=127) + scale = tl.where( + raw_scale == 255, + float("nan"), + tl.exp2(raw_scale.to(tl.float32) - 127.0), + ) + tl.store(out_ptr + offsets, (fp4 * scale).to(tl.bfloat16), mask=mask) + + +def _fp4_table(device: torch.device) -> torch.Tensor: + index = torch.device(device).index + if index is None: + index = torch.cuda.current_device() + table = _TABLE_CACHE.get(index) + if table is None: + table = torch.tensor(_DSV4_FP4_TABLE, dtype=torch.float32, device=device) + _TABLE_CACHE[index] = table + return table + + +def dequant_mxfp4_cuda(weight: torch.Tensor, scale: torch.Tensor) -> torch.Tensor: + device = ( + weight.device + if weight.device.type == "cuda" + else torch.device("cuda", torch.cuda.current_device()) + ) + weight = weight.contiguous().to(device=device, non_blocking=True).view(torch.uint8) + scale = scale.contiguous().to(device=device, non_blocking=True).view(torch.uint8) + out_dim, in_bytes = weight.shape + in_dim = in_bytes * 2 + out = torch.empty((out_dim, in_dim), dtype=torch.bfloat16, device=device) + block = 256 + _mxfp4_dequant_kernel[(triton.cdiv(out.numel(), block),)]( + weight, + scale, + _fp4_table(device), + out, + out.numel(), # ty: ignore[invalid-argument-type] + in_dim, # ty: ignore[invalid-argument-type] + in_bytes, # ty: ignore[invalid-argument-type] + scale.shape[1], # ty: ignore[invalid-argument-type] + block, # ty: ignore[invalid-argument-type] + num_warps=4, # ty: ignore[unknown-argument] + ) + return out + + +@triton.jit +def _block_fp8_dequant_kernel( + weight_ptr, + scale_ptr, + out_ptr, + total: tl.constexpr, + n_cols: tl.constexpr, + scale_cols: tl.constexpr, + block_elems: tl.constexpr, +): + offsets = tl.program_id(0) * block_elems + tl.arange(0, block_elems) + mask = offsets < total + col = offsets % n_cols + row = offsets // n_cols + value = tl.load(weight_ptr + offsets, mask=mask, other=0.0).to(tl.float32) + raw_scale = tl.load( + scale_ptr + (row // 128) * scale_cols + col // 128, + mask=mask, + other=127, + ) + scale = tl.where( + raw_scale == 255, + float("nan"), + tl.exp2(raw_scale.to(tl.float32) - 127.0), + ) + tl.store(out_ptr + offsets, (value * scale).to(tl.bfloat16), mask=mask) + + +def dequant_block_fp8_cuda(weight: torch.Tensor, scale: torch.Tensor) -> torch.Tensor: + device = ( + weight.device + if weight.device.type == "cuda" + else torch.device("cuda", torch.cuda.current_device()) + ) + weight = weight.contiguous().to(device=device, non_blocking=True) + scale = scale.contiguous().to(device=device, non_blocking=True).view(torch.uint8) + out = torch.empty_like(weight, dtype=torch.bfloat16) + block_elems = 256 + _block_fp8_dequant_kernel[(triton.cdiv(out.numel(), block_elems),)]( + weight, + scale, + out, + out.numel(), # ty: ignore[invalid-argument-type] + weight.shape[1], # ty: ignore[invalid-argument-type] + scale.shape[1], # ty: ignore[invalid-argument-type] + block_elems, # ty: ignore[invalid-argument-type] + num_warps=4, # ty: ignore[unknown-argument] + ) + return out + + +@triton.jit +def _mxfp4_quant_kernel( + weight_ptr, + packed_ptr, + scale_ptr, + total_blocks: tl.constexpr, + cols: tl.constexpr, + blocks_per_row: tl.constexpr, +): + block_id = tl.program_id(0) + pair_offsets = tl.arange(0, 16) + row = block_id // blocks_per_row + block_col = block_id - row * blocks_per_row + even = tl.load(weight_ptr + row * cols + block_col * 32 + pair_offsets * 2).to( + tl.float32 + ) + odd = tl.load(weight_ptr + row * cols + block_col * 32 + pair_offsets * 2 + 1).to( + tl.float32 + ) + amax = tl.maximum( + tl.maximum(tl.max(tl.abs(even), axis=0), tl.max(tl.abs(odd), axis=0)), + 1.0e-4, + ) + exponent = tl.ceil(tl.log2(amax / 6.0)) + raw_scale = (exponent + 127.0).to(tl.uint8) + scale = tl.exp2(exponent) + scaled_even = even / scale + scaled_odd = odd / scale + abs_even = tl.abs(scaled_even) + abs_odd = tl.abs(scaled_odd) + code_even = tl.zeros((16,), dtype=tl.uint8) + code_odd = tl.zeros((16,), dtype=tl.uint8) + code_even += (abs_even > 0.25).to(tl.uint8) + code_even += (abs_even > 0.75).to(tl.uint8) + code_even += (abs_even > 1.25).to(tl.uint8) + code_even += (abs_even > 1.75).to(tl.uint8) + code_even += (abs_even > 2.5).to(tl.uint8) + code_even += (abs_even > 3.5).to(tl.uint8) + code_even += (abs_even > 5.0).to(tl.uint8) + code_even += ((scaled_even < 0.0) & (code_even != 0)).to(tl.uint8) * 8 + code_odd += (abs_odd > 0.25).to(tl.uint8) + code_odd += (abs_odd > 0.75).to(tl.uint8) + code_odd += (abs_odd > 1.25).to(tl.uint8) + code_odd += (abs_odd > 1.75).to(tl.uint8) + code_odd += (abs_odd > 2.5).to(tl.uint8) + code_odd += (abs_odd > 3.5).to(tl.uint8) + code_odd += (abs_odd > 5.0).to(tl.uint8) + code_odd += ((scaled_odd < 0.0) & (code_odd != 0)).to(tl.uint8) * 8 + tl.store( + packed_ptr + row * (cols // 2) + block_col * 16 + pair_offsets, + code_even | (code_odd << 4), + ) + tl.store(scale_ptr + block_id, raw_scale, mask=block_id < total_blocks) + + +def quant_mxfp4_cuda(weight: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]: + if weight.ndim != 2 or weight.shape[1] % 32 != 0: + raise ValueError( + f"Expected 2-D MXFP4 weight with K % 32 == 0, got {weight.shape}." + ) + if weight.device.type != "cuda": + raise ValueError("quant_mxfp4_cuda expects a CUDA tensor") + weight = weight.contiguous() + rows, cols = weight.shape + packed = torch.empty((rows, cols // 2), dtype=torch.uint8, device=weight.device) + scale_raw = torch.empty((rows, cols // 32), dtype=torch.uint8, device=weight.device) + blocks_per_row = cols // 32 + total_blocks = rows * blocks_per_row + _mxfp4_quant_kernel[(total_blocks,)]( + weight, + packed, + scale_raw, + total_blocks, # ty: ignore[invalid-argument-type] + cols, # ty: ignore[invalid-argument-type] + blocks_per_row, # ty: ignore[invalid-argument-type] + num_warps=1, # ty: ignore[unknown-argument] + ) + return packed, scale_raw.view(torch.float8_e8m0fnu) diff --git a/src/art/megatron/dsv4/encoding.py b/src/art/megatron/dsv4/encoding.py new file mode 100644 index 000000000..6895771e2 --- /dev/null +++ b/src/art/megatron/dsv4/encoding.py @@ -0,0 +1,757 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +# ruff: noqa +# fmt: off + +""" +DeepSeek-V4 Encoding + +A self-contained implementation for encoding/decoding DeepSeek-V4 chat messages +with tool calling, thinking mode, and quick instruction task support. +""" + +from typing import Any, Dict, List, Union, Optional, Tuple +import copy +import json + +import regex as re + +# ============================================================ +# Special Tokens +# ============================================================ + +bos_token: str = "<|begin▁of▁sentence|>" +eos_token: str = "<|end▁of▁sentence|>" +thinking_start_token: str = "" +thinking_end_token: str = "" +dsml_token: str = "|DSML|" + +USER_SP_TOKEN = "<|User|>" +ASSISTANT_SP_TOKEN = "<|Assistant|>" +LATEST_REMINDER_SP_TOKEN = "<|latest_reminder|>" + +# Task special tokens for internal classification tasks +DS_TASK_SP_TOKENS = { + "action": "<|action|>", + "query": "<|query|>", + "authority": "<|authority|>", + "domain": "<|domain|>", + "title": "<|title|>", + "read_url": "<|read_url|>", +} +VALID_TASKS = set(DS_TASK_SP_TOKENS.keys()) + +# ============================================================ +# Templates +# ============================================================ + +system_msg_template: str = "{content}" +user_msg_template: str = "{content}" +latest_reminder_msg_template: str = "{content}" +assistant_msg_template: str = "{reasoning}{content}{tool_calls}" + eos_token +assistant_msg_wo_eos_template: str = "{reasoning}{content}{tool_calls}" +thinking_template: str = "{reasoning}" + +response_format_template: str = ( + "## Response Format:\n\nYou MUST strictly adhere to the following schema to reply:\n{schema}" +) +tool_call_template: str = ( + "<{dsml_token}invoke name=\"{name}\">\n{arguments}\n" +) +tool_calls_template = ( + "<{dsml_token}{tc_block_name}>\n{tool_calls}\n" +) +tool_calls_block_name: str = "tool_calls" + +tool_output_template: str = ( + "{content}" +) + +REASONING_EFFORT_MAX = ( + "Reasoning Effort: Absolute maximum with no shortcuts permitted.\n" + "You MUST be very thorough in your thinking and comprehensively decompose the problem to resolve the root cause, rigorously stress-testing your logic against all potential paths, edge cases, and adversarial scenarios.\n" + "Explicitly write out your entire deliberation process, documenting every intermediate step, considered alternative, and rejected hypothesis to ensure absolutely no assumption is left unchecked.\n\n" +) + +TOOLS_TEMPLATE = """## Tools + +You have access to a set of tools to help answer the user's question. You can invoke tools by writing a "<{dsml_token}tool_calls>" block like the following: + +<{dsml_token}tool_calls> +<{dsml_token}invoke name="$TOOL_NAME"> +<{dsml_token}parameter name="$PARAMETER_NAME" string="true|false">$PARAMETER_VALUE +... + +<{dsml_token}invoke name="$TOOL_NAME2"> +... + + + +String parameters should be specified as is and set `string="true"`. For all other types (numbers, booleans, arrays, objects), pass the value in JSON format and set `string="false"`. + +If thinking_mode is enabled (triggered by {thinking_start_token}), you MUST output your complete reasoning inside {thinking_start_token}...{thinking_end_token} BEFORE any tool calls or final response. + +Otherwise, output directly after {thinking_end_token} with tool calls or final response. + +### Available Tool Schemas + +{tool_schemas} + +You MUST strictly follow the above defined tool name and parameter schemas to invoke tool calls. +""" + +# ============================================================ +# Utility Functions +# ============================================================ + +def to_json(value: Any) -> str: + """Serialize a value to JSON string.""" + try: + return json.dumps(value, ensure_ascii=False) + except Exception: + return json.dumps(value, ensure_ascii=True) + + +def tools_from_openai_format(tools): + """Extract function definitions from OpenAI-format tool list.""" + return [tool["function"] for tool in tools] + + +def tool_calls_from_openai_format(tool_calls): + """Convert OpenAI-format tool calls to internal format.""" + return [ + { + "name": tool_call["function"]["name"], + "arguments": tool_call["function"]["arguments"], + } + for tool_call in tool_calls + ] + + +def tool_calls_to_openai_format(tool_calls): + """Convert internal tool calls to OpenAI format.""" + return [ + { + "type": "function", + "function": { + "name": tool_call["name"], + "arguments": tool_call["arguments"], + } + } + for tool_call in tool_calls + ] + + +def encode_arguments_to_dsml(tool_call: Dict[str, Any]) -> str: + """ + Encode tool call arguments into DSML parameter format. + + Args: + tool_call: Dict with "name" and "arguments" keys. + + Returns: + DSML-formatted parameter string. + """ + p_dsml_template = '<{dsml_token}parameter name="{key}" string="{is_str}">{value}' + P_dsml_strs = [] + + if isinstance(tool_call["arguments"], str): + arguments = json.loads(tool_call["arguments"]) + else: + arguments = tool_call["arguments"] + + for k, v in arguments.items(): + p_dsml_str = p_dsml_template.format( + dsml_token=dsml_token, + key=k, + is_str="true" if isinstance(v, str) else "false", + value=v if isinstance(v, str) else to_json(v), + ) + P_dsml_strs.append(p_dsml_str) + + return "\n".join(P_dsml_strs) + + +def decode_dsml_to_arguments(tool_name: str, tool_args: Dict[str, Tuple[str, str]]) -> Dict[str, str]: + """ + Decode DSML parameters back to a tool call dict. + + Args: + tool_name: Name of the tool. + tool_args: Dict mapping param_name -> (value, is_string_flag). + + Returns: + Dict with "name" and "arguments" (JSON string) keys. + """ + def _decode_value(key: str, value: str, string: str): + if string == "true": + value = to_json(value) + return f"{to_json(key)}: {value}" + + tool_args_json = "{" + ", ".join([_decode_value(k, v, string=is_str) for k, (v, is_str) in tool_args.items()]) + "}" + return dict(name=tool_name, arguments=tool_args_json) + + +def render_tools(tools: List[Dict[str, Union[str, Dict[str, Any]]]]) -> str: + """ + Render tool schemas into the system prompt format. + + Args: + tools: List of tool schema dicts (each with name, description, parameters). + + Returns: + Formatted tools section string. + """ + tools_json = [to_json(t) for t in tools] + + return TOOLS_TEMPLATE.format( + tool_schemas="\n".join(tools_json), + dsml_token=dsml_token, + thinking_start_token=thinking_start_token, + thinking_end_token=thinking_end_token, + ) + + +def find_last_user_index(messages: List[Dict[str, Any]]) -> int: + """Find the index of the last user/developer message.""" + last_user_index = -1 + for idx in range(len(messages) - 1, -1, -1): + if messages[idx].get("role") in ["user", "developer"]: + last_user_index = idx + break + return last_user_index + + +# ============================================================ +# Message Rendering +# ============================================================ + +def render_message(index: int, messages: List[Dict[str, Any]], thinking_mode: str, drop_thinking: bool = True, reasoning_effort: Optional[str] = None) -> str: + """ + Render a single message at the given index into its encoded string form. + + This is the core function that converts each message in the conversation + into the DeepSeek-V4 format. + + Args: + index: Index of the message to render. + messages: Full list of messages in the conversation. + thinking_mode: Either "chat" or "thinking". + drop_thinking: Whether to drop reasoning content from earlier turns. + reasoning_effort: Optional reasoning effort level ("max", "high", or None). + + Returns: + Encoded string for this message. + """ + assert 0 <= index < len(messages) + assert thinking_mode in ["chat", "thinking"], f"Invalid thinking_mode `{thinking_mode}`" + + prompt = "" + msg = messages[index] + last_user_idx = find_last_user_index(messages) + + role = msg.get("role") + content = msg.get("content") + tools = msg.get("tools") + response_format = msg.get("response_format") + tool_calls = msg.get("tool_calls") + reasoning = msg.get("reasoning") + wo_eos = msg.get("wo_eos", False) + + if tools: + tools = tools_from_openai_format(tools) + if tool_calls: + tool_calls = tool_calls_from_openai_format(tool_calls) + + # Reasoning effort prefix (only at index 0 in thinking mode with max effort) + assert reasoning_effort in ['max', None, 'high'], f"Invalid reasoning effort: {reasoning_effort}" + if index == 0 and thinking_mode == "thinking" and reasoning_effort == 'max': + prompt += REASONING_EFFORT_MAX + + if role == "system": + prompt += system_msg_template.format(content=content or "") + if tools: + prompt += "\n\n" + render_tools(tools) + if response_format: + prompt += "\n\n" + response_format_template.format(schema=to_json(response_format)) + + elif role == "developer": + assert content, f"Invalid message for role `{role}`: {msg}" + + content_developer = USER_SP_TOKEN + content_developer += content + + if tools: + content_developer += "\n\n" + render_tools(tools) + if response_format: + content_developer += "\n\n" + response_format_template.format(schema=to_json(response_format)) + + prompt += user_msg_template.format(content=content_developer) + + elif role == "user": + prompt += USER_SP_TOKEN + + # Handle content blocks (tool results mixed with text) + content_blocks = msg.get("content_blocks") + if content_blocks: + parts = [] + for block in content_blocks: + block_type = block.get("type") + if block_type == "text": + parts.append(block.get("text", "")) + elif block_type == "tool_result": + tool_content = block.get("content", "") + if isinstance(tool_content, list): + text_parts = [] + for b in tool_content: + if b.get("type") == "text": + text_parts.append(b.get("text", "")) + else: + text_parts.append(f"[Unsupported {b.get('type')}]") + tool_content = "\n\n".join(text_parts) + parts.append(tool_output_template.format(content=tool_content)) + else: + parts.append(f"[Unsupported {block_type}]") + prompt += "\n\n".join(parts) + else: + prompt += content or "" + + elif role == "latest_reminder": + prompt += LATEST_REMINDER_SP_TOKEN + latest_reminder_msg_template.format(content=content) + + elif role == "tool": + raise NotImplementedError("deepseek_v4 merges tool messages into user; please preprocess with merge_tool_messages()") + + elif role == "assistant": + thinking_part = "" + tc_content = "" + + if tool_calls: + tc_list = [ + tool_call_template.format( + dsml_token=dsml_token, + name=tc.get("name"), + arguments=encode_arguments_to_dsml(tc) + ) + for tc in tool_calls + ] + tc_content += '\n\n' + tool_calls_template.format( + dsml_token=dsml_token, + tool_calls="\n".join(tc_list), + tc_block_name=tool_calls_block_name, + ) + + summary_content = content or "" + reasoning = reasoning or "" + + # Check if previous message has a task - if so, this is a task output (no thinking) + prev_has_task = index - 1 >= 0 and messages[index - 1].get("task") is not None + + if thinking_mode == "thinking" and not prev_has_task: + if not drop_thinking or index > last_user_idx: + thinking_part = thinking_template.format(reasoning=reasoning) + thinking_end_token + else: + thinking_part = "" + + if wo_eos: + prompt += assistant_msg_wo_eos_template.format( + reasoning=thinking_part, + content=summary_content, + tool_calls=tc_content, + ) + else: + prompt += assistant_msg_template.format( + reasoning=thinking_part, + content=summary_content, + tool_calls=tc_content, + ) + else: + raise NotImplementedError(f"Unknown role: {role}") + + # Append transition tokens based on what follows + if index + 1 < len(messages) and messages[index + 1].get("role") not in ["assistant", "latest_reminder"]: + return prompt + + task = messages[index].get("task") + if task is not None: + # Task special token for internal classification tasks + assert task in VALID_TASKS, f"Invalid task: '{task}'. Valid tasks are: {list(VALID_TASKS)}" + task_sp_token = DS_TASK_SP_TOKENS[task] + + if task != "action": + # Non-action tasks: append task sp token directly after the message + prompt += task_sp_token + else: + # Action task: append Assistant + thinking token + action sp token + prompt += ASSISTANT_SP_TOKEN + prompt += thinking_end_token if thinking_mode != "thinking" else thinking_start_token + prompt += task_sp_token + + elif messages[index].get("role") in ["user", "developer"]: + # Normal generation: append Assistant + thinking token + prompt += ASSISTANT_SP_TOKEN + if not drop_thinking and thinking_mode == "thinking": + prompt += thinking_start_token + elif drop_thinking and thinking_mode == "thinking" and index >= last_user_idx: + prompt += thinking_start_token + else: + prompt += thinking_end_token + + return prompt + + +# ============================================================ +# Preprocessing +# ============================================================ + +def merge_tool_messages(messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + """ + Merge tool messages into the preceding user message using content_blocks format. + + DeepSeek-V4 does not have a standalone "tool" role; instead, tool results + are encoded as blocks within user messages. + + This function converts a standard OpenAI-format conversation (with separate + "tool" role messages) into V4 format where tool results are merged into + user messages. + + Args: + messages: List of message dicts in OpenAI format. + + Returns: + Processed message list with tool messages merged into user messages. + """ + merged: List[Dict[str, Any]] = [] + + for msg in messages: + msg = copy.deepcopy(msg) + role = msg.get("role") + + if role == "tool": + # Convert tool message to a user message with tool_result block + tool_block = { + "type": "tool_result", + "tool_use_id": msg.get("tool_call_id", ""), + "content": msg.get("content", ""), + } + # Merge into previous message if it's already a user (merged tool) + if merged and merged[-1].get("role") == "user" and "content_blocks" in merged[-1]: + merged[-1]["content_blocks"].append(tool_block) + else: + merged.append({ + "role": "user", + "content_blocks": [tool_block], + }) + elif role == "user": + text_block = {"type": "text", "text": msg.get("content", "")} + if merged and merged[-1].get("role") == "user" and "content_blocks" in merged[-1] and merged[-1].get("task") is None: + merged[-1]["content_blocks"].append(text_block) + else: + new_msg = { + "role": "user", + "content": msg.get("content", ""), + "content_blocks": [text_block], + } + # Preserve extra fields (task, wo_eos, mask, etc.) + for key in ("task", "wo_eos", "mask"): + if key in msg: + new_msg[key] = msg[key] + merged.append(new_msg) + else: + merged.append(msg) + + return merged + + +def sort_tool_results_by_call_order(messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + """ + Sort tool_result blocks within user messages by the order of tool_calls + in the preceding assistant message. + + Args: + messages: Preprocessed message list (after merge_tool_messages). + + Returns: + Message list with sorted tool result blocks. + """ + last_tool_call_order: Dict[str, int] = {} + + for msg in messages: + role = msg.get("role") + if role == "assistant" and msg.get("tool_calls"): + last_tool_call_order = {} + for idx, tc in enumerate(msg["tool_calls"]): + tc_id = tc.get("id") or tc.get("function", {}).get("id", "") + if tc_id: + last_tool_call_order[tc_id] = idx + + elif role == "user" and msg.get("content_blocks"): + tool_blocks = [b for b in msg["content_blocks"] if b.get("type") == "tool_result"] + if len(tool_blocks) > 1 and last_tool_call_order: + sorted_blocks = sorted( + tool_blocks, + key=lambda b: last_tool_call_order.get(b.get("tool_use_id", ""), 0) + ) + sorted_idx = 0 + new_blocks = [] + for block in msg["content_blocks"]: + if block.get("type") == "tool_result": + new_blocks.append(sorted_blocks[sorted_idx]) + sorted_idx += 1 + else: + new_blocks.append(block) + msg["content_blocks"] = new_blocks + + return messages + + +# ============================================================ +# Main Encoding Function +# ============================================================ + +def encode_messages( + messages: List[Dict[str, Any]], + thinking_mode: str, + context: Optional[List[Dict[str, Any]]] = None, + drop_thinking: bool = True, + add_default_bos_token: bool = True, + reasoning_effort: Optional[str] = None, +) -> str: + """ + Encode a list of messages into the DeepSeek-V4 prompt format. + + This is the main entry point for encoding conversations. It handles: + - BOS token insertion + - Thinking mode with optional reasoning content dropping + - Tool message merging into user messages + - Multi-turn conversation context + + Args: + messages: List of message dicts to encode. + thinking_mode: Either "chat" or "thinking". + context: Optional preceding context messages (already encoded prefix). + drop_thinking: If True, drop reasoning from earlier assistant turns + (only keep reasoning for messages after the last user message). + add_default_bos_token: Whether to prepend BOS token at conversation start. + reasoning_effort: Optional reasoning effort level ("max", "high", or None). + + Returns: + The encoded prompt string. + """ + context = context if context else [] + + # Preprocess: merge tool messages and sort tool results + messages = merge_tool_messages(messages) + messages = sort_tool_results_by_call_order(context + messages)[len(context):] + if context: + context = merge_tool_messages(context) + context = sort_tool_results_by_call_order(context) + + full_messages = context + messages + + prompt = bos_token if add_default_bos_token and len(context) == 0 else "" + + # Resolve drop_thinking: if any message has tools defined, don't drop thinking + effective_drop_thinking = drop_thinking + if any(m.get("tools") for m in full_messages): + effective_drop_thinking = False + + if thinking_mode == "thinking" and effective_drop_thinking: + full_messages = _drop_thinking_messages(full_messages) + # After dropping, recalculate how many messages to render + # (context may have shrunk too) + num_to_render = len(full_messages) - len(_drop_thinking_messages(context)) + context_len = len(full_messages) - num_to_render + else: + num_to_render = len(messages) + context_len = len(context) + + for idx in range(num_to_render): + prompt += render_message( + idx + context_len, + full_messages, + thinking_mode=thinking_mode, + drop_thinking=effective_drop_thinking, + reasoning_effort=reasoning_effort, + ) + + return prompt + + +def _drop_thinking_messages(messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + """ + Drop reasoning and non-essential messages before the last user message. + + Behavior: + - Messages with role in ["user", "system", "tool", "latest_reminder"] are always kept. + - Messages at or after the last user index are always kept. + - Assistant messages before the last user get reasoning removed. + - Developer messages before the last user are dropped entirely. + """ + last_user_idx = find_last_user_index(messages) + result = [] + keep_roles = {"user", "system", "tool", "latest_reminder", "direct_search_results"} + + for idx, msg in enumerate(messages): + role = msg.get("role") + if role in keep_roles or idx >= last_user_idx: + result.append(msg) + elif role == "assistant": + msg = copy.copy(msg) + msg.pop("reasoning", None) + result.append(msg) + # developer and other roles before last_user_idx are dropped + + return result + + +# ============================================================ +# Parsing (Decoding model output) +# ============================================================ + +def _read_until_stop(index: int, text: str, stop: List[str]) -> Tuple[int, str, Optional[str]]: + """ + Read text from index until one of the stop strings is found. + + Returns: + Tuple of (new_index, content_before_stop, matched_stop_string_or_None). + """ + min_pos = len(text) + matched_stop = None + + for s in stop: + pos = text.find(s, index) + if pos != -1 and pos < min_pos: + min_pos = pos + matched_stop = s + + if matched_stop: + content = text[index:min_pos] + return min_pos + len(matched_stop), content, matched_stop + else: + content = text[index:] + return len(text), content, None + + +def parse_tool_calls(index: int, text: str) -> Tuple[int, Optional[str], List[Dict[str, str]]]: + """ + Parse DSML tool calls from text starting at the given index. + + Args: + index: Starting position in text. + text: The full text to parse. + + Returns: + Tuple of (new_index, last_stop_token, list_of_tool_call_dicts). + Each tool call dict has "name" and "arguments" keys. + """ + tool_calls: List[Dict[str, Any]] = [] + stop_token = None + tool_calls_end_token = f"" + + while index < len(text): + index, content_before, stop_token = _read_until_stop(index, text, [f"<{dsml_token}invoke", tool_calls_end_token]) + if content_before != ">\n": + raise ValueError(f"Tool call format error: expected '>\\n' but got '{content_before}'") + + if stop_token == tool_calls_end_token: + break + + if stop_token is None: + raise ValueError("Missing special token in tool calls") + + index, tool_name_content, stop_token = _read_until_stop(index, text, [f"<{dsml_token}parameter", f"\n$', tool_name_content, flags=re.DOTALL) + if len(p_tool_name) != 1: + raise ValueError(f"Tool name format error: '{tool_name_content}'") + tool_name = p_tool_name[0] + + tool_args: Dict[str, Tuple[str, str]] = {} + while stop_token == f"<{dsml_token}parameter": + index, param_content, stop_token = _read_until_stop(index, text, [f"/{dsml_token}parameter"]) + + param_kv = re.findall(r'^ name="(.*?)" string="(true|false)">(.*?)<$', param_content, flags=re.DOTALL) + if len(param_kv) != 1: + raise ValueError(f"Parameter format error: '{param_content}'") + param_name, string, param_value = param_kv[0] + + if param_name in tool_args: + raise ValueError(f"Duplicate parameter name: '{param_name}'") + tool_args[param_name] = (param_value, string) + + index, content, stop_token = _read_until_stop(index, text, [f"<{dsml_token}parameter", f"\n": + raise ValueError(f"Parameter format error: expected '>\\n' but got '{content}'") + + tool_call = decode_dsml_to_arguments(tool_name=tool_name, tool_args=tool_args) + tool_calls.append(tool_call) + + return index, stop_token, tool_calls + + +def parse_message_from_completion_text(text: str, thinking_mode: str) -> Dict[str, Any]: + """ + Parse a model completion text into a structured assistant message. + + This function takes the raw text output from the model (a single assistant turn) + and extracts: + - reasoning (thinking block) + - content (summary/response) + - tool_calls (if any) + + NOTE: This function is designed to parse only correctly formatted strings and + will raise ValueError for malformed output. + + Args: + text: The raw completion text (including EOS token). + thinking_mode: Either "chat" or "thinking". + + Returns: + Dict with keys: "role", "content", "reasoning", "tool_calls". + tool_calls are in OpenAI format. + """ + summary_content, reasoning = "", "" + tool_calls: List[Dict[str, str]] = [] + index, stop_token = 0, None + tool_calls_start_token = f"\n\n<{dsml_token}{tool_calls_block_name}" + + is_thinking = thinking_mode == "thinking" + is_tool_calling = False + + if is_thinking: + index, content_delta, stop_token = _read_until_stop(index, text, [thinking_end_token, tool_calls_start_token]) + reasoning = content_delta + if stop_token != thinking_end_token: + raise ValueError("Invalid thinking format: missing ") + + index, content_delta, stop_token = _read_until_stop(index, text, [eos_token, tool_calls_start_token]) + summary_content = content_delta + if stop_token == tool_calls_start_token: + is_tool_calling = True + else: + if stop_token != eos_token: + raise ValueError("Invalid format: missing EOS token") + + if is_tool_calling: + index, stop_token, tool_calls = parse_tool_calls(index, text) + + index, tool_ends_text, stop_token = _read_until_stop(index, text, [eos_token]) + if tool_ends_text: + raise ValueError("Unexpected content after tool calls") + + if len(text) != index or stop_token not in [eos_token, None]: + raise ValueError("Unexpected content at end") + + for sp_token in [bos_token, eos_token, thinking_start_token, thinking_end_token, dsml_token]: + if sp_token in summary_content or sp_token in reasoning: + raise ValueError(f"Unexpected special token '{sp_token}' in content") + + return { + "role": "assistant", + "content": summary_content, + "reasoning": reasoning, + "tool_calls": tool_calls_to_openai_format(tool_calls) + } + +# fmt: on diff --git a/src/art/megatron/dsv4/hf_config.py b/src/art/megatron/dsv4/hf_config.py new file mode 100644 index 000000000..75f6a0b63 --- /dev/null +++ b/src/art/megatron/dsv4/hf_config.py @@ -0,0 +1,103 @@ +from __future__ import annotations + +import sys +from typing import Any + +import transformers + +_TORCHVISION_LIB: Any | None = None + + +class DeepseekV4ForCausalLM: + """Bridge-dispatch marker used before native HF modeling is imported.""" + + def __init__(self, *args: Any, **kwargs: Any) -> None: + del args, kwargs + raise RuntimeError( + "DeepSeek-V4 requires native Transformers DeepseekV4ForCausalLM." + ) + + +def _add_marker_to_transformers_module(module: Any, model_class: type) -> None: + if module is None: + return + objects = getattr(module, "_objects", None) + if isinstance(objects, dict): + objects["DeepseekV4ForCausalLM"] = model_class + setattr(module, "DeepseekV4ForCausalLM", model_class) + + +def _ensure_transformers_marker(model_class: type | None = None) -> None: + model_class = model_class or DeepseekV4ForCausalLM + _add_marker_to_transformers_module(transformers, model_class) + auto_bridge = sys.modules.get("megatron.bridge.models.conversion.auto_bridge") + if auto_bridge is not None: + _add_marker_to_transformers_module( + getattr(auto_bridge, "transformers", None), model_class + ) + + +def _ensure_torchvision_nms_schema() -> None: + global _TORCHVISION_LIB + if _TORCHVISION_LIB is not None: + return + import torch + + try: + _TORCHVISION_LIB = torch.library.Library("torchvision", "DEF") + _TORCHVISION_LIB.define( + "nms(Tensor dets, Tensor scores, float iou_threshold) -> Tensor" + ) + except RuntimeError as exc: + if "Only a single TORCH_LIBRARY" not in str(exc) and "already" not in str(exc): + raise + _TORCHVISION_LIB = torch.library.Library("torchvision", "FRAGMENT") + try: + _TORCHVISION_LIB.define( + "nms(Tensor dets, Tensor scores, float iou_threshold) -> Tensor" + ) + except RuntimeError as define_exc: + if "already" not in str(define_exc): + raise + + +def _native_dsv4_config_class() -> type: + try: + from transformers.models.deepseek_v4.configuration_deepseek_v4 import ( + DeepseekV4Config, + ) + except ModuleNotFoundError as exc: + raise RuntimeError( + "DeepSeek-V4 requires transformers with native deepseek_v4 support." + ) from exc + return DeepseekV4Config + + +def _native_dsv4_model_class() -> type: + _ensure_torchvision_nms_schema() + try: + from transformers.models.deepseek_v4.modeling_deepseek_v4 import ( + DeepseekV4ForCausalLM as NativeDeepseekV4ForCausalLM, + ) + except ModuleNotFoundError as exc: + raise RuntimeError( + "DeepSeek-V4 requires transformers with native DeepseekV4ForCausalLM." + ) from exc + return NativeDeepseekV4ForCausalLM + + +def ensure_dsv4_hf_config_registered() -> None: + _native_dsv4_config_class() + _ensure_transformers_marker() + + +def ensure_dsv4_hf_model_registered() -> None: + _native_dsv4_config_class() + _ensure_transformers_marker(_native_dsv4_model_class()) + + +__all__ = [ + "DeepseekV4ForCausalLM", + "ensure_dsv4_hf_config_registered", + "ensure_dsv4_hf_model_registered", +] diff --git a/src/art/megatron/dsv4/hyper_connection.py b/src/art/megatron/dsv4/hyper_connection.py new file mode 100644 index 000000000..abe37397f --- /dev/null +++ b/src/art/megatron/dsv4/hyper_connection.py @@ -0,0 +1,156 @@ +from typing import Any, cast + +import einops +from megatron.core.transformer.module import MegatronModule +from megatron.core.transformer.transformer_config import TransformerConfig +import torch +from torch import Tensor +import torch.nn.functional as F + + +def _unweighted_rms_norm(x: Tensor, eps: float) -> Tensor: + return x * torch.rsqrt(x.float().square().mean(-1, keepdim=True) + eps).to(x.dtype) + + +class HCHeadParams(MegatronModule): + def __init__(self, config: TransformerConfig): + super().__init__(config) + cfg = cast(Any, config) + hc_mult = int(cfg.dsv4_hc_mult) + hc_dim = hc_mult * config.hidden_size + self.hc_head_fn = torch.nn.Parameter( + torch.empty(hc_mult, hc_dim, dtype=torch.float32) + ) + self.hc_head_base = torch.nn.Parameter( + torch.empty(hc_mult, dtype=torch.float32) + ) + self.hc_head_scale = torch.nn.Parameter(torch.empty(1, dtype=torch.float32)) + self._keep_fp32_parameters = ( + "hc_head_fn", + "hc_head_base", + "hc_head_scale", + ) + for param in (self.hc_head_fn, self.hc_head_base, self.hc_head_scale): + setattr(param, "_keep_fp32", True) + + def forward(self): + raise NotImplementedError + + +class DeepSeekV4HyperConnectionUtil: + """DeepSeek-V4 manifold-constrained hyper-connection math. + + This implements the HF reference equations directly in PyTorch. TileKernels + MHC currently requires a newer CUDA toolchain than this ART Megatron env + provides, so production training keeps the exact eager math here. + """ + + def __init__(self, config: TransformerConfig): + cfg = cast(Any, config) + self.norm_eps = config.layernorm_epsilon + self.hc_mult = int(cfg.dsv4_hc_mult) + self.hc_sinkhorn_iters = int(cfg.dsv4_hc_sinkhorn_iters) + self.hc_eps = float(cfg.dsv4_hc_eps) + + def hc_pre_raw( + self, + x: Tensor, + hc_fn: Tensor, + hc_scale: Tensor, + hc_base: Tensor, + ) -> tuple[Tensor, Tensor, Tensor]: + dtype = x.dtype + hc = self.hc_mult + flat = _unweighted_rms_norm(x.flatten(start_dim=2).float(), self.norm_eps) + pre_w, post_w, comb_w = F.linear(flat, hc_fn.float()).split( + [hc, hc, hc * hc], dim=-1 + ) + pre_b, post_b, comb_b = hc_base.float().split([hc, hc, hc * hc]) + pre_scale, post_scale, comb_scale = hc_scale.float().unbind(0) + + pre = torch.sigmoid(pre_w * pre_scale + pre_b) + self.hc_eps + post = 2 * torch.sigmoid(post_w * post_scale + post_b) + comb_logits = comb_w.view( + *comb_w.shape[:-1], hc, hc + ) * comb_scale + comb_b.view(hc, hc) + comb = torch.softmax(comb_logits, dim=-1) + self.hc_eps + comb = comb / (comb.sum(dim=-2, keepdim=True) + self.hc_eps) + for _ in range(self.hc_sinkhorn_iters - 1): + comb = comb / (comb.sum(dim=-1, keepdim=True) + self.hc_eps) + comb = comb / (comb.sum(dim=-2, keepdim=True) + self.hc_eps) + layer_input = (pre.unsqueeze(-1) * x).sum(dim=2).to(dtype) + return layer_input, post, comb + + def hc_post_raw( + self, + x: Tensor, + residual: Tensor, + post: Tensor, + comb: Tensor, + ) -> Tensor: + dtype = residual.dtype + return post.to(dtype).unsqueeze(-1) * x.unsqueeze(-2) + torch.matmul( + comb.to(dtype).transpose(-1, -2), residual + ) + + def hc_head_raw( + self, + x: Tensor, + hc_fn: Tensor, + hc_scale: Tensor, + hc_base: Tensor, + ) -> Tensor: + dtype = x.dtype + flat = _unweighted_rms_norm(x.flatten(start_dim=2).float(), self.norm_eps) + mixes = F.linear(flat, hc_fn.float()) + pre = ( + torch.sigmoid(mixes * hc_scale.float().reshape(1) + hc_base.float()) + + self.hc_eps + ) + return (pre.unsqueeze(-1) * x).sum(dim=2).to(dtype) + + def layer_pre( + self, + hidden_states: Tensor, + hc_fn: Tensor, + hc_scale: Tensor, + hc_base: Tensor, + ) -> tuple[Tensor, Tensor, Tensor]: + x = einops.rearrange(hidden_states, "s b hc d -> b s hc d") + x, post, comb = self.hc_pre_raw( + x=x, hc_fn=hc_fn, hc_scale=hc_scale, hc_base=hc_base + ) + return einops.rearrange(x, "b s d -> s b d"), post, comb + + def layer_post( + self, + output_with_bias: Tensor | tuple[Tensor, Tensor | None], + residual: Tensor, + post: Tensor, + comb: Tensor, + ) -> Tensor: + if isinstance(output_with_bias, tuple): + out, bias = output_with_bias + assert bias is None + else: + out = output_with_bias + out = einops.rearrange(out, "s b d -> b s d") + residual_bshd = einops.rearrange(residual, "s b hc d -> b s hc d") + hidden_states = self.hc_post_raw( + x=out, residual=residual_bshd, post=post, comb=comb + ) + return einops.rearrange(hidden_states, "b s hc d -> s b hc d") + + def block_expand(self, hidden_states: Tensor) -> Tensor: + return einops.repeat(hidden_states, "s b d -> s b hc d", hc=self.hc_mult) + + def block_head( + self, + hidden_states: Tensor, + hc_fn: Tensor, + hc_scale: Tensor, + hc_base: Tensor, + ) -> Tensor: + x = einops.rearrange(hidden_states, "s b hc d -> b s hc d") + x = self.hc_head_raw(x=x, hc_fn=hc_fn, hc_scale=hc_scale, hc_base=hc_base) + return einops.rearrange(x, "b s d -> s b d") diff --git a/src/art/megatron/dsv4/kernel/__init__.py b/src/art/megatron/dsv4/kernel/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/art/megatron/dsv4/kernel/precision_aligned_ops.py b/src/art/megatron/dsv4/kernel/precision_aligned_ops.py new file mode 100644 index 000000000..5cdb6d1c9 --- /dev/null +++ b/src/art/megatron/dsv4/kernel/precision_aligned_ops.py @@ -0,0 +1,46 @@ +from typing import Any + +import torch + + +class _BFloat16LinearFP32Func(torch.autograd.Function): + # Forward matches SGLang's default DeepSeek-V4 compressor path + # (`sglang.jit_kernel.deepseek_v4.linear_bf16_fp32`, cublas backend): + # BF16 activation x BF16 weight -> FP32 output. This keeps Megatron's + # compressor log-prob computation aligned with SGLang rollout. Backward is + # only needed for training, so keep its gradient matmuls in FP32. + @staticmethod + def forward(ctx, x: torch.Tensor, weight: torch.Tensor) -> torch.Tensor: + x_bf16 = x.to(torch.bfloat16) + weight_bf16 = weight.to(torch.bfloat16) + ctx.save_for_backward(x_bf16, weight_bf16) + ctx.input_shape = x.shape + ctx.input_dtype = x.dtype + ctx.weight_dtype = weight.dtype + + x_2d = x_bf16.reshape(-1, x_bf16.shape[-1]) + out = torch.mm(x_2d, weight_bf16.t(), out_dtype=torch.float32) + return out.view(*x.shape[:-1], weight_bf16.shape[0]) + + @staticmethod + def backward(ctx: Any, *grad_outputs: Any): + grad_output = grad_outputs[0] + x_bf16, weight_bf16 = ctx.saved_tensors + grad_output_2d = grad_output.reshape(-1, grad_output.shape[-1]).float() + + grad_x = None + if ctx.needs_input_grad[0]: + grad_x = grad_output_2d.matmul(weight_bf16.float()) + grad_x = grad_x.view(ctx.input_shape).to(ctx.input_dtype) + + grad_weight = None + if ctx.needs_input_grad[1]: + x_2d = x_bf16.reshape(-1, x_bf16.shape[-1]) + grad_weight = grad_output_2d.t().matmul(x_2d.float()).to(ctx.weight_dtype) + + return grad_x, grad_weight + + +@torch.compiler.disable +def linear_bf16_fp32(x: torch.Tensor, weight: torch.Tensor) -> torch.Tensor: + return _BFloat16LinearFP32Func.apply(x, weight) diff --git a/src/art/megatron/dsv4/kernel/tilelang_import.py b/src/art/megatron/dsv4/kernel/tilelang_import.py new file mode 100644 index 000000000..2f35faef1 --- /dev/null +++ b/src/art/megatron/dsv4/kernel/tilelang_import.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +from collections.abc import Iterator +from contextlib import contextmanager +import importlib +import os +from typing import Any + +_TILELANG_ENV_KEYS = ( + "PYTHONPATH", + "TVM_IMPORT_PYTHON_PATH", + "TVM_LIBRARY_PATH", + "TL_CUTLASS_PATH", + "TL_TEMPLATE_PATH", + "TL_COMPOSABLE_KERNEL_PATH", +) + +_TILELANG_PATH_MARKERS = ("/site-packages/tilelang/", "\\site-packages\\tilelang\\") + + +def _drop_tilelang_paths(value: str | None) -> str | None: + if value is None: + return None + kept = [ + part + for part in value.split(os.pathsep) + if not any(marker in part for marker in _TILELANG_PATH_MARKERS) + ] + return os.pathsep.join(kept) if kept else None + + +def sanitize_tilelang_env() -> None: + """Remove TileLang's vendored TVM paths from env inherited by child processes.""" + for key in _TILELANG_ENV_KEYS: + value = _drop_tilelang_paths(os.environ.get(key)) + if value is None: + os.environ.pop(key, None) + else: + os.environ[key] = value + + +def _restore_env(saved: dict[str, str | None]) -> None: + for key, value in saved.items(): + if value is None: + os.environ.pop(key, None) + else: + os.environ[key] = value + sanitize_tilelang_env() + + +@contextmanager +def preserve_tilelang_env() -> Iterator[None]: + saved = {key: os.environ.get(key) for key in _TILELANG_ENV_KEYS} + try: + yield + finally: + _restore_env(saved) + + +def import_tilelang() -> tuple[Any, Any]: + """Import TileLang without leaking its vendored TVM paths to child processes.""" + with preserve_tilelang_env(): + tilelang = importlib.import_module("tilelang") + language = importlib.import_module("tilelang.language") + return tilelang, language diff --git a/src/art/megatron/dsv4/kernel/tilelang_indexer.py b/src/art/megatron/dsv4/kernel/tilelang_indexer.py new file mode 100644 index 000000000..87018deba --- /dev/null +++ b/src/art/megatron/dsv4/kernel/tilelang_indexer.py @@ -0,0 +1,116 @@ +# ruff: noqa +"""TileLang-based DSA Indexer for DeepSeek-V4. + +Adapts GLM-5's lighting_indexer to V4's SBHD data layout and causal masking. +Provides both a low-level per-sample interface and a batched autograd Function. +""" + +from typing import Any + +import torch + +from art.megatron.dsv4.kernel.tilelang_import import preserve_tilelang_env + + +def pytorch_extract_topk_scores(logits, topk_indices, dim=-1): + valid_mask = topk_indices != -1 + safe_indices = topk_indices.clamp(min=0).to(torch.int64) + scores = torch.gather(logits, dim=dim, index=safe_indices) + scores = torch.where(valid_mask, scores, float("-inf")) + return scores + + +class V4IndexerFunction(torch.autograd.Function): + """Autograd function for V4 tilelang indexer. + + Inputs are in V4's native SBHD layout: + q: [seqlen, batch, heads, dim] bf16 + k: [seqlen_kv, batch, dim] bf16 + weights: [seqlen, batch, heads] fp32 + """ + + @staticmethod + def forward( + ctx, + index_q: torch.Tensor, + index_k: torch.Tensor, + weights: torch.Tensor, + compress_ratio: int, + topk: int, + topk_indices: torch.Tensor | None = None, + ): + with preserve_tilelang_env(): + from art.megatron.dsv4.kernel.tilelang_indexer_fwd import ( + _make_causal_cu_seqlens, + batched_indexer_fwd, + ) + + seqlen_q = index_q.shape[0] + seq_len_kv = index_k.shape[0] + + cu_seqlen_ks, cu_seqlen_ke = _make_causal_cu_seqlens( + seqlen_q, seq_len_kv, compress_ratio, index_q.device + ) + + # [batch, seqlen, seqlen_kv] + logits = batched_indexer_fwd( + index_q, index_k, weights, cu_seqlen_ks, cu_seqlen_ke + ) + + if topk_indices is None: + actual_topk = min(topk, seq_len_kv) + index_score, topk_indices = torch.topk(logits, actual_topk, dim=-1) + topk_indices = topk_indices.to(torch.int32) + topk_indices = topk_indices.masked_fill(index_score == -torch.inf, -1) + + index_score = pytorch_extract_topk_scores(logits, topk_indices) + + ctx.save_for_backward( + index_q, index_k, weights, cu_seqlen_ks, cu_seqlen_ke, topk_indices + ) + ctx.compress_ratio = compress_ratio + ctx.topk = topk + return index_score, topk_indices + + @staticmethod + def backward(ctx: Any, *grad_outputs: Any): + grad_scores = grad_outputs[0] + index_q, index_k, weights, cu_seqlen_ks, cu_seqlen_ke, topk_indices = ( + ctx.saved_tensors + ) + with preserve_tilelang_env(): + from art.megatron.dsv4.kernel.tilelang_indexer_bwd import ( + batched_indexer_bwd, + ) + + grad_q, grad_w, grad_k = batched_indexer_bwd( + index_q, weights, index_k, topk_indices, grad_scores + ) + return grad_q, grad_k, grad_w, None, None, None + + +def v4_lighting_indexer( + index_q: torch.Tensor, + index_k: torch.Tensor, + weights: torch.Tensor, + compress_ratio: int, + topk: int, + topk_indices: torch.Tensor | None = None, +): + """Main entry point for V4 tilelang indexer. + + Args: + index_q: [seqlen, batch, heads, dim] bf16 + index_k: [seqlen_kv, batch, dim] bf16 + weights: [seqlen, batch, heads] fp32 + compress_ratio: compression ratio (4 for C4 layers) + topk: number of top-k indices to select + topk_indices: optional pre-computed topk indices [batch, seqlen, topk] int32 + + Returns: + index_score: [batch, seqlen, topk] fp32 + topk_indices: [batch, seqlen, topk] int32 + """ + return V4IndexerFunction.apply( + index_q, index_k, weights, compress_ratio, topk, topk_indices + ) diff --git a/src/art/megatron/dsv4/kernel/tilelang_indexer_bwd.py b/src/art/megatron/dsv4/kernel/tilelang_indexer_bwd.py new file mode 100644 index 000000000..d17f17065 --- /dev/null +++ b/src/art/megatron/dsv4/kernel/tilelang_indexer_bwd.py @@ -0,0 +1,270 @@ +# ruff: noqa +# Adapted from miles_plugins/models/glm5/ops/tilelang_indexer_bwd.py for DeepSeek-V4. +from typing import Any, cast + +import torch + +from art.megatron.dsv4.kernel.tilelang_import import ( + import_tilelang, + preserve_tilelang_env, + sanitize_tilelang_env, +) + +_tl, _T = import_tilelang() + +tl = cast(Any, _tl) +T = cast(Any, _T) + +BF16 = T.bfloat16 +FP32 = T.float32 +INT32 = T.int32 + +pass_configs = { + tl.PassConfigKey.TL_DISABLE_TMA_LOWER: True, + tl.PassConfigKey.TL_DISABLE_WARP_SPECIALIZED: True, +} + + +@tl.jit(pass_configs=pass_configs) +def tl_indexer_bwd_impl( + heads: int, + dim: int, + topk: int, + block_I: int = 32, + num_stages: int = 0, + num_threads: int = 128, +): + assert num_stages == 0 + assert topk == tl.math.next_power_of_2(topk) + assert topk % block_I == 0 + assert heads <= 64 and heads % 8 == 0 + seq_len = T.symbolic("seq_len") + q_seq_len = T.symbolic("q_seq_len") + + dtype: str = BF16 + accum_dtype: str = FP32 + index_q_shape = [q_seq_len, heads, dim] + weights_shape = [q_seq_len, heads] + index_k_shape = [seq_len, dim] + shape_p = [q_seq_len, topk] + topk_indices_shape = [q_seq_len, topk] + + pad_heads = heads + if heads < 16: + pad_heads = 16 + + @T.prim_func + def tl_indexer_bwd_kernel( + IndexQ: T.Tensor(index_q_shape, dtype), # type: ignore + IndexK: T.Tensor(index_k_shape, dtype), # type: ignore + Weights: T.Tensor(weights_shape, FP32), # type: ignore + TopkIndices: T.Tensor(topk_indices_shape, INT32), # type: ignore + OGrad: T.Tensor(shape_p, FP32), # type: ignore + dIndexQ: T.Tensor(index_q_shape, dtype), # type: ignore + dWeights: T.Tensor(weights_shape, FP32), # type: ignore + dIndexK: T.Tensor(index_k_shape, FP32), # type: ignore + ): + + with T.Kernel(q_seq_len, threads=num_threads) as (bx): + index_q_shared = T.alloc_shared([pad_heads, dim], dtype=FP32) + weights_shared = T.alloc_shared([pad_heads], dtype=FP32) + index_k_shared = T.alloc_shared([block_I, dim], dtype=FP32) + indices_shared = T.alloc_shared([block_I], dtype=INT32) + d_index_q_frag = T.alloc_fragment([pad_heads, dim], dtype=accum_dtype) + d_weights_frag = T.alloc_fragment([pad_heads], dtype=accum_dtype) + d_index_k_frag = T.alloc_fragment([block_I, dim], dtype=accum_dtype) + logits = T.alloc_fragment((block_I, pad_heads), dtype=accum_dtype) + _logits = T.alloc_shared((block_I, pad_heads), dtype=accum_dtype) + grad = T.alloc_shared([block_I], dtype=FP32) + + num_blocks = T.ceildiv(topk, block_I) + for i, j in T.Parallel(pad_heads, dim): + index_q_shared[i, j] = T.if_then_else(i < heads, IndexQ[bx, i, j], 0) + for i in T.Parallel(heads): + weights_shared[i] = Weights[bx, i] + + T.fill(d_index_q_frag, 0) + T.fill(d_weights_frag, 0) + + for bi_i in T.serial(num_blocks): + for i in T.Parallel(block_I): + if bi_i * block_I + i < topk: + indices_shared[i] = TopkIndices[bx, bi_i * block_I + i] + grad[i] = OGrad[bx, bi_i * block_I + i] + + T.sync_threads() + for i, j in T.Parallel(block_I, dim): + index_k_shared[i, j] = T.if_then_else( + indices_shared[i] > -1 and indices_shared[i] < seq_len, + IndexK[indices_shared[i], j], + 0, + ) + + T.sync_threads() + T.gemm( + index_k_shared, + index_q_shared, + logits, + transpose_A=False, + transpose_B=True, + clear_accum=True, + ) + for i, j in T.Parallel(block_I, heads): + logits[i, j] = T.max(logits[i, j], 0) + + d_weights_i = T.alloc_fragment((block_I, pad_heads), accum_dtype) + for i, j in T.Parallel(block_I, heads): + d_weights_i[i, j] = grad[i] * logits[i, j] + T.reduce_sum(d_weights_i, d_weights_frag, dim=0, clear=False) + + for i, j in T.Parallel(block_I, pad_heads): + _logits[i, j] = T.if_then_else( + logits[i, j] > 0 and j < heads, grad[i] * weights_shared[j], 0 + ) + T.sync_threads() + T.gemm( + _logits, + index_k_shared, + d_index_q_frag, + transpose_A=True, + transpose_B=False, + clear_accum=False, + ) + + T.gemm( + _logits, + index_q_shared, + d_index_k_frag, + transpose_A=False, + transpose_B=False, + clear_accum=True, + ) + + for i, j in T.Parallel(block_I, dim): + if indices_shared[i] > -1 and indices_shared[i] < seq_len: + T.atomic_add( + dIndexK[indices_shared[i], j], d_index_k_frag[i, j] + ) + + T.copy(d_index_q_frag[:heads, :], dIndexQ[bx, :, :]) + T.copy(d_weights_frag[:heads], dWeights[bx, :]) + + return tl_indexer_bwd_kernel + + +sanitize_tilelang_env() + + +def indexer_bwd_interface( + index_q: torch.Tensor, + weights: torch.Tensor, + index_k: torch.Tensor, + topk_indices: torch.Tensor, + grad_scores: torch.Tensor, +): + """Backward interface for a single batch element. + + Args: + index_q: [seq_len, heads, dim] bf16 + weights: [seq_len, heads] fp32 + index_k: [seq_len_kv, dim] bf16 + topk_indices: [seq_len, topk] int32 + grad_scores: [seq_len, topk] fp32 + + Returns: + grad_q: [seq_len, heads, dim] bf16 + grad_w: [seq_len, heads] fp32 + grad_k: [seq_len_kv, dim] fp32 + """ + _, head_num, head_dim = index_q.shape + k_top = topk_indices.shape[1] + + grad_scores = grad_scores.contiguous() + grad_q = torch.empty_like(index_q) + grad_w = torch.empty_like(weights, dtype=torch.float32) + grad_k = torch.zeros_like(index_k, dtype=torch.float32) + + # Pad topk to block_I=32 boundary (kernel requires topk % block_I == 0 and topk >= 32) + padded_topk = max(k_top, 32) + padded_topk = ((padded_topk + 31) // 32) * 32 + if padded_topk != k_top: + pad_size = padded_topk - k_top + topk_indices = torch.cat( + [ + topk_indices, + torch.full( + (topk_indices.shape[0], pad_size), + -1, + device=topk_indices.device, + dtype=topk_indices.dtype, + ), + ], + dim=1, + ).contiguous() + grad_scores = torch.cat( + [ + grad_scores, + torch.zeros( + (grad_scores.shape[0], pad_size), + device=grad_scores.device, + dtype=grad_scores.dtype, + ), + ], + dim=1, + ).contiguous() + + with preserve_tilelang_env(): + tl_indexer_bwd_impl(head_num, head_dim, padded_topk)( + index_q.contiguous(), + index_k.contiguous(), + weights.squeeze(-1).contiguous(), + topk_indices.contiguous(), + grad_scores, + grad_q, + grad_w.squeeze(-1), + grad_k, + ) + + return grad_q, grad_w, grad_k + + +@torch.compiler.disable +def batched_indexer_bwd(index_q, weights, index_k, topk_indices, grad_scores): + """Batched backward: loops over batch dim. + + Args: + index_q: [seqlen, batch, heads, dim] bf16 + weights: [seqlen, batch, heads] fp32 + index_k: [seqlen_kv, batch, dim] bf16 + topk_indices: [batch, seqlen, topk] int32 + grad_scores: [batch, seqlen, topk] fp32 + + Returns: + grad_q: [seqlen, batch, heads, dim] bf16 + grad_w: [seqlen, batch, heads] fp32 + grad_k: [seqlen_kv, batch, dim] fp32 + """ + seqlen, batch, heads, dim = index_q.shape + seq_len_kv = index_k.shape[0] + + all_grad_q = torch.empty_like(index_q) + all_grad_w = torch.empty( + seqlen, batch, heads, device=index_q.device, dtype=torch.float32 + ) + all_grad_k = torch.zeros( + seq_len_kv, batch, dim, device=index_q.device, dtype=torch.float32 + ) + + for b in range(batch): + gq, gw, gk = indexer_bwd_interface( + index_q[:, b, :, :].contiguous(), + weights[:, b, :].contiguous(), + index_k[:, b, :].contiguous(), + topk_indices[b].contiguous(), + grad_scores[b].contiguous(), + ) + all_grad_q[:, b, :, :] = gq + all_grad_w[:, b, :] = gw + all_grad_k[:, b, :] = gk + + return all_grad_q, all_grad_w, all_grad_k diff --git a/src/art/megatron/dsv4/kernel/tilelang_indexer_fwd.py b/src/art/megatron/dsv4/kernel/tilelang_indexer_fwd.py new file mode 100644 index 000000000..6d2e4a51c --- /dev/null +++ b/src/art/megatron/dsv4/kernel/tilelang_indexer_fwd.py @@ -0,0 +1,459 @@ +# ruff: noqa +# Adapted from miles_plugins/models/glm5/ops/tilelang_indexer_fwd.py for DeepSeek-V4. +# Key differences from GLM-5: +# - Operates on [seqlen, batch, heads, dim] (SBHD) layout, batch handled externally +# - Uses causal mask via cu_seqlens instead of variable-length packed sequences +# - Supports compressed KV (seq_len_kv = seq_len_q / compress_ratio) +from typing import Any, cast + +import torch + +from art.megatron.dsv4.kernel.tilelang_import import ( + import_tilelang, + preserve_tilelang_env, + sanitize_tilelang_env, +) + +_tilelang, _T = import_tilelang() + +tilelang = cast(Any, _tilelang) +T = cast(Any, _T) + + +@tilelang.jit( + pass_configs={ + tilelang.PassConfigKey.TL_ENABLE_FAST_MATH: True, + }, +) +def tl_indexer_fwd_impl( + heads, + index_dim, + block_N=256, + num_stages=3, + threads=512, + block_Q=None, +): + if block_Q is None: + block_Q = 128 // heads + dtype = T.bfloat16 + accum_dtype = T.float32 + index_dtype = T.int32 + + seq_len = T.dynamic("seq_len") + seq_len_kv = T.dynamic("seq_len_kv") + + index_q_shape = [seq_len * heads, index_dim] + index_k_shape = [seq_len_kv, index_dim] + logits_shape = [seq_len, seq_len_kv] + + @T.prim_func + def tl_indexer_fwd_kernel( + IndexQ: T.Tensor(index_q_shape, dtype), # type: ignore + IndexK: T.Tensor(index_k_shape, dtype), # type: ignore + Logits: T.Tensor(logits_shape, accum_dtype), # type: ignore + Weights: T.Tensor([seq_len, heads], accum_dtype), # type: ignore + CuSeqLenKS: T.Tensor([seq_len], index_dtype), # type: ignore + CuSeqLenKE: T.Tensor([seq_len], index_dtype), # type: ignore + ): + with T.Kernel(T.ceildiv(seq_len, block_Q), threads=threads) as bx: + index_q_shared = T.alloc_shared([block_Q * heads, index_dim], dtype) + index_k_shared = T.alloc_shared([block_N, index_dim], dtype) + s = T.alloc_fragment([block_N, block_Q * heads], accum_dtype) + s_reshaped = T.reshape(s, (block_N, block_Q, heads)) + logits = T.alloc_fragment([block_N, block_Q], accum_dtype) + weights = T.alloc_fragment([block_Q, heads], accum_dtype) + + seq_len_i = bx * block_Q + + cu_k_s_min = T.alloc_var(index_dtype) + cu_k_e_max = T.alloc_var(index_dtype) + + cu_k_s_min = 2147483647 + cu_k_e_max = -2147483648 + + for bq_i in T.serial(block_Q): + cu_k_s_min = T.min( + cu_k_s_min, T.min(CuSeqLenKS[seq_len_i + bq_i], seq_len_kv) + ) + for bq_i in T.serial(block_Q): + cu_k_e_max = T.max( + cu_k_e_max, T.min(CuSeqLenKE[seq_len_i + bq_i], seq_len_kv) + ) + + T.copy(IndexQ[seq_len_i * heads, 0], index_q_shared) + T.copy(Weights[seq_len_i, 0], weights) + + for nbn_i in T.Pipelined( + T.ceildiv(cu_k_e_max - cu_k_s_min, block_N), num_stages=num_stages + ): + T.copy(IndexK[cu_k_s_min + nbn_i * block_N, 0], index_k_shared) + + T.gemm( + index_k_shared, + index_q_shared, + s, + transpose_B=True, + clear_accum=True, + policy=T.GemmWarpPolicy.FullCol, + ) + + for bn_i, bq_i, h_i in T.Parallel(block_N, block_Q, heads): + s_reshaped[bn_i, bq_i, h_i] = ( + T.max(s_reshaped[bn_i, bq_i, h_i], 0) * weights[bq_i, h_i] + ) + + T.reduce_sum(s_reshaped, logits, dim=-1, clear=True) + + for bq_i, bn_i in T.Parallel(block_Q, block_N): + Logits[seq_len_i + bq_i, cu_k_s_min + nbn_i * block_N + bn_i] = ( + logits[bn_i, bq_i] + ) + + return tl_indexer_fwd_kernel + + +@tilelang.jit +def clean_logits_( + threads: int = 512, + block_K: int = 4096, +): + seq_len = T.dynamic("seq_len") + seq_len_kv = T.dynamic("seq_len_kv") + + dtype = T.float + indices_dtype = T.int32 + + @T.prim_func + def clean_logits_kernel( + Logits: T.Tensor([seq_len, seq_len_kv], dtype), # type: ignore + CuSeqLenKS: T.Tensor([seq_len], indices_dtype), # type: ignore + CuSeqLenKE: T.Tensor([seq_len], indices_dtype), # type: ignore + ): + with T.Kernel(seq_len, threads=threads) as bx: + tx = T.thread_binding(0, threads, thread="threadIdx.x") + cu_k_s = CuSeqLenKS[bx] + cu_k_e = CuSeqLenKE[bx] + + for n_i in T.Pipelined(T.ceildiv(seq_len_kv, block_K)): + for k_i in T.serial(block_K // threads): + idx = n_i * block_K + k_i * threads + tx + if idx < cu_k_s or idx >= cu_k_e: + Logits[bx, idx] = -T.infinity(dtype) + + return clean_logits_kernel + + +sanitize_tilelang_env() + + +def _make_causal_cu_seqlens(seq_len_q, seq_len_kv, compress_ratio, device): + """Generate cu_seqlens for causal masking on compressed KV positions. + + For query at position p, valid compressed groups are [0, (p+1) // compress_ratio). + """ + positions = torch.arange(seq_len_q, device=device, dtype=torch.int32) + cu_seqlen_ks = torch.zeros(seq_len_q, device=device, dtype=torch.int32) + cu_seqlen_ke = ((positions + 1) // compress_ratio).to(torch.int32) + return cu_seqlen_ks, cu_seqlen_ke + + +def indexer_fwd_interface( + q, kv, weights, cu_seqlen_ks, cu_seqlen_ke, clean_logits=True +): + """Forward interface matching GLM-5's API but for a single batch element. + + Args: + q: [seq_len, heads, index_dim] bf16 + kv: [seq_len_kv, index_dim] bf16 + weights: [seq_len, heads] fp32 + cu_seqlen_ks: [seq_len] int32 — start of valid KV range per query + cu_seqlen_ke: [seq_len] int32 — end of valid KV range per query + + Returns: + logits: [seq_len, seq_len_kv] fp32 + """ + if q.dtype is torch.float32: + q = q.to(torch.bfloat16) + if kv.dtype is torch.float32: + kv = kv.to(torch.bfloat16) + if q.dtype != torch.bfloat16 or kv.dtype != torch.bfloat16: + raise TypeError( + f"DSV4 indexer TileLang launch requires bf16, got {q.dtype=}, {kv.dtype=}" + ) + seq_len, heads, index_dim = q.shape + seq_len_kv = kv.shape[0] + block_q = max(1, 128 // heads) + if seq_len % block_q != 0: + raise ValueError( + f"DSV4 indexer TileLang query length must be divisible by {block_q}, " + f"got {seq_len}." + ) + + logits = torch.empty([seq_len, seq_len_kv], device=q.device, dtype=torch.float32) + with preserve_tilelang_env(): + clean_logits_kernel = clean_logits_() + tl_indexer_fwd_kernel = tl_indexer_fwd_impl(heads=heads, index_dim=index_dim) + tl_indexer_fwd_kernel( + q.view(seq_len * heads, index_dim), + kv, + logits, + weights.float(), + cu_seqlen_ks, + cu_seqlen_ke, + ) + if clean_logits: + clean_logits_kernel(logits, cu_seqlen_ks, cu_seqlen_ke) + return logits + + +def _topk_indices_from_logits(logits, topk): + actual_topk = min(int(topk), int(logits.shape[-1])) + top_scores, top_indices = logits.topk(actual_topk, dim=-1) + return torch.where( + torch.isneginf(top_scores), + torch.full_like(top_indices, -1), + top_indices, + ).to(torch.int32) + + +def indexer_topk_interface(q, kv, weights, cu_seqlen_ks, cu_seqlen_ke, topk): + """Compute DSV4 indexer scores and return top-k compressed ids. + + Keep topk owned by the indexer wrapper so callers do not materialize or + inspect dense logits. + """ + return _topk_indices_from_logits( + indexer_fwd_interface(q, kv, weights, cu_seqlen_ks, cu_seqlen_ke), + topk, + ) + + +@tilelang.jit( + pass_configs={ + tilelang.PassConfigKey.TL_ENABLE_FAST_MATH: True, + tilelang.PassConfigKey.TL_DISABLE_DATA_RACE_CHECK: True, + }, +) +def tl_shared_prefix_indexer_fwd_impl( + heads, + index_dim, + block_N=256, + num_stages=3, + threads=512, + block_Q=None, +): + if block_Q is None: + block_Q = 128 // heads + dtype = T.bfloat16 + accum_dtype = T.float32 + index_dtype = T.int32 + + seq_len = T.dynamic("seq_len") + seq_len_kv = T.dynamic("seq_len_kv") + + index_q_shape = [seq_len * heads, index_dim] + index_k_shape = [seq_len_kv, index_dim] + logits_shape = [seq_len, seq_len_kv] + + @T.prim_func + def tl_shared_prefix_indexer_fwd_kernel( + IndexQ: T.Tensor(index_q_shape, dtype), # type: ignore + IndexK: T.Tensor(index_k_shape, dtype), # type: ignore + Logits: T.Tensor(logits_shape, accum_dtype), # type: ignore + Weights: T.Tensor([seq_len, heads], accum_dtype), # type: ignore + QPosition: T.Tensor([seq_len], index_dtype), # type: ignore + QGroup: T.Tensor([seq_len], index_dtype), # type: ignore + QParent: T.Tensor([seq_len], index_dtype), # type: ignore + EntryGroup: T.Tensor([seq_len_kv], index_dtype), # type: ignore + EntryParentVisible: T.Tensor([seq_len_kv], index_dtype), # type: ignore + EntryEndPosition: T.Tensor([seq_len_kv], index_dtype), # type: ignore + EntryValid: T.Tensor([seq_len_kv], index_dtype), # type: ignore + ): + with T.Kernel(T.ceildiv(seq_len, block_Q), threads=threads) as bx: + index_q_shared = T.alloc_shared([block_Q * heads, index_dim], dtype) + index_k_shared = T.alloc_shared([block_N, index_dim], dtype) + s = T.alloc_fragment([block_N, block_Q * heads], accum_dtype) + s_reshaped = T.reshape(s, (block_N, block_Q, heads)) + logits = T.alloc_fragment([block_N, block_Q], accum_dtype) + weights = T.alloc_fragment([block_Q, heads], accum_dtype) + + seq_len_i = bx * block_Q + + T.copy(IndexQ[seq_len_i * heads, 0], index_q_shared) + T.copy(Weights[seq_len_i, 0], weights) + + for nbn_i in T.Pipelined( + T.ceildiv(seq_len_kv, block_N), num_stages=num_stages + ): + for bn_i, d_i in T.Parallel(block_N, index_dim): + k_i = nbn_i * block_N + bn_i + index_k_shared[bn_i, d_i] = T.if_then_else( + k_i < seq_len_kv, + IndexK[k_i, d_i], + 0, + ) + + T.gemm( + index_k_shared, + index_q_shared, + s, + transpose_B=True, + clear_accum=True, + policy=T.GemmWarpPolicy.FullCol, + ) + + for bn_i, bq_i, h_i in T.Parallel(block_N, block_Q, heads): + s_reshaped[bn_i, bq_i, h_i] = ( + T.max(s_reshaped[bn_i, bq_i, h_i], 0) * weights[bq_i, h_i] + ) + + T.reduce_sum(s_reshaped, logits, dim=-1, clear=True) + + for bq_i, bn_i in T.Parallel(block_Q, block_N): + q_i = seq_len_i + bq_i + k_i = nbn_i * block_N + bn_i + if k_i < seq_len_kv: + entry_group = EntryGroup[k_i] + visible = ( + (EntryValid[k_i] != 0) + and (EntryEndPosition[k_i] <= QPosition[q_i]) + and ( + (entry_group == QGroup[q_i]) + or ( + (EntryParentVisible[k_i] != 0) + and (entry_group == QParent[q_i]) + ) + ) + ) + Logits[q_i, k_i] = T.if_then_else( + visible, + logits[bn_i, bq_i], + -T.infinity(accum_dtype), + ) + + return tl_shared_prefix_indexer_fwd_kernel + + +def _i32_contiguous(tensor): + if tensor.dtype != torch.int32: + tensor = tensor.to(torch.int32) + return tensor.contiguous() + + +def shared_prefix_indexer_fwd_interface( + q, + kv, + weights, + position_ids, + group_ids, + parent_ids, + entry_group_ids, + entry_parent_visible, + entry_end_positions, + entry_valid, +): + """Compute shared-prefix-aware indexer logits for one batch element. + + The output is intentionally block-local [query_block, compressed_kv] fp32. + Callers run topk on this block to avoid materializing full [S, compressed_kv] + logits for long shared-prefix packed sequences. + """ + if q.dtype is torch.float32: + q = q.to(torch.bfloat16) + if kv.dtype is torch.float32: + kv = kv.to(torch.bfloat16) + if q.dtype != torch.bfloat16 or kv.dtype != torch.bfloat16: + raise TypeError( + f"DSV4 indexer TileLang launch requires bf16, got {q.dtype=}, {kv.dtype=}" + ) + seq_len, heads, index_dim = q.shape + seq_len_kv = kv.shape[0] + block_q = max(1, 128 // heads) + if seq_len % block_q != 0: + raise ValueError( + "DSV4 shared-prefix indexer TileLang query length must be divisible " + f"by {block_q}, got {seq_len}." + ) + + logits = torch.empty([seq_len, seq_len_kv], device=q.device, dtype=torch.float32) + with preserve_tilelang_env(): + tl_indexer_fwd_kernel = tl_shared_prefix_indexer_fwd_impl( + heads=heads, + index_dim=index_dim, + ) + tl_indexer_fwd_kernel( + q.view(seq_len * heads, index_dim), + kv, + logits, + weights.float(), + _i32_contiguous(position_ids), + _i32_contiguous(group_ids), + _i32_contiguous(parent_ids), + _i32_contiguous(entry_group_ids), + _i32_contiguous(entry_parent_visible), + _i32_contiguous(entry_end_positions), + _i32_contiguous(entry_valid), + ) + return logits + + +def shared_prefix_indexer_topk_interface( + q, + kv, + weights, + position_ids, + group_ids, + parent_ids, + entry_group_ids, + entry_parent_visible, + entry_end_positions, + entry_valid, + topk, +): + """Compute shared-prefix-aware DSV4 indexer top-k compressed ids.""" + return _topk_indices_from_logits( + shared_prefix_indexer_fwd_interface( + q, + kv, + weights, + position_ids, + group_ids, + parent_ids, + entry_group_ids, + entry_parent_visible, + entry_end_positions, + entry_valid, + ), + topk, + ) + + +@torch.compiler.disable +def batched_indexer_fwd(q, k, weights, cu_seqlen_ks, cu_seqlen_ke): + """Batched forward: loops over batch dim. + + Args: + q: [seqlen, batch, heads, dim] bf16 + k: [seqlen_kv, batch, dim] bf16 + weights: [seqlen, batch, heads] fp32 + cu_seqlen_ks: [seqlen] int32 + cu_seqlen_ke: [seqlen] int32 + + Returns: + logits: [batch, seqlen, seqlen_kv] fp32 + """ + seqlen, batch, heads, dim = q.shape + seq_len_kv = k.shape[0] + + all_logits = torch.empty( + [batch, seqlen, seq_len_kv], device=q.device, dtype=torch.float32 + ) + for b in range(batch): + all_logits[b] = indexer_fwd_interface( + q[:, b, :, :].contiguous(), + k[:, b, :].contiguous(), + weights[:, b, :].contiguous(), + cu_seqlen_ks, + cu_seqlen_ke, + ) + return all_logits diff --git a/src/art/megatron/dsv4/kernel/tilelang_sparse_mla.py b/src/art/megatron/dsv4/kernel/tilelang_sparse_mla.py new file mode 100644 index 000000000..20fefe44f --- /dev/null +++ b/src/art/megatron/dsv4/kernel/tilelang_sparse_mla.py @@ -0,0 +1,117 @@ +from typing import Any + +import torch + +from art.megatron.dsv4.kernel.tilelang_import import preserve_tilelang_env + + +def _sparse_attn_torch(q, kv, attn_sink, topk_idxs, sm_scale): + if sm_scale is None: + sm_scale = q.shape[-1] ** -0.5 + bsz, seqlen, _, dim = q.shape + safe_idxs = topk_idxs.clamp_min(0) + selected_kv = torch.gather( + kv[:, None].expand(-1, seqlen, -1, -1), + 2, + safe_idxs[..., None].expand(-1, -1, -1, dim), + ) + scores = torch.einsum("bshd,bskd->bshk", q.float(), selected_kv.float()) + scores = scores * float(sm_scale) + scores = scores.masked_fill(topk_idxs[:, :, None, :] < 0, float("-inf")) + sinks = attn_sink.view(1, 1, -1, 1).expand(bsz, seqlen, -1, -1) + probs = torch.softmax(torch.cat([scores, sinks], dim=-1), dim=-1) + attn_probs = probs[..., :-1] + return torch.einsum("bshk,bskd->bshd", attn_probs, selected_kv.float()) + + +def _pad_topk_idxs(topk_idxs: torch.Tensor, block_size: int = 64) -> torch.Tensor: + topk = int(topk_idxs.shape[-1]) + padded_topk = (topk + block_size - 1) // block_size * block_size + if padded_topk == topk: + return topk_idxs + pad = torch.full( + (*topk_idxs.shape[:-1], padded_topk - topk), + -1, + device=topk_idxs.device, + dtype=topk_idxs.dtype, + ) + return torch.cat([topk_idxs, pad], dim=-1).contiguous() + + +class DeepSeekV4SparseAttention(torch.autograd.Function): + @staticmethod + def forward(ctx, q, kv, attn_sink, topk_idxs, sm_scale=None, output_dtype=None): + with preserve_tilelang_env(): + from art.megatron.dsv4.kernel import ( + tilelang_sparse_mla_fwd as sparse_mla_fwd, + ) + + o, lse = sparse_mla_fwd.sparse_mqa_fwd_interface( + q, kv, attn_sink, topk_idxs, sm_scale=sm_scale + ) + + output = o if output_dtype is None else o.to(output_dtype) + ctx.save_for_backward(q, kv, attn_sink, topk_idxs, output.clone(), lse) + ctx.sm_scale = sm_scale + + return output + + @staticmethod + def backward(ctx: Any, *grad_outputs: Any): + do = grad_outputs[0] + q, kv, attn_sink, topk_idxs, output, lse = ctx.saved_tensors + sm_scale = ctx.sm_scale + + with preserve_tilelang_env(): + from art.megatron.dsv4.kernel import ( + tilelang_sparse_mla_bwd as sparse_mla_bwd, + ) + + dq, dkv, d_attn_sink = sparse_mla_bwd.sparse_mqa_bwd_interface( + q, + kv, + attn_sink, + output.to(q.dtype), + do.to(q.dtype), + topk_idxs, + lse, + sm_scale=sm_scale, + ) + + return dq, dkv, d_attn_sink, None, None, None + + +@torch.compiler.disable +def sparse_attn_tilelang(q, kv, attn_sink, topk_idxs, sm_scale=None): + """Run TileLang sparse MLA outside TorchDynamo tracing. + + TileLang's TVM FFI adapter uses non-literal string objects internally, which + Dynamo cannot represent as constants. Keep only this kernel boundary eager + while allowing the surrounding DSV4 transformer layer to compile. + """ + output_dtype = q.dtype + if q.dtype is torch.float32: + return _sparse_attn_torch(q, kv, attn_sink, topk_idxs, sm_scale) + if kv.dtype != q.dtype: + kv = kv.to(q.dtype) + q = q.contiguous() + kv = kv.contiguous() + topk_idxs = _pad_topk_idxs(topk_idxs.contiguous()) + head_count = int(q.shape[2]) + if head_count < 16: + pad_heads = 16 - head_count + q = torch.cat( + [ + q, + q.new_zeros((*q.shape[:2], pad_heads, q.shape[3])), + ], + dim=2, + ).contiguous() + attn_sink = torch.cat( + [attn_sink, attn_sink.new_zeros(pad_heads)], + dim=0, + ).contiguous() + out = DeepSeekV4SparseAttention.apply( + q, kv, attn_sink, topk_idxs, sm_scale, output_dtype + ) + return out[:, :, :head_count, :] diff --git a/src/art/megatron/dsv4/kernel/tilelang_sparse_mla_bwd.py b/src/art/megatron/dsv4/kernel/tilelang_sparse_mla_bwd.py new file mode 100644 index 000000000..0d14e5e4a --- /dev/null +++ b/src/art/megatron/dsv4/kernel/tilelang_sparse_mla_bwd.py @@ -0,0 +1,406 @@ +# ruff: noqa +# Adapted from miles_plugins/models/glm5/ops/tilelang_sparse_mla_bwd.py for DeepSeek-V4. +# Key differences from GLM-5: +# - attn_sink: gradient computation for learnable per-head scalar +# - Single-head KV: kv shape [B, S_kv, D] (no kv_group, no D/D_tail split) +# - Index shape: [B, S, topk] (no kv_group dim) +# - Outputs: dQ [B, S, H, D], dKV [B, S_kv, D], dAttnSink [H] +from typing import Any, cast + +import torch + +from art.megatron.dsv4.kernel.tilelang_import import ( + import_tilelang, + preserve_tilelang_env, + sanitize_tilelang_env, +) + +_tilelang, _T = import_tilelang() + +tilelang = cast(Any, _tilelang) +T = cast(Any, _T) + + +@tilelang.jit(out_idx=[-1]) +def preprocess( + B, + S, + H, + D, + block_ND=32, + num_stages=5, + dtype=T.bfloat16, + accum_dtype=T.float32, +): + assert dtype == T.bfloat16 + assert accum_dtype == T.float32 + shape = [B, S, H, D] + + @T.prim_func + def preprocess_kernel( + O: T.Tensor(shape, dtype), # type: ignore + dO: T.Tensor(shape, dtype), # type: ignore + Delta: T.Tensor([B, S, H], accum_dtype), # type: ignore + ): + with T.Kernel(H, T.ceildiv(S, block_ND), B) as (bx, by, bz): + o = T.alloc_fragment([block_ND, block_ND], accum_dtype) + do = T.alloc_fragment([block_ND, block_ND], accum_dtype) + delta = T.alloc_fragment([block_ND], accum_dtype) + acc = T.alloc_fragment([block_ND, block_ND], accum_dtype) + T.clear(acc) + for k in T.Pipelined(T.ceildiv(D, block_ND), num_stages=num_stages): + T.copy( + O[ + bz, + by * block_ND : (by + 1) * block_ND, + bx, + k * block_ND : (k + 1) * block_ND, + ], + o, + ) + T.copy( + dO[ + bz, + by * block_ND : (by + 1) * block_ND, + bx, + k * block_ND : (k + 1) * block_ND, + ], + do, + ) + for i, j in T.Parallel(block_ND, block_ND): + acc[i, j] += o[i, j] * do[i, j] + T.reduce_sum(acc, delta, 1) + T.copy(delta, Delta[bz, by * block_ND : (by + 1) * block_ND, bx]) + + return preprocess_kernel + + +@tilelang.jit(out_idx=[-1]) +def postprocess( + B, + S_kv, + D, + block_N=64, + threads=128, + dtype=T.bfloat16, + accum_dtype=T.float32, +): + assert dtype == T.bfloat16 + assert accum_dtype == T.float32 + dkv_shape = [B, S_kv, D] + + @T.prim_func + def postprocess_kernel( + dKV: T.Tensor(dkv_shape, accum_dtype), # type: ignore + dKV_out: T.Tensor(dkv_shape, dtype), # type: ignore + ): + with T.Kernel(T.ceildiv(S_kv, block_N), B, threads=threads) as (bx, by): + T.copy( + dKV[by, bx * block_N : (bx + 1) * block_N, :], + dKV_out[by, bx * block_N : (bx + 1) * block_N, :], + ) + + return postprocess_kernel + + +@tilelang.jit( + out_idx=[-3], + pass_configs={ + tilelang.PassConfigKey.TL_DISABLE_TMA_LOWER: True, + tilelang.PassConfigKey.TL_DISABLE_WARP_SPECIALIZED: True, + tilelang.PassConfigKey.TL_ENABLE_AGGRESSIVE_SHARED_MEMORY_MERGE: True, + }, +) +def bwd( + B, + S, + S_kv, + H, + D, + topk, + sm_scale=None, + block_size=32, + num_stages=0, + threads=128, + indices_dtype=T.int32, + dtype=T.bfloat16, + accum_dtype=T.float32, +): + assert topk % block_size == 0, ( + f"topk ({topk}) must be divisible by block_size ({block_size})" + ) + assert dtype == T.bfloat16 + assert accum_dtype == T.float32 + + if sm_scale is None: + sm_scale = D ** (-0.5) + sm_scale_mul_reciprocal_log2 = sm_scale * 1.44269504 # log2(e) + + q_shape = [B, S, H, D] + kv_shape = [B, S_kv, D] + o_shape = [B, S, H, D] + indices_shape = [B, S, topk] + delta_shape = [B, S, H] + lse_shape = [B, S, H] + attn_sink_shape = [H] + + padded_H = max(tilelang.math.next_power_of_2(H), 16) + block_H = min(64, padded_H) + assert padded_H % block_H == 0 + NH = padded_H // block_H + BS = block_size + NS = tilelang.cdiv(topk, block_size) + + split_store = 2 + + @T.prim_func + def sparse_mqa_bwd_kernel( + Q: T.Tensor(q_shape, dtype), # type: ignore + KV: T.Tensor(kv_shape, dtype), # type: ignore + dO: T.Tensor(o_shape, dtype), # type: ignore + AttnSink: T.Tensor(attn_sink_shape, accum_dtype), # type: ignore + Indices: T.Tensor(indices_shape, indices_dtype), # type: ignore + Lse: T.Tensor(lse_shape, accum_dtype), # type: ignore + Delta: T.Tensor(delta_shape, accum_dtype), # type: ignore + dQ: T.Tensor(q_shape, dtype), # type: ignore + dKV: T.Tensor(kv_shape, accum_dtype), # type: ignore + dAttnSink: T.Tensor(attn_sink_shape, accum_dtype), # type: ignore + ): + with T.Kernel(S, B, NH, threads=threads) as (s_i, by, bz): + Q_shared = T.alloc_shared([block_H, D], dtype) + KV_shared = T.alloc_shared([BS, D], dtype) + dO_shared = T.alloc_shared([block_H, D], dtype) + mask = T.alloc_fragment([BS], "bool") + + P_shared_cast = T.alloc_shared([block_H, BS], dtype) + dP_shared_cast = T.alloc_shared([block_H, BS], dtype) + dQ_shared = T.alloc_shared([block_H, D], dtype) + + acc_p = T.alloc_fragment([block_H, BS], accum_dtype) + acc_dp = T.alloc_fragment([block_H, BS], accum_dtype) + acc_dq = T.alloc_fragment([block_H, D], accum_dtype) + acc_dq_i = T.alloc_fragment([block_H, D], accum_dtype) + acc_dkv = T.alloc_fragment([BS, D], accum_dtype) + acc_dkv_shared = T.alloc_shared([BS // split_store, D], accum_dtype) + safe_indices = T.alloc_fragment([BS], indices_dtype) + + T.copy(Q[by, s_i, bz * block_H : (bz + 1) * block_H, :D], Q_shared) + T.copy(dO[by, s_i, bz * block_H : (bz + 1) * block_H, :D], dO_shared) + + T.clear(acc_dq) + + for i_i in T.Pipelined(NS, num_stages=num_stages): + for bi_i in T.Parallel(BS): + mask[bi_i] = Indices[by, s_i, i_i * BS + bi_i] != -1 + safe_indices[bi_i] = T.if_then_else( + mask[bi_i], Indices[by, s_i, i_i * BS + bi_i], 0 + ) + + T.clear(acc_p) + + for bi_i, d_i in T.Parallel(BS, D): + KV_shared[bi_i, d_i] = KV[by, safe_indices[bi_i], d_i] + + T.gemm( + Q_shared, + KV_shared, + acc_p, + transpose_B=True, + policy=T.GemmWarpPolicy.FullCol, + ) + for h_i, bi_i in T.Parallel(block_H, BS): + acc_p[h_i, bi_i] = T.if_then_else( + mask[bi_i], acc_p[h_i, bi_i], -T.infinity(acc_p.dtype) + ) + + # P = exp2(scores * sm_scale_log2e - LSE) + for h_i, bi_i in T.Parallel(block_H, BS): + acc_p[h_i, bi_i] = T.exp2( + acc_p[h_i, bi_i] * sm_scale_mul_reciprocal_log2 + - Lse[by, s_i, bz * block_H + h_i] + ) + + T.copy(acc_p, P_shared_cast) + + # dP = P * (dO @ KV^T - Delta) + T.gemm( + dO_shared, + KV_shared, + acc_dp, + transpose_B=True, + policy=T.GemmWarpPolicy.FullCol, + clear_accum=True, + ) + + for h_i, bi_i in T.Parallel(block_H, BS): + acc_dp[h_i, bi_i] = ( + acc_p[h_i, bi_i] + * (acc_dp[h_i, bi_i] - Delta[by, s_i, bz * block_H + h_i]) + * sm_scale + ) + + T.copy(acc_dp, dP_shared_cast) + + # dQ += dP @ KV + T.gemm( + dP_shared_cast, + KV_shared, + acc_dq_i, + policy=T.GemmWarpPolicy.FullCol, + clear_accum=True, + ) + for h_i, d_i in T.Parallel(block_H, D): + acc_dq[h_i, d_i] += acc_dq_i[h_i, d_i] + + # dKV += dP^T @ Q + P^T @ dO + T.gemm( + dP_shared_cast, + Q_shared, + acc_dkv, + transpose_A=True, + policy=T.GemmWarpPolicy.FullCol, + clear_accum=True, + ) + T.gemm( + P_shared_cast, + dO_shared, + acc_dkv, + transpose_A=True, + policy=T.GemmWarpPolicy.FullCol, + ) + + # Atomic store dKV with split to reduce register pressure + for s in range(split_store): + for bi_i, d_i in T.Parallel(BS, D): + if bi_i < BS // split_store: + src_i = bi_i + s * (BS // split_store) + acc_dkv_shared[bi_i, d_i] = acc_dkv[src_i, d_i] + + for bi_i, d_i in T.Parallel(BS // split_store, D // 4): + src_i = bi_i + s * (BS // split_store) + T.atomic_addx4( + dKV[ + by, + safe_indices[src_i], + d_i * 4, + ], + acc_dkv_shared[bi_i, d_i * 4], + ) + + # Store dQ + T.copy(acc_dq, dQ_shared) + T.copy(dQ_shared, dQ[by, s_i, bz * block_H : (bz + 1) * block_H, :D]) + + # dAttnSink[h] = -sum_{b,s}( Delta[b,s,h] * p_sink[b,s,h] ) + # where p_sink = exp(attn_sink[h]) / Z = exp2(attn_sink[h]*log2e - LSE) + # attn_sink is a pre-scaled logit, so only convert to log2 base (no sm_scale) + for h_i in T.Parallel(block_H): + T.atomic_add( + dAttnSink[bz * block_H + h_i], + -Delta[by, s_i, bz * block_H + h_i] + * T.exp2( + AttnSink[bz * block_H + h_i] * 1.44269504 + - Lse[by, s_i, bz * block_H + h_i] + ), + ) + + return sparse_mqa_bwd_kernel + + +sanitize_tilelang_env() + + +def _tilelang_input_dtype(torch_dtype): + if torch_dtype is torch.bfloat16: + return T.bfloat16 + raise TypeError(f"DSV4 sparse MLA TileLang launch requires bf16, got {torch_dtype}") + + +def sparse_mqa_bwd_interface(q, kv, attn_sink, o, do, topk_idxs, lse, sm_scale=None): + """Backward interface for V4 sparse MQA attention. + + Args: + q: [B, S, H, D] bf16 + kv: [B, S_kv, D] bf16 + attn_sink: [H] fp32 + o: [B, S, H, D] bf16 (forward output) + do: [B, S, H, D] bf16 (grad of output) + topk_idxs: [B, S, topk] int32 + lse: [B, S, H] fp32 (log-sum-exp from forward) + sm_scale: float or None + + Returns: + dq: [B, S, H, D] bf16 + dkv: [B, S_kv, D] bf16 + d_attn_sink: [H] fp32 + """ + assert q.is_contiguous() and kv.is_contiguous() + assert topk_idxs.is_contiguous() and lse.is_contiguous() + B, S, H, D = q.shape + _, S_kv, _ = kv.shape + topk = topk_idxs.shape[-1] + dtype = _tilelang_input_dtype(q.dtype) + assert kv.dtype == q.dtype and o.dtype == q.dtype and do.dtype == q.dtype + + # Pad topk to next multiple of block_size (kernel requires divisibility) + block_size = 64 + padded_topk = (topk + block_size - 1) // block_size * block_size + if padded_topk != topk: + pad = torch.full( + (B, S, padded_topk - topk), + -1, + device=topk_idxs.device, + dtype=topk_idxs.dtype, + ) + topk_idxs = torch.cat([topk_idxs, pad], dim=-1).contiguous() + topk = padded_topk + + with preserve_tilelang_env(): + preprocess_kernel = preprocess(B, S, H, D, dtype=dtype) + postprocess_kernel = postprocess(B, S_kv, D, dtype=dtype) + delta = preprocess_kernel(o, do) + dkv = torch.zeros_like(kv, dtype=torch.float32) + d_attn_sink = torch.zeros_like(attn_sink) + if topk <= block_size: + with preserve_tilelang_env(): + bwd_kernel = bwd( + B, + S, + S_kv, + H, + D, + topk, + sm_scale, + block_size=block_size, + dtype=dtype, + ) + dq = bwd_kernel( + q, kv, do, attn_sink, topk_idxs, lse, delta, dkv, d_attn_sink + ) + else: + dq_accum = torch.zeros_like(q, dtype=torch.float32) + chunk_count = topk // block_size + with preserve_tilelang_env(): + bwd_kernel = bwd( + B, + S, + S_kv, + H, + D, + block_size, + sm_scale, + block_size=block_size, + dtype=dtype, + ) + for start in range(0, topk, block_size): + chunk = topk_idxs[:, :, start : start + block_size].contiguous() + dq_i = bwd_kernel( + q, kv, do, attn_sink, chunk, lse, delta, dkv, d_attn_sink + ) + dq_accum.add_(dq_i.float()) + dq = dq_accum.to(q.dtype) + d_attn_sink.div_(chunk_count) + with preserve_tilelang_env(): + dkv = postprocess_kernel(dkv) + + return dq, dkv, d_attn_sink diff --git a/src/art/megatron/dsv4/kernel/tilelang_sparse_mla_fwd.py b/src/art/megatron/dsv4/kernel/tilelang_sparse_mla_fwd.py new file mode 100644 index 000000000..34cf531db --- /dev/null +++ b/src/art/megatron/dsv4/kernel/tilelang_sparse_mla_fwd.py @@ -0,0 +1,240 @@ +# ruff: noqa +# Adapted from miles_plugins/models/glm5/ops/tilelang_sparse_mla_fwd.py for DeepSeek-V4. +# Key differences from GLM-5: +# - attn_sink: learnable per-head scalar added to softmax denominator +# - Single-head KV: kv shape [B, S_kv, D] (no kv_group, no D/D_tail split) +# - Index shape: [B, S, topk] (no kv_group dim) +# - Output: [B, S, H, D] + LSE [B, S, H] +from typing import Any, cast + +import torch + +from art.megatron.dsv4.kernel.tilelang_import import ( + import_tilelang, + preserve_tilelang_env, + sanitize_tilelang_env, +) + +_tilelang, _T = import_tilelang() + +tilelang = cast(Any, _tilelang) +T = cast(Any, _T) + + +@tilelang.jit( + out_idx=[-2, -1], + pass_configs={ + tilelang.PassConfigKey.TL_DISABLE_TMA_LOWER: True, + tilelang.PassConfigKey.TL_DISABLE_WARP_SPECIALIZED: True, + }, +) +def sparse_mqa_fwd( + heads, + dim, + topk, + sm_scale=None, + block_I=64, + num_stages=2, + threads=256, + dtype=T.bfloat16, +): + assert dim == tilelang.math.next_power_of_2(dim), ( + f"dim must be power of 2, got {dim}" + ) + assert topk % block_I == 0, ( + f"topk ({topk}) must be divisible by block_I ({block_I})" + ) + if sm_scale is None: + sm_scale = (1.0 / dim) ** 0.5 * 1.44269504 # log2(e) + else: + sm_scale = sm_scale * 1.44269504 # log2(e) + + batch = T.dynamic("batch") + seq_len = T.dynamic("seq_len") + seq_len_kv = T.dynamic("seq_len_kv") + + q_shape = [batch, seq_len, heads, dim] + kv_shape = [batch, seq_len_kv, dim] + o_shape = [batch, seq_len, heads, dim] + indices_shape = [batch, seq_len, topk] + lse_shape = [batch, seq_len, heads] + attn_sink_shape = [heads] + indices_dtype = T.int32 + assert dtype == T.bfloat16 + accum_dtype = T.float32 + + H = heads + padded_H = max(tilelang.math.next_power_of_2(heads), 16) + BI = block_I + NI = tilelang.cdiv(topk, block_I) + D = dim + + if heads > 64: + assert heads % 64 == 0, "heads should be a multiple of 64" + REPLICATE_H = heads // 64 + else: + REPLICATE_H = 1 + + H_per_block = padded_H if REPLICATE_H == 1 else 64 + + @T.prim_func + def main( + Q: T.Tensor(q_shape, dtype), # type: ignore + KV: T.Tensor(kv_shape, dtype), # type: ignore + AttnSink: T.Tensor(attn_sink_shape, accum_dtype), # type: ignore + Indices: T.Tensor(indices_shape, indices_dtype), # type: ignore + Output: T.Tensor(o_shape, dtype), # type: ignore + Lse: T.Tensor(lse_shape, accum_dtype), # type: ignore + ): + with T.Kernel(seq_len * REPLICATE_H, batch, threads=threads) as (bx, by): + Q_shared = T.alloc_shared([H_per_block, D], dtype) + KV_shared = T.alloc_shared([BI, D], dtype) + O_shared = T.alloc_shared([H_per_block, D], dtype) + Lse_shared = T.alloc_shared([H_per_block], accum_dtype) + mask = T.alloc_fragment([BI], "bool") + + acc_o = T.alloc_fragment([H_per_block, D], accum_dtype) + acc_s = T.alloc_fragment([H_per_block, BI], accum_dtype) + S_shared = T.alloc_shared([H_per_block, BI], dtype) + sumexp = T.alloc_fragment([H_per_block], accum_dtype) + sumexp_i = T.alloc_fragment([H_per_block], accum_dtype) + alpha = T.alloc_fragment([H_per_block], accum_dtype) + m_i = T.alloc_fragment([H_per_block], accum_dtype) + m_i_prev = T.alloc_fragment([H_per_block], accum_dtype) + + T.fill(acc_o, 0) + T.fill(sumexp, 0) + T.fill(m_i, -(2**30)) + + b_i = by + s_i = bx if REPLICATE_H == 1 else (bx // REPLICATE_H) + + H0 = 0 if REPLICATE_H == 1 else (bx % REPLICATE_H) * 64 + H1 = H0 + H_per_block + + T.copy(Q[b_i, s_i, H0:H1, :D], Q_shared) + + for i_i in T.Pipelined(NI, num_stages=num_stages): + for bi_i in T.Parallel(BI): + mask[bi_i] = Indices[b_i, s_i, i_i * BI + bi_i] != -1 + + for bi_i, d_i in T.Parallel(BI, D): + KV_shared[bi_i, d_i] = KV[ + b_i, + T.if_then_else( + Indices[b_i, s_i, i_i * BI + bi_i] != -1, + Indices[b_i, s_i, i_i * BI + bi_i], + 0, + ), + d_i, + ] + + T.clear(acc_s) + T.gemm( + Q_shared, + KV_shared, + acc_s, + transpose_B=True, + policy=T.GemmWarpPolicy.FullRow, + ) + for h_i, bi_i in T.Parallel(H_per_block, BI): + acc_s[h_i, bi_i] = T.if_then_else( + mask[bi_i], acc_s[h_i, bi_i], -T.infinity(acc_s.dtype) + ) + T.copy(m_i, m_i_prev) + T.reduce_max(acc_s, m_i, dim=1, clear=False) + for h_i in T.Parallel(H_per_block): + m_i[h_i] = T.max(m_i[h_i], m_i_prev[h_i]) + for h_i in T.Parallel(H_per_block): + alpha[h_i] = T.exp2((m_i_prev[h_i] - m_i[h_i]) * sm_scale) + for h_i, bi_i in T.Parallel(H_per_block, BI): + acc_s[h_i, bi_i] = T.exp2( + acc_s[h_i, bi_i] * sm_scale - m_i[h_i] * sm_scale + ) + T.reduce_sum(acc_s, sumexp_i, dim=1) + for h_i in T.Parallel(H_per_block): + sumexp[h_i] = sumexp[h_i] * alpha[h_i] + sumexp_i[h_i] + for h_i, d_i in T.Parallel(H_per_block, D): + acc_o[h_i, d_i] = acc_o[h_i, d_i] * alpha[h_i] + + T.copy(acc_s, S_shared) + T.gemm(S_shared, KV_shared, acc_o, policy=T.GemmWarpPolicy.FullRow) + + # attn_sink: add exp(attn_sink[h] - max_scaled) to softmax denominator + # attn_sink is a pre-scaled logit (same space as scores*sm_scale), so only convert to log2 base + for h_i in T.Parallel(H_per_block): + sumexp[h_i] += T.exp2( + AttnSink[H0 + h_i] * 1.44269504 - m_i[h_i] * sm_scale + ) + + # Rescale output + for h_i, d_i in T.Parallel(H_per_block, D): + acc_o[h_i, d_i] /= sumexp[h_i] + # LSE = log2(sumexp) + m_i * sm_scale (in log2 space) + for h_i in T.Parallel(H_per_block): + sumexp[h_i] = T.log2(sumexp[h_i]) + m_i[h_i] * sm_scale + + T.copy(acc_o, Output[b_i, s_i, H0:H1, :]) + T.copy(sumexp, Lse[b_i, s_i, H0:H1]) + + return main + + +sanitize_tilelang_env() + + +def _tilelang_input_dtype(torch_dtype): + if torch_dtype is torch.bfloat16: + return T.bfloat16 + raise TypeError(f"DSV4 sparse MLA TileLang launch requires bf16, got {torch_dtype}") + + +def sparse_mqa_fwd_interface( + q, kv, attn_sink, topk_idxs, sm_scale=None, block_I=64, num_stages=2, threads=256 +): + """Forward interface for V4 sparse MQA attention. + + Args: + q: [B, S, H, D] bf16 + kv: [B, S_kv, D] bf16 + attn_sink: [H] fp32 + topk_idxs: [B, S, topk] int32 + sm_scale: float or None (defaults to 1/sqrt(D)) + + Returns: + out: [B, S, H, D] bf16 + lse: [B, S, H] fp32 + """ + assert q.is_contiguous() and kv.is_contiguous() and topk_idxs.is_contiguous() + batch, seq_len, heads, dim = q.shape + _, seq_len_kv, kv_dim = kv.shape + assert kv_dim == dim + assert kv.dtype == q.dtype + _, _, topk = topk_idxs.shape + dtype = _tilelang_input_dtype(q.dtype) + + # Pad topk to next multiple of block_I (kernel requires divisibility) + padded_topk = (topk + block_I - 1) // block_I * block_I + if padded_topk != topk: + pad = torch.full( + (batch, seq_len, padded_topk - topk), + -1, + device=topk_idxs.device, + dtype=topk_idxs.dtype, + ) + topk_idxs = torch.cat([topk_idxs, pad], dim=-1).contiguous() + topk = padded_topk + + with preserve_tilelang_env(): + kernel = sparse_mqa_fwd( + heads, + dim, + topk, + sm_scale, + block_I=block_I, + num_stages=num_stages, + threads=threads, + dtype=dtype, + ) + out, lse = kernel(q, kv, attn_sink, topk_idxs) + return out, lse diff --git a/src/art/megatron/dsv4/layer.py b/src/art/megatron/dsv4/layer.py new file mode 100644 index 000000000..f7a42c0a7 --- /dev/null +++ b/src/art/megatron/dsv4/layer.py @@ -0,0 +1,346 @@ +import types +from typing import Any, cast + +from megatron.core.transformer.module import MegatronModule +from megatron.core.transformer.moe.moe_layer import MoELayer +from megatron.core.transformer.moe.router import TopKRouter +from megatron.core.transformer.transformer_config import TransformerConfig +from megatron.core.transformer.transformer_layer import TransformerLayer +from megatron.core.typed_torch import apply_module +from megatron.core.utils import make_viewless_tensor +import torch +from torch import Tensor +import torch.nn.functional as F + +from art.megatron.dsv4.hyper_connection import ( + DeepSeekV4HyperConnectionUtil, + HCHeadParams, +) +from art.megatron.dsv4.utils import freeze_parameters_as_buffers + + +def _first_tensor(value: Any) -> Tensor: + return value[0] if isinstance(value, tuple) else value + + +def _input_ids_sbd( + input_ids: Tensor, hidden_states: Tensor, tp_group: Any | None +) -> Tensor: + if input_ids.ndim != 2: + raise ValueError( + f"DSV4 hash routing expects 2D input_ids, got {tuple(input_ids.shape)}." + ) + if input_ids.shape == hidden_states.shape[:2]: + return input_ids + if input_ids.t().shape == hidden_states.shape[:2]: + return input_ids.t().contiguous() + input_ids_sbd = input_ids.t().contiguous() + if ( + tp_group is not None + and tp_group.size() > 1 + and input_ids_sbd.shape[1] == hidden_states.shape[1] + and input_ids_sbd.shape[0] % tp_group.size() == 0 + ): + local_s = input_ids_sbd.shape[0] // tp_group.size() + start = tp_group.rank() * local_s + local_input_ids = input_ids_sbd[start : start + local_s] + if local_input_ids.shape == hidden_states.shape[:2]: + return local_input_ids + raise ValueError( + "DSV4 hash routing input_ids do not match hidden states: " + f"input_ids={tuple(input_ids.shape)} hidden={tuple(hidden_states.shape)}." + ) + + +class Dsv4Router(TopKRouter): + def __init__(self, config: TransformerConfig, *args: Any, **kwargs: Any) -> None: + super().__init__(config, *args, **kwargs) + self._dsv4_input_ids: Tensor | None = None + self._dsv4_is_hash_layer = False + if self.topk == 1: + freeze_parameters_as_buffers(self) + + def set_layer_number(self, layer_number: int): + super().set_layer_number(layer_number) + self._dsv4_is_hash_layer = self._compute_is_hash_layer(layer_number) + cfg = cast(Any, self.config) + if self._is_hash_layer(): + if "tid2eid" not in self._buffers: + vocab_size = getattr(cfg, "vocab_size", None) + if vocab_size is None: + vocab_size = cfg.padded_vocab_size + self.register_buffer( + "tid2eid", + torch.zeros(int(vocab_size), self.topk, dtype=torch.long), + persistent=True, + ) + expert_pattern = ( + torch.arange(self.topk, dtype=torch.long) % int(cfg.num_moe_experts) + ).expand(int(vocab_size), -1) + cast(Tensor, self.tid2eid).copy_(expert_pattern) + routing_fn = Dsv4Router._hash_routing + else: + if "e_score_correction_bias" not in self._buffers: + self.register_buffer( + "e_score_correction_bias", + torch.zeros(int(cfg.num_moe_experts), dtype=torch.float32), + persistent=True, + ) + routing_fn = Dsv4Router._moe_routing + object.__setattr__(self, "routing", types.MethodType(routing_fn, self)) + + def set_input_ids(self, input_ids: Tensor | None) -> None: + self._dsv4_input_ids = input_ids + + def _compute_is_hash_layer(self, layer_number: int | None) -> bool: + cfg = cast(Any, self.config) + return layer_number is not None and layer_number <= int(cfg.dsv4_n_hash_layers) + + def _is_hash_layer(self) -> bool: + return self._dsv4_is_hash_layer + + def _scores(self, logits: Tensor) -> Tensor: + if self.score_function == "sigmoid": + return torch.sigmoid(logits) + if self.score_function == "softmax": + return F.softmax(logits, dim=-1, dtype=torch.float32).to(logits.dtype) + if self.score_function == "sqrtsoftplus": + return F.softplus(logits.float()).sqrt().to(logits.dtype) + raise ValueError( + f"Unsupported DSV4 router score function {self.score_function!r}." + ) + + def _select_indices( + self, selection_scores: Tensor, default_indices: Tensor + ) -> Tensor: + router_replay = getattr(self, "router_replay", None) + if router_replay is None: + return default_indices.long() + + def default_compute_topk( + local_scores: Tensor, + topk: int, + num_groups: int | None = None, + group_topk: int | None = None, + ) -> tuple[Tensor, Tensor]: + del num_groups, group_topk + del topk + return local_scores.gather(1, default_indices), default_indices + + _selected_scores, indices = router_replay.get_replay_topk( + selection_scores, + self.topk, + None, + None, + default_compute_topk, + ) + return indices.long() + + def _finish_routing( + self, + scores: Tensor, + indices: Tensor, + padding_mask: Tensor | None, + num_moe_experts: int, + ) -> tuple[Tensor, Tensor]: + cfg = cast(Any, self.config) + if indices.shape[-1] != self.topk: + raise RuntimeError( + "DSV4 router selected an invalid number of experts: " + f"selected={indices.shape[-1]} expected={self.topk}." + ) + selected_probs = scores.gather(1, indices) + if self.score_function != "softmax": + selected_probs = selected_probs / ( + selected_probs.sum(dim=-1, keepdim=True) + 1e-20 + ) + selected_probs = selected_probs * float(cfg.moe_router_topk_scaling_factor) + probs = torch.zeros_like(scores).scatter(1, indices, selected_probs) + routing_map = F.one_hot(indices, num_classes=num_moe_experts).sum(dim=1).bool() + if padding_mask is not None: + valid = padding_mask.reshape(-1).bool() + probs = torch.where(valid.unsqueeze(-1), probs, torch.zeros_like(probs)) + routing_map = routing_map & valid.unsqueeze(-1) + return probs, routing_map + + def _hash_routing(self, logits: Tensor, padding_mask: Tensor | None = None): + cfg = cast(Any, self.config) + num_moe_experts = int(cfg.num_moe_experts) + scores = self._scores(logits.view(-1, num_moe_experts)) + if self._dsv4_input_ids is None: + raise RuntimeError( + "DSV4 hash router requires input_ids for hash-moe layers." + ) + tid2eid = cast(Tensor, self.tid2eid) + default_indices = tid2eid[self._dsv4_input_ids.reshape(-1)].long() + indices = self._select_indices(scores, default_indices) + return self._finish_routing(scores, indices, padding_mask, num_moe_experts) + + def _moe_routing(self, logits: Tensor, padding_mask: Tensor | None = None): + cfg = cast(Any, self.config) + num_moe_experts = int(cfg.num_moe_experts) + scores = self._scores(logits.view(-1, num_moe_experts)) + selection_scores = scores + e_score_correction_bias = getattr(self, "e_score_correction_bias", None) + if e_score_correction_bias is not None: + selection_scores = selection_scores + e_score_correction_bias + default_indices = selection_scores.topk(self.topk, dim=-1, sorted=False).indices + indices = self._select_indices(selection_scores, default_indices) + return self._finish_routing(scores, indices, padding_mask, num_moe_experts) + + def routing(self, logits: Tensor, padding_mask: Tensor | None = None): + if self._is_hash_layer(): + return self._hash_routing(logits, padding_mask) + return self._moe_routing(logits, padding_mask) + + +class Dsv4MoELayer(MoELayer): + _dsv4_input_ids: Tensor | None = None + + def set_input_ids(self, input_ids: Tensor | None) -> None: + self._dsv4_input_ids = input_ids + + def forward( + self, + hidden_states: Tensor, + intermediate_tensors=None, + padding_mask: Tensor | None = None, + input_ids: Tensor | None = None, + ): + if isinstance(self.router, Dsv4Router): + input_ids = input_ids if input_ids is not None else self._dsv4_input_ids + router_input_ids = None + if input_ids is not None: + router_input_ids = _input_ids_sbd( + input_ids, hidden_states, getattr(self, "attn_tp_group", None) + ) + self.router.set_input_ids(router_input_ids) + try: + return super().forward( + hidden_states, + intermediate_tensors=intermediate_tensors, + padding_mask=padding_mask, + ) + finally: + if isinstance(self.router, Dsv4Router): + self.router.set_input_ids(None) + + +class Dsv4TransformerLayer(TransformerLayer): + def __init__(self, *args: Any, **kwargs: Any) -> None: + super().__init__(*args, **kwargs) + cfg = cast(Any, self.config) + hc = int(cfg.dsv4_hc_mult) + hc_dim = hc * self.config.hidden_size + mix = (2 + hc) * hc + self.hc_attn_fn = torch.nn.Parameter( + torch.empty(mix, hc_dim, dtype=torch.float32) + ) + self.hc_attn_base = torch.nn.Parameter(torch.empty(mix, dtype=torch.float32)) + self.hc_attn_scale = torch.nn.Parameter(torch.empty(3, dtype=torch.float32)) + self.hc_ffn_fn = torch.nn.Parameter( + torch.empty(mix, hc_dim, dtype=torch.float32) + ) + self.hc_ffn_base = torch.nn.Parameter(torch.empty(mix, dtype=torch.float32)) + self.hc_ffn_scale = torch.nn.Parameter(torch.empty(3, dtype=torch.float32)) + self._keep_fp32_parameters = ( + "hc_attn_fn", + "hc_attn_base", + "hc_attn_scale", + "hc_ffn_fn", + "hc_ffn_base", + "hc_ffn_scale", + ) + for param in ( + self.hc_attn_fn, + self.hc_attn_base, + self.hc_attn_scale, + self.hc_ffn_fn, + self.hc_ffn_base, + self.hc_ffn_scale, + ): + setattr(param, "_keep_fp32", True) + self.hc_util = DeepSeekV4HyperConnectionUtil(self.config) + + def forward( + self, + hidden_states: Tensor, + attention_mask: Tensor | None = None, + context: Tensor | None = None, + context_mask: Tensor | None = None, + rotary_pos_emb: Tensor | None = None, + rotary_pos_cos: Tensor | None = None, + rotary_pos_sin: Tensor | None = None, + rotary_pos_cos_sin: Tensor | None = None, + attention_bias: Tensor | None = None, + inference_context: Any = None, + packed_seq_params: Any = None, + sequence_len_offset: Tensor | None = None, + padding_mask: Tensor | None = None, + input_ids: Tensor | None = None, + **_: Any, + ): + if hidden_states.ndim == 3: + hidden_states = self.hc_util.block_expand(hidden_states) + + attn_input, post, comb = self.hc_util.layer_pre( + hidden_states, self.hc_attn_fn, self.hc_attn_scale, self.hc_attn_base + ) + attn_input = _first_tensor(apply_module(self.input_layernorm)(attn_input)) + attn_output = self.self_attention( + attn_input, + attention_mask=attention_mask, + inference_context=inference_context, + rotary_pos_emb=rotary_pos_emb, + rotary_pos_cos=rotary_pos_cos, + rotary_pos_sin=rotary_pos_sin, + rotary_pos_cos_sin=rotary_pos_cos_sin, + attention_bias=attention_bias, + packed_seq_params=packed_seq_params, + sequence_len_offset=sequence_len_offset, + ) + hidden_states = self.hc_util.layer_post(attn_output, hidden_states, post, comb) + + mlp_input, post, comb = self.hc_util.layer_pre( + hidden_states, self.hc_ffn_fn, self.hc_ffn_scale, self.hc_ffn_base + ) + mlp_input = _first_tensor(apply_module(self.pre_mlp_layernorm)(mlp_input)) + if isinstance(self.mlp, Dsv4MoELayer): + mlp_output = self.mlp( + mlp_input, padding_mask=padding_mask, input_ids=input_ids + ) + else: + mlp_output = self.mlp(mlp_input, padding_mask=padding_mask) + hidden_states = self.hc_util.layer_post(mlp_output, hidden_states, post, comb) + return make_viewless_tensor( + inp=hidden_states, + requires_grad=hidden_states.requires_grad, + keep_graph=True, + ), context + + +class Dsv4FinalNorm(MegatronModule): + def __init__( + self, + config: TransformerConfig, + hidden_size: int, + eps: float, + ) -> None: + super().__init__(config=config) + self.weight = torch.nn.Parameter(torch.ones(hidden_size)) + self.eps = eps + self.hc_head_params = HCHeadParams(config) + self.hc_util = DeepSeekV4HyperConnectionUtil(config) + + def forward(self, hidden_states: Tensor) -> Tensor: + if hidden_states.ndim == 4: + hidden_states = self.hc_util.block_head( + hidden_states, + self.hc_head_params.hc_head_fn, + self.hc_head_params.hc_head_scale, + self.hc_head_params.hc_head_base, + ) + dtype = hidden_states.dtype + normed = hidden_states.float() + normed = normed * torch.rsqrt(normed.square().mean(-1, keepdim=True) + self.eps) + return (normed * self.weight.float()).to(dtype) diff --git a/src/art/megatron/dsv4/lora.py b/src/art/megatron/dsv4/lora.py new file mode 100644 index 000000000..658077081 --- /dev/null +++ b/src/art/megatron/dsv4/lora.py @@ -0,0 +1,665 @@ +from __future__ import annotations + +import math +from typing import Any + +from megatron.core import parallel_state as ps +from megatron.core.tensor_parallel.mappings import ( + copy_to_tensor_model_parallel_region, + reduce_from_tensor_model_parallel_region, +) +import torch + +from art.megatron.lora import ( + GRAD_SYNC_OP_NONE, + GRAD_SYNC_OP_SUM, + LORA_ALPHA, + TP_DEFAULT_GRAD_SYNC_DOMAIN, + LoRA, + LoRAParallelSpec, +) + + +def _weight_device_dtype(weight: torch.Tensor) -> tuple[torch.device, torch.dtype]: + return weight.device, weight.dtype + + +def replicated_lora( + *, + adapter_model_prefix: str, + weight: torch.Tensor, + in_features: int, + out_features: int, + rank: int, + alpha: int = LORA_ALPHA, +) -> LoRA: + device, dtype = _weight_device_dtype(weight) + sync = LoRAParallelSpec( + grad_sync_domain=TP_DEFAULT_GRAD_SYNC_DOMAIN, + grad_sync_op=GRAD_SYNC_OP_NONE, + ) + return LoRA( + adapter_model_prefix=adapter_model_prefix, + in_features=in_features, + out_features=out_features, + rank=rank, + alpha=alpha, + dtype=dtype, + device=device, + a_parallel_spec=sync, + b_parallel_spec=sync, + allreduce=True, + ) + + +def column_parallel_lora( + *, + adapter_model_prefix: str, + weight: torch.Tensor, + in_features: int, + out_features: int, + rank: int, + alpha: int = LORA_ALPHA, +) -> LoRA: + device, dtype = _weight_device_dtype(weight) + a_spec = LoRAParallelSpec( + grad_sync_domain=TP_DEFAULT_GRAD_SYNC_DOMAIN, + grad_sync_op=GRAD_SYNC_OP_SUM, + ) + b_spec = LoRAParallelSpec( + sharded=True, + shard_dim=-1, + grad_sync_domain=TP_DEFAULT_GRAD_SYNC_DOMAIN, + grad_sync_op=GRAD_SYNC_OP_NONE, + ) + return LoRA( + adapter_model_prefix=adapter_model_prefix, + in_features=in_features, + out_features=out_features, + rank=rank, + alpha=alpha, + dtype=dtype, + device=device, + a_parallel_spec=a_spec, + b_parallel_spec=b_spec, + allreduce=True, + ) + + +def row_parallel_lora( + *, + adapter_model_prefix: str, + weight: torch.Tensor, + in_features: int, + out_features: int, + rank: int, + alpha: int = LORA_ALPHA, +) -> LoRA: + device, dtype = _weight_device_dtype(weight) + a_spec = LoRAParallelSpec( + sharded=True, + shard_dim=-2, + grad_sync_domain=TP_DEFAULT_GRAD_SYNC_DOMAIN, + grad_sync_op=GRAD_SYNC_OP_NONE, + ) + b_spec = LoRAParallelSpec( + grad_sync_domain=TP_DEFAULT_GRAD_SYNC_DOMAIN, + grad_sync_op=GRAD_SYNC_OP_SUM, + ) + return LoRA( + adapter_model_prefix=adapter_model_prefix, + in_features=in_features, + out_features=out_features, + rank=rank, + alpha=alpha, + dtype=dtype, + device=device, + a_parallel_spec=a_spec, + b_parallel_spec=b_spec, + allreduce=True, + ) + + +class Dsv4GroupedOutputLoRA(torch.nn.Module): + def __init__( + self, + *, + adapter_model_prefix: str, + weight: torch.Tensor, + in_features: int, + out_features: int, + local_groups: int, + rank: int, + alpha: int = LORA_ALPHA, + ) -> None: + super().__init__() + if out_features % local_groups != 0: + raise ValueError( + f"{adapter_model_prefix}: out_features={out_features} is not " + f"divisible by local_groups={local_groups}" + ) + self.local_groups = local_groups + self.out_per_group = out_features // local_groups + self.lora = column_parallel_lora( + adapter_model_prefix=adapter_model_prefix, + weight=weight, + in_features=in_features, + out_features=out_features, + rank=rank, + alpha=alpha, + ) + + @property + def adapter_model_prefix(self) -> str: + return self.lora.adapter_model_prefix + + def forward(self, x: torch.Tensor) -> torch.Tensor: + rank = self.lora.A_T.shape[-1] + hidden = x @ self.lora.A_T + weight = self.lora.B_T.view(rank, self.local_groups, self.out_per_group) + out = torch.einsum("bsgr,rgo->bsgo", hidden, weight) + return out if self.lora.scale == 1.0 else out * self.lora.scale + + +class Dsv4RowOutputLoRA(torch.nn.Module): + def __init__( + self, + *, + adapter_model_prefix: str, + weight: torch.Tensor, + in_features: int, + out_features: int, + tp_group: Any, + rank: int, + alpha: int = LORA_ALPHA, + ) -> None: + super().__init__() + self.tp_group = tp_group + self.lora = row_parallel_lora( + adapter_model_prefix=adapter_model_prefix, + weight=weight, + in_features=in_features, + out_features=out_features, + rank=rank, + alpha=alpha, + ) + + @property + def adapter_model_prefix(self) -> str: + return self.lora.adapter_model_prefix + + def forward(self, x: torch.Tensor) -> torch.Tensor: + out = self.lora(x) + if self.tp_group is not None and self.tp_group.size() > 1: + out = reduce_from_tensor_model_parallel_region(out, group=self.tp_group) + return out + + +class Dsv4PlainLoRA(torch.nn.Module): + def __init__(self, lora: LoRA, *, sync_input_grad_group: Any | None = None) -> None: + super().__init__() + self.lora = lora + self.sync_input_grad_group = sync_input_grad_group + + @property + def adapter_model_prefix(self) -> str: + return self.lora.adapter_model_prefix + + def forward(self, x: torch.Tensor) -> torch.Tensor: + if ( + self.sync_input_grad_group is not None + and self.sync_input_grad_group.size() > 1 + ): + x = copy_to_tensor_model_parallel_region( + x, group=self.sync_input_grad_group + ) + return self.lora(x) + + +def _targets_include(target_modules: set[str], *names: str) -> bool: + return not target_modules or any(name in target_modules for name in names) + + +def _attach_lora( + module: torch.nn.Module, attr_name: str, lora: torch.nn.Module +) -> None: + if hasattr(module, attr_name): + raise RuntimeError(f"{module.__class__.__name__}.{attr_name} is already set") + setattr(module, attr_name, lora) + + +def apply_dsv4_attention_lora( + attention: Any, + *, + adapter_model_prefix: str, + target_modules: set[str], + rank: int, + alpha: int, +) -> None: + if _targets_include(target_modules, "q_a_proj"): + _attach_lora( + attention, + "wq_a_lora", + Dsv4PlainLoRA( + replicated_lora( + adapter_model_prefix=f"{adapter_model_prefix}.self_attn.q_a_proj", + weight=attention.wq_a.weight, + in_features=attention.wq_a.weight.shape[1], + out_features=attention.wq_a.weight.shape[0], + rank=rank, + alpha=alpha, + ) + ), + ) + if _targets_include(target_modules, "q_b_proj"): + _attach_lora( + attention, + "wq_b_lora", + Dsv4PlainLoRA( + column_parallel_lora( + adapter_model_prefix=f"{adapter_model_prefix}.self_attn.q_b_proj", + weight=attention.wq_b.weight, + in_features=attention.wq_b.weight.shape[1], + out_features=attention.wq_b.weight.shape[0], + rank=rank, + alpha=alpha, + ), + sync_input_grad_group=attention.tp_group, + ), + ) + if _targets_include(target_modules, "kv_proj"): + _attach_lora( + attention, + "wkv_lora", + Dsv4PlainLoRA( + replicated_lora( + adapter_model_prefix=f"{adapter_model_prefix}.self_attn.kv_proj", + weight=attention.wkv.weight, + in_features=attention.wkv.weight.shape[1], + out_features=attention.wkv.weight.shape[0], + rank=rank, + alpha=alpha, + ) + ), + ) + if _targets_include(target_modules, "o_a_proj"): + _attach_lora( + attention, + "wo_a_lora", + Dsv4GroupedOutputLoRA( + adapter_model_prefix=f"{adapter_model_prefix}.self_attn.o_a_proj", + weight=attention.wo_a.weight, + in_features=attention.wo_a.weight.shape[1], + out_features=attention.wo_a.weight.shape[0], + local_groups=attention.n_local_groups, + rank=rank, + alpha=alpha, + ), + ) + if _targets_include(target_modules, "o_b_proj"): + _attach_lora( + attention, + "wo_b_lora", + Dsv4RowOutputLoRA( + adapter_model_prefix=f"{adapter_model_prefix}.self_attn.o_b_proj", + weight=attention.wo_b.weight, + in_features=attention.wo_b.weight.shape[1], + out_features=attention.wo_b.weight.shape[0], + tp_group=attention.tp_group, + rank=rank, + alpha=alpha, + ), + ) + compressor = getattr(attention, "compressor", None) + if compressor is None: + return + if _targets_include(target_modules, "compressor.kv_proj"): + _attach_lora( + compressor, + "kv_proj_lora", + Dsv4PlainLoRA( + replicated_lora( + adapter_model_prefix=( + f"{adapter_model_prefix}.self_attn.compressor.kv_proj" + ), + weight=compressor.wkv.weight, + in_features=compressor.wkv.weight.shape[1], + out_features=compressor.wkv.weight.shape[0], + rank=rank, + alpha=alpha, + ) + ), + ) + if _targets_include(target_modules, "compressor.gate_proj"): + _attach_lora( + compressor, + "gate_proj_lora", + Dsv4PlainLoRA( + replicated_lora( + adapter_model_prefix=( + f"{adapter_model_prefix}.self_attn.compressor.gate_proj" + ), + weight=compressor.wgate.weight, + in_features=compressor.wgate.weight.shape[1], + out_features=compressor.wgate.weight.shape[0], + rank=rank, + alpha=alpha, + ) + ), + ) + + +def _unwrap_lora(module: torch.nn.Module | None) -> LoRA | None: + if isinstance(module, LoRA): + return module + nested = getattr(module, "lora", None) if module is not None else None + return nested if isinstance(nested, LoRA) else None + + +def _adapter_alpha_dim(lora: LoRA) -> tuple[int, int]: + dim = int(lora.A_T.shape[-1]) + alpha = float(lora.scale) * dim + rounded = round(alpha) + if not math.isclose(alpha, rounded): + raise RuntimeError(f"{lora.adapter_model_prefix}: non-integral alpha={alpha}") + return rounded, dim + + +def _adapter_param_prefix(base_prefix: str, adapter_key: str | None) -> str: + if adapter_key is None: + return f"{base_prefix}.adapter" + return f"{base_prefix}.adapter.{adapter_key}" + + +def _adapter_weight( + base_prefix: str, + lora: LoRA, + *, + adapter_key: str | None = None, +) -> Any: + from megatron.bridge.models.conversion.model_bridge import MegatronWeightTuple + from megatron.bridge.models.conversion.peft_bridge import AdapterWeight + + alpha, dim = _adapter_alpha_dim(lora) + linear_in = lora.A_T.transpose(-1, -2).contiguous() + linear_out = lora.B_T.transpose(-1, -2).contiguous() + param_prefix = _adapter_param_prefix(base_prefix, adapter_key) + return AdapterWeight( + global_base_prefix=base_prefix, + adapter_key=adapter_key, + alpha=alpha, + dim=dim, + linear_in_weight=MegatronWeightTuple( + param_name=f"{param_prefix}.linear_in.weight", + weight=linear_in, + vp_stage=0, + ), + linear_out_weight=MegatronWeightTuple( + param_name=f"{param_prefix}.linear_out.weight", + weight=linear_out, + vp_stage=0, + ), + ) + + +def _tensor_parallel_group() -> Any | None: + try: + return ps.get_tensor_model_parallel_group() + except Exception: + return None + + +def _gather_tp_input_shards( + linear_in: torch.Tensor, tp_group: Any | None +) -> torch.Tensor: + dist = torch.distributed + if not (dist.is_available() and dist.is_initialized()): + return linear_in + tp_group = tp_group or _tensor_parallel_group() + world_size = tp_group.size() if tp_group is not None else dist.get_world_size() + if world_size <= 1: + return linear_in + gathered = [torch.empty_like(linear_in) for _ in range(world_size)] + dist.all_gather(gathered, linear_in.contiguous(), group=tp_group) + return torch.cat(gathered, dim=-1) + + +def _row_parallel_adapter_weight( + base_prefix: str, + lora: LoRA, + *, + tp_group: Any | None, +) -> Any: + from megatron.bridge.models.conversion.model_bridge import MegatronWeightTuple + from megatron.bridge.models.conversion.peft_bridge import AdapterWeight + + alpha, dim = _adapter_alpha_dim(lora) + linear_in = _gather_tp_input_shards( + lora.A_T.transpose(-1, -2).contiguous(), tp_group + ) + linear_out = lora.B_T.transpose(-1, -2).contiguous() + param_prefix = _adapter_param_prefix(base_prefix, None) + return AdapterWeight( + global_base_prefix=base_prefix, + adapter_key=None, + alpha=alpha, + dim=dim, + linear_in_weight=MegatronWeightTuple( + param_name=f"{param_prefix}.linear_in.weight", + weight=linear_in, + vp_stage=0, + ), + linear_out_weight=MegatronWeightTuple( + param_name=f"{param_prefix}.linear_out.weight", + weight=linear_out, + vp_stage=0, + ), + ) + + +def _add_adapter( + adapter_weights_by_base: dict[str, list[Any]], + *, + base_prefix: str, + lora_module: torch.nn.Module | None, +) -> None: + lora = _unwrap_lora(lora_module) + if lora is None: + return + adapter_weights_by_base[f"{base_prefix}.weight"] = [ + _adapter_weight(base_prefix, lora) + ] + + +def _add_row_parallel_adapter( + adapter_weights_by_base: dict[str, list[Any]], + *, + base_prefix: str, + lora_module: torch.nn.Module | None, +) -> None: + lora = _unwrap_lora(lora_module) + if lora is None: + return + adapter_weights_by_base[f"{base_prefix}.weight"] = [ + _row_parallel_adapter_weight( + base_prefix, + lora, + tp_group=getattr(lora_module, "tp_group", None), + ) + ] + + +def add_dsv4_attention_adapter_weights( + adapter_weights_by_base: dict[str, list[Any]], + *, + layer_prefix: str, + attention: Any, +) -> None: + attn_prefix = f"{layer_prefix}.self_attention" + _add_adapter( + adapter_weights_by_base, + base_prefix=f"{attn_prefix}.wq_a", + lora_module=getattr(attention, "wq_a_lora", None), + ) + _add_adapter( + adapter_weights_by_base, + base_prefix=f"{attn_prefix}.wq_b", + lora_module=getattr(attention, "wq_b_lora", None), + ) + _add_adapter( + adapter_weights_by_base, + base_prefix=f"{attn_prefix}.wkv", + lora_module=getattr(attention, "wkv_lora", None), + ) + _add_adapter( + adapter_weights_by_base, + base_prefix=f"{attn_prefix}.wo_a", + lora_module=getattr(attention, "wo_a_lora", None), + ) + _add_row_parallel_adapter( + adapter_weights_by_base, + base_prefix=f"{attn_prefix}.wo_b", + lora_module=getattr(attention, "wo_b_lora", None), + ) + compressor = getattr(attention, "compressor", None) + if compressor is None: + return + _add_adapter( + adapter_weights_by_base, + base_prefix=f"{attn_prefix}.compressor.wkv", + lora_module=getattr(compressor, "kv_proj_lora", None), + ) + _add_adapter( + adapter_weights_by_base, + base_prefix=f"{attn_prefix}.compressor.wgate", + lora_module=getattr(compressor, "gate_proj_lora", None), + ) + + +def add_dsv4_shared_experts_adapter_weights( + adapter_weights_by_base: dict[str, list[Any]], + *, + layer_prefix: str, + shared_experts: Any, +) -> None: + from art.megatron.lora import SharedExpertsLinearFC1LoRA, SharedExpertsLinearFC2LoRA + + linear_fc1 = getattr(shared_experts, "linear_fc1", None) + if isinstance(linear_fc1, SharedExpertsLinearFC1LoRA): + base_prefix = f"{layer_prefix}.mlp.shared_experts.linear_fc1" + adapter_weights_by_base[f"{base_prefix}.weight"] = [ + _adapter_weight( + base_prefix, + linear_fc1.gate_lora, + adapter_key="adapter_gate", + ), + _adapter_weight( + base_prefix, + linear_fc1.up_lora, + adapter_key="adapter_up", + ), + ] + + linear_fc2 = getattr(shared_experts, "linear_fc2", None) + if isinstance(linear_fc2, SharedExpertsLinearFC2LoRA): + base_prefix = f"{layer_prefix}.mlp.shared_experts.linear_fc2" + adapter_weights_by_base[f"{base_prefix}.weight"] = [ + _row_parallel_adapter_weight( + base_prefix, + linear_fc2.row_parallel_lora.lora, + tp_group=_tensor_parallel_group(), + ) + ] + + +def _triton_autotune_key( + autotuner: Any, args: tuple[Any, ...], kwargs: dict[str, Any] +) -> tuple[Any, ...]: + nargs = dict(zip(autotuner.arg_names, args)) + all_args = {**nargs, **kwargs} + named_args = { + key: value for key, value in all_args.items() if key in autotuner.arg_names + } + key = [named_args[name] for name in autotuner.keys if name in named_args] + for arg in named_args.values(): + if hasattr(arg, "dtype"): + key.append(str(arg.dtype)) + return tuple(key) + + +def _dsv4_te_permutation_config(hidden_size: int) -> Any: + import triton + + block_size = 1 << max(6, min(12, (max(1, hidden_size) - 1).bit_length())) + return triton.Config({"BLOCK_SIZE": block_size}) + + +def _install_dsv4_triton_static_config(autotuner: Any) -> None: + """Bypass TE Triton permutation autotune during DSV4 ETP backward. + + DSV4 ETP validation can make every rank hit the same first-use TE MoE + permutation backward path at once. Serializing autotune inside autograd is + unsafe because other ranks can be waiting at distributed edges of the same + backward graph. Instead, cache a deterministic BLOCK_SIZE before Triton's + Autotuner benchmarks. The failure risk is performance from a non-autotuned + copy/unpermute tile, not a change in model math. + """ + + if bool(getattr(autotuner, "_art_dsv4_static_config_wrapped", False)): + return + original_run = autotuner.run + + def static_config_run(*args: Any, **kwargs: Any) -> Any: + configs = getattr(autotuner, "configs", ()) + if len(configs) <= 1: + return original_run(*args, **kwargs) + key = _triton_autotune_key(autotuner, args, kwargs) + cache = getattr(autotuner, "cache", None) + if isinstance(cache, dict) and key not in cache and key: + cache[key] = _dsv4_te_permutation_config(int(key[0])) + return original_run(*args, **kwargs) + + static_config_run._art_dsv4_static_config_wrapped = True # type: ignore[attr-defined] + autotuner.run = static_config_run + autotuner._art_dsv4_static_config_wrapped = True + + +def install_dsv4_te_permutation_static_configs() -> None: + """Install DSV4-only static configs for TE MoE permutation Triton autotuners. + + This is called from the DSV4 handler only when expert tensor parallelism is + enabled. It is intentionally not a generic compile workaround: the current + correctness blocker is DSV4 ETP first-backward autotune inside distributed + autograd, and the performance goal is to bypass only cold autotune while + leaving normal launches on Triton's regular JIT path. + """ + + from transformer_engine.common.triton import permutation as te_permutation + + for name in ( + "_permute_kernel", + "_unpermute_kernel", + "_unpermute_bwd_with_merging_probs_kernel", + "_sort_chunks_by_map_kernel", + ): + autotuner = getattr(te_permutation, name, None) + if hasattr(autotuner, "run"): + _install_dsv4_triton_static_config(autotuner) + + +def disable_dsv4_etp_shared_expert_lora_compile(shared_experts: Any) -> None: + """Keep DSV4 ETP shared-expert down LoRA outside compiled layer graphs. + + Torch 2.11 can spend unbounded time compiling the row-parallel shared-expert + LoRA path when DSV4 runs with expert tensor parallelism. The barrier is + instance-local and leaves non-ETP and generic MoE shared experts unchanged. + """ + from art.megatron.lora import SharedExpertsLinearFC2LoRA + + linear_fc2 = getattr(shared_experts, "linear_fc2", None) + if not isinstance(linear_fc2, SharedExpertsLinearFC2LoRA): + return + if bool(getattr(linear_fc2, "_art_dsv4_compile_disabled", False)): + return + linear_fc2.forward = torch.compiler.disable(linear_fc2.forward) + linear_fc2._art_dsv4_compile_disabled = True diff --git a/src/art/megatron/dsv4/rope.py b/src/art/megatron/dsv4/rope.py new file mode 100644 index 000000000..5f1a07d71 --- /dev/null +++ b/src/art/megatron/dsv4/rope.py @@ -0,0 +1,253 @@ +from functools import lru_cache +import math +from typing import Any, cast + +from megatron.core.transformer import TransformerConfig +import torch +from torch import nn + +_DEVICE_ROPE_CACHES: dict[tuple[object, ...], torch.Tensor] = {} + + +@lru_cache(2) +def precompute_freqs_cis( + dim, seqlen, original_seq_len, base, factor, beta_fast, beta_slow +) -> torch.Tensor: + """Precompute the complex rotary frequencies for RoPE, with optional YaRN smoothing. + + When ``original_seq_len > 0``, applies YaRN factor rescaling interpolated + by a linear ramp between ``beta_fast`` and ``beta_slow``. Otherwise the + base frequencies are used verbatim. + """ + + def find_correction_dim(num_rotations, dim, base, max_seq_len): + return ( + dim + * math.log(max_seq_len / (num_rotations * 2 * math.pi)) + / (2 * math.log(base)) + ) + + def find_correction_range(low_rot, high_rot, dim, base, max_seq_len): + low = math.floor(find_correction_dim(low_rot, dim, base, max_seq_len)) + high = math.ceil(find_correction_dim(high_rot, dim, base, max_seq_len)) + return max(low, 0), min(high, dim - 1) + + def linear_ramp_factor(min, max, dim): + if min == max: + max += 0.001 + linear_func = (torch.arange(dim, dtype=torch.float32) - min) / (max - min) + ramp_func = torch.clamp(linear_func, 0, 1) + return ramp_func + + freqs = 1.0 / (base ** (torch.arange(0, dim, 2, dtype=torch.float32) / dim)) + if original_seq_len > 0: + low, high = find_correction_range( + beta_fast, beta_slow, dim, base, original_seq_len + ) + smooth = 1 - linear_ramp_factor(low, high, dim // 2) + freqs = freqs / factor * (1 - smooth) + freqs * smooth + + t = torch.arange(seqlen) + freqs = torch.outer(t, freqs) + freqs_cis = torch.polar(torch.ones_like(freqs), freqs) + return freqs_cis + + +def apply_rotary_emb( + x: torch.Tensor, freqs_cis: torch.Tensor, inverse: bool = False +) -> torch.Tensor: + """Apply RoPE in-place to the last dim of ``x``. + + ``x`` has shape ``[..., dim]`` where ``dim`` is even; the last-dim pairs are + treated as complex numbers multiplied by ``freqs_cis``. When ``inverse=True`` + the conjugate rotation is applied (used for the indexer's inverse rope). + """ + y = x + x = torch.view_as_complex(x.float().unflatten(-1, (-1, 2))) + if inverse: + freqs_cis = freqs_cis.conj() + if x.ndim == 3: + if freqs_cis.ndim == 2: + freqs_cis = freqs_cis.view(1, x.size(1), x.size(-1)) + else: + freqs_cis = freqs_cis.view(x.size(0), x.size(1), x.size(-1)) + else: + if freqs_cis.ndim == 2: + freqs_cis = freqs_cis.view(1, x.size(1), 1, x.size(-1)) + else: + freqs_cis = freqs_cis.view(x.size(0), x.size(1), 1, x.size(-1)) + x = torch.view_as_real(x * freqs_cis).flatten(-2) + y.copy_(x) + return y + + +def wrapped_precompute_freqs_cis( + config: TransformerConfig, + rope_head_dim: int, + base: float, + yarn_disabled: bool = False, + seqlen: int = 65536, +): + cfg = cast(Any, config) + + # yarn_disabled=True makes precompute_freqs_cis skip YaRN interpolation. + # DSV4 Flash keeps YaRN enabled for sliding and compressed attention. + original_seq_len = 0 if yarn_disabled else cfg.original_max_position_embeddings + + inputs = dict( + dim=rope_head_dim, + seqlen=seqlen, + original_seq_len=original_seq_len, + base=base, + factor=cfg.rotary_scaling_factor, + beta_fast=cfg.beta_fast, + beta_slow=cfg.beta_slow, + ) + + assert cfg.rotary_scaling_factor in (4, 16), ( + f"Unexpected rotary_scaling_factor: {cfg.rotary_scaling_factor}" + ) + expected_original = 0 if yarn_disabled else 65536 + assert inputs == dict( + dim=rope_head_dim, + seqlen=seqlen, + original_seq_len=expected_original, + base=base, + factor=cfg.rotary_scaling_factor, + beta_fast=32, + beta_slow=1, + ) + + return precompute_freqs_cis(**inputs) + + +def _rope_cache_key( + config: TransformerConfig, + *, + rope_head_dim: int, + base: float, + yarn_disabled: bool, + device: torch.device, +) -> tuple[object, ...]: + cfg = cast(Any, config) + device_index = ( + torch.cuda.current_device() + if device.type == "cuda" and device.index is None + else device.index + ) + original_seq_len = 0 if yarn_disabled else cfg.original_max_position_embeddings + return ( + device.type, + device_index, + int(rope_head_dim), + int(original_seq_len), + float(base), + float(cfg.rotary_scaling_factor), + int(cfg.beta_fast), + int(cfg.beta_slow), + ) + + +def _get_device_rope_cache( + config: TransformerConfig, + *, + rope_head_dim: int, + base: float, + yarn_disabled: bool, + device: torch.device, + seqlen: int, +) -> torch.Tensor: + seqlen = max(1, int(seqlen)) + key = _rope_cache_key( + config, + rope_head_dim=rope_head_dim, + base=base, + yarn_disabled=yarn_disabled, + device=device, + ) + cached = _DEVICE_ROPE_CACHES.get(key) + if cached is None or cached.shape[0] < seqlen: + cached = wrapped_precompute_freqs_cis( + config, + rope_head_dim=rope_head_dim, + base=base, + yarn_disabled=yarn_disabled, + seqlen=seqlen, + ).to(device) + _DEVICE_ROPE_CACHES[key] = cached + return cached + + +def configure_rope_cache( + module: nn.Module, + config: TransformerConfig, + *, + rope_head_dim: int, + base: float, + yarn_disabled: bool = False, +) -> None: + setattr(module, "_dsv4_rope_config", config) + setattr(module, "_dsv4_rope_head_dim", int(rope_head_dim)) + setattr(module, "_dsv4_rope_base", float(base)) + setattr(module, "_dsv4_rope_yarn_disabled", bool(yarn_disabled)) + module.register_buffer( + "freqs_cis", torch.empty(0, dtype=torch.complex64), persistent=False + ) + + +def materialize_rope_cache( + module: nn.Module, device: torch.device | None = None, seqlen: int = 65536 +) -> bool: + config = getattr(module, "_dsv4_rope_config", None) + if config is None: + return False + if device is None: + try: + device = next(module.parameters()).device + except StopIteration: + device = torch.device("cpu") + if device.type == "meta": + return False + freqs_cis = _get_device_rope_cache( + config, + rope_head_dim=int(getattr(module, "_dsv4_rope_head_dim")), + base=float(getattr(module, "_dsv4_rope_base")), + yarn_disabled=bool(getattr(module, "_dsv4_rope_yarn_disabled")), + device=device, + seqlen=seqlen, + ) + module.freqs_cis = freqs_cis + return True + + +def get_rope_cache( + module: nn.Module, *, seqlen: int, device: torch.device +) -> torch.Tensor: + freqs_cis = cast(torch.Tensor, module.freqs_cis) + if ( + freqs_cis.numel() == 0 + or freqs_cis.device != device + or freqs_cis.shape[0] < seqlen + ): + materialize_rope_cache(module, device, seqlen=seqlen) + freqs_cis = cast(torch.Tensor, module.freqs_cis) + return freqs_cis[:seqlen] + + +def get_rope_cache_at_positions( + module: nn.Module, *, position_ids: torch.Tensor, device: torch.device +) -> torch.Tensor: + freqs_cis = cast(torch.Tensor, module.freqs_cis) + safe_positions = position_ids.to(device=device, dtype=torch.long).clamp_min(0) + seqlen = max(1, safe_positions.numel()) + if ( + freqs_cis.numel() == 0 + or freqs_cis.device != device + or freqs_cis.shape[0] < seqlen + ): + materialize_rope_cache(module, device, seqlen=seqlen) + freqs_cis = cast(torch.Tensor, module.freqs_cis) + return freqs_cis.index_select(0, safe_positions.reshape(-1)).view( + *safe_positions.shape, + freqs_cis.shape[-1], + ) diff --git a/src/art/megatron/dsv4/spec.py b/src/art/megatron/dsv4/spec.py new file mode 100644 index 000000000..2c6f6204b --- /dev/null +++ b/src/art/megatron/dsv4/spec.py @@ -0,0 +1,48 @@ +from copy import deepcopy +from typing import Any, cast + +from megatron.core.models.gpt.gpt_layer_specs import get_gpt_decoder_block_spec +from megatron.core.transformer.moe.moe_layer import MoELayer +from megatron.core.transformer.spec_utils import ModuleSpec + +from art.megatron.dsv4.deepseek_v4 import DeepSeekV4Attention +from art.megatron.dsv4.layer import ( + Dsv4FinalNorm, + Dsv4MoELayer, + Dsv4Router, + Dsv4TransformerLayer, +) + +try: + import transformer_engine # noqa: F401 + + HAVE_TE = True +except (ImportError, ModuleNotFoundError): + HAVE_TE = False + + +def get_dsv4_decoder_block_spec(config: Any, vp_stage: int | None = None) -> Any: + config.moe_layer_freq = [1] * int(config.num_layers) + block_spec = deepcopy( + get_gpt_decoder_block_spec( + config, + use_transformer_engine=HAVE_TE, + normalization="RMSNorm", + vp_stage=vp_stage, + ) + ) + block_spec.layer_norm = Dsv4FinalNorm + for layer_spec in block_spec.layer_specs or (): + layer_spec.module = Dsv4TransformerLayer + submodules = cast(Any, layer_spec.submodules) + submodules.input_layernorm = submodules.pre_mlp_layernorm + submodules.self_attention = ModuleSpec( + module=DeepSeekV4Attention, + submodules=None, + metainfo={"fuse_input_layernorm": False}, + ) + mlp = submodules.mlp + if isinstance(mlp, ModuleSpec) and mlp.module == MoELayer: + mlp.module = Dsv4MoELayer + mlp.submodules.router = Dsv4Router + return block_spec diff --git a/src/art/megatron/dsv4/tokenizer.py b/src/art/megatron/dsv4/tokenizer.py new file mode 100644 index 000000000..1fb33adf2 --- /dev/null +++ b/src/art/megatron/dsv4/tokenizer.py @@ -0,0 +1,93 @@ +from __future__ import annotations + +import copy +from typing import Any + +from transformers.tokenization_utils_base import PreTrainedTokenizerBase + +from art.megatron.dsv4.encoding import encode_messages + +DSV4_CHAT_TEMPLATE_MARKER = "deepseek_v4_python_encoder enable_thinking" + + +def has_configured_chat_template(internal_config: Any) -> bool: + return ( + internal_config.get("chat_template") is not None + or internal_config.get("chat_template_path") is not None + ) + + +def get_dsv4_tokenizer( + tokenizer: PreTrainedTokenizerBase, +) -> PreTrainedTokenizerBase: + if getattr(tokenizer, "_art_dsv4_chat_template_wrapped", False): + return tokenizer + + wrapped = copy.copy(tokenizer) + added_vocab = tokenizer.get_added_vocab() + added_vocab_size = len(added_vocab) + tokenizer_vocab_size = tokenizer.vocab_size + + class _ArtDsv4Tokenizer(tokenizer.__class__): # type: ignore[misc, valid-type] + def apply_chat_template( + self, + messages: list[dict[str, Any]], + tools: list[dict[str, Any]] | None = None, + **kwargs: Any, + ) -> str | list[int]: + thinking = bool(kwargs.get("thinking", False)) or bool( + kwargs.get("enable_thinking", False) + ) + thinking_mode = "thinking" if thinking else "chat" + conversation = kwargs.get("conversation", messages) + rendered_messages = list(conversation) + if tools: + rendered_messages.insert(0, {"role": "system", "tools": tools}) + + reasoning_effort = kwargs.get("reasoning_effort") + if not isinstance(reasoning_effort, str): + reasoning_effort = None + elif reasoning_effort == "none": + thinking_mode = "chat" + reasoning_effort = None + elif reasoning_effort in ("max", "xhigh"): + reasoning_effort = "max" + else: + reasoning_effort = "high" + + prompt = encode_messages( + rendered_messages, + thinking_mode=thinking_mode, + drop_thinking=kwargs.get("drop_thinking", True), + reasoning_effort=reasoning_effort, + ) + if not kwargs.get("tokenize", True): + return prompt + tokenizer_kwargs = { + key: kwargs[key] + for key in ("truncation", "max_length") + if key in kwargs + } + return self.encode( + prompt, + add_special_tokens=False, + **tokenizer_kwargs, + ) + + def num_special_tokens_to_add(self) -> int: + return len(self.encode("")) + + def __len__(self) -> int: + return tokenizer_vocab_size + added_vocab_size + + def get_added_vocab(self) -> dict[str, int]: + return added_vocab.copy() + + def __reduce__(self) -> Any: + return get_dsv4_tokenizer, (tokenizer,) + + _ArtDsv4Tokenizer.__name__ = f"ArtDsv4{tokenizer.__class__.__name__}" + wrapped.__class__ = _ArtDsv4Tokenizer + wrapped.chat_template = DSV4_CHAT_TEMPLATE_MARKER + setattr(wrapped, "_art_dsv4_chat_template_wrapped", True) + return wrapped diff --git a/src/art/megatron/dsv4/utils.py b/src/art/megatron/dsv4/utils.py new file mode 100644 index 000000000..3be094e67 --- /dev/null +++ b/src/art/megatron/dsv4/utils.py @@ -0,0 +1,32 @@ +import torch +from torch import nn + + +def freeze_parameters_as_buffers(module: nn.Module) -> None: + for child in module.modules(): + for name, param in list(child.named_parameters(recurse=False)): + del child._parameters[name] + child.register_buffer(name, param.detach(), persistent=True) + + +def rotate_activation(x: torch.Tensor) -> torch.Tensor: + """Scaled Hadamard transform over the last dimension. + + DeepSeek-V4 uses this before activation FP8 simulation in the indexer and + compressor. The supported ART dimensions are powers of two, specifically + 128 and 512 in the DSV4 Flash config. + """ + if x.dtype not in (torch.bfloat16, torch.float32): + raise TypeError(f"rotate_activation supports bf16/fp32, got {x.dtype}") + width = int(x.size(-1)) + if width <= 0 or width & (width - 1): + raise ValueError(f"Hadamard width must be a power of two, got {width}.") + y = x.float() + h = 1 + while h < width: + y = y.reshape(*x.shape[:-1], width // (2 * h), 2, h) + left, right = y.unbind(dim=-2) + y = torch.stack((left + right, left - right), dim=-2) + y = y.reshape(*x.shape[:-1], width) + h *= 2 + return (y * (width**-0.5)).to(x.dtype) diff --git a/src/art/megatron/dsv4/v4_indexer.py b/src/art/megatron/dsv4/v4_indexer.py new file mode 100644 index 000000000..6a90a055e --- /dev/null +++ b/src/art/megatron/dsv4/v4_indexer.py @@ -0,0 +1,378 @@ +from typing import Any, cast + +import einops +from megatron.core.extensions.transformer_engine import TELinear +from megatron.core.process_groups_config import ProcessGroupCollection +from megatron.core.tensor_parallel.mappings import gather_from_sequence_parallel_region +from megatron.core.transformer.module import MegatronModule +from megatron.core.transformer.transformer_config import TransformerConfig +import torch + +from art.megatron.dsv4.compressor import ( + DeepSeekV4Compressor, + Dsv4CompressionLayout, + compressed_layout_visibility, +) +from art.megatron.dsv4.kernel.tilelang_indexer_fwd import ( + indexer_topk_interface, + shared_prefix_indexer_topk_interface, +) +from art.megatron.dsv4.rope import ( + apply_rotary_emb, + configure_rope_cache, + get_rope_cache, + get_rope_cache_at_positions, +) +from art.megatron.dsv4.utils import freeze_parameters_as_buffers, rotate_activation + +_INDEXER_QUERY_BLOCK = 512 +_INDEXER_HEAD_BLOCK = 8 + + +def _make_causal_cu_seqlens(seq_len_q, seq_len_kv, compress_ratio, device): + del seq_len_kv + positions = torch.arange(seq_len_q, device=device, dtype=torch.int32) + cu_seqlen_ks = torch.zeros(seq_len_q, device=device, dtype=torch.int32) + cu_seqlen_ke = ((positions + 1) // compress_ratio).to(torch.int32) + return cu_seqlen_ks, cu_seqlen_ke + + +@torch.compiler.disable +def _exact_indexer_topk( + q: torch.Tensor, + k: torch.Tensor, + weights: torch.Tensor, + cu_seqlen_ks: torch.Tensor, + cu_seqlen_ke: torch.Tensor, + topk: int, + *, + shared_layout: Dsv4CompressionLayout | None = None, + position_ids: torch.Tensor | None = None, + group_ids: torch.Tensor | None = None, + parent_ids: torch.Tensor | None = None, +) -> torch.Tensor: + """Return frozen DSV4 indexer topk using the reference score equation. + + The indexer routing is discrete, so small BF16 fused-kernel score drift can + choose different compressed KV rows and send CSA gradients to different + windows. Keep the production path exact against the HF/Megatron reference: + fp32 dot, ReLU, head weighting, compressed-causal mask, then topk. Query + and head chunking avoid materializing the full [S, H, S / 4] score tensor. + """ + + seqlen, batch, heads, _ = q.shape + seqlen_kv = k.shape[0] + actual_topk = min(topk, seqlen_kv) + out = torch.empty(batch, seqlen, actual_topk, device=q.device, dtype=torch.int32) + if actual_topk == 0: + return out + + kv_ids = torch.arange(seqlen_kv, device=q.device) + q_block = _INDEXER_QUERY_BLOCK + h_block = _INDEXER_HEAD_BLOCK + with torch.no_grad(): + for b in range(batch): + k_b = k[:, b].float() + for q_start in range(0, seqlen, q_block): + q_end = min(q_start + q_block, seqlen) + scores = torch.zeros( + q_end - q_start, seqlen_kv, device=q.device, dtype=torch.float32 + ) + for h_start in range(0, heads, h_block): + h_end = min(h_start + h_block, heads) + dot = torch.einsum( + "qhd,kd->qhk", + q[q_start:q_end, b, h_start:h_end].float(), + k_b, + ).relu_() + scores += ( + dot + * weights[q_start:q_end, b, h_start:h_end].float().unsqueeze(-1) + ).sum(dim=1) + if shared_layout is None: + visible = ( + kv_ids.unsqueeze(0) >= cu_seqlen_ks[q_start:q_end, None] + ) & (kv_ids.unsqueeze(0) < cu_seqlen_ke[q_start:q_end, None]) + else: + if position_ids is None or group_ids is None or parent_ids is None: + raise ValueError( + "DSV4 shared-prefix indexer requires position/group metadata." + ) + visible = compressed_layout_visibility( + shared_layout, + position_ids=position_ids[b : b + 1], + group_ids=group_ids[b : b + 1], + parent_ids=parent_ids[b : b + 1], + q_start=q_start, + q_end=q_end, + )[0] + scores.masked_fill_(~visible, float("-inf")) + top_scores, top_indices = scores.topk(actual_topk, dim=-1) + out[b, q_start:q_end] = torch.where( + torch.isneginf(top_scores), + torch.full_like(top_indices, -1), + top_indices, + ) + return out + + +@torch.compiler.disable +def _tilelang_indexer_topk( + q: torch.Tensor, + k: torch.Tensor, + weights: torch.Tensor, + cu_seqlen_ks: torch.Tensor, + cu_seqlen_ke: torch.Tensor, + topk: int, + *, + shared_layout: Dsv4CompressionLayout | None = None, + position_ids: torch.Tensor | None = None, + group_ids: torch.Tensor | None = None, + parent_ids: torch.Tensor | None = None, + shared_layout_i32: tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor] + | None = None, +) -> torch.Tensor: + seqlen, batch, heads, _ = q.shape + seqlen_kv = k.shape[0] + actual_topk = min(topk, seqlen_kv) + out = torch.empty(batch, seqlen, actual_topk, device=q.device, dtype=torch.int32) + if actual_topk == 0: + return out + + internal_q_block = max(1, 128 // heads) + q_block = (_INDEXER_QUERY_BLOCK // internal_q_block) * internal_q_block + q_block = max(internal_q_block, q_block) + fast_seqlen = (seqlen // internal_q_block) * internal_q_block + + with torch.no_grad(): + for b in range(batch): + if shared_layout is not None: + if position_ids is None or group_ids is None or parent_ids is None: + raise ValueError( + "DSV4 shared-prefix indexer requires position/group metadata." + ) + if shared_layout_i32 is None: + entry_group_ids = shared_layout.entry_group_ids.to(torch.int32) + entry_parent_visible = shared_layout.entry_parent_visible.to( + torch.int32 + ) + entry_end_positions = shared_layout.entry_end_positions.to( + torch.int32 + ) + entry_valid = shared_layout.entry_valid.to(torch.int32) + else: + ( + entry_group_ids, + entry_parent_visible, + entry_end_positions, + entry_valid, + ) = shared_layout_i32 + position_b = position_ids[b].to(torch.int32).contiguous() + group_b = group_ids[b].to(torch.int32).contiguous() + parent_b = parent_ids[b].to(torch.int32).contiguous() + entry_group_b = entry_group_ids[b].contiguous() + entry_parent_visible_b = entry_parent_visible[b].contiguous() + entry_end_b = entry_end_positions[b].contiguous() + entry_valid_b = entry_valid[b].contiguous() + for q_start in range(0, fast_seqlen, q_block): + q_end = min(q_start + q_block, fast_seqlen) + q_slice = q[q_start:q_end, b].contiguous() + k_slice = k[:, b].contiguous() + weights_slice = weights[q_start:q_end, b].contiguous() + if shared_layout is None: + out[b, q_start:q_end] = indexer_topk_interface( + q_slice, + k_slice, + weights_slice, + cu_seqlen_ks[q_start:q_end].contiguous(), + cu_seqlen_ke[q_start:q_end].contiguous(), + topk, + ) + else: + out[b, q_start:q_end] = shared_prefix_indexer_topk_interface( + q_slice, + k_slice, + weights_slice, + position_b[q_start:q_end], + group_b[q_start:q_end], + parent_b[q_start:q_end], + entry_group_b, + entry_parent_visible_b, + entry_end_b, + entry_valid_b, + topk, + ) + if fast_seqlen != seqlen: + out[:, fast_seqlen:] = _exact_indexer_topk( + q[fast_seqlen:], + k, + weights[fast_seqlen:], + cu_seqlen_ks[fast_seqlen:], + cu_seqlen_ke[fast_seqlen:], + topk, + shared_layout=shared_layout, + position_ids=None + if position_ids is None + else position_ids[:, fast_seqlen:], + group_ids=None if group_ids is None else group_ids[:, fast_seqlen:], + parent_ids=None if parent_ids is None else parent_ids[:, fast_seqlen:], + ) + return out + + +class V4Indexer(MegatronModule): + """DSA Indexer for DeepSeek-V4 C4 layers.""" + + def __init__(self, config: TransformerConfig, pg_collection=None): + super().__init__(config=config) + cfg = cast(Any, config) + init_method = config.init_method + if init_method is None: + raise RuntimeError("DeepSeek-V4 indexer requires config.init_method.") + + self.hidden_size = config.hidden_size + self.q_lora_rank = ( + int(cfg.q_lora_rank) if cfg.q_lora_rank is not None else config.hidden_size + ) + self.index_n_heads = int(cfg.dsa_indexer_n_heads) + self.index_head_dim = int(cfg.dsa_indexer_head_dim) + self.index_topk = int(cfg.dsa_indexer_topk) + self.rope_head_dim = int(cfg.qk_pos_emb_head_dim) + self.compress_ratio = 4 + + if pg_collection is None: + pg_collection = ProcessGroupCollection.use_mpu_process_groups( + required_pgs=["tp"] + ) + self.pg_collection = pg_collection + + self.linear_wq_b = TELinear( + self.q_lora_rank, + self.index_n_heads * self.index_head_dim, + config=config, + init_method=init_method, + bias=False, + skip_bias_add=False, + skip_weight_param_allocation=False, + parallel_mode="duplicated", + ) + + self.linear_weights_proj = TELinear( + self.hidden_size, + self.index_n_heads, + config=config, + init_method=init_method, + bias=False, + skip_bias_add=False, + skip_weight_param_allocation=False, + parallel_mode="duplicated", + ) + + self.compressor = DeepSeekV4Compressor( + config=config, + head_dim=self.index_head_dim, + compress_ratio=self.compress_ratio, + rotate=True, + cp_group=None, + ) + + rope_base = ( + cfg.dsv4_compress_rope_theta if self.compress_ratio else cfg.rotary_base + ) + configure_rope_cache( + self, config, rope_head_dim=self.rope_head_dim, base=rope_base + ) + freeze_parameters_as_buffers(self) + + def forward( + self, + x: torch.Tensor, + qr: torch.Tensor, + mask=None, + packed_seq_params=None, + position_ids: torch.Tensor | None = None, + shared_layout: Dsv4CompressionLayout | None = None, + group_ids: torch.Tensor | None = None, + parent_ids: torch.Tensor | None = None, + shared_layout_i32: tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor] + | None = None, + ): + """Forward pass. + + Args: + x: hidden states [seqlen, batch, hidden_size] + qr: low-rank query [seqlen, batch, q_lora_rank] + mask: unused (causal mask generated internally via cu_seqlens) + packed_seq_params: unused + + Returns: + topk_indices: [batch, seqlen, index_topk] int64 + """ + + # ========================================= + # Gather inputs if SP is enabled + # ========================================= + if self.config.sequence_parallel and self.pg_collection.tp.size() > 1: + x = gather_from_sequence_parallel_region(x, group=self.pg_collection.tp) + qr = gather_from_sequence_parallel_region(qr, group=self.pg_collection.tp) + + seqlen, bsz, _ = x.size() + + q, _ = self.linear_wq_b(qr) + q = q.reshape(seqlen, bsz, self.index_n_heads, self.index_head_dim) + + rd = self.rope_head_dim + cp_group = getattr(self.pg_collection, "cp", None) + if cp_group is not None and cp_group.size() != 1: + raise RuntimeError( + "DeepSeek-V4 non-CP indexer received context_parallel_size > 1." + ) + if position_ids is None: + freqs_cis = get_rope_cache(self, seqlen=seqlen, device=x.device) + else: + freqs_cis = get_rope_cache_at_positions( + self, position_ids=position_ids, device=x.device + ) + q = q.clone() + q = einops.rearrange(q, "s b ... -> b s ...") + apply_rotary_emb(q[..., -rd:], freqs_cis) + q = einops.rearrange(q, "b s ... -> s b ...") + + q = rotate_activation(q) + + k = self.compressor( + x, + position_ids=position_ids, + shared_layout=shared_layout, + ) + if k.shape[0] == 0: + return torch.empty( + bsz, + seqlen, + 0, + device=x.device, + dtype=torch.long, + ) + + weights, _ = self.linear_weights_proj(x) + softmax_scale = self.index_head_dim**-0.5 + weights = weights * (self.index_n_heads**-0.5) * softmax_scale + + seqlen_global = seqlen + seqlen_kv = k.shape[0] + cu_ks, cu_ke = _make_causal_cu_seqlens( + seqlen_global, seqlen_kv, self.compress_ratio, q.device + ) + return _tilelang_indexer_topk( + q, + k, + weights.float(), + cu_ks, + cu_ke, + self.index_topk, + shared_layout=shared_layout, + position_ids=position_ids, + group_ids=group_ids, + parent_ids=parent_ids, + shared_layout_i32=shared_layout_i32, + ) diff --git a/src/art/megatron/kernels/cute_grouped_lora_quack.py b/src/art/megatron/kernels/cute_grouped_lora_quack.py index f93bdb663..c0c9a70d6 100644 --- a/src/art/megatron/kernels/cute_grouped_lora_quack.py +++ b/src/art/megatron/kernels/cute_grouped_lora_quack.py @@ -1,6 +1,12 @@ from __future__ import annotations +from getpass import getuser +import hashlib +import importlib import os +from pathlib import Path +import pickle +import tempfile from typing import Any, cast from quack.gemm import gemm as quack_gemm @@ -29,6 +35,65 @@ def _env_positive_int(name: str) -> int | None: return value +def _quack_compile_lock_dir() -> Path: + path = ( + Path(os.environ["ART_QUACK_COMPILE_LOCK_DIR"]) + if "ART_QUACK_COMPILE_LOCK_DIR" in os.environ + else Path(tempfile.gettempdir()) / getuser() / "art_quack_compile_locks" + ) + path.mkdir(parents=True, exist_ok=True) + return path + + +def _cache_key(args: tuple[Any, ...], kwargs: dict[str, Any]) -> tuple[Any, ...]: + return args + tuple(sorted(kwargs.items())) if kwargs else args + + +def _key_hash(key: tuple[Any, ...]) -> str: + try: + data = pickle.dumps(key) + except Exception: + data = repr(key).encode() + return hashlib.sha256(data).hexdigest() + + +def _install_quack_compile_lock() -> None: + """Serialize QuACK CuTe compilation misses across distributed ranks. + + QuACK protects disk-cache load/store with file locks, but it compiles cache + misses outside that lock. ETP runs can make every rank compile the same + grouped-LoRA backward GEMM at once, which has crashed inside CuTe DSL codegen. + Hot in-process cache hits bypass this guard, so kernel launches stay unlocked. + """ + import quack.cache_utils as quack_cache_utils + + gemm_module = importlib.import_module("quack.gemm") + compile_gemm = getattr(gemm_module, "_compile_gemm") + if bool(getattr(compile_gemm, "_art_compile_lock_wrapped", False)): + return + cache = getattr(compile_gemm, "cache", None) + + def locked_compile_gemm(*args: Any, **kwargs: Any) -> Any: + key = _cache_key(args, kwargs) + if isinstance(cache, dict) and key in cache: + return compile_gemm(*args, **kwargs) + lock_path = _quack_compile_lock_dir() / f"{_key_hash(key)}.lock" + with quack_cache_utils.FileLock(lock_path, exclusive=True, timeout=600): + return compile_gemm(*args, **kwargs) + + locked_compile_gemm_any = cast(Any, locked_compile_gemm) + locked_compile_gemm_any.cache = getattr(compile_gemm, "cache", None) + locked_compile_gemm_any.cache_clear = getattr(compile_gemm, "cache_clear", None) + locked_compile_gemm_any.cache_info = getattr(compile_gemm, "cache_info", None) + locked_compile_gemm_any._art_compile_lock_wrapped = True + setattr(gemm_module, "_compile_gemm", locked_compile_gemm) + + +def _quack_gemm(*args: Any, **kwargs: Any) -> Any: + _install_quack_compile_lock() + return quack_gemm(*args, **kwargs) + + def _tokens_per_expert_to_tensor( tokens_per_expert: list[int] | torch.Tensor, ) -> torch.Tensor: @@ -216,7 +281,7 @@ def _varlen_quack_gemm( raise ValueError( f"Output tensor must match input device/dtype, got {out.device}/{out.dtype}" ) - quack_gemm( + _quack_gemm( a, b, out, @@ -252,7 +317,7 @@ def _varlen_quack_gemm_k( device=a.device, dtype=a.dtype, ) - quack_gemm( + _quack_gemm( a, b, out, diff --git a/src/art/megatron/model_support/__init__.py b/src/art/megatron/model_support/__init__.py index de569af7d..7e1a8b374 100644 --- a/src/art/megatron/model_support/__init__.py +++ b/src/art/megatron/model_support/__init__.py @@ -24,8 +24,10 @@ is_model_support_registered, list_model_support_specs, model_requires_merged_rollout, + model_supports_context_parallel, model_uses_expert_parallel, native_vllm_lora_status_for_model, + vllm_lora_config_for_model, ) from art.megatron.model_support.spec import ( ArchitectureReport, @@ -89,7 +91,9 @@ def __getattr__(name: str): "is_model_support_registered", "list_model_support_specs", "model_uses_expert_parallel", + "model_supports_context_parallel", "model_requires_merged_rollout", "native_vllm_lora_status_for_model", + "vllm_lora_config_for_model", "summarize_layer_families", ] diff --git a/src/art/megatron/model_support/handlers/__init__.py b/src/art/megatron/model_support/handlers/__init__.py index e45e74761..a8da0158d 100644 --- a/src/art/megatron/model_support/handlers/__init__.py +++ b/src/art/megatron/model_support/handlers/__init__.py @@ -48,6 +48,14 @@ "art.megatron.model_support.handlers.qwen3_5", "Qwen35MoeHandler", ), + "DSV4_HANDLER": ( + "art.megatron.model_support.handlers.dsv4", + "DSV4_HANDLER", + ), + "Dsv4Handler": ( + "art.megatron.model_support.handlers.dsv4", + "Dsv4Handler", + ), "GPT_OSS_MOE_HANDLER": ( "art.megatron.model_support.handlers.gpt_oss", "GPT_OSS_MOE_HANDLER", diff --git a/src/art/megatron/model_support/handlers/default_dense.py b/src/art/megatron/model_support/handlers/default_dense.py index bf77d50e7..cc7128215 100644 --- a/src/art/megatron/model_support/handlers/default_dense.py +++ b/src/art/megatron/model_support/handlers/default_dense.py @@ -1,4 +1,4 @@ -from typing import Any, Sequence +from typing import Any, Literal, Sequence import torch @@ -10,6 +10,7 @@ LayerFamilyInstance, RolloutWeightsMode, SharedExpertCompileState, + SharedPrefixModelStateContext, ) _CONTEXT_PARALLEL_ATTENTION_WORKAROUND_FLAG = "context_parallel_attention" @@ -37,6 +38,7 @@ class DefaultDenseHandler: key = "default_dense" build_gdn_execution_spec = False is_moe = False + cp_supported = True native_vllm_lora_status = "disabled" def identity_lora_model_config(self, base_config: Any) -> Any: @@ -96,6 +98,18 @@ def configure_provider_for_runtime(self, provider: Any) -> None: del provider return None + def default_chat_template(self) -> str | None: + return None + + def configure_tokenizer( + self, + tokenizer: Any, + *, + internal_config: Any, + ) -> Any: + del internal_config + return tokenizer + def vllm_engine_args( self, *, @@ -111,6 +125,23 @@ def install_preprocess_patch(self, model_chunks: Sequence[Any]) -> None: del model_chunks return None + def build_shared_prefix_model_state( + self, + context: SharedPrefixModelStateContext, + ) -> dict[str, Any]: + del context + return {} + + def correctness_precision(self) -> Literal["bf16", "fp32"]: + return "fp32" + + def correctness_use_fp32_lora_reference(self) -> bool: + return True + + def correctness_phase_pass_fns(self, oracle_harness: Any) -> dict[str, Any] | None: + del oracle_harness + return None + def to_vllm_lora_tensors( self, tensors: dict[str, torch.Tensor], @@ -119,6 +150,9 @@ def to_vllm_lora_tensors( ) -> tuple[dict[str, torch.Tensor], dict[str, Any]]: return tensors, adapter_config + def to_vllm_lora_config(self, adapter_config: dict[str, Any]) -> dict[str, Any]: + return adapter_config + def from_vllm_lora_tensors( self, tensors: dict[str, torch.Tensor], diff --git a/src/art/megatron/model_support/handlers/dsv4.py b/src/art/megatron/model_support/handlers/dsv4.py new file mode 100644 index 000000000..500df992e --- /dev/null +++ b/src/art/megatron/model_support/handlers/dsv4.py @@ -0,0 +1,1198 @@ +from __future__ import annotations + +import hashlib +import os +import re +from typing import Any, Literal, Sequence, cast + +import torch + +from art.megatron.model_support.handlers.default_dense import ( + DefaultMoeHandler, + _compile_workaround_flags_for_provider, + _require_moe_experts, +) +from art.megatron.model_support.spec import ( + CompileWorkaroundConfig, + LayerFamilyInstance, + SharedPrefixModelStateContext, +) + +_ORACLE_HIDDEN_SIZE = 512 +_ORACLE_Q_LORA_RANK = 128 +_ORACLE_NUM_ATTENTION_HEADS = 16 +_ORACLE_NUM_EXPERTS = 4 +_ORACLE_NUM_EXPERTS_PER_TOK = 1 +_ORACLE_FFN_HIDDEN_SIZE = 128 +_ORACLE_INDEX_HEADS = 1 +_ORACLE_INDEX_TOPK = 1024 +_VALIDATION_NUM_LAYERS_ENV = "ART_DSV4_VALIDATION_NUM_LAYERS" +_ORACLE_EXPERT_WEIGHT_RE = re.compile(r"\.mlp\.experts\..*\.weight(?P\d+)$") +_DSV4_ART_MOE_EXPERT_KEY_RE = re.compile( + r"^(?P.*\.mlp\.experts)\.(?P\d+)\." + r"(?Pgate_proj|up_proj|down_proj)\.(?Plora_[AB])\.weight$" +) +_DSV4_VLLM_MOE_KEY_RE = re.compile( + r"^(?P.*\.mlp\.experts)\." + r"(?:(?Pbase_layer)\.)?(?Plora_[AB])\.weight$" +) +_DSV4_VLLM_MOE_EXPERT_KEY_RE = re.compile( + r"^(?P.*\.ffn\.experts)\.(?P\d+)\." + r"(?Pw1|w2|w3)\.(?Plora_[AB])\.weight$" +) +_DSV4_MOE_COMPILE_WORKAROUND_FLAGS = ( + "alltoall_dtoh", + "alltoall_dispatch_preprocess", + "deepep_dispatch_combine", + "deepep_permute_restore", + "te_triton_permute_with_mask_map", +) + + +class Dsv4Handler(DefaultMoeHandler): + key = "dsv4" + is_moe = True + cp_supported = False + native_vllm_lora_status = "validated" + + def identity_lora_model_config(self, base_config: Any) -> Any: + self.ensure_hf_reference_registered() + return base_config + + def patch_provider(self, provider: Any, bridge: Any) -> None: + del bridge + from art.megatron.dsv4.spec import get_dsv4_decoder_block_spec + + provider.transformer_layer_spec = get_dsv4_decoder_block_spec + if int(getattr(provider, "context_parallel_size", 1) or 1) != 1: + raise RuntimeError( + "DSV4 model support in this worktree does not implement context parallelism." + ) + + def configure_provider_for_runtime(self, provider: Any) -> None: + provider.mtp_num_layers = None + provider.moe_shared_expert_overlap = False + raw_num_layers = os.environ.get(_VALIDATION_NUM_LAYERS_ENV) + if raw_num_layers is None: + return + num_layers = int(raw_num_layers) + if num_layers < 1: + raise ValueError(f"{_VALIDATION_NUM_LAYERS_ENV} must be positive") + provider.num_layers = num_layers + provider.moe_layer_freq = [1] * num_layers + ratios = list(getattr(provider, "dsv4_compress_ratios", ()) or ()) + if ratios: + if num_layers > len(ratios): + raise ValueError( + f"{_VALIDATION_NUM_LAYERS_ENV}={num_layers} exceeds " + f"dsv4_compress_ratios length {len(ratios)}" + ) + provider.dsv4_compress_ratios = ratios[:num_layers] + + def default_chat_template(self) -> str | None: + return None + + def configure_tokenizer( + self, + tokenizer: Any, + *, + internal_config: Any, + ) -> Any: + from art.megatron.dsv4.tokenizer import ( + get_dsv4_tokenizer, + has_configured_chat_template, + ) + + if has_configured_chat_template(internal_config): + return tokenizer + return get_dsv4_tokenizer(tokenizer) + + def build_shared_prefix_model_state( + self, + context: SharedPrefixModelStateContext, + ) -> dict[str, Any]: + if context.input_pos is None: + raise RuntimeError( + "DSV4 shared-prefix compression layouts require input_pos." + ) + from art.megatron.dsv4.compressor import ( + Dsv4SharedPrefixState, + build_shared_prefix_compression_layouts, + ) + + return { + "dsv4": Dsv4SharedPrefixState( + compression_layouts=build_shared_prefix_compression_layouts( + position_ids=context.input_pos, + group_ids=context.group_ids, + parent_ids=context.parent_ids, + device=context.device, + ) + ) + } + + def correctness_precision(self) -> Literal["bf16", "fp32"]: + return "bf16" + + def correctness_use_fp32_lora_reference(self) -> bool: + return False + + def correctness_phase_pass_fns(self, oracle_harness: Any) -> dict[str, Any]: + non_zero_scales = {"typical_abs_scale": 0.0, "candidate_abs_scale": 0.0} + fwd = oracle_harness.MetricThresholdRule( + limits={"mean_abs_pct": 3.0}, + minimums=non_zero_scales, + ) + loss = oracle_harness.MetricThresholdRule(limits={"mean_abs_pct": 3.0}) + grad = oracle_harness.MetricThresholdRule( + limits={"mean_abs_pct": 5.0}, + minimums=non_zero_scales, + ) + router_topk = oracle_harness.MetricThresholdRule( + limits={"topk_mismatch_fraction": 0.0, "top1_mismatch_fraction": 0.0} + ) + return { + "forward": fwd, + "outputs": fwd, + "losses": loss, + "grads": grad, + "deltas": grad, + "router_scores": fwd, + "router_topk_ids": router_topk, + } + + def identity_lora_target_parameters( + self, + model: Any, + *, + target_modules: list[str], + ) -> list[str]: + target_set = set(target_modules) + + def include(name: str) -> bool: + if ".self_attn.compressor.indexer." in name: + return False + if "q_a_proj" in target_set and name.endswith(".self_attn.q_a_proj.weight"): + return True + if "q_b_proj" in target_set and name.endswith(".self_attn.q_b_proj.weight"): + return True + if "kv_proj" in target_set and name.endswith(".self_attn.kv_proj.weight"): + return True + if "o_a_proj" in target_set and name.endswith(".self_attn.o_a_proj.weight"): + return True + if "o_b_proj" in target_set and name.endswith(".self_attn.o_b_proj.weight"): + return True + if "compressor.kv_proj" in target_set and name.endswith( + ".self_attn.compressor.kv_proj.weight" + ): + return True + if "compressor.gate_proj" in target_set and name.endswith( + ".self_attn.compressor.gate_proj.weight" + ): + return True + if ( + "gate_proj" in target_set + and ".mlp." in name + and name.endswith(".gate_proj.weight") + ): + return True + if ( + "up_proj" in target_set + and ".mlp." in name + and name.endswith(".up_proj.weight") + ): + return True + if ( + "down_proj" in target_set + and ".mlp." in name + and name.endswith(".down_proj.weight") + ): + return True + if "experts" in target_set and name.endswith( + (".mlp.experts.gate_up_proj", ".mlp.experts.down_proj") + ): + return True + return False + + return [name for name, _ in model.named_parameters() if include(name)] + + def install_preprocess_patch(self, model_chunks: Sequence[Any]) -> None: + from megatron.core.models.gpt.gpt_model import GPTModel + + from art.megatron.dsv4.deepseek_v4 import DeepSeekV4Attention + from art.megatron.dsv4.layer import Dsv4MoELayer + + for chunk in list(model_chunks): + module: Any = chunk + while hasattr(module, "module"): + module = module.module + gpt_module = ( + module + if isinstance(module, GPTModel) + else cast(GPTModel, getattr(module, "language_model")) + ) + preprocess = gpt_module._preprocess + + def preprocess_hook( + *args: Any, _preprocess=preprocess, _gpt=gpt_module, **kwargs: Any + ): + input_ids = kwargs.get("input_ids") + position_ids = kwargs.get("position_ids") + for child in _gpt.decoder.modules(): + if isinstance(child, Dsv4MoELayer): + child.set_input_ids( + input_ids if isinstance(input_ids, torch.Tensor) else None + ) + if isinstance(child, DeepSeekV4Attention): + child.set_position_ids( + position_ids + if isinstance(position_ids, torch.Tensor) + else None + ) + preproc_output = list(_preprocess(*args, **kwargs)) + decoder_input = cast(torch.Tensor, preproc_output[0]) + if not decoder_input.requires_grad and decoder_input.is_leaf: + decoder_input.requires_grad_(True) + table = preproc_output[1] + if isinstance(position_ids, torch.Tensor) and torch.is_tensor(table): + embedding_dim = int(table.shape[-1]) + batch_size, sequence_length = position_ids.shape + gathered = table.view(table.shape[0], embedding_dim).index_select( + 0, position_ids.reshape(-1) + ) + preproc_output[1] = ( + gathered.view(batch_size, sequence_length, embedding_dim) + .permute(1, 0, 2) + .contiguous() + .unsqueeze(2) + ) + return tuple(preproc_output) + + gpt_module._preprocess = preprocess_hook # type: ignore[attr-defined] + + def collect_layer_families(self, provider: Any) -> list[LayerFamilyInstance]: + ratios = list(getattr(provider, "dsv4_compress_ratios", ()) or ()) + + def first_layer_index(ratio: int) -> int | None: + try: + return ratios.index(ratio) + except ValueError: + return None + + return [ + LayerFamilyInstance( + key="dsv4_sliding_attention", layer_index=first_layer_index(0) + ), + LayerFamilyInstance( + key="dsv4_csa_attention", layer_index=first_layer_index(4) + ), + LayerFamilyInstance( + key="dsv4_hca_attention", layer_index=first_layer_index(128) + ), + LayerFamilyInstance(key="grouped_moe_mlp", layer_index=0), + LayerFamilyInstance(key="shared_experts_mlp", layer_index=0), + ] + + def apply_lora_adapters( + self, + model_chunks: Sequence[Any], + provider: Any, + *, + target_modules: list[str], + rank: int, + alpha: int, + ) -> None: + from art.megatron.dsv4.layer import Dsv4TransformerLayer + from art.megatron.dsv4.lora import ( + apply_dsv4_attention_lora, + disable_dsv4_etp_shared_expert_lora_compile, + install_dsv4_te_permutation_static_configs, + ) + from art.megatron.lora import ( + _adapter_model_prefix, + wrap_grouped_moe_experts, + wrap_shared_experts_mlp, + ) + + target_set = set(target_modules) + etp_enabled = int(getattr(provider, "expert_tensor_parallel_size", 1) or 1) > 1 + if etp_enabled: + install_dsv4_te_permutation_static_configs() + for chunk in model_chunks: + for module in chunk.modules(): + if not isinstance(module, Dsv4TransformerLayer): + continue + adapter_model_prefix = _adapter_model_prefix(module) + apply_dsv4_attention_lora( + module.self_attention, + adapter_model_prefix=adapter_model_prefix, + target_modules=target_set, + rank=rank, + alpha=alpha, + ) + wrap_grouped_moe_experts( + _require_moe_experts(module), + adapter_model_prefix=adapter_model_prefix, + target_modules=target_set, + rank=rank, + alpha=alpha, + ) + if getattr(module.mlp, "shared_experts", None) is not None: + wrap_shared_experts_mlp( + module.mlp.shared_experts, + adapter_model_prefix=adapter_model_prefix, + provider=provider, + target_modules=target_set, + rank=rank, + alpha=alpha, + ) + if etp_enabled: + disable_dsv4_etp_shared_expert_lora_compile( + module.mlp.shared_experts + ) + + def build_adapter_weights_by_base( + self, model_chunks: Sequence[Any] + ) -> dict[str, list[Any]]: + from art.megatron.dsv4.layer import Dsv4TransformerLayer + from art.megatron.dsv4.lora import ( + add_dsv4_attention_adapter_weights, + add_dsv4_shared_experts_adapter_weights, + ) + from art.megatron.weights.adapter_export import ( + add_grouped_moe_adapter_weights, + layer_base_prefix, + ) + + adapter_weights_by_base: dict[str, list[Any]] = {} + for chunk in model_chunks: + for module_name, module in chunk.named_modules(): + if not isinstance(module, Dsv4TransformerLayer): + continue + layer_prefix = layer_base_prefix(module, module_name=module_name) + add_dsv4_attention_adapter_weights( + adapter_weights_by_base, + layer_prefix=layer_prefix, + attention=module.self_attention, + ) + add_grouped_moe_adapter_weights( + adapter_weights_by_base, + layer_prefix=layer_prefix, + experts=_require_moe_experts(module), + ) + if getattr(module.mlp, "shared_experts", None) is not None: + add_dsv4_shared_experts_adapter_weights( + adapter_weights_by_base, + layer_prefix=layer_prefix, + shared_experts=module.mlp.shared_experts, + ) + return adapter_weights_by_base + + def iter_merged_vllm_weight_metadata( + self, + weight_export: Any, + ) -> Any: + bridge = getattr(weight_export.bridge, "_model_bridge", None) + metadata_iter = getattr(bridge, "iter_merged_vllm_weight_metadata", None) + if metadata_iter is None: + return None + return metadata_iter(weight_export) + + def from_vllm_lora_tensors( + self, + tensors: dict[str, torch.Tensor], + *, + adapter_config: dict[str, Any], + ) -> dict[str, torch.Tensor]: + return _dsv4_from_vllm_lora_tensors( + tensors, + adapter_config=adapter_config, + ) + + def to_vllm_lora_tensors( + self, + tensors: dict[str, torch.Tensor], + *, + adapter_config: dict[str, Any], + ) -> tuple[dict[str, torch.Tensor], dict[str, Any]]: + return _dsv4_to_vllm_lora_tensors(tensors, adapter_config=adapter_config) + + def to_vllm_lora_config(self, adapter_config: dict[str, Any]) -> dict[str, Any]: + """Translate ART training targets only for restrictive vLLM launches. + + A vLLM-format DSV4 adapter can be loaded by a vLLM server whose + ``target_modules`` filter is unset. ART-managed vLLM launches set that + filter for performance/memory control, so the filter must use + vLLM/Miles module names rather than ART/Megatron training target names. + """ + return _dsv4_vllm_lora_config(adapter_config) + + def compile_workaround_config(self, provider: Any) -> CompileWorkaroundConfig: + return CompileWorkaroundConfig( + flags=_compile_workaround_flags_for_provider( + provider, + _DSV4_MOE_COMPILE_WORKAROUND_FLAGS, + ), + shared_expert_state=self._shared_expert_compile_state(provider), + ) + + def ensure_hf_reference_registered(self) -> None: + from art.megatron.dsv4.hf_config import ensure_dsv4_hf_model_registered + + ensure_dsv4_hf_model_registered() + + def prepare_hf_reference_config(self, config: Any) -> None: + """Puts native HF parity in eager training mode with reduced fit-only axes.""" + if hasattr(config, "quantization_config"): + delattr(config, "quantization_config") + config._experts_implementation = "eager" + self._apply_oracle_shape_overrides(config) + + def hf_reference_from_pretrained_kwargs( + self, *, config: Any, dtype: torch.dtype + ) -> dict[str, Any]: + del config, dtype + return { + "experts_implementation": "eager", + "ignore_mismatched_sizes": True, + "key_mapping": _dsv4_source_to_hf_key_mapping(), + } + + def use_hf_reference_state_for_hf_parity(self) -> bool: + """DSV4 parity seeds Megatron from the reduced canonical HF oracle state. + + The public checkpoint uses Miles/RadixArk source names and full model + shapes, while the validation oracle uses canonical HF names and reduced + fit-only axes. This hook is validation-only; production loading remains + tied to the normal Bridge checkpoint source. + """ + return True + + def normalize_hf_reference_state_for_hf_parity( + self, + state: dict[str, torch.Tensor], + *, + config: Any, + ) -> None: + _add_dsv4_hf_reference_source_aliases(state, config) + + def configure_oracle_provider(self, provider: Any, *, case_config: Any) -> None: + """Mirrors HF oracle reductions while keeping DSV4 hard kernel invariants.""" + hooks = list(getattr(provider, "_pre_wrap_hooks", [])) + kept = [hook for hook in hooks if not self._is_bridge_hf_load_hook(hook)] + if len(kept) != len(hooks): + provider._pre_wrap_hooks = kept + provider.register_pre_wrap_hook( + lambda chunks: self._initialize_oracle_base_weights( + chunks, + seed=int(case_config.seed), + ) + ) + self._apply_oracle_shape_overrides(provider) + provider.kv_lora_rank = 512 + provider.kv_channels = 512 + provider.qk_pos_emb_head_dim = 64 + provider.num_query_groups = _ORACLE_NUM_ATTENTION_HEADS + provider.num_moe_experts = _ORACLE_NUM_EXPERTS + provider.moe_ffn_hidden_size = _ORACLE_FFN_HIDDEN_SIZE + provider.ffn_hidden_size = _ORACLE_FFN_HIDDEN_SIZE + provider.moe_shared_expert_intermediate_size = _ORACLE_FFN_HIDDEN_SIZE + provider.moe_router_topk = _ORACLE_NUM_EXPERTS_PER_TOK + provider.dsv4_o_groups = _ORACLE_NUM_ATTENTION_HEADS + provider.dsv4_o_lora_rank = 1024 + provider.dsa_indexer_n_heads = _ORACLE_INDEX_HEADS + provider.dsa_indexer_head_dim = 128 + provider.dsa_indexer_topk = _ORACLE_INDEX_TOPK + + @staticmethod + def _is_bridge_hf_load_hook(hook: Any) -> bool: + fn = getattr(hook, "func", hook) + name = getattr(fn, "__name__", "") + qualname = getattr(fn, "__qualname__", "") + return name in { + "load_weights_hf_to_megatron", + "_optimized_load_weights_hf_to_megatron", + } or qualname.endswith(".load_weights_hf_to_megatron") + + def _apply_oracle_shape_overrides(self, config: Any) -> None: + """Reduces memory-heavy axes only; head_dim/window/o-rank stay production-sized.""" + config.hidden_size = _ORACLE_HIDDEN_SIZE + config.q_lora_rank = _ORACLE_Q_LORA_RANK + config.num_attention_heads = _ORACLE_NUM_ATTENTION_HEADS + config.n_routed_experts = _ORACLE_NUM_EXPERTS + config.num_experts_per_tok = _ORACLE_NUM_EXPERTS_PER_TOK + config.moe_intermediate_size = _ORACLE_FFN_HIDDEN_SIZE + config.o_groups = _ORACLE_NUM_ATTENTION_HEADS + config.index_n_heads = _ORACLE_INDEX_HEADS + config.index_head_dim = 128 + config.index_topk = _ORACLE_INDEX_TOPK + + def _initialize_oracle_base_weights( + self, + model_chunks: Sequence[Any], + *, + seed: int, + ) -> Sequence[Any]: + """Seeds reduced DSV4 oracle base tensors after meta-device materialization. + + DSV4 correctness runs strip the full-checkpoint Bridge load because the + public tensors are production-sized and quantized. Since ART materializes + meta models with empty storage, every base tensor must be explicitly + initialized before freeze/LoRA hooks run; otherwise the oracle exercises a + zero model and cannot produce meaningful adapter gradients. + """ + from megatron.core import parallel_state as ps + + ep_rank = ps.get_expert_model_parallel_rank() + ep_size = ps.get_expert_model_parallel_world_size() + with torch.no_grad(): + for chunk in model_chunks: + for name, param in chunk.named_parameters(): + if self._is_oracle_lora_tensor(name): + continue + init_name = self._oracle_base_tensor_name( + name, + ep_rank=ep_rank, + ep_size=ep_size, + ) + self._initialize_oracle_tensor( + init_name, + param, + seed=seed, + ) + for name, buffer in chunk.named_buffers(): + self._initialize_oracle_buffer(name, buffer, seed=seed) + return model_chunks + + @staticmethod + def _is_oracle_lora_tensor(name: str) -> bool: + return "_lora." in name or ".lora." in name + + @staticmethod + def _oracle_base_tensor_name(name: str, *, ep_rank: int, ep_size: int) -> str: + if ep_size <= 1: + return name + match = _ORACLE_EXPERT_WEIGHT_RE.search(name) + if match is None: + return name + local_expert = int(match.group("expert")) + local_expert_count = max(1, _ORACLE_NUM_EXPERTS // ep_size) + global_expert = ep_rank * local_expert_count + local_expert + return f"{name[: match.start('expert')]}{global_expert}" + + def _initialize_oracle_buffer( + self, + name: str, + tensor: torch.Tensor, + *, + seed: int, + ) -> None: + if name.endswith("freqs_cis"): + return + if name.endswith("tid2eid"): + self._initialize_oracle_tid2eid(tensor) + return + if not torch.is_floating_point(tensor): + return + self._initialize_oracle_tensor(name, tensor, seed=seed) + + @staticmethod + def _initialize_oracle_tid2eid(tensor: torch.Tensor) -> None: + if tensor.ndim != 2: + raise RuntimeError( + f"Expected DSV4 tid2eid to be 2D, got {tuple(tensor.shape)}" + ) + token_ids = torch.arange(tensor.shape[0], device=tensor.device).unsqueeze(1) + offsets = torch.arange(tensor.shape[1], device=tensor.device).unsqueeze(0) + tensor.copy_( + (token_ids + offsets).remainder(_ORACLE_NUM_EXPERTS).to(tensor.dtype) + ) + + def _initialize_oracle_tensor( + self, + name: str, + tensor: torch.Tensor, + *, + seed: int, + ) -> None: + if tensor.is_meta: + raise RuntimeError(f"DSV4 oracle tensor was not materialized: {name}") + if not torch.is_floating_point(tensor): + return + if self._is_oracle_identity_weight(name): + tensor.fill_(1) + return + if name.endswith( + ("bias", "attn_sink", "_base", "_scale", "e_score_correction_bias") + ): + tensor.zero_() + return + logical_shape, partition_dim, partition_rank = ( + self._oracle_logical_tensor_for_rank(name, tensor) + ) + digest = hashlib.sha256(f"{seed}:{name}".encode("utf-8")).digest() + key_seed = int.from_bytes(digest[:8], "little") % (2**31) + generator = torch.Generator(device=tensor.device).manual_seed(key_seed) + values = torch.randn( + logical_shape, + generator=generator, + device=tensor.device, + dtype=torch.float32, + ) + if partition_dim is not None: + values = self._oracle_slice_logical_tensor( + name, + values, + tensor, + partition_dim=partition_dim, + partition_rank=partition_rank, + ) + tensor.copy_((0.02 * values).to(dtype=tensor.dtype)) + + @staticmethod + def _oracle_slice_logical_tensor( + name: str, + values: torch.Tensor, + tensor: torch.Tensor, + *, + partition_dim: int, + partition_rank: int, + ) -> torch.Tensor: + if partition_dim == 0 and Dsv4Handler._oracle_is_fused_fc1(name): + if values.shape[0] % 2 != 0 or tensor.shape[0] % 2 != 0: + raise RuntimeError( + "DSV4 fused FC1 oracle tensor must have an even " + f"gate/up dimension, got logical={tuple(values.shape)} " + f"local={tuple(tensor.shape)} for {name}." + ) + local_component = tensor.shape[0] // 2 + gate, up = values.chunk(2, dim=0) + start = partition_rank * local_component + return torch.cat( + ( + gate.narrow(0, start, local_component), + up.narrow(0, start, local_component), + ), + dim=0, + ) + return values.narrow( + partition_dim, + partition_rank * tensor.shape[partition_dim], + tensor.shape[partition_dim], + ) + + @staticmethod + def _oracle_logical_tensor_for_rank( + name: str, + tensor: torch.Tensor, + ) -> tuple[tuple[int, ...], int | None, int]: + """Returns the logical full tensor shape and rank-local TP slice metadata. + + DSV4 oracle base weights are validation-only random tensors. TP ranks + must receive slices of the same logical tensor as the TP1 oracle; if each + rank independently initializes its local shard, TP2 is not mathematically + comparable to TP1 even when the layer implementation is correct. + """ + partition_dim = getattr(tensor, "partition_dim", None) + from megatron.core import parallel_state as ps + + if _ORACLE_EXPERT_WEIGHT_RE.search(name) is not None: + etp_group = ps.get_expert_tensor_parallel_group(check_initialized=False) + etp_size = etp_group.size() if etp_group is not None else 1 + if etp_size <= 1: + return tuple(tensor.shape), None, 0 + dim = ( + int(partition_dim) + if partition_dim is not None and int(partition_dim) >= 0 + else Dsv4Handler._oracle_dsv4_expert_missing_etp_partition_dim(name) + ) + if dim is None: + return tuple(tensor.shape), None, 0 + logical_shape = list(tensor.shape) + logical_shape[dim] *= etp_size + return tuple(logical_shape), dim, etp_group.rank() + + tp_size = ps.get_tensor_model_parallel_world_size() + tp_rank = ps.get_tensor_model_parallel_rank() + if ( + bool(getattr(tensor, "tensor_model_parallel", False)) + and partition_dim is not None + and int(partition_dim) >= 0 + ): + dim = int(partition_dim) + else: + dim = Dsv4Handler._oracle_dsv4_missing_tp_partition_dim(name) + if dim is None or tp_size <= 1: + return tuple(tensor.shape), None, 0 + logical_shape = list(tensor.shape) + logical_shape[dim] *= tp_size + return tuple(logical_shape), dim, tp_rank + + @staticmethod + def _oracle_dsv4_missing_tp_partition_dim(name: str) -> int | None: + """Mirrors DSV4 Bridge TP shape rules for custom modules without TP attrs.""" + if name.endswith(("embedding.word_embeddings.weight", "output_layer.weight")): + return 0 + if name.endswith( + ( + ".self_attention.wq_b.weight", + ".self_attention.wo_a.weight", + ".mlp.shared_experts.linear_fc1.weight", + ".mlp.shared_experts.linear_fc1.linear_fc1.weight", + ) + ): + return 0 + if name.endswith( + ( + ".self_attention.wo_b.weight", + ".mlp.shared_experts.linear_fc2.weight", + ".mlp.shared_experts.linear_fc2.row_parallel_lora.linear_proj.weight", + ) + ): + return 1 + return None + + @staticmethod + def _oracle_dsv4_expert_missing_etp_partition_dim(name: str) -> int | None: + """Mirrors TE grouped-expert ETP shards when TE omits TP metadata.""" + if ".mlp.experts.linear_fc1." in name: + return 0 + if ".mlp.experts.linear_fc2." in name: + return 1 + return None + + @staticmethod + def _oracle_is_shared_expert_fused_fc1(name: str) -> bool: + return name.endswith( + ( + ".mlp.shared_experts.linear_fc1.weight", + ".mlp.shared_experts.linear_fc1.linear_fc1.weight", + ) + ) + + @staticmethod + def _oracle_is_grouped_expert_fused_fc1(name: str) -> bool: + return ".mlp.experts.linear_fc1." in name and bool( + _ORACLE_EXPERT_WEIGHT_RE.search(name) + ) + + @staticmethod + def _oracle_is_fused_fc1(name: str) -> bool: + return Dsv4Handler._oracle_is_shared_expert_fused_fc1( + name + ) or Dsv4Handler._oracle_is_grouped_expert_fused_fc1(name) + + @staticmethod + def _is_oracle_identity_weight(name: str) -> bool: + return name.endswith(".weight") and any( + token in name for token in ("layernorm", "_norm", ".norm.") + ) + + +def ensure_dsv4_bridge_registered() -> None: + from art.megatron.dsv4.bridge import ensure_dsv4_bridge_registered as _ensure + + _ensure() + + +def _ensure_dsv4_hf_config_registered() -> None: + from art.megatron.dsv4.hf_config import ensure_dsv4_hf_config_registered + + ensure_dsv4_hf_config_registered() + + +def _sanitize_dsv4_child_process_env() -> None: + from art.megatron.dsv4.kernel.tilelang_import import sanitize_tilelang_env + + sanitize_tilelang_env() + + +_sanitize_dsv4_child_process_env() +_ensure_dsv4_hf_config_registered() +DSV4_HANDLER = Dsv4Handler() + + +def _dsv4_source_to_hf_key_mapping() -> dict[str, str]: + layer = r"layers\.(\d+)" + target = r"model.layers.\1" + return { + r"^embed\.weight$": "model.embed_tokens.weight", + r"^head\.weight$": "lm_head.weight", + r"^norm\.weight$": "model.norm.weight", + r"^hc_head_fn$": "model.hc_head.hc_fn", + r"^hc_head_base$": "model.hc_head.hc_base", + r"^hc_head_scale$": "model.hc_head.hc_scale", + rf"^{layer}\.attn_norm\.weight$": rf"{target}.input_layernorm.weight", + rf"^{layer}\.ffn_norm\.weight$": rf"{target}.post_attention_layernorm.weight", + rf"^{layer}\.hc_attn_fn$": rf"{target}.attn_hc.fn", + rf"^{layer}\.hc_attn_base$": rf"{target}.attn_hc.base", + rf"^{layer}\.hc_attn_scale$": rf"{target}.attn_hc.scale", + rf"^{layer}\.hc_ffn_fn$": rf"{target}.ffn_hc.fn", + rf"^{layer}\.hc_ffn_base$": rf"{target}.ffn_hc.base", + rf"^{layer}\.hc_ffn_scale$": rf"{target}.ffn_hc.scale", + rf"^{layer}\.attn\.wq_a\.weight$": rf"{target}.self_attn.q_a_proj.weight", + rf"^{layer}\.attn\.q_norm\.weight$": rf"{target}.self_attn.q_a_norm.weight", + rf"^{layer}\.attn\.wq_b\.weight$": rf"{target}.self_attn.q_b_proj.weight", + rf"^{layer}\.attn\.wkv\.weight$": rf"{target}.self_attn.kv_proj.weight", + rf"^{layer}\.attn\.kv_norm\.weight$": rf"{target}.self_attn.kv_norm.weight", + rf"^{layer}\.attn\.wo_a\.weight$": rf"{target}.self_attn.o_a_proj.weight", + rf"^{layer}\.attn\.wo_b\.weight$": rf"{target}.self_attn.o_b_proj.weight", + rf"^{layer}\.attn\.attn_sink$": rf"{target}.self_attn.sinks", + rf"^{layer}\.ffn\.gate\.weight$": rf"{target}.mlp.gate.weight", + rf"^{layer}\.ffn\.gate\.tid2eid$": rf"{target}.mlp.gate.tid2eid", + rf"^{layer}\.ffn\.gate\.bias$": (rf"{target}.mlp.gate.e_score_correction_bias"), + rf"^{layer}\.ffn\.shared_experts\.w1\.weight$": ( + rf"{target}.mlp.shared_experts.gate_proj.weight" + ), + rf"^{layer}\.ffn\.shared_experts\.w3\.weight$": ( + rf"{target}.mlp.shared_experts.up_proj.weight" + ), + rf"^{layer}\.ffn\.shared_experts\.w2\.weight$": ( + rf"{target}.mlp.shared_experts.down_proj.weight" + ), + rf"^{layer}\.attn\.compressor\.ape$": ( + rf"{target}.self_attn.compressor.position_bias" + ), + rf"^{layer}\.attn\.compressor\.wkv\.weight$": ( + rf"{target}.self_attn.compressor.kv_proj.weight" + ), + rf"^{layer}\.attn\.compressor\.wgate\.weight$": ( + rf"{target}.self_attn.compressor.gate_proj.weight" + ), + rf"^{layer}\.attn\.compressor\.norm\.weight$": ( + rf"{target}.self_attn.compressor.kv_norm.weight" + ), + rf"^{layer}\.attn\.indexer\.wq_b\.weight$": ( + rf"{target}.self_attn.compressor.indexer.q_b_proj.weight" + ), + rf"^{layer}\.attn\.indexer\.weights_proj\.weight$": ( + rf"{target}.self_attn.compressor.indexer.scorer.weights_proj.weight" + ), + rf"^{layer}\.attn\.indexer\.compressor\.ape$": ( + rf"{target}.self_attn.compressor.indexer.position_bias" + ), + rf"^{layer}\.attn\.indexer\.compressor\.wkv\.weight$": ( + rf"{target}.self_attn.compressor.indexer.kv_proj.weight" + ), + rf"^{layer}\.attn\.indexer\.compressor\.wgate\.weight$": ( + rf"{target}.self_attn.compressor.indexer.gate_proj.weight" + ), + rf"^{layer}\.attn\.indexer\.compressor\.norm\.weight$": ( + rf"{target}.self_attn.compressor.indexer.kv_norm.weight" + ), + } + + +def _add_dsv4_hf_reference_source_aliases( + state: dict[str, torch.Tensor], + config: Any, +) -> None: + def add(source: str, canonical: str) -> None: + if canonical in state and source not in state: + state[source] = state[canonical] + + add("embed.weight", "model.embed_tokens.weight") + add("head.weight", "lm_head.weight") + add("hc_head_fn", "model.hc_head.hc_fn") + add("hc_head_base", "model.hc_head.hc_base") + add("hc_head_scale", "model.hc_head.hc_scale") + for layer_idx in range(int(config.num_hidden_layers)): + source = f"layers.{layer_idx}" + canonical = f"model.layers.{layer_idx}" + add(f"{source}.attn_norm.weight", f"{canonical}.input_layernorm.weight") + add(f"{source}.ffn_norm.weight", f"{canonical}.post_attention_layernorm.weight") + add(f"{source}.hc_attn_fn", f"{canonical}.attn_hc.fn") + add(f"{source}.hc_attn_base", f"{canonical}.attn_hc.base") + add(f"{source}.hc_attn_scale", f"{canonical}.attn_hc.scale") + add(f"{source}.hc_ffn_fn", f"{canonical}.ffn_hc.fn") + add(f"{source}.hc_ffn_base", f"{canonical}.ffn_hc.base") + add(f"{source}.hc_ffn_scale", f"{canonical}.ffn_hc.scale") + add(f"{source}.attn.wq_a.weight", f"{canonical}.self_attn.q_a_proj.weight") + add(f"{source}.attn.q_norm.weight", f"{canonical}.self_attn.q_a_norm.weight") + add(f"{source}.attn.wq_b.weight", f"{canonical}.self_attn.q_b_proj.weight") + add(f"{source}.attn.wkv.weight", f"{canonical}.self_attn.kv_proj.weight") + add(f"{source}.attn.kv_norm.weight", f"{canonical}.self_attn.kv_norm.weight") + add(f"{source}.attn.wo_a.weight", f"{canonical}.self_attn.o_a_proj.weight") + add(f"{source}.attn.wo_b.weight", f"{canonical}.self_attn.o_b_proj.weight") + add(f"{source}.attn.attn_sink", f"{canonical}.self_attn.sinks") + add(f"{source}.ffn.gate.weight", f"{canonical}.mlp.gate.weight") + add(f"{source}.ffn.gate.tid2eid", f"{canonical}.mlp.gate.tid2eid") + add( + f"{source}.ffn.gate.bias", + f"{canonical}.mlp.gate.e_score_correction_bias", + ) + add( + f"{source}.ffn.shared_experts.w1.weight", + f"{canonical}.mlp.shared_experts.gate_proj.weight", + ) + add( + f"{source}.ffn.shared_experts.w3.weight", + f"{canonical}.mlp.shared_experts.up_proj.weight", + ) + add( + f"{source}.ffn.shared_experts.w2.weight", + f"{canonical}.mlp.shared_experts.down_proj.weight", + ) + add( + f"{source}.attn.compressor.ape", + f"{canonical}.self_attn.compressor.position_bias", + ) + add( + f"{source}.attn.compressor.wkv.weight", + f"{canonical}.self_attn.compressor.kv_proj.weight", + ) + add( + f"{source}.attn.compressor.wgate.weight", + f"{canonical}.self_attn.compressor.gate_proj.weight", + ) + add( + f"{source}.attn.compressor.norm.weight", + f"{canonical}.self_attn.compressor.kv_norm.weight", + ) + add( + f"{source}.attn.indexer.wq_b.weight", + f"{canonical}.self_attn.compressor.indexer.q_b_proj.weight", + ) + add( + f"{source}.attn.indexer.weights_proj.weight", + f"{canonical}.self_attn.compressor.indexer.scorer.weights_proj.weight", + ) + add( + f"{source}.attn.indexer.compressor.ape", + f"{canonical}.self_attn.compressor.indexer.position_bias", + ) + add( + f"{source}.attn.indexer.compressor.wkv.weight", + f"{canonical}.self_attn.compressor.indexer.kv_proj.weight", + ) + add( + f"{source}.attn.indexer.compressor.wgate.weight", + f"{canonical}.self_attn.compressor.indexer.gate_proj.weight", + ) + add( + f"{source}.attn.indexer.compressor.norm.weight", + f"{canonical}.self_attn.compressor.indexer.kv_norm.weight", + ) + gate_up = state.get(f"{canonical}.mlp.experts.gate_up_proj") + if gate_up is not None: + gate, up = gate_up.chunk(2, dim=1) + for expert_idx in range(int(gate.shape[0])): + state.setdefault( + f"{source}.ffn.experts.{expert_idx}.w1.weight", + gate[expert_idx].contiguous(), + ) + state.setdefault( + f"{source}.ffn.experts.{expert_idx}.w3.weight", + up[expert_idx].contiguous(), + ) + down = state.get(f"{canonical}.mlp.experts.down_proj") + if down is not None: + for expert_idx in range(int(down.shape[0])): + state.setdefault( + f"{source}.ffn.experts.{expert_idx}.w2.weight", + down[expert_idx].contiguous(), + ) + + +def _dsv4_unpack_vllm_3d_lora_b( + tensor: torch.Tensor, + *, + num_experts: int, + rank: int, +) -> torch.Tensor: + return tensor.reshape(tensor.shape[0], rank, num_experts).permute(2, 0, 1) + + +def _dsv4_clone(tensor: torch.Tensor) -> torch.Tensor: + return tensor.clone().contiguous() + + +def _dsv4_to_vllm_lora_key(key: str) -> str: + match = _DSV4_ART_MOE_EXPERT_KEY_RE.match(key) + if match is not None: + module = { + "gate_proj": "w1", + "down_proj": "w2", + "up_proj": "w3", + }[match.group("module")] + prefix = match.group("prefix").replace(".mlp.experts", ".ffn.experts", 1) + return f"{prefix}.{match.group('expert')}.{module}.{match.group('lora')}.weight" + + replacements = ( + (".self_attn.compressor.kv_proj.", ".attn.mla_attn.compressor.wkv."), + (".self_attn.compressor.gate_proj.", ".attn.mla_attn.compressor.wgate."), + (".self_attn.q_a_proj.", ".attn.wq_a."), + (".self_attn.q_b_proj.", ".attn.wq_b."), + (".self_attn.kv_proj.", ".attn.wkv."), + (".self_attn.o_a_proj.", ".attn.wo_a."), + (".self_attn.o_b_proj.", ".attn.wo_b."), + (".mlp.shared_expert.", ".ffn.shared_experts."), + (".mlp.shared_experts.", ".ffn.shared_experts."), + ) + for old, new in replacements: + if old in key: + return key.replace(old, new, 1) + return key + + +def _dsv4_from_vllm_lora_key(key: str) -> str: + match = _DSV4_VLLM_MOE_EXPERT_KEY_RE.match(key) + if match is not None: + module = { + "w1": "gate_proj", + "w2": "down_proj", + "w3": "up_proj", + }[match.group("module")] + prefix = match.group("prefix").replace(".ffn.experts", ".mlp.experts", 1) + return f"{prefix}.{match.group('expert')}.{module}.{match.group('lora')}.weight" + + replacements = ( + (".attn.mla_attn.compressor.wkv.", ".self_attn.compressor.kv_proj."), + (".attn.mla_attn.compressor.wgate.", ".self_attn.compressor.gate_proj."), + (".attn.wq_a.", ".self_attn.q_a_proj."), + (".attn.wq_b.", ".self_attn.q_b_proj."), + (".attn.wkv.", ".self_attn.kv_proj."), + (".attn.wo_a.", ".self_attn.o_a_proj."), + (".attn.wo_b.", ".self_attn.o_b_proj."), + (".ffn.shared_experts.", ".mlp.shared_expert."), + (".mlp.shared_experts.", ".mlp.shared_expert."), + ) + for old, new in replacements: + if old in key: + return key.replace(old, new, 1) + return key + + +def _dsv4_vllm_lora_config(adapter_config: dict[str, Any]) -> dict[str, Any]: + target_modules = adapter_config.get("target_modules") + if not isinstance(target_modules, (list, tuple, set)): + return adapter_config + transformed: list[str] = [] + ordered_target_modules = ( + sorted(target_modules) if isinstance(target_modules, set) else target_modules + ) + for module in ordered_target_modules: + if module in {"q_a_proj", "kv_proj"}: + transformed.append("fused_wqa_wkv") + elif module == "q_b_proj": + transformed.append("wq_b") + elif module == "o_a_proj": + transformed.append("wo_a") + elif module == "o_b_proj": + transformed.append("wo_b") + elif module in {"compressor.kv_proj", "compressor.gate_proj"}: + transformed.append("fused_wkv_wgate") + elif module in {"gate_proj", "up_proj"}: + transformed.extend(("gate_up_proj", "experts")) + elif module == "down_proj": + transformed.extend(("down_proj", "experts")) + elif module == "experts": + transformed.append("experts") + else: + transformed.append(module) + config = dict(adapter_config) + config["target_modules"] = list(dict.fromkeys(transformed)) + return config + + +def _dsv4_to_vllm_lora_tensors( + tensors: dict[str, torch.Tensor], + *, + adapter_config: dict[str, Any], +) -> tuple[dict[str, torch.Tensor], dict[str, Any]]: + transformed: dict[str, torch.Tensor] = {} + for key, tensor in tensors.items(): + vllm_key = _dsv4_to_vllm_lora_key(key) + if vllm_key in transformed: + raise RuntimeError( + f"Duplicate DSV4 LoRA tensor after conversion: {vllm_key}" + ) + transformed[vllm_key] = tensor + return transformed, _dsv4_vllm_lora_config(adapter_config) + + +def _dsv4_from_vllm_lora_tensors( + tensors: dict[str, torch.Tensor], + *, + adapter_config: dict[str, Any], +) -> dict[str, torch.Tensor]: + grouped: dict[str, dict[str, torch.Tensor]] = {} + for key, tensor in tensors.items(): + match = _DSV4_VLLM_MOE_KEY_RE.match(key) + if match is None: + continue + slot = ( + f"{'base_layer.' if match.group('base_layer') else ''}{match.group('lora')}" + ) + grouped.setdefault(match.group("prefix"), {})[slot] = tensor + if not grouped: + return { + _dsv4_from_vllm_lora_key(key): tensor for key, tensor in tensors.items() + } + + rank = int(adapter_config["r"]) + transformed: dict[str, torch.Tensor] = {} + used_keys: set[str] = set() + for prefix, slots in grouped.items(): + try: + gate_up_a = slots["base_layer.lora_A"] + gate_up_b = slots["base_layer.lora_B"] + down_a = slots["lora_A"] + down_b = slots["lora_B"] + except KeyError as exc: + raise RuntimeError( + f"Incomplete DSV4 vLLM MoE LoRA block for {prefix}" + ) from exc + if gate_up_a.shape[0] % rank != 0: + raise RuntimeError( + f"{prefix}: gate/up lora_A rows {gate_up_a.shape[0]} are not " + f"divisible by rank {rank}" + ) + if gate_up_b.shape[0] % 2 != 0: + raise RuntimeError( + f"{prefix}: gate/up lora_B rows {gate_up_b.shape[0]} are not even" + ) + num_experts = gate_up_a.shape[0] // rank + gate_up_b_by_expert = _dsv4_unpack_vllm_3d_lora_b( + gate_up_b, + num_experts=num_experts, + rank=rank, + ) + down_b_by_expert = _dsv4_unpack_vllm_3d_lora_b( + down_b, + num_experts=num_experts, + rank=rank, + ) + for expert in range(num_experts): + row = expert * rank + gate_up_a_block = gate_up_a[row : row + rank] + down_a_block = down_a[row : row + rank] + gate_b, up_b = gate_up_b_by_expert[expert].chunk(2, dim=0) + transformed[f"{prefix}.{expert}.gate_proj.lora_A.weight"] = _dsv4_clone( + gate_up_a_block + ) + transformed[f"{prefix}.{expert}.gate_proj.lora_B.weight"] = _dsv4_clone( + gate_b + ) + transformed[f"{prefix}.{expert}.up_proj.lora_A.weight"] = _dsv4_clone( + gate_up_a_block + ) + transformed[f"{prefix}.{expert}.up_proj.lora_B.weight"] = _dsv4_clone(up_b) + transformed[f"{prefix}.{expert}.down_proj.lora_A.weight"] = _dsv4_clone( + down_a_block + ) + transformed[f"{prefix}.{expert}.down_proj.lora_B.weight"] = _dsv4_clone( + down_b_by_expert[expert] + ) + used_keys.update( + { + f"{prefix}.base_layer.lora_A.weight", + f"{prefix}.base_layer.lora_B.weight", + f"{prefix}.lora_A.weight", + f"{prefix}.lora_B.weight", + } + ) + for key, tensor in tensors.items(): + if key not in used_keys: + transformed[_dsv4_from_vllm_lora_key(key)] = tensor + return transformed diff --git a/src/art/megatron/model_support/handlers/qwen3_moe.py b/src/art/megatron/model_support/handlers/qwen3_moe.py index 548419d94..41245bc16 100644 --- a/src/art/megatron/model_support/handlers/qwen3_moe.py +++ b/src/art/megatron/model_support/handlers/qwen3_moe.py @@ -35,6 +35,9 @@ def to_vllm_lora_tensors( ) -> tuple[dict[str, torch.Tensor], dict[str, Any]]: return _to_vllm_lora_tensors(tensors, adapter_config=adapter_config) + def to_vllm_lora_config(self, adapter_config: dict[str, Any]) -> dict[str, Any]: + return _qwen3_moe_config(adapter_config) + def install_preprocess_patch(self, model_chunks: Sequence[Any]) -> None: install_qwen3_text_preprocess_patch(model_chunks) diff --git a/src/art/megatron/model_support/registry.py b/src/art/megatron/model_support/registry.py index 716573e64..0c9926ab6 100644 --- a/src/art/megatron/model_support/registry.py +++ b/src/art/megatron/model_support/registry.py @@ -14,6 +14,7 @@ _QWEN3_5_MOE_HANDLER_KEY = "qwen3_5_moe" _GEMMA4_DENSE_HANDLER_KEY = "gemma4_dense" _GEMMA4_MOE_HANDLER_KEY = "gemma4_moe" +_DSV4_HANDLER_KEY = "dsv4" _GPT_OSS_MOE_HANDLER_KEY = "gpt_oss_moe" _VALIDATED_NATIVE_VLLM_LORA_STATUS: NativeVllmLoraStatus = "validated" _WIP_NATIVE_VLLM_LORA_STATUS: NativeVllmLoraStatus = "wip" @@ -59,6 +60,19 @@ "up_proj", "down_proj", ) +_DSV4_TARGET_MODULES = ( + "q_a_proj", + "q_b_proj", + "kv_proj", + "o_a_proj", + "o_b_proj", + "compressor.kv_proj", + "compressor.gate_proj", + "gate_proj", + "up_proj", + "down_proj", + "experts", +) DEFAULT_DENSE_SPEC = ModelSupportSpec( key="default_dense", @@ -166,6 +180,21 @@ ), ) +DSV4_SPEC = ModelSupportSpec( + key="dsv4", + handler_key=_DSV4_HANDLER_KEY, + is_moe=True, + model_names=( + "deepseek-ai/DeepSeek-V4-Flash", + "deepseek-ai/DeepSeek-V4-Flash-Base", + "deepseek-ai/DeepSeek-V4-Pro", + "deepseek-ai/DeepSeek-V4-Pro-Base", + ), + default_target_modules=_DSV4_TARGET_MODULES, + native_vllm_lora_status=_VALIDATED_NATIVE_VLLM_LORA_STATUS, + dependency_floor=DependencyFloor(transformers="5.12.1"), +) + GPT_OSS_MOE_SPEC = ModelSupportSpec( key="gpt_oss_moe", handler_key=_GPT_OSS_MOE_HANDLER_KEY, @@ -189,6 +218,7 @@ QWEN3_5_DENSE_SPEC, GEMMA4_MOE_SPEC, GEMMA4_DENSE_SPEC, + DSV4_SPEC, GPT_OSS_MOE_SPEC, ) PROBE_ONLY_MODEL_SUPPORT_SPECS = () @@ -237,6 +267,10 @@ "art.megatron.model_support.handlers.gemma4", "GEMMA4_DENSE_HANDLER", ), + _DSV4_HANDLER_KEY: ( + "art.megatron.model_support.handlers.dsv4", + "DSV4_HANDLER", + ), _GPT_OSS_MOE_HANDLER_KEY: ( "art.megatron.model_support.handlers.gpt_oss", "GPT_OSS_MOE_HANDLER", @@ -259,6 +293,10 @@ "art.megatron.model_support.handlers.gemma4", "ensure_gemma4_text_only_bridge_registered", ), + "dsv4": ( + "art.megatron.model_support.handlers.dsv4", + "ensure_dsv4_bridge_registered", + ), } _HANDLERS_BY_KEY: dict[str, ModelSupportHandler] = {} _REGISTERED_BRIDGE_KEYS: set[str] = set() @@ -270,6 +308,7 @@ QWEN3_5_MODELS = QWEN3_5_DENSE_MODELS | QWEN3_5_MOE_MODELS GEMMA4_MOE_MODELS = frozenset(GEMMA4_MOE_SPEC.model_names) GEMMA4_DENSE_MODELS = frozenset(GEMMA4_DENSE_SPEC.model_names) +DSV4_MODELS = frozenset(DSV4_SPEC.model_names) GPT_OSS_MOE_MODELS = frozenset(GPT_OSS_MOE_SPEC.model_names) @@ -354,6 +393,18 @@ def default_target_modules_for_model( ) +def vllm_lora_config_for_model( + base_model: str, + adapter_config: dict, + *, + allow_unvalidated_arch: bool = False, +) -> dict: + return get_model_support_handler( + base_model, + allow_unvalidated_arch=allow_unvalidated_arch, + ).to_vllm_lora_config(adapter_config) + + def native_vllm_lora_status_for_model( base_model: str, *, @@ -390,6 +441,18 @@ def model_uses_expert_parallel( ).is_moe +def model_supports_context_parallel( + base_model: str, + *, + allow_unvalidated_arch: bool = False, +) -> bool: + spec = get_model_support_spec( + base_model, + allow_unvalidated_arch=allow_unvalidated_arch, + ) + return bool(get_model_support_handler_for_spec(spec).cp_supported) + + def is_model_support_registered(base_model: str) -> bool: return base_model in _SPECS_BY_MODEL diff --git a/src/art/megatron/model_support/spec.py b/src/art/megatron/model_support/spec.py index eb10dfe79..e9d0e8b4b 100644 --- a/src/art/megatron/model_support/spec.py +++ b/src/art/megatron/model_support/spec.py @@ -1,6 +1,6 @@ from typing import TYPE_CHECKING, Any, Literal, Protocol, Sequence, runtime_checkable -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict, Field if TYPE_CHECKING: from megatron.bridge import AutoBridge @@ -46,6 +46,18 @@ class ArchitectureReport(BaseModel): unresolved_risks: list[str] = Field(default_factory=list) +class SharedPrefixModelStateContext(BaseModel): + model_config = ConfigDict(arbitrary_types_allowed=True) + + group_ids: Any + parent_ids: Any + input_pos: Any | None = None + device: Any + attention_token_layout_index: Any | None = None + attention_head_dim: int | None = None + attention_value_head_dim: int | None = None + + class CompileWorkaroundConfig(BaseModel): flags: tuple[str, ...] = () unconditional_flags: tuple[str, ...] = () @@ -92,6 +104,7 @@ class ModelSupportSpec(BaseModel): class ModelSupportHandler(Protocol): key: str is_moe: bool + cp_supported: bool native_vllm_lora_status: NativeVllmLoraStatus def identity_lora_model_config(self, base_config: Any) -> Any: ... @@ -121,6 +134,15 @@ def patch_provider( def configure_provider_for_runtime(self, provider: "GPTModelProvider") -> None: ... + def default_chat_template(self) -> str | None: ... + + def configure_tokenizer( + self, + tokenizer: Any, + *, + internal_config: Any, + ) -> Any: ... + def vllm_engine_args( self, *, @@ -131,6 +153,19 @@ def vllm_server_args(self) -> dict[str, object]: ... def install_preprocess_patch(self, model_chunks: Sequence[Any]) -> None: ... + def build_shared_prefix_model_state( + self, + context: SharedPrefixModelStateContext, + ) -> dict[str, Any]: ... + + def correctness_precision(self) -> Literal["bf16", "fp32"]: ... + + def correctness_use_fp32_lora_reference(self) -> bool: ... + + def correctness_phase_pass_fns( + self, oracle_harness: Any + ) -> dict[str, Any] | None: ... + def collect_layer_families( self, provider: "GPTModelProvider", @@ -158,6 +193,11 @@ def to_vllm_lora_tensors( adapter_config: dict[str, Any], ) -> tuple[dict[str, Any], dict[str, Any]]: ... + def to_vllm_lora_config( + self, + adapter_config: dict[str, Any], + ) -> dict[str, Any]: ... + def expert_packed_lora_groups(self) -> tuple[ExpertPackedLoraGroup, ...]: ... def from_vllm_lora_tensors( diff --git a/src/art/megatron/model_support/tokenizer.py b/src/art/megatron/model_support/tokenizer.py new file mode 100644 index 000000000..9e6da103c --- /dev/null +++ b/src/art/megatron/model_support/tokenizer.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, cast + +from transformers.tokenization_utils_base import PreTrainedTokenizerBase + +from .registry import UnsupportedModelArchitectureError, get_model_support_handler + + +def _has_configured_chat_template(internal_config: Mapping[str, Any]) -> bool: + return ( + internal_config.get("chat_template") is not None + or internal_config.get("chat_template_path") is not None + ) + + +def configure_tokenizer_for_model_support( + tokenizer: PreTrainedTokenizerBase, + *, + base_model: str, + internal_config: Mapping[str, Any], +) -> PreTrainedTokenizerBase: + try: + handler = get_model_support_handler( + base_model, + allow_unvalidated_arch=bool( + internal_config.get("allow_unvalidated_arch", False) + ), + ) + except UnsupportedModelArchitectureError: + return tokenizer + + if not _has_configured_chat_template(internal_config) and not isinstance( + getattr(tokenizer, "chat_template", None), str + ): + default = handler.default_chat_template() + if default is not None: + tokenizer.chat_template = default + + return cast( + PreTrainedTokenizerBase, + handler.configure_tokenizer(tokenizer, internal_config=internal_config), + ) diff --git a/src/art/megatron/provider.py b/src/art/megatron/provider.py index 822b34d6e..a3cf5a531 100644 --- a/src/art/megatron/provider.py +++ b/src/art/megatron/provider.py @@ -364,10 +364,18 @@ def _resolve_default_deepep_num_sms(provider: GPTModelProvider) -> int: return sm_count if sm_count >= 2 else 20 -def _apply_default_parallel_topology(provider: GPTModelProvider) -> None: +def _handler_cp_supported(handler: Any) -> bool: + return bool(getattr(handler, "cp_supported", True)) + + +def _apply_default_parallel_topology( + provider: GPTModelProvider, + handler: Any, +) -> None: visible_gpu_count = max(torch.cuda.device_count(), 1) - provider.tensor_model_parallel_size = 1 - provider.context_parallel_size = visible_gpu_count + cp_supported = _handler_cp_supported(handler) + provider.tensor_model_parallel_size = 1 if cp_supported else visible_gpu_count + provider.context_parallel_size = visible_gpu_count if cp_supported else 1 provider.pipeline_model_parallel_size = 1 provider.expert_model_parallel_size = ( visible_gpu_count @@ -377,12 +385,32 @@ def _apply_default_parallel_topology(provider: GPTModelProvider) -> None: provider.expert_tensor_parallel_size = 1 -def _apply_art_training_runtime_prepare_defaults(provider: GPTModelProvider) -> None: +def _apply_art_training_runtime_prepare_defaults( + provider: GPTModelProvider, + handler: Any, +) -> None: provider.recompute_granularity = "full" provider.recompute_method = "uniform" provider.recompute_num_layers = 1 provider.moe_shared_expert_overlap = True - _apply_default_parallel_topology(provider) + _apply_default_parallel_topology(provider, handler) + + +def _validate_context_parallel_support( + handler: Any, + runtime_env: _ProviderRuntimeEnv, +) -> None: + if _handler_cp_supported(handler): + return + if ( + runtime_env.is_set("context_parallel_size") + and runtime_env.context_parallel_size is not None + and runtime_env.context_parallel_size > 1 + ): + raise RuntimeError( + f"{handler.key} model support does not implement context parallelism; " + "set ART_MEGATRON_CONTEXT_PARALLEL_SIZE=1." + ) def _apply_art_training_runtime_finalize_defaults( @@ -582,8 +610,9 @@ def prepare_provider_bundle( provider.calculate_per_token_loss = True provider.cross_entropy_loss_fusion = True provider.cross_entropy_fusion_impl = "te" - _apply_art_training_runtime_prepare_defaults(provider) + _apply_art_training_runtime_prepare_defaults(provider, bundle.handler) bundle.handler.configure_provider_for_runtime(provider) + _validate_context_parallel_support(bundle.handler, runtime_env) _apply_runtime_env_overrides(provider, runtime_env) provider.art_flex_compile_crash_config = ( bundle.handler.flex_attention_compile_crash_config(provider) diff --git a/src/art/megatron/routing_replay.py b/src/art/megatron/routing_replay.py index acc8ff2aa..69ce534bc 100644 --- a/src/art/megatron/routing_replay.py +++ b/src/art/megatron/routing_replay.py @@ -34,6 +34,39 @@ def _active_routing_replay_controller() -> Any | None: return _ACTIVE_ROUTING_REPLAY_CONTROLLER +def _branch_rows_without_future_scored_tokens( + *, + group_ids: torch.Tensor, + parent_ids: torch.Tensor, + logprobs: torch.Tensor, +) -> torch.Tensor: + """Rows whose MoE route cannot affect any later trainable token. + + vLLM returns routes for forwards it actually ran. During generation it does + not run a forward for the final generated token, because no later token is + sampled from that row. ART shared-prefix packing may still leave a masked + suffix token in the same branch, so a physical group boundary is not enough + to identify rows that can safely use synthetic replay routes. + """ + non_padding = group_ids != -1 + branch = non_padding & (group_ids != parent_ids) + scored = non_padding & torch.isfinite(logprobs) + allowed = torch.zeros_like(branch) + for sample_index in range(int(group_ids.shape[0])): + future_scored_by_group: dict[int, bool] = {} + for position in range(int(group_ids.shape[1]) - 1, -1, -1): + group_id = int(group_ids[sample_index, position].item()) + if group_id == -1: + continue + has_future_scored = future_scored_by_group.get(group_id, False) + allowed[sample_index, position] = ( + bool(branch[sample_index, position].item()) and not has_future_scored + ) + if bool(scored[sample_index, position].item()): + future_scored_by_group[group_id] = True + return allowed + + def _to_tensor_cpu_contiguous( tensor: torch.Tensor, *, dtype: torch.dtype ) -> torch.Tensor: @@ -318,7 +351,15 @@ def from_dir(cls, bundle_dir: str | Path) -> "MoeRoutingReplayBundle": steps: dict[int, StepRoutes] = {} for step_index_str, step_info in manifest["steps"].items(): step_index = int(step_index_str) - step_tensors = load_file(str(base_dir / step_info["file"])) + loaded_tensors = load_file(str(base_dir / step_info["file"])) + # Own CPU storage immediately. Safetensors CPU loads can keep + # file-backed storage alive, which makes shared-filesystem cleanup + # fail while long-lived Megatron ranks still hold replay bundles. + step_tensors = { + key: tensor.detach().clone().contiguous() + for key, tensor in loaded_tensors.items() + } + del loaded_tensors if GLOBAL_TOKEN_UIDS_KEY not in step_tensors: raise RuntimeError( f"Missing tensor key '{GLOBAL_TOKEN_UIDS_KEY}' for step={step_index}" @@ -450,7 +491,13 @@ def build_moe_routing_replay_bundle_from_packed_tensors( terminal_completion = ( non_padding & (group_ids != parent_ids) & (group_ids != next_group_ids) ) - unexpected_missing = non_padding & ~token_mask & ~terminal_completion + no_future_scored = _branch_rows_without_future_scored_tokens( + group_ids=group_ids, + parent_ids=parent_ids, + logprobs=packed_tensors["logprobs"], + ) + allowed_missing = terminal_completion | no_future_scored + unexpected_missing = non_padding & ~token_mask & ~allowed_missing if bool(unexpected_missing.any().item()): raise RuntimeError( "Packed tensors are missing MoE routes outside terminal completion " @@ -478,8 +525,8 @@ def build_moe_routing_replay_bundle_from_packed_tensors( if bool(missing_rows.any().item()): # Megatron Core RouterReplay replays only top-k ids and does # not consume an expert mask. Rows without vLLM routes are - # allowed only for padding or terminal completion query - # positions, whose next-token logits are not scored. + # allowed only for padding or branch-tail query positions + # that cannot affect any later scored token. missing_positions = torch.nonzero( missing_rows, as_tuple=False ).flatten() @@ -688,6 +735,26 @@ def __init__( self._target_copy_waited: bool = True self._active_token_uid_key: str | None = None + def update_bundle(self, *, bundle: MoeRoutingReplayBundle, strict: bool) -> None: + self.bundle = bundle + self.strict = strict + self.clear_replay_state() + if self.strict: + missing = sorted( + router_key + for router_key in self._local_router_keys + if router_key not in self.bundle.router_keys + ) + if missing: + raise RuntimeError( + "Router keys from model are missing in replay bundle: " + f"router_keys={missing}" + ) + + def clear_replay_state(self) -> None: + self._clear_native_router_replay_state() + self._reset_step_state() + def _target_device(self) -> torch.device: if self._device is not None: return self._device @@ -754,6 +821,28 @@ def _prepare_native_target_for_bound_router( _prepare_native_target_for_bound_router ) + def _hash_routing_with_replay_target( + router_module: Any, + *args: Any, + _original_routing: Any = original_routing, + _prepare_native_target: Any = prepare_native_target, + **kwargs: Any, + ) -> Any: + del router_module + _prepare_native_target() + return _original_routing(*args, **kwargs) + + def _moe_routing_with_replay_target( + router_module: Any, + *args: Any, + _original_routing: Any = original_routing, + _prepare_native_target: Any = prepare_native_target, + **kwargs: Any, + ) -> Any: + del router_module + _prepare_native_target() + return _original_routing(*args, **kwargs) + def _routing_with_replay_target( router_module: Any, *args: Any, @@ -768,7 +857,16 @@ def _routing_with_replay_target( _prepare_native_target() return _original_routing(*args, **kwargs) - module.routing = types.MethodType(_routing_with_replay_target, module) + original_routing_name = getattr( + getattr(original_routing, "__func__", None), "__name__", "" + ) + if original_routing_name == "_hash_routing": + routing_wrapper = _hash_routing_with_replay_target + elif original_routing_name == "_moe_routing": + routing_wrapper = _moe_routing_with_replay_target + else: + routing_wrapper = _routing_with_replay_target + module.routing = types.MethodType(routing_wrapper, module) setattr(module, "_art_routing_replay_target_patched", True) self._router_bindings[router_key] = { "module": module, diff --git a/src/art/megatron/runtime/bridge_runtime.py b/src/art/megatron/runtime/bridge_runtime.py index 4b6b89d7f..8aabed47b 100644 --- a/src/art/megatron/runtime/bridge_runtime.py +++ b/src/art/megatron/runtime/bridge_runtime.py @@ -1,6 +1,6 @@ from __future__ import annotations -from collections.abc import Iterable, Mapping +from collections.abc import Callable, Iterable, Mapping import contextlib import fnmatch from typing import Any, cast @@ -23,6 +23,8 @@ from art.megatron.model_support.spec import HfWeightSource +_Fp32PreservedTensor = tuple[torch.nn.Module, str, torch.Tensor, bool] + class ExpertTensorSlice: __slots__ = ("global_start", "global_stop", "tensor") @@ -240,6 +242,8 @@ def load_unique_hf_keys_once( hf_state_dict: Mapping[str, torch.Tensor], *, bridge: MegatronModelBridge | None = None, + extra_keys: Callable[[Iterable[str], Mapping[str, torch.Tensor]], Iterable[str]] + | None = None, ) -> dict[str, torch.Tensor | ExpertTensorSlice]: task_list = list(tasks) prefetch_task_by_key: dict[str, Any] = {} @@ -248,6 +252,9 @@ def load_unique_hf_keys_once( continue for key in _iter_hf_param_names(task.mapping.hf_param): prefetch_task_by_key.setdefault(key, task) + if extra_keys is not None: + for key in extra_keys(tuple(sorted(prefetch_task_by_key)), hf_state_dict): + prefetch_task_by_key.setdefault(key, None) keys = sorted(prefetch_task_by_key) expert_slice_ranges: dict[str, tuple[int, int]] = {} expert_slice_task_by_key: dict[str, Any] = {} @@ -415,21 +422,62 @@ def _wrap_with_mp_wrapper( ) -> list[MegatronModule]: if not (model_config.fp16 or model_config.bf16) or mixed_precision_wrapper is None: return model - keep_in_fp32: list[tuple[Any, torch.Tensor]] = [] - for model_module in model: - for submodule in model_module.modules(): - if hasattr(submodule, "_maintain_float32_expert_bias"): - expert_bias = getattr(submodule, "expert_bias", None) - if expert_bias is not None: - keep_in_fp32.append((submodule, expert_bias.data.clone())) + keep_in_fp32 = _collect_fp32_preserved_tensors(model) wrapped = [ mixed_precision_wrapper(model_config, model_module) for model_module in model ] - for submodule, fp32_data in keep_in_fp32: - submodule.expert_bias.data = fp32_data + _restore_fp32_preserved_tensors(keep_in_fp32) return wrapped +def _collect_fp32_preserved_tensors( + model: list[MegatronModule], +) -> list[_Fp32PreservedTensor]: + """Snapshot tensors explicitly marked to survive Megatron fp16/bf16 casts.""" + + keep_in_fp32: list[_Fp32PreservedTensor] = [] + for model_module in model: + for submodule in model_module.modules(): + fp32_parameter_names = set(getattr(submodule, "_keep_fp32_parameters", ())) + fp32_buffer_names = set(getattr(submodule, "_keep_fp32_buffers", ())) + explicit_names = fp32_parameter_names | fp32_buffer_names + seen: set[str] = set() + if hasattr(submodule, "_maintain_float32_expert_bias"): + expert_bias = getattr(submodule, "expert_bias", None) + if isinstance(expert_bias, torch.nn.Parameter): + keep_in_fp32.append( + (submodule, "expert_bias", expert_bias.data.clone(), True) + ) + seen.add("expert_bias") + for name in explicit_names: + tensor = getattr(submodule, name, None) + if isinstance(tensor, torch.nn.Parameter): + keep_in_fp32.append((submodule, name, tensor.data.clone(), True)) + seen.add(name) + elif isinstance(tensor, torch.Tensor): + keep_in_fp32.append((submodule, name, tensor.data.clone(), False)) + seen.add(name) + for name, param in submodule.named_parameters(recurse=False): + if name not in seen and getattr(param, "_keep_fp32", False): + keep_in_fp32.append((submodule, name, param.data.clone(), True)) + seen.add(name) + for name, buffer in submodule.named_buffers(recurse=False): + if name not in seen and getattr(buffer, "_keep_fp32", False): + keep_in_fp32.append((submodule, name, buffer.data.clone(), False)) + seen.add(name) + return keep_in_fp32 + + +def _restore_fp32_preserved_tensors( + keep_in_fp32: list[_Fp32PreservedTensor], +) -> None: + for submodule, name, fp32_data, is_parameter in keep_in_fp32: + if is_parameter: + getattr(submodule, name).data = fp32_data + else: + submodule._buffers[name] = fp32_data + + def _art_get_model( model_provider: ModelProviderMixin, ddp_config: DistributedDataParallelConfig, @@ -527,11 +575,13 @@ def _column_parallel_hf_to_megatron( splits = list(torch.chunk(hf_weights, self.tp_size, dim=0)) else: splits = None - return self.scatter_to_tp_ranks( + return _scatter_to_tp_ranks( + self, splits, target_param.shape, target_param.dtype, target_param.device, + output_tensor=target_param.data, ) @@ -542,20 +592,50 @@ def _scatter_to_tp_ranks( dtype: torch.dtype, device: torch.device, src_rank: int = 0, + output_tensor: torch.Tensor | None = None, ) -> torch.Tensor: if self.tp_size == 1: - return cast(list[torch.Tensor], splits)[0].to( - device=device, dtype=dtype, non_blocking=True - ) - output = torch.empty(output_shape, dtype=dtype, device=device) + shard = cast(list[torch.Tensor], splits)[0] + if output_tensor is None: + return shard.to(device=device, dtype=dtype, non_blocking=True) + output_tensor.copy_(shard, non_blocking=True) + if output_tensor.device.type == "cuda": + torch.cuda.synchronize(output_tensor.device) + return output_tensor + output = ( + torch.empty(output_shape, dtype=dtype, device=device) + if output_tensor is None + else output_tensor + ) dist = cast(Any, torch.distributed) global_src = dist.get_global_rank(group=self.tp_group, group_rank=src_rank) - scatter_list = None - if self.tp_rank == src_rank and splits: - scatter_list = [ - shard.to(device=device, dtype=dtype, non_blocking=True) for shard in splits - ] - dist.scatter(output, scatter_list, src=global_src, group=self.tp_group) + if self.tp_rank == src_rank: + if not splits: + raise RuntimeError("source TP rank must provide tensor splits") + if len(splits) != self.tp_size: + raise RuntimeError( + f"source TP rank got {len(splits)} tensor splits for TP size " + f"{self.tp_size}" + ) + for peer_rank, shard in enumerate(splits): + if peer_rank == src_rank: + output.copy_(shard, non_blocking=True) + continue + send_buffer = torch.empty(output_shape, dtype=dtype, device=device) + send_buffer.copy_(shard, non_blocking=True) + if send_buffer.device.type == "cuda": + torch.cuda.current_stream(send_buffer.device).synchronize() + dist.send( + send_buffer, + dst=dist.get_global_rank(group=self.tp_group, group_rank=peer_rank), + group=self.tp_group, + ) + if send_buffer.device.type == "cuda": + torch.cuda.synchronize(send_buffer.device) + else: + dist.recv(output, src=global_src, group=self.tp_group) + if output.device.type == "cuda": + torch.cuda.synchronize(output.device) return output @@ -598,7 +678,12 @@ def _optimized_load_weights_hf_to_megatron( stack.enter_context(megatron_model[0].hide_loss_modules()) tasks = self.build_conversion_tasks(hf_pretrained, megatron_model) hf_state_dict = hf_pretrained.state - raw_cache = load_unique_hf_keys_once(tasks, hf_state_dict, bridge=self) + raw_cache = load_unique_hf_keys_once( + tasks, + hf_state_dict, + bridge=self, + extra_keys=getattr(self, "art_extra_hf_prefetch_keys", None), + ) cached_state = _CachedStateLookup(cache=raw_cache, source=hf_state_dict) description = f"Loading from {hf_pretrained.model_name_or_path}" pending_device_copy = False @@ -642,7 +727,8 @@ def _optimized_load_weights_hf_to_megatron( f" Bridge type: {type(task.mapping).__name__}\n" f" HF mapping: {task.mapping.hf_param}" ) - task.param_weight.data.copy_(converted_weights, non_blocking=True) + if converted_weights.data_ptr() != task.param_weight.data.data_ptr(): + task.param_weight.data.copy_(converted_weights, non_blocking=True) if task.param_weight.device.type == "cuda": pending_device_copy = True if pending_device_copy and torch.cuda.is_available(): diff --git a/src/art/megatron/service.py b/src/art/megatron/service.py index 53d62d6a4..7535a9c76 100644 --- a/src/art/megatron/service.py +++ b/src/art/megatron/service.py @@ -8,6 +8,7 @@ import socket import sys from typing import Any, AsyncIterator, Literal, TypedDict, cast +from urllib.parse import urlparse import warnings from peft.tuners.lora.config import LoraConfig @@ -31,6 +32,10 @@ from ..vllm_runtime import ( ManagedVllmRuntime, VllmRuntimeLaunchConfig, + get_external_vllm_runtime_config, + map_checkpoint_path_for_vllm, + normalize_vllm_server_url, + wait_for_vllm_http_runtime, ) from .lora import ( LORA_ALPHA, @@ -242,14 +247,22 @@ def rollout_weights_mode(self) -> Literal["lora", "merged"]: @property def _vllm_base_url(self) -> str: + if external_runtime := get_external_vllm_runtime_config(self.config): + return normalize_vllm_server_url(external_runtime.server_url) return self._vllm_runtime.base_url @property def _vllm_host(self) -> str: + if external_runtime := get_external_vllm_runtime_config(self.config): + parsed = urlparse(normalize_vllm_server_url(external_runtime.server_url)) + return parsed.hostname or self._vllm_runtime.host return self._vllm_runtime.host @property def _vllm_port(self) -> int: + if external_runtime := get_external_vllm_runtime_config(self.config): + parsed = urlparse(normalize_vllm_server_url(external_runtime.server_url)) + return parsed.port or (443 if parsed.scheme == "https" else 80) return self._vllm_runtime.port @_vllm_port.setter @@ -258,6 +271,8 @@ def _vllm_port(self, port: int) -> None: @property def _vllm_api_key(self) -> str | None: + if external_runtime := get_external_vllm_runtime_config(self.config): + return external_runtime.api_key return self._vllm_runtime.api_key @property @@ -446,6 +461,9 @@ def _runtime_request_kwargs(self) -> _RuntimeRequestKwargs: headers = self._runtime_headers() return {"headers": headers} if headers else {} + def _vllm_checkpoint_path(self, checkpoint_path: str) -> str: + return map_checkpoint_path_for_vllm(self.config, checkpoint_path) + def _sleep_mode_enabled(self) -> bool: return bool(self.config.get("engine_args", {}).get("enable_sleep_mode", True)) @@ -624,7 +642,7 @@ async def _reload_adapter(self, checkpoint_path: str, step: int) -> None: f"{self._vllm_base_url}/v1/load_lora_adapter", json={ "lora_name": f"{self.model_name}@{step}", - "lora_path": checkpoint_path, + "lora_path": self._vllm_checkpoint_path(checkpoint_path), "load_inplace": True, }, **self._runtime_request_kwargs(), @@ -909,6 +927,7 @@ async def start_openai_server( ) -> tuple[str, int]: self._raise_if_child_failed() lora_path = self._resolve_active_lora_path() + external_runtime = get_external_vllm_runtime_config(self.config) if not self.is_dedicated and not self._sleep_mode_enabled(): raise ValueError( @@ -916,6 +935,21 @@ async def start_openai_server( "for the external vLLM runtime" ) + if external_runtime is not None: + if self.rollout_weights_mode != "lora": + raise RuntimeError( + "External vLLM runtime requires LoRA rollout weights" + ) + await wait_for_vllm_http_runtime( + base_url=self._vllm_base_url, + timeout=external_runtime.health_timeout_s, + headers=self._runtime_headers(), + ) + await self._reload_adapter(lora_path, self._latest_step) + self._loaded_adapter_steps.add(self._latest_step) + self._status(f"External vLLM runtime is ready at {self._vllm_base_url}") + return self._vllm_host, self._vllm_port + port = (config or {}).get("server_args", {}).get("port", 8000) location = await self._start_vllm_subprocess(lora_path, port, config) if self.rollout_weights_mode == "lora": diff --git a/src/art/megatron/shared_prefix_state.py b/src/art/megatron/shared_prefix_state.py index e6ecccfc8..97dbb6d93 100644 --- a/src/art/megatron/shared_prefix_state.py +++ b/src/art/megatron/shared_prefix_state.py @@ -31,6 +31,7 @@ move_gdn_rank_execution_plan_to_device, parse_gdn_shared_prefix_segments, ) +from art.megatron.model_support.spec import SharedPrefixModelStateContext class SharedPrefixAttentionState(FlexSharedPrefixAttentionState): @@ -38,6 +39,7 @@ class SharedPrefixAttentionState(FlexSharedPrefixAttentionState): group_ids: Tensor parent_ids: Tensor + model_state: dict[str, Any] = Field(default_factory=dict) gdn_execution_spec: GdnPackedExecutionSpec | None = None gdn_execution_plan: GdnRankExecutionPlan | None = None gdn_hidden_layout: str = "attention" @@ -59,6 +61,7 @@ def create_shared_prefix_state( input_pos: Tensor | None = None, sliding_windows: tuple[int, ...] = (), build_gdn_execution_spec: bool = False, + model_support_handler: Any | None = None, attention_token_layout_index: TokenLayoutIndex | None = None, attention_head_dim: int | None = None, attention_value_head_dim: int | None = None, @@ -106,6 +109,16 @@ def create_shared_prefix_state( sliding_block_masks=sliding_block_masks, group_ids=group_ids_cpu, parent_ids=parent_ids_cpu, + model_state=_build_model_state_once( + model_support_handler, + input_pos=input_pos_cpu, + group_ids=group_ids_cpu, + parent_ids=parent_ids_cpu, + device=device, + attention_token_layout_index=attention_token_layout_index, + attention_head_dim=attention_head_dim, + attention_value_head_dim=attention_value_head_dim, + ), gdn_execution_spec=gdn_execution_spec, gdn_execution_plan=_build_gdn_execution_plan_once( gdn_execution_spec, @@ -118,6 +131,34 @@ def create_shared_prefix_state( ) +def _build_model_state_once( + model_support_handler: Any | None, + *, + input_pos: Tensor | None, + group_ids: Tensor, + parent_ids: Tensor, + device: torch.device, + attention_token_layout_index: TokenLayoutIndex | None, + attention_head_dim: int | None, + attention_value_head_dim: int | None, +) -> dict[str, Any]: + if model_support_handler is None: + return {} + return dict( + model_support_handler.build_shared_prefix_model_state( + SharedPrefixModelStateContext( + input_pos=input_pos, + group_ids=group_ids, + parent_ids=parent_ids, + device=device, + attention_token_layout_index=attention_token_layout_index, + attention_head_dim=attention_head_dim, + attention_value_head_dim=attention_value_head_dim, + ) + ) + ) + + def _metadata_cpu(tensor: Tensor) -> Tensor: tensor = tensor.detach() if tensor.device.type != "cpu" or tensor.dtype != torch.int64: diff --git a/src/art/megatron/training/microbatches.py b/src/art/megatron/training/microbatches.py index 8bf65017f..de6650cba 100644 --- a/src/art/megatron/training/microbatches.py +++ b/src/art/megatron/training/microbatches.py @@ -315,6 +315,7 @@ def _causal_attention_state( *, sliding_windows: tuple[int, ...] = (), build_gdn_execution_spec: bool, + model_support_handler: Any, attention_head_dim: int | None = None, attention_value_head_dim: int | None = None, ) -> Any: @@ -327,6 +328,7 @@ def _causal_attention_state( input_pos=torch.arange(seq_len, dtype=torch.int64).unsqueeze(0), sliding_windows=sliding_windows, build_gdn_execution_spec=build_gdn_execution_spec, + model_support_handler=model_support_handler, attention_head_dim=attention_head_dim, attention_value_head_dim=attention_value_head_dim, ) @@ -360,6 +362,7 @@ def _prepare_dense_rl_micro( build_gdn_execution_spec=bool( getattr(model_support_handler, "build_gdn_execution_spec", False) ), + model_support_handler=model_support_handler, attention_head_dim=getattr(provider, "kv_channels", None), attention_value_head_dim=getattr(provider, "kv_channels", None), ) @@ -561,6 +564,7 @@ def _prepare_dense_sft_micro( build_gdn_execution_spec=bool( getattr(model_support_handler, "build_gdn_execution_spec", False) ), + model_support_handler=model_support_handler, attention_head_dim=getattr(provider, "kv_channels", None), attention_value_head_dim=getattr(provider, "kv_channels", None), ), diff --git a/src/art/megatron/training/streaming_weight_offload.py b/src/art/megatron/training/streaming_weight_offload.py index c7da2d702..a36ca5414 100644 --- a/src/art/megatron/training/streaming_weight_offload.py +++ b/src/art/megatron/training/streaming_weight_offload.py @@ -27,6 +27,8 @@ STREAMING_COMPILED_LAYERS_MESSAGE = ( "Streaming weight offload managing compiled transformer layers" ) +# Quantized/custom kernels may vector-load weights and scales from streamed views. +STREAMED_PARAM_ALIGNMENT_BYTES = 256 def _rank0_info(rank: int, message: str, *args: object) -> None: @@ -199,7 +201,7 @@ def _pre_forward(self, layer_state: _LayerState) -> None: ) def _post_forward(self, layer_state: _LayerState) -> None: - if is_checkpointing() and not torch.is_grad_enabled(): + if not torch.is_grad_enabled(): self._start_offload(layer_state) self._prefetch_window(layer_state.index + self.config.resident_layers, 1, 1) @@ -490,13 +492,16 @@ def _build_tensor_groups( grouped.setdefault(param.dtype, []).append((name, param)) groups: list[_TensorGroup] = [] for dtype, dtype_params in grouped.items(): - total_numel = sum(param.numel() for _name, param in dtype_params) - cpu_flat = torch.empty(total_numel, dtype=dtype, device="cpu") + element_size = dtype_params[0][1].element_size() + alignment_numel = max( + 1, + (STREAMED_PARAM_ALIGNMENT_BYTES + element_size - 1) // element_size, + ) specs: list[_ParamSpec] = [] offset = 0 for name, param in dtype_params: + offset = _align_numel(offset, alignment_numel) numel = param.numel() - cpu_flat[offset : offset + numel].copy_(param.detach().view(-1).cpu()) specs.append( _ParamSpec( name=name, @@ -507,10 +512,19 @@ def _build_tensor_groups( ) ) offset += numel + cpu_flat = torch.empty(offset, dtype=dtype, device="cpu") + for spec in specs: + cpu_flat[spec.offset : spec.offset + spec.numel].copy_( + spec.param.detach().view(-1).cpu() + ) groups.append(_TensorGroup(dtype=dtype, cpu_flat=cpu_flat, specs=specs)) return groups +def _align_numel(offset: int, alignment_numel: int) -> int: + return ((offset + alignment_numel - 1) // alignment_numel) * alignment_numel + + def _validate_streamed_param(spec: _ParamSpec) -> None: if spec.param.requires_grad: raise RuntimeError( diff --git a/src/art/transformers/patches.py b/src/art/transformers/patches.py index 6eb28b007..fe3509cb8 100644 --- a/src/art/transformers/patches.py +++ b/src/art/transformers/patches.py @@ -1,4 +1,4 @@ -from typing import TYPE_CHECKING, Optional, Union +from typing import TYPE_CHECKING, Any, Optional, Union import torch from transformers import masking_utils @@ -19,15 +19,7 @@ def _patched_preprocess_mask_arguments( position_ids: Optional[torch.Tensor], layer_idx: Optional[int], encoder_hidden_states: Optional[torch.Tensor] = None, -) -> tuple[ - bool, - Optional[Union[torch.Tensor, "BlockMask"]], - Optional[torch.Tensor], - int, - int, - int, - int, -]: +) -> tuple[Any, ...]: if position_ids is not None and len(position_ids.shape) == 3: position_ids = position_ids[0] return _preprocess_mask_arguments( diff --git a/src/art/vllm_runtime.py b/src/art/vllm_runtime.py index 31e4b98b8..7445758d1 100644 --- a/src/art/vllm_runtime.py +++ b/src/art/vllm_runtime.py @@ -10,7 +10,8 @@ import shutil import subprocess import tempfile -from typing import Any, Callable, Literal, TypedDict +from typing import Any, Callable, Literal, Mapping, TypedDict +from urllib.parse import urlparse import httpx from pydantic import BaseModel, ConfigDict, Field @@ -25,6 +26,17 @@ RUNTIME_PACKAGE = "art-vllm-runtime" RUNTIME_PROTOCOL_VERSION = 1 RUNTIME_INSTALL_MARKER = "openpipe-art-vllm-runtime" +_TILELANG_ENV_KEYS = ( + "PYTHONPATH", + "TVM_IMPORT_PYTHON_PATH", + "TVM_LIBRARY_PATH", + "TL_CUTLASS_PATH", + "TL_TEMPLATE_PATH", + "TL_COMPOSABLE_KERNEL_PATH", +) +_TILELANG_PATH_MARKERS = ("/site-packages/tilelang/", "\\site-packages\\tilelang\\") +_FLASHINFER_WORKSPACE_ENV = "FLASHINFER_WORKSPACE_BASE" +_ART_FLASHINFER_WORKSPACE_ENV = "ART_VLLM_RUNTIME_FLASHINFER_WORKSPACE_BASE" VLLM_RUNTIME_CLOSE_TIMEOUT = 15.0 @@ -35,13 +47,24 @@ class VllmRuntimeLaunchConfig(BaseModel): port: int host: str = "127.0.0.1" cuda_visible_devices: str - lora_path: str + lora_path: str | None = None served_model_name: str rollout_weights_mode: Literal["lora", "merged"] engine_args: dict[str, object] = Field(default_factory=dict) server_args: dict[str, object] = Field(default_factory=dict) +class ExternalVllmRuntimeConfig(BaseModel): + model_config = ConfigDict(extra="forbid") + + mode: Literal["external"] + server_url: str + api_key: str | None = None + local_checkpoint_root: str | None = None + server_checkpoint_root: str | None = None + health_timeout_s: float = Field(default=120.0, gt=0) + + class VllmRuntimeManifest(BaseModel): model_config = ConfigDict(extra="forbid") @@ -75,6 +98,123 @@ class VllmRuntimeRequestKwargs(TypedDict, total=False): headers: dict[str, str] +def is_external_vllm_runtime(config: Mapping[str, Any]) -> bool: + runtime_config = config.get("vllm_runtime") + return ( + isinstance(runtime_config, Mapping) and runtime_config.get("mode") == "external" + ) + + +def get_external_vllm_runtime_config( + config: Mapping[str, Any], +) -> ExternalVllmRuntimeConfig | None: + runtime_config = config.get("vllm_runtime") + if not isinstance(runtime_config, Mapping): + return None + if runtime_config.get("mode", "managed") != "external": + return None + return ExternalVllmRuntimeConfig.model_validate(runtime_config) + + +def normalize_vllm_server_url(server_url: str) -> str: + normalized = server_url.rstrip("/") + if normalized.endswith("/v1"): + normalized = normalized[:-3].rstrip("/") + parsed = urlparse(normalized) + if parsed.scheme not in {"http", "https"} or not parsed.netloc: + raise ValueError( + f"External vLLM server_url must be an HTTP URL: {server_url!r}" + ) + return normalized + + +def openai_base_url_from_vllm_server_url(server_url: str) -> str: + return f"{normalize_vllm_server_url(server_url)}/v1" + + +def map_checkpoint_path_for_vllm( + config: Mapping[str, Any], + checkpoint_path: str, +) -> str: + runtime_config = get_external_vllm_runtime_config(config) + if runtime_config is None: + return checkpoint_path + local_root = runtime_config.local_checkpoint_root + server_root = runtime_config.server_checkpoint_root + if local_root is None and server_root is None: + return checkpoint_path + if not local_root or not server_root: + raise ValueError( + "Set both vllm_runtime.local_checkpoint_root and " + "vllm_runtime.server_checkpoint_root, or neither." + ) + checkpoint_abs = os.path.abspath(checkpoint_path) + local_root_abs = os.path.abspath(local_root) + rel_path = os.path.relpath(checkpoint_abs, local_root_abs) + if rel_path == os.pardir or rel_path.startswith(os.pardir + os.sep): + raise ValueError( + f"Checkpoint path {checkpoint_path!r} is not under " + f"vllm_runtime.local_checkpoint_root {local_root!r}" + ) + return os.path.join(server_root, rel_path) + + +async def wait_for_vllm_http_runtime( + *, + base_url: str, + timeout: float, + headers: dict[str, str] | None = None, +) -> None: + deadline = asyncio.get_running_loop().time() + timeout + url = f"{base_url.rstrip('/')}/health" + async with httpx.AsyncClient() as client: + while True: + try: + response = await client.get(url, headers=headers, timeout=5.0) + if response.status_code == 200: + return + except httpx.HTTPError: + pass + if asyncio.get_running_loop().time() >= deadline: + raise TimeoutError( + f"vLLM runtime did not become ready within {math.ceil(timeout)}s" + ) + await asyncio.sleep(0.5) + + +def _drop_tilelang_env_paths(value: str | None) -> str | None: + if value is None: + return None + kept = [ + part + for part in value.split(os.pathsep) + if not any(marker in part for marker in _TILELANG_PATH_MARKERS) + ] + return os.pathsep.join(kept) if kept else None + + +def _vllm_runtime_subprocess_env() -> dict[str, str]: + """Build a child env isolated from runtime-specific JIT path leaks. + + TileLang mutates process env during import. If a vLLM runtime child inherits + those paths, spawn workers can load two TVM FFI libraries and abort on + duplicate global registration during model load. + + FlashInfer writes absolute source/include paths into generated build.ninja + files. Sharing its default ~/.cache/flashinfer across source worktrees can + make one runtime compile kernels from another runtime's venv. + """ + env = os.environ.copy() + for key in _TILELANG_ENV_KEYS: + value = _drop_tilelang_env_paths(env.get(key)) + if value is None: + env.pop(key, None) + else: + env[key] = value + env[_FLASHINFER_WORKSPACE_ENV] = str(_vllm_runtime_flashinfer_workspace_base()) + return env + + class ManagedVllmRuntime: def __init__(self, *, host: str = "127.0.0.1") -> None: self.host = host @@ -123,7 +263,7 @@ async def start( self.process = subprocess.Popen( managed_process_cmd(cmd), cwd=str(get_vllm_runtime_working_dir()), - env=os.environ.copy(), + env=_vllm_runtime_subprocess_env(), stdout=self.log_file, stderr=subprocess.STDOUT, bufsize=1, @@ -186,7 +326,13 @@ async def start( def close(self) -> None: if self.process is not None: terminate_popen_process_group( - self.process, timeout=VLLM_RUNTIME_CLOSE_TIMEOUT + self.process, + timeout=float( + os.environ.get( + "ART_VLLM_RUNTIME_CLOSE_TIMEOUT", + VLLM_RUNTIME_CLOSE_TIMEOUT, + ) + ), ) self.process = None if self.log_file is not None: @@ -227,6 +373,16 @@ def get_vllm_runtime_cache_root() -> Path: return Path.home() / ".cache" / "art" / "vllm_runtime" +def _vllm_runtime_flashinfer_workspace_base() -> Path: + override = os.environ.get(_ART_FLASHINFER_WORKSPACE_ENV) + if override: + return Path(override).expanduser() + runtime_root = get_vllm_runtime_project_root() + if runtime_root.exists(): + return runtime_root.resolve().parent / "scratch" / "vllm_runtime_flashinfer" + return get_vllm_runtime_cache_root().expanduser() / "flashinfer_workspace" + + def _bundled_runtime_dir() -> Path: return Path(__file__).resolve().parent / "_vllm_runtime" @@ -578,18 +734,24 @@ def _runtime_command_prefix() -> list[str]: def build_vllm_runtime_server_cmd(config: VllmRuntimeLaunchConfig) -> list[str]: - return [ + command = [ *_runtime_command_prefix(), f"--model={config.base_model}", f"--port={config.port}", f"--host={config.host}", f"--cuda-visible-devices={config.cuda_visible_devices}", - f"--lora-path={config.lora_path}", - f"--served-model-name={config.served_model_name}", - f"--rollout-weights-mode={config.rollout_weights_mode}", - f"--engine-args-json={json.dumps(config.engine_args)}", - f"--server-args-json={json.dumps(config.server_args)}", ] + if config.lora_path is not None: + command.append(f"--lora-path={config.lora_path}") + command.extend( + [ + f"--served-model-name={config.served_model_name}", + f"--rollout-weights-mode={config.rollout_weights_mode}", + f"--engine-args-json={json.dumps(config.engine_args)}", + f"--server-args-json={json.dumps(config.server_args)}", + ] + ) + return command async def wait_for_vllm_runtime( diff --git a/tests/integration/megatron/lora/merged_vllm_serving.py b/tests/integration/megatron/lora/merged_vllm_serving.py index 7a9a1fd8a..6909ca461 100644 --- a/tests/integration/megatron/lora/merged_vllm_serving.py +++ b/tests/integration/megatron/lora/merged_vllm_serving.py @@ -1,9 +1,11 @@ from __future__ import annotations import asyncio +from contextlib import contextmanager import os from pathlib import Path import socket +from typing import Any, Iterator, cast from pydantic import BaseModel, Field import torch @@ -15,9 +17,15 @@ from ..model_support.oracle_harness import ( ORACLE_TOPOLOGY, OracleCaseConfig, + Topology, ensure_case_artifacts, ) from ..model_support.oracle_worker import provider_topology_env +from ..model_support.workflow_resources import ( + handler_workflow_resources_for_base_model, + resolve_stage_resources_for_visible_gpus, + validate_dedicated_test_resources, +) _TRAINER_GPU_IDS_ENV = "ART_MODEL_SUPPORT_TRAINER_GPU_IDS" _INFERENCE_GPU_IDS_ENV = "ART_MODEL_SUPPORT_INFERENCE_GPU_IDS" @@ -66,14 +74,28 @@ def _resolve_dedicated_gpu_ids() -> tuple[list[int], list[int]]: return [0], [1] -def _init_runtime_config(case_config: OracleCaseConfig) -> None: +@contextmanager +def _temporary_env(updates: dict[str, str]) -> Iterator[None]: + previous = {name: os.environ.get(name) for name in updates} + os.environ.update(updates) + try: + yield + finally: + for name, value in previous.items(): + if value is None: + os.environ.pop(name, None) + else: + os.environ[name] = value + + +def _init_runtime_config(case_config: OracleCaseConfig, topology: Any) -> None: art.init_megatron_runtime_config( topology=art.MegatronTopologyConfig( - tp=ORACLE_TOPOLOGY.tp, - cp=ORACLE_TOPOLOGY.cp, - ep=ORACLE_TOPOLOGY.ep, - pp=ORACLE_TOPOLOGY.pp, - etp=ORACLE_TOPOLOGY.etp, + tp=topology.tp, + cp=topology.cp, + ep=topology.ep, + pp=topology.pp, + etp=topology.etp, ), packed_sequence_length=case_config.packed_tensors.sequence_length, ) @@ -82,7 +104,50 @@ def _init_runtime_config(case_config: OracleCaseConfig) -> None: async def _run_merged_vllm_serving( case_config: OracleCaseConfig, ) -> MergedVllmServingReport: - trainer_gpu_ids, inference_gpu_ids = _resolve_dedicated_gpu_ids() + workflow_resources = handler_workflow_resources_for_base_model( + case_config.base_model, + allow_unvalidated_arch=case_config.allow_unvalidated_arch, + ) + stage_resources = ( + workflow_resources.merged_vllm_serving + if workflow_resources is not None + else None + ) + topology: Topology = ORACLE_TOPOLOGY + megatron_env: dict[str, str] = {} + engine_args: dev.EngineArgs = dev.EngineArgs() + if stage_resources is not None: + stage_resources = resolve_stage_resources_for_visible_gpus( + "merged_vllm_serving", + stage_resources, + visible_gpu_count=int(torch.cuda.device_count()), + ) + if stage_resources.megatron is None or stage_resources.vllm is None: + raise RuntimeError( + "merged_vllm_serving resources require Megatron and vLLM" + ) + trainer_gpu_ids = list(stage_resources.megatron.gpu_ids) + inference_gpu_ids = list(stage_resources.vllm.gpu_ids) + validate_dedicated_test_resources( + stage_name="merged_vllm_serving", + trainer_gpu_ids=trainer_gpu_ids, + inference_gpu_ids=inference_gpu_ids, + allow_overlap=stage_resources.allow_gpu_overlap, + ) + resource_topology = stage_resources.megatron.topology + topology = Topology( + tp=resource_topology.tp, + ep=resource_topology.ep, + etp=resource_topology.etp, + dp=resource_topology.dp, + cp=resource_topology.cp, + pp=resource_topology.pp, + sp=resource_topology.sp, + ) + megatron_env = dict(stage_resources.megatron_env) + engine_args = cast(dev.EngineArgs, stage_resources.vllm.engine_args()) + else: + trainer_gpu_ids, inference_gpu_ids = _resolve_dedicated_gpu_ids() service_name = "model_support_merged_validation" case_artifacts = ensure_case_artifacts(case_config) output_dir = str(Path(case_artifacts.case_dir) / "merged_vllm_serving") @@ -92,10 +157,12 @@ async def _run_merged_vllm_serving( inference_gpu_ids=inference_gpu_ids, rollout_weights_mode="merged", allow_unvalidated_arch=case_config.allow_unvalidated_arch, + engine_args=engine_args, ) - dev.validate_dedicated_config(internal_config) - with provider_topology_env(ORACLE_TOPOLOGY): - _init_runtime_config(case_config) + if stage_resources is None: + dev.validate_dedicated_config(internal_config) + with _temporary_env(megatron_env), provider_topology_env(topology): + _init_runtime_config(case_config, topology) service = MegatronService( model_name=service_name, base_model=case_config.base_model, diff --git a/tests/integration/megatron/lora/native_vllm_lora.py b/tests/integration/megatron/lora/native_vllm_lora.py index a5689275b..d0040e860 100644 --- a/tests/integration/megatron/lora/native_vllm_lora.py +++ b/tests/integration/megatron/lora/native_vllm_lora.py @@ -6,6 +6,7 @@ import shutil import socket import tempfile +from typing import cast from pydantic import BaseModel, Field import torch @@ -21,6 +22,11 @@ ensure_case_artifacts, ) from ..model_support.oracle_worker import provider_topology_env +from ..model_support.workflow_resources import ( + handler_workflow_resources_for_base_model, + resolve_stage_resources_for_visible_gpus, + validate_dedicated_test_resources, +) _TRAINER_GPU_IDS_ENV = "ART_MODEL_SUPPORT_TRAINER_GPU_IDS" _INFERENCE_GPU_IDS_ENV = "ART_MODEL_SUPPORT_INFERENCE_GPU_IDS" @@ -122,7 +128,33 @@ def _init_runtime_config(case_config: OracleCaseConfig) -> None: async def _run_native_vllm_lora( case_config: OracleCaseConfig, ) -> NativeVllmLoraServingReport: - trainer_gpu_ids, inference_gpu_ids = _resolve_dedicated_gpu_ids() + workflow_resources = handler_workflow_resources_for_base_model( + case_config.base_model, + allow_unvalidated_arch=case_config.allow_unvalidated_arch, + ) + stage_resources = ( + workflow_resources.native_vllm_lora if workflow_resources is not None else None + ) + if stage_resources is not None: + stage_resources = resolve_stage_resources_for_visible_gpus( + "native_vllm_lora", + stage_resources, + visible_gpu_count=int(torch.cuda.device_count()), + ) + if stage_resources.vllm is None: + raise RuntimeError("native_vllm_lora resources require vLLM") + trainer_gpu_ids = [0] + inference_gpu_ids = list(stage_resources.vllm.gpu_ids) + validate_dedicated_test_resources( + stage_name="native_vllm_lora", + trainer_gpu_ids=trainer_gpu_ids, + inference_gpu_ids=inference_gpu_ids, + allow_overlap=True, + ) + engine_args = cast(dev.EngineArgs, stage_resources.vllm.engine_args()) + else: + trainer_gpu_ids, inference_gpu_ids = _resolve_dedicated_gpu_ids() + engine_args = dev.EngineArgs() service_name = "model_support_native_lora_validation" case_artifacts = ensure_case_artifacts(case_config) output_root = Path(case_artifacts.case_dir) / "native_vllm_lora" @@ -133,8 +165,10 @@ async def _run_native_vllm_lora( inference_gpu_ids=inference_gpu_ids, rollout_weights_mode="lora", allow_unvalidated_arch=case_config.allow_unvalidated_arch, + engine_args=engine_args, ) - dev.validate_dedicated_config(internal_config) + if stage_resources is None: + dev.validate_dedicated_config(internal_config) with provider_topology_env(ORACLE_TOPOLOGY): _init_runtime_config(case_config) service = MegatronService( diff --git a/tests/integration/megatron/lora/test_merged_weight_export.py b/tests/integration/megatron/lora/test_merged_weight_export.py index 54a155f1f..fc95cfa42 100644 --- a/tests/integration/megatron/lora/test_merged_weight_export.py +++ b/tests/integration/megatron/lora/test_merged_weight_export.py @@ -1,3 +1,4 @@ +from types import SimpleNamespace from typing import Any, cast import httpx diff --git a/tests/integration/megatron/model_support/chat_template_rollout.py b/tests/integration/megatron/model_support/chat_template_rollout.py index 4067eedbf..f126fb3ee 100644 --- a/tests/integration/megatron/model_support/chat_template_rollout.py +++ b/tests/integration/megatron/model_support/chat_template_rollout.py @@ -6,6 +6,7 @@ import art from art.local import LocalBackend +from art.local.backend import _tokenizer_cache_key from art.preprocessing.pack import PackedTensors from art.preprocessing.tokenize import ( TokenizedResult, @@ -101,12 +102,18 @@ def run_chat_template_rollout(base_model: str) -> ChatTemplateRolloutReport: base_model=base_model, _internal_config={"init_args": {"max_seq_length": 2048}}, ) - tokenizer_key = (base_model, None) + internal_config = model._internal_config + assert internal_config is not None + tokenizer_key = _tokenizer_cache_key(base_model, internal_config) tokenizer = backend._tokenizers.get(tokenizer_key) if tokenizer is None: from transformers import AutoTokenizer - tokenizer = AutoTokenizer.from_pretrained(base_model) + tokenizer = backend._configure_training_tokenizer( + AutoTokenizer.from_pretrained(base_model), + model=model, + internal_config=internal_config, + ) backend._tokenizers[tokenizer_key] = tokenizer inputs = build_chat_template_conformance_inputs(tokenizer) diff --git a/tests/integration/megatron/model_support/forward_trace.py b/tests/integration/megatron/model_support/forward_trace.py index cf3aca284..4a0410c6f 100644 --- a/tests/integration/megatron/model_support/forward_trace.py +++ b/tests/integration/megatron/model_support/forward_trace.py @@ -7,8 +7,11 @@ import torch from .trace_uids import ( + TRACE_ROW_TOKEN_UIDS_ATTR, + TRACE_UID_SPAN_ATTR, expand_token_uids_for_heads, extract_tensor_attr, + normalize_row_token_uids, row_token_uids_from_trace_sources, ) @@ -54,6 +57,15 @@ ) +def _module_layer_index(module_name: str) -> int | None: + marker = "decoder.layers." + marker_index = module_name.find(marker) + if marker_index < 0: + return None + raw = module_name[marker_index + len(marker) :].split(".", 1)[0] + return int(raw) if raw.isdigit() else None + + def _trace_hook(fn: Callable[..., Any]) -> Callable[..., Any]: return torch.compiler.disable(fn) @@ -71,6 +83,15 @@ def _safe_int(value: Any, default: int = 0) -> int: return default +def _call_cp_world_size(call: dict[str, Any]) -> int: + rank_meta = call.get("rank_meta") + if isinstance(rank_meta, list) and rank_meta: + rank_meta = rank_meta[0] + if not isinstance(rank_meta, dict): + return 1 + return _safe_int(rank_meta.get("cp_world_size"), 1) + + def _safe_ps_stat(name: str, default: int) -> int: """Reads one Megatron parallel-state integer when available.""" try: @@ -269,11 +290,15 @@ def __init__( *, enabled: bool, capture_name_tokens: tuple[str, ...] = CAPTURE_NAME_TOKENS, + capture_layer_outputs: bool = True, + max_layer_index: int | None = None, micro_start_callback: Callable[[int | None, int], None] | None = None, strict_output_match: bool = True, ) -> None: self.enabled = enabled self.capture_name_tokens = capture_name_tokens + self.capture_layer_outputs = capture_layer_outputs + self.max_layer_index = max_layer_index self.micro_start_callback = micro_start_callback self.strict_output_match = strict_output_match self.current_step_index: int | None = None @@ -307,6 +332,13 @@ def _register_hooks(self, model_chunks: list[Any]) -> None: named_modules = list(chunk.named_modules()) module_by_name = dict(named_modules) for module_name, module in named_modules: + layer_index = _module_layer_index(module_name) + if ( + self.max_layer_index is not None + and layer_index is not None + and layer_index > self.max_layer_index + ): + continue trace_module_name = _normalize_trace_module_name( f"chunk{chunk_index}.{module_name}" ) @@ -318,7 +350,8 @@ def _register_hooks(self, model_chunks: list[Any]) -> None: if metadata: self._trace_metadata_by_name[trace_module_name] = metadata is_layer_output = ( - ".decoder.layers." in module_name + self.capture_layer_outputs + and ".decoder.layers." in module_name and module_name.rsplit(".", 1)[-1].isdigit() ) if not is_layer_output and not any( @@ -593,7 +626,8 @@ def _hook(_module: Any, inputs: Any, output: Any) -> None: if isinstance(primary_output, torch.Tensor) and primary_output.ndim > 0 else None ) - row_token_uids, _uid_span = self._row_token_uids_for_trace( + row_token_uids, _uid_span = self._row_token_uids_for_capture( + module_name=name, inputs=inputs, output=output, module=module, @@ -704,6 +738,47 @@ def _row_token_uids_for_trace( prefer_uid_span=prefer_uid_span, ) + @staticmethod + def _module_row_token_uids( + module: Any, + *, + row_count: int, + ) -> tuple[torch.Tensor | None, int | None]: + row_token_uids = normalize_row_token_uids( + getattr(module, TRACE_ROW_TOKEN_UIDS_ATTR, None) + ) + if row_token_uids is None or int(row_token_uids.numel()) != int(row_count): + return None, None + uid_span = getattr(module, TRACE_UID_SPAN_ATTR, None) + return ( + row_token_uids, + uid_span if isinstance(uid_span, int) and uid_span > 0 else None, + ) + + @classmethod + def _row_token_uids_for_capture( + cls, + *, + module_name: str, + inputs: Any, + output: Any, + module: Any, + row_count: int | None, + ) -> tuple[torch.Tensor | None, int | None]: + if row_count is not None and not cls._is_moe_expert_forward_module(module_name): + row_token_uids, uid_span = cls._module_row_token_uids( + module, + row_count=row_count, + ) + if row_token_uids is not None: + return row_token_uids, uid_span + return cls._row_token_uids_for_trace( + inputs=inputs, + output=output, + module=module, + row_count=row_count, + ) + @classmethod def _slice_row_aligned_value( cls, @@ -1404,6 +1479,39 @@ def _propagate_attention_output_row_token_uids( head_count=head_count, ) + @classmethod + def _restore_non_cp_sequence_row_token_uids( + cls, + trace: dict[str, list[dict[str, Any]]], + ) -> None: + for module_name, calls in trace.items(): + if cls._is_moe_expert_forward_module(module_name): + continue + for call in calls: + if ( + "rank_meta" not in call + or _call_cp_world_size(call) > 1 + or "row_uid_span" in call + ): + continue + tensor = call.get("primary_output") + row_token_uids = call.get("row_token_uids") + if ( + not isinstance(tensor, torch.Tensor) + or tensor.ndim == 0 + or not isinstance(row_token_uids, torch.Tensor) + or row_token_uids.ndim != 1 + or int(row_token_uids.numel()) != int(tensor.shape[0]) + or not bool((row_token_uids >= 0).all().item()) + or int(row_token_uids.unique().numel()) + != int(row_token_uids.numel()) + ): + continue + call["row_token_uids"] = torch.arange( + row_token_uids.numel(), + dtype=torch.int64, + ) + @classmethod def canonicalize_trace( cls, @@ -1426,6 +1534,7 @@ def canonicalize_trace( call[PRIMARY_OUTPUT_CANONICAL_KEY] = True cls._propagate_decoder_row_token_uids(trace) cls._propagate_attention_output_row_token_uids(trace) + cls._restore_non_cp_sequence_row_token_uids(trace) for calls in trace.values(): for call in calls: cls._canonicalize_call_row_token_order(call) diff --git a/tests/integration/megatron/model_support/hf_parity_worker.py b/tests/integration/megatron/model_support/hf_parity_worker.py index 6e7944f67..1bbc2aaaf 100644 --- a/tests/integration/megatron/model_support/hf_parity_worker.py +++ b/tests/integration/megatron/model_support/hf_parity_worker.py @@ -306,6 +306,39 @@ def _timed_load_weights(*args: Any, **kwargs: Any) -> Any: model_bridge._art_hf_parity_timing_wrapped = True +def _is_bridge_hf_load_hook(hook: Any) -> bool: + fn = getattr(hook, "func", hook) + name = getattr(fn, "__name__", "") + qualname = getattr(fn, "__qualname__", "") + return name in { + "load_weights_hf_to_megatron", + "_optimized_load_weights_hf_to_megatron", + } or qualname.endswith(".load_weights_hf_to_megatron") + + +def _remove_bridge_hf_load_hook(provider_bundle: Any) -> None: + """Disable raw checkpoint load when parity seeds from HF oracle state.""" + + provider = provider_bundle.provider + hooks = list(getattr(provider, "_pre_wrap_hooks", [])) + kept = [hook for hook in hooks if not _is_bridge_hf_load_hook(hook)] + if len(kept) == len(hooks): + raise RuntimeError( + "HF parity expected a Bridge HF-load pre-wrap hook to remove" + ) + provider._pre_wrap_hooks = kept + + +def _configure_hf_parity_provider_bundle( + provider_bundle: Any, + *, + use_hf_reference_state: bool, +) -> None: + if use_hf_reference_state: + _remove_bridge_hf_load_hook(provider_bundle) + _install_bridge_timing_debug(provider_bundle) + + def _load_hf_model( *, base_model: str, @@ -315,15 +348,35 @@ def _load_hf_model( ) -> Any: from transformers import AutoConfig, AutoModelForCausalLM + from art.megatron.model_support.registry import get_model_support_handler + + handler = get_model_support_handler(base_model) + ensure_hf_reference_registered = getattr( + handler, "ensure_hf_reference_registered", None + ) + if ensure_hf_reference_registered is not None: + ensure_hf_reference_registered() config = AutoConfig.from_pretrained(base_model, trust_remote_code=True) set_hf_config_num_layers(config, num_layers) zero_hf_dropout_config(config) + prepare_hf_reference_config = getattr(handler, "prepare_hf_reference_config", None) + if prepare_hf_reference_config is not None: + prepare_hf_reference_config(config) + hf_reference_from_pretrained_kwargs = getattr( + handler, "hf_reference_from_pretrained_kwargs", None + ) + extra_kwargs = ( + hf_reference_from_pretrained_kwargs(config=config, dtype=dtype) + if hf_reference_from_pretrained_kwargs is not None + else {} + ) model = AutoModelForCausalLM.from_pretrained( base_model, config=config, trust_remote_code=True, torch_dtype=dtype, low_cpu_mem_usage=True, + **extra_kwargs, ) model.train() return cast(Any, model).to(device) @@ -339,6 +392,37 @@ def _collect_hf_grads(model: Any) -> dict[str, torch.Tensor]: return grads +def _collect_hf_state_dict(model: Any) -> dict[str, torch.Tensor]: + return { + key: value.detach().cpu().clone() + for key, value in model.state_dict().items() + if _is_language_hf_param_name(key) + } + + +def _normalize_hf_reference_state_for_hf_parity( + *, + base_model: str, + model: Any, + state: dict[str, torch.Tensor], +) -> dict[str, torch.Tensor]: + from art.megatron.model_support.registry import get_model_support_handler + + handler = get_model_support_handler(base_model) + normalize = getattr(handler, "normalize_hf_reference_state_for_hf_parity", None) + if normalize is not None: + normalize(state, config=model.config) + return state + + +def _use_hf_reference_state_for_hf_parity(base_model: str) -> bool: + from art.megatron.model_support.registry import get_model_support_handler + + handler = get_model_support_handler(base_model) + enabled = getattr(handler, "use_hf_reference_state_for_hf_parity", None) + return bool(enabled()) if enabled is not None else False + + def _bridge_compatible_hf_key(key: str, expected_keys: set[str]) -> str: if key in expected_keys: return key @@ -454,11 +538,21 @@ def _focus_derivative_tensor_map( loss_active_last_layer_experts: set[int], ) -> dict[str, torch.Tensor]: focused: dict[str, torch.Tensor] = {} + active_router_expert_sets = { + layer_index: set(int(row) for row in rows.reshape(-1).tolist()) + for layer_index, rows in active_router_rows.items() + if rows.numel() > 0 + } for key, value in tensor_map.items(): if match := _EXPERT_WEIGHT_PATTERN.match(key): + layer_index = int(match.group("layer")) + expert_index = int(match.group("expert")) + active_experts = active_router_expert_sets.get(layer_index) + if active_experts is not None and expert_index not in active_experts: + continue if ( - int(match.group("layer")) == last_layer_index - and int(match.group("expert")) not in loss_active_last_layer_experts + layer_index == last_layer_index + and expert_index not in loss_active_last_layer_experts ): continue focused_value = value @@ -489,6 +583,7 @@ def _run_hf_sft_step( torch.Tensor, dict[str, torch.Tensor], MoeRoutingReplayBundle | None, + dict[str, torch.Tensor] | None, ]: _debug("loading HF model") model = _load_hf_model( @@ -540,6 +635,15 @@ def _run_hf_sft_step( token_count += int(mask.sum().item()) (masked_losses.sum() / total_token_count).backward() grads = _collect_hf_grads(model) + hf_reference_state_dict = ( + _normalize_hf_reference_state_for_hf_parity( + base_model=base_model, + model=model, + state=_collect_hf_state_dict(model), + ) + if _use_hf_reference_state_for_hf_parity(base_model) + else None + ) routing_replay_bundle = route_capture.build_replay_bundle(topology=topology) scalar_loss = (loss_sum / max(token_count, 1)).detach().cpu().reshape(1) output_vector = torch.cat(trainable_losses, dim=0).to(dtype=torch.float32) @@ -548,7 +652,13 @@ def _run_hf_sft_step( if torch.cuda.is_available(): torch.cuda.empty_cache() _debug("finished HF step") - return output_vector, scalar_loss, grads, routing_replay_bundle + return ( + output_vector, + scalar_loss, + grads, + routing_replay_bundle, + hf_reference_state_dict, + ) def _install_hf_qwen35_gdn_fp32_reference(model: Any, *, base_model: str) -> None: @@ -572,10 +682,18 @@ def _build_megatron_runtime( *, moe_routing_replay_bundle: MoeRoutingReplayBundle | None = None, ) -> megatron_train.TrainingRuntime: + use_hf_reference_state = _use_hf_reference_state_for_hf_parity( + request.case_config.base_model + ) return megatron_train.build_training_runtime( model_identifier=request.case_config.base_model, provider_torch_dtype=_dtype_for_precision(request.case_config.precision), - provider_bundle_configure=_install_bridge_timing_debug, + provider_bundle_configure=lambda provider_bundle: ( + _configure_hf_parity_provider_bundle( + provider_bundle, + use_hf_reference_state=use_hf_reference_state, + ) + ), provider_configure=lambda provider: _configure_provider( provider, ORACLE_TOPOLOGY, request.case_config ), @@ -644,6 +762,53 @@ def _mapping_targets_language_only(mapping: Any) -> bool: return all(_is_language_hf_param_name(name) for name in names) +def _hf_param_names_for_mapping(mapping: Any) -> set[str]: + names = _language_hf_param_names(mapping) + if not names: + return set() + return set(names) + + +def _build_hf_parity_conversion_tasks( + *, + bridge: Any, + model: list[Any], + hf_keys: set[str], +) -> list[Any]: + tasks = [] + for task in build_art_conversion_tasks(bridge=bridge, model=model): + mapping_names = _hf_param_names_for_mapping(task.mapping) + if not mapping_names: + tasks.append(task) + continue + if mapping_names & hf_keys: + tasks.append(task) + return tasks + + +def _seed_megatron_from_hf_reference_state( + runtime: megatron_train.TrainingRuntime, + *, + tasks: list[Any], + hf_reference_state_dict: dict[str, torch.Tensor], +) -> None: + model_bridge = runtime.bridge._model_bridge + for task in tasks: + if task.mapping is None: + continue + hf_weights = model_bridge.maybe_modify_loaded_hf_weight( + task.mapping.hf_param, + hf_reference_state_dict, + ) + converted_weights = task.mapping.hf_to_megatron( + hf_weights, task.megatron_module + ) + if isinstance(task.param_weight, torch.nn.Parameter): + task.param_weight.data.copy_(converted_weights.to(task.param_weight.device)) + elif isinstance(task.param_weight, torch.Tensor): + task.param_weight.copy_(converted_weights.to(task.param_weight.device)) + + def _filter_language_only_tensor_map( tensor_map: dict[str, torch.Tensor], ) -> dict[str, torch.Tensor]: @@ -772,6 +937,7 @@ def _convert_megatron_tasks_to_hf( *, mode: str, tasks: list[Any] | None = None, + hf_state_dict_override: dict[str, torch.Tensor] | None = None, ) -> dict[str, torch.Tensor]: if tasks is None: tasks = [ @@ -783,7 +949,11 @@ def _convert_megatron_tasks_to_hf( if isinstance(task.param_weight, torch.nn.Parameter) ] model_bridge = runtime.bridge._model_bridge - hf_state_dict = runtime.bridge.hf_pretrained.state + hf_state_dict = ( + hf_state_dict_override + if hf_state_dict_override is not None + else runtime.bridge.hf_pretrained.state + ) grouped_buffers: dict[str, dict[int, torch.Tensor]] = {} converted: dict[str, torch.Tensor] = {} additive_grad_keys: set[str] = set() @@ -847,6 +1017,7 @@ def _run_megatron_sft_step( sample_indices: list[int | None], device: torch.device, moe_routing_replay_bundle: MoeRoutingReplayBundle | None = None, + hf_reference_state_dict: dict[str, torch.Tensor] | None = None, ) -> tuple[torch.Tensor, torch.Tensor, dict[str, torch.Tensor]]: runtime = _build_megatron_runtime( request, @@ -865,16 +1036,34 @@ def _run_megatron_sft_step( sample_index=sample_indices, global_grad_accumulation_sequences=request.case_config.grad_accumulation_sequences, ) - _debug("initializing Megatron optimizer state") - megatron_train._eager_initialize_optimizer_state(runtime.optimizer) - tasks = [ - task - for task in build_art_conversion_tasks( + if hf_reference_state_dict is None: + tasks = [ + task + for task in build_art_conversion_tasks( + bridge=runtime.bridge, + model=runtime.model, + ) + if isinstance(task.param_weight, torch.nn.Parameter) + ] + else: + seed_tasks = _build_hf_parity_conversion_tasks( bridge=runtime.bridge, model=runtime.model, + hf_keys=set(hf_reference_state_dict), ) - if isinstance(task.param_weight, torch.nn.Parameter) - ] + tasks = [ + task + for task in seed_tasks + if isinstance(task.param_weight, torch.nn.Parameter) + ] + _debug("seeding Megatron weights from HF oracle state") + _seed_megatron_from_hf_reference_state( + runtime, + tasks=seed_tasks, + hf_reference_state_dict=hf_reference_state_dict, + ) + _debug("initializing Megatron optimizer state") + megatron_train._eager_initialize_optimizer_state(runtime.optimizer) _debug(f"built {len(tasks)} Megatron conversion tasks") for chunk in runtime.model: if hasattr(chunk, "zero_grad_buffer"): @@ -940,6 +1129,7 @@ def _run_megatron_sft_step( runtime, mode="grad", tasks=derivative_tasks, + hf_state_dict_override=hf_reference_state_dict, ) _debug("exported Megatron grads") if runtime.moe_routing_replay_controller is not None: @@ -1030,7 +1220,13 @@ def _worker_run(request: HfParityRunRequest) -> None: ) try: _debug("starting HF parity worker") - hf_outputs, hf_loss, hf_grads, moe_routing_replay_bundle = _run_hf_sft_step( + ( + hf_outputs, + hf_loss, + hf_grads, + moe_routing_replay_bundle, + hf_reference_state_dict, + ) = _run_hf_sft_step( base_model=request.case_config.base_model, num_layers=request.case_config.num_layers, micro_inputs=micro_inputs, @@ -1045,6 +1241,7 @@ def _worker_run(request: HfParityRunRequest) -> None: sample_indices=sample_indices, device=device, moe_routing_replay_bundle=moe_routing_replay_bundle, + hf_reference_state_dict=hf_reference_state_dict, ) _debug("finished HF and Megatron steps, building report") normalized_hf_grads = _normalize_hf_grads_for_bridge( diff --git a/tests/integration/megatron/model_support/lora_coverage.py b/tests/integration/megatron/model_support/lora_coverage.py index 07097c4d4..7d998de06 100644 --- a/tests/integration/megatron/model_support/lora_coverage.py +++ b/tests/integration/megatron/model_support/lora_coverage.py @@ -22,6 +22,13 @@ from .oracle_worker import _configure_provider, provider_topology_env _WRAPPED_TARGET_SUFFIXES: dict[str, tuple[str, ...]] = { + "q_a_proj": (".self_attn.q_a_proj",), + "q_b_proj": (".self_attn.q_b_proj",), + "kv_proj": (".self_attn.kv_proj",), + "o_a_proj": (".self_attn.o_a_proj",), + "o_b_proj": (".self_attn.o_b_proj",), + "compressor.kv_proj": (".self_attn.compressor.kv_proj",), + "compressor.gate_proj": (".self_attn.compressor.gate_proj",), "q_proj": (".self_attn.q_proj",), "k_proj": (".self_attn.k_proj",), "v_proj": (".self_attn.v_proj",), @@ -107,6 +114,27 @@ def _covered_exported_target_modules( ) -> set[str]: covered: set[str] = set() for base_name, adapter_weights in adapter_weights_by_base.items(): + if base_name.endswith(".self_attention.wq_a.weight"): + covered.add("q_a_proj") + continue + if base_name.endswith(".self_attention.wq_b.weight"): + covered.add("q_b_proj") + continue + if base_name.endswith(".self_attention.wkv.weight"): + covered.add("kv_proj") + continue + if base_name.endswith(".self_attention.wo_a.weight"): + covered.add("o_a_proj") + continue + if base_name.endswith(".self_attention.wo_b.weight"): + covered.add("o_b_proj") + continue + if base_name.endswith(".self_attention.compressor.wkv.weight"): + covered.add("compressor.kv_proj") + continue + if base_name.endswith(".self_attention.compressor.wgate.weight"): + covered.add("compressor.gate_proj") + continue if base_name.endswith(".self_attention.linear_qkv.weight"): for adapter_weight in adapter_weights: adapter_key = getattr(adapter_weight, "adapter_key", None) diff --git a/tests/integration/megatron/model_support/oracle_harness.py b/tests/integration/megatron/model_support/oracle_harness.py index d5bf7c581..2ea15447e 100644 --- a/tests/integration/megatron/model_support/oracle_harness.py +++ b/tests/integration/megatron/model_support/oracle_harness.py @@ -220,6 +220,17 @@ def world_size(self) -> int: Topology(tp=2, ep=2, etp=1, dp=1, cp=2, sp=True), Topology(tp=2, ep=4, etp=2, dp=2, cp=2, sp=True), ] + + +def _without_context_parallel(topology: Topology) -> Topology: + return topology.model_copy(update={"dp": topology.dp * topology.cp, "cp": 1}) + + +CP_UNSUPPORTED_MOE_TOPOLOGIES = [ + _without_context_parallel(topology) for topology in TOPOLOGIES[:-1] +] + [ + Topology(tp=2, ep=2, etp=2, dp=2, cp=1, sp=True), +] DENSE_TOPOLOGIES = [ Topology(tp=1, ep=1, etp=1, dp=1, sp=False), Topology(tp=2, ep=1, etp=1, dp=1, sp=True), @@ -751,9 +762,30 @@ def oracle_topology(*, is_moe: bool = True) -> Topology: return ORACLE_TOPOLOGY if is_moe else DENSE_ORACLE_TOPOLOGY -def selected_suite_topologies(*, is_moe: bool = True) -> list[Topology]: +def _filter_context_parallel_support( + topologies: list[Topology], + *, + is_moe: bool, + cp_supported: bool, +) -> list[Topology]: + if cp_supported: + return topologies + if is_moe: + return list(CP_UNSUPPORTED_MOE_TOPOLOGIES) + return [_without_context_parallel(topology) for topology in topologies] + + +def selected_suite_topologies( + *, + is_moe: bool = True, + cp_supported: bool = True, +) -> list[Topology]: """Returns the correctness topology list for a model family.""" - return list(TOPOLOGIES if is_moe else DENSE_TOPOLOGIES) + return _filter_context_parallel_support( + list(TOPOLOGIES if is_moe else DENSE_TOPOLOGIES), + is_moe=is_moe, + cp_supported=cp_supported, + ) def stable_case_id(case_config: OracleCaseConfig) -> str: @@ -1204,7 +1236,11 @@ def _stacked_layers( if len(reference_shapes) != 1 or len(candidate_shapes) != 1: original_names = original_names_by_group[normalized] for original_name, (reference, candidate) in zip(original_names, group): - stacked_pairs.append((original_name, reference, candidate)) + # Keep one synthetic layer axis so layer-averaged comparison + # does not treat tensor rows/features as layer entries. + stacked_pairs.append( + (original_name, reference.unsqueeze(0), candidate.unsqueeze(0)) + ) continue stacked_pairs.append( ( @@ -2063,8 +2099,17 @@ def run_variant( def run_suite( self, variants: list[VariantSpec], + *, + prune_reference_artifacts: bool = True, + prune_case_artifacts: bool = True, ) -> list[VariantReport]: - """Runs variants in order and stops at the first unexpected signal.""" + """Runs variants in order and stops at the first unexpected signal. + + Reference and case artifacts are normally pruned when the suite exits. + Callers that immediately run another comparison suite against the same + reference can defer that pruning so the second suite does not have to + regenerate or fail on missing forward traces. + """ reports: list[VariantReport] = [] try: for variant in variants: @@ -2078,8 +2123,10 @@ def run_suite( / "variant_report.json", ) finally: - self._prune_reference_artifacts() - _prune_case_artifacts(self.case_dir) + if prune_reference_artifacts: + self._prune_reference_artifacts() + if prune_case_artifacts: + _prune_case_artifacts(self.case_dir) return reports @@ -2125,13 +2172,18 @@ def _suite_variants( objective: OracleObjective, *, is_moe: bool = True, + cp_supported: bool = True, max_world_size: int | None = None, variant_flex_backend: FlexBackend | None = None, + phase_pass_fns: dict[str, PhasePassFn] | None = None, ) -> list[VariantSpec]: """Builds the standard oracle suite variant ordering.""" - phase_pass = _default_phase_pass_fns() + phase_pass = phase_pass_fns or _default_phase_pass_fns() variants: list[VariantSpec] = [] - for topology in selected_suite_topologies(is_moe=is_moe)[1:]: + for topology in selected_suite_topologies( + is_moe=is_moe, + cp_supported=cp_supported, + )[1:]: if max_world_size is not None and topology.world_size() > max_world_size: continue variants.append( @@ -2152,6 +2204,11 @@ def run_suite( max_world_size: int | None = None, oracle_flex_backend: FlexBackend | None = None, variant_flex_backend: FlexBackend | None = None, + cp_supported: bool = True, + phase_pass_fns: dict[str, PhasePassFn] | None = None, + use_fp32_lora_reference: bool = True, + prune_reference_artifacts: bool = True, + prune_case_artifacts: bool = True, ) -> list[VariantReport]: """Runs non-oracle topologies against the canonical replay-backed oracle.""" reports: list[VariantReport] = [] @@ -2161,15 +2218,20 @@ def run_suite( case_config=case_config, oracle_flex_backend=oracle_flex_backend, variant_flex_backend=variant_flex_backend, + use_fp32_lora_reference=use_fp32_lora_reference, ) reports.extend( runner.run_suite( _suite_variants( objective, is_moe=case_config.is_moe, + cp_supported=cp_supported, max_world_size=max_world_size, variant_flex_backend=variant_flex_backend, - ) + phase_pass_fns=phase_pass_fns, + ), + prune_reference_artifacts=prune_reference_artifacts, + prune_case_artifacts=prune_case_artifacts, ) ) return reports diff --git a/tests/integration/megatron/model_support/oracle_worker.py b/tests/integration/megatron/model_support/oracle_worker.py index 29a537469..42da1eab5 100644 --- a/tests/integration/megatron/model_support/oracle_worker.py +++ b/tests/integration/megatron/model_support/oracle_worker.py @@ -20,6 +20,7 @@ ParallelTopology as ReplayParallelTopology, ) from art.preprocessing.pack import PackedTensors +from art.utils.lifecycle import terminate_popen_process_group from ..routing_replay.bundle import build_bundle_from_forward_trace_dir from ..routing_replay.trace import install_moe_routing_trace_hooks @@ -103,6 +104,7 @@ def run_worker_subprocess( worker_log_path = topology_dir / "worker.log" live_log_raw = os.environ.get("ART_ORACLE_LIVE_TRAINING_LOG") live_log_path = None if not live_log_raw else Path(live_log_raw) + run: subprocess.Popen[str] | None = None worker_log_path.parent.mkdir(parents=True, exist_ok=True) with worker_log_path.open("w", encoding="utf-8") as worker_log: live_log = None @@ -129,6 +131,7 @@ def run_worker_subprocess( stderr=subprocess.STDOUT, text=True, bufsize=1, + start_new_session=True, ) assert run.stdout is not None for line in run.stdout: @@ -140,6 +143,8 @@ def run_worker_subprocess( live_log.flush() run.returncode = run.wait() finally: + if run is not None and run.poll() is None: + terminate_popen_process_group(run) if live_log is not None: live_log.close() combined_output = "".join(combined_lines).strip() @@ -389,7 +394,12 @@ def _configure_provider( topology: Topology, case_config: OracleCaseConfig, ) -> None: - """Applies deterministic topology/model overrides to provider config.""" + """Applies deterministic topology/model overrides to provider config. + + Handler-specific oracle hooks are validation-only. They keep large model + families such as DSV4 fit-sized while preserving the layer families and + kernel-facing invariants under test. + """ del topology provider.num_layers = case_config.num_layers if case_config.precision == "fp32": @@ -405,6 +415,15 @@ def _configure_provider( provider.attention_dropout = 0.0 if hasattr(provider, "hidden_dropout"): provider.hidden_dropout = 0.0 + from art.megatron.model_support.registry import get_model_support_handler + + handler = get_model_support_handler( + case_config.base_model, + allow_unvalidated_arch=case_config.allow_unvalidated_arch, + ) + configure_oracle_provider = getattr(handler, "configure_oracle_provider", None) + if configure_oracle_provider is not None: + configure_oracle_provider(provider, case_config=case_config) @contextmanager diff --git a/tests/integration/megatron/model_support/packed_position_ids.py b/tests/integration/megatron/model_support/packed_position_ids.py index 657fdc1ec..397e7ce12 100644 --- a/tests/integration/megatron/model_support/packed_position_ids.py +++ b/tests/integration/megatron/model_support/packed_position_ids.py @@ -52,6 +52,7 @@ "qwen3_moe", "qwen3_5_dense", "qwen3_5_moe", + "dsv4", "gpt_oss_moe", } ) @@ -613,6 +614,7 @@ def _logits_equivalence_check( build_gdn_execution_spec=bool( getattr(handler, "build_gdn_execution_spec", False) ), + model_support_handler=handler, attention_head_dim=getattr(provider, "kv_channels", None), attention_value_head_dim=getattr(provider, "kv_channels", None), ) @@ -663,6 +665,7 @@ def _logits_equivalence_check( build_gdn_execution_spec=bool( getattr(handler, "build_gdn_execution_spec", False) ), + model_support_handler=handler, attention_head_dim=getattr(provider, "kv_channels", None), attention_value_head_dim=getattr(provider, "kv_channels", None), ) diff --git a/tests/integration/megatron/model_support/test_dsv4_real_path_correctness.py b/tests/integration/megatron/model_support/test_dsv4_real_path_correctness.py new file mode 100644 index 000000000..d0eee8460 --- /dev/null +++ b/tests/integration/megatron/model_support/test_dsv4_real_path_correctness.py @@ -0,0 +1,170 @@ +from __future__ import annotations + +from collections.abc import Iterator +from contextlib import contextmanager, redirect_stderr, redirect_stdout +import os +from pathlib import Path + +import pytest + +torch = pytest.importorskip("torch") + +from .oracle_harness import ( # noqa: E402 + LIVE_TRAINING_LOG_PATH, + MetricThresholdRule, + OracleCaseConfig, + OracleObjective, + PhasePassFn, + VariantReport, + VariantRunner, + VariantSpec, + available_gpu_count, + selected_oracle_objectives, + selected_suite_topologies, +) + +BASE_MODEL = "deepseek-ai/DeepSeek-V4-Flash" +NUM_LAYERS = 4 +REPO_ROOT = Path(__file__).resolve().parents[4] +CORRECTNESS_LOG_PATH = REPO_ROOT / ".local" / "correctness.log" +ORACLE_LIVE_TRAINING_LOG_ENV = "ART_ORACLE_LIVE_TRAINING_LOG" +_EXPECTED_COMPRESS_RATIOS = [0, 0, 4, 128] +_EXPECTED_LAYER_TYPES = [ + "sliding_attention", + "sliding_attention", + "compressed_sparse_attention", + "heavily_compressed_attention", +] +_EXPECTED_MLP_LAYER_TYPES = ["hash_moe", "hash_moe", "hash_moe", "moe"] + + +def test_dsv4_real_path_bf16_correctness( + capsys: pytest.CaptureFixture[str], +) -> None: + """Runs the real DSV4 ART Megatron path through the standard oracle harness.""" + _assert_representative_dsv4_layers() + gpu_count = available_gpu_count() + reports: list[VariantReport] = [] + with capsys.disabled(): + print( + f"\nDSV4 real-path bf16 correctness log: {CORRECTNESS_LOG_PATH}", + flush=True, + ) + print( + f"DSV4 real-path bf16 live training log: {LIVE_TRAINING_LOG_PATH}", + flush=True, + ) + CORRECTNESS_LOG_PATH.parent.mkdir(parents=True, exist_ok=True) + LIVE_TRAINING_LOG_PATH.parent.mkdir(parents=True, exist_ok=True) + LIVE_TRAINING_LOG_PATH.write_text( + ( + "DSV4 real-path bf16 live training log.\n" + "Topology worker output is appended below. If no topology sections " + "appear, complete cached artifacts were reused.\n" + ), + encoding="utf-8", + ) + with _temporary_env(**{ORACLE_LIVE_TRAINING_LOG_ENV: str(LIVE_TRAINING_LOG_PATH)}): + with CORRECTNESS_LOG_PATH.open("w", encoding="utf-8") as log_file: + with redirect_stdout(log_file), redirect_stderr(log_file): + print("DSV4 real-path bf16 correctness") + print(f"base_model={BASE_MODEL}") + print(f"num_layers={NUM_LAYERS}") + print(f"precision=bf16") + print(f"visible_gpus={gpu_count}") + print(f"live_training_log={LIVE_TRAINING_LOG_PATH}") + for objective in selected_oracle_objectives(): + runner = VariantRunner( + objective=objective, + case_config=OracleCaseConfig( + base_model=BASE_MODEL, + precision="bf16", + num_layers=NUM_LAYERS, + ), + use_fp32_lora_reference=False, + ) + variants = _dsv4_bf16_variants( + objective=objective, + max_world_size=gpu_count, + ) + if variants: + reports.extend(runner.run_suite(variants)) + if not reports: + CORRECTNESS_LOG_PATH.write_text( + f"DSV4 real-path bf16 correctness skipped. Need at least 2 GPUs; found {gpu_count}.\n", + encoding="utf-8", + ) + pytest.skip(f"Need at least 2 GPUs for DSV4 correctness; found {gpu_count}.") + assert all(report.signal == "pass" for report in reports) + + +@contextmanager +def _temporary_env(**updates: str) -> Iterator[None]: + previous = {key: os.environ.get(key) for key in updates} + os.environ.update(updates) + try: + yield + finally: + for key, value in previous.items(): + if value is None: + os.environ.pop(key, None) + else: + os.environ[key] = value + + +def _dsv4_bf16_variants( + *, + objective: OracleObjective, + max_world_size: int, +) -> list[VariantSpec]: + phase_pass = _dsv4_bf16_phase_pass_fns() + variants: list[VariantSpec] = [] + for topology in selected_suite_topologies(is_moe=True, cp_supported=False)[1:]: + if topology.world_size() > max_world_size: + continue + variants.append( + VariantSpec( + name=f"{objective}_dsv4_bf16_topology_{topology.slug()}", + objective=objective, + topology=topology, + pass_fn_by_phase=phase_pass, + ) + ) + return variants + + +def _dsv4_bf16_phase_pass_fns() -> dict[str, PhasePassFn]: + non_zero_scales = {"typical_abs_scale": 0.0, "candidate_abs_scale": 0.0} + fwd = MetricThresholdRule( + limits={"mean_abs_pct": 3.0}, + minimums=non_zero_scales, + ) + loss = MetricThresholdRule(limits={"mean_abs_pct": 3.0}) + grad = MetricThresholdRule( + limits={"mean_abs_pct": 5.0}, + minimums=non_zero_scales, + ) + router_topk = MetricThresholdRule( + limits={"topk_mismatch_fraction": 0.0, "top1_mismatch_fraction": 0.0} + ) + return { + "forward": fwd, + "outputs": fwd, + "losses": loss, + "grads": grad, + "deltas": grad, + "router_scores": fwd, + "router_topk_ids": router_topk, + } + + +def _assert_representative_dsv4_layers() -> None: + from transformers import AutoConfig + + from art.megatron.dsv4.hf_config import ensure_dsv4_hf_model_registered + + ensure_dsv4_hf_model_registered() + config = AutoConfig.from_pretrained(BASE_MODEL, trust_remote_code=True) + assert list(config.compress_ratios[:NUM_LAYERS]) == _EXPECTED_COMPRESS_RATIOS + assert list(config.layer_types[:NUM_LAYERS]) == _EXPECTED_LAYER_TYPES + assert list(config.mlp_layer_types[:NUM_LAYERS]) == _EXPECTED_MLP_LAYER_TYPES diff --git a/tests/integration/megatron/model_support/test_oracle_harness_invariants.py b/tests/integration/megatron/model_support/test_oracle_harness_invariants.py index 52dac4893..991e925ec 100644 --- a/tests/integration/megatron/model_support/test_oracle_harness_invariants.py +++ b/tests/integration/megatron/model_support/test_oracle_harness_invariants.py @@ -239,6 +239,65 @@ def test_forward_trace_prefers_local_tensor_uids_over_module_fallback() -> None: assert torch.equal(row_uids, torch.tensor([4, 7])) +def test_forward_trace_capture_prefers_dense_module_uids_for_sequence_modules() -> None: + module = type("ModuleWithDenseTraceUids", (), {})() + output = torch.zeros((2, 1), dtype=torch.float32) + setattr(module, "_art_trace_row_token_uids", torch.tensor([10, 11])) + setattr(output, "_art_trace_row_token_uids", torch.tensor([4, 7])) + + row_uids, _uid_span = ForwardTraceCapture._row_token_uids_for_capture( + module_name="chunk0.module.decoder.layers.0.self_attention", + inputs=(), + output=output, + module=module, + row_count=2, + ) + + assert row_uids is not None + assert torch.equal(row_uids, torch.tensor([10, 11])) + + +def test_forward_trace_capture_keeps_expert_uid_span_for_expert_modules() -> None: + module = type("ModuleWithDenseTraceUids", (), {})() + output = torch.zeros((2, 1), dtype=torch.float32) + setattr(module, "_art_trace_row_token_uids", torch.tensor([10, 11])) + setattr(output, "_art_trace_row_token_uids", torch.tensor([4, 7])) + setattr(output, "_art_trace_uid_span", 16) + + row_uids, uid_span = ForwardTraceCapture._row_token_uids_for_capture( + module_name="chunk0.module.decoder.layers.0.mlp.experts.linear_fc1", + inputs=(), + output=output, + module=module, + row_count=2, + ) + + assert uid_span == 16 + assert row_uids is not None + assert torch.equal(row_uids, torch.tensor([4, 7])) + + +def test_forward_trace_restores_dense_non_cp_sequence_uids_before_sorting() -> None: + trace: dict[str, list[dict[str, Any]]] = { + "chunk0.module.decoder.layers.0.self_attention": [ + { + "primary_output": torch.tensor([[1.0], [2.0], [3.0]]), + "row_token_uids": torch.tensor([10, 30, 20]), + "rank_meta": [ + {"cp_world_size": 1, "tp_rank": 0}, + {"cp_world_size": 1, "tp_rank": 1}, + ], + } + ] + } + + ForwardTraceCapture.canonicalize_trace(trace) + + call = trace["chunk0.module.decoder.layers.0.self_attention"][0] + assert torch.equal(call["row_token_uids"], torch.tensor([0, 1, 2])) + assert torch.equal(call["primary_output"], torch.tensor([[1.0], [2.0], [3.0]])) + + def test_forward_trace_extracts_empty_router_topk_with_config_hint() -> None: topk = _extract_router_topk( ( @@ -353,6 +412,44 @@ def test_forward_trace_canonicalizes_row_outputs_by_token_uid() -> None: ) +def test_forward_trace_canonicalizes_router_outputs_without_output_attrs() -> None: + module = type("RouterWithDenseTraceUids", (), {})() + probs = torch.tensor([[3.0], [1.0], [2.0]]) + routing_map = torch.tensor([[True], [False], [True]]) + setattr(module, "_art_trace_row_token_uids", torch.tensor([3, 1, 2])) + + row_uids, _uid_span = ForwardTraceCapture._row_token_uids_for_capture( + module_name="chunk0.module.decoder.layers.0.mlp.router", + inputs=(), + output=(probs, routing_map), + module=module, + row_count=3, + ) + assert row_uids is not None + + trace: dict[str, list[dict[str, Any]]] = { + "chunk0.module.decoder.layers.0.mlp.router": [ + { + "primary_output": probs, + "router_topk_scores": probs, + "router_topk_ids": torch.tensor([[3], [1], [2]]), + "output": {"probs": probs, "routing_map": routing_map}, + "row_token_uids": row_uids, + } + ] + } + + ForwardTraceCapture.canonicalize_trace(trace) + + call = trace["chunk0.module.decoder.layers.0.mlp.router"][0] + assert torch.equal(call["row_token_uids"], torch.tensor([1, 2, 3])) + assert torch.equal(call["output"]["probs"], torch.tensor([[1.0], [2.0], [3.0]])) + assert torch.equal( + call["output"]["routing_map"], + torch.tensor([[False], [True], [True]]), + ) + + def test_forward_trace_expands_attention_output_uids_for_out_norm_heads() -> None: trace: dict[str, list[dict[str, Any]]] = { "chunk0.module.decoder.layers.0.self_attention": [ diff --git a/tests/integration/megatron/model_support/test_provider_support.py b/tests/integration/megatron/model_support/test_provider_support.py index ae1098829..f8cd668d9 100644 --- a/tests/integration/megatron/model_support/test_provider_support.py +++ b/tests/integration/megatron/model_support/test_provider_support.py @@ -17,6 +17,7 @@ UnsupportedModelArchitectureError, get_model_support_handler, get_model_support_spec, + model_requires_merged_rollout, model_uses_expert_parallel, ) import art.megatron.provider as provider_module @@ -130,6 +131,37 @@ def test_model_support_specs_own_moe_metadata() -> None: assert model_uses_expert_parallel("OpenPipe/Qwen3-14B-Instruct") is False assert model_uses_expert_parallel("Qwen/Qwen3-30B-A3B-Instruct-2507") is True assert model_uses_expert_parallel("Qwen/Qwen3.5-35B-A3B") is True + assert model_uses_expert_parallel("deepseek-ai/DeepSeek-V4-Flash") is True + + +def test_dsv4_prefers_validated_native_lora_rollout() -> None: + spec = get_model_support_spec("deepseek-ai/DeepSeek-V4-Flash") + + assert spec.native_vllm_lora_status == "validated" + assert model_requires_merged_rollout("deepseek-ai/DeepSeek-V4-Flash") is False + + +def test_dsv4_provider_disables_shared_expert_overlap( + monkeypatch: pytest.MonkeyPatch, +) -> None: + provider = _FakeProvider() + provider.num_moe_experts = 256 + provider.moe_shared_expert_overlap = True + fake_bridge = _FakeBridge( + model_bridge=object(), + provider=provider, + ) + monkeypatch.setattr( + provider_module.AutoBridge, + "from_hf_pretrained", + lambda *args, **kwargs: fake_bridge, + ) + monkeypatch.setattr(provider_module.torch.cuda, "device_count", lambda: 2) + + resolved = provider_module.get_provider("deepseek-ai/DeepSeek-V4-Flash") + + assert resolved.moe_shared_expert_overlap is False + assert resolved.context_parallel_size == 1 def test_megatron_lora_rank_defaults_by_architecture() -> None: @@ -418,6 +450,52 @@ def test_get_provider_bundle_honors_context_parallel_env_topology( ) +def test_cp_unsupported_handler_defaults_to_tensor_parallel( + monkeypatch: pytest.MonkeyPatch, +) -> None: + provider = _FakeProvider() + provider.num_moe_experts = 8 + fake_bridge = _FakeBridge( + model_bridge=object(), + provider=provider, + ) + monkeypatch.setattr( + provider_module.AutoBridge, + "from_hf_pretrained", + lambda *args, **kwargs: fake_bridge, + ) + monkeypatch.setattr(provider_module.torch.cuda, "device_count", lambda: 4) + + bundle = provider_module.prepare_provider_bundle("deepseek-ai/DeepSeek-V4-Flash") + resolved = bundle.provider + + assert resolved.tensor_model_parallel_size == 4 + assert resolved.context_parallel_size == 1 + assert resolved.expert_model_parallel_size == 4 + assert resolved.sequence_parallel is True + + +def test_cp_unsupported_handler_rejects_context_parallel_env( + monkeypatch: pytest.MonkeyPatch, +) -> None: + provider = _FakeProvider() + provider.num_moe_experts = 8 + fake_bridge = _FakeBridge( + model_bridge=object(), + provider=provider, + ) + monkeypatch.setattr( + provider_module.AutoBridge, + "from_hf_pretrained", + lambda *args, **kwargs: fake_bridge, + ) + monkeypatch.setattr(provider_module.torch.cuda, "device_count", lambda: 4) + monkeypatch.setenv("ART_MEGATRON_CONTEXT_PARALLEL_SIZE", "2") + + with pytest.raises(RuntimeError, match="does not implement context parallelism"): + provider_module.prepare_provider_bundle("deepseek-ai/DeepSeek-V4-Flash") + + def test_qwen35_handler_keeps_standard_attention_on_flex_under_cp( monkeypatch: pytest.MonkeyPatch, ) -> None: diff --git a/tests/integration/megatron/model_support/test_workflow.py b/tests/integration/megatron/model_support/test_workflow.py index 4f918ffcf..118fef764 100644 --- a/tests/integration/megatron/model_support/test_workflow.py +++ b/tests/integration/megatron/model_support/test_workflow.py @@ -1,5 +1,6 @@ import os from types import SimpleNamespace +from typing import cast import pytest @@ -29,6 +30,11 @@ run_yes_no_trainability_stage, validated_architecture_representative_models, ) +from .workflow_resources import ( + _h200_equivalent_slots_for_total_gib, + handler_workflow_resources_for_base_model, + resolve_stage_resources_for_visible_gpus, +) @pytest.fixture(autouse=True) @@ -70,10 +76,88 @@ def test_validated_architecture_representative_models_are_fixed() -> None: "Qwen/Qwen3.5-27B", "google/gemma-4-26B-A4B-it", "google/gemma-4-31B-it", + "deepseek-ai/DeepSeek-V4-Flash", "openai/gpt-oss-20b", ] +def test_dsv4_runtime_stages_use_full_model_resources() -> None: + resources = handler_workflow_resources_for_base_model( + "deepseek-ai/DeepSeek-V4-Flash" + ) + assert resources is not None + for stage in ( + resources.train_inf_mismatch, + resources.yes_no_trainability, + resources.length_trainability, + ): + assert stage is not None + assert stage.required_world_size == 8 + assert stage.requires_external_vllm is True + assert stage.megatron is not None + assert stage.megatron.gpu_ids == [0, 1, 2, 3, 4, 5, 6, 7] + assert stage.megatron.topology.tp == 2 + assert stage.megatron.topology.ep == 8 + assert stage.megatron.topology.cp == 1 + assert stage.vllm is not None + assert stage.vllm.gpu_ids == [4, 5, 6, 7] + engine_args = stage.vllm.engine_args() + assert "hf_overrides" not in engine_args + assert engine_args.get("load_format") != "dummy" + assert engine_args["moe_backend"] == "triton_unfused" + assert engine_args["kv_cache_dtype"] == "fp8" + assert stage.megatron_env == {"ART_MEGATRON_STREAMING_WEIGHT_OFFLOAD": "1"} + + for stage in (resources.merged_vllm_serving, resources.native_vllm_lora): + assert stage is not None + assert stage.vllm is not None + engine_args = stage.vllm.engine_args() + assert engine_args["load_format"] == "dummy" + hf_overrides = cast(dict[str, object], engine_args["hf_overrides"]) + assert hf_overrides["num_hidden_layers"] == 4 + assert resources.merged_vllm_serving is not None + assert resources.merged_vllm_serving.vllm is not None + assert resources.merged_vllm_serving.vllm.engine_args()["kv_cache_dtype"] == "fp8" + assert resources.native_vllm_lora is not None + assert resources.native_vllm_lora.vllm is not None + assert resources.native_vllm_lora.vllm.engine_args().get("max_loras", 2) == 2 + + +def test_dsv4_resources_remap_to_four_high_vram_gpus(monkeypatch) -> None: + resources = handler_workflow_resources_for_base_model( + "deepseek-ai/DeepSeek-V4-Flash" + ) + assert resources is not None + assert resources.train_inf_mismatch is not None + monkeypatch.setattr( + "tests.integration.megatron.model_support.workflow_resources." + "_visible_h200_equivalent_gpus", + lambda *, visible_gpu_count: 8, + ) + + stage = resolve_stage_resources_for_visible_gpus( + "train_inf_mismatch", + resources.train_inf_mismatch, + visible_gpu_count=4, + ) + + assert stage.megatron is not None + assert stage.vllm is not None + assert stage.megatron.gpu_ids == [0, 1] + assert stage.megatron.topology.tp == 2 + assert stage.megatron.topology.ep == 2 + assert stage.vllm.gpu_ids == [2, 3] + assert stage.vllm.tensor_parallel_size == 2 + assert stage.vllm.engine_args()["moe_backend"] == "triton_unfused" + assert stage.vllm.engine_args()["kv_cache_dtype"] == "fp8" + + +def test_h200_equivalent_slots_tolerate_reported_gb300_vram() -> None: + assert _h200_equivalent_slots_for_total_gib(80.0) == 0 + assert _h200_equivalent_slots_for_total_gib(139.0) == 1 + assert _h200_equivalent_slots_for_total_gib(276.6) == 2 + + def test_inspect_architecture_for_workflow_uses_minimal_topology(monkeypatch) -> None: seen_env: dict[str, str | None] = {} @@ -628,7 +712,7 @@ def test_run_correctness_sensitivity_stage_runs_dense_models(monkeypatch) -> Non case_configs: list[SimpleNamespace] = [] oracle_module = SimpleNamespace( OracleCaseConfig=lambda **kwargs: SimpleNamespace(**kwargs), - selected_suite_topologies=lambda *, is_moe: [ + selected_suite_topologies=lambda *, is_moe, cp_supported=True: [ SimpleNamespace(world_size=lambda: 1, slug=lambda: "tp1"), SimpleNamespace(world_size=lambda: 2, slug=lambda: "tp2"), SimpleNamespace(world_size=lambda: 2, slug=lambda: "dp2"), @@ -643,7 +727,7 @@ def test_run_correctness_sensitivity_stage_runs_dense_models(monkeypatch) -> Non world_size=lambda: 2 ), available_gpu_count=lambda: 4, - run_suite=lambda case_config, max_world_size: ( + run_suite=lambda case_config, max_world_size, cp_supported=True, **kwargs: ( case_configs.append(case_config) or [ SimpleNamespace( @@ -991,7 +1075,7 @@ def test_run_correctness_sensitivity_stage_summarizes_reports(monkeypatch) -> No ) oracle_module = SimpleNamespace( OracleCaseConfig=lambda **kwargs: SimpleNamespace(**kwargs), - selected_suite_topologies=lambda *, is_moe: [ + selected_suite_topologies=lambda *, is_moe, cp_supported=True: [ SimpleNamespace(world_size=lambda: 1, slug=lambda: "tp1"), SimpleNamespace(world_size=lambda: 2, slug=lambda: "tp2"), ], @@ -1004,7 +1088,7 @@ def test_run_correctness_sensitivity_stage_summarizes_reports(monkeypatch) -> No world_size=lambda: 2 ), available_gpu_count=lambda: 2, - run_suite=lambda case_config, max_world_size: [ + run_suite=lambda case_config, max_world_size, cp_supported=True, **kwargs: [ SimpleNamespace( variant="sft_topology_tp2", topology="tp2", @@ -1051,6 +1135,70 @@ def test_run_correctness_sensitivity_stage_summarizes_reports(monkeypatch) -> No assert stage.artifact_dir == "/tmp/oracle" +def test_run_correctness_sensitivity_stage_uses_dsv4_real_path_config( + monkeypatch, +) -> None: + architecture = ArchitectureReport( + base_model="deepseek-ai/DeepSeek-V4-Flash", + model_key="dsv4", + handler_key="dsv4", + layer_families=[LayerFamilyInstance(key="dsv4_attention", layer_index=0)], + recommended_min_layers=4, + ) + captured: dict[str, object] = {} + oracle_module = SimpleNamespace( + OracleCaseConfig=lambda **kwargs: SimpleNamespace(**kwargs), + MetricThresholdRule=lambda **kwargs: SimpleNamespace(**kwargs), + selected_suite_topologies=lambda *, is_moe, cp_supported=True: [ + SimpleNamespace(world_size=lambda: 1, slug=lambda: "tp1"), + SimpleNamespace(world_size=lambda: 2, slug=lambda: "tp2"), + ], + oracle_topology=lambda *, is_moe: SimpleNamespace(world_size=lambda: 1), + selected_oracle_objectives=lambda: ["rl"], + supported_sensitivity_mutations_for_objective=lambda objective, *, is_moe: [], + sensitivity_topology_for_mutation=lambda mutation, *, is_moe: SimpleNamespace( + world_size=lambda: 2 + ), + available_gpu_count=lambda: 2, + run_suite=lambda case_config, **kwargs: ( + captured.update(case_config=case_config, suite_kwargs=kwargs) + or [ + SimpleNamespace( + variant="rl_topology_tp2", + topology="tp2", + signal="pass", + fail_count=0, + ) + ] + ), + run_sensitivity_suite=lambda case_config, mutations, max_world_size: [], + ensure_case_artifacts=lambda case_config: SimpleNamespace( + case_dir="/tmp/oracle" + ), + keep_topology_artifacts=lambda: False, + ) + monkeypatch.setattr( + "tests.integration.megatron.model_support.workflow._import_integration_module", + lambda name: oracle_module, + ) + monkeypatch.setenv(SKIP_SENSITIVITY_ENV, "1") + + stage = run_correctness_sensitivity_stage( + base_model="deepseek-ai/DeepSeek-V4-Flash", + architecture=architecture, + ) + + case_config = captured["case_config"] + suite_kwargs = cast(dict[str, object], captured["suite_kwargs"]) + phase_pass_fns = cast(dict[str, object], suite_kwargs["phase_pass_fns"]) + assert getattr(case_config, "precision") == "bf16" + assert suite_kwargs["use_fp32_lora_reference"] is False + assert getattr(phase_pass_fns["forward"], "limits") == {"mean_abs_pct": 3.0} + assert getattr(phase_pass_fns["grads"], "limits") == {"mean_abs_pct": 5.0} + assert stage.metrics["precision"] == "bf16" + assert stage.metrics["use_fp32_lora_reference"] is False + + def test_run_correctness_sensitivity_stage_can_skip_sensitivity_only( monkeypatch, ) -> None: @@ -1063,7 +1211,7 @@ def test_run_correctness_sensitivity_stage_can_skip_sensitivity_only( ) oracle_module = SimpleNamespace( OracleCaseConfig=lambda **kwargs: SimpleNamespace(**kwargs), - selected_suite_topologies=lambda *, is_moe: [ + selected_suite_topologies=lambda *, is_moe, cp_supported=True: [ SimpleNamespace(world_size=lambda: 1, slug=lambda: "tp1"), SimpleNamespace(world_size=lambda: 2, slug=lambda: "tp2"), ], @@ -1076,7 +1224,7 @@ def test_run_correctness_sensitivity_stage_can_skip_sensitivity_only( world_size=lambda: 4 ), available_gpu_count=lambda: 2, - run_suite=lambda case_config, max_world_size: [ + run_suite=lambda case_config, max_world_size, cp_supported=True, **kwargs: [ SimpleNamespace( variant="sft_topology_tp2", topology="tp2", @@ -1154,8 +1302,7 @@ def _import_integration_module(name: str): assert stage.name == "merged_vllm_serving" assert stage.passed is True - assert stage.metrics == { - "base_model": "Qwen/Qwen3.5-35B-A3B", - "served_model_name": "validation@0", - } + assert stage.metrics["base_model"] == "Qwen/Qwen3.5-35B-A3B" + assert stage.metrics["served_model_name"] == "validation@0" + assert "readable_summary" in stage.metrics assert stage.artifact_dir == "/tmp/merged-serving" diff --git a/tests/integration/megatron/model_support/workflow.py b/tests/integration/megatron/model_support/workflow.py index 0c9d6977a..0550d92e3 100644 --- a/tests/integration/megatron/model_support/workflow.py +++ b/tests/integration/megatron/model_support/workflow.py @@ -65,6 +65,7 @@ "qwen3_5_dense": "Qwen/Qwen3.5-27B", "gemma4_moe": "google/gemma-4-26B-A4B-it", "gemma4_dense": "google/gemma-4-31B-it", + "dsv4": "deepseek-ai/DeepSeek-V4-Flash", "gpt_oss_moe": "openai/gpt-oss-20b", } SUBPROCESS_VALIDATION_STAGES = frozenset( @@ -473,16 +474,23 @@ def run_correctness_sensitivity_stage( allow_unvalidated_arch=allow_unvalidated_arch, ) handler = get_model_support_handler_for_spec(spec) + cp_supported = bool(handler.cp_supported) + correctness_precision = handler.correctness_precision() + correctness_use_fp32_lora_reference = handler.correctness_use_fp32_lora_reference() + correctness_phase_pass_fns = handler.correctness_phase_pass_fns(oracle_harness) case_config = oracle_harness.OracleCaseConfig( base_model=base_model, is_moe=handler.is_moe, - precision="fp32", + precision=correctness_precision, num_layers=max(1, architecture.recommended_min_layers), num_steps=1, allow_unvalidated_arch=allow_unvalidated_arch, ) suite_topologies = list( - oracle_harness.selected_suite_topologies(is_moe=handler.is_moe) + oracle_harness.selected_suite_topologies( + is_moe=handler.is_moe, + cp_supported=cp_supported, + ) ) objectives = list(oracle_harness.selected_oracle_objectives()) skip_sensitivity = _truthy_env(SKIP_SENSITIVITY_ENV) @@ -526,6 +534,14 @@ def run_correctness_sensitivity_stage( is_moe=handler.is_moe, ).world_size() > max_world_size + or ( + not cp_supported + and oracle_harness.sensitivity_topology_for_mutation( + mutation, + is_moe=handler.is_moe, + ).cp + > 1 + ) ] mutations = [ mutation @@ -539,6 +555,11 @@ def run_correctness_sensitivity_stage( suite_reports = oracle_harness.run_suite( case_config=case_config, max_world_size=max_world_size, + cp_supported=cp_supported, + phase_pass_fns=correctness_phase_pass_fns, + use_fp32_lora_reference=correctness_use_fp32_lora_reference, + prune_reference_artifacts=skip_sensitivity or not mutations, + prune_case_artifacts=skip_sensitivity or not mutations, ) sensitivity_reports = [] if skip_sensitivity: @@ -572,7 +593,10 @@ def run_correctness_sensitivity_stage( passed=True, metrics={ "requested_num_layers": case_config.num_layers, + "precision": correctness_precision, + "use_fp32_lora_reference": correctness_use_fp32_lora_reference, "is_moe": handler.is_moe, + "cp_supported": cp_supported, "allow_unvalidated_arch": allow_unvalidated_arch, "objectives": objectives, "sensitivity_mutations": mutations, @@ -644,14 +668,42 @@ def run_merged_vllm_serving_stage( allow_unvalidated_arch=allow_unvalidated_arch, ) report = merged_vllm_serving.run_merged_vllm_serving(case_config) + metrics = report.model_dump(mode="json") + warning_lines = _read_vllm_reload_warnings(report.output_dir) + metrics["vllm_reload_warning_count"] = len(warning_lines) + metrics["vllm_reload_warnings"] = warning_lines + metrics["readable_summary"] = _merged_vllm_serving_summary(metrics) return ValidationStageResult( name="merged_vllm_serving", passed=bool(report.model_ids), - metrics=report.model_dump(mode="json"), + metrics=metrics, artifact_dir=report.output_dir, ) +def _read_vllm_reload_warnings(output_dir: str) -> list[str]: + log_path = Path(output_dir) / "logs" / "vllm-runtime.log" + if not log_path.exists(): + return [] + return [ + line.strip() + for line in log_path.read_text(encoding="utf-8", errors="replace").splitlines() + if "Failed to load weights" in line + ] + + +def _merged_vllm_serving_summary(metrics: dict[str, Any]) -> list[str]: + lines = [ + f"served_model_name={metrics.get('served_model_name', '')}", + f"model_ids={metrics.get('model_ids', [])}", + f"completion_text={metrics.get('completion_text', '')!r}", + f"vllm_reload_warning_count={metrics.get('vllm_reload_warning_count', 0)}", + ] + for warning in metrics.get("vllm_reload_warnings", []): + lines.append(f"vllm_reload_warning={warning}") + return lines + + def run_chat_template_rollout_stage( *, base_model: str, @@ -981,6 +1033,20 @@ def _parse_args(argv: list[str] | None = None) -> argparse.Namespace: return args +def _print_stage_result(stage: ValidationStageResult, *, indent: str = "") -> None: + status = "PASS" if stage.passed else "FAIL" + print(f"{indent}{stage.name}: {status}", flush=True) + child_indent = f"{indent} " + if stage.artifact_dir: + print(f"{child_indent}artifact_dir={stage.artifact_dir}", flush=True) + summary = stage.metrics.get("readable_summary") + if isinstance(summary, list): + for line in summary: + print(f"{child_indent}{line}", flush=True) + if not stage.passed: + print(f"{child_indent}metrics={stage.metrics}", flush=True) + + def main(argv: list[str] | None = None) -> int: args = _parse_args(argv) if args.all_architectures: @@ -996,12 +1062,7 @@ def main(argv: list[str] | None = None) -> int: for report in all_report.reports: print(f"base_model={report.base_model}", flush=True) for stage in report.stages: - status = "PASS" if stage.passed else "FAIL" - print(f" {stage.name}: {status}", flush=True) - if stage.artifact_dir: - print(f" artifact_dir={stage.artifact_dir}", flush=True) - if not stage.passed: - print(f" metrics={stage.metrics}", flush=True) + _print_stage_result(stage, indent=" ") print(f"report_json={args.output_json}", flush=True) return 0 if all_report.passed else 1 report = build_validation_report( @@ -1015,12 +1076,7 @@ def main(argv: list[str] | None = None) -> int: allow_unvalidated_arch=args.allow_unsupported_arch, ) for stage in report.stages: - status = "PASS" if stage.passed else "FAIL" - print(f"{stage.name}: {status}", flush=True) - if stage.artifact_dir: - print(f" artifact_dir={stage.artifact_dir}", flush=True) - if not stage.passed: - print(f" metrics={stage.metrics}", flush=True) + _print_stage_result(stage) print(f"report_json={args.output_json}", flush=True) return 0 if all(stage.passed for stage in report.stages) else 1 diff --git a/tests/integration/megatron/model_support/workflow_resources.py b/tests/integration/megatron/model_support/workflow_resources.py new file mode 100644 index 000000000..7bc3091c6 --- /dev/null +++ b/tests/integration/megatron/model_support/workflow_resources.py @@ -0,0 +1,441 @@ +from __future__ import annotations + +from typing import Literal + +from pydantic import BaseModel, ConfigDict, Field + +_H200_REFERENCE_VRAM_GIB = 140.0 +_H200_SLOT_TOLERANCE = 0.05 + + +class MegatronWorkflowTopology(BaseModel): + model_config = ConfigDict(frozen=True) + + tp: int = 1 + ep: int = 1 + etp: int = 1 + dp: int = 1 + cp: int = 1 + pp: int = 1 + sp: bool = False + + def to_megatron_config(self) -> dict[str, int | None]: + return { + "tp": self.tp, + "ep": self.ep, + "etp": self.etp, + "cp": self.cp, + "pp": self.pp, + } + + def to_oracle_topology_kwargs(self) -> dict[str, int | bool]: + return self.model_dump() + + def to_train_inf_topology_kwargs(self) -> dict[str, int]: + return { + "tp": self.tp, + "ep": self.ep, + "etp": self.etp, + "dp": self.dp, + "cp": self.cp, + "pp": self.pp, + } + + +class MegatronWorkflowResources(BaseModel): + model_config = ConfigDict(frozen=True) + + gpu_ids: list[int] + topology: MegatronWorkflowTopology + + +class VllmWorkflowResources(BaseModel): + model_config = ConfigDict(frozen=True) + + gpu_ids: list[int] + tensor_parallel_size: int + enable_expert_parallel: bool = False + hf_overrides: dict[str, object] = Field(default_factory=dict) + extra_engine_args: dict[str, object] = Field(default_factory=dict) + + def engine_args(self) -> dict[str, object]: + engine_args: dict[str, object] = { + "tensor_parallel_size": self.tensor_parallel_size, + } + if self.enable_expert_parallel: + engine_args["enable_expert_parallel"] = True + if self.hf_overrides: + engine_args["hf_overrides"] = dict(self.hf_overrides) + engine_args.update(self.extra_engine_args) + return engine_args + + +class WorkflowStageResources(BaseModel): + model_config = ConfigDict(frozen=True) + + required_world_size: int + required_h200_equivalent_gpus: int | None = None + allow_gpu_overlap: bool = False + requires_external_vllm: bool = False + megatron: MegatronWorkflowResources | None = None + vllm: VllmWorkflowResources | None = None + high_vram_megatron: MegatronWorkflowResources | None = None + high_vram_vllm: VllmWorkflowResources | None = None + megatron_env: dict[str, str] = Field(default_factory=dict) + + +class HandlerWorkflowResources(BaseModel): + model_config = ConfigDict(frozen=True) + + train_inf_mismatch: WorkflowStageResources | None = None + merged_vllm_serving: WorkflowStageResources | None = None + native_vllm_lora: WorkflowStageResources | None = None + yes_no_trainability: WorkflowStageResources | None = None + length_trainability: WorkflowStageResources | None = None + yes_no_trainability_variant: ( + Literal[ + "megatron_shared", + "megatron_dedicated", + "unsloth_dedicated", + ] + | None + ) = None + + +_DSV4_TP2_EP8 = MegatronWorkflowTopology( + tp=2, + ep=8, + etp=1, + dp=4, + cp=1, + pp=1, + sp=True, +) +_DSV4_TP2_EP4 = MegatronWorkflowTopology( + tp=2, + ep=4, + etp=1, + dp=2, + cp=1, + pp=1, + sp=True, +) +_DSV4_TP2_EP2 = MegatronWorkflowTopology( + tp=2, + ep=2, + etp=1, + dp=1, + cp=1, + pp=1, + sp=True, +) +_DSV4_REPRESENTATIVE_NUM_LAYERS = 4 +_DSV4_REPRESENTATIVE_COMPRESS_RATIOS = [0, 0, 4, 128] +_DSV4_REPRESENTATIVE_LAYER_TYPES = [ + "sliding_attention", + "sliding_attention", + "compressed_sparse_attention", + "heavily_compressed_attention", +] +_DSV4_REPRESENTATIVE_MLP_LAYER_TYPES = ["hash_moe", "hash_moe", "hash_moe", "moe"] +_DSV4_MEGATRON_ENV = { + "ART_DSV4_VALIDATION_NUM_LAYERS": str(_DSV4_REPRESENTATIVE_NUM_LAYERS) +} +_DSV4_STREAMING_OFFLOAD_ENV = {"ART_MEGATRON_STREAMING_WEIGHT_OFFLOAD": "1"} +_DSV4_HF_OVERRIDES = { + "num_hidden_layers": _DSV4_REPRESENTATIVE_NUM_LAYERS, + "compress_ratios": _DSV4_REPRESENTATIVE_COMPRESS_RATIOS, + "layer_types": _DSV4_REPRESENTATIVE_LAYER_TYPES, + "mlp_layer_types": _DSV4_REPRESENTATIVE_MLP_LAYER_TYPES, +} +_DSV4_COMMON_VLLM_ENGINE_ARGS = { + "compilation_config": { + "cudagraph_mode": "NONE", + "pass_config": {"fuse_allreduce_rms": False}, + }, + "disable_custom_all_reduce": True, + "enforce_eager": True, + "gpu_memory_utilization": 0.82, + "kv_cache_dtype": "fp8", + "max_num_batched_tokens": 1032, +} +_DSV4_MERGED_VLLM_ENGINE_ARGS = { + **_DSV4_COMMON_VLLM_ENGINE_ARGS, + "moe_backend": "triton_unfused", +} +_DSV4_LORA_VLLM_ENGINE_ARGS = { + **_DSV4_COMMON_VLLM_ENGINE_ARGS, + "moe_backend": "triton_unfused", +} +_DSV4_REDUCED_VLLM_ENGINE_ARGS = { + **_DSV4_MERGED_VLLM_ENGINE_ARGS, + # The quick DSV4 vLLM serving gates use a reduced 4-layer validation model and then + # sync Megatron weights into vLLM through merged-weight transfer. Loading + # the full public checkpoint before that sync is incompatible with the + # reduced hf_overrides because vLLM still streams layer-4+ tensors. + "load_format": "dummy", +} +_DSV4_NATIVE_LORA_VLLM_ENGINE_ARGS = { + **_DSV4_LORA_VLLM_ENGINE_ARGS, + "load_format": "dummy", +} +_DSV4_MEGATRON = MegatronWorkflowResources( + gpu_ids=[0, 1, 2, 3, 4, 5, 6, 7], + topology=_DSV4_TP2_EP8, +) +_DSV4_HIGH_VRAM_MEGATRON = MegatronWorkflowResources( + gpu_ids=[0, 1], + topology=_DSV4_TP2_EP2, +) +_DSV4_FULL_VLLM_EP4 = VllmWorkflowResources( + gpu_ids=[4, 5, 6, 7], + tensor_parallel_size=4, + enable_expert_parallel=True, + extra_engine_args=_DSV4_LORA_VLLM_ENGINE_ARGS, +) +_DSV4_FULL_VLLM_EP2 = VllmWorkflowResources( + gpu_ids=[2, 3], + tensor_parallel_size=2, + enable_expert_parallel=True, + extra_engine_args=_DSV4_LORA_VLLM_ENGINE_ARGS, +) +_DSV4_REDUCED_VLLM_EP4 = VllmWorkflowResources( + gpu_ids=[4, 5, 6, 7], + tensor_parallel_size=4, + enable_expert_parallel=True, + hf_overrides=_DSV4_HF_OVERRIDES, + extra_engine_args=_DSV4_REDUCED_VLLM_ENGINE_ARGS, +) +_DSV4_REDUCED_VLLM_EP2 = VllmWorkflowResources( + gpu_ids=[2, 3], + tensor_parallel_size=2, + enable_expert_parallel=True, + hf_overrides=_DSV4_HF_OVERRIDES, + extra_engine_args=_DSV4_REDUCED_VLLM_ENGINE_ARGS, +) +_DSV4_REDUCED_NATIVE_VLLM_EP4 = VllmWorkflowResources( + gpu_ids=[0, 1, 2, 3], + tensor_parallel_size=4, + enable_expert_parallel=True, + hf_overrides=_DSV4_HF_OVERRIDES, + extra_engine_args=_DSV4_NATIVE_LORA_VLLM_ENGINE_ARGS, +) + +# Explicitly for large models which do not fit in the default topology. +HANDLER_WORKFLOW_RESOURCES: dict[str, HandlerWorkflowResources] = { + "dsv4": HandlerWorkflowResources( + train_inf_mismatch=WorkflowStageResources( + required_world_size=8, + required_h200_equivalent_gpus=8, + requires_external_vllm=True, + megatron=_DSV4_MEGATRON, + vllm=_DSV4_FULL_VLLM_EP4, + high_vram_megatron=_DSV4_HIGH_VRAM_MEGATRON, + high_vram_vllm=_DSV4_FULL_VLLM_EP2, + megatron_env=_DSV4_STREAMING_OFFLOAD_ENV, + ), + merged_vllm_serving=WorkflowStageResources( + required_world_size=8, + required_h200_equivalent_gpus=8, + megatron=_DSV4_MEGATRON, + vllm=_DSV4_REDUCED_VLLM_EP4, + high_vram_megatron=_DSV4_HIGH_VRAM_MEGATRON, + high_vram_vllm=_DSV4_REDUCED_VLLM_EP2, + megatron_env=_DSV4_MEGATRON_ENV, + ), + native_vllm_lora=WorkflowStageResources( + required_world_size=4, + vllm=_DSV4_REDUCED_NATIVE_VLLM_EP4, + ), + yes_no_trainability=WorkflowStageResources( + required_world_size=8, + required_h200_equivalent_gpus=8, + requires_external_vllm=True, + megatron=_DSV4_MEGATRON, + vllm=_DSV4_FULL_VLLM_EP4, + high_vram_megatron=_DSV4_HIGH_VRAM_MEGATRON, + high_vram_vllm=_DSV4_FULL_VLLM_EP2, + megatron_env=_DSV4_STREAMING_OFFLOAD_ENV, + ), + length_trainability=WorkflowStageResources( + required_world_size=8, + required_h200_equivalent_gpus=8, + requires_external_vllm=True, + megatron=_DSV4_MEGATRON, + vllm=_DSV4_FULL_VLLM_EP4, + high_vram_megatron=_DSV4_HIGH_VRAM_MEGATRON, + high_vram_vllm=_DSV4_FULL_VLLM_EP2, + megatron_env=_DSV4_STREAMING_OFFLOAD_ENV, + ), + yes_no_trainability_variant="megatron_dedicated", + ), +} + + +def handler_workflow_resources_for_base_model( + base_model: str, + *, + allow_unvalidated_arch: bool = False, +) -> HandlerWorkflowResources | None: + from art.megatron.model_support.registry import get_model_support_spec + + spec = get_model_support_spec( + base_model, + allow_unvalidated_arch=allow_unvalidated_arch, + ) + return HANDLER_WORKFLOW_RESOURCES.get(spec.handler_key) + + +def _h200_equivalent_slots_for_total_gib(total_gib: float) -> int: + return max(0, int(total_gib / _H200_REFERENCE_VRAM_GIB + _H200_SLOT_TOLERANCE)) + + +def _visible_h200_equivalent_gpus(*, visible_gpu_count: int) -> int: + try: + import torch + except ImportError: + return 0 + if not torch.cuda.is_available(): + return 0 + equivalent = 0 + for device_index in range(visible_gpu_count): + props = torch.cuda.get_device_properties(device_index) + total_gib = float(props.total_memory) / (1024**3) + equivalent += _h200_equivalent_slots_for_total_gib(total_gib) + return equivalent + + +def _remap_gpu_ids_to_visible( + gpu_ids: list[int], *, visible_gpu_count: int +) -> list[int]: + if all(0 <= gpu_id < visible_gpu_count for gpu_id in gpu_ids): + return list(gpu_ids) + if len(gpu_ids) > visible_gpu_count: + raise RuntimeError( + "Cannot remap workflow GPU ids to visible high-VRAM devices: " + f"gpu_ids={gpu_ids}, visible_gpu_count={visible_gpu_count}" + ) + return list(range(len(gpu_ids))) + + +def _validate_gpu_ids_visible(gpu_ids: list[int], *, visible_gpu_count: int) -> None: + invalid = [ + gpu_id for gpu_id in gpu_ids if gpu_id < 0 or gpu_id >= visible_gpu_count + ] + if invalid: + raise RuntimeError( + f"Workflow GPU ids {gpu_ids} are not visible on host with " + f"{visible_gpu_count} GPUs" + ) + + +def resolve_stage_resources_for_visible_gpus( + stage_name: str, + stage_resources: WorkflowStageResources, + *, + visible_gpu_count: int, +) -> WorkflowStageResources: + if visible_gpu_count >= stage_resources.required_world_size: + return stage_resources + required_equivalent = stage_resources.required_h200_equivalent_gpus + available_equivalent = _visible_h200_equivalent_gpus( + visible_gpu_count=visible_gpu_count + ) + if required_equivalent is None or available_equivalent < required_equivalent: + raise RuntimeError( + f"Need {stage_resources.required_world_size} visible GPUs for " + f"{stage_name}, found {visible_gpu_count}. High-VRAM remapping " + f"requires {required_equivalent or stage_resources.required_world_size} " + f"H200-equivalent GPUs, found {available_equivalent}." + ) + if ( + stage_resources.high_vram_megatron is not None + or stage_resources.high_vram_vllm is not None + ): + megatron = stage_resources.high_vram_megatron or stage_resources.megatron + vllm = stage_resources.high_vram_vllm or stage_resources.vllm + if megatron is not None: + _validate_gpu_ids_visible( + megatron.gpu_ids, + visible_gpu_count=visible_gpu_count, + ) + if vllm is not None: + _validate_gpu_ids_visible( + vllm.gpu_ids, + visible_gpu_count=visible_gpu_count, + ) + return stage_resources.model_copy(update={"megatron": megatron, "vllm": vllm}) + if not stage_resources.allow_gpu_overlap: + raise RuntimeError( + f"Need {stage_resources.required_world_size} visible GPUs for " + f"{stage_name}, found {visible_gpu_count}. No high-VRAM resource " + "override is configured for this stage." + ) + megatron = stage_resources.megatron + if megatron is not None: + megatron = megatron.model_copy( + update={ + "gpu_ids": _remap_gpu_ids_to_visible( + megatron.gpu_ids, + visible_gpu_count=visible_gpu_count, + ) + } + ) + vllm = stage_resources.vllm + if vllm is not None: + vllm = vllm.model_copy( + update={ + "gpu_ids": _remap_gpu_ids_to_visible( + vllm.gpu_ids, + visible_gpu_count=visible_gpu_count, + ) + } + ) + return stage_resources.model_copy(update={"megatron": megatron, "vllm": vllm}) + + +def resolve_stage_resources_for_current_host( + stage_name: str, + stage_resources: WorkflowStageResources, +) -> WorkflowStageResources: + try: + import torch + except ImportError: + visible_gpu_count = 0 + else: + visible_gpu_count = int(torch.cuda.device_count()) + return resolve_stage_resources_for_visible_gpus( + stage_name, + stage_resources, + visible_gpu_count=visible_gpu_count, + ) + + +def validate_visible_gpu_count( + stage_name: str, + stage_resources: WorkflowStageResources, + *, + visible_gpu_count: int, +) -> None: + if visible_gpu_count < stage_resources.required_world_size: + raise RuntimeError( + f"Need {stage_resources.required_world_size} visible GPUs for " + f"{stage_name}, found {visible_gpu_count}" + ) + + +def validate_dedicated_test_resources( + *, + stage_name: str, + trainer_gpu_ids: list[int], + inference_gpu_ids: list[int], + allow_overlap: bool = False, +) -> None: + if not trainer_gpu_ids: + raise RuntimeError(f"{stage_name} trainer GPU ids must be non-empty") + if not inference_gpu_ids: + raise RuntimeError(f"{stage_name} inference GPU ids must be non-empty") + if not allow_overlap and set(trainer_gpu_ids) & set(inference_gpu_ids): + raise RuntimeError( + f"{stage_name} trainer and inference GPU ids must not overlap" + ) diff --git a/tests/integration/megatron/routing_replay/trace.py b/tests/integration/megatron/routing_replay/trace.py index 4538df56a..815b99e61 100644 --- a/tests/integration/megatron/routing_replay/trace.py +++ b/tests/integration/megatron/routing_replay/trace.py @@ -1,6 +1,5 @@ from __future__ import annotations -import types from typing import Any, Callable from megatron.core.tensor_parallel import ( @@ -21,6 +20,11 @@ def _active_controller() -> Any | None: return _CONTROLLER_GETTER() +def _trace_hook(fn: Callable[..., Any]) -> Callable[..., Any]: + """Keep correctness-only routing trace monkeypatches out of Dynamo graphs.""" + return torch.compiler.disable(fn) + + @torch._dynamo.disable def _dispatcher_local_token_uids( controller: Any, @@ -137,93 +141,6 @@ def _attach_trace_row_uids( setattr(target, TRACE_UID_SPAN_ATTR, uid_span) -def _attach_trace_row_uids_recursive( - target: Any, - *, - row_token_uids: torch.Tensor, - uid_span: int | None, -) -> None: - if isinstance(target, torch.Tensor): - _attach_trace_row_uids( - target, - row_token_uids=row_token_uids, - uid_span=uid_span, - ) - return - if isinstance(target, dict): - for value in target.values(): - _attach_trace_row_uids_recursive( - value, - row_token_uids=row_token_uids, - uid_span=uid_span, - ) - return - if isinstance(target, (list, tuple)): - for value in target: - _attach_trace_row_uids_recursive( - value, - row_token_uids=row_token_uids, - uid_span=uid_span, - ) - - -@torch._dynamo.disable -def _attach_router_output_trace_row_uids( - *, - controller: Any, - router_key: str, - output: Any, -) -> None: - target_key = getattr(controller, "_router_prepared_target_keys", {}).get(router_key) - if target_key is None: - return - token_uid_key, _call_index = target_key - token_uids = getattr(controller, "_prepared_uid_sets", {}).get(token_uid_key) - binding = getattr(controller, "_router_bindings", {}).get(router_key) - if token_uids is None or binding is None: - return - router_token_uids = controller._token_uids_for_router_binding( - token_uids, - sequence_parallel=bool(binding["sequence_parallel"]), - ) - _attach_trace_row_uids_recursive( - output, - row_token_uids=router_token_uids, - uid_span=getattr(controller, "_global_uid_count", None), - ) - - -def _install_router_output_trace_hooks(controller: Any | None) -> None: - if controller is None: - return - for router_key, binding in getattr(controller, "_router_bindings", {}).items(): - module = binding.get("module") - if module is None or getattr(module, "_art_oracle_router_trace_patched", False): - continue - original_routing = module.routing - - def patched_routing( - router_module: Any, - *args: Any, - _original_routing: Any = original_routing, - _router_key: str = router_key, - **kwargs: Any, - ) -> Any: - del router_module - output = _original_routing(*args, **kwargs) - controller = _active_controller() - if controller is not None: - _attach_router_output_trace_row_uids( - controller=controller, - router_key=_router_key, - output=output, - ) - return output - - module.routing = types.MethodType(patched_routing, module) - setattr(module, "_art_oracle_router_trace_patched", True) - - @torch._dynamo.disable def _propagate_grouped_mlp_trace_row_uids(source: Any, linear_fc2: Any) -> None: row_token_uids, uid_span = _trace_row_uids_from_source(source) @@ -415,7 +332,6 @@ def install_moe_routing_trace_hooks( ) -> None: global _CONTROLLER_GETTER _CONTROLLER_GETTER = controller_getter - _install_router_output_trace_hooks(_active_controller()) try: from megatron.core.transformer.moe.experts import TEGroupedMLP from megatron.core.transformer.moe.token_dispatcher import ( @@ -609,23 +525,27 @@ def patched_fc2_forward( ) return original_fc2_forward(self, x, tokens_per_expert) - setattr(MoEAlltoAllTokenDispatcher, "preprocess", patched_preprocess) + setattr(MoEAlltoAllTokenDispatcher, "preprocess", _trace_hook(patched_preprocess)) setattr( MoEAlltoAllTokenDispatcher, "dispatch_preprocess", - patched_dispatch_preprocess, + _trace_hook(patched_dispatch_preprocess), + ) + setattr( + MoEAlltoAllTokenDispatcher, + "token_dispatch", + _trace_hook(patched_token_dispatch), ) - setattr(MoEAlltoAllTokenDispatcher, "token_dispatch", patched_token_dispatch) setattr( MoEAlltoAllTokenDispatcher, "dispatch_postprocess", - patched_dispatch_postprocess, + _trace_hook(patched_dispatch_postprocess), ) setattr( MoEAlltoAllTokenDispatcher, "combine_preprocess", - patched_combine_preprocess, + _trace_hook(patched_combine_preprocess), ) - setattr(TEGroupedMLP, "forward", patched_te_grouped_mlp_forward) - setattr(MLPExpertsLinearFC2LoRA, "forward", patched_fc2_forward) + setattr(TEGroupedMLP, "forward", _trace_hook(patched_te_grouped_mlp_forward)) + setattr(MLPExpertsLinearFC2LoRA, "forward", _trace_hook(patched_fc2_forward)) setattr(MoEAlltoAllTokenDispatcher, "_art_oracle_trace_patched", True) diff --git a/tests/integration/megatron/runtime_isolation/test_runtime_launcher.py b/tests/integration/megatron/runtime_isolation/test_runtime_launcher.py index b9bca13fe..65103ce3f 100644 --- a/tests/integration/megatron/runtime_isolation/test_runtime_launcher.py +++ b/tests/integration/megatron/runtime_isolation/test_runtime_launcher.py @@ -68,6 +68,51 @@ def test_build_runtime_server_cmd_honors_runtime_bin_override(monkeypatch) -> No assert command[:2] == ["/opt/art/bin/runtime", "--wrapped"] +def test_build_runtime_server_cmd_allows_lora_without_initial_adapter( + monkeypatch, + tmp_path: Path, +) -> None: + monkeypatch.delenv("ART_VLLM_RUNTIME_BIN", raising=False) + runtime_root = tmp_path / "custom-runtime" + runtime_bin = runtime_root / ".venv" / "bin" / "art-vllm-runtime-server" + runtime_bin.parent.mkdir(parents=True, exist_ok=True) + runtime_bin.write_text("#!/bin/sh\n", encoding="ascii") + monkeypatch.setenv("ART_VLLM_RUNTIME_PROJECT_ROOT", str(runtime_root)) + + command = runtime.build_vllm_runtime_server_cmd( + runtime.VllmRuntimeLaunchConfig( + base_model="Qwen/Qwen3-14B", + port=8000, + host="0.0.0.0", + cuda_visible_devices="0,1", + served_model_name="test@0", + rollout_weights_mode="lora", + ) + ) + + assert command[0] == str(runtime_bin) + assert not any(arg.startswith("--lora-path=") for arg in command) + assert "--rollout-weights-mode=lora" in command + + +def test_external_checkpoint_path_mapping() -> None: + config = { + "vllm_runtime": { + "mode": "external", + "server_url": "http://inference:8000", + "local_checkpoint_root": "/mnt/ws_pvc/ws", + "server_checkpoint_root": "/remote/ws", + } + } + + mapped = runtime.map_checkpoint_path_for_vllm( + config, + "/mnt/ws_pvc/ws/projects/art/.art/models/model/0001", + ) + + assert mapped == "/remote/ws/projects/art/.art/models/model/0001" + + def test_get_vllm_runtime_nccl_so_path_queries_runtime_python( monkeypatch, tmp_path: Path, @@ -90,7 +135,7 @@ def fake_run(command, *, capture_output: bool, text: bool): return SimpleNamespace(returncode=0, stdout=f"{nccl_so_path}\n", stderr="") monkeypatch.setenv("ART_VLLM_RUNTIME_PROJECT_ROOT", str(runtime_root)) - monkeypatch.setattr(runtime.subprocess, "run", fake_run) + monkeypatch.setattr(runtime, "subprocess", SimpleNamespace(run=fake_run)) assert runtime.get_vllm_runtime_nccl_so_path() == nccl_so_path.resolve() command = seen["command"] @@ -100,6 +145,61 @@ def fake_run(command, *, capture_output: bool, text: bool): assert seen["text"] is True +def test_vllm_runtime_subprocess_env_isolates_flashinfer_for_source_runtime( + monkeypatch, + tmp_path: Path, +) -> None: + runtime_root = tmp_path / "vllm_runtime" + runtime_root.mkdir() + monkeypatch.setenv("ART_VLLM_RUNTIME_PROJECT_ROOT", str(runtime_root)) + monkeypatch.setenv("FLASHINFER_WORKSPACE_BASE", "/shared/flashinfer") + monkeypatch.setenv( + "PYTHONPATH", + os.pathsep.join( + [ + "/keep", + "/venv/lib/python3.12/site-packages/tilelang/vendored", + ] + ), + ) + + env = runtime._vllm_runtime_subprocess_env() + + assert env["PYTHONPATH"] == "/keep" + assert env["FLASHINFER_WORKSPACE_BASE"] == str( + tmp_path / "scratch" / "vllm_runtime_flashinfer" + ) + + +def test_vllm_runtime_subprocess_env_isolates_flashinfer_for_managed_runtime( + monkeypatch, + tmp_path: Path, +) -> None: + cache_root = tmp_path / "runtime_cache" + monkeypatch.setenv("ART_VLLM_RUNTIME_PROJECT_ROOT", str(tmp_path / "missing")) + monkeypatch.setenv("ART_VLLM_RUNTIME_CACHE_DIR", str(cache_root)) + monkeypatch.setenv("FLASHINFER_WORKSPACE_BASE", "/shared/flashinfer") + + env = runtime._vllm_runtime_subprocess_env() + + assert env["FLASHINFER_WORKSPACE_BASE"] == str(cache_root / "flashinfer_workspace") + + +def test_vllm_runtime_subprocess_env_honors_flashinfer_workspace_override( + monkeypatch, + tmp_path: Path, +) -> None: + override = tmp_path / "explicit_flashinfer" + monkeypatch.setenv( + "ART_VLLM_RUNTIME_FLASHINFER_WORKSPACE_BASE", + str(override), + ) + + env = runtime._vllm_runtime_subprocess_env() + + assert env["FLASHINFER_WORKSPACE_BASE"] == str(override) + + def test_cleanup_old_managed_runtimes_only_deletes_marked_venvs( monkeypatch, tmp_path: Path, diff --git a/tests/integration/megatron/train_inf_mismatch/output_parity.py b/tests/integration/megatron/train_inf_mismatch/output_parity.py index ed46cee9f..839404894 100644 --- a/tests/integration/megatron/train_inf_mismatch/output_parity.py +++ b/tests/integration/megatron/train_inf_mismatch/output_parity.py @@ -11,6 +11,11 @@ from pydantic import BaseModel, ConfigDict, Field, model_validator +from ..model_support.workflow_resources import ( + handler_workflow_resources_for_base_model, + resolve_stage_resources_for_current_host, +) + # These gates are intentionally bf16-scale, not fp32 oracle-scale. A 2026-05-18 # Qwen/Qwen3.5-35B-A3B diagnostic on the exact same real generated tokens found: # vLLM generation vs Megatron: 2.916% mean_abs_pct, 0.0123 MAE, 0.883 top1, @@ -22,8 +27,14 @@ # prefix route-conflict behavior on the measured path. With the workflow's # 16-token completions, Qwen3.5 MoE reruns on 2026-05-25 measured 4.169% and # 4.606% mean_abs_pct while staying under the KL gate, so its gate is 5%. +# DeepSeek-V4-Flash uses vLLM quantized DSV4 kernels on the serving side while +# Megatron materializes train-time bf16/fp32 tensors. A 2026-06-18 diagnostic +# measured non-QAT Megatron vs vLLM generation at 19.016% mean_abs_pct and +# 0.02603 candidate->target top20 KL; vLLM generation vs exact vLLM prompt +# rescore was already 15.176% mean_abs_pct and 0.04424 KL. BF16_FWD_MEAN_ABS_PCT_LIMIT = 4.0 BF16_FWD_MEAN_ABS_PCT_LIMIT_BY_MODEL_KEY = { + "dsv4": 20.0, # Gemma 4 MoE long-prompt SWA native-LoRA runs showed high variation, with # repeated samples reaching 7.6% mean_abs_pct and 0.0076 KL. "gemma4_dense": 8.0, @@ -33,6 +44,7 @@ } TOP20_KL_CANDIDATE_TO_TARGET_LIMIT = 0.002 TOP20_KL_CANDIDATE_TO_TARGET_LIMIT_BY_MODEL_KEY = { + "dsv4": 0.07, "gemma4_dense": 0.003, "gemma4_moe": 0.008, # GPT OSS MXFP4/native-LoRA repeats on 2026-07-06 stayed under the 4% @@ -107,6 +119,10 @@ class TrainInfOutputParityConfig(BaseModel): lora_target_modules: list[str] | None = None engine_args: dict[str, Any] = Field(default_factory=dict) server_args: dict[str, Any] = Field(default_factory=dict) + megatron_env: dict[str, str] = Field(default_factory=dict) + replay_vllm_routing: bool = False + external_vllm_server_url: str | None = None + external_vllm_api_key: str | None = None @model_validator(mode="after") def _set_default_rollout_modes(self) -> "TrainInfOutputParityConfig": @@ -304,7 +320,28 @@ def model_support_is_moe( return get_model_support_handler_for_spec(spec).is_moe +def model_supports_context_parallel( + base_model: str, + *, + allow_unvalidated_arch: bool = False, +) -> bool: + from art.megatron.model_support.registry import ( + get_model_support_handler_for_spec, + get_model_support_spec, + ) + + spec = get_model_support_spec( + base_model, + allow_unvalidated_arch=allow_unvalidated_arch, + ) + return bool(getattr(get_model_support_handler_for_spec(spec), "cp_supported", True)) + + def config_from_env() -> TrainInfOutputParityConfig: + train_inf_external_url_env = "ART_TRAIN_INF_MISMATCH_EXTERNAL_VLLM_URL" + model_support_external_url_env = "ART_MODEL_SUPPORT_EXTERNAL_VLLM_URL" + train_inf_external_key_env = "ART_TRAIN_INF_MISMATCH_EXTERNAL_VLLM_API_KEY" + model_support_external_key_env = "ART_MODEL_SUPPORT_EXTERNAL_VLLM_API_KEY" config = TrainInfOutputParityConfig( base_model=os.environ.get( "ART_TRAIN_INF_MISMATCH_BASE_MODEL", @@ -323,6 +360,44 @@ def config_from_env() -> TrainInfOutputParityConfig: ) == "1", ) + workflow_resources = handler_workflow_resources_for_base_model( + config.base_model, + allow_unvalidated_arch=config.allow_unvalidated_arch, + ) + stage_resources = ( + workflow_resources.train_inf_mismatch + if workflow_resources is not None + else None + ) + if stage_resources is not None: + stage_resources = resolve_stage_resources_for_current_host( + "train_inf_mismatch", + stage_resources, + ) + if stage_resources is not None: + if ( + stage_resources.megatron is not None + and "ART_TRAIN_INF_MISMATCH_TRAINER_GPU_IDS" not in os.environ + ): + config.trainer_gpu_ids = list(stage_resources.megatron.gpu_ids) + if ( + stage_resources.vllm is not None + and "ART_TRAIN_INF_MISMATCH_INFERENCE_GPU_IDS" not in os.environ + ): + config.inference_gpu_ids = list(stage_resources.vllm.gpu_ids) + if stage_resources.megatron is not None: + config.topology = config.topology.model_copy( + update=stage_resources.megatron.topology.to_train_inf_topology_kwargs() + ) + if stage_resources.vllm is not None: + config.engine_args = { + **stage_resources.vllm.engine_args(), + **config.engine_args, + } + config.megatron_env = { + **stage_resources.megatron_env, + **config.megatron_env, + } if raw_modes := os.environ.get("ART_TRAIN_INF_MISMATCH_ROLLOUT_MODES"): config.rollout_modes = _parse_rollout_modes(raw_modes) if raw_seq_len := os.environ.get("ART_TRAIN_INF_MISMATCH_SEQUENCE_LENGTH"): @@ -335,18 +410,50 @@ def config_from_env() -> TrainInfOutputParityConfig: ("ART_TRAIN_INF_MISMATCH_TP", "tp"), ("ART_TRAIN_INF_MISMATCH_EP", "ep"), ("ART_TRAIN_INF_MISMATCH_ETP", "etp"), + ("ART_TRAIN_INF_MISMATCH_DP", "dp"), ("ART_TRAIN_INF_MISMATCH_CP", "cp"), ("ART_TRAIN_INF_MISMATCH_PP", "pp"), ): if raw_value := os.environ.get(env_name): config.topology = config.topology.model_copy(update={attr: int(raw_value)}) - if not model_support_is_moe( + is_moe = model_support_is_moe( config.base_model, allow_unvalidated_arch=config.allow_unvalidated_arch, - ): + ) + cp_supported = model_supports_context_parallel( + config.base_model, + allow_unvalidated_arch=config.allow_unvalidated_arch, + ) + if not is_moe: config.topology = config.topology.model_copy(update={"ep": 1, "etp": 1}) + if not cp_supported and "ART_TRAIN_INF_MISMATCH_CP" not in os.environ: + updates = {"cp": 1} + if stage_resources is None and "ART_TRAIN_INF_MISMATCH_DP" not in os.environ: + updates["dp"] = config.topology.dp * config.topology.cp + config.topology = config.topology.model_copy(update=updates) if raw_targets := os.environ.get("ART_TRAIN_INF_MISMATCH_LORA_TARGET_MODULES"): config.lora_target_modules = _parse_str_list(raw_targets) + raw_url = os.environ.get(train_inf_external_url_env) + if raw_url is None and stage_resources is not None: + raw_url = os.environ.get(model_support_external_url_env) + if raw_url: + config.external_vllm_server_url = raw_url + config.external_vllm_api_key = os.environ.get( + train_inf_external_key_env, + os.environ.get( + model_support_external_key_env, + "art-external-vllm", + ), + ) + if ( + stage_resources is not None + and stage_resources.requires_external_vllm + and not config.external_vllm_server_url + ): + raise RuntimeError( + "train_inf_mismatch for this model requires an external vLLM server. " + f"Set {train_inf_external_url_env} or {model_support_external_url_env}." + ) return config @@ -390,10 +497,21 @@ def build_logical_token_map(packed_tensors: dict[str, Any]) -> LogicalTokenMap: tokens = packed_tensors["tokens"] group_ids = packed_tensors["group_ids"] parent_ids = packed_tensors["parent_ids"] + assistant_mask = packed_tensors.get("assistant_mask") + logprobs = packed_tensors.get("logprobs") prompts: list[LogicalPrompt] = [] logical_tokens: list[LogicalToken] = [] prompt_id_by_tokens: dict[tuple[int, ...], int] = {} + def scored_token(sample_id: int, packed_i: int) -> bool: + if assistant_mask is not None and not bool(assistant_mask[sample_id, packed_i]): + return False + if logprobs is not None: + value = float(logprobs[sample_id, packed_i]) + if math.isnan(value): + return False + return True + for sample_id in range(int(tokens.shape[0])): families = _prompt_family_segments(group_ids[sample_id], parent_ids[sample_id]) for family_id, (prompt_segment, completion_segments) in enumerate(families): @@ -402,15 +520,20 @@ def build_logical_token_map(packed_tensors: dict[str, Any]) -> LogicalTokenMap: for completion_id, (completion_start, completion_end) in enumerate( completion_segments ): - if completion_end - completion_start < 2: + last_scored_i = None + for packed_i in range(completion_start + 1, completion_end): + if scored_token(sample_id, packed_i): + last_scored_i = packed_i + if last_scored_i is None: continue + effective_completion_end = last_scored_i + 1 flat = [ int(value) for value in tokens[sample_id, prompt_start:prompt_end].tolist() ] + [ int(value) for value in tokens[ - sample_id, completion_start:completion_end + sample_id, completion_start:effective_completion_end ].tolist() ] flat_key = tuple(flat) @@ -429,7 +552,9 @@ def build_logical_token_map(packed_tensors: dict[str, Any]) -> LogicalTokenMap: token_ids=flat, ) ) - for packed_i in range(completion_start + 1, completion_end): + for packed_i in range(completion_start + 1, effective_completion_end): + if not scored_token(sample_id, packed_i): + continue logical_tokens.append( LogicalToken( token_id=int(tokens[sample_id, packed_i].item()), @@ -886,6 +1011,7 @@ def _run_logits( build_gdn_execution_spec=bool( getattr(runtime.model_support_handler, "build_gdn_execution_spec", False) ), + model_support_handler=runtime.model_support_handler, attention_head_dim=getattr(runtime.provider, "kv_channels", None), attention_value_head_dim=getattr(runtime.provider, "kv_channels", None), ) diff --git a/tests/integration/megatron/train_inf_mismatch/real_path.py b/tests/integration/megatron/train_inf_mismatch/real_path.py index b882160e8..18f5ebb05 100644 --- a/tests/integration/megatron/train_inf_mismatch/real_path.py +++ b/tests/integration/megatron/train_inf_mismatch/real_path.py @@ -2,7 +2,9 @@ import argparse import asyncio -from contextlib import asynccontextmanager +from contextlib import asynccontextmanager, contextmanager +import hashlib +import json import os from pathlib import Path import random @@ -10,13 +12,13 @@ import socket import subprocess import sys -from typing import Any, AsyncIterator, cast +from typing import Any, AsyncIterator, Iterator, cast import uuid from openai.types.chat.chat_completion import Choice from pydantic import BaseModel, ConfigDict, Field -from art.dev.model import RolloutWeightsMode +from art.dev.model import InternalModelConfig, RolloutWeightsMode from art.preprocessing.moe_routing import ( MoeRoutingPackStats, PackedMoeRoutingReplay, @@ -71,6 +73,7 @@ class RealPathConfig(BaseModel): diagnose_base: bool = False trace_layers: bool = False trace_enforce_eager: bool = False + adapter_cache_dir: str | None = None class RealPathMegatronWorkerRequest(BaseModel): @@ -106,6 +109,23 @@ class RealPathBaseDiagnosticBundle(BaseModel): megatron_forward_trace_dir: str | None = None +@contextmanager +def _temporary_env(updates: dict[str, str] | None) -> Iterator[None]: + if not updates: + yield + return + previous = {key: os.environ.get(key) for key in updates} + os.environ.update(updates) + try: + yield + finally: + for key, value in previous.items(): + if value is None: + os.environ.pop(key, None) + else: + os.environ[key] = value + + class RealPathTrainInfReport(BaseModel): base_model: str artifact_dir: str @@ -117,6 +137,8 @@ class RealPathTrainInfReport(BaseModel): base_moe_routing_shared_prefix_conflict_rows: int | None = None base_moe_routing_shared_prefix_conflict_slots: int | None = None adapter_path: str + adapter_cache_key: str + adapter_cache_hit: bool megatron_base_scores: str | None = None vllm_base_scores: str | None = None megatron_lora_scores: str @@ -135,6 +157,12 @@ class RealPathTrainInfReport(BaseModel): passed: bool +class AdapterCacheResult(BaseModel): + path: str + cache_key: str + cache_hit: bool + + def _real_path_rollout_mode(config: TrainInfOutputParityConfig) -> RolloutMode: return config.rollout_modes[0] @@ -196,6 +224,8 @@ def config_from_env() -> RealPathConfig: config.diagnose_base = True if raw := os.environ.get("ART_REAL_PATH_TRACE_ENFORCE_EAGER"): config.trace_enforce_eager = raw == "1" + if raw := os.environ.get("ART_TRAIN_INF_MISMATCH_ADAPTER_CACHE_DIR"): + config.adapter_cache_dir = raw return config @@ -306,10 +336,19 @@ async def _collect_real_trajectory_groups( from transformers import AutoTokenizer import art + from art.megatron.model_support.tokenizer import ( + configure_tokenizer_for_model_support, + ) if config.rollouts_per_prompt < 2: raise ValueError("real-path mismatch requires at least two rollouts per prompt") - tokenizer = AutoTokenizer.from_pretrained(config.output_parity.base_model) + tokenizer = configure_tokenizer_for_model_support( + AutoTokenizer.from_pretrained(config.output_parity.base_model), + base_model=config.output_parity.base_model, + internal_config={ + "allow_unvalidated_arch": config.output_parity.allow_unvalidated_arch + }, + ) chat_template_kwargs: dict[str, Any] = {} if isinstance(tokenizer.chat_template, str): if "enable_thinking" in tokenizer.chat_template: @@ -819,6 +858,134 @@ def _make_nonzero_adapter( return _run_real_path_megatron_worker(request, adapter_only=True).adapter_path or "" +def _adapter_cache_key(config: TrainInfOutputParityConfig) -> str: + from art.megatron.model_support import vllm_lora_config_for_model + + from .output_parity import _adapter_config + + def jsonable(value: Any) -> Any: + if isinstance(value, dict): + return {key: jsonable(item) for key, item in value.items()} + if isinstance(value, set): + return [jsonable(item) for item in sorted(value, key=str)] + if isinstance(value, (list, tuple)): + return [jsonable(item) for item in value] + return value + + adapter_config = _adapter_config(config) + published_adapter_config = vllm_lora_config_for_model( + config.base_model, + adapter_config, + allow_unvalidated_arch=config.allow_unvalidated_arch, + ) + payload = { + "schema": 2, + "base_model": config.base_model, + "seed": config.seed, + "allow_unvalidated_arch": config.allow_unvalidated_arch, + "lora_target_modules": _lora_target_modules(config), + "adapter_config": adapter_config, + "published_adapter_config": published_adapter_config, + } + encoded = json.dumps( + jsonable(payload), + sort_keys=True, + separators=(",", ":"), + ).encode() + return hashlib.sha256(encoded).hexdigest() + + +def _default_adapter_cache_dir() -> Path: + return REPO_ROOT / "scratch" / "train_inf_mismatch_adapters" + + +def _adapter_cache_dir(config: RealPathConfig) -> Path: + if config.adapter_cache_dir: + return Path(config.adapter_cache_dir) + return _default_adapter_cache_dir() + + +def _adapter_cache_manifest_path(adapter_path: Path) -> Path: + return adapter_path / "art_train_inf_mismatch_adapter_cache.json" + + +def _cached_adapter_is_valid(adapter_path: Path, *, cache_key: str) -> bool: + manifest_path = _adapter_cache_manifest_path(adapter_path) + model_path = adapter_path / "adapter_model.safetensors" + config_path = adapter_path / "adapter_config.json" + if not (manifest_path.exists() and model_path.exists() and config_path.exists()): + return False + try: + manifest = _read_json(manifest_path) + except Exception: + return False + return ( + manifest.get("cache_key") == cache_key + and manifest.get("non_identity") is True + and int(manifest.get("adapter_model_bytes", 0)) == model_path.stat().st_size + and model_path.stat().st_size > 0 + ) + + +def _copy_adapter_dir(src: Path, dst: Path, *, cache_key: str) -> None: + dst.mkdir(parents=True, exist_ok=True) + for filename in ("adapter_model.safetensors", "adapter_config.json"): + shutil.copy2(src / filename, dst / filename) + _write_json( + _adapter_cache_manifest_path(dst), + { + "cache_key": cache_key, + "non_identity": True, + "adapter_model_bytes": (dst / "adapter_model.safetensors").stat().st_size, + }, + ) + + +def _prune_adapter_cache_dir(cache_dir: Path, *, keep_key: str) -> None: + if not cache_dir.exists(): + return + cache_root = cache_dir.resolve() + keep_path = (cache_root / keep_key).resolve() + for candidate in cache_root.iterdir(): + if not candidate.is_dir() or candidate.is_symlink(): + continue + resolved = candidate.resolve() + if resolved == keep_path: + continue + resolved.relative_to(cache_root) + if _adapter_cache_manifest_path(resolved).exists(): + shutil.rmtree(resolved) + + +def _make_or_reuse_nonzero_adapter( + *, + config: RealPathConfig, + artifact_dir: Path, +) -> AdapterCacheResult: + cache_key = _adapter_cache_key(config.output_parity) + cache_dir = _adapter_cache_dir(config) + cache_path = cache_dir / cache_key + if _cached_adapter_is_valid(cache_path, cache_key=cache_key): + _prune_adapter_cache_dir(cache_dir, keep_key=cache_key) + return AdapterCacheResult( + path=str(cache_path), + cache_key=cache_key, + cache_hit=True, + ) + adapter_path = Path( + _make_nonzero_adapter(config=config.output_parity, artifact_dir=artifact_dir) + ) + if not adapter_path: + raise RuntimeError("Real-path adapter worker did not create an adapter") + _copy_adapter_dir(adapter_path, cache_path, cache_key=cache_key) + _prune_adapter_cache_dir(cache_dir, keep_key=cache_key) + return AdapterCacheResult( + path=str(cache_path), + cache_key=cache_key, + cache_hit=False, + ) + + def _run_logits_with_replay( *, runtime: Any, @@ -923,6 +1090,7 @@ def _real_path_megatron_worker( from art.megatron import train as megatron_train from art.megatron.model_support.lora_disk import load_lora_tensors_for_megatron + from art.megatron.training.weight_offload import WeightOffloadManager from art.preprocessing.pack import packed_tensors_from_dir local_rank = int(os.environ["LOCAL_RANK"]) @@ -959,92 +1127,113 @@ def _configure_worker_bundle(bundle: Any) -> None: for chunk in runtime.model: chunk.eval() + weight_offload = None + if not adapter_only: + weight_offload = WeightOffloadManager.from_env( + model=runtime.model, + rank=torch.distributed.get_rank(), # type: ignore[possibly-missing-attribute] + compile_enabled=runtime.transformer_layers_compiled, + ) + weight_offload.install() + weight_offload.after_job() + weight_offload.before_job() + artifact_dir = Path(request.artifact_dir) adapter_path: Path | None = None - if request.weight_state == "lora": - if request.adapter_path is None: - initial_state = _collect_full_lora_state(cast(list[Any], runtime.model)) - if torch.distributed.get_rank() == 0: # type: ignore[possibly-missing-attribute] + job_completed = False + try: + if request.weight_state == "lora": + if request.adapter_path is None: + initial_state = _collect_full_lora_state(cast(list[Any], runtime.model)) + if torch.distributed.get_rank() == 0: # type: ignore[possibly-missing-attribute] + adapter_path = artifact_dir / "real_path_active_lora" + initialized = _build_deterministic_nonzero_lora( + initial_state or {}, + seed=request.config.seed, + ) + _save_vllm_lora_adapter( + lora_path=adapter_path, + state=initialized, + runtime=runtime, + config=request.config, + ) + torch.distributed.barrier() # type: ignore[possibly-missing-attribute] adapter_path = artifact_dir / "real_path_active_lora" - initialized = _build_deterministic_nonzero_lora( - initial_state or {}, - seed=request.config.seed, + else: + adapter_path = Path(request.adapter_path) + adapter_model = load_lora_tensors_for_megatron( + str(adapter_path), + handler=runtime.model_support_handler, + allow_unvalidated_arch=request.config.allow_unvalidated_arch, + ) + megatron_train.load_adapter_into_model(runtime.model, adapter_model) + + if adapter_only: + if torch.distributed.get_rank() == 0: # type: ignore[possibly-missing-attribute] + result = RealPathMegatronWorkerResult( + score_path="", + adapter_path=str(adapter_path) + if adapter_path is not None + else None, ) - _save_vllm_lora_adapter( - lora_path=adapter_path, - state=initialized, - runtime=runtime, - config=request.config, + _write_json( + artifact_dir / "real_path_adapter_worker_result.json", + result.model_dump(mode="json"), ) torch.distributed.barrier() # type: ignore[possibly-missing-attribute] - adapter_path = artifact_dir / "real_path_active_lora" - else: - adapter_path = Path(request.adapter_path) - adapter_model = load_lora_tensors_for_megatron( - str(adapter_path), - handler=runtime.model_support_handler, - allow_unvalidated_arch=request.config.allow_unvalidated_arch, + torch.distributed.destroy_process_group() # type: ignore[possibly-missing-attribute] + return + + packed_tensors = packed_tensors_from_dir(**request.disk_packed_tensors) + logical_map = LogicalTokenMap.model_validate( + _read_json(Path(request.logical_map_path)) + ) + forward_trace_capture = None + if request.forward_trace_dir is not None: + from ..model_support.forward_trace import ( + CAPTURE_NAME_TOKENS, + ForwardTraceCapture, + ) + + forward_trace_capture = ForwardTraceCapture( + runtime.model, + enabled=True, + capture_name_tokens=(*CAPTURE_NAME_TOKENS, ".decoder.final_layernorm"), + strict_output_match=True, + ) + forward_trace_capture.set_step( + 0, + list(range(int(packed_tensors["tokens"].shape[0]))), + ) + score = _score_megatron_runtime( + runtime=runtime, + packed_tensors=cast(dict[str, Any], packed_tensors), + logical_map=logical_map, + weight_state=request.weight_state, + rollout_mode=_real_path_rollout_mode(request.config), + global_grad_accumulation_sequences=request.global_grad_accumulation_sequences, + forward_trace_capture=forward_trace_capture, + forward_trace_dir=request.forward_trace_dir, ) - megatron_train.load_adapter_into_model(runtime.model, adapter_model) - if adapter_only: if torch.distributed.get_rank() == 0: # type: ignore[possibly-missing-attribute] + score_path = ( + artifact_dir / f"real_path_megatron_{request.weight_state}.json" + ) + _write_json(score_path, score.model_dump(mode="json")) result = RealPathMegatronWorkerResult( - score_path="", + score_path=str(score_path), adapter_path=str(adapter_path) if adapter_path is not None else None, ) _write_json( - artifact_dir / "real_path_adapter_worker_result.json", + artifact_dir + / f"real_path_megatron_{request.weight_state}_worker_result.json", result.model_dump(mode="json"), ) - torch.distributed.barrier() # type: ignore[possibly-missing-attribute] - torch.distributed.destroy_process_group() # type: ignore[possibly-missing-attribute] - return - - packed_tensors = packed_tensors_from_dir(**request.disk_packed_tensors) - logical_map = LogicalTokenMap.model_validate( - _read_json(Path(request.logical_map_path)) - ) - forward_trace_capture = None - if request.forward_trace_dir is not None: - from ..model_support.forward_trace import ( - CAPTURE_NAME_TOKENS, - ForwardTraceCapture, - ) - - forward_trace_capture = ForwardTraceCapture( - runtime.model, - enabled=True, - capture_name_tokens=(*CAPTURE_NAME_TOKENS, ".decoder.final_layernorm"), - strict_output_match=True, - ) - forward_trace_capture.set_step( - 0, - list(range(int(packed_tensors["tokens"].shape[0]))), - ) - score = _score_megatron_runtime( - runtime=runtime, - packed_tensors=cast(dict[str, Any], packed_tensors), - logical_map=logical_map, - weight_state=request.weight_state, - rollout_mode=_real_path_rollout_mode(request.config), - global_grad_accumulation_sequences=request.global_grad_accumulation_sequences, - forward_trace_capture=forward_trace_capture, - forward_trace_dir=request.forward_trace_dir, - ) - - if torch.distributed.get_rank() == 0: # type: ignore[possibly-missing-attribute] - score_path = artifact_dir / f"real_path_megatron_{request.weight_state}.json" - _write_json(score_path, score.model_dump(mode="json")) - result = RealPathMegatronWorkerResult( - score_path=str(score_path), - adapter_path=str(adapter_path) if adapter_path is not None else None, - ) - _write_json( - artifact_dir - / f"real_path_megatron_{request.weight_state}_worker_result.json", - result.model_dump(mode="json"), - ) + job_completed = True + finally: + if weight_offload is not None and job_completed: + weight_offload.after_job() torch.distributed.barrier() # type: ignore[possibly-missing-attribute] torch.distributed.destroy_process_group() # type: ignore[possibly-missing-attribute] @@ -1066,6 +1255,7 @@ def _run_real_path_megatron_worker( env["CUDA_VISIBLE_DEVICES"] = ",".join( str(value) for value in request.config.trainer_gpu_ids ) + env.update(request.config.megatron_env) env["PYTHONUNBUFFERED"] = "1" tests_dir = str(REPO_ROOT / "tests") env["PYTHONPATH"] = ( @@ -1142,11 +1332,8 @@ async def run_real_path_train_inf_mismatch( allow_unvalidated_arch=parity_config.allow_unvalidated_arch, ) _write_json(artifact_dir / "real_path_config.json", config.model_dump(mode="json")) - adapter_path = _make_nonzero_adapter( - config=parity_config, artifact_dir=artifact_dir - ) - if not adapter_path: - raise RuntimeError("Real-path adapter worker did not create an adapter") + adapter = _make_or_reuse_nonzero_adapter(config=config, artifact_dir=artifact_dir) + adapter_path = adapter.path _init_art_megatron_runtime_config(parity_config) backend = MegatronBackend( @@ -1154,16 +1341,9 @@ async def run_real_path_train_inf_mismatch( enable_expert_replay=is_moe, ) backend_open = False - model = art.TrainableModel( - name=f"train-inf-real-{uuid.uuid4().hex[:8]}", - project="train_inf_mismatch", - base_model=parity_config.base_model, - lora_config=( - {"target_modules": _lora_target_modules(parity_config)} - if parity_config.lora_target_modules is not None - else None - ), - _internal_config={ + internal_config = cast( + InternalModelConfig, + { "trainer_gpu_ids": parity_config.trainer_gpu_ids, "inference_gpu_ids": parity_config.inference_gpu_ids, "rollout_weights_mode": _real_path_rollout_weights_mode(parity_config), @@ -1181,11 +1361,29 @@ async def run_real_path_train_inf_mismatch( }, }, ) + if parity_config.external_vllm_server_url is not None: + internal_config["vllm_runtime"] = { + "mode": "external", + "server_url": parity_config.external_vllm_server_url, + "api_key": parity_config.external_vllm_api_key, + } + model = art.TrainableModel( + name=f"train-inf-real-{uuid.uuid4().hex[:8]}", + project="train_inf_mismatch", + base_model=parity_config.base_model, + lora_config=( + {"target_modules": _lora_target_modules(parity_config)} + if parity_config.lora_target_modules is not None + else None + ), + _internal_config=internal_config, + ) _move_adapter_to_step_zero(adapter_path=adapter_path, model=model, backend=backend) try: - await model.register(backend) - backend_open = True + with _temporary_env(parity_config.megatron_env): + await model.register(backend) + backend_open = True trajectory_groups = await _collect_real_trajectory_groups( model=model, config=config, @@ -1342,6 +1540,8 @@ async def run_real_path_train_inf_mismatch( else None ), adapter_path=adapter_path, + adapter_cache_key=adapter.cache_key, + adapter_cache_hit=adapter.cache_hit, megatron_base_scores=( base_diagnostic.megatron_score_path if base_diagnostic is not None diff --git a/tests/integration/megatron/train_inf_mismatch/test_config.py b/tests/integration/megatron/train_inf_mismatch/test_config.py new file mode 100644 index 000000000..bce110550 --- /dev/null +++ b/tests/integration/megatron/train_inf_mismatch/test_config.py @@ -0,0 +1,68 @@ +from __future__ import annotations + +from types import SimpleNamespace + +import torch + +from . import output_parity +from .output_parity import config_from_env + + +def test_cp_unsupported_default_converts_cp_to_dp_without_changing_tp( + monkeypatch, +) -> None: + monkeypatch.setenv("BASE_MODEL", "Qwen/Qwen3.5-35B-A3B") + monkeypatch.delenv("ART_TRAIN_INF_MISMATCH_TP", raising=False) + monkeypatch.delenv("ART_TRAIN_INF_MISMATCH_CP", raising=False) + monkeypatch.delenv("ART_TRAIN_INF_MISMATCH_DP", raising=False) + monkeypatch.setattr( + output_parity, + "handler_workflow_resources_for_base_model", + lambda base_model, *, allow_unvalidated_arch=False: None, + ) + monkeypatch.setattr(output_parity, "model_support_is_moe", lambda *_, **__: True) + monkeypatch.setattr( + output_parity, + "model_supports_context_parallel", + lambda *_, **__: False, + ) + + config = config_from_env() + + assert config.topology.tp == 1 + assert config.topology.cp == 1 + assert config.topology.dp == 2 + assert config.topology.ep == 2 + assert config.topology.world_size() == 2 + + +def test_cp_unsupported_model_uses_non_cp_default_topology(monkeypatch) -> None: + monkeypatch.setattr(torch.cuda, "device_count", lambda: 4) + monkeypatch.setattr(torch.cuda, "is_available", lambda: True) + monkeypatch.setattr( + torch.cuda, + "get_device_properties", + lambda device: SimpleNamespace(total_memory=284 * 1024**3), + ) + monkeypatch.setenv("BASE_MODEL", "deepseek-ai/DeepSeek-V4-Flash") + monkeypatch.delenv("ART_TRAIN_INF_MISMATCH_TRAINER_GPU_IDS", raising=False) + monkeypatch.delenv("ART_TRAIN_INF_MISMATCH_INFERENCE_GPU_IDS", raising=False) + monkeypatch.delenv("ART_TRAIN_INF_MISMATCH_TP", raising=False) + monkeypatch.delenv("ART_TRAIN_INF_MISMATCH_CP", raising=False) + monkeypatch.delenv("ART_TRAIN_INF_MISMATCH_EP", raising=False) + monkeypatch.setenv("ART_MODEL_SUPPORT_EXTERNAL_VLLM_URL", "http://127.0.0.1:8000") + + config = config_from_env() + + assert config.topology.cp == 1 + assert config.topology.tp == 2 + assert config.topology.ep == 2 + assert config.topology.dp == 1 + assert config.trainer_gpu_ids == [0, 1] + assert config.inference_gpu_ids == [2, 3] + assert config.engine_args["tensor_parallel_size"] == 2 + assert config.engine_args["enable_expert_parallel"] is True + assert config.engine_args["kv_cache_dtype"] == "fp8" + assert config.engine_args["moe_backend"] == "triton_unfused" + assert config.megatron_env == {"ART_MEGATRON_STREAMING_WEIGHT_OFFLOAD": "1"} + assert config.external_vllm_server_url == "http://127.0.0.1:8000" diff --git a/tests/integration/megatron/trainability/test_config.py b/tests/integration/megatron/trainability/test_config.py index b265c2b3d..75e5cc37c 100644 --- a/tests/integration/megatron/trainability/test_config.py +++ b/tests/integration/megatron/trainability/test_config.py @@ -1,12 +1,15 @@ import asyncio +from types import SimpleNamespace from typing import cast from openai.types.chat.chat_completion import ChatCompletion, Choice from openai.types.chat.chat_completion_message import ChatCompletionMessage import pytest +import torch import art +from .test_live_length_trainability import _use_default_moe_dedicated_placement from .yes_no_trainability import ( _build_internal_config, _build_variant, @@ -17,6 +20,7 @@ _variant_max_steps, _variant_packed_sequence_length, _variant_rollouts_per_prompt, + _variant_train_kwargs, ) @@ -105,6 +109,7 @@ def test_megatron_variants_keep_short_packed_sequence_default(monkeypatch) -> No ) assert _variant_packed_sequence_length(variant) == 1024 + assert _variant_train_kwargs(variant) == {"packed_sequence_length": 1024} config = _build_internal_config( variant, base_model="Qwen/Qwen3-30B-A3B-Instruct-2507" ) @@ -128,6 +133,7 @@ def test_unsloth_variant_uses_chunk_aligned_training_length(monkeypatch) -> None ) assert _variant_packed_sequence_length(variant) == 1024 + assert _variant_train_kwargs(variant) == {"packed_sequence_length": 1024} assert _variant_init_args(variant) == {"max_seq_length": 1024} assert _build_internal_config( variant, base_model="Qwen/Qwen3-30B-A3B-Instruct-2507" @@ -162,6 +168,7 @@ def test_validated_dense_model_uses_dense_shared_topology( base_model="Qwen/Qwen3.5-4B", ) assert built_variant.topology is not None + assert built_variant.topology.tp == 1 assert built_variant.topology.cp == 2 assert built_variant.topology.ep == 1 assert built_variant.topology.etp == 1 @@ -194,3 +201,80 @@ def test_qwen3_5_moe_shared_variant_enables_expert_parallel(monkeypatch) -> None assert config["rollout_weights_mode"] == "lora" assert config["engine_args"]["enable_expert_parallel"] is True + + +def test_dsv4_trainability_uses_large_model_dedicated_resources( + monkeypatch, +) -> None: + monkeypatch.setattr(torch.cuda, "device_count", lambda: 4) + monkeypatch.setattr(torch.cuda, "is_available", lambda: True) + monkeypatch.setattr( + torch.cuda, + "get_device_properties", + lambda device: SimpleNamespace(total_memory=284 * 1024**3), + ) + monkeypatch.setattr( + "tests.integration.megatron.trainability.yes_no_trainability." + "_safe_gpu_memory_utilization", + lambda device_ids: 0.5, + ) + monkeypatch.setenv("ART_MODEL_SUPPORT_EXTERNAL_VLLM_URL", "http://127.0.0.1:8000") + + default_variant = _default_variant_name( + "deepseek-ai/DeepSeek-V4-Flash", + ) + variant = _build_variant( + default_variant, + base_model="deepseek-ai/DeepSeek-V4-Flash", + ) + config = _build_internal_config( + variant, + base_model="deepseek-ai/DeepSeek-V4-Flash", + ) + + assert default_variant == "megatron_dedicated" + assert variant.topology is not None + assert variant.topology.tp == 2 + assert variant.topology.ep == 2 + assert variant.topology.cp == 1 + assert variant.topology.dp == 1 + assert variant.topology.sp is True + assert variant.trainer_gpu_ids == [0, 1] + assert variant.inference_gpu_ids == [2, 3] + assert config["engine_args"]["tensor_parallel_size"] == 2 + assert config["engine_args"]["enable_expert_parallel"] is True + assert config["engine_args"]["kv_cache_dtype"] == "fp8" + assert config["engine_args"].get("moe_backend") == "triton_unfused" + assert "megatron_topology" not in config + assert config["vllm_runtime"] == { + "mode": "external", + "server_url": "http://127.0.0.1:8000", + "api_key": "art-external-vllm", + } + + +def test_dsv4_length_trainability_keeps_handler_resources(monkeypatch) -> None: + monkeypatch.setattr(torch.cuda, "device_count", lambda: 8) + monkeypatch.setattr(torch.cuda, "is_available", lambda: True) + monkeypatch.setattr( + torch.cuda, + "get_device_properties", + lambda device: SimpleNamespace(total_memory=140 * 1024**3), + ) + + variant = _build_variant( + "megatron_dedicated", + base_model="deepseek-ai/DeepSeek-V4-Flash", + resource_stage_name="length_trainability", + ) + _use_default_moe_dedicated_placement( + variant, + base_model="deepseek-ai/DeepSeek-V4-Flash", + ) + + assert variant.topology is not None + assert variant.trainer_gpu_ids == [0, 1, 2, 3, 4, 5, 6, 7] + assert variant.inference_gpu_ids == [4, 5, 6, 7] + assert variant.topology.tp == 2 + assert variant.topology.ep == 8 + assert variant.topology.cp == 1 diff --git a/tests/integration/megatron/trainability/test_live_length_trainability.py b/tests/integration/megatron/trainability/test_live_length_trainability.py index 8e8aa313b..2921c2667 100644 --- a/tests/integration/megatron/trainability/test_live_length_trainability.py +++ b/tests/integration/megatron/trainability/test_live_length_trainability.py @@ -31,6 +31,7 @@ _get_env_int, _init_megatron_runtime_config, _list_model_ids, + _trainability_stage_resources, ) torch = pytest.importorskip("torch") @@ -210,6 +211,13 @@ def _target_tokens() -> int: def _use_default_moe_dedicated_placement(variant: Any, *, base_model: str) -> None: if not model_uses_expert_parallel(base_model, allow_unvalidated_arch=True): return + stage_resources = _trainability_stage_resources( + base_model, + stage_name="length_trainability", + allow_unvalidated_arch=True, + ) + if stage_resources is not None: + return if os.environ.get(TRAINER_GPU_IDS_ENV) or os.environ.get(INFERENCE_GPU_IDS_ENV): return if torch.cuda.device_count() < 3: @@ -636,6 +644,7 @@ async def run_length_trainability_async( "megatron_dedicated", base_model=base_model, allow_unvalidated_arch=allow_unvalidated_arch, + resource_stage_name="length_trainability", ) _use_default_moe_dedicated_placement(variant, base_model=base_model) max_steps = _length_max_steps() @@ -667,6 +676,7 @@ async def run_length_trainability_async( variant, base_model=base_model, allow_unvalidated_arch=allow_unvalidated_arch, + resource_stage_name="length_trainability", ) internal_config["engine_args"]["max_model_len"] = _get_env_int( "ART_MODEL_SUPPORT_LENGTH_MAX_MODEL_LEN", @@ -682,8 +692,18 @@ async def run_length_trainability_async( chat_template_kwargs = _length_chat_template_kwargs(base_model, tokenizer) rollout_weights_mode = internal_config["rollout_weights_mode"] _init_megatron_runtime_config(variant) + stage_resources = _trainability_stage_resources( + base_model, + stage_name="length_trainability", + allow_unvalidated_arch=allow_unvalidated_arch, + ) + backend_env = stage_resources.megatron_env if stage_resources is not None else {} - async with _backend_context(variant, backend_root=backend_root) as backend: + async with _backend_context( + variant, + backend_root=backend_root, + extra_env=backend_env, + ) as backend: model = art.TrainableModel( name=f"length-{uuid.uuid4().hex[:8]}", project="integration-tests", diff --git a/tests/integration/megatron/trainability/yes_no_trainability.py b/tests/integration/megatron/trainability/yes_no_trainability.py index a3f7918ae..67aba7078 100644 --- a/tests/integration/megatron/trainability/yes_no_trainability.py +++ b/tests/integration/megatron/trainability/yes_no_trainability.py @@ -8,7 +8,7 @@ from pathlib import Path import re import time -from typing import Any, AsyncIterator, Iterator, Literal, cast +from typing import Any, AsyncIterator, Iterator, Literal, TypedDict, cast import uuid from pydantic import BaseModel, Field @@ -20,16 +20,23 @@ from art.megatron.backend import MegatronBackend from art.megatron.model_support.registry import ( get_model_support_spec, + model_supports_context_parallel, model_uses_expert_parallel, ) from art.megatron.model_support.spec import RolloutWeightsMode from ..model_support.oracle_harness import Topology, oracle_topology from ..model_support.oracle_worker import provider_topology_env +from ..model_support.workflow_resources import ( + handler_workflow_resources_for_base_model, + resolve_stage_resources_for_current_host, +) _TRAINER_GPU_IDS_ENV = "ART_MODEL_SUPPORT_TRAINER_GPU_IDS" _INFERENCE_GPU_IDS_ENV = "ART_MODEL_SUPPORT_INFERENCE_GPU_IDS" _SHARED_GPU_IDS_ENV = "ART_MODEL_SUPPORT_SHARED_GPU_IDS" +_EXTERNAL_VLLM_URL_ENV = "ART_MODEL_SUPPORT_EXTERNAL_VLLM_URL" +_EXTERNAL_VLLM_API_KEY_ENV = "ART_MODEL_SUPPORT_EXTERNAL_VLLM_API_KEY" _TRAINABILITY_ROOT = ( Path(__file__).resolve().parents[4] / ".local" / "model_support_validation" ) @@ -40,6 +47,11 @@ "megatron_dedicated", "unsloth_dedicated", ] +_RESOURCE_STAGE_NAME = Literal["yes_no_trainability", "length_trainability"] + + +class _TrainKwargs(TypedDict): + packed_sequence_length: int class TrainabilityStepReport(BaseModel): @@ -96,9 +108,11 @@ def build_prompts() -> list[str]: for length in (3, 2) for words in permutations(("yes", "no", "maybe"), length) for body in [ - ", ".join(f"'{word}'" if use_quotes else word for word in words) - if length == 3 - else " or ".join(f"'{word}'" if use_quotes else word for word in words) + ( + ", ".join(f"'{word}'" if use_quotes else word for word in words) + if length == 3 + else " or ".join(f"'{word}'" if use_quotes else word for word in words) + ) ] ] if prompt_count <= len(prompts): @@ -117,6 +131,50 @@ def _parse_gpu_id_env(name: str) -> list[int] | None: return [int(part.strip()) for part in raw.split(",") if part.strip()] +def _external_vllm_runtime_config() -> dev.VllmRuntimeArgs | None: + server_url = os.environ.get(_EXTERNAL_VLLM_URL_ENV) + if server_url is None or server_url.strip() == "": + return None + return { + "mode": "external", + "server_url": server_url, + "api_key": os.environ.get(_EXTERNAL_VLLM_API_KEY_ENV, "art-external-vllm"), + } + + +def _topology_with_env_overrides(topology: Topology) -> Topology: + updates: dict[str, int | bool] = {} + for env_name, attr in ( + ("ART_MODEL_SUPPORT_TP", "tp"), + ("ART_MODEL_SUPPORT_EP", "ep"), + ("ART_MODEL_SUPPORT_ETP", "etp"), + ("ART_MODEL_SUPPORT_DP", "dp"), + ("ART_MODEL_SUPPORT_CP", "cp"), + ("ART_MODEL_SUPPORT_PP", "pp"), + ("ART_MODEL_SUPPORT_VPP", "vpp"), + ): + if raw_value := os.environ.get(env_name): + updates[attr] = int(raw_value) + if raw_sp := os.environ.get("ART_MODEL_SUPPORT_SP"): + updates["sp"] = raw_sp.strip().lower() in {"1", "true", "yes", "on"} + return topology.model_copy(update=updates) if updates else topology + + +def _variant_with_env_overrides( + variant: _TrainabilityVariant, +) -> _TrainabilityVariant: + trainer_gpu_ids = _parse_gpu_id_env(_TRAINER_GPU_IDS_ENV) + inference_gpu_ids = _parse_gpu_id_env(_INFERENCE_GPU_IDS_ENV) + updates: dict[str, object] = {} + if trainer_gpu_ids is not None: + updates["trainer_gpu_ids"] = trainer_gpu_ids + if inference_gpu_ids is not None: + updates["inference_gpu_ids"] = inference_gpu_ids + if variant.topology is not None: + updates["topology"] = _topology_with_env_overrides(variant.topology) + return variant.model_copy(update=updates) if updates else variant + + def _resolve_shared_gpu_ids() -> list[int]: if shared_gpu_ids := _parse_gpu_id_env(_SHARED_GPU_IDS_ENV): return shared_gpu_ids @@ -331,44 +389,133 @@ def _artifact_dir(base_model: str, variant_name: _VARIANT_NAME) -> Path: return path +def _trainability_stage_resources( + base_model: str, + *, + stage_name: _RESOURCE_STAGE_NAME, + allow_unvalidated_arch: bool = False, +): + workflow_resources = handler_workflow_resources_for_base_model( + base_model, + allow_unvalidated_arch=allow_unvalidated_arch, + ) + if workflow_resources is None: + return None + stage_resources = getattr(workflow_resources, stage_name) + if stage_resources is None: + return None + return resolve_stage_resources_for_current_host(stage_name, stage_resources) + + def _build_variant( variant_name: _VARIANT_NAME, *, base_model: str, allow_unvalidated_arch: bool = False, + resource_stage_name: _RESOURCE_STAGE_NAME = "yes_no_trainability", ) -> _TrainabilityVariant: + stage_resources = _trainability_stage_resources( + base_model, + stage_name=resource_stage_name, + allow_unvalidated_arch=allow_unvalidated_arch, + ) is_moe = model_uses_expert_parallel( base_model, allow_unvalidated_arch=allow_unvalidated_arch, ) + cp_supported = model_supports_context_parallel( + base_model, + allow_unvalidated_arch=allow_unvalidated_arch, + ) if variant_name == "megatron_shared": - shared_gpu_ids = _resolve_shared_gpu_ids() - return _TrainabilityVariant( - name=variant_name, - backend_name="megatron", - placement_mode="shared", - topology=_SHARED_MEGATRON_TOPOLOGY - if is_moe - else _DENSE_SHARED_MEGATRON_TOPOLOGY, - trainer_gpu_ids=shared_gpu_ids, - inference_gpu_ids=shared_gpu_ids, + if ( + stage_resources is not None + and stage_resources.megatron is not None + and stage_resources.vllm is not None + ): + shared_gpu_ids = sorted( + {*stage_resources.megatron.gpu_ids, *stage_resources.vllm.gpu_ids} + ) + else: + shared_gpu_ids = _resolve_shared_gpu_ids() + if not cp_supported: + shared_world_size = len(shared_gpu_ids) + return _variant_with_env_overrides( + _TrainabilityVariant( + name=variant_name, + backend_name="megatron", + placement_mode="shared", + topology=Topology( + tp=shared_world_size, + ep=shared_world_size if is_moe else 1, + etp=1, + dp=1, + cp=1, + sp=shared_world_size > 1, + ), + trainer_gpu_ids=shared_gpu_ids, + inference_gpu_ids=shared_gpu_ids, + ) + ) + return _variant_with_env_overrides( + _TrainabilityVariant( + name=variant_name, + backend_name="megatron", + placement_mode="shared", + topology=( + _SHARED_MEGATRON_TOPOLOGY + if is_moe + else _DENSE_SHARED_MEGATRON_TOPOLOGY + ), + trainer_gpu_ids=shared_gpu_ids, + inference_gpu_ids=shared_gpu_ids, + ) + ) + if ( + variant_name == "megatron_dedicated" + and stage_resources is not None + and stage_resources.megatron is not None + and stage_resources.vllm is not None + ): + workflow_topology = stage_resources.megatron.topology + return _variant_with_env_overrides( + _TrainabilityVariant( + name=variant_name, + backend_name="megatron", + placement_mode="dedicated", + topology=Topology( + tp=workflow_topology.tp, + ep=workflow_topology.ep, + etp=workflow_topology.etp, + dp=workflow_topology.dp, + sp=workflow_topology.sp, + cp=workflow_topology.cp, + pp=workflow_topology.pp, + ), + trainer_gpu_ids=list(stage_resources.megatron.gpu_ids), + inference_gpu_ids=list(stage_resources.vllm.gpu_ids), + ) ) trainer_gpu_ids, inference_gpu_ids = _resolve_dedicated_gpu_ids() if variant_name == "megatron_dedicated": - return _TrainabilityVariant( + return _variant_with_env_overrides( + _TrainabilityVariant( + name=variant_name, + backend_name="megatron", + placement_mode="dedicated", + topology=oracle_topology(is_moe=is_moe), + trainer_gpu_ids=trainer_gpu_ids, + inference_gpu_ids=inference_gpu_ids, + ) + ) + return _variant_with_env_overrides( + _TrainabilityVariant( name=variant_name, - backend_name="megatron", + backend_name="local", placement_mode="dedicated", - topology=oracle_topology(is_moe=is_moe), trainer_gpu_ids=trainer_gpu_ids, inference_gpu_ids=inference_gpu_ids, ) - return _TrainabilityVariant( - name=variant_name, - backend_name="local", - placement_mode="dedicated", - trainer_gpu_ids=trainer_gpu_ids, - inference_gpu_ids=inference_gpu_ids, ) @@ -376,6 +523,10 @@ def _variant_packed_sequence_length(variant: _TrainabilityVariant) -> int: return _get_env_int("ART_MODEL_SUPPORT_YES_NO_PACKED_SEQUENCE_LENGTH", 1024) +def _variant_train_kwargs(variant: _TrainabilityVariant) -> _TrainKwargs: + return {"packed_sequence_length": _variant_packed_sequence_length(variant)} + + def _variant_init_args(variant: _TrainabilityVariant) -> dev.InitArgs: return {"max_seq_length": _variant_packed_sequence_length(variant)} @@ -383,7 +534,10 @@ def _variant_init_args(variant: _TrainabilityVariant) -> dev.InitArgs: def _init_megatron_runtime_config(variant: _TrainabilityVariant) -> None: if variant.topology is None: return - art.init_megatron_runtime_config( + init_runtime_config = getattr(art, "init_megatron_runtime_config", None) + if init_runtime_config is None: + return + init_runtime_config( topology=art.MegatronTopologyConfig( tp=variant.topology.tp, cp=variant.topology.cp, @@ -420,6 +574,15 @@ def _default_variant_name( *, allow_unvalidated_arch: bool = False, ) -> _VARIANT_NAME: + workflow_resources = handler_workflow_resources_for_base_model( + base_model, + allow_unvalidated_arch=allow_unvalidated_arch, + ) + if ( + workflow_resources is not None + and workflow_resources.yes_no_trainability_variant is not None + ): + return workflow_resources.yes_no_trainability_variant if ( _rollout_weights_mode( base_model, @@ -437,16 +600,43 @@ def _build_internal_config( base_model: str, rollout_weights_mode: RolloutWeightsMode | None = None, allow_unvalidated_arch: bool = False, + resource_stage_name: _RESOURCE_STAGE_NAME = "yes_no_trainability", ) -> dev.InternalModelConfig: shared = variant.placement_mode == "shared" - inference_gpu_ids = ( - variant.inference_gpu_ids if not shared else _resolve_shared_gpu_ids() + inference_gpu_ids = variant.inference_gpu_ids + stage_resources = _trainability_stage_resources( + base_model, + stage_name=resource_stage_name, + allow_unvalidated_arch=allow_unvalidated_arch, + ) + stage_resources_apply = ( + not shared + and variant.backend_name == "megatron" + and stage_resources is not None + and stage_resources.megatron is not None + and stage_resources.vllm is not None + and variant.trainer_gpu_ids == stage_resources.megatron.gpu_ids + and variant.inference_gpu_ids == stage_resources.vllm.gpu_ids ) + if stage_resources_apply: + assert stage_resources is not None + assert stage_resources.vllm is not None + vllm_resources = stage_resources.vllm + else: + vllm_resources = None engine_args = _engine_args_for_yes_no_trainability( inference_gpu_ids=inference_gpu_ids, - tensor_parallel_size=len(inference_gpu_ids) if shared else 1, + tensor_parallel_size=( + vllm_resources.tensor_parallel_size + if vllm_resources is not None + else len(inference_gpu_ids) + if shared + else 1 + ), enable_expert_parallel=( - shared + vllm_resources.enable_expert_parallel + if vllm_resources is not None + else shared and variant.backend_name == "megatron" and model_uses_expert_parallel( base_model, @@ -455,6 +645,10 @@ def _build_internal_config( ), enable_sleep_mode=True if shared else None, ) + if vllm_resources is not None: + engine_args.update(vllm_resources.engine_args()) + elif shared and stage_resources is not None and stage_resources.vllm is not None: + engine_args.update(stage_resources.vllm.extra_engine_args) engine_args["model"] = base_model internal_config = dev.InternalModelConfig( rollout_weights_mode=rollout_weights_mode @@ -466,10 +660,23 @@ def _build_internal_config( init_args=_variant_init_args(variant), allow_unvalidated_arch=allow_unvalidated_arch, ) + external_runtime = _external_vllm_runtime_config() + if ( + stage_resources is not None + and stage_resources.requires_external_vllm + and external_runtime is None + ): + raise RuntimeError( + f"{resource_stage_name} for this model requires an external vLLM server. " + f"Set {_EXTERNAL_VLLM_URL_ENV}." + ) + if external_runtime is not None: + internal_config["vllm_runtime"] = external_runtime if not shared: internal_config["trainer_gpu_ids"] = variant.trainer_gpu_ids internal_config["inference_gpu_ids"] = variant.inference_gpu_ids - dev.validate_dedicated_config(internal_config) + if not stage_resources_apply: + dev.validate_dedicated_config(internal_config) return internal_config @@ -704,8 +911,28 @@ async def run_yes_no_trainability_async( _internal_config=internal_config, report_metrics=[], ) + train_kwargs = _variant_train_kwargs(variant) + workflow_resources = handler_workflow_resources_for_base_model( + base_model, + allow_unvalidated_arch=allow_unvalidated_arch, + ) + stage_resources = ( + workflow_resources.yes_no_trainability + if workflow_resources is not None + else None + ) + if stage_resources is not None: + stage_resources = resolve_stage_resources_for_current_host( + "yes_no_trainability", + stage_resources, + ) + backend_env = { + **(stage_resources.megatron_env if stage_resources is not None else {}), + **(extra_env or {}), + } + async with _backend_context( - variant, backend_root=backend_root, extra_env=extra_env + variant, backend_root=backend_root, extra_env=backend_env ) as backend: await model.register(backend) output_dir = Path(model.base_path) / model.project / "models" / model.name @@ -756,6 +983,7 @@ async def run_yes_no_trainability_async( 1e-4, ), loss_fn="cispo", + packed_sequence_length=train_kwargs["packed_sequence_length"], ) await model.log( train_groups, diff --git a/tests/unit/test_chat_template_config.py b/tests/unit/test_chat_template_config.py deleted file mode 100644 index 9ba17ba09..000000000 --- a/tests/unit/test_chat_template_config.py +++ /dev/null @@ -1,69 +0,0 @@ -import pytest - -from art.local.backend import ( - _apply_configured_chat_template, - _apply_configured_chat_template_server_args, - _tokenizer_cache_key, -) - - -class _Tokenizer: - chat_template = "base" - - -def test_apply_configured_chat_template_reads_path(tmp_path) -> None: - path = tmp_path / "chat_template.jinja" - path.write_text("custom template", encoding="utf-8") - tokenizer = _Tokenizer() - - _apply_configured_chat_template( - tokenizer, # type: ignore[arg-type] - {"chat_template_path": str(path)}, - ) - - assert tokenizer.chat_template == "custom template" - - -def test_apply_configured_chat_template_server_args_preserves_explicit_override() -> ( - None -): - config_dict = {"server_args": {"chat_template": "explicit"}} - - _apply_configured_chat_template_server_args( - config_dict, - { - "chat_template": "default", - "chat_template_content_format": "string", - }, - ) - - assert config_dict["server_args"] == { - "chat_template": "explicit", - "chat_template_content_format": "string", - } - - -def test_configured_chat_template_rejects_ambiguous_config(tmp_path) -> None: - path = tmp_path / "chat_template.jinja" - path.write_text("custom template", encoding="utf-8") - - with pytest.raises(ValueError, match="Set only one"): - _apply_configured_chat_template( - _Tokenizer(), # type: ignore[arg-type] - { - "chat_template": "raw", - "chat_template_path": str(path), - }, - ) - - -def test_tokenizer_cache_key_includes_chat_template_content(tmp_path) -> None: - path = tmp_path / "chat_template.jinja" - path.write_text("first template", encoding="utf-8") - first_key = _tokenizer_cache_key("base-model", {"chat_template_path": str(path)}) - - path.write_text("second template", encoding="utf-8") - second_key = _tokenizer_cache_key("base-model", {"chat_template_path": str(path)}) - - assert _tokenizer_cache_key("base-model", {}) == ("base-model", None) - assert first_key != second_key diff --git a/tests/unit/test_dsv4_vllm_runtime_patches.py b/tests/unit/test_dsv4_vllm_runtime_patches.py new file mode 100644 index 000000000..1ed497f8d --- /dev/null +++ b/tests/unit/test_dsv4_vllm_runtime_patches.py @@ -0,0 +1,114 @@ +from __future__ import annotations + +import importlib.util +from pathlib import Path +import sys +import types +from types import SimpleNamespace + +import torch + + +def _load_dsv4_patches_module(): + path = ( + Path(__file__).resolve().parents[2] + / "vllm_runtime/src/art_vllm_runtime/dsv4_patches.py" + ) + spec = importlib.util.spec_from_file_location( + "_art_vllm_runtime_dsv4_patches", + path, + ) + assert spec is not None + module = importlib.util.module_from_spec(spec) + assert spec.loader is not None + spec.loader.exec_module(module) + return module + + +def test_dsv4_compressor_helper_uses_punica_metadata_without_full_batch_lora( + monkeypatch, +) -> None: + patches = _load_dsv4_patches_module() + fake_vllm = types.ModuleType("vllm") + fake_platforms = types.ModuleType("vllm.platforms") + setattr( + fake_platforms, + "current_platform", + SimpleNamespace(can_update_inplace=lambda: True), + ) + setattr(fake_vllm, "platforms", fake_platforms) + monkeypatch.setitem(sys.modules, "vllm", fake_vllm) + monkeypatch.setitem( + sys.modules, + "vllm.platforms", + fake_platforms, + ) + monkeypatch.setattr( + patches, "_register_dsv4_lora_expand_fp32_output_op", lambda: None + ) + + expand_calls: list[tuple[tuple[int, ...], int]] = [] + + def fake_expand(inputs, lora_b, output, *args): + offset = args[-1] + width = lora_b.shape[2] + expand_calls.append((tuple(lora_b.shape), offset)) + output[:, offset : offset + width].add_(inputs.sum(dim=-1, keepdim=True)) + + monkeypatch.setattr( + torch.ops.vllm, + "art_dsv4_lora_expand_fp32_output", + fake_expand, + raising=False, + ) + + class FakeTokenMappingMeta: + def meta_args(self, token_count, specialize_active_lora): + assert token_count == 4 + assert specialize_active_lora is False + return ( + torch.tensor([0, 0, 1, 1], dtype=torch.int32), + torch.tensor([0, 1, 2, 3], dtype=torch.int32), + torch.tensor([2, 2, 0], dtype=torch.int32), + torch.tensor([0, 2, 4, 4], dtype=torch.int32), + torch.tensor([0, 1, -1], dtype=torch.int32), + torch.tensor([False]), + torch.tensor([2], dtype=torch.int32), + ) + + class FakeWrapper: + no_lora = False + indices_len = [4] + lora_config = SimpleNamespace(specialize_active_lora=False) + token_mapping_meta = FakeTokenMappingMeta() + + def add_shrink(self, buffers, x, lora_a_stacked, scale): + assert buffers.shape == (2, 4, 2) + assert scale == 1.0 + buffers.copy_( + torch.arange(buffers.numel(), dtype=torch.float32).view_as(buffers) + ) + return None + + module = SimpleNamespace( + lora_a_stacked=( + torch.zeros(2, 1, 2, 4, dtype=torch.bfloat16), + torch.zeros(2, 1, 2, 4, dtype=torch.bfloat16), + ), + lora_b_stacked=( + torch.zeros(2, 1, 3, 2, dtype=torch.bfloat16), + torch.zeros(2, 1, 5, 2, dtype=torch.bfloat16), + ), + output_slices=(3, 5), + punica_wrapper=FakeWrapper(), + tp_size=1, + ) + output = torch.zeros(4, 8, dtype=torch.float32) + + result = patches._apply_dsv4_compressor_lora_to_existing_output( + module, torch.zeros(4, 4, dtype=torch.bfloat16), output + ) + + assert result is output + assert expand_calls == [((2, 1, 3, 2), 0), ((2, 1, 5, 2), 3)] + assert output.abs().sum() > 0 diff --git a/tests/unit/test_tinker_renderers.py b/tests/unit/test_tinker_renderers.py index c03f87129..59b01961b 100644 --- a/tests/unit/test_tinker_renderers.py +++ b/tests/unit/test_tinker_renderers.py @@ -111,7 +111,7 @@ def test_qwen3_5_parse_response_handles_xml_tool_calls() -> None: message, success = renderer.parse_response(response) - assert success == renderers.ParseTermination.STOP_SEQUENCE + assert success is True or getattr(success, "value", None) == "stop_sequence" assert message["content"] == [ {"type": "thinking", "thinking": "reasoning"}, {"type": "text", "text": "Answer first.\n\n"}, diff --git a/tests/unit/test_train_inf_mismatch_logical_map.py b/tests/unit/test_train_inf_mismatch_logical_map.py new file mode 100644 index 000000000..34987a6e3 --- /dev/null +++ b/tests/unit/test_train_inf_mismatch_logical_map.py @@ -0,0 +1,37 @@ +import math +from pathlib import Path +import sys + +import torch + +sys.path.insert(0, str(Path(__file__).resolve().parents[2])) + +from tests.integration.megatron.train_inf_mismatch.output_parity import ( + build_logical_token_map, +) + + +def test_logical_map_excludes_masked_template_tail_token() -> None: + packed = { + "tokens": torch.tensor([[10, 20, 30, 99, 4, 5, 1, 0]], dtype=torch.long), + "group_ids": torch.tensor([[7, 7, 7, 8, 8, 8, 8, -1]], dtype=torch.long), + "parent_ids": torch.tensor([[7, 7, 7, 7, 7, 7, 7, -1]], dtype=torch.long), + "assistant_mask": torch.tensor( + [[False, False, False, False, True, True, False, False]] + ), + "logprobs": torch.tensor( + [[math.nan, math.nan, math.nan, math.nan, -0.1, -0.2, math.nan, math.nan]], + dtype=torch.float32, + ), + } + + logical_map = build_logical_token_map(packed) + + assert len(logical_map.prompts) == 1 + prompt = logical_map.prompts[0] + assert prompt.packed_prompt_length == 3 + assert prompt.scored_token_start_index == 4 + assert prompt.token_ids == [10, 20, 30, 99, 4, 5] + assert [token.token_id for token in logical_map.tokens] == [4, 5] + assert [token.vllm_prompt_token_index for token in logical_map.tokens] == [4, 5] + assert [token.art_packed_token_index for token in logical_map.tokens] == [4, 5] diff --git a/tests/unit/test_vllm_lora_delta.py b/tests/unit/test_vllm_lora_delta.py new file mode 100644 index 000000000..7b6930a3d --- /dev/null +++ b/tests/unit/test_vllm_lora_delta.py @@ -0,0 +1,68 @@ +from __future__ import annotations + +import importlib.util +from pathlib import Path +from types import SimpleNamespace + +import torch + + +def _load_lora_delta_module(): + path = ( + Path(__file__).resolve().parents[2] + / "vllm_runtime/src/art_vllm_runtime/lora_delta.py" + ) + spec = importlib.util.spec_from_file_location("_art_vllm_runtime_lora_delta", path) + assert spec is not None + module = importlib.util.module_from_spec(spec) + assert spec.loader is not None + spec.loader.exec_module(module) + return module + + +def test_additive_weight_loader_uses_legacy_loader_for_plain_merged_column_param(): + lora_delta = _load_lora_delta_module() + param = torch.nn.Parameter(torch.zeros(2, 4)) + loaded = torch.arange(8, dtype=torch.float32).view(2, 4) + calls = [] + + class Owner: + def weight_loader_v2(self, loader_param, loaded_weight, shard_id): + del shard_id + loader_param.load_merged_column_weight(loaded_weight=loaded_weight) + + def weight_loader(self, loader_param, loaded_weight, shard_id): + calls.append((loader_param, shard_id)) + loader_param.data.copy_(loaded_weight) + + owner = Owner() + loader = lora_delta._additive_weight_loader(param, owner.weight_loader_v2) + result = loader(param, loaded, 0) + + assert result is None + assert calls == [(param, 0)] + assert torch.equal(param, loaded) + + +def test_additive_weight_loader_keeps_v2_for_vllm_parameter_like_param(): + lora_delta = _load_lora_delta_module() + param = torch.nn.Parameter(torch.zeros(2, 4)) + loaded = torch.arange(8, dtype=torch.float32).view(2, 4) + calls = [] + + def load_merged_column_weight(*, loaded_weight, **_kwargs): + calls.append("v2") + param.data.copy_(loaded_weight) + + setattr(param, "load_merged_column_weight", load_merged_column_weight) + owner = SimpleNamespace( + weight_loader_v2=lambda loader_param, loaded_weight, shard_id: ( + loader_param.load_merged_column_weight(loaded_weight=loaded_weight) + ), + weight_loader=lambda *_args, **_kwargs: calls.append("legacy"), + ) + loader = lora_delta._additive_weight_loader(param, owner.weight_loader_v2) + loader(param, loaded, 0) + + assert calls == ["v2"] + assert torch.equal(param, loaded) diff --git a/uv.lock b/uv.lock index 36ad513d8..149ff5f9e 100644 --- a/uv.lock +++ b/uv.lock @@ -1220,7 +1220,7 @@ name = "cuda-bindings" version = "12.9.7" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cuda-pathfinder", marker = "sys_platform == 'linux' or sys_platform == 'win32' or extra == 'extra-12-openpipe-art-megatron'" }, + { name = "cuda-pathfinder", marker = "sys_platform == 'linux' or (sys_platform != 'win32' and extra == 'extra-12-openpipe-art-megatron') or (sys_platform == 'win32' and extra != 'extra-12-openpipe-art-backend') or (extra == 'extra-12-openpipe-art-backend' and extra == 'extra-12-openpipe-art-megatron')" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/32/45/557d4ed1fa54f0c7db8aee083229f624990d69f7d00f55477eed5c7e169a/cuda_bindings-12.9.7-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0666d3c082ef8f4b2d670950589373550e9f3bf564d635dd883f24a0b40402ff", size = 7071026, upload-time = "2026-05-27T18:44:13.356Z" }, @@ -1291,37 +1291,37 @@ wheels = [ [package.optional-dependencies] cublas = [ - { name = "nvidia-cublas-cu12", marker = "sys_platform == 'linux' or sys_platform == 'win32' or (extra == 'extra-12-openpipe-art-backend' and extra == 'extra-12-openpipe-art-megatron') or (extra == 'extra-12-openpipe-art-megatron' and extra == 'extra-12-openpipe-art-tinker')" }, + { name = "nvidia-cublas-cu12", marker = "sys_platform == 'linux' or (sys_platform == 'win32' and extra != 'extra-12-openpipe-art-backend' and extra != 'extra-12-openpipe-art-megatron') or (extra == 'extra-12-openpipe-art-backend' and extra == 'extra-12-openpipe-art-megatron') or (extra == 'extra-12-openpipe-art-megatron' and extra == 'extra-12-openpipe-art-tinker')" }, ] cudart = [ - { name = "nvidia-cuda-runtime-cu12", marker = "sys_platform == 'linux' or sys_platform == 'win32' or (extra == 'extra-12-openpipe-art-backend' and extra == 'extra-12-openpipe-art-megatron') or (extra == 'extra-12-openpipe-art-megatron' and extra == 'extra-12-openpipe-art-tinker')" }, + { name = "nvidia-cuda-runtime-cu12", marker = "sys_platform == 'linux' or (sys_platform == 'win32' and extra != 'extra-12-openpipe-art-backend' and extra != 'extra-12-openpipe-art-megatron') or (extra == 'extra-12-openpipe-art-backend' and extra == 'extra-12-openpipe-art-megatron') or (extra == 'extra-12-openpipe-art-megatron' and extra == 'extra-12-openpipe-art-tinker')" }, ] cufft = [ - { name = "nvidia-cufft-cu12", marker = "sys_platform == 'linux' or sys_platform == 'win32' or (extra == 'extra-12-openpipe-art-backend' and extra == 'extra-12-openpipe-art-megatron') or (extra == 'extra-12-openpipe-art-megatron' and extra == 'extra-12-openpipe-art-tinker')" }, + { name = "nvidia-cufft-cu12", marker = "sys_platform == 'linux' or (sys_platform == 'win32' and extra != 'extra-12-openpipe-art-backend' and extra != 'extra-12-openpipe-art-megatron') or (extra == 'extra-12-openpipe-art-backend' and extra == 'extra-12-openpipe-art-megatron') or (extra == 'extra-12-openpipe-art-megatron' and extra == 'extra-12-openpipe-art-tinker')" }, ] cufile = [ - { name = "nvidia-cufile-cu12", marker = "sys_platform == 'linux' or (extra == 'extra-12-openpipe-art-backend' and extra == 'extra-12-openpipe-art-megatron') or (extra == 'extra-12-openpipe-art-megatron' and extra == 'extra-12-openpipe-art-tinker')" }, + { name = "nvidia-cufile-cu12", marker = "sys_platform == 'linux'" }, ] cupti = [ - { name = "nvidia-cuda-cupti-cu12", marker = "sys_platform == 'linux' or sys_platform == 'win32' or (extra == 'extra-12-openpipe-art-backend' and extra == 'extra-12-openpipe-art-megatron') or (extra == 'extra-12-openpipe-art-megatron' and extra == 'extra-12-openpipe-art-tinker')" }, + { name = "nvidia-cuda-cupti-cu12", marker = "sys_platform == 'linux' or (sys_platform == 'win32' and extra != 'extra-12-openpipe-art-backend' and extra != 'extra-12-openpipe-art-megatron') or (extra == 'extra-12-openpipe-art-backend' and extra == 'extra-12-openpipe-art-megatron') or (extra == 'extra-12-openpipe-art-megatron' and extra == 'extra-12-openpipe-art-tinker')" }, ] curand = [ - { name = "nvidia-curand-cu12", marker = "sys_platform == 'linux' or sys_platform == 'win32' or (extra == 'extra-12-openpipe-art-backend' and extra == 'extra-12-openpipe-art-megatron') or (extra == 'extra-12-openpipe-art-megatron' and extra == 'extra-12-openpipe-art-tinker')" }, + { name = "nvidia-curand-cu12", marker = "sys_platform == 'linux' or (sys_platform == 'win32' and extra != 'extra-12-openpipe-art-backend' and extra != 'extra-12-openpipe-art-megatron') or (extra == 'extra-12-openpipe-art-backend' and extra == 'extra-12-openpipe-art-megatron') or (extra == 'extra-12-openpipe-art-megatron' and extra == 'extra-12-openpipe-art-tinker')" }, ] cusolver = [ - { name = "nvidia-cusolver-cu12", marker = "sys_platform == 'linux' or sys_platform == 'win32' or (extra == 'extra-12-openpipe-art-backend' and extra == 'extra-12-openpipe-art-megatron') or (extra == 'extra-12-openpipe-art-megatron' and extra == 'extra-12-openpipe-art-tinker')" }, + { name = "nvidia-cusolver-cu12", marker = "sys_platform == 'linux' or (sys_platform == 'win32' and extra != 'extra-12-openpipe-art-backend' and extra != 'extra-12-openpipe-art-megatron') or (extra == 'extra-12-openpipe-art-backend' and extra == 'extra-12-openpipe-art-megatron') or (extra == 'extra-12-openpipe-art-megatron' and extra == 'extra-12-openpipe-art-tinker')" }, ] cusparse = [ - { name = "nvidia-cusparse-cu12", marker = "sys_platform == 'linux' or sys_platform == 'win32' or (extra == 'extra-12-openpipe-art-backend' and extra == 'extra-12-openpipe-art-megatron') or (extra == 'extra-12-openpipe-art-megatron' and extra == 'extra-12-openpipe-art-tinker')" }, + { name = "nvidia-cusparse-cu12", marker = "sys_platform == 'linux' or (sys_platform == 'win32' and extra != 'extra-12-openpipe-art-backend' and extra != 'extra-12-openpipe-art-megatron') or (extra == 'extra-12-openpipe-art-backend' and extra == 'extra-12-openpipe-art-megatron') or (extra == 'extra-12-openpipe-art-megatron' and extra == 'extra-12-openpipe-art-tinker')" }, ] nvjitlink = [ - { name = "nvidia-nvjitlink-cu12", marker = "sys_platform == 'linux' or sys_platform == 'win32' or (extra == 'extra-12-openpipe-art-backend' and extra == 'extra-12-openpipe-art-megatron') or (extra == 'extra-12-openpipe-art-megatron' and extra == 'extra-12-openpipe-art-tinker')" }, + { name = "nvidia-nvjitlink-cu12", marker = "sys_platform == 'linux' or (sys_platform == 'win32' and extra != 'extra-12-openpipe-art-backend' and extra != 'extra-12-openpipe-art-megatron') or (extra == 'extra-12-openpipe-art-backend' and extra == 'extra-12-openpipe-art-megatron') or (extra == 'extra-12-openpipe-art-megatron' and extra == 'extra-12-openpipe-art-tinker')" }, ] nvrtc = [ - { name = "nvidia-cuda-nvrtc-cu12", marker = "sys_platform == 'linux' or sys_platform == 'win32' or (extra == 'extra-12-openpipe-art-backend' and extra == 'extra-12-openpipe-art-megatron') or (extra == 'extra-12-openpipe-art-megatron' and extra == 'extra-12-openpipe-art-tinker')" }, + { name = "nvidia-cuda-nvrtc-cu12", marker = "sys_platform == 'linux' or (sys_platform == 'win32' and extra != 'extra-12-openpipe-art-backend' and extra != 'extra-12-openpipe-art-megatron') or (extra == 'extra-12-openpipe-art-backend' and extra == 'extra-12-openpipe-art-megatron') or (extra == 'extra-12-openpipe-art-megatron' and extra == 'extra-12-openpipe-art-tinker')" }, ] nvtx = [ - { name = "nvidia-nvtx-cu12", marker = "sys_platform == 'linux' or sys_platform == 'win32' or (extra == 'extra-12-openpipe-art-backend' and extra == 'extra-12-openpipe-art-megatron') or (extra == 'extra-12-openpipe-art-megatron' and extra == 'extra-12-openpipe-art-tinker')" }, + { name = "nvidia-nvtx-cu12", marker = "sys_platform == 'linux' or (sys_platform == 'win32' and extra != 'extra-12-openpipe-art-backend' and extra != 'extra-12-openpipe-art-megatron') or (extra == 'extra-12-openpipe-art-backend' and extra == 'extra-12-openpipe-art-megatron') or (extra == 'extra-12-openpipe-art-megatron' and extra == 'extra-12-openpipe-art-tinker')" }, ] [[package]] @@ -1938,7 +1938,7 @@ version = "0.5.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "fla-core" }, - { name = "transformers", version = "5.6.2", source = { registry = "https://pypi.org/simple" } }, + { name = "transformers", version = "5.12.1", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/79/5c/1db76cc829c951117a3112f306d50333bd71399d2e35807fe7c99ffc2007/flash_linear_attention-0.5.0.tar.gz", hash = "sha256:22b789a47f07738b4382ecdf775d7bb40e0d803c467c34f8e2ecd6a1dc780938", size = 160419, upload-time = "2026-04-21T20:25:42.344Z" } wheels = [ @@ -2502,7 +2502,7 @@ name = "gunicorn" version = "25.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "packaging" }, + { name = "packaging", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/c4/f4/e78fa054248fab913e2eab0332c6c2cb07421fca1ce56d8fe43b6aef57a4/gunicorn-25.3.0.tar.gz", hash = "sha256:f74e1b2f9f76f6cd1ca01198968bd2dd65830edc24b6e8e4d78de8320e2fe889", size = 634883, upload-time = "2026-03-27T00:00:26.092Z" } wheels = [ @@ -3844,7 +3844,7 @@ dependencies = [ { name = "torch", version = "2.11.0", source = { registry = "https://pypi.org/simple" }, marker = "(sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-12-openpipe-art-megatron') or (sys_platform == 'linux' and extra == 'extra-12-openpipe-art-backend' and extra == 'extra-12-openpipe-art-megatron') or (sys_platform == 'linux' and extra == 'extra-12-openpipe-art-megatron' and extra == 'extra-12-openpipe-art-tinker') or (sys_platform == 'win32' and extra == 'extra-12-openpipe-art-backend' and extra == 'extra-12-openpipe-art-megatron') or (sys_platform == 'win32' and extra == 'extra-12-openpipe-art-megatron' and extra == 'extra-12-openpipe-art-tinker')" }, { name = "torch", version = "2.11.0+cu128", source = { registry = "https://download.pytorch.org/whl/cu128" }, marker = "(sys_platform == 'linux' and extra == 'extra-12-openpipe-art-megatron') or (sys_platform == 'win32' and extra == 'extra-12-openpipe-art-megatron') or (extra == 'extra-12-openpipe-art-backend' and extra == 'extra-12-openpipe-art-megatron') or (extra == 'extra-12-openpipe-art-megatron' and extra == 'extra-12-openpipe-art-tinker')" }, { name = "tqdm" }, - { name = "transformers", version = "5.6.2", source = { registry = "https://pypi.org/simple" } }, + { name = "transformers", version = "5.12.1", source = { registry = "https://pypi.org/simple" } }, { name = "typing-extensions" }, { name = "wandb" }, ] @@ -4394,7 +4394,7 @@ name = "nvidia-cudnn-cu12" version = "9.19.0.56" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-cublas-cu12", marker = "sys_platform == 'linux' or sys_platform == 'win32' or (extra == 'extra-12-openpipe-art-backend' and extra == 'extra-12-openpipe-art-megatron') or (extra == 'extra-12-openpipe-art-megatron' and extra == 'extra-12-openpipe-art-tinker')" }, + { name = "nvidia-cublas-cu12", marker = "sys_platform == 'linux' or (sys_platform == 'win32' and extra != 'extra-12-openpipe-art-backend' and extra != 'extra-12-openpipe-art-megatron') or (extra == 'extra-12-openpipe-art-backend' and extra == 'extra-12-openpipe-art-megatron') or (extra == 'extra-12-openpipe-art-megatron' and extra == 'extra-12-openpipe-art-tinker')" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/09/b8/277c51962ee46fa3e5b203ac5f76107c650f781d6891e681e28e6f3e9fe6/nvidia_cudnn_cu12-9.19.0.56-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:08caaf27fe556aca82a3ee3b5aa49a77e7de0cfcb7ff4e5c29da426387a8267e", size = 656910700, upload-time = "2026-02-03T20:40:25.508Z" }, @@ -4423,7 +4423,7 @@ name = "nvidia-cufft-cu12" version = "11.3.3.83" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-nvjitlink-cu12", marker = "sys_platform == 'linux' or sys_platform == 'win32' or (extra == 'extra-12-openpipe-art-backend' and extra == 'extra-12-openpipe-art-megatron') or (extra == 'extra-12-openpipe-art-megatron' and extra == 'extra-12-openpipe-art-tinker')" }, + { name = "nvidia-nvjitlink-cu12", marker = "sys_platform == 'linux' or (sys_platform == 'win32' and extra != 'extra-12-openpipe-art-backend' and extra != 'extra-12-openpipe-art-megatron') or (extra == 'extra-12-openpipe-art-backend' and extra == 'extra-12-openpipe-art-megatron') or (extra == 'extra-12-openpipe-art-megatron' and extra == 'extra-12-openpipe-art-tinker')" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/60/bc/7771846d3a0272026c416fbb7e5f4c1f146d6d80704534d0b187dd6f4800/nvidia_cufft_cu12-11.3.3.83-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:848ef7224d6305cdb2a4df928759dca7b1201874787083b6e7550dd6765ce69a", size = 193109211, upload-time = "2025-03-07T01:44:56.873Z" }, @@ -4455,9 +4455,9 @@ name = "nvidia-cusolver-cu12" version = "11.7.3.90" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-cublas-cu12", marker = "sys_platform == 'linux' or sys_platform == 'win32' or (extra == 'extra-12-openpipe-art-backend' and extra == 'extra-12-openpipe-art-megatron') or (extra == 'extra-12-openpipe-art-megatron' and extra == 'extra-12-openpipe-art-tinker')" }, - { name = "nvidia-cusparse-cu12", marker = "sys_platform == 'linux' or sys_platform == 'win32' or (extra == 'extra-12-openpipe-art-backend' and extra == 'extra-12-openpipe-art-megatron') or (extra == 'extra-12-openpipe-art-megatron' and extra == 'extra-12-openpipe-art-tinker')" }, - { name = "nvidia-nvjitlink-cu12", marker = "sys_platform == 'linux' or sys_platform == 'win32' or (extra == 'extra-12-openpipe-art-backend' and extra == 'extra-12-openpipe-art-megatron') or (extra == 'extra-12-openpipe-art-megatron' and extra == 'extra-12-openpipe-art-tinker')" }, + { name = "nvidia-cublas-cu12", marker = "sys_platform == 'linux' or (sys_platform == 'win32' and extra != 'extra-12-openpipe-art-backend' and extra != 'extra-12-openpipe-art-megatron') or (extra == 'extra-12-openpipe-art-backend' and extra == 'extra-12-openpipe-art-megatron') or (extra == 'extra-12-openpipe-art-megatron' and extra == 'extra-12-openpipe-art-tinker')" }, + { name = "nvidia-cusparse-cu12", marker = "sys_platform == 'linux' or (sys_platform == 'win32' and extra != 'extra-12-openpipe-art-backend' and extra != 'extra-12-openpipe-art-megatron') or (extra == 'extra-12-openpipe-art-backend' and extra == 'extra-12-openpipe-art-megatron') or (extra == 'extra-12-openpipe-art-megatron' and extra == 'extra-12-openpipe-art-tinker')" }, + { name = "nvidia-nvjitlink-cu12", marker = "sys_platform == 'linux' or (sys_platform == 'win32' and extra != 'extra-12-openpipe-art-backend' and extra != 'extra-12-openpipe-art-megatron') or (extra == 'extra-12-openpipe-art-backend' and extra == 'extra-12-openpipe-art-megatron') or (extra == 'extra-12-openpipe-art-megatron' and extra == 'extra-12-openpipe-art-tinker')" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/c8/32/f7cd6ce8a7690544d084ea21c26e910a97e077c9b7f07bf5de623ee19981/nvidia_cusolver_cu12-11.7.3.90-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:db9ed69dbef9715071232caa9b69c52ac7de3a95773c2db65bdba85916e4e5c0", size = 267229841, upload-time = "2025-03-07T01:46:54.356Z" }, @@ -4470,7 +4470,7 @@ name = "nvidia-cusparse-cu12" version = "12.5.8.93" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-nvjitlink-cu12", marker = "sys_platform == 'linux' or sys_platform == 'win32' or (extra == 'extra-12-openpipe-art-backend' and extra == 'extra-12-openpipe-art-megatron') or (extra == 'extra-12-openpipe-art-megatron' and extra == 'extra-12-openpipe-art-tinker')" }, + { name = "nvidia-nvjitlink-cu12", marker = "sys_platform == 'linux' or (sys_platform == 'win32' and extra != 'extra-12-openpipe-art-backend' and extra != 'extra-12-openpipe-art-megatron') or (extra == 'extra-12-openpipe-art-backend' and extra == 'extra-12-openpipe-art-megatron') or (extra == 'extra-12-openpipe-art-megatron' and extra == 'extra-12-openpipe-art-tinker')" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/bc/f7/cd777c4109681367721b00a106f491e0d0d15cfa1fd59672ce580ce42a97/nvidia_cusparse_cu12-12.5.8.93-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:9b6c161cb130be1a07a27ea6923df8141f3c295852f4b260c65f18f3e0a091dc", size = 288117129, upload-time = "2025-03-07T01:47:40.407Z" }, @@ -4799,7 +4799,7 @@ megatron = [ { name = "transformer-engine" }, { name = "transformer-engine-cu12" }, { name = "transformer-engine-torch" }, - { name = "transformers", version = "5.6.2", source = { registry = "https://pypi.org/simple" } }, + { name = "transformers", version = "5.12.1", source = { registry = "https://pypi.org/simple" } }, ] plotting = [ { name = "matplotlib" }, @@ -4905,7 +4905,7 @@ requires-dist = [ { name = "transformer-engine-cu12", marker = "extra == 'megatron'", specifier = "==2.11.0" }, { name = "transformer-engine-torch", marker = "extra == 'megatron'", git = "https://github.com/NVIDIA/TransformerEngine.git?subdirectory=transformer_engine%2Fpytorch&rev=v2.11" }, { name = "transformers", marker = "extra == 'backend'", specifier = "==5.2.0" }, - { name = "transformers", marker = "extra == 'megatron'", specifier = "==5.6.2" }, + { name = "transformers", marker = "extra == 'megatron'", specifier = "==5.12.1" }, { name = "transformers", marker = "extra == 'tinker'", specifier = ">=5.2.0,<=5.5.3" }, { name = "trl", marker = "extra == 'backend'", specifier = "==0.20.0" }, { name = "typer", specifier = ">=0.15.2" }, @@ -5236,7 +5236,7 @@ dependencies = [ { name = "torch", version = "2.11.0+cu128", source = { registry = "https://download.pytorch.org/whl/cu128" }, marker = "(sys_platform == 'linux' and extra == 'extra-12-openpipe-art-backend') or (sys_platform == 'linux' and extra == 'extra-12-openpipe-art-megatron') or (sys_platform == 'win32' and extra == 'extra-12-openpipe-art-backend') or (sys_platform == 'win32' and extra == 'extra-12-openpipe-art-megatron') or (extra == 'extra-12-openpipe-art-backend' and extra == 'extra-12-openpipe-art-megatron') or (extra == 'extra-12-openpipe-art-megatron' and extra == 'extra-12-openpipe-art-tinker')" }, { name = "tqdm" }, { name = "transformers", version = "5.2.0", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'extra-12-openpipe-art-backend' or (extra == 'extra-12-openpipe-art-megatron' and extra == 'extra-12-openpipe-art-tinker')" }, - { name = "transformers", version = "5.6.2", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'extra-12-openpipe-art-megatron'" }, + { name = "transformers", version = "5.12.1", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'extra-12-openpipe-art-megatron'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/86/cf/037f1e3d5186496c05513a6754639e2dab3038a05f384284d49a9bd06a2d/peft-0.19.1.tar.gz", hash = "sha256:0d97542fe96dcdaa20d3b81c06f26f988618f416a73544ab23c3618ccb674a40", size = 763738, upload-time = "2026-04-16T15:46:45.105Z" } wheels = [ @@ -7696,16 +7696,16 @@ name = "tilelang" version = "0.1.10" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "apache-tvm-ffi", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "cloudpickle", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "ml-dtypes", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "numpy", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "psutil", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "torch", version = "2.11.0+cu128", source = { registry = "https://download.pytorch.org/whl/cu128" }, marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "torch-c-dlpack-ext", marker = "(python_full_version < '3.14' and sys_platform == 'linux') or (python_full_version < '3.14' and sys_platform == 'win32')" }, - { name = "tqdm", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "typing-extensions", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "z3-solver", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "apache-tvm-ffi", marker = "platform_machine != 's390x' and sys_platform == 'linux'" }, + { name = "cloudpickle", marker = "platform_machine != 's390x' and sys_platform == 'linux'" }, + { name = "ml-dtypes", marker = "platform_machine != 's390x' and sys_platform == 'linux'" }, + { name = "numpy", marker = "platform_machine != 's390x' and sys_platform == 'linux'" }, + { name = "psutil", marker = "platform_machine != 's390x' and sys_platform == 'linux'" }, + { name = "torch", version = "2.11.0+cu128", source = { registry = "https://download.pytorch.org/whl/cu128" }, marker = "platform_machine != 's390x' and sys_platform == 'linux'" }, + { name = "torch-c-dlpack-ext", marker = "python_full_version < '3.14' and platform_machine != 's390x' and sys_platform == 'linux'" }, + { name = "tqdm", marker = "platform_machine != 's390x' and sys_platform == 'linux'" }, + { name = "typing-extensions", marker = "platform_machine != 's390x' and sys_platform == 'linux'" }, + { name = "z3-solver", marker = "platform_machine != 's390x' and sys_platform == 'linux'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/77/5c/07146b4527656102e48d21c2599aa80477e83ea3f149ac0df3b15a247bd4/tilelang-0.1.10.tar.gz", hash = "sha256:d8813e668fcf75843bc2d68c633c352b419c1e292895a6038a4aadd943e56c2b", size = 93184128, upload-time = "2026-05-25T03:58:57.006Z" } wheels = [ @@ -7953,20 +7953,20 @@ resolution-markers = [ "(python_full_version < '3.13' and sys_platform == 'linux' and extra != 'extra-12-openpipe-art-backend' and extra != 'extra-12-openpipe-art-megatron') or (python_full_version < '3.13' and sys_platform == 'win32' and extra != 'extra-12-openpipe-art-backend' and extra != 'extra-12-openpipe-art-megatron')", ] dependencies = [ - { name = "cuda-bindings", marker = "sys_platform == 'linux' or (extra == 'extra-12-openpipe-art-backend' and extra == 'extra-12-openpipe-art-megatron') or (extra == 'extra-12-openpipe-art-megatron' and extra == 'extra-12-openpipe-art-tinker')" }, + { name = "cuda-bindings", marker = "sys_platform == 'linux'" }, { name = "cuda-toolkit", extra = ["cublas", "cudart", "cufft", "cufile", "cupti", "curand", "cusolver", "cusparse", "nvjitlink", "nvrtc", "nvtx"], marker = "sys_platform == 'linux' or (extra == 'extra-12-openpipe-art-backend' and extra == 'extra-12-openpipe-art-megatron') or (extra == 'extra-12-openpipe-art-megatron' and extra == 'extra-12-openpipe-art-tinker')" }, - { name = "filelock", marker = "sys_platform == 'linux' or sys_platform == 'win32' or (extra == 'extra-12-openpipe-art-backend' and extra == 'extra-12-openpipe-art-megatron') or (extra == 'extra-12-openpipe-art-megatron' and extra == 'extra-12-openpipe-art-tinker')" }, - { name = "fsspec", marker = "sys_platform == 'linux' or sys_platform == 'win32' or (extra == 'extra-12-openpipe-art-backend' and extra == 'extra-12-openpipe-art-megatron') or (extra == 'extra-12-openpipe-art-megatron' and extra == 'extra-12-openpipe-art-tinker')" }, - { name = "jinja2", marker = "sys_platform == 'linux' or sys_platform == 'win32' or (extra == 'extra-12-openpipe-art-backend' and extra == 'extra-12-openpipe-art-megatron') or (extra == 'extra-12-openpipe-art-megatron' and extra == 'extra-12-openpipe-art-tinker')" }, - { name = "networkx", marker = "sys_platform == 'linux' or sys_platform == 'win32' or (extra == 'extra-12-openpipe-art-backend' and extra == 'extra-12-openpipe-art-megatron') or (extra == 'extra-12-openpipe-art-megatron' and extra == 'extra-12-openpipe-art-tinker')" }, - { name = "nvidia-cudnn-cu12", marker = "sys_platform == 'linux' or (extra == 'extra-12-openpipe-art-backend' and extra == 'extra-12-openpipe-art-megatron') or (extra == 'extra-12-openpipe-art-megatron' and extra == 'extra-12-openpipe-art-tinker')" }, - { name = "nvidia-cusparselt-cu12", marker = "sys_platform == 'linux' or (extra == 'extra-12-openpipe-art-backend' and extra == 'extra-12-openpipe-art-megatron') or (extra == 'extra-12-openpipe-art-megatron' and extra == 'extra-12-openpipe-art-tinker')" }, - { name = "nvidia-nccl-cu12", marker = "sys_platform == 'linux' or (extra == 'extra-12-openpipe-art-backend' and extra == 'extra-12-openpipe-art-megatron') or (extra == 'extra-12-openpipe-art-megatron' and extra == 'extra-12-openpipe-art-tinker')" }, - { name = "nvidia-nvshmem-cu12", marker = "sys_platform == 'linux' or (extra == 'extra-12-openpipe-art-backend' and extra == 'extra-12-openpipe-art-megatron') or (extra == 'extra-12-openpipe-art-megatron' and extra == 'extra-12-openpipe-art-tinker')" }, - { name = "setuptools", marker = "sys_platform == 'linux' or sys_platform == 'win32' or (extra == 'extra-12-openpipe-art-backend' and extra == 'extra-12-openpipe-art-megatron') or (extra == 'extra-12-openpipe-art-megatron' and extra == 'extra-12-openpipe-art-tinker')" }, - { name = "sympy", marker = "sys_platform == 'linux' or sys_platform == 'win32' or (extra == 'extra-12-openpipe-art-backend' and extra == 'extra-12-openpipe-art-megatron') or (extra == 'extra-12-openpipe-art-megatron' and extra == 'extra-12-openpipe-art-tinker')" }, - { name = "triton", marker = "sys_platform == 'linux' or (extra == 'extra-12-openpipe-art-backend' and extra == 'extra-12-openpipe-art-megatron') or (extra == 'extra-12-openpipe-art-megatron' and extra == 'extra-12-openpipe-art-tinker')" }, - { name = "typing-extensions", marker = "sys_platform == 'linux' or sys_platform == 'win32' or (extra == 'extra-12-openpipe-art-backend' and extra == 'extra-12-openpipe-art-megatron') or (extra == 'extra-12-openpipe-art-megatron' and extra == 'extra-12-openpipe-art-tinker')" }, + { name = "filelock", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "fsspec", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "jinja2", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "networkx", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "nvidia-cudnn-cu12", marker = "sys_platform == 'linux'" }, + { name = "nvidia-cusparselt-cu12", marker = "sys_platform == 'linux'" }, + { name = "nvidia-nccl-cu12", marker = "sys_platform == 'linux'" }, + { name = "nvidia-nvshmem-cu12", marker = "sys_platform == 'linux'" }, + { name = "setuptools", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "sympy", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "triton", marker = "sys_platform == 'linux'" }, + { name = "typing-extensions", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, ] wheels = [ { url = "https://download-r2.pytorch.org/whl/cu128/torch-2.11.0%2Bcu128-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:9c8f38efee365cb9d334de8a83ce52fc7e5fc9e5a7b0853285efa1b69e00b0f2", upload-time = "2026-04-27T17:41:30Z" }, @@ -8178,7 +8178,7 @@ wheels = [ [[package]] name = "transformers" -version = "5.6.2" +version = "5.12.1" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.14' and platform_machine != 's390x' and sys_platform == 'linux'", @@ -8211,9 +8211,9 @@ dependencies = [ { name = "tqdm", marker = "extra == 'extra-12-openpipe-art-megatron'" }, { name = "typer", marker = "extra == 'extra-12-openpipe-art-megatron'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a4/e9/c6c80a07690142a7d05444271f47b9f3c8aac7dea01d52e1137ee480ad78/transformers-5.6.2.tar.gz", hash = "sha256:e657134c3e5a6bc00a3c35f4e2674bb51adfcd89898495b788a18552bac2b91a", size = 8311867, upload-time = "2026-04-23T18:33:29.332Z" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/7c/8240f612819718100a9346dc28dea6a11370c3ca9c8c6eabadd3dea4ef29/transformers-5.12.1.tar.gz", hash = "sha256:679ee731c8225347889ad4fb3b2c926a62e9da3b7d284e9d12c791da7272466b", size = 8924054, upload-time = "2026-06-15T17:27:50.604Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5d/95/0b0218149b0d6f14df35f5b8f676fa83df4f19ed253c3cc447107ef86eca/transformers-5.6.2-py3-none-any.whl", hash = "sha256:f8d3a1bb96778fed9b8aabfd0dd6e19843e4b0f2bb6b59f32b8a92051b0f348f", size = 10364898, upload-time = "2026-04-23T18:33:26.081Z" }, + { url = "https://files.pythonhosted.org/packages/df/56/bbd60dd8668055803bf8ba55a81f9b8a8b31497f620109a9671d26a2076d/transformers-5.12.1-py3-none-any.whl", hash = "sha256:2a5e109d2021265df7098ffbb738295acaf5ad256f12cbc586db2ea4dcbb1a8a", size = 11150587, upload-time = "2026-06-15T17:27:46.679Z" }, ] [[package]] diff --git a/vllm_runtime/pyproject.toml b/vllm_runtime/pyproject.toml index 673f66585..412a9c1c1 100644 --- a/vllm_runtime/pyproject.toml +++ b/vllm_runtime/pyproject.toml @@ -5,7 +5,7 @@ description = "Tiny ART-owned vLLM runtime package" requires-python = ">=3.12,<3.13" dependencies = [ "nvidia-nccl-cu12==2.28.9 ; sys_platform == 'linux'", - "transformers==5.6.2", + "transformers==5.12.1", "vllm @ https://github.com/vllm-project/vllm/releases/download/v0.23.0/vllm-0.23.0%2Bcu129-cp38-abi3-manylinux_2_28_x86_64.whl ; sys_platform == 'linux'", ] @@ -37,5 +37,5 @@ override-dependencies = [ "torch @ https://download.pytorch.org/whl/test/cu128/torch-2.11.0%2Bcu128-cp312-cp312-manylinux_2_28_x86_64.whl", "torchaudio @ https://download.pytorch.org/whl/test/cu128/torchaudio-2.11.0%2Bcu128-cp312-cp312-manylinux_2_28_x86_64.whl", "torchvision @ https://download.pytorch.org/whl/test/cu128/torchvision-0.26.0%2Bcu128-cp312-cp312-manylinux_2_28_x86_64.whl", - "transformers==5.6.2", + "transformers==5.12.1", ] diff --git a/vllm_runtime/src/art_vllm_runtime/dedicated_server.py b/vllm_runtime/src/art_vllm_runtime/dedicated_server.py index 856c055b9..535960338 100644 --- a/vllm_runtime/src/art_vllm_runtime/dedicated_server.py +++ b/vllm_runtime/src/art_vllm_runtime/dedicated_server.py @@ -15,7 +15,7 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace: parser.add_argument("--port", type=int, required=True) parser.add_argument("--host", default="127.0.0.1") parser.add_argument("--cuda-visible-devices", required=True) - parser.add_argument("--lora-path", required=True, help="Initial checkpoint path") + parser.add_argument("--lora-path", help="Optional initial checkpoint path") parser.add_argument("--served-model-name", required=True) parser.add_argument( "--rollout-weights-mode", @@ -139,6 +139,8 @@ def _append_cli_arg(vllm_args: list[str], key: str, value: object) -> None: def main(argv: list[str] | None = None) -> None: args = parse_args(argv) + if args.rollout_weights_mode == "merged" and not args.lora_path: + raise SystemExit("--lora-path is required for merged rollout weights") engine_args = json.loads(args.engine_args_json) server_args = json.loads(args.server_args_json) @@ -164,12 +166,11 @@ def main(argv: list[str] | None = None) -> None: f"--served-model-name={args.served_model_name}", ] if args.rollout_weights_mode == "lora": - vllm_args.extend( - [ - "--enable-lora", - f"--lora-modules={args.served_model_name}={args.lora_path}", - ] - ) + vllm_args.append("--enable-lora") + if args.lora_path: + vllm_args.append( + f"--lora-modules={args.served_model_name}={args.lora_path}" + ) for extra_args in (engine_args, server_args): for key, value in extra_args.items(): _append_cli_arg(vllm_args, key, value) diff --git a/vllm_runtime/src/art_vllm_runtime/dsv4_patches.py b/vllm_runtime/src/art_vllm_runtime/dsv4_patches.py new file mode 100644 index 000000000..383bfe6ca --- /dev/null +++ b/vllm_runtime/src/art_vllm_runtime/dsv4_patches.py @@ -0,0 +1,1603 @@ +"""DSV4-specific monkey patches for the ART-owned vLLM runtime.""" + +import importlib +from typing import Any + + +def apply_dsv4_vllm_runtime_patches() -> None: + patch_layerwise_reload_shadow_attrs() + patch_dsv4_attn_sink_layerwise_reload() + patch_dsv4_mhc_pre_fixed_split() + patch_dsv4_lora_support() + patch_dsv4_mla_lora_aliases() + patch_dsv4_fast_path_lora() + patch_dsv4_triton_moe_topk6_routing() + patch_lora_linear_base_attr_proxy() + patch_marlin_lora_swiglu_limit() + + +def _drop_reload_shadow_attrs(layer: Any, names: Any) -> None: + for name in names: + if ( + name in getattr(layer, "__dict__", {}) + and name not in layer._parameters + and name not in layer._buffers + and name not in layer._modules + ): + delattr(layer, name) + + +def patch_layerwise_reload_shadow_attrs() -> None: + """Allow vLLM layerwise reload to restore processed DSV4 MegaMoE params. + + DeepSeek V4 MegaMoE drops loader-side Parameters after transforming them for + DeepGEMM. Some vLLM builds leave same-name plain attributes behind; PyTorch + then rejects register_parameter during the next checkpoint-format reload. + """ + from vllm.model_executor.model_loader.reload import layerwise, meta + + if getattr(meta, "_art_reload_shadow_attrs_patched", False): + return + + original_restore_layer_on_meta = meta.restore_layer_on_meta + original_place_kernel_tensors = layerwise._place_kernel_tensors + + def restore_layer_on_meta(layer: Any, info: Any) -> None: + restore_params, restore_buffers = info.restore_metadata + _drop_reload_shadow_attrs(layer, tuple(restore_params) + tuple(restore_buffers)) + return original_restore_layer_on_meta(layer, info) + + def _place_kernel_tensors(layer: Any, info: Any) -> None: + assert info.kernel_tensors is not None + parameters, buffers = info.kernel_tensors + _drop_reload_shadow_attrs(layer, tuple(parameters) + tuple(buffers)) + return original_place_kernel_tensors(layer, info) + + restore_layer_on_meta.__art_patched__ = True # type: ignore[attr-defined] + _place_kernel_tensors.__art_patched__ = True # type: ignore[attr-defined] + meta.restore_layer_on_meta = restore_layer_on_meta # type: ignore[method-assign] + layerwise.restore_layer_on_meta = restore_layer_on_meta # type: ignore[method-assign] + layerwise._place_kernel_tensors = _place_kernel_tensors # type: ignore[method-assign] + setattr(meta, "_art_reload_shadow_attrs_patched", True) + + +def _import_dsv4_model_module() -> Any | None: + for module_name in ( + "vllm.model_executor.models.deepseek_v4", + "vllm.models.deepseek_v4.nvidia.model", + ): + try: + return importlib.import_module(module_name) + except ImportError: + continue + return None + + +def patch_dsv4_attn_sink_layerwise_reload() -> None: + """Route DSV4 attention-sink loads through vLLM's layerwise loader. + + Merged-weight transfer uses vLLM checkpoint-format reload. During that path, + every loadable parameter must be applied through its `weight_loader`; direct + `copy_` into `attn_sink` bypasses layerwise accounting and finalize restores + the old kernel tensor. With `load_format=dummy`, that old tensor is the + initialized sink, not the checkpoint sink. + """ + dsv4_model = _import_dsv4_model_module() + if dsv4_model is None: + return + from vllm.model_executor.models.utils import is_pp_missing_parameter + + model_cls = getattr(dsv4_model, "DeepseekV4Model", None) + if model_cls is None: + return + original = model_cls.load_weights + if getattr(original, "__art_patched__", False): + return + + def load_weights(self: Any, weights: Any) -> set[str]: + stacked_params_mapping = [ + ("gate_up_proj", "w1", 0), + ("gate_up_proj", "w3", 1), + ("attn.fused_wqa_wkv", "attn.wq_a", 0), + ("attn.fused_wqa_wkv", "attn.wkv", 1), + ("compressor.fused_wkv_wgate", "compressor.wkv", 0), + ("compressor.fused_wkv_wgate", "compressor.wgate", 1), + ] + params_dict = dict(self.named_parameters()) + loaded_params: set[str] = set() + + tp_size = dsv4_model.get_tensor_model_parallel_world_size() + tp_rank = dsv4_model.get_tensor_model_parallel_rank() + n_head = self.config.num_attention_heads + n_local_head = n_head // tp_size + head_rank_start = n_local_head * tp_rank + head_rank_end = n_local_head * (tp_rank + 1) + expert_mapping = self.get_expert_mapping() + + for name, loaded_weight in weights: + for param_name, weight_name, shard_id in stacked_params_mapping: + if ".experts." in name: + continue + if weight_name not in name: + continue + name = name.replace(weight_name, param_name) + + if is_pp_missing_parameter(name, self): + break + param = params_dict[name] + param.weight_loader(param, loaded_weight, shard_id) + loaded_params.add(name) + break + else: + if ".experts." in name: + if ( + "weight_scale" in name + and loaded_weight.dtype == dsv4_model.torch.float8_e8m0fnu + ): + loaded_weight = loaded_weight.view(dsv4_model.torch.uint8) + for mapping in expert_mapping: + param_name, weight_name, expert_id, expert_shard_id = mapping + if weight_name not in name: + continue + name_mapped = name.replace(weight_name, param_name) + if is_pp_missing_parameter(name_mapped, self): + continue + param = params_dict[name_mapped] + success = param.weight_loader( + param, + loaded_weight, + name_mapped, + shard_id=expert_shard_id, + expert_id=expert_id, + return_success=True, + ) + if success: + name = name_mapped + break + loaded_params.add(name_mapped) + continue + if "attn_sink" in name: + if is_pp_missing_parameter(name, self): + continue + param = params_dict[name] + narrow_weight = loaded_weight[head_rank_start:head_rank_end] + padded_weight = loaded_weight.new_full( + tuple(param.shape), -float("inf") + ) + padded_weight[: narrow_weight.shape[0]].copy_(narrow_weight) + weight_loader = getattr( + param, "weight_loader", dsv4_model.default_weight_loader + ) + weight_loader(param, padded_weight) + loaded_params.add(name) + continue + + if is_pp_missing_parameter(name, self): + continue + param = params_dict[name] + weight_loader = getattr( + param, "weight_loader", dsv4_model.default_weight_loader + ) + weight_loader(param, loaded_weight) + loaded_params.add(name) + + return loaded_params + + load_weights.__art_patched__ = True # type: ignore[attr-defined] + model_cls.load_weights = load_weights # type: ignore[method-assign] + + +def patch_dsv4_mhc_pre_fixed_split() -> None: + """Make DSV4 mHC pre reductions invariant to total prefill length. + + vLLM's current DSV4 mHC pre path chooses split-K from ``num_tokens``. The + same prompt prefix can therefore use a different reduction tree when a + suffix is appended, producing float32-level post/comb mix drift that becomes + bf16 output drift and later MoE route changes. Pinning the DSV4 prenorm + shape to the TileLang kernel default split count keeps the reduction plan + stable without changing model math. + """ + try: + mhc = importlib.import_module("vllm.model_executor.layers.mhc") + except ImportError: + return + original = getattr(mhc, "compute_num_split", None) + if original is None or getattr(original, "__art_dsv4_fixed_split_patched__", False): + return + + def compute_num_split(block_k: int, k: int | None, grid_size: int) -> int: + if block_k == 64 and k == 16_384: + return 16 + return original(block_k, k, grid_size) + + compute_num_split.__art_dsv4_fixed_split_patched__ = True # type: ignore[attr-defined] + mhc.compute_num_split = compute_num_split + + +def patch_dsv4_lora_support() -> None: + """Enable vLLM's existing LoRA manager for ART-served DSV4. + + DSV4 itself does not need a custom LoRA executor here. Once the model + advertises packed MLA/shared-expert modules and MoE expert children, vLLM + wraps the same FusedMoE module it already uses for serving. With LoRA + enabled, vLLM's modular MoE selector picks Marlin, whose expert backend + supports fused MoE LoRA. Do not point this patch at the FlashInfer TRTLLM + MXFP4 backend; that backend currently has no LoRA hooks. + """ + dsv4_model = _import_dsv4_model_module() + if dsv4_model is None: + return + model_cls = getattr(dsv4_model, "DeepseekV4ForCausalLM", None) + if model_cls is None or getattr(model_cls, "_art_dsv4_lora_patched", False): + return + model_cls.supports_lora = True + model_cls.embedding_modules = {} + model_cls.packed_modules_mapping = { + "fused_wqa_wkv": ["wq_a", "wkv"], + "fused_wkv_wgate": ["wkv", "wgate"], + "gate_up_proj": ["gate_proj", "up_proj"], + } + model_cls.is_3d_moe_weight = False + model_cls.is_non_gated_moe = False + model_cls.lora_skip_prefixes = ["mtp", "indexer"] + model_cls._art_dsv4_lora_patched = True + _patch_dsv4_lora_manager_indexer_skip(model_cls) + + +def _patch_dsv4_lora_manager_indexer_skip(model_cls: type) -> None: + from vllm.lora.model_manager import LoRAModelManager + + original = LoRAModelManager._match_target_modules + if getattr(original, "__art_dsv4_indexer_skip_patched__", False): + return + + duplicate_mla_aliases = {"fused_wqa_wkv", "wq_b", "wo_a", "wo_b"} + + def _match_target_modules(self: Any, module_name: str) -> bool: + if isinstance(self.model, model_cls) and ".indexer." in module_name: + return False + if ( + isinstance(self.model, model_cls) + and ".attn.mla_attn." in module_name + and module_name.rsplit(".", 1)[-1] in duplicate_mla_aliases + ): + return False + return original(self, module_name) + + _match_target_modules.__art_dsv4_indexer_skip_patched__ = True # type: ignore[attr-defined] + LoRAModelManager._match_target_modules = _match_target_modules # type: ignore[method-assign] + + +def patch_dsv4_mla_lora_aliases() -> None: + """Keep DSV4 MLA wrapper references aligned with vLLM LoRA replacements. + + DeepSeek V4 stores direct references to several attention linears inside + ``mla_attn`` during model construction. vLLM's LoRA manager later replaces + the canonical modules on ``attn``. Without refreshing the aliases, the DSV4 + custom attention path keeps calling the original unwrapped linears. + """ + from vllm.lora.model_manager import LoRAModelManager + + original = LoRAModelManager.register_module + if getattr(original, "__art_dsv4_mla_alias_patched__", False): + return + + alias_names = {"fused_wqa_wkv", "wq_b", "wo_a", "wo_b"} + + def register_module(self: Any, module_name: str, module: Any) -> Any: + result = original(self, module_name, module) + leaf = module_name.rsplit(".", 1)[-1] + if leaf not in alias_names: + return result + parent_name, _, _ = module_name.rpartition(".") + if parent_name.endswith(".mla_attn"): + mla_name = parent_name + else: + mla_name = f"{parent_name}.mla_attn" + try: + mla_attn = self.model.get_submodule(mla_name) + except AttributeError: + return result + if mla_attn is not None and hasattr(mla_attn, leaf): + setattr(mla_attn, leaf, module) + return result + + register_module.__art_dsv4_mla_alias_patched__ = True # type: ignore[attr-defined] + LoRAModelManager.register_module = register_module # type: ignore[method-assign] + + +def _is_lora_wrapped_linear(module: Any) -> bool: + return all( + hasattr(module, name) + for name in ("lora_a_stacked", "lora_b_stacked", "punica_wrapper") + ) + + +def _apply_lora_to_existing_linear_output( + module: Any, + x: Any, + output: Any, +) -> Any: + if not _is_lora_wrapped_linear(module): + return output + wrapper = module.punica_wrapper + if getattr(wrapper, "no_lora", False): + return output + if getattr(wrapper, "indices_len", [None])[0] is None: + return output + return module._apply_lora_to_output(x, output) + + +def _register_dsv4_lora_expand_fp32_output_op() -> None: + if getattr(_register_dsv4_lora_expand_fp32_output_op, "_registered", False): + return + + import torch + from vllm import envs + from vllm.lora.ops.triton_ops.utils import get_lora_op_configs, supports_pdl + from vllm.triton_utils import tl, triton + from vllm.utils.torch_utils import direct_register_custom_op + + @triton.jit + def _kernel( + input_ptr, + lora_b_ptr, + output_ptr, + token_indices_sorted_by_lora_ids, + num_tokens_per_lora, + lora_token_start_loc, + lora_ids, + M, + slice_offset, + input_stride_m, + input_stride_k, + lora_stride_lora, + lora_stride_out, + lora_stride_k, + output_stride_m, + output_stride_n, + RANK: tl.constexpr, + OUT_WIDTH: tl.constexpr, + BLOCK_M: tl.constexpr, + BLOCK_N: tl.constexpr, + BLOCK_K: tl.constexpr, + USE_GDC: tl.constexpr, + launch_pdl: tl.constexpr, + ): + cta_n_num = tl.cdiv(OUT_WIDTH, BLOCK_N) + cta_m_num = tl.cdiv(M, BLOCK_M) + pid_mn = tl.program_id(0) + pid_m = pid_mn % cta_m_num + pid_n = (pid_mn // cta_m_num) % cta_n_num + lora_idx = tl.program_id(1) + + lora_id = tl.load(lora_ids + lora_idx) + if lora_id == -1: + return + + lora_m_size = tl.load(num_tokens_per_lora + lora_idx) + cta_m_offset = pid_m * BLOCK_M + if cta_m_offset >= lora_m_size: + return + + cta_m_len = min(BLOCK_M, lora_m_size - cta_m_offset) + lora_m_start = tl.load(lora_token_start_loc + lora_idx) + row_ptr = token_indices_sorted_by_lora_ids + lora_m_start + cta_m_offset + row_offsets = tl.arange(0, BLOCK_M) % cta_m_len + rows = tl.load(row_ptr + row_offsets) + + offs_n = tl.arange(0, BLOCK_N) + pid_n * BLOCK_N + accumulator = tl.zeros((BLOCK_M, BLOCK_N), dtype=tl.float32) + for k_start in range(0, RANK, BLOCK_K): + offs_k = tl.arange(0, BLOCK_K) + k_start + x = tl.load( + input_ptr + + rows[:, None] * input_stride_m + + offs_k[None, :] * input_stride_k, + mask=(row_offsets[:, None] < cta_m_len) & (offs_k[None, :] < RANK), + other=0.0, + ) + if USE_GDC: + tl.extra.cuda.gdc_wait() + b = tl.load( + lora_b_ptr + + lora_id * lora_stride_lora + + offs_n[None, :] * lora_stride_out + + offs_k[:, None] * lora_stride_k, + mask=(offs_k[:, None] < RANK) & (offs_n[None, :] < OUT_WIDTH), + other=0.0, + ).to(tl.float32) + accumulator += tl.dot(x, b) + + out_cols = slice_offset + offs_n + out_ptrs = ( + output_ptr + + rows[:, None] * output_stride_m + + out_cols[None, :] * output_stride_n + ) + mask = (row_offsets[:, None] < cta_m_len) & (offs_n[None, :] < OUT_WIDTH) + old = tl.load(out_ptrs, mask=mask) + tl.store(out_ptrs, old + accumulator, mask=mask) + + @torch.inference_mode() + def _impl( + inputs: torch.Tensor, + lora_b_weight: torch.Tensor, + output_tensor: torch.Tensor, + token_indices_sorted_by_lora_ids: torch.Tensor, + num_tokens_per_lora: torch.Tensor, + lora_token_start_loc: torch.Tensor, + lora_ids: torch.Tensor, + no_lora_flag_cpu: torch.Tensor, + num_active_loras: torch.Tensor, + slice_offset: int, + ) -> None: + assert no_lora_flag_cpu.numel() == 1 + if no_lora_flag_cpu.item(): + return + assert inputs.dtype == torch.float32 + assert output_tensor.dtype == torch.float32 + assert output_tensor.is_contiguous() + assert lora_b_weight.ndim == 4 + assert lora_b_weight.size(1) == 1 + assert lora_b_weight.is_contiguous() + + m = inputs.size(0) + out_width = lora_b_weight.size(2) + rank = lora_b_weight.size(3) + kernel_config = get_lora_op_configs( + op_type="expand", + max_loras=lora_ids.size(0), + batch=m, + hidden_size=out_width, + rank=rank, + num_slices=1, + add_inputs=True, + ) + block_m = kernel_config["block_m"] + block_n = kernel_config["block_n"] + block_k = kernel_config["block_k"] + use_gdc = supports_pdl(inputs.device) and envs.VLLM_LORA_ENABLE_DUAL_STREAM + grid = ( + triton.cdiv(m, block_m) * triton.cdiv(out_width, block_n), + num_active_loras.item(), + ) + _kernel[grid]( + inputs, + lora_b_weight, + output_tensor, + token_indices_sorted_by_lora_ids, + num_tokens_per_lora, + lora_token_start_loc, + lora_ids, + m, + slice_offset, + inputs.stride(0), + inputs.stride(1), + lora_b_weight.stride(0), + lora_b_weight.stride(2), + lora_b_weight.stride(3), + output_tensor.stride(0), + output_tensor.stride(1), + RANK=rank, + OUT_WIDTH=out_width, + BLOCK_M=block_m, + BLOCK_N=block_n, + BLOCK_K=block_k, + USE_GDC=use_gdc, + num_warps=kernel_config["num_warps"], + num_ctas=kernel_config["num_ctas"], + num_stages=kernel_config["num_stages"], + launch_pdl=use_gdc, + ) + + def _fake( + inputs: torch.Tensor, + lora_b_weight: torch.Tensor, + output_tensor: torch.Tensor, + token_indices_sorted_by_lora_ids: torch.Tensor, + num_tokens_per_lora: torch.Tensor, + lora_token_start_loc: torch.Tensor, + lora_ids: torch.Tensor, + no_lora_flag_cpu: torch.Tensor, + num_active_loras: torch.Tensor, + slice_offset: int, + ) -> None: + return None + + direct_register_custom_op( + op_name="art_dsv4_lora_expand_fp32_output", + op_func=_impl, + mutates_args=["output_tensor"], + fake_impl=_fake, + ) + _register_dsv4_lora_expand_fp32_output_op._registered = True # type: ignore[attr-defined] + + +def _apply_dsv4_compressor_lora_to_existing_output( + module: Any, + x: Any, + output: Any, +) -> Any: + if not _is_active_lora_wrapped_linear(module): + return output + + import torch + from vllm.platforms import current_platform + + _register_dsv4_lora_expand_fp32_output_op() + original_shape = output.shape if output.ndim == 3 else None + if x.ndim == 3 and output.ndim == 3: + x = x.flatten(0, 1) + output = output.flatten(0, 1) + if x.ndim != 2 or output.ndim != 2: + raise RuntimeError( + "DSV4 compressor LoRA expects 2D hidden/output tensors, got " + f"x={tuple(x.shape)}, output={tuple(output.shape)}." + ) + if output.dtype != torch.float32: + raise RuntimeError( + "DSV4 compressor LoRA must preserve the fp32 compressor projection, " + f"got output dtype {output.dtype}." + ) + if getattr(module, "tp_size", 1) != 1: + raise RuntimeError( + "DSV4 compressor LoRA expects disable_tp compressor linears." + ) + + wrapper = module.punica_wrapper + local_rank = module.lora_a_stacked[0].shape[2] + buffers = torch.empty_strided( + (len(module.output_slices), x.shape[0], local_rank), + (x.shape[0] * local_rank, local_rank, 1), + dtype=torch.float32, + device=x.device, + ) + shrunk = wrapper.add_shrink(buffers, x, module.lora_a_stacked, 1.0) + if not current_platform.can_update_inplace(): + buffers = shrunk + + ( + _, + token_indices_sorted_by_lora_ids, + num_tokens_per_lora, + lora_token_start_loc, + lora_ids, + no_lora_flag_cpu, + num_active_loras, + ) = wrapper.token_mapping_meta.meta_args( + x.shape[0], wrapper.lora_config.specialize_active_lora + ) + offset = 0 + for idx, width in enumerate(module.output_slices): + torch.ops.vllm.art_dsv4_lora_expand_fp32_output( + buffers[idx], + module.lora_b_stacked[idx], + output, + token_indices_sorted_by_lora_ids, + num_tokens_per_lora, + lora_token_start_loc, + lora_ids, + no_lora_flag_cpu, + num_active_loras, + offset, + ) + offset += width + if offset != output.shape[-1]: + raise RuntimeError( + "DSV4 compressor LoRA output slice width mismatch: " + f"slices={module.output_slices}, output={tuple(output.shape)}." + ) + return output.reshape(original_shape) if original_shape is not None else output + + +def _is_active_lora_wrapped_linear(module: Any) -> bool: + if not _is_lora_wrapped_linear(module): + return False + wrapper = module.punica_wrapper + return not getattr(wrapper, "no_lora", False) and ( + getattr(wrapper, "indices_len", [None])[0] is not None + ) + + +def _register_dsv4_inv_rope_lora_input_op() -> None: + if getattr(_register_dsv4_inv_rope_lora_input_op, "_registered", False): + return + + import torch + from vllm.platforms import current_platform + from vllm.triton_utils import tl, triton + from vllm.utils.torch_utils import direct_register_custom_op + + @triton.jit + def _kernel( + o_ptr, + positions_ptr, + cos_sin_cache_ptr, + fp8_ptr, + scale_ptr, + lora_input_ptr, + num_tokens, + heads_per_group: tl.constexpr, + o_stride_token, + o_stride_head, + cache_stride_pos, + fp8_stride_group, + fp8_stride_token, + scale_stride_group, + scale_stride_k, + lora_stride_group, + lora_stride_token, + fp8_max: tl.constexpr, + eps: tl.constexpr, + QUANT_GROUP_SIZE: tl.constexpr, + CHUNKS_PER_HEAD: tl.constexpr, + ROPE_START: tl.constexpr, + HALF_ROPE: tl.constexpr, + TMA_ALIGNED_SCALES: tl.constexpr, + ): + pid_token = tl.program_id(0).to(tl.int64) + pid_gh = tl.program_id(1).to(tl.int64) + g = pid_gh // heads_per_group + head_in_group = pid_gh % heads_per_group + qb_start = head_in_group * CHUNKS_PER_HEAD + + if pid_token >= num_tokens: + if TMA_ALIGNED_SCALES: + scale_addr = ( + scale_ptr + + g * scale_stride_group + + pid_token + + head_in_group * scale_stride_k + ) + tl.store(scale_addr, tl.zeros((), dtype=tl.int32)) + else: + block_offsets = tl.arange(0, CHUNKS_PER_HEAD) + qb_indices = qb_start + block_offsets + scale_addrs = ( + scale_ptr + + g * scale_stride_group + + pid_token + + qb_indices * scale_stride_k + ) + tl.store(scale_addrs, tl.zeros((CHUNKS_PER_HEAD,), dtype=tl.float32)) + return + + head_dim: tl.constexpr = CHUNKS_PER_HEAD * QUANT_GROUP_SIZE + offsets = tl.arange(0, head_dim) + input_base = o_ptr + pid_token * o_stride_token + pid_gh * o_stride_head + x = tl.load(input_base + offsets).to(tl.float32) + + rope_abs_start: tl.constexpr = ( + CHUNKS_PER_HEAD - 1 + ) * QUANT_GROUP_SIZE + ROPE_START + pos = tl.load(positions_ptr + pid_token) + cache_base = cos_sin_cache_ptr + pos * cache_stride_pos + is_rope = offsets >= rope_abs_start + rope_local = offsets - rope_abs_start + + x_partner = tl.load(input_base + (offsets ^ 1), mask=is_rope, other=0.0).to( + tl.float32 + ) + cs_idx = tl.maximum(rope_local >> 1, 0) + cos_v = tl.load(cache_base + cs_idx, mask=is_rope, other=1.0) + sin_v = tl.load(cache_base + HALF_ROPE + cs_idx, mask=is_rope, other=0.0) + x_add = x * cos_v + x_partner * sin_v + x_sub = x * cos_v - x_partner * sin_v + is_even = (rope_local & 1) == 0 + x = tl.where(is_rope, tl.where(is_even, x_add, x_sub), x) + + group_head_offset = head_in_group * head_dim + lora_base = ( + lora_input_ptr + + g * lora_stride_group + + pid_token * lora_stride_token + + group_head_offset + ) + tl.store(lora_base + offsets, x) + + x_2d = tl.reshape(tl.abs(x), (CHUNKS_PER_HEAD, QUANT_GROUP_SIZE)) + block_absmax = tl.maximum(tl.max(x_2d, axis=1), eps) + scale_raw = block_absmax * (1.0 / fp8_max) + scales = tl.math.exp2(tl.ceil(tl.log2(scale_raw))) + scales_exp = tl.reshape( + tl.broadcast_to( + tl.reshape(scales, (CHUNKS_PER_HEAD, 1)), + (CHUNKS_PER_HEAD, QUANT_GROUP_SIZE), + ), + (head_dim,), + ) + x_quant = tl.clamp(x / scales_exp, -fp8_max, fp8_max).to(tl.float8e4nv) + + fp8_base = ( + fp8_ptr + + g * fp8_stride_group + + pid_token * fp8_stride_token + + qb_start * QUANT_GROUP_SIZE + ) + tl.store(fp8_base + offsets, x_quant) + + block_offsets = tl.arange(0, CHUNKS_PER_HEAD) + qb_indices = qb_start + block_offsets + if TMA_ALIGNED_SCALES: + scale_bits = scales.to(tl.int32, bitcast=True) + ue8m0_bytes = (scale_bits >> 23) & 0xFF + packed_val = tl.sum(ue8m0_bytes << (block_offsets * 8)) + scale_addr = ( + scale_ptr + + g * scale_stride_group + + pid_token + + head_in_group * scale_stride_k + ) + tl.store(scale_addr, packed_val) + else: + scale_addrs = ( + scale_ptr + + g * scale_stride_group + + pid_token + + qb_indices * scale_stride_k + ) + tl.store(scale_addrs, scales) + + def _impl( + o: torch.Tensor, + positions: torch.Tensor, + cos_sin_cache: torch.Tensor, + fp8_buf: torch.Tensor, + scale_buf: torch.Tensor, + lora_input: torch.Tensor, + heads_per_group: int, + quant_group_size: int, + chunks_per_head: int, + rope_start: int, + half_rope: int, + tma_aligned_scales: bool, + fp8_max: float, + tma_aligned_t: int, + num_tokens: int, + ) -> None: + grid = (tma_aligned_t, fp8_buf.shape[0] * heads_per_group) + pdl_kwargs = {} if current_platform.is_rocm() else {"launch_pdl": False} + _kernel[grid]( + o, + positions, + cos_sin_cache, + fp8_buf, + scale_buf, + lora_input, + num_tokens, + heads_per_group=heads_per_group, + o_stride_token=o.stride(0), + o_stride_head=o.stride(1), + cache_stride_pos=cos_sin_cache.stride(0), + fp8_stride_group=fp8_buf.stride(0), + fp8_stride_token=fp8_buf.stride(1), + scale_stride_group=scale_buf.stride(0), + scale_stride_k=scale_buf.stride(2), + lora_stride_group=lora_input.stride(0), + lora_stride_token=lora_input.stride(1), + fp8_max=fp8_max, + eps=1e-10, + QUANT_GROUP_SIZE=quant_group_size, + CHUNKS_PER_HEAD=chunks_per_head, + ROPE_START=rope_start, + HALF_ROPE=half_rope, + TMA_ALIGNED_SCALES=tma_aligned_scales, + num_stages=1, + num_warps=1, + **pdl_kwargs, + ) + + def _fake( + o: torch.Tensor, + positions: torch.Tensor, + cos_sin_cache: torch.Tensor, + fp8_buf: torch.Tensor, + scale_buf: torch.Tensor, + lora_input: torch.Tensor, + heads_per_group: int, + quant_group_size: int, + chunks_per_head: int, + rope_start: int, + half_rope: int, + tma_aligned_scales: bool, + fp8_max: float, + tma_aligned_t: int, + num_tokens: int, + ) -> None: + return None + + direct_register_custom_op( + op_name="art_dsv4_inv_rope_fp8_quant_lora_input", + op_func=_impl, + mutates_args=["fp8_buf", "scale_buf", "lora_input"], + fake_impl=_fake, + ) + _register_dsv4_inv_rope_lora_input_op._registered = True # type: ignore[attr-defined] + + +def _dsv4_fused_inv_rope_fp8_quant_with_lora_input( + dsv4_attn: Any, + o: Any, + positions: Any, + cos_sin_cache: Any, + *, + n_groups: int, + heads_per_group: int, + lora_dtype: Any, + nope_dim: int = 448, + rope_dim: int = 64, + quant_group_size: int = 128, + tma_aligned_scales: bool = False, +) -> tuple[Any, Any, Any]: + import torch + from vllm.utils.deep_gemm import get_tma_aligned_size + + num_tokens, num_heads, head_dim = o.shape + assert num_heads == n_groups * heads_per_group + assert head_dim == nope_dim + rope_dim + assert head_dim % quant_group_size == 0 + assert nope_dim % quant_group_size == (quant_group_size - rope_dim) + assert rope_dim % 2 == 0 + assert cos_sin_cache.shape[-1] == rope_dim + assert cos_sin_cache.dtype == torch.float32 + + d = heads_per_group * head_dim + num_scale_blocks = d // quant_group_size + chunks_per_head = head_dim // quant_group_size + fp8_dtype = torch.float8_e4m3fn + tma_aligned_t = get_tma_aligned_size(num_tokens, 4) + scale_inner = ( + (num_scale_blocks + 3) // 4 if tma_aligned_scales else num_scale_blocks + ) + + fp8_buf = torch.empty((n_groups, num_tokens, d), dtype=fp8_dtype, device=o.device) + scale_dtype = torch.int32 if tma_aligned_scales else torch.float32 + scale_buf = torch.empty( + n_groups * scale_inner * tma_aligned_t, + dtype=scale_dtype, + device=o.device, + ).as_strided( + (n_groups, num_tokens, scale_inner), + (scale_inner * tma_aligned_t, 1, tma_aligned_t), + ) + lora_input = torch.empty( + (n_groups, num_tokens, d), + dtype=lora_dtype, + device=o.device, + ) + dsv4_attn.torch.ops.vllm.art_dsv4_inv_rope_fp8_quant_lora_input( + o, + positions, + cos_sin_cache, + fp8_buf, + scale_buf, + lora_input, + heads_per_group, + quant_group_size, + chunks_per_head, + nope_dim % quant_group_size, + rope_dim // 2, + tma_aligned_scales, + torch.finfo(fp8_dtype).max, + tma_aligned_t, + num_tokens, + ) + return fp8_buf.transpose(0, 1), scale_buf.transpose(0, 1), lora_input + + +def _dsv4_wo_a_lora_b_group_cache( + wo_a: Any, + *, + n_local_groups: int, + out_per_group: int, +) -> tuple[Any, ...]: + source = wo_a.lora_b_stacked[0] + key = ( + source.data_ptr(), + getattr(source, "_version", None), + n_local_groups, + out_per_group, + source.shape[-1], + ) + if getattr(wo_a, "_art_wo_a_lora_b_group_cache_key", None) != key: + wo_a._art_wo_a_lora_b_group_cache = tuple( + source[ + :, :, group * out_per_group : (group + 1) * out_per_group, : + ].contiguous() + for group in range(n_local_groups) + ) + wo_a._art_wo_a_lora_b_group_cache_key = key + return wo_a._art_wo_a_lora_b_group_cache + + +def _apply_dsv4_wo_a_lora_fast( + wo_a: Any, + z: Any, + *, + lora_input: Any, + n_local_groups: int, +) -> Any: + if not _is_active_lora_wrapped_linear(wo_a): + return z + + import torch + from vllm.distributed import tensor_model_parallel_all_gather + from vllm.platforms import current_platform + + wrapper = wo_a.punica_wrapper + out_per_group = z.shape[-1] + z_flat = z.view(z.shape[0], n_local_groups * out_per_group) + group_b = _dsv4_wo_a_lora_b_group_cache( + wo_a, + n_local_groups=n_local_groups, + out_per_group=out_per_group, + ) + local_rank = wo_a.lora_a_stacked[0].shape[2] + buffers = torch.empty_strided( + (n_local_groups, z.shape[0], local_rank), + (z.shape[0] * local_rank, local_rank, 1), + dtype=torch.float32, + device=z.device, + ) + for group, lora_b in enumerate(group_b): + buffer = buffers[group : group + 1] + buffer.zero_() + shrunk = wrapper.add_shrink(buffer, lora_input[group], wo_a.lora_a_stacked, 1.0) + if not current_platform.can_update_inplace(): + buffer = shrunk + buffer = tensor_model_parallel_all_gather(buffer) + expanded = wrapper.add_expand( + z_flat, + buffer, + (lora_b,), + (out_per_group,), + offset_start=group * out_per_group, + add_inputs=True, + ) + if not current_platform.can_update_inplace(): + z_flat = expanded + z = z_flat.view_as(z) + return z + + +def _patch_dsv4_compressor_fast_path_lora(attention_cls: Any) -> None: + if getattr(attention_cls, "_art_compressor_fast_path_lora_patched", False): + return + + original_attn_gemm_parallel_execute = attention_cls.attn_gemm_parallel_execute + + def attn_gemm_parallel_execute(self: Any, hidden_states: Any) -> tuple[Any, ...]: + qr_kv, kv_score, indexer_kv_score, indexer_weights = ( + original_attn_gemm_parallel_execute(self, hidden_states) + ) + if self.compressor is not None: + kv_score = _apply_dsv4_compressor_lora_to_existing_output( + self.compressor.fused_wkv_wgate, + hidden_states, + kv_score, + ) + if self.indexer is not None: + indexer_kv_score = _apply_dsv4_compressor_lora_to_existing_output( + self.indexer.compressor.fused_wkv_wgate, + hidden_states, + indexer_kv_score, + ) + return qr_kv, kv_score, indexer_kv_score, indexer_weights + + attn_gemm_parallel_execute.__art_patched__ = True # type: ignore[attr-defined] + attention_cls.attn_gemm_parallel_execute = attn_gemm_parallel_execute + attention_cls._art_compressor_fast_path_lora_patched = True + + +def _dsv4_deep_gemm_fp8_o_proj_with_lora( + o_proj_mod: Any, + o: Any, + positions: Any, + cos_sin_cache: Any, + wo_a: Any, + wo_b: Any, + *, + n_groups: int, + heads_per_group: int, + nope_dim: int, + rope_dim: int, + o_lora_rank: int, + einsum_recipe: tuple[int, int, int], + tma_aligned_scales: bool, +) -> Any: + wo_a_lora_input = None + if _is_active_lora_wrapped_linear(wo_a): + o_fp8, o_scale, wo_a_lora_input = ( + _dsv4_fused_inv_rope_fp8_quant_with_lora_input( + o_proj_mod, + o, + positions, + cos_sin_cache, + n_groups=n_groups, + heads_per_group=heads_per_group, + lora_dtype=wo_a.lora_a_stacked[0].dtype, + nope_dim=nope_dim, + rope_dim=rope_dim, + tma_aligned_scales=tma_aligned_scales, + ) + ) + else: + o_fp8, o_scale = o_proj_mod.fused_inv_rope_fp8_quant( + o, + positions, + cos_sin_cache, + n_groups=n_groups, + heads_per_group=heads_per_group, + nope_dim=nope_dim, + rope_dim=rope_dim, + tma_aligned_scales=tma_aligned_scales, + ) + + z = o_proj_mod.torch.empty( + (o.shape[0], n_groups, o_lora_rank), + device=o.device, + dtype=o_proj_mod.torch.bfloat16, + ) + o_proj_mod.fp8_einsum( + "bhr,hdr->bhd", + (o_fp8, o_scale), + (wo_a.weight, wo_a.weight_scale_inv), + z, + recipe=einsum_recipe, + ) + if wo_a_lora_input is not None: + z = _apply_dsv4_wo_a_lora_fast( + wo_a, + z, + lora_input=wo_a_lora_input, + n_local_groups=n_groups, + ) + return wo_b(z.flatten(1)) + + +def _patch_dsv4_cuda_o_proj_lora(attn_cls: Any, o_proj_mod: Any) -> None: + if getattr(attn_cls, "_art_wo_a_fast_path_lora_patched", False): + return + + def _o_proj(self: Any, o: Any, positions: Any) -> Any: + return _dsv4_deep_gemm_fp8_o_proj_with_lora( + o_proj_mod, + o, + positions, + self.rotary_emb.cos_sin_cache, + self.wo_a, + self.wo_b, + n_groups=self.n_local_groups, + heads_per_group=self.n_local_heads // self.n_local_groups, + nope_dim=self.nope_head_dim, + rope_dim=self.rope_head_dim, + o_lora_rank=self.o_lora_rank, + einsum_recipe=self._einsum_recipe, + tma_aligned_scales=self._tma_aligned_scales, + ) + + _o_proj.__art_patched__ = True # type: ignore[attr-defined] + attn_cls._o_proj = _o_proj + attn_cls._art_wo_a_fast_path_lora_patched = True + + +def _patch_current_dsv4_fast_path_lora() -> bool: + try: + dsv4_attention = importlib.import_module("vllm.models.deepseek_v4.attention") + except ModuleNotFoundError: + return False + + attention_cls = getattr(dsv4_attention, "DeepseekV4Attention", None) + if attention_cls is None: + return False + + _patch_dsv4_compressor_fast_path_lora(attention_cls) + + try: + o_proj_mod = importlib.import_module( + "vllm.models.deepseek_v4.nvidia.ops.o_proj" + ) + except ModuleNotFoundError: + return True + + for module_name, class_name in ( + ( + "vllm.models.deepseek_v4.nvidia.flashmla", + "DeepseekV4FlashMLAAttention", + ), + ( + "vllm.models.deepseek_v4.nvidia.flashinfer_sparse", + "DeepseekV4FlashInferMLAAttention", + ), + ): + try: + module = importlib.import_module(module_name) + except ModuleNotFoundError: + continue + attn_cls = getattr(module, class_name, None) + if attn_cls is not None: + _patch_dsv4_cuda_o_proj_lora(attn_cls, o_proj_mod) + return True + + +def patch_dsv4_fast_path_lora() -> None: + """Apply LoRA deltas on DSV4 paths that read base weights directly. + + vLLM's generic LoRA manager can wrap DSV4 linear modules, but the DSV4 + Flash runtime bypasses some wrapped forwards for performance: compressor + projections are direct ``hidden @ fused_wkv_wgate.weight.T`` calls, and + ``wo_a`` is a custom inverse-RoPE/FP8/einsum path. Without this patch vLLM + accepts and activates these adapter tensors while silently omitting their + deltas from generation. + """ + _register_dsv4_inv_rope_lora_input_op() + _register_dsv4_lora_expand_fp32_output_op() + if _patch_current_dsv4_fast_path_lora(): + return + + dsv4_attn = importlib.import_module( + "vllm.model_executor.layers.deepseek_v4_attention" + ) + wrapper_cls = getattr(dsv4_attn, "DeepseekV4MultiHeadLatentAttentionWrapper", None) + if wrapper_cls is None: + return + if getattr(wrapper_cls, "_art_fast_path_lora_patched", False): + return + + original_attn_gemm_parallel_execute = wrapper_cls.attn_gemm_parallel_execute + original_forward = wrapper_cls.forward + + def attn_gemm_parallel_execute(self: Any, hidden_states: Any) -> tuple[Any, ...]: + qr_kv, kv_score, indexer_kv_score, indexer_weights = ( + original_attn_gemm_parallel_execute(self, hidden_states) + ) + if self.compressor is not None: + kv_score = _apply_dsv4_compressor_lora_to_existing_output( + self.compressor.fused_wkv_wgate, + hidden_states, + kv_score, + ) + if self.indexer is not None: + indexer_kv_score = _apply_dsv4_compressor_lora_to_existing_output( + self.indexer.compressor.fused_wkv_wgate, + hidden_states, + indexer_kv_score, + ) + return qr_kv, kv_score, indexer_kv_score, indexer_weights + + def forward( + self: Any, + positions: Any, + hidden_states: Any, + llama_4_scaling: Any | None = None, + ) -> Any: + if dsv4_attn.current_platform.is_rocm(): + return original_forward(self, positions, hidden_states, llama_4_scaling) + + num_tokens = hidden_states.shape[0] + o_padded = dsv4_attn.torch.empty( + (num_tokens, self.padded_heads, self.head_dim), + dtype=hidden_states.dtype, + device=hidden_states.device, + ) + + dsv4_attn.torch.ops.vllm.deepseek_v4_attention( + hidden_states, + positions, + o_padded, + self.layer_name, + ) + o = o_padded[:, : self.n_local_heads, :] + + wo_a_lora_input = None + if _is_active_lora_wrapped_linear(self.wo_a): + o_fp8, o_scale, wo_a_lora_input = ( + _dsv4_fused_inv_rope_fp8_quant_with_lora_input( + dsv4_attn, + o, + positions, + self.rotary_emb.cos_sin_cache, + n_groups=self.n_local_groups, + heads_per_group=self.n_local_heads // self.n_local_groups, + lora_dtype=self.wo_a.lora_a_stacked[0].dtype, + nope_dim=self.nope_head_dim, + rope_dim=self.rope_head_dim, + tma_aligned_scales=self._tma_aligned_scales, + ) + ) + else: + o_fp8, o_scale = dsv4_attn.fused_inv_rope_fp8_quant( + o, + positions, + self.rotary_emb.cos_sin_cache, + n_groups=self.n_local_groups, + heads_per_group=self.n_local_heads // self.n_local_groups, + nope_dim=self.nope_head_dim, + rope_dim=self.rope_head_dim, + tma_aligned_scales=self._tma_aligned_scales, + ) + + z = dsv4_attn.torch.empty( + (num_tokens, self.n_local_groups, self.o_lora_rank), + device=o.device, + dtype=dsv4_attn.torch.bfloat16, + ) + dsv4_attn.torch.ops.vllm.deepseek_v4_fp8_einsum( + o_fp8, + o_scale, + self.wo_a.weight, + self.wo_a.weight_scale_inv, + z, + "bhr,hdr->bhd", + list(self._einsum_recipe), + ) + if wo_a_lora_input is not None: + z = _apply_dsv4_wo_a_lora_fast( + self.wo_a, + z, + lora_input=wo_a_lora_input, + n_local_groups=self.n_local_groups, + ) + return self.wo_b(z.flatten(1)) + + attn_gemm_parallel_execute.__art_patched__ = True # type: ignore[attr-defined] + forward.__art_patched__ = True # type: ignore[attr-defined] + wrapper_cls.attn_gemm_parallel_execute = attn_gemm_parallel_execute + wrapper_cls.forward = forward + wrapper_cls._art_fast_path_lora_patched = True + + +def _next_power_of_two(value: int) -> int: + return 1 << (max(value, 1) - 1).bit_length() + + +def patch_dsv4_triton_moe_topk6_routing() -> None: + """Make vLLM's DSV4 Triton MoE routing sort compile for top-k 6. + + Triton's ``tl.arange`` requires a power-of-two range. Current vLLM's + DSV4/MXFP4 routing path sorts ``BLOCK_M * num_experts_per_tok`` entries; + DSV4 uses top-k 6, so the first profile run tries ``tl.arange(0, 192)`` and + the engine exits before serving starts. Keep the original indexing stride at + 192, but sort over a padded power-of-two vector and mask padded lanes. + """ + try: + import torch + import triton + import triton.language as tl + from vllm.third_party.triton_kernels.routing_details._expt_data import ( + _expt_data_compute, + ) + except ImportError: + return + + @triton.jit + def _routing_compute_indx_pow2( + pid_m, + GatherIndx, + ScatterIndx, + GateScal, + ExptScal, + ExptIndx, + PartialOffs, + stride_pm, + stride_pn, + TokensStart, + n_tokens, + BLOCK_M: tl.constexpr, + N_EXPTS_ACT: tl.constexpr, + BLOCK_SIZE_PADDED: tl.constexpr, + ): + if isinstance(n_tokens, tl.tensor) and n_tokens.dtype.is_ptr(): + n_tokens = tl.load(n_tokens) + n_gates = n_tokens * N_EXPTS_ACT + block_size: tl.constexpr = N_EXPTS_ACT * BLOCK_M + tl.static_assert(BLOCK_SIZE_PADDED >= block_size) + tl.static_assert(BLOCK_SIZE_PADDED <= 32768) + + local_offs = tl.arange(0, BLOCK_SIZE_PADDED) + valid_local = local_offs < block_size + offs = pid_m * block_size + local_offs + expert = tl.load( + ExptIndx + offs, + mask=valid_local & (offs < n_gates), + other=-1, + ).to(tl.uint32) + + kv_pairs = ((expert << 16) | local_offs).to(tl.uint32) + kv_pairs = tl.sort(kv_pairs, 0) + expert = kv_pairs >> 16 + offs = pid_m * block_size + (kv_pairs & 0xFFFF) + mask = expert != 0xFFFF + gate_scal = tl.load(ExptScal + offs, mask=mask) + + x = kv_pairs & 0xFFFF0000 | 0x00000001 + expts_and_inclusive_run_lengths = tl.associative_scan(x, 0, _keyed_add_pow2) + exclusive_run_lengths = (expts_and_inclusive_run_lengths - 1) & 0xFFFF + + gates = tl.load(PartialOffs + pid_m * stride_pm + expert * stride_pn, mask=mask) + gates += tl.load(TokensStart + expert, mask=mask) + gates += exclusive_run_lengths + + tl.store(ScatterIndx + offs, gates, mask=mask) + tl.store(GatherIndx + gates, offs, mask=mask) + tl.store(GateScal + gates, gate_scal, mask=mask) + + @triton.jit + def _keyed_add_pow2(x, y): + key_mask: tl.constexpr = 0xFFFF0000 + kx = x & key_mask + ky = y & key_mask + return tl.where(kx == ky, x + y - kx, y) + + @triton.jit + def _combined_routing_compute_pow2( + GatherIndx, + ScatterIndx, + GateScal, + ExptScal, + ExptIndx, + PartialOffs, + stride_pm, + stride_pn, + TokensStart, + n_tokens, + BLOCK_M: tl.constexpr, + N_EXPTS_ACT: tl.constexpr, + Hist, + MDTileStarts, + tile_starts_stridem, + MDTileInfo, + tile_info_stridem, + first_tile_dim_log2, + SIZES: tl.constexpr, + BLOCK: tl.constexpr, + blocks2a, + BLOCK_SIZE_PADDED: tl.constexpr, + ): + pid = tl.program_id(0) + if pid < blocks2a: + _expt_data_compute( + Hist, + MDTileStarts, + tile_starts_stridem, + MDTileInfo, + tile_info_stridem, + first_tile_dim_log2, + SIZES, + BLOCK, + ) + else: + pid -= blocks2a + _routing_compute_indx_pow2( + pid, + GatherIndx, + ScatterIndx, + GateScal, + ExptScal, + ExptIndx, + PartialOffs, + stride_pm, + stride_pn, + TokensStart, + n_tokens, + BLOCK_M, + N_EXPTS_ACT, + BLOCK_SIZE_PADDED, + ) + + for module_name in ( + "vllm.third_party.triton_kernels.routing", + "triton_kernels.routing", + ): + try: + routing = importlib.import_module(module_name) + except ImportError: + continue + original_forward = routing.SortTokens.forward + if getattr(original_forward, "__art_dsv4_topk6_pow2_patched__", False): + continue + + def forward( + ctx: Any, + expt_scal: Any, + expt_indx: Any, + n_expts_tot: int, + bitmatrix: Any, + _routing: Any = routing, + ) -> Any: + hist_block_m = 32 + indx_offs_block_m = 512 + memset_block = 1024 + cdiv = triton.cdiv + + device = expt_scal.device + dtype = expt_scal.dtype + n_tokens_raw, _ = bitmatrix.shape + n_tokens_pad, n_expts_act = expt_scal.shape + n_gates_pad = n_tokens_pad * n_expts_act + + hist, partial_hist = bitmatrix.sum(partials_block_size=hist_block_m) + hist = hist[:n_expts_tot] + assert hist.dtype == torch.int32 + expt_offs = torch.empty(n_expts_tot, dtype=torch.int32, device=device) + combined_indx = torch.empty( + n_gates_pad * 2, dtype=torch.int32, device=device + ) + topk_indx = combined_indx[:n_gates_pad] + gate_indx = combined_indx[n_gates_pad:] + gate_scal = torch.empty(n_gates_pad, dtype=dtype, device=device) + + ( + token_offs_combined, + token_offs_raw, + token_offs_pad, + block_pid_map, + blocks1a, + blocks2a, + memset_block_a, + hist2_block_m, + block_m_log2_start, + block_m_num, + ) = _routing._compute_expt_data_internal(hist, n_expts_tot, n_gates_pad) + + blocks1b = cdiv(n_gates_pad * 2, memset_block) + n_expts_tot + 1 + blocks2b = cdiv(n_tokens_pad, hist_block_m) + + _routing._combined_routing_memset[(blocks1a + blocks1b,)]( + combined_indx, + n_gates_pad * 2, + -1, + memset_block, + hist, + expt_offs, + hist.shape[0], + n_expts_tot, + partial_hist, + partial_hist.shape[0], + partial_hist.stride(0), + partial_hist.stride(1), + token_offs_combined, + token_offs_combined.stride(0), + blocks1a, + block_pid_map, + block_m_log2_start, + SIZES=block_m_num, + BLOCK_A=memset_block_a, + BLOCK_N=512, + BLOCK_M=indx_offs_block_m, + ) + + indx_offs = partial_hist + _combined_routing_compute_pow2[(blocks2a + blocks2b,)]( + topk_indx, + gate_indx, + gate_scal, + expt_scal, + expt_indx, + indx_offs, + indx_offs.stride(0), + indx_offs.stride(1), + expt_offs, + n_tokens_raw, + hist_block_m, + n_expts_act, + hist, + token_offs_pad, + token_offs_pad.stride(0), + block_pid_map, + block_pid_map.stride(0), + block_m_log2_start, + block_m_num, + hist2_block_m, + blocks2a, + BLOCK_SIZE_PADDED=_next_power_of_two(hist_block_m * n_expts_act), + ) + + ctx.n_tokens_raw = n_tokens_raw + ctx.n_tokens_pad = n_tokens_pad + ctx.n_expts_act = n_expts_act + ctx.save_for_backward(gate_indx) + return ( + hist, + topk_indx, + gate_indx, + gate_scal, + token_offs_raw, + token_offs_pad, + block_pid_map, + ) + + forward.__art_dsv4_topk6_pow2_patched__ = True # type: ignore[attr-defined] + forward.__art_original__ = original_forward # type: ignore[attr-defined] + routing.SortTokens.forward = staticmethod(forward) + routing._combined_routing_compute = _combined_routing_compute_pow2 + + +def _base_layer_attr_proxy(name: str) -> property: + def attr(self: Any) -> Any: + return getattr(self.base_layer, name) + + return property(attr) + + +def patch_lora_linear_base_attr_proxy() -> None: + """Expose DSV4 base metadata through vLLM linear LoRA wrappers. + + DeepSeek V4's output attention path calls a custom FP8 einsum directly and + reads ``wo_a.weight_scale_inv`` next to ``wo_a.weight``. vLLM's linear LoRA + wrappers already proxy ``weight`` but not the quant scale. Its router also + reads dynamic gate metadata from ``self.gate`` after that gate can be LoRA + wrapped. Keep these tensors owned by the base layer instead of copying or + re-registering them on every wrapper. + """ + from vllm.lora.layers.base_linear import BaseLinearLayerWithLoRA + + if getattr(BaseLinearLayerWithLoRA, "_art_base_attr_proxy_patched", False): + return + + for name in ("weight_scale_inv", "tid2eid", "e_score_correction_bias"): + if not hasattr(BaseLinearLayerWithLoRA, name): + setattr(BaseLinearLayerWithLoRA, name, _base_layer_attr_proxy(name)) + BaseLinearLayerWithLoRA._art_base_attr_proxy_patched = True + + +def patch_marlin_lora_swiglu_limit() -> None: + """Keep Marlin MoE LoRA active when DSV4 uses a SwiGLU clamp limit. + + vLLM's Marlin LoRA path injects W13 LoRA inside the activation callback and + stores that activated cache for W2 LoRA. DSV4 sets ``gemm1_clamp_limit``; + upstream Marlin bypasses the callback in that case and calls the clamp op + directly, so W13 LoRA is skipped and W2 LoRA later misses ``cache2``. Route + the callback through the same clamp op while preserving Marlin execution. + """ + try: + marlin_moe = importlib.import_module( + "vllm.model_executor.layers.fused_moe.fused_marlin_moe" + ) + except ModuleNotFoundError: + return + + from vllm.model_executor.layers.fused_moe.activation import MoEActivation + from vllm.model_executor.layers.fused_moe.utils import swiglu_limit_func + + MarlinExperts = marlin_moe.MarlinExperts + + original_apply = MarlinExperts.apply + if getattr(original_apply, "__art_patched__", False): + return + + sentinel = object() + + def apply(self: Any, *args: Any, **kwargs: Any) -> Any: + clamp_limit = getattr(self, "gemm1_clamp_limit", None) + if getattr(self, "_lora_context", None) is None or clamp_limit is None: + return original_apply(self, *args, **kwargs) + + original_activation = self.activation + previous_activation = self.__dict__.get("activation", sentinel) + previous_clamp_limit = self.gemm1_clamp_limit + + def activation_with_clamp( + activation: Any, + output: Any, + input: Any, + ) -> None: + if activation == MoEActivation.SILU: + swiglu_limit_func(output, input, clamp_limit) + else: + original_activation(activation, output, input) + + self.activation = activation_with_clamp + self.gemm1_clamp_limit = None + try: + return original_apply(self, *args, **kwargs) + finally: + self.gemm1_clamp_limit = previous_clamp_limit + if previous_activation is sentinel: + delattr(self, "activation") + else: + self.activation = previous_activation + + apply.__art_patched__ = True # type: ignore[attr-defined] + MarlinExperts.apply = apply # type: ignore[method-assign] diff --git a/vllm_runtime/src/art_vllm_runtime/lora_delta.py b/vllm_runtime/src/art_vllm_runtime/lora_delta.py index 30952f93e..8952bb5d2 100644 --- a/vllm_runtime/src/art_vllm_runtime/lora_delta.py +++ b/vllm_runtime/src/art_vllm_runtime/lora_delta.py @@ -87,6 +87,7 @@ def _iter_lora_checkpoint_deltas( expert_a = a_tensor[expert_id * rank : (expert_id + 1) * rank] delta = b_expert.float().matmul(expert_a.float()).mul_(scaling) if previous_b is not None: + assert previous_lora_tensors is not None previous_a = previous_lora_tensors[a_key][ expert_id * rank : (expert_id + 1) * rank ] @@ -120,6 +121,7 @@ def _iter_lora_checkpoint_deltas( expert_a = a_tensor[expert_id * rank : (expert_id + 1) * rank] delta = b_expert.float().matmul(expert_a.float()).mul_(scaling) if previous_b is not None: + assert previous_lora_tensors is not None previous_a = previous_lora_tensors[a_key][ expert_id * rank : (expert_id + 1) * rank ] @@ -157,6 +159,25 @@ def _default_weight_loader(param: torch.Tensor, loaded_weight: torch.Tensor) -> param.data.copy_(loaded_weight) +def _call_weight_loader( + loader: Any, + loader_param: torch.Tensor, + loaded_weight: torch.Tensor, + *args: Any, + **kwargs: Any, +) -> Any: + if not hasattr(loader_param, "load_merged_column_weight"): + owner = getattr(loader, "__self__", None) + legacy_loader = getattr(owner, "weight_loader", None) + if ( + legacy_loader is not None + and legacy_loader is not loader + and getattr(loader, "__name__", "") == "weight_loader_v2" + ): + return legacy_loader(loader_param, loaded_weight, *args, **kwargs) + return loader(loader_param, loaded_weight, *args, **kwargs) + + def _additive_weight_loader(param: torch.Tensor, original_loader: Any) -> Any: def load_delta( loader_param: torch.Tensor, @@ -168,7 +189,13 @@ def load_delta( scratch = torch.zeros_like(real_data) loader_param.data = scratch try: - result = original_loader(loader_param, loaded_weight, *args, **kwargs) + result = _call_weight_loader( + original_loader, + loader_param, + loaded_weight, + *args, + **kwargs, + ) finally: loader_param.data = real_data if result is not False: diff --git a/vllm_runtime/src/art_vllm_runtime/patches.py b/vllm_runtime/src/art_vllm_runtime/patches.py index 91f0298b2..5077613dc 100644 --- a/vllm_runtime/src/art_vllm_runtime/patches.py +++ b/vllm_runtime/src/art_vllm_runtime/patches.py @@ -1,6 +1,7 @@ """Monkey patches and bootstrap contract for the ART-owned vLLM runtime.""" import ctypes +import importlib import inspect import logging from typing import Any @@ -11,6 +12,7 @@ def apply_vllm_runtime_patches() -> None: + from art_vllm_runtime.dsv4_patches import apply_dsv4_vllm_runtime_patches from art_vllm_runtime.gemma4_moe_lora_patch import ( patch_gemma4_moe_lora_support, ) @@ -21,6 +23,7 @@ def apply_vllm_runtime_patches() -> None: patch_listen_for_disconnect() patch_tool_parser_manager() patch_nccl_unique_id_bootstrap() + apply_dsv4_vllm_runtime_patches() patch_art_lora_delta_weight_update() patch_gemma4_checkpoint_weight_update_reload() patch_routed_experts_prefix_cache_sidecar() @@ -65,8 +68,6 @@ def _patch_gemma4_moe_experts_per_tok_alias() -> None: return def num_experts_per_tok(self: Any) -> Any: - # vLLM's routed-expert sidecar uses the Mistral MoE field name, while - # HF Gemma4 stores the same router top-k value as top_k_experts. return self.top_k_experts Gemma4TextConfig.num_experts_per_tok = property(num_experts_per_tok) # type: ignore[attr-defined] @@ -91,7 +92,10 @@ def __init__(self, *args: object, **kwargs: object) -> None: def patch_listen_for_disconnect() -> None: - from vllm.entrypoints.serve.utils import api_utils + try: + api_utils = importlib.import_module("vllm.entrypoints.serve.utils.api_utils") + except ModuleNotFoundError: + api_utils = importlib.import_module("vllm.entrypoints.utils") if getattr(api_utils, "_art_listen_for_disconnect_patched", False): return @@ -224,10 +228,6 @@ def start_weight_update( "start_weight_update called while a weight update is " "already active. Call finish_weight_update first." ) - # vLLM's layerwise checkpoint reload corrupts Gemma4 after reloading - # the original checkpoint. Direct model.load_weights keeps the update - # path identical to initial checkpoint loading while preserving the - # streaming NCCL transfer used by ART merged serving. self._is_checkpoint_format = True self._weight_update_active = True @@ -372,16 +372,11 @@ def patch_routed_experts_prefix_cache_sidecar() -> None: if getattr(routed_experts_capturer, "_art_prefix_route_sidecar_patched", False): return - if hasattr(routed_experts_capturer, "RoutedExpertsManager"): - # vLLM 0.23 stores routed experts by physical KV-cache slot, so prefix - # cache hits recover routes from the shared slot buffer without ART's - # old per-request host-cache sidecar. - setattr(routed_experts_capturer, "_art_prefix_route_sidecar_patched", True) + host_cls = getattr(routed_experts_capturer, "_RoutedExpertsHostCache", None) + capturer_cls = getattr(routed_experts_capturer, "_RoutedExpertsCapturerReal", None) + if host_cls is None or capturer_cls is None: return - host_cls = routed_experts_capturer._RoutedExpertsHostCache - capturer_cls = routed_experts_capturer._RoutedExpertsCapturerReal - original_host_init = host_cls.__init__ original_get_or_grow_buffer = host_cls.get_or_grow_buffer original_free_request = host_cls.free_request diff --git a/vllm_runtime/uv.lock b/vllm_runtime/uv.lock index f647be079..0c697f2ec 100644 --- a/vllm_runtime/uv.lock +++ b/vllm_runtime/uv.lock @@ -10,7 +10,7 @@ overrides = [ { name = "torch", url = "https://download.pytorch.org/whl/test/cu128/torch-2.11.0%2Bcu128-cp312-cp312-manylinux_2_28_x86_64.whl" }, { name = "torchaudio", url = "https://download.pytorch.org/whl/test/cu128/torchaudio-2.11.0%2Bcu128-cp312-cp312-manylinux_2_28_x86_64.whl" }, { name = "torchvision", url = "https://download.pytorch.org/whl/test/cu128/torchvision-0.26.0%2Bcu128-cp312-cp312-manylinux_2_28_x86_64.whl" }, - { name = "transformers", specifier = "==5.6.2" }, + { name = "transformers", specifier = "==5.12.1" }, ] [[package]] @@ -142,7 +142,7 @@ dependencies = [ [package.metadata] requires-dist = [ { name = "nvidia-nccl-cu12", marker = "sys_platform == 'linux'", specifier = "==2.28.9" }, - { name = "transformers", specifier = "==5.6.2" }, + { name = "transformers", specifier = "==5.12.1" }, { name = "vllm", marker = "sys_platform == 'linux'", url = "https://github.com/vllm-project/vllm/releases/download/v0.23.0/vllm-0.23.0%2Bcu129-cp38-abi3-manylinux_2_28_x86_64.whl" }, ] @@ -2464,7 +2464,7 @@ wheels = [ [[package]] name = "transformers" -version = "5.6.2" +version = "5.12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "huggingface-hub" }, @@ -2477,9 +2477,9 @@ dependencies = [ { name = "tqdm" }, { name = "typer" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a4/e9/c6c80a07690142a7d05444271f47b9f3c8aac7dea01d52e1137ee480ad78/transformers-5.6.2.tar.gz", hash = "sha256:e657134c3e5a6bc00a3c35f4e2674bb51adfcd89898495b788a18552bac2b91a", size = 8311867, upload-time = "2026-04-23T18:33:29.332Z" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/7c/8240f612819718100a9346dc28dea6a11370c3ca9c8c6eabadd3dea4ef29/transformers-5.12.1.tar.gz", hash = "sha256:679ee731c8225347889ad4fb3b2c926a62e9da3b7d284e9d12c791da7272466b", size = 8924054, upload-time = "2026-06-15T17:27:50.604Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5d/95/0b0218149b0d6f14df35f5b8f676fa83df4f19ed253c3cc447107ef86eca/transformers-5.6.2-py3-none-any.whl", hash = "sha256:f8d3a1bb96778fed9b8aabfd0dd6e19843e4b0f2bb6b59f32b8a92051b0f348f", size = 10364898, upload-time = "2026-04-23T18:33:26.081Z" }, + { url = "https://files.pythonhosted.org/packages/df/56/bbd60dd8668055803bf8ba55a81f9b8a8b31497f620109a9671d26a2076d/transformers-5.12.1-py3-none-any.whl", hash = "sha256:2a5e109d2021265df7098ffbb738295acaf5ad256f12cbc586db2ea4dcbb1a8a", size = 11150587, upload-time = "2026-06-15T17:27:46.679Z" }, ] [[package]]