From cebca698e2da89b73599bf358f3fb918c41fc2a6 Mon Sep 17 00:00:00 2001 From: Jimmy Shong <69131491+Jiminator@users.noreply.github.com> Date: Sun, 13 Sep 2026 01:23:41 -0700 Subject: [PATCH] [Qwen3.8] Enable NVIDIA NVFP4 on DGX Spark with file-backed PLE and PDL router fix (#39126) Co-authored-by: Claude Fable 5.1 Co-authored-by: rdxa Co-authored-by: Yangmin Li Co-authored-by: Manrique Co-authored-by: yhyang201 --- .../advanced_features/server_arguments.mdx | 18 + .../docs/references/environment_variables.mdx | 25 + .../kernels/jit/csrc/moe/route_radix.cuh | 5 +- .../sglang/kernels/ops/moe/moe_fused_gate.py | 14 +- python/sglang/srt/arg_groups/fields/exec_.py | 24 + python/sglang/srt/arg_groups/memory_hook.py | 6 + .../arg_groups/model_overrides/qwen3_moe.py | 47 +- python/sglang/srt/configs/qwen4_exp.py | 6 + python/sglang/srt/environ.py | 12 + python/sglang/srt/layers/quantization/fp8.py | 12 + .../srt/layers/quantization/modelopt_quant.py | 25 + .../load_model_utils.py | 21 + python/sglang/srt/models/qwen3_5_mtp.py | 12 +- python/sglang/srt/models/qwen4_exp.py | 74 ++- .../sglang/srt/models/qwen4_exp_ple_table.py | 483 ++++++++++++++++++ .../embeddings/test_qwen4_ple_offload.py | 78 ++- .../test_fp8_moe_runner_fallback.py | 38 ++ .../unit/model_loader/test_modelopt_loader.py | 57 ++- .../unit/models/test_qwen4_exp_ple_table.py | 332 ++++++++++++ .../test_shared_experts_fusion_gates.py | 15 +- test/registered/unit/test_model_overrides.py | 103 ++++ 21 files changed, 1376 insertions(+), 31 deletions(-) create mode 100644 python/sglang/srt/models/qwen4_exp_ple_table.py create mode 100644 test/registered/unit/layers/quantization/test_fp8_moe_runner_fallback.py create mode 100644 test/registered/unit/models/test_qwen4_exp_ple_table.py diff --git a/docs/docs/advanced_features/server_arguments.mdx b/docs/docs/advanced_features/server_arguments.mdx index b573d1a2f..5837c0f10 100644 --- a/docs/docs/advanced_features/server_arguments.mdx +++ b/docs/docs/advanced_features/server_arguments.mdx @@ -2306,6 +2306,24 @@ Please consult the documentation below and [server_args.py](https://github.com/s Number of layers per group in offloading. `-1` Type: int + + + `--ple-offload-embedding` + Keep the Qwen4-Exp PLE n-gram embedding table in host memory instead of device memory; the gather kernel reads it from a host pointer. Enabled by default for BF16 Qwen4-Exp on CUDA; `--no-ple-offload-embedding` disables it. + `None` + Type: bool + + + `--ple-offload-backend` + Host storage for the offloaded PLE table: `pinned` (CPU pinned memory) or `file` (a sparse file-backed mmap under `--ple-offload-dir`, read directly by the gather kernel). Use `file` on unified-memory devices such as GB10 / DGX Spark, where pinned host memory comes out of the same pool as the weights; it requires a device that reports `cudaDevAttrPageableMemoryAccessUsesHostPageTables`. + `pinned` + Type: str + + + `--ple-offload-dir` + Directory for the file-backed PLE table when `--ple-offload-backend` is `file`. The file is sparse and rewritten on every startup; keep it on fast local storage (NVMe). Rewriting an existing table can be much slower than filling a fresh sparse file. Before restarting, stop all servers using that directory and remove its `ple_table_*.bin` files to avoid this startup cost. + `$SGLANG_CACHE_DIR/ple/` + Type: str `--offload-num-in-group` diff --git a/docs/docs/references/environment_variables.mdx b/docs/docs/references/environment_variables.mdx index b08398520..cf8c9fd8c 100644 --- a/docs/docs/references/environment_variables.mdx +++ b/docs/docs/references/environment_variables.mdx @@ -83,6 +83,31 @@ SGLang supports various environment variables that can be used to configure its Cache directory for model weights and other data. Also the default root for compiled-kernel caches: Triton, Inductor, FlashInfer, the CUDA driver and DeepGEMM are pointed under it unless their own env vars (`TRITON_CACHE_DIR`, `TORCHINDUCTOR_CACHE_DIR`, `FLASHINFER_WORKSPACE_BASE`, `CUDA_CACHE_PATH`, `SGLANG_DG_CACHE_DIR`) are set explicitly ~/.cache/sglang + + SGLANG_QWEN4_PLE_FILE_DIR + Default directory for the file-backed Qwen4-Exp PLE table (`--ple-offload-backend file`); one subdirectory per model path is created under it + {SGLANG_CACHE_DIR}/ple + + + SGLANG_QWEN4_PLE_FILE_PREFETCH + Hint the page cache (`posix_fadvise(WILLNEED)`) with the rows a prefill-sized PLE gather is about to read from the file-backed table. Set to `0` to disable + 1 + + + SGLANG_QWEN4_PLE_FILE_SKIP_DEVICE_CHECK + Skip the load-time check that the device reads pageable host memory through the host page tables (`cudaDevAttrPageableMemoryAccessUsesHostPageTables`) before using the file-backed PLE table; only for devices that cannot be queried + 0 + + + SGLANG_QWEN4_PLE_FILE_RSS_BUDGET_GB + Cap (GiB) on the resident set of the file-backed PLE mapping. A row fault maps a whole page-cache folio, so the mapping otherwise creeps towards the full table and consumes the free memory that sizes the KV pool; over the cap the pages are dropped with `MADV_DONTNEED` and stay in the page cache. Set to `0` to disable + 8.0 + + + SGLANG_QWEN4_PLE_FILE_RSS_INTERVAL_S + How often the file-backed PLE mapping's resident set is compared against `SGLANG_QWEN4_PLE_FILE_RSS_BUDGET_GB` + 30.0 + SGLANG_CUTE_AOT_CACHE_DIR Trusted directory for persistent CuTe DSL AOT objects shared across process restarts. Artifacts are namespaced by source, runtime ABI, host platform, and target GPU architecture. Set to an empty string to keep compilation process-local. Only use a directory writable by trusted users because SGLang loads cached object files into the process. diff --git a/python/sglang/kernels/jit/csrc/moe/route_radix.cuh b/python/sglang/kernels/jit/csrc/moe/route_radix.cuh index 0c39f6047..492d1b980 100644 --- a/python/sglang/kernels/jit/csrc/moe/route_radix.cuh +++ b/python/sglang/kernels/jit/csrc/moe/route_radix.cuh @@ -142,9 +142,10 @@ SGL_DEVICE void route_radix_block(const RouteRadixParams& params, typename Large // radix math below is fp32 either way — only the load width differs. AlignedVector, kVecSize / 2> scores_vec; - // prefetch bias (frozen weight) before the PDL wait - bias_vec.load(params.bias, tx); + // Bias may be produced by a preceding cast or fill kernel (the caller + // does not guarantee a frozen weight), so wait before loading either input. PDLWaitPrimary(); + bias_vec.load(params.bias, tx); scores_vec.load(scores, tx); #pragma unroll diff --git a/python/sglang/kernels/ops/moe/moe_fused_gate.py b/python/sglang/kernels/ops/moe/moe_fused_gate.py index f6933e6f0..faea161e2 100644 --- a/python/sglang/kernels/ops/moe/moe_fused_gate.py +++ b/python/sglang/kernels/ops/moe/moe_fused_gate.py @@ -127,17 +127,19 @@ def _router_triton_kernel( mask_m = offs_m < M mask_n = offs_n < N - # Prefetch a real bias before the PDL wait. Plain softmax routing has no - # bias, so keep the zero value in registers rather than materializing and - # clearing a device tensor for every routing call. + # PDL may start this grid before prior kernel stores are visible. Bias can + # be produced by a preceding cast or fill kernel, so wait before loading + # either bias or scores. + if USE_PDL: + tl.extra.cuda.gdc_wait() + + # Plain softmax routing has no bias, so keep the zero value in registers + # rather than materializing and clearing a device tensor per call. if HAS_BIAS: bias = tl.load(bias_ptr + offs_n, mask=mask_n, other=0.0).to(tl.float32) else: bias = tl.zeros([BLOCK_N], dtype=tl.float32) - if USE_PDL: - tl.extra.cuda.gdc_wait() - row_ptr = scores_ptr + offs_m[:, None] * stride_sm + offs_n[None, :] * stride_sn mask2d = mask_m[:, None] & mask_n[None, :] scores = tl.load(row_ptr, mask=mask2d, other=0.0).to( diff --git a/python/sglang/srt/arg_groups/fields/exec_.py b/python/sglang/srt/arg_groups/fields/exec_.py index e9f7e43ab..e70086e61 100644 --- a/python/sglang/srt/arg_groups/fields/exec_.py +++ b/python/sglang/srt/arg_groups/fields/exec_.py @@ -879,6 +879,30 @@ class ExecOffload(msgspec.Struct): ), ] = None + ple_offload_backend: A[ + str, + Arg( + help="Host storage for the offloaded Qwen4 PLE n-gram table. " + "'pinned' (default) uses CPU pinned memory. 'file' maps a sparse " + "file under --ple-offload-dir and lets the gather kernel read it " + "directly; use it on unified-memory devices (e.g. GB10 / DGX Spark) " + "where pinned host memory comes out of the same pool as the model " + "weights. Requires a device that reports " + "cudaDevAttrPageableMemoryAccessUsesHostPageTables.", + choices=["pinned", "file"], + ), + ] = "pinned" + ple_offload_dir: A[ + Optional[str], + Arg( + help="Directory for the file-backed PLE table when " + "--ple-offload-backend is 'file'. Defaults to " + "$SGLANG_CACHE_DIR/ple/, one directory per checkpoint. " + "The file is sparse and reused across restarts; put it on fast " + "local storage (NVMe).", + ), + ] = None + class ExecDllm(msgspec.Struct): """Namespace ``exec.dllm``.""" diff --git a/python/sglang/srt/arg_groups/memory_hook.py b/python/sglang/srt/arg_groups/memory_hook.py index 0d9888748..aed841a57 100644 --- a/python/sglang/srt/arg_groups/memory_hook.py +++ b/python/sglang/srt/arg_groups/memory_hook.py @@ -37,6 +37,12 @@ def handle_offload_compatibility(server_args: Any) -> None: "would stage the pinned PLE embedding back to the device." ) + if cfg.ple_offload_backend == "file" and cfg.ple_offload_embedding is False: + raise ValueError( + "--ple-offload-backend file requires --ple-offload-embedding: " + "the file-backed table is the offloaded table." + ) + def handle_gpu_memory_settings(server_args: Any): """ diff --git a/python/sglang/srt/arg_groups/model_overrides/qwen3_moe.py b/python/sglang/srt/arg_groups/model_overrides/qwen3_moe.py index 68840f8e2..1747e5192 100644 --- a/python/sglang/srt/arg_groups/model_overrides/qwen3_moe.py +++ b/python/sglang/srt/arg_groups/model_overrides/qwen3_moe.py @@ -10,12 +10,25 @@ from sglang.srt.arg_groups.model_override_base import ( _register_for, resolving_view, ) +from sglang.srt.environ import envs from sglang.srt.runtime_context import get_platform from sglang.srt.utils.common import get_quantization_config logger = logging.getLogger(__name__) +def _mixed_precision_moe_quant_algos(hf_config: Any) -> set: + """quant_algo values ModelOpt MIXED_PRECISION assigns to `*.experts` layers.""" + quantization_config = getattr(hf_config, "quantization_config", None) + if not isinstance(quantization_config, dict): + return set() + return { + str(info.get("quant_algo", "")).upper() + for name, info in quantization_config.get("quantized_layers", {}).items() + if ".experts" in name and isinstance(info, dict) + } + + @_register_for( "Qwen3MoeForCausalLM", "Qwen3VLMoeForConditionalGeneration", @@ -38,8 +51,38 @@ def _qwen3_moe_family_overrides(server_args: Any, hf_config: Any) -> dict: ): overrides["quantization"] = quant_method quantization = quant_method - if ( - (quantization in ("fp8", "modelopt_fp4") or quantization is None) + has_w4a16_moe_layers = ( + quantization == "modelopt_mixed" + and "W4A16_NVFP4" in _mixed_precision_moe_quant_algos(hf_config) + ) + if has_w4a16_moe_layers: + # trtllm-gen only has the W4A4 NVFP4 MoE path. + # CuTe DSL v2 also supports W4A16 with BF16 activations when opted in. + use_cutedsl_w4a16 = ( + cfg.moe_runner_backend == "flashinfer_cutedsl" + and cfg.moe_a2a_backend in ("none", "flashinfer") + and envs.SGLANG_FLASHINFER_CUTEDSL_NVFP4_W4A16.get() + ) + if ( + cfg.moe_runner_backend not in ("auto", "marlin") + and not use_cutedsl_w4a16 + ): + raise ValueError( + "W4A16_NVFP4 MoE layers require --moe-runner-backend=marlin, " + "or flashinfer_cutedsl with --moe-a2a-backend=none/flashinfer " + "and SGLANG_FLASHINFER_CUTEDSL_NVFP4_W4A16=1." + ) + if cfg.moe_runner_backend == "auto": + overrides["moe_runner_backend"] = "marlin" + logger.info( + "Use marlin as MoE runner backend for " + f"{hf_config.architectures[0]} with W4A16_NVFP4 MoE layers" + ) + elif ( + ( + quantization in ("fp8", "modelopt_fp4", "modelopt_mixed") + or quantization is None + ) and cfg.moe_a2a_backend == "none" and cfg.moe_runner_backend == "auto" ): diff --git a/python/sglang/srt/configs/qwen4_exp.py b/python/sglang/srt/configs/qwen4_exp.py index a161face8..01e18c713 100644 --- a/python/sglang/srt/configs/qwen4_exp.py +++ b/python/sglang/srt/configs/qwen4_exp.py @@ -31,6 +31,8 @@ class Qwen4ExpTextConfig(Qwen3NextConfig): ngram_vocab_size_base=20000000, make_ngram_vocab_size_divisible_by=128, ple_offload_embedding=False, + ple_offload_backend="pinned", + ple_offload_dir=None, ple_embedding_dtype=None, index_share_for_mtp_iteration=True, rope_parameters=None, @@ -68,6 +70,10 @@ class Qwen4ExpTextConfig(Qwen3NextConfig): self.ngram_vocab_size_base = ngram_vocab_size_base self.make_ngram_vocab_size_divisible_by = make_ngram_vocab_size_divisible_by self.ple_offload_embedding = ple_offload_embedding + # Host storage for the offloaded table: "pinned" or "file" (a sparse + # file-backed mmap for unified-memory devices); see --ple-offload-backend. + self.ple_offload_backend = ple_offload_backend + self.ple_offload_dir = ple_offload_dir # "float8_e4m3fn" keeps fp8 PLE tables fp8-resident; text_config-scoped. self.ple_embedding_dtype = ple_embedding_dtype # Draft decode steps reuse the draft-extend indexer top-k (IndexShare). diff --git a/python/sglang/srt/environ.py b/python/sglang/srt/environ.py index db80e167d..df10504e4 100644 --- a/python/sglang/srt/environ.py +++ b/python/sglang/srt/environ.py @@ -304,6 +304,18 @@ class Envs: # Bitwise-exact, shape-guarded Qwen4 PLE decode fusion. Unsupported inputs # and phases fall back to the original implementation. SGLANG_ENABLE_QWEN4_PLE_FUSION = EnvBool(True) + # --ple-offload-backend file: where the sparse, file-backed PLE table lives + # (deterministic name, reused across restarts), whether prefill-sized + # gathers hint the page cache first, and an escape hatch for the device + # attribute check (pageable host memory reachable through host page tables). + SGLANG_QWEN4_PLE_FILE_DIR = EnvStr(lambda: _default_cache_subdir("ple")) + SGLANG_QWEN4_PLE_FILE_PREFETCH = EnvBool(True) + SGLANG_QWEN4_PLE_FILE_SKIP_DEVICE_CHECK = EnvBool(False) + # Faulting rows in maps whole page-cache folios, so the mapping creeps + # towards full residency (~45 KB/token) and eats the free memory that + # sizes the KV pool. Cap its resident set; 0 disables the trim. + SGLANG_QWEN4_PLE_FILE_RSS_BUDGET_GB = EnvFloat(8.0) + SGLANG_QWEN4_PLE_FILE_RSS_INTERVAL_S = EnvFloat(30.0) SGLANG_PREFETCH_BLOCK_SIZE_MB = EnvInt(16) SGLANG_GEMMA_OUT_OF_PLACE_POSITION_MUTATION = EnvBool(False) SGLANG_ENABLE_WEIGHT_LOADER_V2 = EnvBool(False) diff --git a/python/sglang/srt/layers/quantization/fp8.py b/python/sglang/srt/layers/quantization/fp8.py index 776229b8c..b9ca03d8c 100644 --- a/python/sglang/srt/layers/quantization/fp8.py +++ b/python/sglang/srt/layers/quantization/fp8.py @@ -2506,6 +2506,18 @@ class Fp8MoEMethod(FusedMoEMethodBase): else: moe_runner_backend = MoeRunnerBackend.TRITON + if ( + moe_runner_backend.is_flashinfer_cutlass() + or moe_runner_backend.is_flashinfer_cutedsl() + ): + # Neither runner has an fp8 MoE path; they get pinned globally for + # NVFP4 experts on sm120, so run this layer's fp8 experts on triton. + logger.info( + "Fp8MoEMethod has no %s path; using triton for its fp8 experts.", + moe_runner_backend.name, + ) + moe_runner_backend = MoeRunnerBackend.TRITON + if ( moe_runner_backend.is_deep_gemm() or moe_runner_backend.is_triton() diff --git a/python/sglang/srt/layers/quantization/modelopt_quant.py b/python/sglang/srt/layers/quantization/modelopt_quant.py index bcad61e6f..1add64f5c 100755 --- a/python/sglang/srt/layers/quantization/modelopt_quant.py +++ b/python/sglang/srt/layers/quantization/modelopt_quant.py @@ -802,12 +802,14 @@ class ModelOptMixedPrecisionConfig(ModelOptQuantConfig): nvfp4_config: ModelOptFp4Config, nvfp4a16_config: ModelOptFp4Config, mxfp8_config: Fp8Config, + fp8_block_config: Fp8Config, ) -> None: super().__init__(kv_cache_quant_algo, exclude_modules, packed_modules_mapping) self.quantized_layers = quantized_layers self.fp8_config = fp8_config self.fp8_pb_wo_config = fp8_pb_wo_config self.mxfp8_config = mxfp8_config + self.fp8_block_config = fp8_block_config self.nvfp4_config = nvfp4_config self.nvfp4a16_config = nvfp4a16_config @@ -864,6 +866,9 @@ class ModelOptMixedPrecisionConfig(ModelOptQuantConfig): exclude_modules = quantization_section.get("exclude_modules") quantized_layers = quantization_section.get("quantized_layers", {}) + # ModelOpt emits `ignore: []` or omits it; is_layer_skipped iterates it. + exclude_modules = list(exclude_modules or []) + if quant_algo != "MIXED_PRECISION": raise ValueError( "ModelOptMixedPrecisionConfig only supports MIXED_PRECISION checkpoints." @@ -904,6 +909,13 @@ class ModelOptMixedPrecisionConfig(ModelOptQuantConfig): packed_modules_mapping=packed_modules_mapping, use_mxfp8=True, ) + # ModelOpt FP8_BLOCK_SCALES: 128x128 block fp8 with weight_scale_inv. + fp8_block_config = Fp8Config( + is_checkpoint_fp8_serialized=True, + activation_scheme="dynamic", + weight_block_size=[128, 128], + packed_modules_mapping=packed_modules_mapping, + ) nvfp4_config = ModelOptFp4Config( is_checkpoint_nvfp4_serialized=True, kv_cache_quant_algo=kv_cache_quant_algo, @@ -928,6 +940,7 @@ class ModelOptMixedPrecisionConfig(ModelOptQuantConfig): fp8_config=fp8_config, fp8_pb_wo_config=fp8_pb_wo_config, mxfp8_config=mxfp8_config, + fp8_block_config=fp8_block_config, nvfp4_config=nvfp4_config, nvfp4a16_config=nvfp4a16_config, ) @@ -986,9 +999,17 @@ class ModelOptMixedPrecisionConfig(ModelOptQuantConfig): candidates.append( "language_model.model." + prefix[len("model.language_model.") :] ) + candidates.append("model." + prefix[len("model.language_model.") :]) + elif prefix.startswith("model."): + # VL models such as Qwen4-Exp name the text stack `model.layers.*` + # while ModelOpt keys it `model.language_model.layers.*`. + candidates.append("model.language_model." + prefix[len("model.") :]) return tuple(dict.fromkeys(candidates)) + def resolve_quant_algo(self, prefix: str) -> Optional[str]: + return self._resolve_quant_algo(prefix) + def get_quant_method( self, layer: torch.nn.Module, prefix: str ) -> Optional[QuantizeMethodBase]: @@ -1010,6 +1031,8 @@ class ModelOptMixedPrecisionConfig(ModelOptQuantConfig): return ModelOptFp8LinearMethod(self.fp8_config) if quant_algo == "FP8_PB_WO": return Fp8LinearMethod(self.fp8_pb_wo_config) + if quant_algo == "FP8_BLOCK_SCALES": + return Fp8LinearMethod(self.fp8_block_config) if quant_algo == "MXFP8": return Fp8LinearMethod(self.mxfp8_config) if quant_algo == "NVFP4": @@ -1039,6 +1062,8 @@ class ModelOptMixedPrecisionConfig(ModelOptQuantConfig): return ModelOptFp8MoEMethod(self.fp8_config) if quant_algo == "MXFP8": return Fp8MoEMethod(self.mxfp8_config) + if quant_algo == "FP8_BLOCK_SCALES": + return Fp8MoEMethod(self.fp8_block_config) if quant_algo == "NVFP4": return ModelOptNvFp4FusedMoEMethod(self.nvfp4_config) if quant_algo == "W4A16_NVFP4": diff --git a/python/sglang/srt/model_executor/model_runner_components/load_model_utils.py b/python/sglang/srt/model_executor/model_runner_components/load_model_utils.py index bbee9773b..b43cfdb45 100644 --- a/python/sglang/srt/model_executor/model_runner_components/load_model_utils.py +++ b/python/sglang/srt/model_executor/model_runner_components/load_model_utils.py @@ -275,6 +275,27 @@ def load_model_with_memory_saver( ) if is_qwen4_exp: model_config.hf_text_config.ple_offload_embedding = ple_offload_embedding + model_config.hf_text_config.ple_offload_backend = ( + get_exec().offload.ple_offload_backend + ) + if get_exec().offload.ple_offload_backend != "file": + model_config.hf_text_config.ple_offload_dir = ( + get_exec().offload.ple_offload_dir + ) + else: + from sglang.srt.models.qwen4_exp_ple_table import ( + check_file_backend_supported, + default_ple_table_dir, + ) + + model_config.hf_text_config.ple_offload_dir = ( + get_exec().offload.ple_offload_dir + or default_ple_table_dir(get_model().model_path) + ) + if ple_offload_embedding and device == "cuda": + check_file_backend_supported( + torch.cuda.current_device() if torch.cuda.is_available() else 0 + ) enable_cpu_backup = get_exec().features.enable_weights_cpu_backup or ( is_draft_worker and get_exec().features.enable_draft_weights_cpu_backup diff --git a/python/sglang/srt/models/qwen3_5_mtp.py b/python/sglang/srt/models/qwen3_5_mtp.py index f116e12c9..31d066a58 100644 --- a/python/sglang/srt/models/qwen3_5_mtp.py +++ b/python/sglang/srt/models/qwen3_5_mtp.py @@ -58,12 +58,14 @@ def _mtp_quant_config(quant_config): # Serialized Qwen3.5 ModelOpt checkpoints keep embedded MTP weights in # BF16. Disable quantization for those checkpoints; non-serialized # modelopt_fp4 still converts MoE expert weights on load. + if quant_config and quant_config.get_name() == "modelopt_mixed": + # MIXED_PRECISION lists mtp.* layers only when the MTP head is quantized. + if any(name.startswith("mtp.") for name in quant_config.quantized_layers): + return quant_config + return None if quant_config and ( - quant_config.get_name() == "modelopt_mixed" - or ( - quant_config.get_name() == "modelopt_fp4" - and quant_config.is_checkpoint_nvfp4_serialized - ) + quant_config.get_name() == "modelopt_fp4" + and quant_config.is_checkpoint_nvfp4_serialized ): return None if is_npu() and get_spec().speculative_draft_model_quantization is None: diff --git a/python/sglang/srt/models/qwen4_exp.py b/python/sglang/srt/models/qwen4_exp.py index 32a51803e..8275c3fbb 100644 --- a/python/sglang/srt/models/qwen4_exp.py +++ b/python/sglang/srt/models/qwen4_exp.py @@ -43,6 +43,9 @@ from sglang.srt.layers.logits_processor import LogitsProcessorOutput from sglang.srt.layers.moe import get_moe_a2a_backend, should_use_dp_reduce_scatterv from sglang.srt.layers.moe.fused_moe_triton.layer import FusedMoE from sglang.srt.layers.quantization.base_config import QuantizationConfig +from sglang.srt.layers.quantization.modelopt_quant import ( + ModelOptMixedPrecisionConfig, +) from sglang.srt.layers.quantization.unquant import UnquantizedEmbeddingMethod from sglang.srt.layers.utils import get_layer_id from sglang.srt.layers.vocab_parallel_embedding import VocabParallelEmbedding @@ -60,6 +63,11 @@ from sglang.srt.models.qwen3_5 import ( Qwen3_5LinearDecoderLayer, ) from sglang.srt.models.qwen3_vl import Qwen3VLForConditionalGeneration +from sglang.srt.models.qwen4_exp_ple_table import ( + allocate_ple_host_table, + make_ple_file_prefetcher, + make_ple_file_rss_trimmer, +) from sglang.srt.runtime_context import get_parallel from sglang.srt.utils import logger @@ -68,6 +76,24 @@ from sglang.srt.utils import logger _QSA_INDEXER_OVERLAP_TOKEN_THRESHOLD = 1024 +def _ple_table_is_fp8( + config: Qwen4ExpTextConfig, + quant_config: Optional[QuantizationConfig], + prefix: str, +) -> bool: + """fp8 PLE shards: declared by config, an fp8 checkpoint, or a ModelOpt + MIXED_PRECISION entry for the ngram table (nvidia/*-Flash-Next-NVFP4).""" + if config.ple_embedding_dtype == "float8_e4m3fn": + return True + if quant_config is None: + return False + if quant_config.get_name() == "fp8": + return True + if isinstance(quant_config, ModelOptMixedPrecisionConfig): + return quant_config.resolve_quant_algo(prefix) == "FP8" + return False + + def _get_ple_forward_mode(forward_batch: ForwardBatch) -> ForwardMode: if forward_batch._original_forward_mode is not None: return forward_batch._original_forward_mode @@ -423,6 +449,7 @@ class Qwen4ExpNGramEmbedding(nn.Module): embedding_dim: int, ple_layer_index: int = 0, quant_config: Optional[QuantizationConfig] = None, + prefix: str = "", ) -> None: super().__init__() self.config = config @@ -479,13 +506,13 @@ class Qwen4ExpNGramEmbedding(nn.Module): and get_attention_dp_size() > 1 and not self.use_attn_tp_ngram ) + ngram_prefix = f"{prefix}.ngram_embedding" if prefix else "ngram_embedding" self.ngram_embedding = VocabParallelEmbedding( padded_vocab_size, self.head_dim_per_ngram, params_dtype=( torch.float8_e4m3fn - if (quant_config is not None and quant_config.get_name() == "fp8") - or getattr(config, "ple_embedding_dtype", None) == "float8_e4m3fn" + if _ple_table_is_fp8(config, quant_config, ngram_prefix) else torch.bfloat16 ), output_dtype=torch.bfloat16, @@ -739,7 +766,7 @@ def _gather_ple_embedding_from_pinned_kernel( class Qwen4ExpPinnedHostEmbedding(VocabParallelEmbedding): - """PLE table read directly from pinned host memory. + """PLE table read directly from host memory (pinned, or a file-backed mmap). The table stays in its checkpoint storage dtype (fp8 with a per-tensor weight_scale for fp8 checkpoints, bf16 otherwise); gathers emit bf16. @@ -764,7 +791,13 @@ class Qwen4ExpPinnedHostEmbedding(VocabParallelEmbedding): "num_added_embeddings_per_partition", ) - def __init__(self, embedding: VocabParallelEmbedding) -> None: + def __init__( + self, + embedding: VocabParallelEmbedding, + *, + backend: str = "pinned", + table_dir: Optional[str] = None, + ) -> None: nn.Module.__init__(self) if not isinstance(embedding.quant_method, UnquantizedEmbeddingMethod): raise NotImplementedError( @@ -786,15 +819,23 @@ class Qwen4ExpPinnedHostEmbedding(VocabParallelEmbedding): self.quant_method = None source_weight = embedding.weight - cpu_weight = nn.Parameter( - torch.empty( - source_weight.shape, - dtype=source_weight.dtype, - device="cpu", - pin_memory=True, + host_table = allocate_ple_host_table( + shape=source_weight.shape, + dtype=source_weight.dtype, + backend=backend, + table_dir=table_dir, + # Each TP rank holds a different vocabulary shard of the same shape. + tag=( + f"rows{self.shard_indices.org_vocab_start_index}" + f"-{self.shard_indices.org_vocab_end_index}" ), - requires_grad=False, ) + # Only the file backend has anything to prefetch (rows live on storage). + self._file_prefetcher = make_ple_file_prefetcher(host_table) + # ... and only it needs its resident set bounded: a fault maps a whole + # folio, so the mapping would otherwise creep towards the full table. + self._file_rss_trimmer = make_ple_file_rss_trimmer(host_table) + cpu_weight = nn.Parameter(host_table, requires_grad=False) for name, value in vars(source_weight).items(): setattr(cpu_weight, name, value) cpu_weight.weight_loader = self.weight_loader @@ -837,6 +878,12 @@ class Qwen4ExpPinnedHostEmbedding(VocabParallelEmbedding): flat_ids = input_ids.reshape(-1).long() if flat_ids.numel(): + if self._file_prefetcher is not None: + self._file_prefetcher.enqueue( + flat_ids, + vocab_start=self.shard_indices.org_vocab_start_index, + vocab_end=self.shard_indices.org_vocab_end_index, + ) _gather_ple_embedding_from_pinned_kernel[(flat_ids.numel(),)]( self.weight.data_ptr(), flat_ids, @@ -881,10 +928,13 @@ class Qwen4ExpPLELayer(nn.Module): self.ple_embed_dim, ple_layer_index=ple_layer_index, quant_config=quant_config, + prefix=f"{prefix}.ple_embedding" if prefix else "ple_embedding", ) if config.ple_offload_embedding: self.ple_embedding.ngram_embedding = Qwen4ExpPinnedHostEmbedding( - self.ple_embedding.ngram_embedding + self.ple_embedding.ngram_embedding, + backend=getattr(config, "ple_offload_backend", "pinned"), + table_dir=getattr(config, "ple_offload_dir", None), ) self.short_conv_dilation = self.ple_embedding.ngram_size self.short_conv_state_len = ( diff --git a/python/sglang/srt/models/qwen4_exp_ple_table.py b/python/sglang/srt/models/qwen4_exp_ple_table.py new file mode 100644 index 000000000..2e7a02c0a --- /dev/null +++ b/python/sglang/srt/models/qwen4_exp_ple_table.py @@ -0,0 +1,483 @@ +"""Host-side storage for the offloaded Qwen4-Exp PLE n-gram table. + +``--ple-offload-embedding`` keeps the PLE table (47.7 GiB in fp8 for +Qwen3.8-Flash-Next) out of device memory and lets the Triton gather kernel read +rows straight from a host pointer. Two backends provide that pointer: + +``pinned`` (default) + ``torch.empty(..., pin_memory=True)``. On a discrete GPU this frees VRAM. + +``file`` + A file-backed, shared ``mmap`` of a sparse file under + ``--ple-offload-dir``. Meant for unified-memory parts (GB10 / DGX Spark and + similar), where pinned host memory comes out of the *same* pool as the + model weights and ``pinned`` therefore frees nothing: Qwen3.8-Flash-Next is + 126.0 GiB of weights on a 121.63 GiB box and does not boot with ``pinned``. + The kernel dereferences the pageable pointer directly, which only works on + devices that report ``cudaDevAttrPageableMemoryAccessUsesHostPageTables``; + rows are paged in from storage on demand, the file is sparse, deterministic + in name and reused across restarts, and gathers of prefill size hint the + page cache (``posix_fadvise(WILLNEED)``) so page faults are served + concurrently instead of one at a time. A background trimmer keeps the + mapping's resident set under a budget, because faulting rows in maps whole + page-cache folios and the table would otherwise creep towards full + residency (see ``PleFileRssTrimmer``). + +This module has no Triton or CUDA-kernel imports so that its allocator and +prefetcher can be unit-tested on CPU. +""" + +from __future__ import annotations + +import ctypes +import ctypes.util +import logging +import os +import re +import threading +from concurrent.futures import ThreadPoolExecutor +from typing import Optional, Sequence + +import torch + +from sglang.srt.environ import envs + +logger = logging.getLogger(__name__) + +_LIBC: Optional[ctypes.CDLL] = None +_SMAPS_HEADER = re.compile(r"^([0-9a-f]+)-([0-9a-f]+) ") +_SMAPS_RSS = re.compile(r"^Rss:\s+(\d+) kB") + +PLE_OFFLOAD_BACKENDS = ("pinned", "file") + +# cudaDeviceAttr enum values (cuda_runtime_api.h). +_CUDA_DEV_ATTR_PAGEABLE_MEMORY_ACCESS_USES_HOST_PAGE_TABLES = 100 +_MADV_RANDOM = 1 +_MADV_DONTNEED = 4 +_PAGE_SHIFT = 12 +# One MADV_DONTNEED call takes mmap_lock for its whole range; over the full +# 47.7 GiB table that is ~3.5 s during which every fault in the process -- +# including the ones the gather kernel takes -- stalls. Trim in slices. +PLE_FILE_RSS_TRIM_CHUNK_BYTES = 1 << 30 +# Below this many rows a gather is decode-sized (16 rows per token): the page +# faults are cheap and the host-side hint would cost more than it saves. +PLE_FILE_PREFETCH_MIN_ROWS = 2048 + + +class PleFilePrefetcher: + """Hint the page cache about the rows a prefill-sized gather is about to read. + + With the table on storage, a cold prefill chunk faults tens of thousands of + 4 KiB pages one at a time from inside the gather kernel. Advising them + first (``posix_fadvise(WILLNEED)`` per distinct page, on one background + thread) lets the block layer serve them concurrently. Measured on a GB10 / + NVMe: cold prefill 650-750 tok/s -> 1,000-2,100 tok/s (warm: ~2,200-2,600). + Decode-sized gathers are skipped; nothing runs during CUDA-graph capture. + """ + + def __init__( + self, + path: str, + row_bytes: int, + min_rows: int = PLE_FILE_PREFETCH_MIN_ROWS, + ) -> None: + self._fd = os.open(path, os.O_RDONLY) + self._row_bytes = int(row_bytes) + self._min_rows = int(min_rows) + self._pool = ThreadPoolExecutor(max_workers=1) + + @staticmethod + def pages_for_rows(row_ids: torch.Tensor, row_bytes: int) -> list[int]: + start = row_ids.to(torch.int64) * row_bytes + end = start + (row_bytes - 1) + return ( + torch.cat([start >> _PAGE_SHIFT, end >> _PAGE_SHIFT]) + .unique(sorted=True) + .tolist() + ) + + def _advise(self, pages: list[int]) -> None: + for p in pages: + try: + os.posix_fadvise( + self._fd, p << _PAGE_SHIFT, 1 << _PAGE_SHIFT, os.POSIX_FADV_WILLNEED + ) + except OSError: + return + + def enqueue( + self, + flat_ids: torch.Tensor, + *, + vocab_start: int = 0, + vocab_end: Optional[int] = None, + ) -> bool: + """Queue the hint for ``flat_ids``. Returns whether anything was queued.""" + if flat_ids.numel() < self._min_rows: + return False + if flat_ids.is_cuda and torch.cuda.is_current_stream_capturing(): + return False + # The .cpu() syncs the stream; acceptable for prefill chunks (~1 s) and + # it is what lets the page set be computed without touching the kernel. + row_ids = flat_ids.detach().cpu() + if vocab_end is not None: + # The file contains only this rank's vocabulary shard. + row_ids = row_ids[(row_ids >= vocab_start) & (row_ids < vocab_end)] + row_ids = row_ids - vocab_start + if row_ids.numel() == 0: + return False + pages = self.pages_for_rows(row_ids, self._row_bytes) + self._pool.submit(self._advise, pages) + return True + + def close(self) -> None: + self._pool.shutdown(wait=False) + try: + os.close(self._fd) + except OSError: + pass + + +class PleFileRssTrimmer: + """Keep the mapped table's resident set under a budget. + + Every random row fault maps in a whole page-cache folio, so with large + folios (Linux 6.x) the mapping's Rss climbs towards the table's full size + while a generated token only reads a few KB of it: measured ~45 KB of Rss + growth per token on a GB10. On a unified-memory part that is not a slow + leak, it is a countdown -- the free-memory readings that size the KV pool + come from the same pool the folios are accumulating in. + + ``MADV_RANDOM`` does not prevent it (it limits readahead I/O, not the + mapping-in of folios already in cache) and ``posix_fadvise(DONTNEED)`` does + not release them either. ``MADV_DONTNEED`` over the mapping does: the page + table entries go, the pages stay in the page cache, and hot rows come back + at minor-fault cost. + + Dropping entries under a running gather is the state this backend already + handles: the file starts out entirely unfaulted and every cold row is + faulted in from inside the kernel through the same host page tables. What + must not happen is one ``madvise`` call over the whole table, so the trim + is chunked (see ``PLE_FILE_RSS_TRIM_CHUNK_BYTES``) and runs on its own + daemon thread -- decode replays a CUDA graph and executes no Python, so a + hook in the gather would never fire in the phase that grows the table. + """ + + def __init__( + self, + addr: int, + nbytes: int, + budget_bytes: int, + interval_s: float, + chunk_bytes: int = PLE_FILE_RSS_TRIM_CHUNK_BYTES, + ) -> None: + self._addr = int(addr) + self._nbytes = int(nbytes) + self._budget = int(budget_bytes) + self._interval = float(interval_s) + self._chunk = int(chunk_bytes) + self._stop = threading.Event() + self._thread = threading.Thread( + target=self._loop, name="ple-file-rss-trim", daemon=True + ) + + def start(self) -> None: + self._thread.start() + + def mapping_rss_bytes(self) -> Optional[int]: + """Resident bytes of the VMAs backing the table, or None off Linux.""" + return _mapping_rss_bytes(self._addr, self._nbytes) + + def trim_once(self) -> int: + """Drop the mapping's resident pages if over budget. Returns bytes freed.""" + before = self.mapping_rss_bytes() + if before is None or before <= self._budget: + return 0 + for offset in range(0, self._nbytes, self._chunk): + if self._stop.is_set(): + break + length = min(self._chunk, self._nbytes - offset) + if not _madvise(self._addr + offset, length, _MADV_DONTNEED): + return 0 + # Let the faults that queued behind mmap_lock through. + self._stop.wait(0.005) + after = self.mapping_rss_bytes() + freed = before - after if after is not None else 0 + logger.info( + "PLE table: trimmed resident set %.1f -> %.1f GiB (budget %.1f GiB)", + before / 2**30, + (after if after is not None else 0) / 2**30, + self._budget / 2**30, + ) + return max(freed, 0) + + def _loop(self) -> None: + while not self._stop.wait(self._interval): + try: + self.trim_once() + except Exception as exc: # advisory only; never fail a request + logger.warning("PLE table: resident-set trim skipped (%s)", exc) + + def close(self) -> None: + self._stop.set() + + +def allocate_ple_host_table( + shape: Sequence[int], + dtype: torch.dtype, + backend: str = "pinned", + table_dir: Optional[str] = None, + tag: Optional[str] = None, +) -> torch.Tensor: + """Return a host tensor of ``shape``/``dtype`` for the PLE table. + + For the file backend, ``table_dir`` should be private to one checkpoint + (the server defaults it to ``$SGLANG_CACHE_DIR/ple/``): the + file name only encodes shape, dtype and ``tag``, and every boot rewrites + the whole table through the weight loader. + """ + if backend not in PLE_OFFLOAD_BACKENDS: + raise ValueError( + f"unknown PLE offload backend {backend!r}; choose from {PLE_OFFLOAD_BACKENDS}" + ) + if backend == "pinned": + return torch.empty(tuple(shape), dtype=dtype, device="cpu", pin_memory=True) + + numel = 1 + for d in shape: + numel *= int(d) + nbytes = numel * torch.empty(0, dtype=dtype).element_size() + table_dir = os.path.expanduser(table_dir or envs.SGLANG_QWEN4_PLE_FILE_DIR.get()) + os.makedirs(table_dir, exist_ok=True) + path = os.path.join(table_dir, ple_table_file_name(shape, dtype, tag)) + if not os.path.exists(path) or os.path.getsize(path) != nbytes: + # Sparse: only pages that get written take disk space. + with open(path, "wb") as f: + f.truncate(nbytes) + logger.info( + "PLE table: file-backed mmap %s (%.1f GiB, %s)", path, nbytes / 2**30, dtype + ) + storage = torch.from_file(path, shared=True, size=nbytes, dtype=torch.uint8) + _madvise_random(storage, nbytes) + table = storage.view(dtype).view(*[int(d) for d in shape]) + table._sglang_ple_file_path = path # consumed by PleFilePrefetcher + return table + + +def make_ple_file_prefetcher(table: torch.Tensor) -> Optional[PleFilePrefetcher]: + """A prefetcher for a table returned by ``allocate_ple_host_table(..., "file")``.""" + path = getattr(table, "_sglang_ple_file_path", None) + if path is None or not envs.SGLANG_QWEN4_PLE_FILE_PREFETCH.get(): + return None + row_bytes = ( + int(table.shape[-1]) * table.element_size() + if table.dim() >= 2 + else table.element_size() + ) + prefetcher = PleFilePrefetcher(path=path, row_bytes=row_bytes) + logger.info( + "PLE table: WILLNEED prefetch on for gathers of >= %d rows (row = %d B)", + PLE_FILE_PREFETCH_MIN_ROWS, + row_bytes, + ) + return prefetcher + + +def make_ple_file_rss_trimmer(table: torch.Tensor) -> Optional[PleFileRssTrimmer]: + """A started trimmer for a table from ``allocate_ple_host_table(..., "file")``. + + ``SGLANG_QWEN4_PLE_FILE_RSS_BUDGET_GB=0`` turns it off; it is also absent + where the resident set cannot be read (no ``/proc/self/smaps``). + """ + path = getattr(table, "_sglang_ple_file_path", None) + if path is None: + return None + budget_gb = float(envs.SGLANG_QWEN4_PLE_FILE_RSS_BUDGET_GB.get()) + if budget_gb <= 0: + return None + nbytes = table.numel() * table.element_size() + if _mapping_rss_bytes(table.data_ptr(), nbytes) is None: + logger.warning( + "PLE table: resident-set trim off, /proc/self/smaps is not readable; " + "the mapping will creep towards %.1f GiB resident", + nbytes / 2**30, + ) + return None + trimmer = PleFileRssTrimmer( + addr=table.data_ptr(), + nbytes=nbytes, + budget_bytes=int(budget_gb * 2**30), + interval_s=float(envs.SGLANG_QWEN4_PLE_FILE_RSS_INTERVAL_S.get()), + ) + trimmer.start() + logger.info( + "PLE table: resident set capped at %.1f GiB, checked every %.0f s", + budget_gb, + float(envs.SGLANG_QWEN4_PLE_FILE_RSS_INTERVAL_S.get()), + ) + return trimmer + + +def check_file_backend_supported(device_index: int = 0) -> None: + """Fail fast at load time instead of silently reading garbage in the kernel.""" + if envs.SGLANG_QWEN4_PLE_FILE_SKIP_DEVICE_CHECK.get(): + logger.warning( + "PLE table: file backend device check skipped by " + "SGLANG_QWEN4_PLE_FILE_SKIP_DEVICE_CHECK" + ) + return + supported = device_uses_host_page_tables(device_index) + if supported is None: + raise RuntimeError( + "--ple-offload-backend file: could not query " + "cudaDevAttrPageableMemoryAccessUsesHostPageTables. Set " + "SGLANG_QWEN4_PLE_FILE_SKIP_DEVICE_CHECK=1 only if you know the " + "device reads pageable host memory through the host page tables." + ) + if not supported: + raise ValueError( + "--ple-offload-backend file needs a device whose pageable host " + "memory accesses go through the host page tables (unified-memory " + "parts such as GB10). This device reports it does not; use " + "--ple-offload-backend pinned." + ) + + +def default_ple_table_dir(model_path: str) -> str: + """``$SGLANG_QWEN4_PLE_FILE_DIR/``, one directory per checkpoint.""" + safe = re.sub(r"[^A-Za-z0-9._-]+", "_", str(model_path).rstrip("/")).strip("_") + return os.path.join(envs.SGLANG_QWEN4_PLE_FILE_DIR.get(), safe or "model") + + +def ple_table_file_name( + shape: Sequence[int], dtype: torch.dtype, tag: Optional[str] = None +) -> str: + """Deterministic file name so the sparse table is reused across restarts. + + ``tag`` distinguishes tables of the same shape that must not share a file, + e.g. the vocabulary shards of different tensor-parallel ranks. + """ + numel = 1 + for d in shape: + numel *= int(d) + elem = torch.empty(0, dtype=dtype).element_size() + dims = "x".join(str(int(d)) for d in shape) + suffix = f"_{tag}" if tag else "" + return f"ple_table_{dims}_{str(dtype).replace('torch.', '')}_{numel * elem}B{suffix}.bin" + + +def device_uses_host_page_tables(device_index: int = 0) -> Optional[bool]: + """Whether pageable host memory is directly addressable by the GPU. + + Returns None when the CUDA runtime library cannot be queried. + """ + candidates = [ctypes.util.find_library("cudart")] + torch_lib = os.path.join(os.path.dirname(torch.__file__), "lib") + if os.path.isdir(torch_lib): + candidates += sorted( + os.path.join(torch_lib, f) + for f in os.listdir(torch_lib) + if f.startswith("libcudart.so") + ) + try: + import nvidia.cuda_runtime # type: ignore + + nv_lib = os.path.join(os.path.dirname(nvidia.cuda_runtime.__file__), "lib") + if os.path.isdir(nv_lib): + candidates += sorted( + os.path.join(nv_lib, f) + for f in os.listdir(nv_lib) + if f.startswith("libcudart.so") + ) + except Exception: + pass + for name in [c for c in candidates if c]: + try: + cudart = ctypes.CDLL(name) + value = ctypes.c_int() + rc = cudart.cudaDeviceGetAttribute( + ctypes.byref(value), + ctypes.c_int( + _CUDA_DEV_ATTR_PAGEABLE_MEMORY_ACCESS_USES_HOST_PAGE_TABLES + ), + ctypes.c_int(device_index), + ) + if rc == 0: + return bool(value.value) + except OSError: + continue + return None + + +def _madvise_random(storage: torch.Tensor, nbytes: int) -> None: + """The table is pure random access (16 rows of 160 B per token). Without + this the kernel's readahead pulls its whole window: measured 1.4 MB of disk + per token, ~560x the bytes actually used. + + It bounds readahead I/O only. Folios that are already in the page cache are + still mapped in whole on a fault, which is what ``PleFileRssTrimmer`` + exists for.""" + if not _madvise(storage.data_ptr(), nbytes, _MADV_RANDOM): + logger.warning("PLE table: madvise(MADV_RANDOM) not applied") + + +def _libc() -> Optional[ctypes.CDLL]: + global _LIBC + if _LIBC is None: + try: + _LIBC = ctypes.CDLL( + ctypes.util.find_library("c") or "libc.so.6", use_errno=True + ) + except OSError: + return None + return _LIBC + + +def _madvise(addr: int, length: int, advice: int) -> bool: + """``madvise(2)`` on our own mapping. Advisory: never affects correctness.""" + libc = _libc() + if libc is None: + return False + try: + rc = libc.madvise( + ctypes.c_void_p(addr), ctypes.c_size_t(length), ctypes.c_int(advice) + ) + except Exception: + return False + if rc != 0: + logger.warning( + "PLE table: madvise(advice=%d) failed (errno %d)", + advice, + ctypes.get_errno(), + ) + return False + return True + + +def _mapping_rss_bytes( + addr: int, nbytes: int, smaps_path: str = "/proc/self/smaps" +) -> Optional[int]: + """Resident bytes of the VMAs overlapping ``[addr, addr + nbytes)``. + + Summed per mapping rather than taken from ``statm``/``smaps_rollup``: only + the table's own residency should drive the trim, and on a unified-memory + box the process RSS is dominated by everything else. + """ + lo, hi = int(addr), int(addr) + int(nbytes) + total = 0 + overlapping = False + try: + with open(smaps_path, "r") as f: + for line in f: + header = _SMAPS_HEADER.match(line) + if header is not None: + start = int(header.group(1), 16) + end = int(header.group(2), 16) + overlapping = start < hi and end > lo + elif overlapping: + rss = _SMAPS_RSS.match(line) + if rss is not None: + total += int(rss.group(1)) * 1024 + except OSError: + return None + return total diff --git a/test/registered/kernel/embeddings/test_qwen4_ple_offload.py b/test/registered/kernel/embeddings/test_qwen4_ple_offload.py index 015d5fdd4..2c4a9b03b 100644 --- a/test/registered/kernel/embeddings/test_qwen4_ple_offload.py +++ b/test/registered/kernel/embeddings/test_qwen4_ple_offload.py @@ -1,3 +1,5 @@ +import os +import tempfile from types import SimpleNamespace import pytest @@ -79,11 +81,11 @@ def _make_source_embedding( ) -def _load_rows(offloaded, rows): +def _load_rows(offloaded, rows, *, pinned=True): pointer = offloaded.weight.data_ptr() offloaded.weight_loader(offloaded.weight, rows) assert offloaded.weight.data_ptr() == pointer - assert offloaded.weight.is_pinned() + assert offloaded.weight.is_pinned() == pinned assert offloaded.weight.weight_loader.__self__ is offloaded assert offloaded.quant_method is None @@ -189,6 +191,78 @@ def test_qwen4_ple_prefetch_buffer_lifecycle(monkeypatch): assert set(layer._graph_prefetch_buffers) == {3, 5} +def _file_backend_supported() -> bool: + from sglang.srt.models.qwen4_exp_ple_table import device_uses_host_page_tables + + return ( + torch.cuda.is_available() + and device_uses_host_page_tables(torch.cuda.current_device()) is True + ) + + +@pytest.mark.skipif( + not _file_backend_supported(), + reason="the file backend needs pageable host memory reachable through host page tables", +) +@pytest.mark.parametrize("embedding_dim", [7, 160]) +def test_qwen4_ple_file_backend_matches_pinned(embedding_dim): + with tempfile.TemporaryDirectory() as table_dir: + pinned = Qwen4ExpPinnedHostEmbedding( + _make_source_embedding(embedding_dim=embedding_dim) + ) + filed = Qwen4ExpPinnedHostEmbedding( + _make_source_embedding(embedding_dim=embedding_dim), + backend="file", + table_dir=table_dir, + ) + assert pinned._file_prefetcher is None and filed._file_prefetcher is not None + (name,) = os.listdir(table_dir) + assert "rows0-8" in name # this rank's vocabulary shard + rows = torch.arange( + 8 * embedding_dim, dtype=torch.bfloat16, device="cuda" + ).reshape(8, embedding_dim) + _load_rows(pinned, rows) + _load_rows(filed, rows, pinned=False) + ids = torch.tensor([[0, 7, 3], [4, 1, 6]], dtype=torch.int64, device="cuda") + torch.testing.assert_close(filed(ids), pinned(ids), rtol=0, atol=0) + # A prefill-sized gather goes through the page-cache hint path. + big = torch.randint(0, 8, (4096,), device="cuda") + torch.testing.assert_close( + filed(big), rows.index_select(0, big), rtol=0, atol=0 + ) + + +@pytest.mark.skipif( + not _file_backend_supported(), + reason="the file backend needs pageable host memory reachable through host page tables", +) +def test_qwen4_ple_file_backend_fp8_table(): + embedding_dim = 160 + with tempfile.TemporaryDirectory() as table_dir: + filed = Qwen4ExpPinnedHostEmbedding( + _make_source_embedding( + embedding_dim=embedding_dim, dtype=torch.float8_e4m3fn + ), + backend="file", + table_dir=table_dir, + ) + assert filed.weight.dtype == torch.float8_e4m3fn + rows = ( + torch.arange(8 * embedding_dim, dtype=torch.float32, device="cuda").reshape( + 8, embedding_dim + ) + / 64 + ).to(torch.float8_e4m3fn) + _load_rows(filed, rows, pinned=False) + ids = torch.tensor([[0, 7, 3]], dtype=torch.int64, device="cuda") + expected = ( + rows.index_select(0, ids.flatten()) + .to(torch.bfloat16) + .reshape(1, 3, embedding_dim) + ) + torch.testing.assert_close(filed(ids), expected, rtol=0, atol=0) + + if __name__ == "__main__": import sys diff --git a/test/registered/unit/layers/quantization/test_fp8_moe_runner_fallback.py b/test/registered/unit/layers/quantization/test_fp8_moe_runner_fallback.py new file mode 100644 index 000000000..4b3927e32 --- /dev/null +++ b/test/registered/unit/layers/quantization/test_fp8_moe_runner_fallback.py @@ -0,0 +1,38 @@ +"""Fp8MoEMethod builds a triton runner when the global MoE runner backend is +flashinfer_cutlass or flashinfer_cutedsl, which have no fp8 MoE path.""" + +from sglang.test.ci.ci_register import register_cpu_ci + +register_cpu_ci(est_time=5, suite="base-a-test-cpu") + +import unittest +from unittest.mock import patch + +import sglang.srt.layers.quantization.fp8 as fp8 +from sglang.srt.layers.moe import MoeRunnerBackend, MoeRunnerConfig +from sglang.test.test_utils import CustomTestCase + + +class TestFp8MoeRunnerFallback(CustomTestCase): + def _runner_backend_for(self, global_backend): + method = fp8.Fp8MoEMethod.__new__(fp8.Fp8MoEMethod) + with patch.object(fp8, "get_moe_runner_backend", return_value=global_backend): + method.create_moe_runner(layer=None, moe_runner_config=MoeRunnerConfig()) + return method.runner.runner_backend + + def test_flashinfer_cutlass_falls_back_to_triton(self): + self.assertTrue( + self._runner_backend_for(MoeRunnerBackend.FLASHINFER_CUTLASS).is_triton() + ) + + def test_flashinfer_cutedsl_falls_back_to_triton(self): + self.assertTrue( + self._runner_backend_for(MoeRunnerBackend.FLASHINFER_CUTEDSL).is_triton() + ) + + def test_triton_is_kept(self): + self.assertTrue(self._runner_backend_for(MoeRunnerBackend.TRITON).is_triton()) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/registered/unit/model_loader/test_modelopt_loader.py b/test/registered/unit/model_loader/test_modelopt_loader.py index 1e8f8e374..ca2740f26 100644 --- a/test/registered/unit/model_loader/test_modelopt_loader.py +++ b/test/registered/unit/model_loader/test_modelopt_loader.py @@ -22,7 +22,12 @@ from sglang.srt.configs.model_config import ModelConfig from sglang.srt.layers.linear import ReplicatedLinear from sglang.srt.layers.logits_processor import should_apply_lm_head_quant_method from sglang.srt.layers.modelopt_utils import QUANT_CFG_CHOICES -from sglang.srt.layers.quantization.fp8 import Fp8Config, Fp8LinearMethod +from sglang.srt.layers.moe.fused_moe_triton.layer import FusedMoE +from sglang.srt.layers.quantization.fp8 import ( + Fp8Config, + Fp8LinearMethod, + Fp8MoEMethod, +) from sglang.srt.layers.quantization.modelopt_quant import ( ModelOptFp4Config, ModelOptFp4LinearMethod, @@ -1188,6 +1193,56 @@ class TestModelOptMixedPrecisionConfig(CustomTestCase): "FP8", ) + def test_mixed_precision_resolves_vl_language_model_keys(self): + # nvidia/Qwen3.8-Flash-Next-NVFP4 keys the text stack as + # `model.language_model.*` while Qwen4-Exp modules are `model.*`. + quant_config = ModelOptMixedPrecisionConfig.from_config( + { + "quant_algo": "MIXED_PRECISION", + "quantized_layers": { + "model.language_model.layers.3.mlp.experts": { + "quant_algo": "NVFP4", + "group_size": 16, + }, + "model.language_model.layers.1.ple.ple_embedding.ngram_embedding": { + "quant_algo": "FP8" + }, + "mtp.layers.0.mlp.experts": { + "quant_algo": "FP8_BLOCK_SCALES", + "group_size": 128, + }, + }, + } + ) + + self.assertEqual(quant_config.exclude_modules, []) + moe = FusedMoE.__new__(FusedMoE) + self.assertIsInstance( + quant_config.get_quant_method(moe, "mtp.layers.0.mlp.experts"), + Fp8MoEMethod, + ) + self.assertEqual( + quant_config.get_quant_method( + moe, "mtp.layers.0.mlp.experts" + ).quant_config.weight_block_size, + [128, 128], + ) + self.assertEqual( + quant_config.resolve_quant_algo("model.layers.3.mlp.experts"), "NVFP4" + ) + self.assertEqual( + quant_config.resolve_quant_algo( + "model.layers.1.ple.ple_embedding.ngram_embedding" + ), + "FP8", + ) + self.assertIsNone( + quant_config.resolve_quant_algo("model.layers.1.ple.key_proj") + ) + self.assertIsNone( + quant_config.resolve_quant_algo("model.layers.3.mlp.shared_expert") + ) + if __name__ == "__main__": unittest.main() diff --git a/test/registered/unit/models/test_qwen4_exp_ple_table.py b/test/registered/unit/models/test_qwen4_exp_ple_table.py new file mode 100644 index 000000000..81063c33f --- /dev/null +++ b/test/registered/unit/models/test_qwen4_exp_ple_table.py @@ -0,0 +1,332 @@ +"""File-backed host storage for the offloaded Qwen4-Exp PLE table. + +CPU part: the allocator builds a sparse file of exactly the table's size, hands +back a tensor with the requested shape/dtype whose writes land in the file and +survive a re-open, reuses the file across calls, replaces one of the wrong size, +and the prefetcher computes the right page set and honours its size floor. The +resident-set trimmer measures only its own mapping, drops its pages once over +budget without losing what was written through them, and is off when the budget +is zero or the mapping is pinned. + +GPU part (skipped unless the device reads pageable host memory through the host +page tables, i.e. unified-memory parts such as GB10): the production Triton +gather kernel reading from the file-backed table matches a torch gather. +""" + +import os +import tempfile +import unittest +from unittest import mock + +import torch + +from sglang.srt.models.qwen4_exp_ple_table import ( + PleFilePrefetcher, + PleFileRssTrimmer, + _mapping_rss_bytes, + allocate_ple_host_table, + default_ple_table_dir, + device_uses_host_page_tables, + make_ple_file_prefetcher, + make_ple_file_rss_trimmer, + ple_table_file_name, +) +from sglang.test.ci.ci_register import register_cpu_ci +from sglang.test.test_utils import CustomTestCase + +register_cpu_ci(est_time=10, suite="base-a-test-cpu") + + +class TestPleFileTableAllocator(CustomTestCase): + def test_file_is_sparse_and_sized_exactly(self): + with tempfile.TemporaryDirectory() as d: + table = allocate_ple_host_table((1000, 160), torch.float8_e4m3fn, "file", d) + path = os.path.join( + d, ple_table_file_name((1000, 160), torch.float8_e4m3fn) + ) + self.assertTrue(os.path.exists(path)) + self.assertEqual(os.path.getsize(path), 1000 * 160) + self.assertEqual(tuple(table.shape), (1000, 160)) + self.assertEqual(table.dtype, torch.float8_e4m3fn) + # Sparse: nothing written yet, so (almost) no blocks allocated. + self.assertLess(os.stat(path).st_blocks * 512, 64 * 1024) + + def test_writes_persist_and_file_is_reused(self): + with tempfile.TemporaryDirectory() as d: + shape, dtype = (64, 32), torch.bfloat16 + table = allocate_ple_host_table(shape, dtype, "file", d) + row = torch.arange(32, dtype=torch.float32).to(dtype) + table[7].copy_(row) # what the weight loader does, row by row + del table + again = allocate_ple_host_table(shape, dtype, "file", d) + self.assertTrue(torch.equal(again[7].float(), row.float())) + self.assertEqual(len(os.listdir(d)), 1) + + def test_wrong_sized_file_is_replaced(self): + with tempfile.TemporaryDirectory() as d: + path = os.path.join(d, ple_table_file_name((8, 8), torch.bfloat16)) + with open(path, "wb") as f: + f.write(b"\x01" * 10) + table = allocate_ple_host_table((8, 8), torch.bfloat16, "file", d) + self.assertEqual(os.path.getsize(path), 8 * 8 * 2) + self.assertEqual(tuple(table.shape), (8, 8)) + + def test_tag_separates_tensor_parallel_shards(self): + with tempfile.TemporaryDirectory() as d: + a = allocate_ple_host_table( + (8, 8), torch.bfloat16, "file", d, tag="rows0-8" + ) + b = allocate_ple_host_table( + (8, 8), torch.bfloat16, "file", d, tag="rows8-16" + ) + a.fill_(1.0) + self.assertEqual(len(os.listdir(d)), 2) + self.assertTrue(torch.all(b.float() == 0.0)) + self.assertIn( + "rows8-16", ple_table_file_name((8, 8), torch.bfloat16, "rows8-16") + ) + + def test_default_dir_is_per_checkpoint(self): + with mock.patch.dict(os.environ, {"SGLANG_QWEN4_PLE_FILE_DIR": "/cache/ple"}): + a = default_ple_table_dir("RadixArk/Qwen3.8-Flash-Next-NVFP4") + b = default_ple_table_dir("/root/.cache/huggingface/flashnext-fp8/") + self.assertEqual(a, "/cache/ple/RadixArk_Qwen3.8-Flash-Next-NVFP4") + self.assertEqual(b, "/cache/ple/root_.cache_huggingface_flashnext-fp8") + self.assertNotEqual(a, b) + + def test_unknown_backend_rejected(self): + with self.assertRaises(ValueError): + allocate_ple_host_table((4, 4), torch.bfloat16, "nvme", None) + + def test_pinned_backend_unchanged(self): + if not torch.cuda.is_available(): + self.skipTest("pinned memory needs a CUDA runtime") + table = allocate_ple_host_table((4, 4), torch.bfloat16, "pinned", None) + self.assertTrue(table.is_pinned()) + self.assertIsNone(make_ple_file_prefetcher(table)) + + +class TestPleFilePrefetcher(CustomTestCase): + def test_page_set_covers_row_start_and_end(self): + # 160-byte rows: row 25 spans bytes 4000-4159, i.e. pages 0 and 1. + pages = PleFilePrefetcher.pages_for_rows(torch.tensor([25, 0]), 160) + self.assertEqual(pages, [0, 1]) + pages = PleFilePrefetcher.pages_for_rows(torch.tensor([1000, 1000]), 160) + self.assertEqual(pages, [39]) # dedup, single page + + def test_enqueue_uses_local_tp_offsets_and_ignores_other_shards(self): + with tempfile.TemporaryDirectory() as d: + path = os.path.join(d, "t.bin") + with open(path, "wb") as f: + f.truncate(8192) + pf = PleFilePrefetcher(path, row_bytes=160, min_rows=1) + try: + with mock.patch("os.posix_fadvise") as fadvise: + self.assertFalse( + pf.enqueue( + torch.tensor([999, 1032]), vocab_start=1000, vocab_end=1032 + ) + ) + self.assertTrue( + pf.enqueue( + torch.tensor([999, 1000, 1025, 1032]), + vocab_start=1000, + vocab_end=1032, + ) + ) + pf._pool.shutdown(wait=True) + self.assertEqual( + sorted(c.args[1] for c in fadvise.call_args_list), [0, 4096] + ) + finally: + pf.close() + + def test_enqueue_respects_min_rows_and_advises_pages(self): + with tempfile.TemporaryDirectory() as d: + path = os.path.join(d, "t.bin") + with open(path, "wb") as f: + f.truncate(1 << 20) + pf = PleFilePrefetcher(path, row_bytes=160, min_rows=4) + try: + self.assertFalse(pf.enqueue(torch.tensor([1, 2, 3]))) + with mock.patch("os.posix_fadvise") as fadvise: + self.assertTrue(pf.enqueue(torch.tensor([0, 1, 2, 30]))) + pf._pool.shutdown(wait=True) + offsets = sorted(c.args[1] for c in fadvise.call_args_list) + # rows 0-2 live in page 0; row 30 (bytes 4800-4959) in page 1 + self.assertEqual(offsets, [0, 4096]) + finally: + pf.close() + + +class TestSmapsParsing(CustomTestCase): + """The parser runs anywhere; only the live mapping needs Linux.""" + + SMAPS = """00400000-00401000 r--p 00000000 08:01 1 /usr/bin/x +Size: 4 kB +Rss: 4 kB +7f0000000000-7f0004000000 rw-s 00000000 08:01 2 /cache/ple/table.bin +Size: 65536 kB +Rss: 32768 kB +7f0004000000-7f0008000000 rw-s 00000000 08:01 2 /cache/ple/table.bin +Size: 65536 kB +Rss: 1024 kB +7f0100000000-7f0100001000 rw-p 00000000 00:00 0 +Size: 4 kB +Rss: 4 kB +""" + + def _rss(self, addr, nbytes): + with tempfile.NamedTemporaryFile("w", suffix=".smaps", delete=False) as f: + f.write(self.SMAPS) + path = f.name + try: + return _mapping_rss_bytes(addr, nbytes, smaps_path=path) + finally: + os.unlink(path) + + def test_sums_every_vma_of_the_table_and_nothing_else(self): + # The table spans both of its VMAs; the unrelated ones must not count. + self.assertEqual(self._rss(0x7F0000000000, 0x8000000), (32768 + 1024) * 1024) + + def test_counts_a_partially_overlapping_vma(self): + # A range ending inside the first VMA still needs that VMA's pages. + self.assertEqual(self._rss(0x7F0000000000, 0x1000), 32768 * 1024) + + def test_ignores_unrelated_mappings(self): + self.assertEqual(self._rss(0x7F0200000000, 0x1000), 0) + + def test_missing_smaps_is_reported_as_unknown(self): + self.assertIsNone( + _mapping_rss_bytes(0x1000, 0x1000, smaps_path="/nonexistent/smaps") + ) + + +class TestPleFileRssTrimmerConfig(CustomTestCase): + def test_budget_zero_disables_the_trimmer(self): + with tempfile.TemporaryDirectory() as d: + table = allocate_ple_host_table((64, 32), torch.bfloat16, "file", d) + with mock.patch.dict( + os.environ, {"SGLANG_QWEN4_PLE_FILE_RSS_BUDGET_GB": "0"} + ): + self.assertIsNone(make_ple_file_rss_trimmer(table)) + + def test_pinned_table_has_no_trimmer(self): + if not torch.cuda.is_available(): + self.skipTest("pinned memory needs a CUDA runtime") + table = allocate_ple_host_table((4, 4), torch.bfloat16, "pinned", None) + self.assertIsNone(make_ple_file_rss_trimmer(table)) + + +@unittest.skipUnless( + os.path.exists("/proc/self/smaps"), + "the resident set of a mapping is only readable on Linux", +) +class TestPleFileRssTrimmer(CustomTestCase): + # 64 MiB: large enough that the Rss of the mapping stands out, small + # enough to write in a CPU test. + SHAPE = (32768, 1024) + NBYTES = 32768 * 1024 * 2 + + def _trimmer(self, table, budget_bytes): + return PleFileRssTrimmer( + addr=table.data_ptr(), + nbytes=self.NBYTES, + budget_bytes=budget_bytes, + interval_s=3600.0, + chunk_bytes=16 << 20, # several chunks, as in production + ) + + def test_measures_its_own_mapping_only(self): + with tempfile.TemporaryDirectory() as d: + table = allocate_ple_host_table(self.SHAPE, torch.bfloat16, "file", d) + trimmer = self._trimmer(table, 0) + empty = trimmer.mapping_rss_bytes() + table.fill_(1.0) # touches every page + touched = trimmer.mapping_rss_bytes() + self.assertIsNotNone(touched) + self.assertGreater(touched, empty) + self.assertGreater(touched, self.NBYTES // 2) + # Never the whole process: only the VMAs backing this table. + self.assertLessEqual(touched, self.NBYTES + (16 << 20)) + + def test_over_budget_drops_pages_and_keeps_the_data(self): + with tempfile.TemporaryDirectory() as d: + table = allocate_ple_host_table(self.SHAPE, torch.bfloat16, "file", d) + table.fill_(1.0) + table[7][3] = 2.0 + trimmer = self._trimmer(table, budget_bytes=1 << 20) + before = trimmer.mapping_rss_bytes() + freed = trimmer.trim_once() + after = trimmer.mapping_rss_bytes() + self.assertGreater(freed, 0) + self.assertLess(after, before // 2) + # MADV_DONTNEED on a shared file mapping drops the page-table + # entries, not the page cache: the writes are still there. + self.assertEqual(table[7][3].item(), 2.0) + self.assertEqual(table[9][3].item(), 1.0) + + def test_under_budget_is_a_no_op(self): + with tempfile.TemporaryDirectory() as d: + table = allocate_ple_host_table(self.SHAPE, torch.bfloat16, "file", d) + table.fill_(1.0) + trimmer = self._trimmer(table, budget_bytes=self.NBYTES * 4) + before = trimmer.mapping_rss_bytes() + self.assertEqual(trimmer.trim_once(), 0) + self.assertEqual(trimmer.mapping_rss_bytes(), before) + + def test_factory_starts_and_stops_a_thread(self): + with tempfile.TemporaryDirectory() as d: + table = allocate_ple_host_table((64, 32), torch.bfloat16, "file", d) + with mock.patch.dict( + os.environ, + { + "SGLANG_QWEN4_PLE_FILE_RSS_BUDGET_GB": "1", + "SGLANG_QWEN4_PLE_FILE_RSS_INTERVAL_S": "3600", + }, + ): + trimmer = make_ple_file_rss_trimmer(table) + self.assertIsNotNone(trimmer) + try: + self.assertTrue(trimmer._thread.is_alive()) + finally: + trimmer.close() + trimmer._thread.join(timeout=5) + self.assertFalse(trimmer._thread.is_alive()) + + +@unittest.skipUnless( + torch.cuda.is_available() and device_uses_host_page_tables(0) is True, + "needs a device that reads pageable host memory through the host page tables", +) +class TestPleFileTableGatherOnDevice(CustomTestCase): + def test_triton_gather_reads_file_backed_table(self): + import triton + + from sglang.srt.models.qwen4_exp import ( + _gather_ple_embedding_from_pinned_kernel, + ) + + rows, dim = 4096, 160 + with tempfile.TemporaryDirectory() as d: + table = allocate_ple_host_table((rows, dim), torch.bfloat16, "file", d) + table.copy_(torch.randn(rows, dim).to(torch.bfloat16)) + ids = torch.randint(0, rows, (2048,), device="cuda") + out = torch.empty(2048, dim, dtype=torch.bfloat16, device="cuda") + _gather_ple_embedding_from_pinned_kernel[(ids.numel(),)]( + table.data_ptr(), + ids, + out, + embedding_dim=dim, + tp_vocab_start=0, + tp_vocab_end=rows, + is_fp8=False, + BLOCK_D=triton.next_power_of_2(dim), + ) + torch.cuda.synchronize() + expected = table[ids.cpu()].to("cuda") + self.assertTrue(torch.equal(out, expected)) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/registered/unit/models/test_shared_experts_fusion_gates.py b/test/registered/unit/models/test_shared_experts_fusion_gates.py index ede82ad1d..0e99a1817 100644 --- a/test/registered/unit/models/test_shared_experts_fusion_gates.py +++ b/test/registered/unit/models/test_shared_experts_fusion_gates.py @@ -656,7 +656,20 @@ class TestWrapperEntryClassGates(_FusionGateCase): ) # The normalization the constructor applies, shared with the gate. - self.assertIsNone(_mtp_quant_config(_quant("modelopt_mixed"))) + mixed_bf16_mtp = SimpleNamespace( + get_name=lambda: "modelopt_mixed", + quantized_layers={"model.layers.0.mlp.experts": {"quant_algo": "NVFP4"}}, + ) + self.assertIsNone(_mtp_quant_config(mixed_bf16_mtp)) + # MIXED_PRECISION checkpoints that quantize the MTP head keep it. + mixed_fp8_mtp = SimpleNamespace( + get_name=lambda: "modelopt_mixed", + quantized_layers={ + "model.layers.0.mlp.experts": {"quant_algo": "NVFP4"}, + "mtp.layers.0.mlp.experts": {"quant_algo": "FP8_BLOCK_SCALES"}, + }, + ) + self.assertIs(_mtp_quant_config(mixed_fp8_mtp), mixed_fp8_mtp) serialized = SimpleNamespace( get_name=lambda: "modelopt_fp4", is_checkpoint_nvfp4_serialized=True ) diff --git a/test/registered/unit/test_model_overrides.py b/test/registered/unit/test_model_overrides.py index 4dc5b5a3d..ab2c54d45 100644 --- a/test/registered/unit/test_model_overrides.py +++ b/test/registered/unit/test_model_overrides.py @@ -637,6 +637,22 @@ class TestGoldenModelOverrides(_IsolatedPublish): pp_size=2, ) + def test_qwen4_ple_file_requires_offload(self): + qwen4 = ("Qwen4ExpForConditionalGeneration", "qwen4_exp") + with override_platform(is_cuda=True): + sa = self._construct( + *qwen4, + ple_offload_embedding=True, + ple_offload_backend="file", + ple_offload_dir="/tmp/ple", + ) + self.assertEqual(self._resolved(sa, "ple_offload_backend"), "file") + self.assertEqual(self._resolved(sa, "ple_offload_dir"), "/tmp/ple") + with self.assertRaisesRegex(ValueError, "requires --ple-offload-embedding"): + self._construct( + *qwen4, ple_offload_embedding=False, ple_offload_backend="file" + ) + def test_qwen4_ple_offload_default(self): qwen4 = ("Qwen4ExpForConditionalGeneration", "qwen4_exp") with override_platform(is_cuda=True): @@ -3072,6 +3088,93 @@ class TestGoldenModelOverrides(_IsolatedPublish): with override_platform(is_sm100=False): self.assertEqual(_qwen3_moe_family_overrides(None, None), {}) + def test_qwen3_moe_family_mixed_precision_moe_runner(self): + from sglang.srt.arg_groups.model_overrides.qwen3_moe import ( + _qwen3_moe_family_overrides, + ) + + def _mixed(expert_algo): + return SimpleNamespace( + architectures=["Qwen4ExpForConditionalGeneration"], + quantization_config={ + "quant_method": "modelopt_mixed", + "quantized_layers": { + "model.language_model.layers.0.mlp.experts": { + "quant_algo": expert_algo + } + }, + }, + ) + + args = SimpleNamespace( + quantization="modelopt_mixed", + _quantization_explicitly_unset=False, + moe_a2a_backend="none", + moe_runner_backend="auto", + ) + with override_platform(is_sm100=True): + # W4A4 experts take trtllm-gen like modelopt_fp4; W4A16 has no + # trtllm-gen kernel and goes to marlin. + self.assertEqual( + _qwen3_moe_family_overrides(args, _mixed("NVFP4")), + {"moe_runner_backend": "flashinfer_trtllm"}, + ) + self.assertEqual( + _qwen3_moe_family_overrides(args, _mixed("W4A16_NVFP4")), + {"moe_runner_backend": "marlin"}, + ) + + def test_qwen3_moe_family_w4a16_explicit_runner(self): + """Keep opted-in CuTe DSL v2 W4A16 accepted and auto routed to Marlin.""" + from sglang.srt.arg_groups.model_overrides.qwen3_moe import ( + _qwen3_moe_family_overrides, + ) + + hf_config = SimpleNamespace( + architectures=["Qwen4ExpForConditionalGeneration"], + quantization_config={ + "quant_method": "modelopt_mixed", + "quantized_layers": { + "model.language_model.layers.0.mlp.experts": { + "quant_algo": "W4A16_NVFP4" + } + }, + }, + ) + cases = [ + ("auto", "none", False, {"moe_runner_backend": "marlin"}), + ("auto", "none", True, {"moe_runner_backend": "marlin"}), + ("marlin", "none", False, {}), + ("marlin", "none", True, {}), + ("flashinfer_cutedsl", "none", True, {}), + ("flashinfer_cutedsl", "flashinfer", True, {}), + ("flashinfer_cutedsl", "none", False, None), + ("flashinfer_cutedsl", "flashinfer", False, None), + ("flashinfer_cutedsl", "deepep", True, None), + ("flashinfer_cutlass", "none", True, None), + ("flashinfer_trtllm", "none", True, None), + ] + for runner, a2a, w4a16_enabled, expected in cases: + with ( + self.subTest(runner=runner, a2a=a2a, w4a16=w4a16_enabled), + override_platform(is_sm100=True), + envs.SGLANG_FLASHINFER_CUTEDSL_NVFP4_W4A16.override(w4a16_enabled), + ): + args = SimpleNamespace( + quantization=None, + _quantization_explicitly_unset=False, + moe_a2a_backend=a2a, + moe_runner_backend=runner, + ) + if expected is None: + with self.assertRaisesRegex(ValueError, "W4A16_NVFP4"): + _qwen3_moe_family_overrides(args, hf_config) + else: + self.assertEqual( + _qwen3_moe_family_overrides(args, hf_config), + {"quantization": "modelopt_mixed", **expected}, + ) + def test_step3p_declarations_at_callable_level(self): from sglang.srt.arg_groups.overrides import _step3p_overrides