Skip to content

[Feature] NPU (Ascend) training performance optimizations for Qwen3.5 dense text model #2118

Open

Description

@longboat2010

Motivation

Training Qwen3.5-class models (GatedDeltaNet layers, dense MLPs, a 154,880-vocab lm head) on Ascend NPUs currently hits four gaps in the v1 ops/engine stack:

  1. GDN stack dead ends. get_zero_centered_rms_norm_fn() in xtuner/v1/ops/rms_norm raises NotImplementedError on NPU, so the in-repo zero-centered RMSNorm route is unusable on Ascend; and the GatedDeltaNet op stack (causal conv1d, chunked gated delta rule, gated RMSNorm) falls back to slow fp32 torch decompositions — causal conv1d has no NPU kernel at all in the package. The native fp32 decomposition OOMs at step 1 on all 16 ranks at the default GA=4/mbs=2 config.
  2. Eager activation chain. The dense-MLP activation runs as a two-pass silu(gate) + mul forward and a two-pass backward — four full-tensor memory passes where fused one-pass kernels need one each.
  3. No NPU liger loss. loss_cfg.mode == "liger" imports liger_kernel's stock CUDA class unconditionally, so the fused-linear-cross-entropy path (which avoids materializing full logits over the 154,880-token vocab) has no working NPU implementation.
  4. FSDP2 all-gather memory strand. torch_npu's caching allocator segregates cached blocks per stream, so FSDP2's full-param all-gather on the separate all_gather_stream strands reserved memory that empty_cache cannot reclaim — pure per-stream fragmentation, at no benefit.
  5. No host-side CPU affinity. Every rank drives its NPU from the host — aclnn op dispatch, tensor metadata, and the device's companion host threads are all host CPU work; unpinned ranks can land on NUMA nodes remote from their NPU, and a single straggler rank paces the synchronized step.

Proposal

