diff --git a/README.md b/README.md
index 4bfd839..a08c702 100644
--- a/README.md
+++ b/README.md
@@ -15,8 +15,25 @@
+
+
+
+
+
-
+
+
+
+
+ 📖 Documentation
+ ·
+ 🚀 Quick Start
+ ·
+ 🌐 HSDP Guide
+ ·
+ 🏛️ Architecture
+ ·
+ 🇨🇳 中文文档
**DMuon** is a high-performance distributed implementation of the Muon optimizer that drops
diff --git a/dmuon/__init__.py b/dmuon/__init__.py
index 898dae5..de69e96 100644
--- a/dmuon/__init__.py
+++ b/dmuon/__init__.py
@@ -41,6 +41,13 @@
clip_grad_norm_,
register_muon_grad_clip_strategy,
)
+from .fast_clip import (
+ GradClipBucket,
+ GradClipBucketStats,
+ SegmentedGradClipResult,
+ clip_grad_norm_buckets_,
+ try_clip_optimizer_grad_norm_buckets_,
+)
from .diagnostics import summarize_comm_plan, summarize_param_groups
from .policy import DMuonParamPolicy
from .optim import (
@@ -89,8 +96,13 @@
"DedicatedCommContext",
"DMuonParamPolicy",
"clip_grad_norm_",
+ "clip_grad_norm_buckets_",
+ "try_clip_optimizer_grad_norm_buckets_",
"register_muon_grad_clip_strategy",
"MuonGradClipStats",
+ "GradClipBucket",
+ "GradClipBucketStats",
+ "SegmentedGradClipResult",
"summarize_comm_plan",
"summarize_param_groups",
"no_sync",
diff --git a/dmuon/_backends/fsdp2/param.py b/dmuon/_backends/fsdp2/param.py
index 0cd4d22..3a34715 100644
--- a/dmuon/_backends/fsdp2/param.py
+++ b/dmuon/_backends/fsdp2/param.py
@@ -240,7 +240,7 @@ def __init__(
self.is_tp_owner = False
# Storage: every rank in the owner's shard column keeps a populated
- # ``_owned_data``.
+ # ``_owned_data`` for owner-broadcast placement.
#
# Why not just the global owner? In HSDP mode each replicate row is
# a full model instance with its own shard_group; the shard-dim
@@ -256,8 +256,12 @@ def __init__(
# fans out to the other rows' shard-owner ranks.
#
# Ranks outside the owner's shard column never need this buffer.
+ #
+ # Sharded AdamW is different: every shard rank owns its flat shard in
+ # ``_sharded_adamw_data`` and reconstructs the forward view via
+ # all-gather. It must not also keep a full fp32 owner copy here.
is_in_owner_shard_column = dp_group.rank() == self.owner_shard
- if is_in_owner_shard_column:
+ if is_in_owner_shard_column and self._dmuon_route != "sharded_adamw":
master_dtype = self._master_dtype or self._orig_dtype
self._owned_data = local_data.detach().to(
device=device,
diff --git a/dmuon/csrc/fast_clip_kernel.cu b/dmuon/csrc/fast_clip_kernel.cu
new file mode 100644
index 0000000..3a25515
--- /dev/null
+++ b/dmuon/csrc/fast_clip_kernel.cu
@@ -0,0 +1,200 @@
+#include
+
+#include
+#include
+#include
+#include
+#include
+
+#include
+#include
+
+#include
+#include
+
+namespace {
+
+constexpr int kFloat32 = 0;
+constexpr int kFloat16 = 1;
+constexpr int kBFloat16 = 2;
+
+template
+__device__ inline float load_as_float(const int64_t ptr, const int64_t offset) {
+ const auto* data = reinterpret_cast(static_cast(ptr));
+ return static_cast(data[offset]);
+}
+
+template
+__device__ inline void scale_value(const int64_t ptr, const int64_t offset, const float coef) {
+ auto* data = reinterpret_cast(static_cast(ptr));
+ const float value = static_cast(data[offset]) * coef;
+ data[offset] = static_cast(value);
+}
+
+__global__ void segmented_l2_norm_sq_kernel(
+ const int64_t* __restrict__ ptrs,
+ const int64_t* __restrict__ numels,
+ const int32_t* __restrict__ dtypes,
+ const int32_t* __restrict__ segments,
+ const int32_t* __restrict__ job_tensor_ids,
+ const int64_t* __restrict__ job_offsets,
+ const int64_t chunk_size,
+ float* __restrict__ local_sq) {
+ extern __shared__ float smem[];
+
+ const int job = blockIdx.x;
+ const int tensor_id = job_tensor_ids[job];
+ const int64_t tensor_numel = numels[tensor_id];
+ const int64_t begin = job_offsets[job];
+ const int64_t end = min(begin + chunk_size, tensor_numel);
+ const int dtype = dtypes[tensor_id];
+
+ float thread_sum = 0.0f;
+ for (int64_t offset = begin + threadIdx.x; offset < end; offset += blockDim.x) {
+ float value = 0.0f;
+ if (dtype == kFloat32) {
+ value = load_as_float(ptrs[tensor_id], offset);
+ } else if (dtype == kFloat16) {
+ value = load_as_float(ptrs[tensor_id], offset);
+ } else {
+ value = load_as_float(ptrs[tensor_id], offset);
+ }
+ thread_sum += value * value;
+ }
+
+ smem[threadIdx.x] = thread_sum;
+ __syncthreads();
+
+ for (int stride = blockDim.x / 2; stride > 0; stride >>= 1) {
+ if (threadIdx.x < stride) {
+ smem[threadIdx.x] += smem[threadIdx.x + stride];
+ }
+ __syncthreads();
+ }
+
+ if (threadIdx.x == 0) {
+ atomicAdd(local_sq + segments[tensor_id], smem[0]);
+ }
+}
+
+__global__ void segmented_scale_kernel(
+ const int64_t* __restrict__ ptrs,
+ const int64_t* __restrict__ numels,
+ const int32_t* __restrict__ dtypes,
+ const int32_t* __restrict__ segments,
+ const int32_t* __restrict__ job_tensor_ids,
+ const int64_t* __restrict__ job_offsets,
+ const int64_t chunk_size,
+ const float* __restrict__ coefs) {
+ const int job = blockIdx.x;
+ const int tensor_id = job_tensor_ids[job];
+ const int64_t tensor_numel = numels[tensor_id];
+ const int64_t begin = job_offsets[job];
+ const int64_t end = min(begin + chunk_size, tensor_numel);
+ const int dtype = dtypes[tensor_id];
+ const float coef = coefs[segments[tensor_id]];
+
+ for (int64_t offset = begin + threadIdx.x; offset < end; offset += blockDim.x) {
+ if (dtype == kFloat32) {
+ scale_value(ptrs[tensor_id], offset, coef);
+ } else if (dtype == kFloat16) {
+ scale_value(ptrs[tensor_id], offset, coef);
+ } else {
+ scale_value(ptrs[tensor_id], offset, coef);
+ }
+ }
+}
+
+void check_cuda_int_tensor(const torch::Tensor& tensor, const c10::ScalarType dtype) {
+ TORCH_CHECK(tensor.is_cuda(), "metadata tensor must be CUDA");
+ TORCH_CHECK(tensor.is_contiguous(), "metadata tensor must be contiguous");
+ TORCH_CHECK(tensor.scalar_type() == dtype, "metadata tensor has unexpected dtype");
+}
+
+} // namespace
+
+void segmented_l2_norm_sq(
+ torch::Tensor ptrs,
+ torch::Tensor numels,
+ torch::Tensor dtypes,
+ torch::Tensor segments,
+ torch::Tensor job_tensor_ids,
+ torch::Tensor job_offsets,
+ torch::Tensor local_sq,
+ int64_t chunk_size) {
+ check_cuda_int_tensor(ptrs, c10::kLong);
+ check_cuda_int_tensor(numels, c10::kLong);
+ check_cuda_int_tensor(dtypes, c10::kInt);
+ check_cuda_int_tensor(segments, c10::kInt);
+ check_cuda_int_tensor(job_tensor_ids, c10::kInt);
+ check_cuda_int_tensor(job_offsets, c10::kLong);
+ TORCH_CHECK(local_sq.is_cuda(), "local_sq must be CUDA");
+ TORCH_CHECK(local_sq.is_contiguous(), "local_sq must be contiguous");
+ TORCH_CHECK(local_sq.scalar_type() == c10::kFloat, "local_sq must be float32");
+ TORCH_CHECK(chunk_size > 0, "chunk_size must be positive");
+
+ const auto jobs = job_tensor_ids.numel();
+ if (jobs == 0) {
+ return;
+ }
+
+ const c10::cuda::CUDAGuard device_guard(ptrs.device());
+ constexpr int threads = 256;
+ const dim3 blocks(static_cast(jobs));
+ const size_t smem = threads * sizeof(float);
+ segmented_l2_norm_sq_kernel<<>>(
+ ptrs.data_ptr(),
+ numels.data_ptr(),
+ dtypes.data_ptr(),
+ segments.data_ptr(),
+ job_tensor_ids.data_ptr(),
+ job_offsets.data_ptr(),
+ chunk_size,
+ local_sq.data_ptr());
+ C10_CUDA_KERNEL_LAUNCH_CHECK();
+}
+
+void segmented_scale(
+ torch::Tensor ptrs,
+ torch::Tensor numels,
+ torch::Tensor dtypes,
+ torch::Tensor segments,
+ torch::Tensor job_tensor_ids,
+ torch::Tensor job_offsets,
+ torch::Tensor coefs,
+ int64_t chunk_size) {
+ check_cuda_int_tensor(ptrs, c10::kLong);
+ check_cuda_int_tensor(numels, c10::kLong);
+ check_cuda_int_tensor(dtypes, c10::kInt);
+ check_cuda_int_tensor(segments, c10::kInt);
+ check_cuda_int_tensor(job_tensor_ids, c10::kInt);
+ check_cuda_int_tensor(job_offsets, c10::kLong);
+ TORCH_CHECK(coefs.is_cuda(), "coefs must be CUDA");
+ TORCH_CHECK(coefs.is_contiguous(), "coefs must be contiguous");
+ TORCH_CHECK(coefs.scalar_type() == c10::kFloat, "coefs must be float32");
+ TORCH_CHECK(chunk_size > 0, "chunk_size must be positive");
+
+ const auto jobs = job_tensor_ids.numel();
+ if (jobs == 0) {
+ return;
+ }
+
+ const c10::cuda::CUDAGuard device_guard(ptrs.device());
+ constexpr int threads = 256;
+ const dim3 blocks(static_cast(jobs));
+ segmented_scale_kernel<<>>(
+ ptrs.data_ptr(),
+ numels.data_ptr(),
+ dtypes.data_ptr(),
+ segments.data_ptr(),
+ job_tensor_ids.data_ptr(),
+ job_offsets.data_ptr(),
+ chunk_size,
+ coefs.data_ptr());
+ C10_CUDA_KERNEL_LAUNCH_CHECK();
+}
+
+PYBIND11_MODULE(TORCH_EXTENSION_NAME, m) {
+ m.def("segmented_l2_norm_sq", &segmented_l2_norm_sq, "Segmented L2 norm squared");
+ m.def("segmented_scale", &segmented_scale, "Segmented gradient scaling");
+}
diff --git a/dmuon/fast_clip.py b/dmuon/fast_clip.py
new file mode 100644
index 0000000..4a285ee
--- /dev/null
+++ b/dmuon/fast_clip.py
@@ -0,0 +1,490 @@
+"""Optional segmented CUDA fast path for gradient clipping.
+
+The public training semantics stay segment-local: each bucket gets its own
+gradient norm and clip coefficient. The CUDA path only changes how those
+segment-local norms are computed and applied.
+
+Distributed safety: the CUDA kernels accelerate only the *local* norm/scale
+arithmetic and are selected **per tensor**. The collective that turns local
+per-bucket squared norms into global ones is issued exactly once, with a fixed
+shape derived from ``reduce`` flags that are identical on every rank. A rank
+whose tensors happen to be ineligible for the kernel (non-contiguous, unusual
+dtype, CPU) transparently uses the torch reference math for *those tensors*
+only -- it never changes how many collectives are issued, so ranks with
+different local eligibility stay in lockstep.
+"""
+
+from __future__ import annotations
+
+import os
+import warnings
+from dataclasses import dataclass
+from functools import lru_cache
+from importlib import import_module
+from typing import Sequence
+
+import torch
+import torch.distributed as dist
+
+from .grad_clip import MuonGradClipStats, _iter_muon_grad_entries
+
+
+_DTYPE_CODES = {
+ torch.float32: 0,
+ torch.float16: 1,
+ torch.bfloat16: 2,
+}
+_DEFAULT_CHUNK_SIZE = 262144
+
+
+@dataclass(frozen=True)
+class GradClipBucket:
+ """One independently clipped gradient segment."""
+
+ name: str
+ grads: Sequence[torch.Tensor]
+ reduce: bool = True
+
+
+@dataclass(frozen=True)
+class GradClipBucketStats:
+ """Pre-clip norm and coefficient for one gradient segment."""
+
+ name: str
+ total_norm: float
+ max_norm: float | None
+ clip_coef: float
+ clipped: bool
+ param_count: int
+ found_inf: bool
+ fastpath: bool
+
+ def as_dict(self) -> dict[str, object]:
+ return {
+ "name": self.name,
+ "total_norm": self.total_norm,
+ "max_norm": self.max_norm,
+ "clip_coef": self.clip_coef,
+ "clipped": self.clipped,
+ "param_count": self.param_count,
+ "found_inf": self.found_inf,
+ "fastpath": self.fastpath,
+ }
+
+
+@dataclass(frozen=True)
+class SegmentedGradClipResult:
+ """Result for a multi-segment clipping call."""
+
+ total_norm: torch.Tensor
+ bucket_stats: tuple[GradClipBucketStats, ...]
+ fastpath: bool
+ fallback_reason: str | None = None
+
+ @property
+ def stats_by_name(self) -> dict[str, GradClipBucketStats]:
+ return {stats.name: stats for stats in self.bucket_stats}
+
+
+@lru_cache(maxsize=1)
+def _load_fast_clip_extension():
+ """Load the compiled fast-clip CUDA extension, or ``None`` if unavailable.
+
+ Distinguishes "not built" (expected when installed without a CUDA
+ toolchain) from "built but failed to load" (an ABI mismatch, which is a
+ real problem worth surfacing). ``DMUON_FAST_CLIP=0`` disables the fast
+ path; ``DMUON_FAST_CLIP_VERBOSE=1`` re-raises instead of degrading.
+ """
+
+ if os.environ.get("DMUON_FAST_CLIP", "1") == "0":
+ return None
+ verbose = os.environ.get("DMUON_FAST_CLIP_VERBOSE", "0") == "1"
+ try:
+ return import_module("dmuon._fast_clip_cuda")
+ except ModuleNotFoundError:
+ # Extension was never compiled (e.g. installed without CUDA_HOME, or
+ # with --no-build-isolation missing). Expected; stay quiet.
+ if verbose:
+ raise
+ return None
+ except Exception as exc:
+ # Built but unloadable -- almost always a torch/CUDA ABI mismatch.
+ # Warn once (lru_cache makes this a single call) so the silent
+ # slow-path fallback is diagnosable.
+ if verbose:
+ raise
+ warnings.warn(
+ f"dmuon fast-clip extension failed to load ({exc!r}); "
+ "falling back to the pure-Python clip. Rebuild with a CUDA "
+ "toolchain matching your torch, or set DMUON_FAST_CLIP_VERBOSE=1.",
+ RuntimeWarning,
+ stacklevel=2,
+ )
+ return None
+
+
+def _resolve_chunk_size(chunk_size: int | None) -> int:
+ if chunk_size is not None:
+ return max(1, int(chunk_size))
+ raw_value = os.environ.get("DMUON_FAST_CLIP_CHUNK_SIZE")
+ if raw_value is None:
+ return _DEFAULT_CHUNK_SIZE
+ try:
+ return max(1, int(raw_value))
+ except ValueError:
+ return _DEFAULT_CHUNK_SIZE
+
+
+def _resolve_device(buckets: Sequence[GradClipBucket]) -> torch.device:
+ for bucket in buckets:
+ for grad in bucket.grads:
+ if grad is not None:
+ return grad.device
+ if torch.cuda.is_available():
+ return torch.device("cuda", torch.cuda.current_device())
+ return torch.device("cpu")
+
+
+def _tensor_kernel_eligible(grad: torch.Tensor, device: torch.device) -> bool:
+ """Whether the CUDA kernel can process this single tensor."""
+
+ return (
+ grad.device == device
+ and device.type == "cuda"
+ and not grad.is_sparse
+ and grad.is_contiguous()
+ and grad.dtype in _DTYPE_CODES
+ )
+
+
+def _build_metadata(
+ tensors: Sequence[torch.Tensor],
+ segments: Sequence[int],
+ *,
+ device: torch.device,
+ chunk_size: int,
+):
+ ptrs = torch.tensor(
+ [tensor.data_ptr() for tensor in tensors],
+ device=device,
+ dtype=torch.int64,
+ )
+ numels = torch.tensor(
+ [tensor.numel() for tensor in tensors],
+ device=device,
+ dtype=torch.int64,
+ )
+ dtypes = torch.tensor(
+ [_DTYPE_CODES[tensor.dtype] for tensor in tensors],
+ device=device,
+ dtype=torch.int32,
+ )
+ tensor_segments = torch.tensor(segments, device=device, dtype=torch.int32)
+
+ job_tensor_ids: list[int] = []
+ job_offsets: list[int] = []
+ for tensor_id, tensor in enumerate(tensors):
+ numel = int(tensor.numel())
+ for offset in range(0, numel, chunk_size):
+ job_tensor_ids.append(tensor_id)
+ job_offsets.append(offset)
+
+ return {
+ "ptrs": ptrs,
+ "numels": numels,
+ "dtypes": dtypes,
+ "segments": tensor_segments,
+ "job_tensor_ids": torch.tensor(
+ job_tensor_ids, device=device, dtype=torch.int32
+ ),
+ "job_offsets": torch.tensor(job_offsets, device=device, dtype=torch.int64),
+ }
+
+
+def _split_by_kernel_eligibility(
+ buckets: Sequence[GradClipBucket],
+ device: torch.device,
+ ext,
+) -> tuple[list[torch.Tensor], list[int], list[tuple[torch.Tensor, int]]]:
+ """Partition every live grad into kernel-eligible and torch-fallback sets.
+
+ Returns ``(kernel_tensors, kernel_segments, torch_pairs)`` where
+ ``torch_pairs`` is ``[(grad, segment_index), ...]``. The decision is
+ purely local and per-tensor; it never affects collective shape.
+ """
+
+ kernel_tensors: list[torch.Tensor] = []
+ kernel_segments: list[int] = []
+ torch_pairs: list[tuple[torch.Tensor, int]] = []
+ for segment, bucket in enumerate(buckets):
+ for grad in bucket.grads:
+ if grad is None:
+ continue
+ if ext is not None and _tensor_kernel_eligible(grad, device):
+ kernel_tensors.append(grad)
+ kernel_segments.append(segment)
+ else:
+ torch_pairs.append((grad, segment))
+ return kernel_tensors, kernel_segments, torch_pairs
+
+
+def _segmented_local_sq(
+ buckets: Sequence[GradClipBucket],
+ *,
+ device: torch.device,
+ ext,
+ chunk_size: int,
+) -> torch.Tensor:
+ """Per-bucket local sum of squared grads (no collective).
+
+ Kernel-eligible tensors are summed by the CUDA kernel (``atomicAdd`` into
+ ``local_sq``); the rest are summed with torch and accumulated into the same
+ vector. Both run on the current stream, so the torch adds and the kernel
+ atomics compose correctly.
+ """
+
+ num_segments = len(buckets)
+ local_sq = torch.zeros(num_segments, device=device, dtype=torch.float32)
+ kernel_tensors, kernel_segments, torch_pairs = _split_by_kernel_eligibility(
+ buckets, device, ext
+ )
+
+ for grad, segment in torch_pairs:
+ local_sq[segment] += (
+ grad.detach().float().pow(2).sum().to(device=device, dtype=torch.float32)
+ )
+
+ if kernel_tensors:
+ metadata = _build_metadata(
+ kernel_tensors, kernel_segments, device=device, chunk_size=chunk_size
+ )
+ ext.segmented_l2_norm_sq(
+ metadata["ptrs"],
+ metadata["numels"],
+ metadata["dtypes"],
+ metadata["segments"],
+ metadata["job_tensor_ids"],
+ metadata["job_offsets"],
+ local_sq,
+ chunk_size,
+ )
+ return local_sq
+
+
+def _segmented_scale(
+ buckets: Sequence[GradClipBucket],
+ coefs: torch.Tensor,
+ *,
+ device: torch.device,
+ ext,
+ chunk_size: int,
+) -> None:
+ """Scale each grad in place by its bucket coefficient (no collective)."""
+
+ kernel_tensors, kernel_segments, torch_pairs = _split_by_kernel_eligibility(
+ buckets, device, ext
+ )
+
+ for grad, segment in torch_pairs:
+ grad.mul_(coefs[segment].to(device=grad.device))
+
+ if kernel_tensors:
+ metadata = _build_metadata(
+ kernel_tensors, kernel_segments, device=device, chunk_size=chunk_size
+ )
+ ext.segmented_scale(
+ metadata["ptrs"],
+ metadata["numels"],
+ metadata["dtypes"],
+ metadata["segments"],
+ metadata["job_tensor_ids"],
+ metadata["job_offsets"],
+ coefs,
+ chunk_size,
+ )
+
+
+def _apply_segment_reductions(
+ local_sq: torch.Tensor,
+ reduce_flags: Sequence[int],
+ process_group=None,
+) -> torch.Tensor:
+ """The single collective: sum reduce=True segments across ranks.
+
+ Issued unconditionally whenever the process group is live and any bucket
+ reduces -- both conditions are derived identically on every rank, so the
+ collective shape is rank-uniform.
+ """
+
+ if not (
+ dist.is_available()
+ and dist.is_initialized()
+ and any(bool(flag) for flag in reduce_flags)
+ ):
+ return local_sq
+
+ reduce_mask = torch.tensor(
+ reduce_flags,
+ device=local_sq.device,
+ dtype=torch.bool,
+ )
+ reduced_sq = torch.where(reduce_mask, local_sq, torch.zeros_like(local_sq))
+ dist.all_reduce(reduced_sq, op=dist.ReduceOp.SUM, group=process_group)
+ return torch.where(reduce_mask, reduced_sq, local_sq)
+
+
+def _build_stats(
+ buckets: Sequence[GradClipBucket],
+ total_norms: torch.Tensor,
+ coefs: torch.Tensor,
+ *,
+ max_norm: float | None,
+ fastpath: bool,
+) -> tuple[GradClipBucketStats, ...]:
+ """Assemble per-bucket stats with a single batched device-to-host copy."""
+
+ if len(buckets) == 0:
+ return tuple()
+
+ stacked = torch.stack(
+ [total_norms, coefs, torch.isfinite(total_norms).to(total_norms.dtype)]
+ )
+ host = stacked.detach().to("cpu")
+ total_values = host[0].tolist()
+ coef_values = host[1].tolist()
+ finite_values = host[2].tolist()
+
+ stats: list[GradClipBucketStats] = []
+ for idx, bucket in enumerate(buckets):
+ total = float(total_values[idx])
+ coef = float(coef_values[idx])
+ finite = bool(finite_values[idx])
+ clipped = bool(max_norm is not None and finite and total > float(max_norm))
+ param_count = sum(1 for grad in bucket.grads if grad is not None)
+ stats.append(
+ GradClipBucketStats(
+ name=bucket.name,
+ total_norm=total,
+ max_norm=None if max_norm is None else float(max_norm),
+ clip_coef=coef,
+ clipped=clipped,
+ param_count=param_count,
+ found_inf=not finite,
+ fastpath=fastpath,
+ )
+ )
+ return tuple(stats)
+
+
+@torch.no_grad()
+def clip_grad_norm_buckets_(
+ buckets: Sequence[GradClipBucket],
+ max_norm: float | None,
+ *,
+ process_group=None,
+ chunk_size: int | None = None,
+) -> SegmentedGradClipResult:
+ """Clip gradient buckets with segment-local, distributed-safe semantics.
+
+ Each bucket keeps its own gradient norm and clip coefficient. The CUDA
+ kernels accelerate the eligible tensors; ineligible tensors (or all of them
+ when the extension is unavailable) use the torch reference math. The
+ fast/slow choice is per tensor and purely local: it never changes the
+ number or shape of collective operations, so this is safe to call from
+ every rank in lockstep.
+ """
+
+ device = _resolve_device(buckets)
+ ext = _load_fast_clip_extension()
+ resolved_chunk_size = _resolve_chunk_size(chunk_size)
+ reduce_flags = [1 if bucket.reduce else 0 for bucket in buckets]
+
+ # Stage 1 -- local per-bucket squared norm (no collective).
+ local_sq = _segmented_local_sq(
+ buckets, device=device, ext=ext, chunk_size=resolved_chunk_size
+ )
+
+ # Stage 2 -- the single, rank-uniform collective.
+ global_sq = _apply_segment_reductions(local_sq, reduce_flags, process_group)
+
+ # Stage 3 -- total norm + coefficient (torch, matches reference semantics).
+ total_norms = torch.sqrt(global_sq)
+ if max_norm is None:
+ coefs = torch.ones_like(total_norms)
+ else:
+ coefs = torch.clamp(float(max_norm) / (total_norms + 1e-6), max=1.0)
+
+ # Stage 4 -- scale in place (no collective). found_inf never gates this.
+ if max_norm is not None:
+ _segmented_scale(
+ buckets, coefs, device=device, ext=ext, chunk_size=resolved_chunk_size
+ )
+
+ # Stage 5 -- stats (single batched D2H).
+ stats = _build_stats(
+ buckets, total_norms, coefs, max_norm=max_norm, fastpath=ext is not None
+ )
+ combined_total = torch.linalg.vector_norm(total_norms, 2.0)
+ return SegmentedGradClipResult(
+ combined_total,
+ stats,
+ fastpath=ext is not None,
+ fallback_reason=None if ext is not None else "cuda_extension_unavailable",
+ )
+
+
+@torch.no_grad()
+def try_clip_optimizer_grad_norm_buckets_(
+ optimizer,
+ *,
+ regular_grads: Sequence[torch.Tensor],
+ adamw_grads: Sequence[torch.Tensor],
+ max_norm: float,
+ process_group=None,
+) -> SegmentedGradClipResult | None:
+ """Fast path for Wall-X style regular/Muon/dedicated-AdamW clipping.
+
+ Returns ``None`` only when the fast-clip CUDA extension is entirely
+ unavailable, letting the caller run its own path. Extension availability
+ is a property of the install and is therefore uniform across ranks, so this
+ ``None`` decision is rank-consistent and cannot desync collectives. Once a
+ result is produced, clipping goes through the distributed-safe
+ :func:`clip_grad_norm_buckets_`.
+ """
+
+ if _load_fast_clip_extension() is None:
+ return None
+
+ ensure = getattr(optimizer, "_ensure_grads_ready", None)
+ if ensure is None:
+ return None
+ try:
+ ensure(coalesce_wait=True)
+ except TypeError:
+ ensure()
+
+ muon_entries = tuple(_iter_muon_grad_entries(optimizer))
+ muon_grads = [entry.grad for entry in muon_entries]
+ result = clip_grad_norm_buckets_(
+ (
+ GradClipBucket("regular", regular_grads, reduce=False),
+ GradClipBucket("muon", muon_grads, reduce=True),
+ GradClipBucket("adamw", adamw_grads, reduce=True),
+ ),
+ max_norm,
+ process_group=process_group,
+ )
+
+ muon_stats = result.stats_by_name.get("muon")
+ if muon_stats is not None:
+ optimizer._last_muon_grad_clip_stats = MuonGradClipStats(
+ total_norm=muon_stats.total_norm,
+ max_norm=muon_stats.max_norm,
+ norm_type=2.0,
+ clip_coef=muon_stats.clip_coef,
+ clipped=muon_stats.clipped,
+ param_count=muon_stats.param_count,
+ found_inf=muon_stats.found_inf,
+ strategy="segmented_cuda_fastpath",
+ )
+ return result
diff --git a/dmuon/optim/muon.py b/dmuon/optim/muon.py
index 5d77a13..8d3eb6c 100644
--- a/dmuon/optim/muon.py
+++ b/dmuon/optim/muon.py
@@ -838,6 +838,26 @@ def _build_semantic_param_groups(
{"dmuon_route": getattr(dp, "_dmuon_route", "muon")}
)
)
+ # A param constructed on the sharded_adamw route owns only a
+ # rank-local shard (``_sharded_adamw_data``) and has no full
+ # ``_owned_data`` copy. Moving it to another route here would
+ # leave later muon/adamw steps dereferencing a ``None``
+ # ``_owned_data`` (crash) or silently skipping the update.
+ # The shard storage cannot be rebuilt after dedicate_params,
+ # so reject the move with a clear error.
+ if (
+ getattr(dp, "_sharded_adamw_data", None) is not None
+ and dedicated_route != "sharded_adamw"
+ ):
+ raise RuntimeError(
+ f"DMuon param {getattr(dp, 'param_name', '')} "
+ f"was constructed on the 'sharded_adamw' route but a "
+ f"param_group tries to move it to '{dedicated_route}'. "
+ "A sharded param has no full owner copy and its shard "
+ "storage is fixed at dedicate_params() time; keep it on "
+ "'sharded_adamw' or change the route hint before "
+ "constructing Muon."
+ )
if dedicated_route == "muon":
dp._dmuon_route = "muon"
dp._dmuon_adamw_replicate_allreduce = False
@@ -1288,12 +1308,16 @@ def consume_last_step_profile(self) -> dict[str, object]:
self._last_step_profile_events = []
return profile
- def _ensure_grads_ready(self) -> None:
+ def _ensure_grads_ready(self, *, coalesce_wait: bool = False) -> None:
"""Prepare DMuon Muon grads once before clipping or stepping."""
if self._grads_ready:
return
- prepare_muon_grads(self.model)
+ prepare_muon_grads(
+ self.model,
+ use_reduce_stream=coalesce_wait,
+ coalesce_wait=coalesce_wait,
+ )
self._grads_ready = True
@torch.no_grad()
diff --git a/dmuon/utils.py b/dmuon/utils.py
index c8f000d..5c3a5af 100644
--- a/dmuon/utils.py
+++ b/dmuon/utils.py
@@ -138,7 +138,49 @@ def wait_group_muon_grads(g) -> None:
g._muon_grad_ready_refs = []
-def prepare_muon_grads(model: nn.Module, *, use_reduce_stream: bool = False) -> None:
+def _finish_group_muon_grads_after_coalesced_wait(g) -> None:
+ """Clear one prepared group after a caller waited for the aggregate event."""
+
+ if getattr(g, "_tp_gather_event", None) is not None:
+ materialize = getattr(g, "_materialize_tp_gathered_grads", None)
+ if materialize is None:
+ wait_group_muon_grads(g)
+ return
+ materialize()
+ setattr(g, "_tp_gather_event", None)
+ if hasattr(g, "_tp_gather_refs"):
+ g._tp_gather_refs = []
+ setattr(g, "_muon_grad_ready_event", None)
+ if hasattr(g, "_muon_grad_ready_refs"):
+ g._muon_grad_ready_refs = []
+
+
+def _coalesced_wait_prepared_muon_grads(groups) -> bool:
+ reduce_stream = None
+ for group in groups:
+ group_stream = getattr(getattr(group, "comm_ctx", None), "reduce_stream", None)
+ if group_stream is None:
+ return False
+ if reduce_stream is None:
+ reduce_stream = group_stream
+ elif reduce_stream is not group_stream:
+ return False
+ if reduce_stream is None:
+ return True
+
+ ready_event = reduce_stream.record_event()
+ torch.cuda.current_stream().wait_event(ready_event)
+ for group in groups:
+ _finish_group_muon_grads_after_coalesced_wait(group)
+ return True
+
+
+def prepare_muon_grads(
+ model: nn.Module,
+ *,
+ use_reduce_stream: bool = False,
+ coalesce_wait: bool = False,
+) -> None:
"""Prepare all pending Muon gradients after backward.
Gradient reduces are dispatched asynchronously during backward. This
@@ -146,10 +188,18 @@ def prepare_muon_grads(model: nn.Module, *, use_reduce_stream: bool = False) ->
the TP gather that materializes ``_tp_full_grad`` for the TP owner. It
drains all prepared groups before returning so callers can immediately
run Muon update code.
+
+ When every group shares one reduce stream, ``use_reduce_stream`` combined
+ with ``coalesce_wait`` lets advanced callers (such as the fused fast-clip
+ path) issue a single aggregate stream wait instead of draining each group
+ individually. Both default to ``False`` and preserve the per-group wait.
"""
groups = _ordered_post_step_groups(model)
for g in groups:
prepare_group_muon_grads(g, use_reduce_stream=use_reduce_stream)
+ if coalesce_wait and use_reduce_stream:
+ if _coalesced_wait_prepared_muon_grads(groups):
+ return
for g in groups:
wait_group_muon_grads(g)
diff --git a/docs/contributing.md b/docs/contributing.md
index da5b1f0..8089d12 100644
--- a/docs/contributing.md
+++ b/docs/contributing.md
@@ -12,7 +12,7 @@
```bash
# Clone and install in editable mode
-git clone https://github.com/StarrickLiu/dmuon
+git clone https://github.com/X-Square-Robot/dmuon
cd dmuon
# Create environment (conda or venv)
@@ -128,8 +128,8 @@ Documentation is deployed automatically to GitHub Pages on every push to
## Community
-- **Bug reports and feature requests:** [GitHub Issues](https://github.com/StarrickLiu/dmuon/issues)
-- **Design discussions:** [GitHub Discussions](https://github.com/StarrickLiu/dmuon/discussions)
+- **Bug reports and feature requests:** [GitHub Issues](https://github.com/X-Square-Robot/dmuon/issues)
+- **Design discussions:** [GitHub Discussions](https://github.com/X-Square-Robot/dmuon/discussions)
- No Discord at this time
Please search existing issues before opening a new one.
diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md
index f440c92..2782ef5 100644
--- a/docs/getting-started/installation.md
+++ b/docs/getting-started/installation.md
@@ -30,7 +30,7 @@
=== "From source (recommended)"
```bash
- git clone https://github.com/StarrickLiu/dmuon
+ git clone https://github.com/X-Square-Robot/dmuon
cd dmuon
pip install -e .
```
@@ -51,7 +51,7 @@
=== "Development (editable + test deps)"
```bash
- git clone https://github.com/StarrickLiu/dmuon
+ git clone https://github.com/X-Square-Robot/dmuon
cd dmuon
pip install -e ".[dev]"
```
@@ -86,6 +86,55 @@ The compiled artifact is cached in `~/.cache/dmuon/`.
---
+## Optional: Fast Gradient Clipping (CUDA)
+
+DMuon ships an optional CUDA kernel that fuses **segmented gradient clipping**
+— the per-bucket norm, clip coefficient, and in-place scaling for the
+`regular` / `muon` / `adamw` gradient groups — into a single pass. The
+training semantics are identical to the pure-Python path: each bucket still
+gets its own norm and its own clip coefficient. Only the arithmetic moves to
+the GPU.
+
+`torch` is intentionally **not** a build dependency (pinning it would force an
+isolated build to download a multi-GB generic torch and link the kernel against
+it — an ABI-mismatch risk). So to compile the kernel, build **without isolation**
+in an environment that already has torch, with a CUDA toolchain on `PATH`:
+
+```bash
+# nvcc / CUDA_HOME must be visible; torch must already be installed
+pip install -e . --no-build-isolation
+```
+
+- With `--no-build-isolation` and `CUDA_HOME` present, `dmuon._fast_clip_cuda`
+ is built against your real torch and used automatically.
+- A plain `pip install -e .` (isolated build) has no torch in the build env, so
+ `setup.py` skips the extension and DMuon uses the equivalent pure-Python clip
+ at runtime. Nothing breaks — clipping is just computed on the host side.
+- If the extension is **built but fails to load** (e.g. a later torch/CUDA
+ upgrade breaks its ABI), DMuon warns once and falls back to Python. Set
+ `DMUON_FAST_CLIP_VERBOSE=1` to raise the underlying error instead.
+
+### Build & runtime toggles
+
+| Variable | Effect |
+|----------|--------|
+| `DMUON_BUILD_FAST_CLIP=0` | Skip building the CUDA extension at install time. |
+| `DMUON_FAST_CLIP=0` | Disable the fast path at runtime (use pure Python). |
+| `DMUON_FAST_CLIP_CHUNK_SIZE` | Per-tensor chunk size for the kernel (default `262144`). |
+| `DMUON_FAST_CLIP_VERBOSE=1` | Raise the import error instead of silently falling back — use when a build "should" have worked. |
+
+The runtime path also falls back to Python automatically for inputs outside the
+kernel contract (non-contiguous, sparse, unsupported dtype) or when a
+non-finite bucket norm is detected, so a missing or stale extension never
+changes results.
+
+!!! note "The build needs a compiler, not a specific GPU"
+ Compiling the clip kernels only requires a host CUDA compiler (`nvcc`), not
+ an SM80+ GPU — the kernels are architecture-agnostic. Match the CUDA
+ toolkit to your PyTorch CUDA build (11.8 / 12.1 / 12.4).
+
+---
+
## Verify Installation
```python title="verify_install.py"
@@ -136,6 +185,12 @@ NS backend : Gram NS · kernel=cublas (SM80, universal fallback)
If the build still fails, the compiled fallback is used automatically —
Newton-Schulz will still run correctly, just slightly slower.
+**Fast-clip kernel not used (`fastpath=False` in clip stats)**
+: The `dmuon._fast_clip_cuda` extension was not built (no `CUDA_HOME` at install
+ time) or was disabled via `DMUON_FAST_CLIP=0`. Reinstall with a CUDA toolchain
+ on `PATH`, or set `DMUON_FAST_CLIP_VERBOSE=1` to surface the import error.
+ Gradient clipping stays correct via the Python path in the meantime.
+
---
## See Also
diff --git a/docs/getting-started/quickstart.md b/docs/getting-started/quickstart.md
index a63510b..c1d5210 100644
--- a/docs/getting-started/quickstart.md
+++ b/docs/getting-started/quickstart.md
@@ -10,7 +10,7 @@
## Step 1 — Install
```bash
-git clone https://github.com/StarrickLiu/dmuon && cd dmuon
+git clone https://github.com/X-Square-Robot/dmuon && cd dmuon
pip install -e .
```
diff --git a/docs/guides/training.md b/docs/guides/training.md
index 45967f1..6fffe3e 100644
--- a/docs/guides/training.md
+++ b/docs/guides/training.md
@@ -311,6 +311,14 @@ The default strategy is global p-norm clipping over Muon gradients. Custom
strategies can be registered with `dmuon.register_muon_grad_clip_strategy(...)`
for future schemes such as MuonClip or projection-specific clipping.
+!!! tip "Fused CUDA clip for regular / Muon / AdamW"
+ When you clip all three gradient groups together (the Wall-X pattern),
+ `dmuon.try_clip_optimizer_grad_norm_buckets_(...)` computes the norm, clip
+ coefficient, and in-place scaling for every bucket in a single CUDA pass
+ with the same per-bucket semantics. It uses the optional fast-clip
+ extension (see [Installation](../getting-started/installation.md)) and
+ falls back to pure Python automatically when the extension is unavailable.
+
## Logging and Debugging
### Check NS Backend
diff --git a/docs/index.md b/docs/index.md
index 6cffa41..9b0a9ba 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -173,4 +173,4 @@ The 128GPU rows cover FSDP-Z2, FSDP-Z3, HSDP-Z2, and HSDP-Z3.
DMuon builds on dedicated ownership pioneered by ZeRO-1 (Rajbhandari et al., 2020) and Distributed Shampoo (Shi et al., 2023). Gram Newton-Schulz kernel adapted from Dao et al., 2026.
-GitHub: [StarrickLiu/dmuon](https://github.com/StarrickLiu/dmuon) · arXiv preprint: [TBD]
+GitHub: [X-Square-Robot/dmuon](https://github.com/X-Square-Robot/dmuon) · arXiv preprint: [TBD]
diff --git a/docs/reference/api.md b/docs/reference/api.md
index 8c46135..baafe00 100644
--- a/docs/reference/api.md
+++ b/docs/reference/api.md
@@ -256,6 +256,50 @@ Return type for DMuon gradient clipping.
---
+### clip_grad_norm_buckets_
+
+Clip independent gradient buckets with segment-local semantics, using the
+optional CUDA fast path when available and pure Python otherwise. See
+[Fast Gradient Clipping](../getting-started/installation.md) for the extension.
+
+::: dmuon.clip_grad_norm_buckets_
+
+---
+
+### try_clip_optimizer_grad_norm_buckets_
+
+Segmented `regular` / `muon` / `adamw` clip entry point for Wall-X-style
+optimizers. Returns `None` when the CUDA extension is unavailable so callers
+can fall back without any gradient mutation.
+
+::: dmuon.try_clip_optimizer_grad_norm_buckets_
+
+---
+
+### GradClipBucket
+
+One independently clipped gradient segment.
+
+::: dmuon.GradClipBucket
+
+---
+
+### GradClipBucketStats
+
+Per-segment pre-clip norm and coefficient.
+
+::: dmuon.GradClipBucketStats
+
+---
+
+### SegmentedGradClipResult
+
+Aggregate result of a multi-segment clip call.
+
+::: dmuon.SegmentedGradClipResult
+
+---
+
### get_dedicated_params
Enumerate all `DedicatedParam` objects across the model.
diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md
index df03920..c4286bf 100644
--- a/docs/troubleshooting.md
+++ b/docs/troubleshooting.md
@@ -16,7 +16,7 @@
**Fix:**
```bash
- git clone https://github.com/StarrickLiu/dmuon && cd dmuon
+ git clone https://github.com/X-Square-Robot/dmuon && cd dmuon
pip install -e .
```
Verify: `python -c "import dmuon; print(dmuon.__version__)"`.
diff --git a/docs/zh/contributing.md b/docs/zh/contributing.md
index 38a6340..2eb24fb 100644
--- a/docs/zh/contributing.md
+++ b/docs/zh/contributing.md
@@ -11,7 +11,7 @@
```bash
# 克隆并以可编辑模式安装
-git clone https://github.com/StarrickLiu/dmuon
+git clone https://github.com/X-Square-Robot/dmuon
cd dmuon
# 创建环境(conda 或 venv)
@@ -123,8 +123,8 @@ DMuon 遵循语义化版本:`MAJOR.MINOR.PATCH`。
## 社区
-- **Bug 报告和功能请求:** [GitHub Issues](https://github.com/StarrickLiu/dmuon/issues)
-- **设计讨论:** [GitHub Discussions](https://github.com/StarrickLiu/dmuon/discussions)
+- **Bug 报告和功能请求:** [GitHub Issues](https://github.com/X-Square-Robot/dmuon/issues)
+- **设计讨论:** [GitHub Discussions](https://github.com/X-Square-Robot/dmuon/discussions)
- 暂无 Discord
提 issue 前请先搜索已有 issue。
diff --git a/docs/zh/getting-started/installation.md b/docs/zh/getting-started/installation.md
index cefc388..8075d77 100644
--- a/docs/zh/getting-started/installation.md
+++ b/docs/zh/getting-started/installation.md
@@ -30,7 +30,7 @@
=== "从源码安装(推荐)"
```bash
- git clone https://github.com/StarrickLiu/dmuon
+ git clone https://github.com/X-Square-Robot/dmuon
cd dmuon
pip install -e .
```
@@ -51,7 +51,7 @@
=== "开发模式(可编辑 + 测试依赖)"
```bash
- git clone https://github.com/StarrickLiu/dmuon
+ git clone https://github.com/X-Square-Robot/dmuon
cd dmuon
pip install -e ".[dev]"
```
@@ -84,6 +84,47 @@ pip install -e ".[syrk]"
---
+## 可选:快速梯度裁剪(CUDA)
+
+DMuon 附带一个可选的 CUDA 内核,把**分段梯度裁剪**——即 `regular` / `muon` /
+`adamw` 三个梯度组各自的范数、裁剪系数与就地缩放——融合到一趟计算里。训练语义与
+纯 Python 路径完全一致:每个分段仍各自计算范数、各自使用独立的裁剪系数,只是把运算
+搬到了 GPU 上。
+
+`torch` **刻意不作为**构建依赖(若固定它,隔离构建会去下载多 GB 的通用 torch 并把
+内核链接到它上——有 ABI 错配风险)。因此要编译内核,需在**已装 torch** 的环境里、
+`PATH` 带上 CUDA 工具链、**关闭构建隔离**安装:
+
+```bash
+# 需要 nvcc / CUDA_HOME 可见,且环境里已装 torch
+pip install -e . --no-build-isolation
+```
+
+- 加了 `--no-build-isolation` 且有 `CUDA_HOME` 时,`dmuon._fast_clip_cuda` 会针对你
+ 真实的 torch 编译并自动启用。
+- 普通 `pip install -e .`(隔离构建)的构建环境里没有 torch,`setup.py` 会跳过扩展,
+ 运行时使用等价的纯 Python 裁剪。不会报错——只是把裁剪放到主机端计算。
+- 若扩展**编译出来但加载失败**(例如之后升级 torch/CUDA 破坏了 ABI),DMuon 会 warn
+ 一次并回退到 Python。设 `DMUON_FAST_CLIP_VERBOSE=1` 可改为直接抛出底层错误。
+
+### 编译与运行时开关
+
+| 变量 | 作用 |
+|------|------|
+| `DMUON_BUILD_FAST_CLIP=0` | 安装时跳过编译该 CUDA 扩展。 |
+| `DMUON_FAST_CLIP=0` | 运行时禁用快速路径(改用纯 Python)。 |
+| `DMUON_FAST_CLIP_CHUNK_SIZE` | 内核的单张量分块大小(默认 `262144`)。 |
+| `DMUON_FAST_CLIP_VERBOSE=1` | 直接抛出导入错误而非静默回退——用于本应编译成功却没生效时排查。 |
+
+运行时路径对于不满足内核契约的输入(非连续、稀疏、不支持的 dtype)或检测到非有限的
+分段范数时,也会自动回退到 Python,因此扩展缺失或过时都不会改变结果。
+
+!!! note "编译需要的是编译器,而非特定 GPU"
+ 编译裁剪内核只需要主机端的 CUDA 编译器(`nvcc`),并不要求 SM80+ GPU——这些
+ 内核与架构无关。将 CUDA 工具包与你的 PyTorch CUDA 版本对齐即可(11.8 / 12.1 / 12.4)。
+
+---
+
## 验证安装
```python title="verify_install.py"
@@ -132,6 +173,11 @@ NS 后端 : Gram NS · kernel=cublas (SM80, universal fallback)
若构建仍失败,编译后备方案会自动启用——
Newton-Schulz 仍可正确运行,只是稍慢。
+**快速裁剪内核未生效(裁剪统计里 `fastpath=False`)**
+: `dmuon._fast_clip_cuda` 扩展没有被编译(安装时无 `CUDA_HOME`),或被
+ `DMUON_FAST_CLIP=0` 禁用了。请在 `PATH` 中带上 CUDA 工具链重新安装,或设置
+ `DMUON_FAST_CLIP_VERBOSE=1` 查看导入错误。其间梯度裁剪仍通过 Python 路径正确执行。
+
---
## 另请参见
diff --git a/docs/zh/getting-started/quickstart.md b/docs/zh/getting-started/quickstart.md
index 1acd0ee..9b04543 100644
--- a/docs/zh/getting-started/quickstart.md
+++ b/docs/zh/getting-started/quickstart.md
@@ -9,7 +9,7 @@
## 第一步 — 安装
```bash
-git clone https://github.com/StarrickLiu/dmuon && cd dmuon
+git clone https://github.com/X-Square-Robot/dmuon && cd dmuon
pip install -e .
```
diff --git a/docs/zh/guides/training.md b/docs/zh/guides/training.md
index 0328f76..9d315b8 100644
--- a/docs/zh/guides/training.md
+++ b/docs/zh/guides/training.md
@@ -304,6 +304,13 @@ for step, batch in enumerate(dataloader):
MuonClip、QK/投影层专用 clip 等方案,可以通过
`dmuon.register_muon_grad_clip_strategy(...)` 注册自定义策略。
+!!! tip "regular / Muon / AdamW 的融合 CUDA 裁剪"
+ 当你需要把三个梯度组一起裁剪(Wall-X 模式)时,
+ `dmuon.try_clip_optimizer_grad_norm_buckets_(...)` 会在一趟 CUDA 计算里
+ 完成每个分段的范数、裁剪系数与就地缩放,且保持逐分段语义。它使用可选的快速
+ 裁剪扩展(见[安装](../getting-started/installation.md)),扩展不可用时自动
+ 回退到纯 Python。
+
## 日志与调试
### 检查 NS 后端
diff --git a/docs/zh/index.md b/docs/zh/index.md
index 0392dd2..27f2c78 100644
--- a/docs/zh/index.md
+++ b/docs/zh/index.md
@@ -169,4 +169,4 @@ DMuon 为每个矩阵参数指定唯一的**所有者 rank**。所有者存储
DMuon 建立在 ZeRO-1(Rajbhandari 等,2020)和 Distributed Shampoo(Shi 等,2023)
所开创的专属所有权原语之上。Gram Newton-Schulz 核改编自 Dao 等,2026。
-GitHub:[StarrickLiu/dmuon](https://github.com/StarrickLiu/dmuon) · arXiv 预印本:[TBD]
+GitHub:[X-Square-Robot/dmuon](https://github.com/X-Square-Robot/dmuon) · arXiv 预印本:[TBD]
diff --git a/docs/zh/reference/api.md b/docs/zh/reference/api.md
index 99e5551..f5f4a96 100644
--- a/docs/zh/reference/api.md
+++ b/docs/zh/reference/api.md
@@ -252,6 +252,48 @@ DMuon 梯度裁剪的返回类型。
---
+### clip_grad_norm_buckets_
+
+以分段局部语义裁剪相互独立的梯度分段;可用时走可选的 CUDA 快速路径,否则回退纯
+Python。扩展的编译见[快速梯度裁剪](../getting-started/installation.md)。
+
+::: dmuon.clip_grad_norm_buckets_
+
+---
+
+### try_clip_optimizer_grad_norm_buckets_
+
+面向 Wall-X 式优化器的 `regular` / `muon` / `adamw` 分段裁剪入口。CUDA 扩展不可用时
+返回 `None`,调用方可在不触碰梯度的情况下回退。
+
+::: dmuon.try_clip_optimizer_grad_norm_buckets_
+
+---
+
+### GradClipBucket
+
+一个可独立裁剪的梯度分段。
+
+::: dmuon.GradClipBucket
+
+---
+
+### GradClipBucketStats
+
+单个分段裁剪前的范数与系数。
+
+::: dmuon.GradClipBucketStats
+
+---
+
+### SegmentedGradClipResult
+
+多分段裁剪调用的聚合结果。
+
+::: dmuon.SegmentedGradClipResult
+
+---
+
### get_dedicated_params
枚举模型中所有 `DedicatedParam` 对象。用于检查 ownership 分配、参数数量
diff --git a/docs/zh/troubleshooting.md b/docs/zh/troubleshooting.md
index a65e1d1..fdb954d 100644
--- a/docs/zh/troubleshooting.md
+++ b/docs/zh/troubleshooting.md
@@ -14,7 +14,7 @@
**修复:**
```bash
- git clone https://github.com/StarrickLiu/dmuon && cd dmuon
+ git clone https://github.com/X-Square-Robot/dmuon && cd dmuon
pip install -e .
```
验证:`python -c "import dmuon; print(dmuon.__version__)"`。
diff --git a/mkdocs.yml b/mkdocs.yml
index 8aab21a..f233af7 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -1,8 +1,8 @@
site_name: DMuon
site_description: Dedicated ownership for Muon optimizer on PyTorch DDP, FSDP2, and HSDP
-site_url: https://starrickliu.github.io/dmuon/
-repo_url: https://github.com/StarrickLiu/dmuon
-repo_name: StarrickLiu/dmuon
+site_url: https://x-square-robot.github.io/dmuon/
+repo_url: https://github.com/X-Square-Robot/dmuon
+repo_name: X-Square-Robot/dmuon
exclude_docs: |
internal/
diff --git a/pyproject.toml b/pyproject.toml
index 9b4550d..5587231 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,4 +1,10 @@
[build-system]
+# torch is intentionally NOT listed here: pinning it would force PEP 517 build
+# isolation to download a multi-GB generic torch into a throwaway env and link
+# the CUDA extension against it (ABI-mismatch risk). To compile the optional
+# fast-clip kernel, install with `--no-build-isolation` in an env that already
+# has torch; otherwise setup.py skips the extension and the pure-Python clip is
+# used. See docs/getting-started/installation.md.
requires = ["setuptools>=77.0", "wheel"]
build-backend = "setuptools.build_meta"
@@ -62,6 +68,9 @@ exclude = [
"site*",
]
+[tool.setuptools.package-data]
+dmuon = ["csrc/*.cu"]
+
[tool.ruff]
line-length = 100
target-version = "py310"
diff --git a/setup.py b/setup.py
new file mode 100644
index 0000000..d71ee5d
--- /dev/null
+++ b/setup.py
@@ -0,0 +1,39 @@
+from __future__ import annotations
+
+import os
+
+from setuptools import setup
+
+
+def _extension_config():
+ if os.environ.get("DMUON_BUILD_FAST_CLIP", "1") == "0":
+ return [], {}
+ try:
+ from torch.utils.cpp_extension import BuildExtension, CUDAExtension, CUDA_HOME
+ except Exception as exc:
+ print(f"DMuon fast clip CUDA extension disabled: {exc}")
+ return [], {}
+
+ if CUDA_HOME is None:
+ print("DMuon fast clip CUDA extension disabled: CUDA_HOME is not available")
+ return [], {}
+
+ ext_modules = [
+ CUDAExtension(
+ name="dmuon._fast_clip_cuda",
+ sources=["dmuon/csrc/fast_clip_kernel.cu"],
+ extra_compile_args={
+ "cxx": ["-O3"],
+ "nvcc": ["-O3"],
+ },
+ )
+ ]
+ return ext_modules, {"build_ext": BuildExtension}
+
+
+ext_modules, cmdclass = _extension_config()
+
+setup(
+ ext_modules=ext_modules,
+ cmdclass=cmdclass,
+)
diff --git a/tests/distributed/test_fast_clip_distributed.py b/tests/distributed/test_fast_clip_distributed.py
new file mode 100644
index 0000000..d4818f4
--- /dev/null
+++ b/tests/distributed/test_fast_clip_distributed.py
@@ -0,0 +1,78 @@
+"""Distributed liveness + correctness check for segmented fast clip.
+
+Reproduces the scenario that used to hang: a non-finite value in a
+``reduce=False`` bucket on only one rank. With the three-stage design the
+collective count is identical on every rank, so the clip must complete and
+produce the correct all-reduced per-bucket norms.
+
+Usage (CPU, no GPU needed)::
+
+ torchrun --nproc_per_node=2 tests/distributed/test_fast_clip_distributed.py
+
+Exit code is non-zero on any assertion failure; a hang (the old bug) shows up
+as a torchrun timeout.
+"""
+
+import os
+import sys
+
+sys.path.insert(
+ 0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+)
+
+import math
+
+import torch
+import torch.distributed as dist
+
+from dmuon.fast_clip import GradClipBucket, clip_grad_norm_buckets_
+
+
+def run() -> None:
+ dist.init_process_group(backend="gloo")
+ rank = dist.get_rank()
+ world_size = dist.get_world_size()
+
+ # reduce=True buckets: each rank contributes a known local square; the
+ # segment-local norm after reduction is sqrt(sum_r local_sq_r).
+ muon = torch.tensor([float(rank + 1), 0.0]) # local sq = (rank+1)^2
+ adamw = torch.tensor([0.0, float(rank + 2)]) # local sq = (rank+2)^2
+
+ # reduce=False bucket stays rank-local; rank 0 injects a non-finite value.
+ regular = torch.tensor([3.0, 4.0])
+ if rank == 0:
+ regular = torch.tensor([float("inf"), 4.0])
+
+ result = clip_grad_norm_buckets_(
+ (
+ GradClipBucket("regular", [regular], reduce=False),
+ GradClipBucket("muon", [muon], reduce=True),
+ GradClipBucket("adamw", [adamw], reduce=True),
+ ),
+ max_norm=1.0,
+ )
+ stats = result.stats_by_name
+
+ expected_muon = math.sqrt(sum((r + 1) ** 2 for r in range(world_size)))
+ expected_adamw = math.sqrt(sum((r + 2) ** 2 for r in range(world_size)))
+
+ assert abs(stats["muon"].total_norm - expected_muon) < 1e-4, (
+ rank, stats["muon"].total_norm, expected_muon,
+ )
+ assert abs(stats["adamw"].total_norm - expected_adamw) < 1e-4, (
+ rank, stats["adamw"].total_norm, expected_adamw,
+ )
+ # regular is local: only rank 0 saw the inf.
+ assert stats["regular"].found_inf is (rank == 0), (rank, stats["regular"].found_inf)
+
+ dist.barrier()
+ if rank == 0:
+ print(
+ f"PASS: fast-clip distributed (world_size={world_size}) — "
+ f"muon={expected_muon:.4f} adamw={expected_adamw:.4f}, no hang"
+ )
+ dist.destroy_process_group()
+
+
+if __name__ == "__main__":
+ run()
diff --git a/tests/unit/test_fast_clip.py b/tests/unit/test_fast_clip.py
new file mode 100644
index 0000000..b88b8bd
--- /dev/null
+++ b/tests/unit/test_fast_clip.py
@@ -0,0 +1,244 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+import pytest
+import torch
+
+import dmuon.fast_clip as fast_clip
+from dmuon.fast_clip import (
+ GradClipBucket,
+ _DEFAULT_CHUNK_SIZE,
+ _resolve_chunk_size,
+ clip_grad_norm_buckets_,
+ try_clip_optimizer_grad_norm_buckets_,
+)
+
+
+def _bucket_stats(result):
+ return {stats.name: stats for stats in result.bucket_stats}
+
+
+def test_fast_clip_chunk_size_defaults_and_env_override(monkeypatch) -> None:
+ monkeypatch.delenv("DMUON_FAST_CLIP_CHUNK_SIZE", raising=False)
+ assert _resolve_chunk_size(None) == _DEFAULT_CHUNK_SIZE
+ assert _DEFAULT_CHUNK_SIZE == 262144
+
+ monkeypatch.setenv("DMUON_FAST_CLIP_CHUNK_SIZE", "131072")
+ assert _resolve_chunk_size(None) == 131072
+
+ monkeypatch.setenv("DMUON_FAST_CLIP_CHUNK_SIZE", "invalid")
+ assert _resolve_chunk_size(None) == _DEFAULT_CHUNK_SIZE
+
+ assert _resolve_chunk_size(4096) == 4096
+ assert _resolve_chunk_size(0) == 1
+
+
+def test_segmented_clip_cpu_preserves_per_bucket_semantics() -> None:
+ """On CPU the kernel never applies; each bucket clips segment-locally."""
+ regular = torch.tensor([3.0, 4.0])
+ muon = torch.tensor([0.0, 12.0])
+ adamw = torch.tensor([8.0, 15.0])
+
+ result = clip_grad_norm_buckets_(
+ (
+ GradClipBucket("regular", [regular], reduce=False),
+ GradClipBucket("muon", [muon], reduce=True),
+ GradClipBucket("adamw", [adamw], reduce=True),
+ ),
+ 6.0,
+ )
+
+ stats = _bucket_stats(result)
+ assert stats["regular"].total_norm == pytest.approx(5.0)
+ assert stats["muon"].total_norm == pytest.approx(12.0)
+ assert stats["adamw"].total_norm == pytest.approx(17.0)
+ assert stats["regular"].clip_coef == pytest.approx(1.0)
+ assert stats["muon"].clip_coef == pytest.approx(6.0 / (12.0 + 1e-6))
+ assert stats["adamw"].clip_coef == pytest.approx(6.0 / (17.0 + 1e-6))
+ assert torch.allclose(regular, torch.tensor([3.0, 4.0]))
+ assert torch.allclose(muon, torch.tensor([0.0, 12.0]) * stats["muon"].clip_coef)
+ assert torch.allclose(adamw, torch.tensor([8.0, 15.0]) * stats["adamw"].clip_coef)
+ assert result.fastpath is False
+
+
+def test_collective_count_is_invariant_to_local_conditions(monkeypatch) -> None:
+ """Regression for the distributed-hang bug.
+
+ A non-finite value in a ``reduce=False`` bucket (a rank-local condition)
+ must NOT change how many collectives are issued. The clip must emit
+ exactly one all_reduce, of shape ``[num_buckets]``, no matter what — that
+ is what keeps ranks in lockstep.
+ """
+ calls: list[torch.Tensor] = []
+
+ def fake_all_reduce(tensor, op=None, group=None): # single-rank identity
+ calls.append(tensor.clone())
+ return tensor
+
+ monkeypatch.setattr(fast_clip.dist, "is_available", lambda: True)
+ monkeypatch.setattr(fast_clip.dist, "is_initialized", lambda: True)
+ monkeypatch.setattr(fast_clip.dist, "all_reduce", fake_all_reduce)
+
+ regular = torch.tensor([float("inf"), 1.0]) # local non-finite, reduce=False
+ muon = torch.tensor([3.0, 4.0])
+ adamw = torch.tensor([0.0, 5.0])
+ result = clip_grad_norm_buckets_(
+ (
+ GradClipBucket("regular", [regular], reduce=False),
+ GradClipBucket("muon", [muon], reduce=True),
+ GradClipBucket("adamw", [adamw], reduce=True),
+ ),
+ 1.0,
+ )
+
+ assert len(calls) == 1 # exactly one fused collective
+ assert calls[0].shape == (3,) # one entry per bucket, not per-bucket scalars
+ stats = _bucket_stats(result)
+ assert stats["regular"].found_inf is True # reported, not acted on via control flow
+ assert stats["muon"].found_inf is False
+
+
+def test_noncontiguous_tensor_is_clipped_not_skipped() -> None:
+ """A non-contiguous grad used to abort the whole call; now it clips fine."""
+ base = torch.randn(4, 4)
+ grad = base.t() # non-contiguous view
+ assert not grad.is_contiguous()
+ reference = grad.clone().contiguous()
+
+ result = clip_grad_norm_buckets_(
+ (GradClipBucket("muon", [grad], reduce=True),), 0.5
+ )
+
+ norm = reference.float().pow(2).sum().sqrt()
+ coef = min(0.5 / (float(norm) + 1e-6), 1.0)
+ assert torch.allclose(grad, reference * coef, atol=1e-5)
+ assert result.stats_by_name["muon"].total_norm == pytest.approx(float(norm), rel=1e-5)
+
+
+@pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA is required")
+def test_cuda_fast_clip_matches_python_reference(monkeypatch) -> None:
+ device = torch.device("cuda")
+ max_norm = 9.0
+ source_buckets = (
+ (
+ "regular",
+ [
+ torch.linspace(-2.0, 2.0, 2048, device=device, dtype=torch.float32),
+ torch.linspace(-1.0, 1.0, 513, device=device, dtype=torch.bfloat16),
+ ],
+ False,
+ ),
+ (
+ "muon",
+ [
+ torch.randn(4097, device=device, dtype=torch.float32),
+ torch.randn(123, device=device, dtype=torch.float16),
+ ],
+ True,
+ ),
+ (
+ "adamw",
+ [
+ torch.randn(8193, device=device, dtype=torch.bfloat16),
+ torch.randn(17, device=device, dtype=torch.float32),
+ ],
+ True,
+ ),
+ )
+
+ def _clone(buckets):
+ return tuple(
+ GradClipBucket(name, [g.clone() for g in grads], reduce=reduce)
+ for name, grads, reduce in buckets
+ )
+
+ fast_buckets = _clone(source_buckets)
+ fast = clip_grad_norm_buckets_(fast_buckets, max_norm)
+ if not fast.fastpath:
+ pytest.skip("DMuon fast clip CUDA extension is unavailable")
+
+ # Force the pure-torch path for the reference by disabling the extension.
+ ref_buckets = _clone(source_buckets)
+ monkeypatch.setattr(fast_clip, "_load_fast_clip_extension", lambda: None)
+ ref = clip_grad_norm_buckets_(ref_buckets, max_norm)
+ assert ref.fastpath is False
+
+ fast_stats = _bucket_stats(fast)
+ ref_stats = _bucket_stats(ref)
+ for name in fast_stats:
+ assert fast_stats[name].total_norm == pytest.approx(
+ ref_stats[name].total_norm, rel=2e-3, abs=2e-3
+ )
+ assert fast_stats[name].clip_coef == pytest.approx(
+ ref_stats[name].clip_coef, rel=2e-3, abs=2e-3
+ )
+ for fast_bucket, ref_bucket in zip(fast_buckets, ref_buckets):
+ for fast_grad, ref_grad in zip(fast_bucket.grads, ref_bucket.grads):
+ assert torch.allclose(fast_grad.float(), ref_grad.float(), rtol=3e-3, atol=3e-3)
+
+
+@pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA is required")
+def test_cuda_and_python_agree_on_nonfinite(monkeypatch) -> None:
+ """NaN/inf must produce identical grads on the CUDA and torch paths."""
+ device = torch.device("cuda")
+ values = [1.0, float("nan"), 3.0, float("inf")]
+
+ fast_grad = torch.tensor(values, device=device)
+ fast = clip_grad_norm_buckets_((GradClipBucket("muon", [fast_grad], reduce=True),), 2.0)
+ if not fast.fastpath:
+ pytest.skip("DMuon fast clip CUDA extension is unavailable")
+
+ ref_grad = torch.tensor(values, device=device)
+ monkeypatch.setattr(fast_clip, "_load_fast_clip_extension", lambda: None)
+ clip_grad_norm_buckets_((GradClipBucket("muon", [ref_grad], reduce=True),), 2.0)
+
+ assert torch.equal(fast_grad.isnan(), ref_grad.isnan())
+ finite = ~fast_grad.isnan() & ~fast_grad.isinf()
+ assert torch.allclose(fast_grad[finite], ref_grad[finite], rtol=3e-3, atol=3e-3)
+
+
+@dataclass
+class _FakeDP:
+ grad: torch.Tensor | None
+ name: str = "proj"
+
+ def __post_init__(self) -> None:
+ self.param_name = self.name
+ self.is_dtensor = False
+ self.tp_group = None
+ self.is_tp_owner = False
+ self._reduced_grad = self.grad
+
+
+class _FakeOptimizer:
+ def __init__(self, grad: torch.Tensor) -> None:
+ self._dedicated_params = [_FakeDP(grad)]
+ self._dp_to_muon_group_idx = {id(self._dedicated_params[0]): 0}
+ self._ensure_calls = 0
+ self._last_muon_grad_clip_stats = None
+
+ def _ensure_grads_ready(self, *, coalesce_wait: bool = False) -> None:
+ self._ensure_calls += 1
+
+
+@pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA is required")
+def test_optimizer_fast_clip_sets_muon_stats_when_available() -> None:
+ muon_grad = torch.tensor([6.0, 8.0], device="cuda")
+ regular_grad = torch.tensor([3.0, 4.0], device="cuda")
+ adamw_grad = torch.tensor([0.0, 12.0], device="cuda")
+ opt = _FakeOptimizer(muon_grad)
+
+ result = try_clip_optimizer_grad_norm_buckets_(
+ opt,
+ regular_grads=[regular_grad],
+ adamw_grads=[adamw_grad],
+ max_norm=5.0,
+ )
+ if result is None:
+ pytest.skip("DMuon fast clip CUDA extension is unavailable")
+
+ assert opt._ensure_calls == 1
+ assert opt._last_muon_grad_clip_stats is not None
+ assert opt._last_muon_grad_clip_stats.total_norm == pytest.approx(10.0)
+ assert opt._last_muon_grad_clip_stats.strategy == "segmented_cuda_fastpath"
diff --git a/tests/unit/test_hsdp_owner_only.py b/tests/unit/test_hsdp_owner_only.py
index 073776e..77c4ad8 100644
--- a/tests/unit/test_hsdp_owner_only.py
+++ b/tests/unit/test_hsdp_owner_only.py
@@ -36,12 +36,16 @@
class _StubGroup:
"""Minimal ProcessGroup stub: just advertises a rank within the group."""
- def __init__(self, rank: int):
+ def __init__(self, rank: int, size: int = 4):
self._rank = rank
+ self._size = size
def rank(self) -> int:
return self._rank
+ def size(self) -> int:
+ return self._size
+
class _StubDist:
@staticmethod
@@ -57,6 +61,7 @@ def _make_dp(
owner_shard: int,
owner_replicate: int,
shape=(8, 4),
+ route_hint: Optional[str] = None,
) -> DedicatedParam:
"""Build a DedicatedParam on CPU with stubbed groups.
@@ -83,6 +88,7 @@ def _make_dp(
device=torch.device("cpu"),
compute_dtype=None,
replicate_group=replicate_group,
+ route_hint=route_hint,
)
finally:
param_module.dist = orig_dist
@@ -171,6 +177,21 @@ def test_owned_data_none_on_non_owner_in_shard_only_mode():
assert dp_owner._owned_data is not None
+def test_sharded_adamw_skips_full_owner_copy():
+ """Sharded AdamW owns a rank-local shard, not a full fp32 owner tensor."""
+ dp = _make_dp(
+ shard_rank=3,
+ replicate_rank=None,
+ owner_shard=3,
+ owner_replicate=0,
+ shape=(8, 4),
+ route_hint="sharded_adamw",
+ )
+ assert dp._owned_data is None
+ assert dp._sharded_adamw_data is not None
+ assert dp._sharded_adamw_data.numel() == 8
+
+
# --- Cached global rank for Stage-2 reduce ---------------------------------