Six independent PRs move the production NPU implementations into the package — one per gap, each opt-in/env-gated, each leaving non-NPU behavior unchanged — plus two stacked follow-up PRs (#2240, #2241):

  1. [Feature] NPU fused zero-centered RMSNorm (XTUNER_NPU_FUSED_ZC_NORM) #2119 — NPU fused zero-centered RMSNorm: a single npu_rms_norm pass with the (1 + w) scale folded into the fp32 gamma (bf16 output rounds once); fp32-decomposition fallback; gated by XTUNER_NPU_FUSED_ZC_NORM (default on).
  2. [Feature] NPU AscendC op stack for GatedDeltaNet (causal conv1d / chunk delta rule / fused gated norm) #2120 — NPU AscendC op stack for GatedDeltaNet: causal conv1d with three-level in-module dispatch, the chunked gated delta rule kernel wired to fla_npu AscendC ops plus mindspeed triton helpers with a device-side chunk-indices cache, and a fused gated RMSNorm. Dispatch is gated on get_device() == 'npu' plus XTUNER_NPU_* env knobs (all default on).
  3. [Feature] Fused SwiGLU Triton kernels for MLP activation (XTUNER_NPU_FUSED_SWIGLU) #2128 — Fused SwiGLU Triton kernels: one-pass silu(gate)·up forward and backward for the dense-MLP activation. Device-agnostic plain Triton (no torch_npu dependency), opt-in via XTUNER_NPU_FUSED_SWIGLU (default off), read live so runtime injection works.
  4. [Feature] NPU liger fused-linear-cross-entropy loss (XTUNER_LOSS_MODE=liger) #2129 — NPU liger fused-linear-cross-entropy loss: a get_flce_loss_cls() device dispatch — NPU returns an in-package NPUFusedLinearCrossEntropyLoss driving liger-ascend's triton kernels (the stock ascend plain-CE kernel miscomputes loss_1d in multi-rank training, so the forward computes CE via torch on the matmul logits), CUDA keeps liger_kernel's stock class. Chunked forward behind a full-logits byte gate; no device env knob by design — the fallback arm is numerically wrong.
  5. [Feature] Route FSDP2 unshard to the compute stream (XTUNER_FSDP_UNSHARD_ASYNC_OP) #2130 — Route FSDP2 unshard to the compute stream: XTUNER_FSDP_UNSHARD_ASYNC_OP=1 (default off) issues FSDP2's parameter all-gather on the compute stream so the per-stream strand is never created; wait_for_unshard + explicit forward prefetch keep the overlap.
  6. [Feature] Bind NPU training ranks to NUMA-local CPU slices (XTUNER_NPU_CPU_BIND) #2131 — Bind NPU training ranks to NUMA-local CPU slices: a stdlib-only npu_cpu_binder pins each rank process and its device's host threads (acl/release/hccp) to an 8-core NUMA-local slice, wired into _try_bind_numa so all training modes bind; XTUNER_NPU_CPU_BIND (default on), bind failures degrade to a log line.
  7. [Fix] fla_npu >= 26.10 compatibility for the GDN AscendC stack (chunk loader + causal conv backends) #2240 — fla_npu ≥ 26.10 compatibility for the GDN AscendC stack (stacked on [Feature] NPU AscendC op stack for GatedDeltaNet (causal conv1d / chunk delta rule / fused gated norm) #2120): fla_npu's 26.10 line splits its API surface — stable raw ops move to fla_npu.ops.ascendc.* and the legacy torch.ops.npu.* registrations need an explicit load_legacy_torch_ops(). The causal-conv backend becomes a dual-backend resolver decided once per process (stable API on 26.10, legacy torch.ops on 26.6), the chunk loader degrades cleanly when the embedded legacy extension is absent, and the startup self-probe upgrades from isfinite-only to a numeric check against an F.conv1d autograd reference, so "finite but wrong" kernels are rejected into the unmasked-correction fallback instead of surfacing as mid-training NaN. XTUNER_NPU_GDN_API no longer selects the conv backend. Behavior on fla_npu 26.6 is unchanged.
  8. [Feature] Optimize the NPU liger FLCE backward and add a fused LM z-loss #2241 — NPU liger FLCE backward optimization + fused LM z-loss (stacked on [Feature] NPU liger fused-linear-cross-entropy loss (XTUNER_LOSS_MODE=liger) #2129): backward scratch-buffer reuse and vocabulary-slab grad_weight accumulation remove the two large logits-path transients (~1.9 GiB fresh alloc per chunk + the ~2 GiB [V, H] temporary; −4.6 GB max_memory e2e), the backward recompute chunk ties to LOSS_CHUNK_SIZE, a latent reduction='none' multi-chunk grad_output indexing bug is fixed (chunks > 0 read wrong rows; ce_loss.py uses sum, so latent only), the LM z-loss (CELossConfig.z_loss_coefficient, default off) fuses CE+LSE² into one forward kernel, and the no-weight backward call gains 0.8.1/0.8.5 dual support via HAS_TAIL signature detection.

Dependency (NPU only)

Both are imported lazily inside the NPU dispatch branches only; non-NPU environments need no new dependency.

Results (Qwen3.5 dense text model, 16 Ascend NPUs, frozen launcher recipe)

  • Step-1/step-2 losses bitwise-identical across A/B runs (anchor gate), 0 NaN; 1000-step run exits 0 with 0 NaN.

  • Global throughput 179.2K tok/s on the default GA=4/mbs=2 config with the GDN stack enabled; the fused RMSNorm is +10.6% over the native fp32 decomposition at GA=2/mbs=1, and is what makes the default config fit in memory at all (the native decomposition OOMs at step 1 on all 16 ranks).

  • CPU binding (XTUNER_NPU_CPU_BIND) is scheduling-only — step-1/step-2 losses bitwise-identical, peak memory unchanged — and adds +1.7% global tok/s at 256 ranks (16 nodes × 16 Ascend NPUs, 100-step A/B).

  • fla_npu 26.10 compat ([Fix] fla_npu >= 26.10 compatibility for the GDN AscendC stack (chunk loader + causal conv backends) #2240): on a 26.10 dev wheel (256 Ascend NPUs = 16 nodes × 16, Qwen3.5 dense text, 10-step benchmark, mbz=1/gas=2/gbs=512) the GDN chunk route resolves via the stable API, 10/10 steps, 0 NaN, benchmark success; the broken stable conv adapter is rejected by the numeric startup probe and the unmasked-correction fallback engages (error band fwd 0.094 / dx 0.125 / dw 1.0). On fla_npu 26.6 the conv route is bit-exact at dispatcher level (dx/dw within the same few-ulp band as [Feature] NPU AscendC op stack for GatedDeltaNet (causal conv1d / chunk delta rule / fused gated norm) #2120).

  • Liger FLCE backward optimization ([Feature] Optimize the NPU liger FLCE backward and add a fused LM z-loss #2241; same implementation A/B'd on an internal fork, GLM-5.3-Flash 30B VL, 16 NPUs, 10 steps): step-1 loss bitwise identical to baseline, max_memory −4.6 GB sustained (27.6 vs 32.2 GB steady), steady tok/s +4.2% (trend only).

Env knobs (per PR)

PR Env knob Default Function
#2119 XTUNER_NPU_FUSED_ZC_NORM 1 (on) NPU fused zero-centered RMSNorm — one npu_rms_norm pass with the (1 + w) scale folded into the fp32 gamma; 0 (or a failed built-in npu_rms_norm self-test) falls back to the fp32 decomposition
#2120 XTUNER_NPU_GDN_ASCENDC 1 (on) Master gate for the GDN AscendC chunk-kernel route; 0 falls back to the in-repo chunk_gated_delta_rule
#2120 XTUNER_NPU_CONV 1 (on) Master gate for the NPU causal-conv1d stack; 0 falls back to the in-repo causal_conv1d_fn
#2120 XTUNER_NPU_GDN_DEVICE_INDICES 1 (on) Device-side chunk-indices cache (replaces the mindspeed host helper); 0 restores the host-side helper
#2120 XTUNER_NPU_GDN_SPLIT_MAX_TOKENS 131072 (unset) Fixed-chunk cap (tokens) for long sequences; 0 disables the cap; non-integer values raise
#2120 XTUNER_NPU_FUSED_GATED_NORM 1 (on) Fused gated RMSNorm for GDN (also subject to the built-in npu_rms_norm probe); 0 falls back to the fp32 decomposition
#2128 XTUNER_NPU_FUSED_SWIGLU off (0) Fused single-pass SwiGLU forward/backward (plain Triton, also runs on CUDA); opt-in, read live so runtime injection works; off = eager silu·mul chain
#2129 XTUNER_LIGER_CHUNK_FWD 1 (on) Chunked lm-head+CE forward in liger mode when the full logits exceed the byte gate; 0 forces the full-logits path
#2129 XTUNER_LIGER_CHUNK_FWD_LIMIT 2048 (MiB) Full-logits byte gate for the chunked liger forward
#2129 LOSS_CHUNK_SIZE 1024 Rows per chunk in the chunked liger forward
#2241 XTUNER_LIGER_GRAD_WEIGHT_CHUNK 4096 Rows per vocabulary slab in the liger backward grad_weight accumulation; bounds the [V, H] grad temporary to a 4096-row slab
#2130 XTUNER_FSDP_UNSHARD_ASYNC_OP 0 (off) Route FSDP2's parameter all-gather (unshard) onto the compute stream, removing the per-stream reserved-memory strand
#2131 XTUNER_NPU_CPU_BIND 1 (on) Per-rank pinning to the NPU-local CPU slice (main process + acl/release/hccp host threads); 0 disables; bind failures log and continue

Notes: #2129 selects its NPU path via the loss config (loss_cfg.mode == "liger", in our runs injected as XTUNER_LOSS_MODE=liger) — the device dispatch itself has no env knob by design; its three knobs above only tune the chunked forward. #2120 additionally reuses the existing XTUNER_HF_IMPL knob for bitwise-parity comparison against the fla/HF reference implementations. #2241 additionally adds the config field CELossConfig.z_loss_coefficient (default 0.0) for the fused LM z-loss — a config, not an env knob; in liger mode the z-loss enters the gradient and logging (lm_z_loss) but not the loss value. #2240 narrows XTUNER_NPU_GDN_API to the GDN chunk op resolution only (it no longer selects the causal-conv backend, which is auto-resolved per fla_npu version instead).

PRs

Activity

  1. changed the title [-][Feature] NPU (Ascend) acceleration for GatedDeltaNet and RMSNorm ops[/-] [+][Feature] NPU (Ascend) training performance optimizations for Qwen3.5 dense text model[/+] on Sep 26, 2026
  2. longboat2010 commented on Sep 27, 2026

    @longboat2010
    Author

    New companion PR: #2129 — NPU liger fused-linear-cross-entropy loss (XTUNER_LOSS_MODE=liger), independent of #2119 / #2120 / #2128.

    mode="liger" now works on NPU via an in-package FLCE that drives liger-ascend's triton kernels directly (the stock ascend plain-CE kernel miscomputes loss_1d in multi-rank training); CUDA keeps liger_kernel's stock class. Chunk-vs-liger A/B (Qwen3.5 dense text model, 16 Ascend NPUs, 30 steps, GA=4/mbs=2): step-1/step-2 losses bitwise-identical, +12.3% global tok/s (157.9K → 177.3K), +1.89 GB peak memory, 0 NaN.

  3. longboat2010 commented on Sep 27, 2026

    @longboat2010
    Author

    New companion PR: #2130 — Route FSDP2 unshard to the compute stream (XTUNER_FSDP_UNSHARD_ASYNC_OP), independent of #2119 / #2120 / #2128 / #2129.

    Behind the knob (default off), FSDP2's parameter all-gather is issued on the compute stream instead of the separate all_gather_stream. On Ascend the caching allocator segregates cached blocks per stream, so the separate-stream unshard buffer strands reserved memory that empty_cache cannot reclaim; routing the unshard to the compute stream removes the strand, while wait_for_unshard + explicit prefetch keep the overlap. A/B (Qwen3.5 dense text model, 16 Ascend NPUs, 30 steps, GA=4/mbs=2): reserved −1.04 GB, live peak unchanged, TGS parity within noise, step-1/step-2 losses bitwise-identical, 0 NaN.

  4. longboat2010 commented on Sep 27, 2026

    @longboat2010
    Author

    New companion PR: #2131 — Bind NPU training ranks to NUMA-local CPU slices (XTUNER_NPU_CPU_BIND), independent of #2119 / #2120 / #2128 / #2129 / #2130.

    A stdlib-only npu_cpu_binder pins each rank process and its NPU's host threads to an 8-core NUMA-local slice, wired into _try_bind_numa (all training modes bind). A/B (Qwen3.5-2B dense text, 16 nodes × 16 Ascend NPUs = 256 ranks, 100 steps): binding verified 256/256, +1.7% global tok/s (2527.4K vs 2485.7K; paired clean steps +4.0% median, 9/12), step-1/step-2 losses bitwise-identical, peak memory unchanged.

  5. PussyCat0700 commented on Oct 27, 2026

    @PussyCat0700

    Follow-up precision fix: #2222 — Preserve FP32 target subtraction in NPU Liger CE backward (draft, depends on #2129).

    We found an extra low-precision rounding in the no-weight Ascend CE backward used by the proposed NPU FLCE path: the target currently follows BF16(BF16(p_y * u) - u) rather than subtracting/scaling in FP32 before a single BF16 store. A dependency-free high-confidence-target example gives approximately -2.074e-10 instead of -5.120e-12; the fix yields -5.116e-12. This is a deterministic local derivative bias, not evidence that global gradients are amplified by the same ratio or that all run-to-run variation/NaN events have this cause.

    The PR adds a local backward kernel, CPU regressions and device-gated NPU kernel tests, without monkey-patching the installed Liger package or changing CUDA dispatch. CPU checks pass; the exact new NPU test module is still pending Ascend execution, hence draft status. Because #2129 is not merged, its implementation is included as an explicitly identified dependency with original authorship, followed by the isolated correction commit.

  6. longboat2010 commented on Oct 29, 2026

    @longboat2010
    Author

    New companion PR: #2240 — fla_npu >= 26.10 compatibility for the GDN AscendC stack (chunk loader + causal conv backends), stacked on #2120 and touching only its two GDN modules.

    fla_npu's 26.10 line splits the API surface: stable raw ops move to fla_npu.ops.ascendc.*, the legacy torch.ops.npu.* registrations need an explicit load_legacy_torch_ops() call, and some wheel variants ship no embedded legacy extension. The chunk loader now tolerates that (best-effort shim; the stable API path the chunk route uses is unaffected), and the causal-conv backend resolution becomes dual-generation (stable raw vs legacy torch.ops.npu.*) with per-backend calling conventions. The startup conv probe is upgraded from isfinite-only to a numeric F.conv1d autograd check, so the "finite but wrong" stable conv adapter observed on a 26.10 dev wheel (all-ones input → all-zeros output) is rejected at startup and the unmasked-correction fallback engages, instead of surfacing as NaN loss mid-training.

    Validation: fla_npu 26.6.0 dispatcher-level conv fwd bit-exact (0.00000), dx ≤ 0.125, dw ≤ 1.0 (behavior on 26.6 unchanged); on 26.10.0+dev the GDN chunk route resolves via the stable API with a clean legacy skip and clean conv fallback. End-to-end (Qwen3.5 dense text, 256 Ascend NPUs = 16 nodes × 16, 8K seq, 10-step benchmark, mbz=1/gas=2/gbs=512): [gdn_ascendc] GDN kernel: fla_npu AscendC + [l2norm] fla_npu triton, 10/10 steps, 0 NaN, benchmark success. Independent of #2119 / #2128 / #2129 / #2130 / #2131; depends on #2120 (files it introduces).

  7. longboat2010 commented on Oct 29, 2026

    @longboat2010
    Author

    #2129 follow-up: #2241 applies the v3 liger CE optimization set on top of it (stacked branch feat/npu-flce-liger-ce-opt; merge #2129 first, then #2241 becomes a clean one-commit diff):

    • Backward scratch-buffer reuse (recomputed logits / grad-logits hoisted out of the chunk loop, torch.mm(out=)) — removes the largest per-chunk transient (~1.9 GiB at vocab 248k).
    • Vocabulary-slab grad_weight accumulation (XTUNER_LIGER_GRAD_WEIGHT_CHUNK, default 4096) — replaces the ~2 GiB [V, H] temporary; K-order per element unchanged.
    • Backward recompute chunk tied to LOSS_CHUNK_SIZE (was hardcoded 4096).
    • Latent bug fix: reduction='none' multi-chunk backward indexed the full [BT] grad_output chunk-locally, so chunks > 0 read wrong rows (probe: old rel 21–5727 vs reference, fixed 0.00e+00). ce_loss.py's liger path uses sum, so latent only.
    • Fused LM z-loss: CELossConfig.z_loss_coefficient (default 0.0), fused CE+LSE² forward kernel; in liger mode the z-loss enters gradient + logging (lm_z_loss) but not the loss value.
    • Compatibility: HAS_TAIL signature detection so the no-weight backward call works on both liger 0.8.1 and 0.8.5+ (0.8.5 made it a required constexpr).

    Validated 45/45 vs fp32 autograd references on NPU (chunked + saved-logits phases, confident-row cases, z-loss vs reference, wiring) plus a 16-NPU e2e A/B with the same implementation on an internal fork: step-1 loss bitwise identical, max_memory −4.6 GB sustained, steady tgs +4.2% (trend). XTUNER_LIGER_GRAD_WEIGHT_CHUNK added to the knob table above.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions