diff --git a/.claude/skills/ws1-single-card-kernel/SKILL.md b/.claude/skills/ws1-single-card-kernel/SKILL.md new file mode 100644 index 00000000..2807ef9c --- /dev/null +++ b/.claude/skills/ws1-single-card-kernel/SKILL.md @@ -0,0 +1,243 @@ +--- +name: ws1-single-card-kernel +description: Use when writing a new WS1 single-card kernel operator in this repo (rl-kernel) - PyTorch golden first, then CUDA, then ROCm, then Ascend; gtest registration; PR with exact pytest/gtest commands and results; deterministic backward when the op needs one. The Ascend section is battle-tested; CUDA/ROCm sections are placeholders. +--- + +# WS1 Single-Card Kernel Workflow + +Follow this workflow when adding a new operator (rmsnorm / embedding / lm_head / logp / +fused linear logp / rope / silu / swiglu / attention ...). The per-platform order is +fixed, as are the registration and PR deliverable requirements. + +## Global Workflow (all platforms) + +1. **Write the PyTorch golden first**: `rl_engine/kernels/ops/pytorch//.py`, + the WS1 ground-truth reference — a hand-written fixed-order fp32 reference (e.g. + the hand-written softmax for attention, per-row `torch.mv` for lm_head), + deliberately NOT `F.scaled_dot_product_attention` / `torch.matmul` shortcuts whose + reduction order is unspecified. Expose both `forward` (dtype path) and + `forward_fp32` (golden path). +2. **CUDA platform** (next section — placeholder for now). +3. **ROCm platform** (next section — placeholder for now). +4. **Ascend platform** (see the Ascend section) — **only after CUDA is done**: the + Ascend kernel mirrors the CUDA deterministic kernel's reduction contract (e.g. + contract v1 in `csrc/cuda/fused_linear_logp_sm90.cu`). +5. **Register in gtest**: add a platform entry (e.g. `"ascend"`) to the op's + `candidate_paths` in `rl_engine/kernels/gtest/operator_specs.py`. Registration + itself is the CI gate (`tests/test_ws1_gtest_gpu.py` checks every WS1 op is in the + spec). +6. **PR must report exact commands and results**: give the actual pytest command + line, the gtest command line (`scripts/check_operator.py` with full arguments), + and the outputs (template below). +7. **Backward must also be deterministic**: whenever the op needs a backward, the + backward must be a deterministic implementation (rules in the Ascend section). + +## CUDA + +(Placeholder — to be filled in.) + +## ROCm + +(Placeholder — to be filled in.) + +## Ascend (battle-tested workflow) + +### Branch and PR conventions + +- Branch off `upstream/test` (NOT `main`): `git checkout -b feat/ascend-deterministic- upstream/test`. +- PR base = `test`; title format: `[WS1][Ascend] [Qwen3-8b] ops` (e.g. + `[WS1][Ascend] [Qwen3-8b] Fused logp ops`). +- Pushing: authenticate `gh` (`gh auth login --with-token`, then + `gh auth setup-git`); if direct github.com connectivity is flaky, push through + the ghfast proxy (token as the proxy host's userinfo). `gh pr edit --base` hits a + GraphQL classic-projects deprecation error — use + `gh api -X PATCH repos/RL-Align/RL-Kernel/pulls/ -f base=test` instead. +- PR description drafts: keep a scratch directory OUTSIDE the repo for + `PR_DESCRIPTION_*.md` drafts (template below). + +### Implementation checklist (file level) + +The complete landing list for a new Ascend op: + +1. `csrc/ascend/_ascend.asc` — Ascend C kernel + torch host wrapper. No + `PYBIND11_MODULE` (consolidated in npu_module.cpp). +2. `csrc/ascend/npu_module.cpp` — the single pybind entry declaring and binding all + ops. Each `.asc` carrying its own `PYBIND11_MODULE` causes duplicate + `PyInit__C_npu` link errors; for an existing `.asc` (e.g. batch_invariant_logp) + just drop its `PYBIND11_MODULE` block. +3. `setup.py` — port the Ascend extension build (bisheng, `**/*.asc` glob, + `_find_ascend_home()` exporting `ASCEND_HOME_PATH`/`ASCEND_TOOLKIT_HOME`). + Fastest: `git checkout -- setup.py scripts/check_operator.py`. +4. `rl_engine/_C_npu.pyi` — type stub (black: no blank line between two top-level + defs). +5. `rl_engine/kernels/ops/ascend//.py` — the op wrapper (mirror the CUDA + wrapper's surface: `__call__`/`apply`/`forward`/`forward_fp32`, dtype gate, + `_NPU_EXT_AVAILABLE` + `hasattr(_C_npu, ...)` check, native fallback path). +6. `rl_engine/kernels/gtest/operator_specs.py` — the `"ascend"` candidate. +7. `rl_engine/kernels/registry.py` — `ASCEND_` enum member + npu priority map + override (`self._priority_map["npu"][""] = [ASCEND_..., PYTORCH_...]`). +8. `rl_engine/tests/test_dispatch.py` — npu priority assertion. +9. `tests/test__ascend.py` — pytest suite (PR 320 style, see below). +10. `docs/operators/.md` — Ascend row in the Backends table, npu dispatch + paragraph, Tests and Implementation Files updates (**keep existing entries**, + add only). +11. `scripts/check_operator.py` — already supports `--device npu` (auto-detect). + +Build and smoke test: + +```bash +KERNEL_ALIGN_FORCE_ASCEND=1 pip install -e . --no-build-isolation +``` + +### Bitwise-consistency rules (mandatory; must be stated clearly in the PR) + +Classify the op BEFORE writing the PR: + +- **Copy/lookup ops (elementwise, e.g. embedding)**: the forward is a pure byte + move, so it MUST be bitwise-identical to the PyTorch golden — assert with + `torch.equal`. +- **Reduction ops (reduction / logprob, e.g. lm_head, logp, fused linear logp)**: + **no independent kernel can be bitwise-identical to the golden** — the golden's + reduction order is the private implementation of + `torch.mv`/`torch.matmul`/`logsumexp`, fp32 addition is not associative, and two + different reduction trees over D=4096 inevitably drift ~1e-4 (the logprob + contract's fp32 atol=1e-5 is naturally unmeetable). Practice: + - The bitwise guarantee goes to **batch invariance on the NPU**: the same row + content across batch 1 vs {2,4,16,300}, different positions, strided blocks + (>MAX_BLOCKS), multi-tile shapes, repeated runs — all asserted with + `torch.equal`. + - Compare against the golden at the existing contract tolerances; state + prominently in a blockquote at the top of the PR body WHY bitwise parity is + impossible (golden's private reduction order + measured drift numbers). +- **Never touch tolerances**: `rl_engine/kernels/gtest/tolerance_contract.json` is + read-only; look up rows by op_class x dtype. + +Known NPU-side golden gotchas (check before writing tests): +- NPU `torch.mv` **rejects bf16** → golden references must go through the + `forward_fp32` paths. +- The gtest `linear_logp` forward comparison is unwinnable even for the CUDA + candidate (the golden's `apply()` accumulates the matmul in the input dtype); CI + never executes that candidate, it only checks registration. Do not try to adjust + tolerances for it. +- `torch.argsort(int64, stable=True)` on NPU runs on the AiCpu — a performance + warning only, results are correct. + +### Ascend C kernel gotchas (each one hit on real hardware) + +- **Cross-pipe race on shared UB buffers**: when one UB tile is written by MTE2 and + read by MTE3, use the canonical two-queue GM→UB→GM pipeline; the fixed out-queue + order is `AllocTensor → EnQue → DeQue → DataCopy → FreeTensor` (the queues + provide the MTE2→V / V→MTE3 sync). Do NOT hand-roll `MTE2_MTE3`/`MTE3_MTE2` + flags (random data corruption or hangs). +- **Vector ops need 32B-aligned counts**: UB→UB `DataCopy`, `Cast`, etc. report + "VEC supports illegal configurations" for small counts → round the count up to a + multiple of `32/sizeof(T)` (over-copy inside UB is harmless; the copy-out writes + only the real byte count to GM). +- **GM scalar reads/writes are unreliable**: `GlobalTensor.GetValue/SetValue` has + hardware issues — always read through a 32B `DataCopyPad` window (int64 window = + 4 per 32B, fp32 = 8 per 32B), sync with an `MTE2_S` flag before `GetValue`. +- **`SyncAll` deadlocks**: with more blocks launched than physical cores the + cross-core barrier deadlocks — only per-pipe `SetFlag`/`WaitFlag` (V_S, S_V, + S_MTE3, MTE3_S, ...). +- **Strided rows across blocks**: `MAX_BLOCKS=128`, + `for (row = GetBlockIdx(); row < N; row += GetBlockNum())`, host side + `blockNum = min(N, MAX_BLOCKS)` — each row is processed end-to-end by one block, + so the instruction sequence depends only on the shape, never on batch layout or + block assignment (the foundation of batch invariance). +- **Scalar math in the kernel**: the scalar unit has no exp/log → use a padded + 8-element vector `Exp`/`Log` (`SetValue → S_V flag → vector op → V_S wait → + GetValue`). +- **Output staging**: `SetValue` into a UB scalar buffer, `S_MTE3` flag, then + `DataCopyPad` out to GM; drain with `MTE3_S` after each row so the next row does + not overwrite the staging area. +- **fp16/bf16 output cast**: `Cast(..., RoundMode::CAST_RINT, 32/sizeof(T))` — + CAST_RINT is IEEE round-to-nearest, matching CUDA's `static_cast` semantics. +- **bisheng build**: needs `ASCEND_HOME_PATH` (setup.py exports it automatically); + when pip swallows the real compiler error, compile the `.asc` manually with + `bisheng` to see it. +- **const pointers**: kernel-launch GM_ADDR parameters take `uint8_t*` (non-const). + +### Backward determinism + +Per PR #299 (frank-2077, FFN deterministic backward): **the backward is assembled +from existing deterministic forward kernels — no new reductions, no fallback to +cuBLAS/torch.matmul**. Priority order: + +1. **Reuse a pure-PyTorch deterministic formula**: when the CUDA op's backward is + itself pure PyTorch (e.g. embedding's sorted-segment dweight: stable argsort + + unique_consecutive + fixed-order accumulation), the Ascend op reuses the exact + same function → bitwise-identical backward. +2. **Row-local fp32 VJP formulas** (logp / linear_logp / lm_head): compute the VJP + in fp32 with torch ops, cast back to the input dtype at the end; no cross-row + reduction → batch-layout independent. +3. **GEMM-shaped backward**: assemble with `det_gemm` forwards + (`grad_hidden = det_gemm(grad, W)`, `grad_weight = det_gemm(grad^T, H)`); the + wrapper must raise when the det_gemm symbols are missing instead of silently + falling back. +4. **TP scenarios**: mind the shard semantics (PR #299 checklist: gate/up input + grads each take one AllReduce, weight grads stay column-parallel shards, down is + row-parallel, etc.). +5. Low-precision gradient comparisons: when both implementations compute the VJP in + fp32 and quantize at the end, compare against the quantization-aligned + reference (`ref_grad.to(dtype)`) — can be bitwise equal; tolerances only absorb + the rare 1-ULP straddle. + +### pytest suite conventions (PR 320 style) + +`tests/test__ascend.py` structure: + +- Module docstring stating the two orthogonal properties (correctness + batch + invariance). +- `_npu_available()` / `_ascend_kernel_available()` helpers + + `requires_ascend = pytest.mark.skipif(...)`. +- `TestAscendCorrectness`: class-level + `@pytest.mark.parametrize("dtype", [fp32, bf16, fp16])`; forward vs golden + (bitwise for copy ops / contract tolerance for reductions), `forward_fp32`, + out-of-range targets, backward, bias (if any). +- `TestAscendBatchInvariance`: bitwise (`torch.equal`). +- `TestAscendRegistryDispatch`: `kernel_registry.get_op("", device="npu")` + `type(op).__name__` assertion. + +Test bugs already hit (check before writing new tests): +- Under class-level `parametrize`, every method must take the `dtype` argument — + move tests that don't into their own class. +- Batch-comparison tests must **reuse the same weight** (regenerating with the same + seed produces a different weight for different batch sizes). +- Position-invariance tests: pin the same row content + (`logits[pos].copy_(base)` + `target[pos] = base_id`). +- Row-local VJP bitwise assertions: align the `grad_out` rows too + (`grad_out[1] = grad_out[0]`). + +### PR description template + +Structure (mirror the wording of previous Ascend PR descriptions): + +```markdown +## Latest Status [date] +Ready for review. + +## Summary +- Bitwise-consistency status (prominent blockquote — mandatory for reduction ops) +- Forward kernel design (which CUDA kernel/contract it mirrors) +- Wrapper / Backward / Registration / Build + +## Files (table, one row per file with Status) + +## Test +# The exact commands that were run: +export KERNEL_ALIGN_FORCE_ASCEND=1 +pip install -e . --no-build-isolation +python scripts/check_operator.py --op --candidate ascend --device npu \ + --dtype {fp32,bf16,fp16} --batch 2 --seq 16 --vocab 257 --normalized-dim 4096 --check-grad +python -m pytest tests/test__ascend.py -v +python -m pytest tests/test_batch_invariant_logp.py -q # regression +python -m pytest rl_engine/tests/test_dispatch.py -q # regression + +## Test results (environment line + results table +
folded raw output) + +## Notes +``` + +Must include: the actual test environment (NPU model, CANN version, torch + +torch_npu versions), per-dtype gtest output and pytest results, +bitwise-invariance conclusions, regression results, pre-commit status. diff --git a/.github/workflows/ws1-chain-npu.yml b/.github/workflows/ws1-chain-npu.yml new file mode 100644 index 00000000..362c18b3 --- /dev/null +++ b/.github/workflows/ws1-chain-npu.yml @@ -0,0 +1,95 @@ +# SPDX-License-Identifier: Apache-2.0 +# WS1 C10/C11 full Qwen3-8B Dense model-level gate on Ascend NPU (ascend_bf16). +# Required check: no skip / xfail / synthetic weights / silent fallback. +# +# Unlike the CUDA job there is no cloud NPU provider wired up here, so this runs +# on a self-hosted Ascend runner (Atlas A2 / 910B with CANN + torch_npu) that a +# maintainer registers with the labels below. Without such a runner the job +# queues rather than reporting a false pass - a required profile that did not +# execute is red, never N/A. +# +# Security: do not use pull_request_target. Fork PRs never reach the self-hosted +# runner; a maintainer dispatches the reviewed SHA from a trusted branch. + +name: WS1-chain-NPU + +on: + pull_request: + branches: [ main, test ] + push: + branches: [ main, test ] + workflow_dispatch: + inputs: + source_repository: + description: "Public repository containing the reviewed commit (owner/name)" + required: true + default: "RL-Align/RL-Kernel" + type: string + source_sha: + description: "Exact reviewed 40-character commit SHA to execute on the NPU host" + required: true + type: string + +concurrency: + group: ws1-chain-npu-${{ github.ref }} + cancel-in-progress: false + +permissions: + contents: read + +jobs: + fork-pr-notice: + if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository + runs-on: ubuntu-latest + steps: + - name: Report required trusted execution + run: | + echo "Fork code does not run on the self-hosted Ascend runner." + echo "A maintainer must dispatch this workflow from a trusted upstream branch." + echo "source_repository=${{ github.event.pull_request.head.repo.full_name }}" + echo "source_sha=${{ github.event.pull_request.head.sha }}" + + ws1-chain-npu: + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository + runs-on: [ self-hosted, linux, ascend-npu ] + timeout-minutes: 240 + env: + # Set on the runner: the pinned Qwen3-8B Dense snapshot directory. + WS1_WEIGHTS_PATH: ${{ vars.WS1_WEIGHTS_PATH }} + RL_KERNEL_REQUIRE_EXT: "1" + WS1_WORKFLOW_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + steps: + - name: Validate trusted dispatch target + if: github.event_name == 'workflow_dispatch' + env: + SOURCE_REPOSITORY: ${{ inputs.source_repository }} + SOURCE_SHA: ${{ inputs.source_sha }} + run: | + [[ "$SOURCE_REPOSITORY" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]] + [[ "$SOURCE_SHA" =~ ^[0-9a-fA-F]{40}$ ]] + + - name: Checkout reviewed commit + uses: actions/checkout@v4 + with: + repository: ${{ github.event_name == 'workflow_dispatch' && inputs.source_repository || github.repository }} + ref: ${{ github.event_name == 'workflow_dispatch' && inputs.source_sha || github.event.pull_request.head.sha || github.sha }} + persist-credentials: false + + - name: Report Ascend environment + run: | + python3 -c "import torch, torch_npu; print('torch', torch.__version__, 'torch_npu', torch_npu.__version__)" + npu-smi info || true + + - name: Run WS1 Ascend C3-C11 gates + run: bash ci/run_ws1_ascend_ci.sh + + - name: Upload C2/C8/C10 JSON + if: always() + uses: actions/upload-artifact@v4 + with: + name: ws1-closeout-ascend + path: | + /tmp/ws1-c2-ascend.json + /tmp/ws1-c8-ascend.json + /tmp/ws1-c10-ascend_bf16.json + if-no-files-found: error diff --git a/benchmarks/benchmark_attention.py b/benchmarks/benchmark_attention.py index c9509a19..99705a52 100644 --- a/benchmarks/benchmark_attention.py +++ b/benchmarks/benchmark_attention.py @@ -1,70 +1,187 @@ -# File: benchmarks/benchmark_attention.py -import pandas as pd -import torch -import triton -from tabulate import tabulate +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 RL-Kernel Contributors -from rl_engine.kernels.ops.cuda.attention.prefix_shared_attn import PrefixSharedAttentionOp +"""Benchmark deterministic standard-softmax attention across backends. +All backends compute ``softmax(Q K^T * scale + causal mask) @ V`` with a locked, +per-row reduction order (batch-invariant, no split-K). The comparison here is +latency across a sequence sweep: -def run_benchmark(): - bs = 1 - G = 64 - len_q = 512 - dim = 128 +- Native is the pure-PyTorch fp32-accumulating ground-truth reference. +- Ascend is the CANN two-pass streaming kernel (one AI core block/row); only + present when the extension is built with ``KERNEL_ALIGN_FORCE_ASCEND=1``. +- CUDA is the deterministic op (one CTA/row); only present when the extension + is built with ``KERNEL_ALIGN_FORCE_SM90=1`` on an SM90 device. - len_kvs = [1024, 2048, 4096, 8192, 16384] +Timing dispatch through the active accelerator (``torch.cuda`` or +``torch.npu``), so the benchmark runs on CUDA/ROCm/NPU devices. - print("Benchmarking GRPO Prefix-Shared Attention") - print(f"Fixed Shapes: Batch={bs}, Group(Response)={G}, Query_Len={len_q}, Head_Dim={dim}\n") +Usage: + python benchmarks/benchmark_attention.py + python benchmarks/benchmark_attention.py --backward + python benchmarks/benchmark_attention.py --configs "1,8,512;2,8,2048" +""" - prefix_shared_sdpa = PrefixSharedAttentionOp() - results = [] +import argparse - for len_kv in len_kvs: - q = torch.randn(bs, G, len_q, dim, dtype=torch.bfloat16, device="cuda") - k = torch.randn(bs, len_kv, dim, dtype=torch.bfloat16, device="cuda") - v = torch.randn(bs, len_kv, dim, dtype=torch.bfloat16, device="cuda") +import torch +from tabulate import tabulate - k_exp = k.unsqueeze(1).expand(-1, G, -1, -1).reshape(bs * G, 1, len_kv, dim).contiguous() - v_exp = v.unsqueeze(1).expand(-1, G, -1, -1).reshape(bs * G, 1, len_kv, dim).contiguous() - q_res = q.view(bs * G, 1, len_q, dim) +from rl_engine.kernels.ops.pytorch.attention.standard_attn import NativeAttentionOp +from rl_engine.platforms.device import device_ctx +from rl_engine.utils.logger import logger - for _ in range(5): - _ = torch.nn.functional.scaled_dot_product_attention(q_res, k_exp, v_exp) - _ = prefix_shared_sdpa(q, k, v) +_D = 128 +_DEFAULT_KV_HEADS = 8 - native_ms = triton.testing.do_bench( - lambda: torch.nn.functional.scaled_dot_product_attention(q_res, k_exp, v_exp), - return_mode="median", - ) - custom_ms = triton.testing.do_bench( - lambda: prefix_shared_sdpa(q, k, v), return_mode="median" - ) +def _accel(): + """The active accelerator module (torch.npu on Ascend, torch.cuda otherwise).""" + if device_ctx.device_type == "npu": + return torch.npu + return torch.cuda - speedup = native_ms / custom_ms - reduction = (native_ms - custom_ms) / native_ms * 100 - - flops = 4 * bs * G * len_q * len_kv * dim - native_tflops = (flops / 1e12) / (native_ms / 1000) - custom_tflops = (flops / 1e12) / (custom_ms / 1000) - - results.append( - { - "Prompt Len": len_kv, - "Native (ms)": f"{native_ms:.3f}", - "RL-Kernel (ms)": f"{custom_ms:.3f}", - "Native TFLOPS": f"{native_tflops:.1f}", - "RL-Kernel TFLOPS": f"{custom_tflops:.1f}", - "Speedup": f"{speedup:.2f}x", - "Time Saved": f"{reduction:.1f}%", - } - ) - df = pd.DataFrame(results) - print(tabulate(df, headers="keys", tablefmt="pretty", stralign="center", showindex=False)) +def _maybe_ascend_op(): + """The Ascend C op, or None when unavailable (no NPU / not built).""" + if device_ctx.device_type != "npu": + return None + try: + from rl_engine.kernels.ops.ascend.attention.deterministic_attn import ( + DeterministicAttentionAscendOp, + ) + except (ImportError, RuntimeError): + return None + return DeterministicAttentionAscendOp() + + +def _maybe_cuda_op(): + """The CUDA deterministic op, or None when unavailable (no CUDA / not built).""" + from rl_engine.kernels.ops.base import _C, _EXT_AVAILABLE + + if not ( + torch.cuda.is_available() + and _EXT_AVAILABLE + and hasattr(_C, "deterministic_attention_forward") + ): + return None + from rl_engine.kernels.ops.cuda.attention.deterministic_attn import DeterministicAttentionOp + + return DeterministicAttentionOp() + + +# (batch, num_q_heads, seqlen); Hq = 32, Hkv = 8 (Qwen3-style GQA, g = 4). +DEFAULT_CONFIGS = [ + (1, 32, 512), + (1, 32, 1024), + (1, 32, 2048), + (4, 32, 2048), +] + + +def _make_inputs(batch, hq, seq, device, dtype): + generator = torch.Generator(device="cpu").manual_seed(0) + q = torch.randn(batch, hq, seq, _D, dtype=dtype, generator=generator).to(device) + k = torch.randn(batch, _DEFAULT_KV_HEADS, seq, _D, dtype=dtype, generator=generator).to(device) + v = torch.randn(batch, _DEFAULT_KV_HEADS, seq, _D, dtype=dtype, generator=generator).to(device) + return q, k, v + + +def _time_ms(fn, warmup, iters): + acc = _accel() + for _ in range(warmup): + fn() + acc.synchronize() + start = acc.Event(enable_timing=True) + end = acc.Event(enable_timing=True) + start.record() + for _ in range(iters): + fn() + end.record() + acc.synchronize() + return start.elapsed_time(end) / iters + + +def _forward_closure(op, q, k, v): + def run(): + with torch.no_grad(): + op(q, k, v, causal=True) + + return run + + +def _forward_backward_closure(op, q, k, v): + def run(): + qq = q.detach().requires_grad_(True) + kk = k.detach().requires_grad_(True) + vv = v.detach().requires_grad_(True) + op(qq, kk, vv, causal=True).sum().backward() + + return run + + +def _bench_table(configs, native_op, other_ops, closure_factory, device, dtype, warmup, iters): + label = "fwd" if closure_factory is _forward_closure else "fwd+bwd" + rows = [] + for batch, hq, seq in configs: + q, k, v = _make_inputs(batch, hq, seq, device, dtype) + n_c = closure_factory(native_op, q, k, v) + n_ms = _time_ms(n_c, warmup, iters) + row = [f"{batch}x{hq}x{seq}", f"{n_ms:.3f}"] + for _name, op in other_ops: + if op is None: + row += ["-"] + continue + o_ms = _time_ms(closure_factory(op, q, k, v), warmup, iters) + row += [f"{o_ms:.3f}", f"{n_ms / o_ms:.2f}x"] + rows.append(row) + + headers = ["shape (B x Hq x S)", f"native {label} ms"] + for name, _ in other_ops: + headers += [f"{name} {label} ms", "vs native"] + logger.info("\n" + tabulate(rows, headers=headers, tablefmt="github")) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--backward", action="store_true", help="also emit the forward+backward table" + ) + parser.add_argument( + "--configs", + default=";".join(",".join(map(str, c)) for c in DEFAULT_CONFIGS), + help="semicolon-separated 'batch,hq,seq' triples", + ) + parser.add_argument("--dtype", choices=("bf16", "fp16"), default="bf16") + parser.add_argument("--warmup", type=int, default=5) + parser.add_argument("--iters", type=int, default=20) + args = parser.parse_args() + + configs = [tuple(int(x) for x in part.split(",")) for part in args.configs.split(";")] + dtype = torch.bfloat16 if args.dtype == "bf16" else torch.float16 + device = torch.device(device_ctx.device_type) + + native_op = NativeAttentionOp() + other_ops = [ + ("ascend", _maybe_ascend_op()), + ("cuda", _maybe_cuda_op()), + ] + + _bench_table( + configs, native_op, other_ops, _forward_closure, device, dtype, args.warmup, args.iters + ) + if args.backward: + _bench_table( + configs, + native_op, + other_ops, + _forward_backward_closure, + device, + dtype, + args.warmup, + args.iters, + ) if __name__ == "__main__": - run_benchmark() + main() diff --git a/benchmarks/benchmark_rmsnorm.py b/benchmarks/benchmark_rmsnorm.py index 81325281..5227729c 100644 --- a/benchmarks/benchmark_rmsnorm.py +++ b/benchmarks/benchmark_rmsnorm.py @@ -14,14 +14,22 @@ except ImportError: HAS_CUDA_EXT = False +try: + from rl_engine.kernels.ops.ascend.norm.rmsnorm import RMSNormAscendOp + + HAS_ASCEND_EXT = True +except (ImportError, OSError, RuntimeError): + HAS_ASCEND_EXT = False + def bench(fn, x, w, dy, warmup=20, iters=100): + sync = torch.npu.synchronize if x.device.type == "npu" else torch.cuda.synchronize for _ in range(warmup): x.grad = None w.grad = None y = fn(x, w) y.backward(dy) - torch.cuda.synchronize() + sync() start = time.time() for _ in range(iters): @@ -29,7 +37,7 @@ def bench(fn, x, w, dy, warmup=20, iters=100): w.grad = None y = fn(x, w) y.backward(dy) - torch.cuda.synchronize() + sync() return (time.time() - start) * 1000.0 / iters @@ -41,7 +49,7 @@ def main(): args = parser.parse_args() dtype = torch.float16 if args.dtype == "fp16" else torch.bfloat16 - device = "cuda" + device = "npu" if torch.npu.is_available() else "cuda" T, H = args.T, args.H torch.manual_seed(0) @@ -72,6 +80,15 @@ def make_inputs(): else: print("cuda : skipped, extension is not built") + if device == "npu": + ascend_op = RMSNormAscendOp() if HAS_ASCEND_EXT else None + if ascend_op is not None: + x, w = make_inputs() + t_asc = bench(lambda a, b: ascend_op(a, b), x, w, dy) + print(f"ascend : {t_asc:.4f} ms | speedup vs ref: {t_ref / t_asc:.2f}x") + else: + print("ascend : skipped, extension is not built") + if __name__ == "__main__": main() diff --git a/ci/run_ws1_ascend_ci.sh b/ci/run_ws1_ascend_ci.sh new file mode 100755 index 00000000..b7eb4d23 --- /dev/null +++ b/ci/run_ws1_ascend_ci.sh @@ -0,0 +1,143 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: Apache-2.0 +# WS1 C3-C11 gate for the Ascend BF16 profile (#266, ascend_bf16). +# +# Runs on an Ascend host (Atlas A2 / 910B) with CANN and torch_npu. It is the +# NPU twin of ci/run_ws1_gtest.sh + ci/run_ws1_chain_gate.sh: same contract, +# same harnesses, same fail-closed rules. Nothing here may fall back to CPU or +# to another vendor's kernels - a required profile that cannot run is red. +# +# Required: +# WS1_WEIGHTS_PATH (or QWEN3_8B) pinned Qwen3-8B Dense safetensors snapshot +# Optional: +# PY interpreter (default python3) +# WS1_SKIP_BUILD=1 reuse an already-built rl_engine._C_npu + +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" + +PY="${PY:-python3}" +export RL_KERNEL_REQUIRE_EXT="${RL_KERNEL_REQUIRE_EXT:-1}" +WEIGHTS_PATH="${WS1_WEIGHTS_PATH:-${QWEN3_8B:-}}" + +echo "[ws1-ascend] interpreter=$PY" + +if [ "${WS1_SKIP_BUILD:-0}" != "1" ]; then + echo "[ws1-ascend] building the Ascend C extension" + KERNEL_ALIGN_FORCE_ASCEND=1 "$PY" -m pip install -e . --no-build-isolation --no-deps +fi + +# Fail before any gate if the NPU or the compiled kernels are missing, so a +# later red cell is never confused with an environment problem. +"$PY" - <<'PY' +import sys + +from rl_engine.kernels.gtest.accelerator import describe, npu_available, resolve_device + +if not npu_available(): + sys.exit("[ws1-ascend] FATAL: torch_npu reports no available NPU") +info = describe(resolve_device(None, profile="ascend_bf16")) +print(f"[ws1-ascend] device={info.device} name={info.name} soc={info.arch_key}") + +from rl_engine import _C_npu # noqa: E402 + +required = ( + "rmsnorm_ascend", + "rope_apply_ascend", + "deterministic_attention_ascend", + "embedding_ascend", + "lm_head_ascend", + "fused_logp_ascend", + "batch_invariant_logp_ascend", + "swiglu_forward", + "silu_forward", + "det_gemm_ascend_fwd", + "det_gemm_rowwise_ascend_fwd_fp32", +) +missing = [name for name in required if not hasattr(_C_npu, name)] +if missing: + sys.exit(f"[ws1-ascend] FATAL: _C_npu is missing {missing}; rebuild the extension") +print(f"[ws1-ascend] all {len(required)} required Ascend entry points are linked") +PY + +echo "[ws1-ascend] CPU-side contract, workload and wiring tests" +"$PY" -m pytest -q \ + tests/test_tolerance_contract.py \ + tests/test_ws1_workload.py \ + tests/test_four_judgment_matrix.py \ + tests/test_elementwise_inventory.py \ + tests/test_ws1_ascend_closeout.py + +echo "[ws1-ascend] Ascend operator tests" +"$PY" -m pytest -q \ + tests/test_det_gemm_ascend.py \ + tests/test_silu_ascend.py + +echo "[ws1-ascend] C2 runtime candidate evidence" +"$PY" scripts/ws1_candidate_evidence.py \ + --profile ascend_bf16 --all --check-grad --emit-json /tmp/ws1-c2-ascend.json +"$PY" - /tmp/ws1-c2-ascend.json <<'PY' +import json +import sys + +payload = json.load(open(sys.argv[1], encoding="utf-8")) +if not payload.get("passed"): + failed = [c["case_id"] for c in payload["cases"] if c["runtime_status"] != "passed"] + raise SystemExit(f"C2 Ascend runtime evidence failed: {failed}") +print(f"[ws1-ascend] C2 evidence passed for {len(payload['cases'])} pinned cases") +PY + +echo "[ws1-ascend] C3/C4 smoke (silu)" +"$PY" scripts/check_forward_invariance.py \ + --op silu --candidate ascend --backend-profile ascend_bf16 +"$PY" scripts/check_gradient_invariance.py \ + --op silu --candidate ascend --backend-profile ascend_bf16 + +echo "[ws1-ascend] C6 direct decode-prefill" +"$PY" scripts/check_decode_prefill.py --backend-profile ascend_bf16 +echo "[ws1-ascend] C7 stateful KV + generate-rescore" +"$PY" scripts/check_stateful_kv.py --backend-profile ascend_bf16 + +C8_OUT="${WS1_C8_JSON:-${TMPDIR:-/tmp}/ws1-c8-ascend.json}" +export WS1_C8_EVIDENCE_PATH="$C8_OUT" +echo "[ws1-ascend] C8 four-judgment sweep -> $C8_OUT" +"$PY" scripts/sweep_ws1_four_judgments.py \ + --execute --profile ascend_bf16 --json > "$C8_OUT" +"$PY" - "$C8_OUT" <<'PY' +import json +import subprocess +import sys + +payload = json.load(open(sys.argv[1], encoding="utf-8")) +git_meta = payload.get("git") or {} +expected = subprocess.check_output(["git", "rev-parse", "HEAD"], text=True).strip() +if git_meta.get("commit") != expected or git_meta.get("dirty"): + raise SystemExit(f"C8 is not from the clean current commit: {git_meta}") +counts = payload.get("counts") or {} +if int(counts.get("red", 0)): + raise SystemExit(f"C8 contains red rows: {counts}") +if int(counts.get("green", 0)) == 0: + raise SystemExit("C8 artifact has no green cells") +for cell in payload.get("cells") or []: + if cell.get("op_name") == "pack" or cell.get("status") != "green": + continue + if not cell.get("judgment", "").endswith("invariance"): + continue + if not cell.get("actual_backend_id") or not cell.get("actual_kernel_config_id"): + raise SystemExit( + f"invariance cell missing provenance: {cell.get('profile')} {cell.get('op_name')}" + ) +print(f"[ws1-ascend] C8 passed counts={counts}") +PY + +if [ -z "$WEIGHTS_PATH" ]; then + echo "[ws1-ascend] FATAL: set WS1_WEIGHTS_PATH or QWEN3_8B for the C10/C11 full-model gate" + exit 2 +fi + +echo "[ws1-ascend] C10/C11 full Qwen3-8B Dense model gate" +WS1_PROFILES="ascend_bf16" WS1_C8_JSON="$C8_OUT" bash ci/run_ws1_chain_gate.sh + +echo "[ws1-ascend] ascend_bf16 passed every required WS1 gate" diff --git a/ci/run_ws1_chain_gate.sh b/ci/run_ws1_chain_gate.sh index b8382ba4..c367ab10 100755 --- a/ci/run_ws1_chain_gate.sh +++ b/ci/run_ws1_chain_gate.sh @@ -1,7 +1,12 @@ #!/usr/bin/env bash # SPDX-License-Identifier: Apache-2.0 -# WS1 C10/C11 full Qwen3-8B Dense model-level gate (CUDA BF16 and Triton-on-CUDA BF16). -# Intended for H20 / H100. Fails closed on skip, xfail, synthetic weights, or silent fallback. +# WS1 C10/C11 full Qwen3-8B Dense model-level gate. +# Profiles come from WS1_PROFILES (default: the two CUDA-host profiles). One host +# has either a GPU or an NPU, so each vendor's job runs its own profiles here: +# CUDA host : WS1_PROFILES="cuda_bf16 triton_cuda_bf16" (H20 / H100) +# Ascend host : WS1_PROFILES="ascend_bf16" (Atlas A2 / 910B) +# C11 closes only when every required profile has passed on its own hardware. +# Fails closed on skip, xfail, synthetic weights, or silent fallback. set -euo pipefail @@ -11,13 +16,14 @@ cd "$ROOT" PY="${PY:-python3}" export RL_KERNEL_REQUIRE_EXT="${RL_KERNEL_REQUIRE_EXT:-1}" WEIGHTS_PATH="${WS1_WEIGHTS_PATH:-${QWEN3_8B:-}}" +WS1_PROFILES="${WS1_PROFILES:-cuda_bf16 triton_cuda_bf16}" if [ -z "$WEIGHTS_PATH" ]; then echo "[ws1-chain] FATAL: set WS1_WEIGHTS_PATH or QWEN3_8B to the pinned Qwen3-8B snapshot" exit 2 fi -echo "[ws1-chain] interpreter=$PY weights=$WEIGHTS_PATH" +echo "[ws1-chain] interpreter=$PY weights=$WEIGHTS_PATH profiles=$WS1_PROFILES" "$PY" -m pytest -q \ tests/test_kv_consistency.py \ @@ -27,7 +33,11 @@ echo "[ws1-chain] interpreter=$PY weights=$WEIGHTS_PATH" C8_OUT="${WS1_C8_JSON:-${TMPDIR:-/tmp}/ws1-c8-ci.json}" export WS1_C8_EVIDENCE_PATH="$C8_OUT" echo "[ws1-chain] C8 runtime evidence $C8_OUT" -"$PY" scripts/sweep_ws1_four_judgments.py --execute --json > "$C8_OUT" +C8_PROFILE_ARGS=() +for PROFILE in $WS1_PROFILES; do + C8_PROFILE_ARGS+=(--profile "$PROFILE") +done +"$PY" scripts/sweep_ws1_four_judgments.py --execute "${C8_PROFILE_ARGS[@]}" --json > "$C8_OUT" "$PY" - "$C8_OUT" <<'PY' import json import subprocess @@ -44,7 +54,7 @@ if int((payload.get("counts") or {}).get("red", 0)): print(f"[ws1-chain] C8 passed source={git_meta}") PY -for PROFILE in cuda_bf16 triton_cuda_bf16; do +for PROFILE in $WS1_PROFILES; do OUT="/tmp/ws1-c10-${PROFILE}.json" echo "[ws1-chain] C10/C11 $PROFILE" "$PY" scripts/ws1_chain_gate.py \ @@ -166,7 +176,11 @@ for kind in ("lm_head", "rms_norm", "det_gemm", "embedding"): raise SystemExit(f"{profile} missing runtime backward record for {kind}") if not event.get("kernel_id"): raise SystemExit(f"{profile} backward {kind} missing kernel_id") - family = "triton" if profile.startswith("triton") else "cuda" + family = { + "cuda_bf16": "cuda", + "triton_cuda_bf16": "triton", + "ascend_bf16": "ascend", + }[profile] if not event.get("kernel_ids"): raise SystemExit(f"{profile} backward {kind} missing kernel_ids") if not event.get("implementation_ids"): @@ -197,4 +211,4 @@ print(f"[ws1-chain] {profile} passed first_drift={payload.get('first_drift')}") PY done -echo "[ws1-chain] both required profiles passed" +echo "[ws1-chain] profiles passed: $WS1_PROFILES" diff --git a/csrc/ascend/activation.asc b/csrc/ascend/activation.asc new file mode 100644 index 00000000..6a6d3613 --- /dev/null +++ b/csrc/ascend/activation.asc @@ -0,0 +1,441 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (c) 2026 RL-Kernel Contributors +// SwiGLU: out = (gate * sigmoid(gate)) * up, with FP32 intermediates. +// SiLU: out = x * sigmoid(x), the same tile shape with one operand. +// Fixed elementwise tiles have no reductions or inter-core synchronization. + +#include +#include + +#include "kernel_operator.h" +#include +#include +#include "torch_npu/csrc/core/npu/NPUStream.h" + +namespace { + +constexpr uint32_t TILE_LENGTH = 2048; +constexpr int64_t MAX_BLOCKS = 32; + +template +class KernelSwiGLU { +public: + __aicore__ inline void Init(AscendC::TPipe* pipe, GM_ADDR gate, GM_ADDR up, + GM_ADDR grad, GM_ADDR out, GM_ADDR dUp, int64_t n) + { + n_ = n; + gateGm_.SetGlobalBuffer(reinterpret_cast<__gm__ T*>(gate)); + upGm_.SetGlobalBuffer(reinterpret_cast<__gm__ T*>(up)); + outGm_.SetGlobalBuffer(reinterpret_cast<__gm__ T*>(out)); + if constexpr (Backward) { + gradGm_.SetGlobalBuffer(reinterpret_cast<__gm__ T*>(grad)); + dUpGm_.SetGlobalBuffer(reinterpret_cast<__gm__ T*>(dUp)); + } + // Each segment starts on a 32-byte boundary, including for short tails. + // Worst-case UB use: (3 inputs + 2 outputs + 5 FP32 tiles) * 8 KiB = 80 KiB. + pipe->InitBuffer(inQueue_, 1, (Backward ? 3 : 2) * TILE_LENGTH * sizeof(T)); + pipe->InitBuffer(outQueue_, 1, (Backward ? 2 : 1) * TILE_LENGTH * sizeof(T)); + pipe->InitBuffer(work_, 5 * TILE_LENGTH * sizeof(float)); + } + + __aicore__ inline void Process() + { + for (int64_t offset = static_cast(AscendC::GetBlockIdx()) * TILE_LENGTH; + offset < n_; + offset += static_cast(AscendC::GetBlockNum()) * TILE_LENGTH) { + const uint32_t count = static_cast( + n_ - offset < TILE_LENGTH ? n_ - offset : TILE_LENGTH); + CopyIn(offset, count); + Compute(count); + CopyOut(offset, count); + } + } + +private: + __aicore__ inline void CopyIn(int64_t offset, uint32_t count) + { + auto input = inQueue_.AllocTensor(); + AscendC::DataCopyExtParams params{ + 1, static_cast(count * sizeof(T)), 0, 0, 0}; + AscendC::DataCopyPadExtParams pad{false, 0, 0, 0}; + AscendC::DataCopyPad(input, gateGm_[offset], params, pad); + AscendC::DataCopyPad(input[TILE_LENGTH], upGm_[offset], params, pad); + if constexpr (Backward) { + AscendC::DataCopyPad(input[2 * TILE_LENGTH], gradGm_[offset], params, pad); + } + inQueue_.EnQue(input); + } + + __aicore__ inline void ToFloat(AscendC::LocalTensor dst, + AscendC::LocalTensor src, uint32_t count) + { + if constexpr (std::is_same_v) { + // UB-to-UB copies require a multiple of 32 bytes. Padding stays in UB. + AscendC::DataCopy(dst, src, (count + 7) / 8 * 8); + } else { + AscendC::Cast(dst, src, AscendC::RoundMode::CAST_NONE, count); + } + } + + __aicore__ inline void Store(AscendC::LocalTensor dst, + AscendC::LocalTensor src, uint32_t count) + { + AscendC::PipeBarrier(); + if constexpr (std::is_same_v) { + AscendC::DataCopy(dst, src, (count + 7) / 8 * 8); + } else { + // Round to nearest, ties to even, matching PyTorch dtype conversion. + AscendC::Cast(dst, src, AscendC::RoundMode::CAST_RINT, count); + } + // The caller may reuse src immediately for the next result. + AscendC::PipeBarrier(); + } + + __aicore__ inline void Compute(uint32_t count) + { + auto input = inQueue_.DeQue(); // MTE2 -> vector synchronization + auto output = outQueue_.AllocTensor(); + auto gate = work_.Get(); + auto up = gate[TILE_LENGTH]; + auto grad = gate[2 * TILE_LENGTH]; + auto sigmoid = gate[3 * TILE_LENGTH]; + auto tmp = gate[4 * TILE_LENGTH]; + ToFloat(gate, input, count); + ToFloat(up, input[TILE_LENGTH], count); + if constexpr (Backward) { + ToFloat(grad, input[2 * TILE_LENGTH], count); + } + AscendC::PipeBarrier(); + + AscendC::Muls(sigmoid, gate, -1.0f, count); + AscendC::PipeBarrier(); + AscendC::Exp(sigmoid, sigmoid, count); + AscendC::PipeBarrier(); + AscendC::Adds(sigmoid, sigmoid, 1.0f, count); + AscendC::Duplicate(tmp, 1.0f, count); + AscendC::PipeBarrier(); + AscendC::Div(sigmoid, tmp, sigmoid, count); + AscendC::PipeBarrier(); + + AscendC::Mul(tmp, gate, sigmoid, count); + AscendC::PipeBarrier(); + if constexpr (Backward) { + // d_up = grad * (gate * sigmoid(gate)). + AscendC::Mul(tmp, grad, tmp, count); + Store(output[TILE_LENGTH], tmp, count); + + // d_gate = (grad * up) * (sigmoid * (1 + gate * (1 - sigmoid))). + AscendC::Muls(tmp, sigmoid, -1.0f, count); + AscendC::PipeBarrier(); + AscendC::Adds(tmp, tmp, 1.0f, count); + AscendC::PipeBarrier(); + AscendC::Mul(tmp, gate, tmp, count); + AscendC::PipeBarrier(); + AscendC::Adds(tmp, tmp, 1.0f, count); + AscendC::PipeBarrier(); + AscendC::Mul(tmp, sigmoid, tmp, count); + AscendC::Mul(up, grad, up, count); + AscendC::PipeBarrier(); + AscendC::Mul(tmp, up, tmp, count); + } else { + AscendC::Mul(tmp, tmp, up, count); + } + Store(output, tmp, count); + outQueue_.EnQue(output); + inQueue_.FreeTensor(input); + } + + __aicore__ inline void CopyOut(int64_t offset, uint32_t count) + { + auto output = outQueue_.DeQue(); // vector -> MTE3 synchronization + AscendC::DataCopyExtParams params{ + 1, static_cast(count * sizeof(T)), 0, 0, 0}; + AscendC::DataCopyPad(outGm_[offset], output, params); + if constexpr (Backward) { + AscendC::DataCopyPad(dUpGm_[offset], output[TILE_LENGTH], params); + } + outQueue_.FreeTensor(output); + } + + AscendC::GlobalTensor gateGm_, upGm_, gradGm_, outGm_, dUpGm_; + AscendC::TQue inQueue_; + AscendC::TQue outQueue_; + AscendC::TBuf work_; + int64_t n_; +}; + +template +class KernelSiLU { +public: + __aicore__ inline void Init(AscendC::TPipe* pipe, GM_ADDR x, GM_ADDR grad, + GM_ADDR out, int64_t n) + { + n_ = n; + xGm_.SetGlobalBuffer(reinterpret_cast<__gm__ T*>(x)); + outGm_.SetGlobalBuffer(reinterpret_cast<__gm__ T*>(out)); + if constexpr (Backward) { + gradGm_.SetGlobalBuffer(reinterpret_cast<__gm__ T*>(grad)); + } + // Worst-case UB use: (2 inputs + 1 output) * 4 KiB + 4 FP32 tiles * 8 KiB. + pipe->InitBuffer(inQueue_, 1, (Backward ? 2 : 1) * TILE_LENGTH * sizeof(T)); + pipe->InitBuffer(outQueue_, 1, TILE_LENGTH * sizeof(T)); + pipe->InitBuffer(work_, 4 * TILE_LENGTH * sizeof(float)); + } + + __aicore__ inline void Process() + { + // Identical strided tiling to SwiGLU: element i is always evaluated by + // the same expression regardless of n_ or the launched block count. + for (int64_t offset = static_cast(AscendC::GetBlockIdx()) * TILE_LENGTH; + offset < n_; + offset += static_cast(AscendC::GetBlockNum()) * TILE_LENGTH) { + const uint32_t count = static_cast( + n_ - offset < TILE_LENGTH ? n_ - offset : TILE_LENGTH); + CopyIn(offset, count); + Compute(count); + CopyOut(offset, count); + } + } + +private: + __aicore__ inline void CopyIn(int64_t offset, uint32_t count) + { + auto input = inQueue_.AllocTensor(); + AscendC::DataCopyExtParams params{ + 1, static_cast(count * sizeof(T)), 0, 0, 0}; + AscendC::DataCopyPadExtParams pad{false, 0, 0, 0}; + AscendC::DataCopyPad(input, xGm_[offset], params, pad); + if constexpr (Backward) { + AscendC::DataCopyPad(input[TILE_LENGTH], gradGm_[offset], params, pad); + } + inQueue_.EnQue(input); + } + + __aicore__ inline void ToFloat(AscendC::LocalTensor dst, + AscendC::LocalTensor src, uint32_t count) + { + if constexpr (std::is_same_v) { + // UB-to-UB copies require a multiple of 32 bytes. Padding stays in UB. + AscendC::DataCopy(dst, src, (count + 7) / 8 * 8); + } else { + AscendC::Cast(dst, src, AscendC::RoundMode::CAST_NONE, count); + } + } + + __aicore__ inline void Store(AscendC::LocalTensor dst, + AscendC::LocalTensor src, uint32_t count) + { + AscendC::PipeBarrier(); + if constexpr (std::is_same_v) { + AscendC::DataCopy(dst, src, (count + 7) / 8 * 8); + } else { + // Round to nearest, ties to even, matching PyTorch dtype conversion. + AscendC::Cast(dst, src, AscendC::RoundMode::CAST_RINT, count); + } + AscendC::PipeBarrier(); + } + + __aicore__ inline void Compute(uint32_t count) + { + auto input = inQueue_.DeQue(); // MTE2 -> vector synchronization + auto output = outQueue_.AllocTensor(); + auto x = work_.Get(); + auto grad = x[TILE_LENGTH]; + auto sigmoid = x[2 * TILE_LENGTH]; + auto tmp = x[3 * TILE_LENGTH]; + ToFloat(x, input, count); + if constexpr (Backward) { + ToFloat(grad, input[TILE_LENGTH], count); + } + AscendC::PipeBarrier(); + + // sigmoid(x) = 1 / (1 + exp(-x)), the same sequence SwiGLU uses on gate, + // so silu(x) is bitwise equal to swiglu(x, ones) on this hardware. + AscendC::Muls(sigmoid, x, -1.0f, count); + AscendC::PipeBarrier(); + AscendC::Exp(sigmoid, sigmoid, count); + AscendC::PipeBarrier(); + AscendC::Adds(sigmoid, sigmoid, 1.0f, count); + AscendC::Duplicate(tmp, 1.0f, count); + AscendC::PipeBarrier(); + AscendC::Div(sigmoid, tmp, sigmoid, count); + AscendC::PipeBarrier(); + + if constexpr (Backward) { + // dx = grad * (sigmoid * (1 + x * (1 - sigmoid))). + AscendC::Muls(tmp, sigmoid, -1.0f, count); + AscendC::PipeBarrier(); + AscendC::Adds(tmp, tmp, 1.0f, count); + AscendC::PipeBarrier(); + AscendC::Mul(tmp, x, tmp, count); + AscendC::PipeBarrier(); + AscendC::Adds(tmp, tmp, 1.0f, count); + AscendC::PipeBarrier(); + AscendC::Mul(tmp, sigmoid, tmp, count); + AscendC::PipeBarrier(); + AscendC::Mul(tmp, grad, tmp, count); + } else { + AscendC::Mul(tmp, x, sigmoid, count); + } + Store(output, tmp, count); + outQueue_.EnQue(output); + inQueue_.FreeTensor(input); + } + + __aicore__ inline void CopyOut(int64_t offset, uint32_t count) + { + auto output = outQueue_.DeQue(); // vector -> MTE3 synchronization + AscendC::DataCopyExtParams params{ + 1, static_cast(count * sizeof(T)), 0, 0, 0}; + AscendC::DataCopyPad(outGm_[offset], output, params); + outQueue_.FreeTensor(output); + } + + AscendC::GlobalTensor xGm_, gradGm_, outGm_; + AscendC::TQue inQueue_; + AscendC::TQue outQueue_; + AscendC::TBuf work_; + int64_t n_; +}; + +template +__global__ __vector__ void silu_ascend_kernel( + GM_ADDR x, GM_ADDR grad, GM_ADDR out, int64_t n) +{ + AscendC::TPipe pipe; + KernelSiLU op; + op.Init(&pipe, x, grad, out, n); + op.Process(); +} + +template +__global__ __vector__ void swiglu_ascend_kernel( + GM_ADDR gate, GM_ADDR up, GM_ADDR grad, GM_ADDR out, GM_ADDR dUp, int64_t n) +{ + AscendC::TPipe pipe; + KernelSwiGLU op; + op.Init(&pipe, gate, up, grad, out, dUp, n); + op.Process(); +} + +void CheckInput(const torch::Tensor& tensor, const char* name) +{ + TORCH_CHECK(tensor.is_privateuseone(), name, " must be on an NPU device"); + TORCH_CHECK(tensor.is_contiguous(), name, " must be contiguous"); + TORCH_CHECK(tensor.scalar_type() == at::kHalf || tensor.scalar_type() == at::kBFloat16 || + tensor.scalar_type() == at::kFloat, name, " must be fp16, bf16, or fp32"); +} + +void CheckLike(const torch::Tensor& tensor, const torch::Tensor& gate, const char* name) +{ + CheckInput(tensor, name); + TORCH_CHECK(tensor.device() == gate.device(), name, " must be on the same NPU device as gate"); + TORCH_CHECK(tensor.sizes() == gate.sizes(), name, " must share shape with gate"); + TORCH_CHECK(tensor.scalar_type() == gate.scalar_type(), name, " must share dtype with gate"); +} + +template +void Launch(torch::Tensor gate, torch::Tensor up, torch::Tensor grad, + torch::Tensor out, torch::Tensor dUp) +{ + const int64_t n = gate.numel(); + if (n == 0) { + return; + } + const uint32_t blocks = static_cast( + std::min((n + TILE_LENGTH - 1) / TILE_LENGTH, MAX_BLOCKS)); + // Flush torch_npu's task queue before launching directly on its current stream. + auto stream = c10_npu::getCurrentNPUStream().stream(true); + auto gatePtr = reinterpret_cast(gate.mutable_data_ptr()); + auto upPtr = reinterpret_cast(up.mutable_data_ptr()); + auto outPtr = reinterpret_cast(out.mutable_data_ptr()); + uint8_t* gradPtr = nullptr; + uint8_t* dUpPtr = nullptr; + if constexpr (Backward) { + gradPtr = reinterpret_cast(grad.mutable_data_ptr()); + dUpPtr = reinterpret_cast(dUp.mutable_data_ptr()); + } + if (gate.scalar_type() == at::kHalf) { + swiglu_ascend_kernel<<>>( + gatePtr, upPtr, gradPtr, outPtr, dUpPtr, n); + } else if (gate.scalar_type() == at::kBFloat16) { + swiglu_ascend_kernel<<>>( + gatePtr, upPtr, gradPtr, outPtr, dUpPtr, n); + } else { + swiglu_ascend_kernel<<>>( + gatePtr, upPtr, gradPtr, outPtr, dUpPtr, n); + } +} + +template +void LaunchSiLU(torch::Tensor x, torch::Tensor grad, torch::Tensor out) +{ + const int64_t n = x.numel(); + if (n == 0) { + return; + } + const uint32_t blocks = static_cast( + std::min((n + TILE_LENGTH - 1) / TILE_LENGTH, MAX_BLOCKS)); + // Flush torch_npu's task queue before launching directly on its current stream. + auto stream = c10_npu::getCurrentNPUStream().stream(true); + auto xPtr = reinterpret_cast(x.mutable_data_ptr()); + auto outPtr = reinterpret_cast(out.mutable_data_ptr()); + uint8_t* gradPtr = nullptr; + if constexpr (Backward) { + gradPtr = reinterpret_cast(grad.mutable_data_ptr()); + } + if (x.scalar_type() == at::kHalf) { + silu_ascend_kernel<<>>( + xPtr, gradPtr, outPtr, n); + } else if (x.scalar_type() == at::kBFloat16) { + silu_ascend_kernel<<>>( + xPtr, gradPtr, outPtr, n); + } else { + silu_ascend_kernel<<>>( + xPtr, gradPtr, outPtr, n); + } +} + +} // namespace + +torch::Tensor swiglu_ascend_forward(torch::Tensor gate, torch::Tensor up) +{ + CheckInput(gate, "gate"); + CheckLike(up, gate, "up"); + const c10::DeviceGuard guard(gate.device()); + auto out = at::empty(gate.sizes(), gate.options()); + Launch(gate, up, {}, out, {}); + return out; +} + +std::vector swiglu_ascend_backward( + torch::Tensor grad, torch::Tensor gate, torch::Tensor up) +{ + CheckInput(gate, "gate"); + CheckLike(up, gate, "up"); + CheckLike(grad, gate, "grad_out"); + const c10::DeviceGuard guard(gate.device()); + auto dGate = at::empty(gate.sizes(), gate.options()); + auto dUp = at::empty(gate.sizes(), gate.options()); + Launch(gate, up, grad, dGate, dUp); + return {dGate, dUp}; +} + +torch::Tensor silu_ascend_forward(torch::Tensor x) +{ + CheckInput(x, "x"); + const c10::DeviceGuard guard(x.device()); + auto out = at::empty(x.sizes(), x.options()); + LaunchSiLU(x, {}, out); + return out; +} + +torch::Tensor silu_ascend_backward(torch::Tensor grad, torch::Tensor x) +{ + CheckInput(x, "x"); + CheckLike(grad, x, "grad_out"); + const c10::DeviceGuard guard(x.device()); + auto dX = at::empty(x.sizes(), x.options()); + LaunchSiLU(x, grad, dX); + return dX; +} diff --git a/csrc/ascend/attention/deterministic_attention_ascend.asc b/csrc/ascend/attention/deterministic_attention_ascend.asc new file mode 100644 index 00000000..4d48e93f --- /dev/null +++ b/csrc/ascend/attention/deterministic_attention_ascend.asc @@ -0,0 +1,600 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (c) 2026 RL-Kernel Contributors +// +// Deterministic (batch-invariant) standard-softmax attention, Ascend C (CANN) +// forward kernel. +// +// out = softmax(Q K^T * scale + masks) @ V, lse = rowmax + log(sum(exp(s - rowmax))) +// +// Mirrors the batch-invariant algorithm of the Triton reference +// (rl_engine/kernels/ops/triton/attention/standard_attn.py, i.e. +// rlkernel.attention.deterministic_core.v1) and the CUDA deterministic op +// (issue #147): +// - layout : q [B, Hq, Sq, D], k/v [B, Hkv, Skv, D] contiguous, D = 128 +// - masks : causal (upper triangle at offset Skv - Sq + 1) and optional +// key_padding_mask [B, Skv] bool, True = keep +// - numerics: all fp32 intermediate; bf16/fp16 inputs are upcast, the output +// row is cast back once at the end +// +// Batch-invariance / no split-K: every (b, q_head, row) is processed +// end-to-end by exactly one AI-core block, with a fixed 64-key tile size and a +// fixed two-pass (max, then sum-exp + P.V) reduction order over the key +// dimension. The instruction sequence for a row depends only on Skv, D and the +// masks -- never on the batch size, the block the row lands on, or how many +// blocks were launched (rows are strided across blocks). No second-pass merge +// of per-split (m, l, u) summaries exists, so the reduction tree is fixed. +// +// Build: see setup.py (AscendBuildExtension, bisheng -x asc), gated by +// KERNEL_ALIGN_FORCE_ASCEND=1. Requires CANN toolkit + torch_npu. + +#include +#include +#include + +#include "kernel_operator.h" + +#include + +#include "torch_npu/csrc/core/npu/NPUStream.h" + +namespace { + +// Fixed head dimension (matches the CUDA deterministic op gate). +constexpr uint32_t HEAD_DIM = 128; +// Keys per tile. Fixed for all rows and batch sizes; this is what makes the +// reduction order batch-invariant (mirrors the Triton reference _BLOCK_N = 64). +constexpr uint32_t TILE_N = 64; +// Cap on launched blocks. Work items are strided across blocks, so launching +// fewer blocks than items is fine and never changes per-row numerics. +constexpr int64_t MAX_BLOCKS = 512; +constexpr float NEG_INF = -3.402823466e+38f; // -FLT_MAX, mask sentinel +// lse of a fully-masked row: true -inf (matches the Triton reference, whose +// max_score == -inf / log(denom=0) == -inf path writes -inf, not -FLT_MAX). +constexpr float LSE_INVALID = -std::numeric_limits::infinity(); + +template +class KernelDeterministicAttention { +public: + __aicore__ inline KernelDeterministicAttention(AscendC::TPipe* pipe) : pipe_(pipe) {} + + __aicore__ inline void Init(GM_ADDR q, + GM_ADDR k, + GM_ADDR v, + GM_ADDR mask, + GM_ADDR out, + GM_ADDR lse, + int64_t B, + int64_t Hq, + int64_t Hkv, + int64_t Sq, + int64_t Skv, + float scale, + int32_t causal, + int32_t hasMask, + int32_t outFp32) + { + B_ = B; + Hq_ = Hq; + Hkv_ = Hkv; + Sq_ = Sq; + Skv_ = Skv; + scale_ = scale; + causal_ = causal; + hasMask_ = hasMask; + outFp32_ = outFp32 != 0; + qGm_.SetGlobalBuffer(reinterpret_cast<__gm__ T*>(q)); + kGm_.SetGlobalBuffer(reinterpret_cast<__gm__ T*>(k)); + vGm_.SetGlobalBuffer(reinterpret_cast<__gm__ T*>(v)); + maskGm_.SetGlobalBuffer(reinterpret_cast<__gm__ uint8_t*>(mask)); + outGm_.SetGlobalBuffer(reinterpret_cast<__gm__ T*>(out)); + outGmF32_.SetGlobalBuffer(reinterpret_cast<__gm__ float*>(out)); + lseGm_.SetGlobalBuffer(reinterpret_cast<__gm__ float*>(lse)); + + // UB budget stays well under 192 KB: + // k tile bf16 16 KB + k tile fp32 32 KB + v tile fp32 32 KB + // + q/acc/prod/work/scores/scalar/mask ~4 KB. + pipe_->InitBuffer(qBufF_, HEAD_DIM * sizeof(float)); + pipe_->InitBuffer(accBufF_, HEAD_DIM * sizeof(float)); + pipe_->InitBuffer(prodBufF_, HEAD_DIM * sizeof(float)); + pipe_->InitBuffer(workBufF_, HEAD_DIM * sizeof(float)); + pipe_->InitBuffer(kBufT_, TILE_N * HEAD_DIM * sizeof(T)); + pipe_->InitBuffer(kBufF_, TILE_N * HEAD_DIM * sizeof(float)); + pipe_->InitBuffer(vBufF_, TILE_N * HEAD_DIM * sizeof(float)); + pipe_->InitBuffer(scoresBuf_, TILE_N * sizeof(float)); + // 128 B: mask tile (64 B) + up to 31 B misalignment + 32 B rounding. + pipe_->InitBuffer(maskBuf_, 128); + // 64 B: floats [0,8) for reduce/log scratch, floats [8,16) for the + // output staging slots (both halves 32-byte aligned for DataCopyPad). + pipe_->InitBuffer(scalarBuf_, 64); + + // Intra-core pipeline events. NOTE: AscendC::SyncAll() is a cross-core + // barrier and deadlocks when more blocks are launched than there are + // physical cores (resident blocks wait for unscheduled ones), so all + // synchronization here uses per-pipe SetFlag/WaitFlag instead. + eventVS_ = pipe_->FetchEventID(AscendC::HardEvent::V_S); + eventSV_ = pipe_->FetchEventID(AscendC::HardEvent::S_V); + eventMTE2S_ = pipe_->FetchEventID(AscendC::HardEvent::MTE2_S); + eventSMTE2_ = pipe_->FetchEventID(AscendC::HardEvent::S_MTE2); + eventVMTE2_ = pipe_->FetchEventID(AscendC::HardEvent::V_MTE2); + eventSMTE3_ = pipe_->FetchEventID(AscendC::HardEvent::S_MTE3); + eventVMTE3_ = pipe_->FetchEventID(AscendC::HardEvent::V_MTE3); + eventMTE3S_ = pipe_->FetchEventID(AscendC::HardEvent::MTE3_S); + } + + __aicore__ inline void Process() + { + const int64_t items = B_ * Hq_ * Sq_; + for (int64_t item = AscendC::GetBlockIdx(); item < items; + item += AscendC::GetBlockNum()) { + const int64_t row = item % Sq_; + const int64_t qh = (item / Sq_) % Hq_; + const int64_t b = item / (Sq_ * Hq_); + ProcessRow(b, qh, row); + } + } + +private: + __aicore__ inline void LoadQRow(int64_t b, int64_t qh, int64_t row) + { + const int64_t offset = ((b * Hq_ + qh) * Sq_ + row) * HEAD_DIM; + AscendC::LocalTensor qT = kBufT_.Get(); + AscendC::DataCopyExtParams cp{1, static_cast(HEAD_DIM * sizeof(T)), 0, 0, 0}; + AscendC::DataCopyPadExtParams pp{false, 0, 0, 0}; + AscendC::DataCopyPad(qT, qGm_[offset], cp, pp); + AscendC::SetFlag(eventMTE2S_); + AscendC::WaitFlag(eventMTE2S_); + AscendC::Cast(qBufF_.Get(), qT, AscendC::RoundMode::CAST_NONE, HEAD_DIM); + } + + __aicore__ inline void LoadKTile(int64_t b, int64_t kvh, int64_t start, uint32_t count) + { + // kBufT_ staging may hold data the vector pipe is still reading (the + // q cast of the first tile or the v cast of the previous tile). + AscendC::SetFlag(eventVMTE2_); + AscendC::WaitFlag(eventVMTE2_); + const int64_t offset = ((b * Hkv_ + kvh) * Skv_ + start) * HEAD_DIM; + AscendC::LocalTensor kT = kBufT_.Get(); + AscendC::DataCopyExtParams cp{1, static_cast(count * HEAD_DIM * sizeof(T)), 0, 0, 0}; + AscendC::DataCopyPadExtParams pp{false, 0, 0, 0}; + AscendC::DataCopyPad(kT, kGm_[offset], cp, pp); + AscendC::SetFlag(eventMTE2S_); + AscendC::WaitFlag(eventMTE2S_); + AscendC::Cast(kBufF_.Get(), kT, AscendC::RoundMode::CAST_NONE, + count * HEAD_DIM); + } + + __aicore__ inline void LoadVTile(int64_t b, int64_t kvh, int64_t start, uint32_t count) + { + // vBufF_ is still being read by the P . V accumulation of the previous + // tile; kBufT_ staging may be in use by the vector pipe as well. + AscendC::SetFlag(eventVMTE2_); + AscendC::WaitFlag(eventVMTE2_); + const int64_t offset = ((b * Hkv_ + kvh) * Skv_ + start) * HEAD_DIM; + AscendC::LocalTensor vT = kBufT_.Get(); + AscendC::DataCopyExtParams cp{1, static_cast(count * HEAD_DIM * sizeof(T)), 0, 0, 0}; + AscendC::DataCopyPadExtParams pp{false, 0, 0, 0}; + AscendC::DataCopyPad(vT, vGm_[offset], cp, pp); + AscendC::SetFlag(eventMTE2S_); + AscendC::WaitFlag(eventMTE2S_); + AscendC::Cast(vBufF_.Get(), vT, AscendC::RoundMode::CAST_NONE, + count * HEAD_DIM); + } + + // Load the mask window covering keys [start, start + count) of batch b. + // The window is read from the last 32-byte-aligned GM address at or below + // the tile start; returns the byte offset of the tile inside the window. + __aicore__ inline uint32_t LoadMaskTile(int64_t b, int64_t start, uint32_t count) + { + // maskBuf_ holds values the scalar pipe read for the previous tile; + // wait for those reads before MTE2 overwrites the window. + AscendC::SetFlag(eventSMTE2_); + AscendC::WaitFlag(eventSMTE2_); + const int64_t base = b * Skv_ + start; + const int64_t aligned = base & ~31LL; + const uint32_t offset = static_cast(base - aligned); + const int64_t remaining = (b + 1) * Skv_ - aligned; + uint32_t alignedCount = (offset + count + 31) & ~31u; + if (alignedCount > remaining) { + alignedCount = static_cast(remaining); + } + AscendC::LocalTensor m = maskBuf_.Get(); + AscendC::DataCopyExtParams cp{1, alignedCount, 0, 0, 0}; + AscendC::DataCopyPadExtParams pp{false, 0, 0, 0}; + AscendC::DataCopyPad(m, maskGm_[aligned], cp, pp); + AscendC::SetFlag(eventMTE2S_); + AscendC::WaitFlag(eventMTE2S_); + return offset; + } + + // scores[j] = scale * (q . k[start + j]) for j in [0, count), with the + // causal and key-padding masks applied; masked lanes become NEG_INF. + // + // Each dot product lands directly in its score lane (ReduceSum's dst + // scalar aliases scores[j]), so the vector pipe runs the whole tile + // without per-key scalar synchronization; a single V_S wait covers all + // lanes before the scalar mask pass. + __aicore__ inline void ComputeScores(int64_t b, + int64_t row, + int64_t start, + uint32_t count, + uint32_t maskOffset) + { + AscendC::LocalTensor qRow = qBufF_.Get(); + AscendC::LocalTensor kTile = kBufF_.Get(); + AscendC::LocalTensor scores = scoresBuf_.Get(); + AscendC::LocalTensor prod = prodBufF_.Get(); + AscendC::LocalTensor maskTile = maskBuf_.Get(); + + // The previous tile's mask pass (and the caller's padding loop) write + // the score lanes on the scalar pipe; drain them before the vector + // ReduceSum targets the same lanes. + AscendC::SetFlag(eventSV_); + AscendC::WaitFlag(eventSV_); + for (uint32_t j = 0; j < count; ++j) { + AscendC::Mul(prod, qRow, kTile[j * HEAD_DIM], HEAD_DIM); + AscendC::ReduceSum(scores[j], prod, workBufF_.Get(), HEAD_DIM); + } + WaitVector(); // all dot products visible to the scalar pipe + AscendC::Muls(scores, scores, scale_, count); + WaitVector(); // scaled lanes visible to the scalar pipe + + const int64_t causalKeep = row + Skv_ - Sq_; + for (uint32_t j = 0; j < count; ++j) { + float s = scores.GetValue(j); + const int64_t jGlobal = start + j; + if (causal_ && jGlobal > causalKeep) { + s = NEG_INF; + } + if (hasMask_ && maskTile.GetValue(maskOffset + j) == 0) { + s = NEG_INF; + } + scores.SetValue(j, s); + } + } + + __aicore__ inline void ProcessRow(int64_t b, int64_t qh, int64_t row) + { + const int64_t kvh = qh / (Hq_ / Hkv_); // GQA: query head h -> KV head h / g + AscendC::LocalTensor scores = scoresBuf_.Get(); + AscendC::LocalTensor scalar = scalarBuf_.Get(); + + LoadQRow(b, qh, row); + + // Left padding shifts the physical positions of the valid keys, and + // the softmax denominator (sumExp) is reduced per physical 64-key + // tile before the per-tile results are summed -- so the FP32 + // addition grouping of the valid keys depends on where the padding + // sits. Anchor the tiles to the first valid key (keyBegin) so the + // valid keys always start at the first position of the first tile + // and the denominator (hence the whole output and LSE) is + // padding-invariant. The numerator's per-key accumulation already + // runs across tiles in a fixed order, and causalKeep keeps its + // physical coordinate. + int64_t keyBegin = 0; + if (hasMask_) { + keyBegin = Skv_; + for (int64_t base = 0; base < Skv_; base += TILE_N) { + const uint32_t winCount = + static_cast(base + TILE_N <= Skv_ ? TILE_N : Skv_ - base); + const uint32_t offset = LoadMaskTile(b, base, winCount); + AscendC::LocalTensor m = maskBuf_.Get(); + for (uint32_t j = 0; j < winCount; ++j) { + if (m.GetValue(offset + j) != 0) { + keyBegin = base + j; + break; + } + } + if (keyBegin < Skv_) { + break; + } + } + } + const int64_t tileCount = (Skv_ - keyBegin + TILE_N - 1) / TILE_N; + + // Pass 1: row max with a fixed tile order. + float rowMax = NEG_INF; + bool anyValid = false; + for (int64_t tile = 0; tile < tileCount; ++tile) { + const int64_t start = keyBegin + tile * TILE_N; + const uint32_t count = TileCount(start); + LoadKTile(b, kvh, start, count); + uint32_t maskOffset = 0; + if (hasMask_) { + maskOffset = LoadMaskTile(b, start, count); + } + ComputeScores(b, row, start, count, maskOffset); + for (uint32_t j = count; j < TILE_N; ++j) { + scores.SetValue(j, NEG_INF); + } + AscendC::SetFlag(eventSV_); + AscendC::WaitFlag(eventSV_); + AscendC::ReduceMax(scalar, scores, workBufF_.Get(), TILE_N, false); + WaitVector(); + const float tileMax = scalar.GetValue(0); + if (tileMax > NEG_INF) { + anyValid = true; + } + rowMax = tileMax > rowMax ? tileMax : rowMax; + } + + // Pass 2: sum(exp(s - rowMax)) and P . V with the same fixed tile + // order (see the batch-invariance note at the top of the file). + float sumExp = 0.0f; + AscendC::LocalTensor acc = accBufF_.Get(); + // Real zeroing: accBuf_ starts as uninitialized UB and 0 * inf == NaN. + AscendC::Duplicate(acc, 0.0f, HEAD_DIM); + if (!anyValid) { + // Fully-masked row: exp(s - rowMax) would be exp(0) = 1 for the + // masked lanes (NEG_INF - NEG_INF == 0), not exp(-inf) = 0, so the + // row is defined as out = 0, lse = -inf -- mirroring the Triton + // reference's max_score == -inf / denom > 0 guards. + WriteOutputs(b, qh, row, NEG_INF, 0.0f, acc); + return; + } + for (int64_t tile = 0; tile < tileCount; ++tile) { + const int64_t start = keyBegin + tile * TILE_N; + const uint32_t count = TileCount(start); + LoadKTile(b, kvh, start, count); + uint32_t maskOffset = 0; + if (hasMask_) { + maskOffset = LoadMaskTile(b, start, count); + } + ComputeScores(b, row, start, count, maskOffset); + for (uint32_t j = count; j < TILE_N; ++j) { + scores.SetValue(j, NEG_INF); + } + AscendC::SetFlag(eventSV_); + AscendC::WaitFlag(eventSV_); + AscendC::Adds(scores, scores, -rowMax, TILE_N); + AscendC::Exp(scores, scores, TILE_N); + WaitVector(); // vector -> scalar visibility; covers the Exp above + // Guarantee the masked lanes contribute exactly zero. The vector + // Exp of the -FLT_MAX sentinel is not required to flush to 0.0f, + // and any residue would shift sumExp (hence the final invDenom) + // by an ULP that depends on where the padding sits in the + // physical layout -- breaking padding invariance at the FP32 + // level even though the BF16 outputs round identically. + if (causal_ || hasMask_) { + const int64_t causalKeep = row + Skv_ - Sq_; + AscendC::LocalTensor maskT = maskBuf_.Get(); + for (uint32_t j = 0; j < count; ++j) { + const int64_t jGlobal = start + j; + const bool masked = (causal_ && jGlobal > causalKeep) || + (hasMask_ && maskT.GetValue(maskOffset + j) == 0); + if (masked) { + scores.SetValue(j, 0.0f); + } + } + AscendC::SetFlag(eventSV_); + AscendC::WaitFlag(eventSV_); + } + AscendC::ReduceSum(scalar, scores, workBufF_.Get(), TILE_N); + WaitVector(); // vector -> scalar read + sumExp += scalar.GetValue(0); + + LoadVTile(b, kvh, start, count); + AscendC::LocalTensor vTile = vBufF_.Get(); + AscendC::LocalTensor prod = prodBufF_.Get(); + for (uint32_t j = 0; j < count; ++j) { + const float pj = scores.GetValue(j); + if (pj == 0.0f) { + continue; + } + AscendC::Muls(prod, vTile[j * HEAD_DIM], pj, HEAD_DIM); + AscendC::Add(acc, acc, prod, HEAD_DIM); + } + } + + // out = acc / sumExp. A fully-masked row keeps rowMax == NEG_INF, so + // sumExp is 0 and the output row is defined as 0 with lse = -inf + // (mirrors the Triton reference's denom > 0 guard). + const float invDenom = (sumExp > 0.0f) ? (1.0f / sumExp) : 0.0f; + AscendC::Muls(acc, acc, invDenom, HEAD_DIM); + WriteOutputs(b, qh, row, rowMax, sumExp, acc); + } + + __aicore__ inline void WriteOutputs(int64_t b, + int64_t qh, + int64_t row, + float rowMax, + float sumExp, + AscendC::LocalTensor acc) + { + AscendC::LocalTensor scalar = scalarBuf_.Get(); + float lse = LSE_INVALID; + if (rowMax > NEG_INF) { + scalar.SetValue(0, sumExp); + AscendC::SetFlag(eventSV_); // scalar write -> vector op + AscendC::WaitFlag(eventSV_); + AscendC::Log(scalar, scalar, 8); + WaitVector(); // vector -> scalar read + lse = rowMax + scalar.GetValue(0); + } + // Stage outputs in UB, then DataCopyPad to GM. GlobalTensor.SetValue + // is unreliable on hardware (cannbot ascendc-precision-debug + // common-traps), so scalar GM stores are not used. + scalar.SetValue(0, lse); + AscendC::LocalTensor outT = kBufT_.Get(); + AscendC::LocalTensor outF = kBufF_.Get(); + AscendC::SetFlag(eventVMTE3_); // vector write -> copy-out + AscendC::WaitFlag(eventVMTE3_); + AscendC::SetFlag(eventSMTE3_); // scalar write -> copy-out + AscendC::WaitFlag(eventSMTE3_); + AscendC::DataCopyExtParams p4{1, sizeof(float), 0, 0, 0}; + AscendC::DataCopyPad(lseGm_[(b * Hq_ + qh) * Sq_ + row], scalar[0], p4); + if (outFp32_) { + // FP32 output: stage the exact FP32 accumulator (Muls by 1.0 is + // an exact same-type copy on A2; Cast with CAST_NONE would emit + // nothing). kBufF_ is dead after the final P . V accumulation. + AscendC::Muls(outF, acc, 1.0f, HEAD_DIM); + AscendC::SetFlag(eventVMTE3_); + AscendC::WaitFlag(eventVMTE3_); + AscendC::DataCopyExtParams outCp{1, static_cast(HEAD_DIM * sizeof(float)), + 0, 0, 0}; + AscendC::DataCopyPad(outGmF32_[((b * Hq_ + qh) * Sq_ + row) * HEAD_DIM], outF, outCp); + } else { + AscendC::Cast(outT, acc, AscendC::RoundMode::CAST_ROUND, HEAD_DIM); + AscendC::SetFlag(eventVMTE3_); + AscendC::WaitFlag(eventVMTE3_); + AscendC::DataCopyExtParams outCp{1, static_cast(HEAD_DIM * sizeof(T)), 0, 0, 0}; + AscendC::DataCopyPad(outGm_[((b * Hq_ + qh) * Sq_ + row) * HEAD_DIM], outT, outCp); + } + // Drain MTE3 before the next row stages new values into the shared + // buffers; the scalar pipe issues all later MTE2 copies in order, so + // this wait alone orders them after the copy-outs. + AscendC::SetFlag(eventMTE3S_); + AscendC::WaitFlag(eventMTE3S_); + } + + // Wait until all outstanding vector-pipe results are readable as scalars. + __aicore__ inline void WaitVector() + { + AscendC::SetFlag(eventVS_); + AscendC::WaitFlag(eventVS_); + } + + __aicore__ inline uint32_t TileCount(int64_t start) const + { + const int64_t remaining = Skv_ - start; + return static_cast(remaining < TILE_N ? remaining : TILE_N); + } + + AscendC::TPipe* pipe_; + AscendC::GlobalTensor qGm_; + AscendC::GlobalTensor kGm_; + AscendC::GlobalTensor vGm_; + AscendC::GlobalTensor maskGm_; + AscendC::GlobalTensor outGm_; + AscendC::GlobalTensor outGmF32_; + AscendC::GlobalTensor lseGm_; + AscendC::TBuf qBufF_; + AscendC::TBuf accBufF_; + AscendC::TBuf prodBufF_; + AscendC::TBuf workBufF_; + AscendC::TBuf kBufT_; + AscendC::TBuf kBufF_; + AscendC::TBuf vBufF_; + AscendC::TBuf scoresBuf_; + AscendC::TBuf maskBuf_; + AscendC::TBuf scalarBuf_; + AscendC::TEventID eventVS_; + AscendC::TEventID eventSV_; + AscendC::TEventID eventMTE2S_; + AscendC::TEventID eventSMTE2_; + AscendC::TEventID eventVMTE2_; + AscendC::TEventID eventSMTE3_; + AscendC::TEventID eventVMTE3_; + AscendC::TEventID eventMTE3S_; + int64_t B_; + int64_t Hq_; + int64_t Hkv_; + int64_t Sq_; + int64_t Skv_; + float scale_; + int32_t causal_; + int32_t hasMask_; + int32_t outFp32_; +}; + +} // namespace + +extern "C" __global__ __vector__ void deterministic_attention_ascend_kernel_bf16( + GM_ADDR q, GM_ADDR k, GM_ADDR v, GM_ADDR mask, GM_ADDR out, GM_ADDR lse, + int64_t B, int64_t Hq, int64_t Hkv, int64_t Sq, int64_t Skv, + float scale, int32_t causal, int32_t hasMask, int32_t outFp32) +{ + AscendC::TPipe pipe; + KernelDeterministicAttention op(&pipe); + op.Init(q, k, v, mask, out, lse, B, Hq, Hkv, Sq, Skv, scale, causal, hasMask, outFp32); + op.Process(); +} + +extern "C" __global__ __vector__ void deterministic_attention_ascend_kernel_fp16( + GM_ADDR q, GM_ADDR k, GM_ADDR v, GM_ADDR mask, GM_ADDR out, GM_ADDR lse, + int64_t B, int64_t Hq, int64_t Hkv, int64_t Sq, int64_t Skv, + float scale, int32_t causal, int32_t hasMask, int32_t outFp32) +{ + AscendC::TPipe pipe; + KernelDeterministicAttention op(&pipe); + op.Init(q, k, v, mask, out, lse, B, Hq, Hkv, Sq, Skv, scale, causal, hasMask, outFp32); + op.Process(); +} + +std::vector deterministic_attention_ascend_forward( + torch::Tensor q, + torch::Tensor k, + torch::Tensor v, + bool causal, + double scale, + c10::optional key_padding_mask, + bool outFp32) +{ + TORCH_CHECK(q.is_privateuseone() && k.is_privateuseone() && v.is_privateuseone(), + "q, k, v must be on an NPU device"); + TORCH_CHECK(q.dim() == 4 && k.dim() == 4 && v.dim() == 4, + "q, k, v must be 4-D [B, H, S, D]"); + TORCH_CHECK(q.is_contiguous() && k.is_contiguous() && v.is_contiguous(), + "q, k, v must be contiguous"); + TORCH_CHECK(q.scalar_type() == at::kBFloat16 || q.scalar_type() == at::kHalf, + "q must be bf16 or fp16"); + TORCH_CHECK(k.scalar_type() == q.scalar_type() && v.scalar_type() == q.scalar_type(), + "q, k, v must share the same dtype"); + TORCH_CHECK(q.size(3) == HEAD_DIM && k.size(3) == HEAD_DIM && v.size(3) == HEAD_DIM, + "head dim D must be 128"); + TORCH_CHECK(q.size(0) == k.size(0) && q.size(0) == v.size(0), + "batch size mismatch between q/k/v"); + TORCH_CHECK(k.size(1) == v.size(1) && k.size(2) == v.size(2), + "k/v must have the same head and key-length layout"); + TORCH_CHECK(q.size(1) % k.size(1) == 0, "Hq not divisible by Hkv (GQA group)"); + + const int64_t B = q.size(0); + const int64_t Hq = q.size(1); + const int64_t Hkv = k.size(1); + const int64_t Sq = q.size(2); + const int64_t Skv = k.size(2); + + torch::Tensor mask; + bool hasMask = key_padding_mask.has_value() && key_padding_mask->defined(); + if (hasMask) { + mask = key_padding_mask->to(torch::kBool).contiguous(); + TORCH_CHECK(mask.is_privateuseone(), "key_padding_mask must be on an NPU device"); + TORCH_CHECK(mask.dim() == 2 && mask.size(0) == B && mask.size(1) == Skv, + "key_padding_mask must be [B, Skv]"); + } + + torch::Tensor out = at::empty( + {B, Hq, Sq, HEAD_DIM}, + q.options().dtype(outFp32 ? at::kFloat : q.scalar_type())); + torch::Tensor lse = at::empty({B, Hq, Sq}, q.options().dtype(at::kFloat)); + + // stream(true): flush the task queue before launch so the kernel cannot + // overtake earlier NPU work; outputs were allocated with at::empty (no + // queued initializer) for the same reason. + auto aclStream = c10_npu::getCurrentNPUStream().stream(true); + const uint32_t blockNum = + static_cast(std::min(B * Hq * Sq, MAX_BLOCKS)); + uint8_t* maskPtr = hasMask ? reinterpret_cast(mask.mutable_data_ptr()) : nullptr; + + if (q.scalar_type() == at::kBFloat16) { + deterministic_attention_ascend_kernel_bf16<<>>( + reinterpret_cast(q.mutable_data_ptr()), + reinterpret_cast(k.mutable_data_ptr()), + reinterpret_cast(v.mutable_data_ptr()), + reinterpret_cast(maskPtr), + reinterpret_cast(out.mutable_data_ptr()), + reinterpret_cast(lse.mutable_data_ptr()), + B, Hq, Hkv, Sq, Skv, static_cast(scale), causal ? 1 : 0, + hasMask ? 1 : 0, outFp32 ? 1 : 0); + } else { + deterministic_attention_ascend_kernel_fp16<<>>( + reinterpret_cast(q.mutable_data_ptr()), + reinterpret_cast(k.mutable_data_ptr()), + reinterpret_cast(v.mutable_data_ptr()), + reinterpret_cast(maskPtr), + reinterpret_cast(out.mutable_data_ptr()), + reinterpret_cast(lse.mutable_data_ptr()), + B, Hq, Hkv, Sq, Skv, static_cast(scale), causal ? 1 : 0, + hasMask ? 1 : 0, outFp32 ? 1 : 0); + } + return {out, lse}; +} diff --git a/csrc/ascend/attention/deterministic_attention_backward_ascend.asc b/csrc/ascend/attention/deterministic_attention_backward_ascend.asc new file mode 100644 index 00000000..7471490b --- /dev/null +++ b/csrc/ascend/attention/deterministic_attention_backward_ascend.asc @@ -0,0 +1,740 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (c) 2026 RL-Kernel Contributors +// +// Deterministic (batch-invariant) standard-softmax attention backward, +// Ascend C (CANN) kernel. +// +// Mirrors the CUDA deterministic_attention_backward (issue #147). The VJP is +// decomposed into three stream-ordered kernel launches, each a grid-stride +// loop over its own tasks, so no block reads another block's output: +// +// Launch 1 (rows): recompute the forward's rowMax / exp with the +// keyBegin-anchored 64-key tiles, then write the NORMALIZED +// P = exp(s - max) / sumExp (every key, masked keys exactly 0) +// dS = P . (dO.V - rowSum) (every key, masked keys exactly 0) +// and accumulate dQ = dS @ K^T * scale in the fixed tile order. +// Launch 2 (keys): dV[key] = sum_qi P[qi,key] . dO[qi] over the fixed +// ascending (qi, g) order -- no cross-block atomics. +// Launch 3 (keys): dK[key] = sum_qi dS[qi,key] . Q[qi] over the same +// fixed order, times scale. +// +// The three launches run on one stream, so the runtime orders them and the +// P/dS dependencies hold. Every reduction follows a fixed logical order, so +// the gradients are bitwise invariant to the batch size, the block a task +// lands on, and where padding sits in the physical layout. +// +// The host allocates the P/dS workspaces as [B, Hq, S, S] fp32 tensors and +// passes the upstream gradient in FP32 (never pre-cast to the input dtype). +// +// Build: see setup.py (AscendBuildExtension, bisheng -x asc), gated by +// KERNEL_ALIGN_FORCE_ASCEND=1. Requires CANN toolkit + torch_npu. + +#include +#include + +#include "kernel_operator.h" + +#include + +#include "torch_npu/csrc/core/npu/NPUStream.h" + +namespace { + +constexpr uint32_t HEAD_DIM = 128; +constexpr uint32_t TILE_N = 64; +constexpr float NEG_INF = -3.402823466e+38f; + +template +class KernelDetAttentionBackward { +public: + __aicore__ inline KernelDetAttentionBackward(AscendC::TPipe* pipe) : pipe_(pipe) {} + + __aicore__ inline void Init(GM_ADDR q, + GM_ADDR k, + GM_ADDR v, + GM_ADDR dO, + GM_ADDR mask, + GM_ADDR p, + GM_ADDR ds, + GM_ADDR dq, + GM_ADDR dk, + GM_ADDR dv, + int64_t B, + int64_t Hq, + int64_t Hkv, + int64_t Sq, + int64_t Skv, + float scale, + int32_t causal, + int32_t hasMask) + { + B_ = B; + Hq_ = Hq; + Hkv_ = Hkv; + Sq_ = Sq; + Skv_ = Skv; + scale_ = scale; + causal_ = causal; + hasMask_ = hasMask; + qGm_.SetGlobalBuffer(reinterpret_cast<__gm__ T*>(q)); + kGm_.SetGlobalBuffer(reinterpret_cast<__gm__ T*>(k)); + vGm_.SetGlobalBuffer(reinterpret_cast<__gm__ T*>(v)); + dOGm_.SetGlobalBuffer(reinterpret_cast<__gm__ float*>(dO)); + maskGm_.SetGlobalBuffer(reinterpret_cast<__gm__ uint8_t*>(mask)); + pGm_.SetGlobalBuffer(reinterpret_cast<__gm__ float*>(p)); + dSGm_.SetGlobalBuffer(reinterpret_cast<__gm__ float*>(ds)); + dQGm_.SetGlobalBuffer(reinterpret_cast<__gm__ T*>(dq)); + dKGm_.SetGlobalBuffer(reinterpret_cast<__gm__ T*>(dk)); + dVGm_.SetGlobalBuffer(reinterpret_cast<__gm__ T*>(dv)); + + pipe_->InitBuffer(qBufF_, HEAD_DIM * sizeof(float)); + pipe_->InitBuffer(kBufT_, TILE_N * HEAD_DIM * sizeof(T)); + pipe_->InitBuffer(kBufF_, TILE_N * HEAD_DIM * sizeof(float)); + pipe_->InitBuffer(vBufF_, TILE_N * HEAD_DIM * sizeof(float)); + pipe_->InitBuffer(scoresBuf_, TILE_N * sizeof(float)); + pipe_->InitBuffer(workBufF_, HEAD_DIM * sizeof(float)); + pipe_->InitBuffer(reduceBufF_, HEAD_DIM * sizeof(float)); + pipe_->InitBuffer(dqBufF_, HEAD_DIM * sizeof(float)); + pipe_->InitBuffer(dOBufF_, HEAD_DIM * sizeof(float)); + pipe_->InitBuffer(accBufF_, HEAD_DIM * sizeof(float)); + pipe_->InitBuffer(maskBuf_, 128); + pipe_->InitBuffer(scalarBuf_, 64); + + eventVS_ = pipe_->FetchEventID(AscendC::HardEvent::V_S); + eventSV_ = pipe_->FetchEventID(AscendC::HardEvent::S_V); + eventMTE2S_ = pipe_->FetchEventID(AscendC::HardEvent::MTE2_S); + eventSMTE2_ = pipe_->FetchEventID(AscendC::HardEvent::S_MTE2); + eventVMTE2_ = pipe_->FetchEventID(AscendC::HardEvent::V_MTE2); + eventVMTE3_ = pipe_->FetchEventID(AscendC::HardEvent::V_MTE3); + eventSMTE3_ = pipe_->FetchEventID(AscendC::HardEvent::S_MTE3); + eventMTE3S_ = pipe_->FetchEventID(AscendC::HardEvent::MTE3_S); + } + + // Launch 1: the row pass (the grid-stride over B * Hq * Sq). + __aicore__ inline void ProcessRows() + { + const int64_t rows = B_ * Hq_ * Sq_; + for (int64_t item = AscendC::GetBlockIdx(); item < rows; + item += AscendC::GetBlockNum()) { + const int64_t row = item % Sq_; + const int64_t qh = (item / Sq_) % Hq_; + const int64_t b = item / (Sq_ * Hq_); + ProcessRow(b, qh, row); + } + } + + // Launch 2: the dV pass (the grid-stride over B * Hkv * Skv). + __aicore__ inline void ProcessDVAll() + { + const int64_t keys = B_ * Hkv_ * Skv_; + for (int64_t item = AscendC::GetBlockIdx(); item < keys; + item += AscendC::GetBlockNum()) { + const int64_t key = item % Skv_; + const int64_t kvh = (item / Skv_) % Hkv_; + const int64_t b = item / (Skv_ * Hkv_); + ProcessDV(b, kvh, key); + } + } + + // Launch 3: the dK pass. + __aicore__ inline void ProcessDKAll() + { + const int64_t keys = B_ * Hkv_ * Skv_; + for (int64_t item = AscendC::GetBlockIdx(); item < keys; + item += AscendC::GetBlockNum()) { + const int64_t key = item % Skv_; + const int64_t kvh = (item / Skv_) % Hkv_; + const int64_t b = item / (Skv_ * Hkv_); + ProcessDK(b, kvh, key); + } + } + +private: + __aicore__ inline void WaitVector() + { + AscendC::SetFlag(eventVS_); + AscendC::WaitFlag(eventVS_); + } + + __aicore__ inline uint32_t TileCount(int64_t start) const + { + return static_cast(start + TILE_N <= Skv_ ? TILE_N : Skv_ - start); + } + + __aicore__ inline int64_t FindKeyBegin(int64_t b) + { + if (!hasMask_) { + return 0; + } + int64_t keyBegin = Skv_; + for (int64_t base = 0; base < Skv_; base += TILE_N) { + const uint32_t winCount = + static_cast(base + TILE_N <= Skv_ ? TILE_N : Skv_ - base); + const uint32_t offset = LoadMaskWindow(b, base, winCount); + AscendC::LocalTensor m = maskBuf_.Get(); + for (uint32_t j = 0; j < winCount; ++j) { + if (m.GetValue(offset + j) != 0) { + keyBegin = base + j; + break; + } + } + if (keyBegin < Skv_) { + break; + } + } + return keyBegin; + } + + __aicore__ inline uint32_t LoadMaskWindow(int64_t b, int64_t base, uint32_t winCount) + { + AscendC::SetFlag(eventSMTE2_); + AscendC::WaitFlag(eventSMTE2_); + const int64_t gBase = b * Skv_ + base; + const int64_t aligned = gBase & ~31LL; + const uint32_t offset = static_cast(gBase - aligned); + const int64_t remaining = (b + 1) * Skv_ - aligned; + uint32_t alignedCount = (offset + winCount + 31) & ~31u; + if (alignedCount > remaining) { + alignedCount = static_cast(remaining); + } + AscendC::LocalTensor m = maskBuf_.Get(); + AscendC::DataCopyExtParams cp{1, alignedCount, 0, 0, 0}; + AscendC::DataCopyPadExtParams pp{false, 0, 0, 0}; + AscendC::DataCopyPad(m, maskGm_[aligned], cp, pp); + AscendC::SetFlag(eventMTE2S_); + AscendC::WaitFlag(eventMTE2S_); + return offset; + } + + __aicore__ inline void LoadQRow(int64_t b, int64_t qh, int64_t row) + { + const int64_t offset = ((b * Hq_ + qh) * Sq_ + row) * HEAD_DIM; + AscendC::LocalTensor qT = kBufT_.Get(); + AscendC::DataCopyExtParams cp{1, static_cast(HEAD_DIM * sizeof(T)), 0, 0, 0}; + AscendC::DataCopyPadExtParams pp{false, 0, 0, 0}; + AscendC::DataCopyPad(qT, qGm_[offset], cp, pp); + AscendC::SetFlag(eventMTE2S_); + AscendC::WaitFlag(eventMTE2S_); + AscendC::Cast(qBufF_.Get(), qT, AscendC::RoundMode::CAST_NONE, HEAD_DIM); + } + + __aicore__ inline void LoadKTile(int64_t b, int64_t kvh, int64_t start, uint32_t count) + { + AscendC::SetFlag(eventVMTE2_); + AscendC::WaitFlag(eventVMTE2_); + const int64_t offset = ((b * Hkv_ + kvh) * Skv_ + start) * HEAD_DIM; + AscendC::LocalTensor kT = kBufT_.Get(); + AscendC::DataCopyExtParams cp{1, static_cast(count * HEAD_DIM * sizeof(T)), 0, 0, 0}; + AscendC::DataCopyPadExtParams pp{false, 0, 0, 0}; + AscendC::DataCopyPad(kT, kGm_[offset], cp, pp); + AscendC::SetFlag(eventMTE2S_); + AscendC::WaitFlag(eventMTE2S_); + AscendC::Cast(kBufF_.Get(), kT, AscendC::RoundMode::CAST_NONE, + count * HEAD_DIM); + } + + __aicore__ inline void LoadVTile(int64_t b, int64_t kvh, int64_t start, uint32_t count) + { + AscendC::SetFlag(eventVMTE2_); + AscendC::WaitFlag(eventVMTE2_); + const int64_t offset = ((b * Hkv_ + kvh) * Skv_ + start) * HEAD_DIM; + AscendC::LocalTensor vT = kBufT_.Get(); + AscendC::DataCopyExtParams cp{1, static_cast(count * HEAD_DIM * sizeof(T)), 0, 0, 0}; + AscendC::DataCopyPadExtParams pp{false, 0, 0, 0}; + AscendC::DataCopyPad(vT, vGm_[offset], cp, pp); + AscendC::SetFlag(eventMTE2S_); + AscendC::WaitFlag(eventMTE2S_); + AscendC::Cast(vBufF_.Get(), vT, AscendC::RoundMode::CAST_NONE, + count * HEAD_DIM); + } + + __aicore__ inline void LoadDORow(int64_t b, int64_t qh, int64_t row) + { + const int64_t offset = ((b * Hq_ + qh) * Sq_ + row) * HEAD_DIM; + AscendC::DataCopyExtParams cp{1, static_cast(HEAD_DIM * sizeof(float)), 0, 0, 0}; + AscendC::DataCopyPad(dOBufF_.Get(), dOGm_[offset], cp, + AscendC::DataCopyPadExtParams{false, 0, 0, 0}); + AscendC::SetFlag(eventMTE2S_); + AscendC::WaitFlag(eventMTE2S_); + } + + __aicore__ inline void ComputeScores(int64_t b, + int64_t row, + int64_t start, + uint32_t count, + uint32_t maskOffset) + { + AscendC::LocalTensor qRow = qBufF_.Get(); + AscendC::LocalTensor kTile = kBufF_.Get(); + AscendC::LocalTensor scores = scoresBuf_.Get(); + AscendC::LocalTensor prod = workBufF_.Get(); + AscendC::LocalTensor scratch = reduceBufF_.Get(); + AscendC::LocalTensor maskTile = maskBuf_.Get(); + + AscendC::SetFlag(eventSV_); + AscendC::WaitFlag(eventSV_); + for (uint32_t j = 0; j < count; ++j) { + AscendC::Mul(prod, qRow, kTile[j * HEAD_DIM], HEAD_DIM); + AscendC::ReduceSum(scores[j], prod, scratch, HEAD_DIM); + } + WaitVector(); + AscendC::Muls(scores, scores, scale_, count); + WaitVector(); + + const int64_t causalKeep = row + Skv_ - Sq_; + for (uint32_t j = 0; j < count; ++j) { + float s = scores.GetValue(j); + const int64_t jGlobal = start + j; + if (causal_ && jGlobal > causalKeep) { + s = NEG_INF; + } + if (hasMask_ && maskTile.GetValue(maskOffset + j) == 0) { + s = NEG_INF; + } + scores.SetValue(j, s); + } + } + + __aicore__ inline float ReadGmFloat(const AscendC::GlobalTensor& src, int64_t idx) + { + AscendC::LocalTensor scalar = scalarBuf_.Get(); + AscendC::DataCopyExtParams cp{1, sizeof(float), 0, 0, 0}; + AscendC::DataCopyPad(scalar[0], src[idx], cp, + AscendC::DataCopyPadExtParams{false, 0, 0, 0}); + AscendC::SetFlag(eventMTE2S_); + AscendC::WaitFlag(eventMTE2S_); + return scalar.GetValue(0); + } + + __aicore__ inline void WriteGmFloat(const AscendC::GlobalTensor& dst, + int64_t idx, + float value) + { + AscendC::LocalTensor scalar = scalarBuf_.Get(); + scalar.SetValue(0, value); + AscendC::SetFlag(eventSMTE3_); + AscendC::WaitFlag(eventSMTE3_); + AscendC::DataCopyExtParams cp{1, sizeof(float), 0, 0, 0}; + AscendC::DataCopyPad(dst[idx], scalar[0], cp); + AscendC::SetFlag(eventMTE3S_); + AscendC::WaitFlag(eventMTE3S_); + } + + __aicore__ inline void WriteRowQ(const AscendC::GlobalTensor& dst, + int64_t b, + int64_t qh, + int64_t row, + AscendC::LocalTensor acc) + { + AscendC::LocalTensor outT = kBufT_.Get(); + AscendC::Cast(outT, acc, AscendC::RoundMode::CAST_RINT, HEAD_DIM); + AscendC::SetFlag(eventVMTE3_); + AscendC::WaitFlag(eventVMTE3_); + AscendC::DataCopyExtParams outCp{1, static_cast(HEAD_DIM * sizeof(T)), 0, 0, 0}; + AscendC::DataCopyPad(dst[((b * Hq_ + qh) * Sq_ + row) * HEAD_DIM], outT, outCp); + AscendC::SetFlag(eventMTE3S_); + AscendC::WaitFlag(eventMTE3S_); + } + + __aicore__ inline void WriteRowKV(const AscendC::GlobalTensor& dst, + int64_t b, + int64_t kvh, + int64_t key, + AscendC::LocalTensor acc) + { + AscendC::LocalTensor outT = kBufT_.Get(); + AscendC::Cast(outT, acc, AscendC::RoundMode::CAST_RINT, HEAD_DIM); + AscendC::SetFlag(eventVMTE3_); + AscendC::WaitFlag(eventVMTE3_); + AscendC::DataCopyExtParams outCp{1, static_cast(HEAD_DIM * sizeof(T)), 0, 0, 0}; + AscendC::DataCopyPad(dst[((b * Hkv_ + kvh) * Skv_ + key) * HEAD_DIM], outT, outCp); + AscendC::SetFlag(eventMTE3S_); + AscendC::WaitFlag(eventMTE3S_); + } + + // Launch 1: P, dS and dQ for one (b, qh, row). + __aicore__ inline void ProcessRow(int64_t b, int64_t qh, int64_t row) + { + const int64_t kvh = qh / (Hq_ / Hkv_); + const int64_t keyBegin = FindKeyBegin(b); + const int64_t tileCount = (Skv_ - keyBegin + TILE_N - 1) / TILE_N; + const int64_t rowBase = ((b * Hq_ + qh) * Sq_ + row) * Skv_; + AscendC::LocalTensor scores = scoresBuf_.Get(); + AscendC::LocalTensor scalar = scalarBuf_.Get(); + AscendC::LocalTensor dqAcc = dqBufF_.Get(); + AscendC::LocalTensor dORow = dOBufF_.Get(); + + LoadQRow(b, qh, row); + LoadDORow(b, qh, row); + + // Zero the whole P/dS row up front: the leading pad keys and the + // masked keys are then already exactly zero. + for (int64_t k = 0; k < Skv_; ++k) { + WriteGmFloat(pGm_, rowBase + k, 0.0f); + WriteGmFloat(dSGm_, rowBase + k, 0.0f); + } + + // Pass 1a: the row max. + float rowMax = NEG_INF; + bool anyValid = false; + for (int64_t tile = 0; tile < tileCount; ++tile) { + const int64_t start = keyBegin + tile * TILE_N; + const uint32_t count = TileCount(start); + LoadKTile(b, kvh, start, count); + uint32_t maskOffset = 0; + if (hasMask_) { + maskOffset = LoadMaskWindow(b, start, count); + } + ComputeScores(b, row, start, count, maskOffset); + for (uint32_t j = count; j < TILE_N; ++j) { + scores.SetValue(j, NEG_INF); + } + AscendC::SetFlag(eventSV_); + AscendC::WaitFlag(eventSV_); + AscendC::ReduceMax(scalar, scores, workBufF_.Get(), TILE_N, false); + WaitVector(); + const float tileMax = scalar.GetValue(0); + if (tileMax > NEG_INF) { + anyValid = true; + } + rowMax = tileMax > rowMax ? tileMax : rowMax; + } + + if (!anyValid) { + AscendC::Duplicate(dqAcc, 0.0f, HEAD_DIM); + WriteRowQ(dQGm_, b, qh, row, dqAcc); + return; + } + + // Pass 1b: the exp tile, the (dO . V) dots, and the raw rowSum. + float sumExp = 0.0f; + float rowSumRaw = 0.0f; + AscendC::Duplicate(dqAcc, 0.0f, HEAD_DIM); + for (int64_t tile = 0; tile < tileCount; ++tile) { + const int64_t start = keyBegin + tile * TILE_N; + const uint32_t count = TileCount(start); + LoadKTile(b, kvh, start, count); + LoadVTile(b, kvh, start, count); + uint32_t maskOffset = 0; + if (hasMask_) { + maskOffset = LoadMaskWindow(b, start, count); + } + ComputeScores(b, row, start, count, maskOffset); + for (uint32_t j = count; j < TILE_N; ++j) { + scores.SetValue(j, NEG_INF); + } + AscendC::SetFlag(eventSV_); + AscendC::WaitFlag(eventSV_); + AscendC::Adds(scores, scores, -rowMax, TILE_N); + AscendC::Exp(scores, scores, TILE_N); + WaitVector(); + if (causal_ || hasMask_) { + AscendC::LocalTensor maskTile = maskBuf_.Get(); + const int64_t causalKeep = row + Skv_ - Sq_; + for (uint32_t j = 0; j < count; ++j) { + const int64_t jGlobal = start + j; + const bool masked = (causal_ && jGlobal > causalKeep) || + (hasMask_ && maskTile.GetValue(maskOffset + j) == 0); + if (masked) { + scores.SetValue(j, 0.0f); + } + } + AscendC::SetFlag(eventSV_); + AscendC::WaitFlag(eventSV_); + } + AscendC::ReduceSum(scalar, scores, workBufF_.Get(), TILE_N); + WaitVector(); + sumExp += scalar.GetValue(0); + + AscendC::LocalTensor vTile = vBufF_.Get(); + AscendC::LocalTensor prod = workBufF_.Get(); + AscendC::LocalTensor scratch = reduceBufF_.Get(); + for (uint32_t j = 0; j < count; ++j) { + const float pRaw = scores.GetValue(j); + if (pRaw == 0.0f) { + continue; + } + AscendC::Mul(prod, dORow, vTile[j * HEAD_DIM], HEAD_DIM); + AscendC::ReduceSum(scalar, prod, scratch, HEAD_DIM); + WaitVector(); + const float dot = scalar.GetValue(0); + rowSumRaw += pRaw * dot; + WriteGmFloat(pGm_, rowBase + start + j, pRaw); + WriteGmFloat(dSGm_, rowBase + start + j, pRaw * dot); + } + } + + // The final pass: normalize (the rowSum and sumExp are complete only + // now) and accumulate dQ = sum_j dS_j . K_j in the fixed tile order. + const float invDenom = (sumExp > 0.0f) ? (1.0f / sumExp) : 0.0f; + const float rowSumNorm = rowSumRaw * invDenom; + for (int64_t tile = 0; tile < tileCount; ++tile) { + const int64_t start = keyBegin + tile * TILE_N; + const uint32_t count = TileCount(start); + LoadKTile(b, kvh, start, count); + AscendC::LocalTensor kTile = kBufF_.Get(); + for (uint32_t j = 0; j < count; ++j) { + const int64_t gKey = start + j; + const float pRaw = ReadGmFloat(pGm_, rowBase + gKey); + if (pRaw == 0.0f) { + continue; + } + const float pj = pRaw * invDenom; + const float dot = ReadGmFloat(dSGm_, rowBase + gKey) / pRaw; // (dO . V) + const float dSj = pj * (dot - rowSumNorm); + WriteGmFloat(pGm_, rowBase + gKey, pj); + WriteGmFloat(dSGm_, rowBase + gKey, dSj); + AscendC::Muls(workBufF_.Get(), kTile[j * HEAD_DIM], dSj, HEAD_DIM); + AscendC::Add(dqAcc, dqAcc, workBufF_.Get(), HEAD_DIM); + } + } + AscendC::Muls(dqAcc, dqAcc, scale_, HEAD_DIM); + WriteRowQ(dQGm_, b, qh, row, dqAcc); + } + + // Launch 2: dV[key] = sum over the fixed ascending (qi, g) order of + // P[qi,key] . dO[qi]. The P rows for the masked queries are exactly zero. + __aicore__ inline void ProcessDV(int64_t b, int64_t kvh, int64_t key) + { + AscendC::LocalTensor acc = accBufF_.Get(); + AscendC::LocalTensor prod = workBufF_.Get(); + AscendC::Duplicate(acc, 0.0f, HEAD_DIM); + const int64_t group = Hq_ / Hkv_; + for (int64_t qi = 0; qi < Sq_; ++qi) { + for (int64_t g = 0; g < group; ++g) { + const int64_t qh = kvh * group + g; + AscendC::SetFlag(eventVMTE2_); + AscendC::WaitFlag(eventVMTE2_); + const float pj = ReadGmFloat(pGm_, ((b * Hq_ + qh) * Sq_ + qi) * Skv_ + key); + if (pj == 0.0f) { + continue; + } + const int64_t offset = ((b * Hq_ + qh) * Sq_ + qi) * HEAD_DIM; + AscendC::DataCopyExtParams cp{1, static_cast(HEAD_DIM * sizeof(float)), 0, 0, 0}; + AscendC::DataCopyPad(dOBufF_.Get(), dOGm_[offset], cp, + AscendC::DataCopyPadExtParams{false, 0, 0, 0}); + AscendC::SetFlag(eventMTE2S_); + AscendC::WaitFlag(eventMTE2S_); + AscendC::SetFlag(eventSV_); + AscendC::WaitFlag(eventSV_); + AscendC::Muls(prod, dOBufF_.Get(), pj, HEAD_DIM); + AscendC::Add(acc, acc, prod, HEAD_DIM); + } + } + WriteRowKV(dVGm_, b, kvh, key, acc); + } + + // Launch 3: dK[key] = scale * sum over the fixed order of dS[qi,key] . Q[qi]. + __aicore__ inline void ProcessDK(int64_t b, int64_t kvh, int64_t key) + { + AscendC::LocalTensor acc = accBufF_.Get(); + AscendC::LocalTensor prod = workBufF_.Get(); + AscendC::Duplicate(acc, 0.0f, HEAD_DIM); + const int64_t group = Hq_ / Hkv_; + for (int64_t qi = 0; qi < Sq_; ++qi) { + for (int64_t g = 0; g < group; ++g) { + const int64_t qh = kvh * group + g; + AscendC::SetFlag(eventVMTE2_); + AscendC::WaitFlag(eventVMTE2_); + const float dSj = ReadGmFloat(dSGm_, ((b * Hq_ + qh) * Sq_ + qi) * Skv_ + key); + if (dSj == 0.0f) { + continue; + } + const int64_t offset = ((b * Hq_ + qh) * Sq_ + qi) * HEAD_DIM; + AscendC::LocalTensor qT = kBufT_.Get(); + AscendC::DataCopyExtParams cp{1, static_cast(HEAD_DIM * sizeof(T)), 0, 0, 0}; + AscendC::DataCopyPadExtParams pp{false, 0, 0, 0}; + AscendC::DataCopyPad(qT, qGm_[offset], cp, pp); + AscendC::SetFlag(eventMTE2S_); + AscendC::WaitFlag(eventMTE2S_); + AscendC::Cast(dOBufF_.Get(), qT, AscendC::RoundMode::CAST_NONE, HEAD_DIM); + AscendC::SetFlag(eventSV_); + AscendC::WaitFlag(eventSV_); + AscendC::Muls(prod, dOBufF_.Get(), dSj, HEAD_DIM); + AscendC::Add(acc, acc, prod, HEAD_DIM); + } + } + AscendC::Muls(acc, acc, scale_, HEAD_DIM); + WriteRowKV(dKGm_, b, kvh, key, acc); + } + + AscendC::TPipe* pipe_; + AscendC::GlobalTensor qGm_; + AscendC::GlobalTensor kGm_; + AscendC::GlobalTensor vGm_; + AscendC::GlobalTensor dOGm_; + AscendC::GlobalTensor maskGm_; + AscendC::GlobalTensor pGm_; + AscendC::GlobalTensor dSGm_; + AscendC::GlobalTensor dQGm_; + AscendC::GlobalTensor dKGm_; + AscendC::GlobalTensor dVGm_; + AscendC::TBuf qBufF_; + AscendC::TBuf kBufT_; + AscendC::TBuf kBufF_; + AscendC::TBuf vBufF_; + AscendC::TBuf scoresBuf_; + AscendC::TBuf workBufF_; + AscendC::TBuf reduceBufF_; + AscendC::TBuf dqBufF_; + AscendC::TBuf dOBufF_; + AscendC::TBuf accBufF_; + AscendC::TBuf maskBuf_; + AscendC::TBuf scalarBuf_; + AscendC::TEventID eventVS_; + AscendC::TEventID eventSV_; + AscendC::TEventID eventMTE2S_; + AscendC::TEventID eventSMTE2_; + AscendC::TEventID eventVMTE2_; + AscendC::TEventID eventVMTE3_; + AscendC::TEventID eventSMTE3_; + AscendC::TEventID eventMTE3S_; + int64_t B_; + int64_t Hq_; + int64_t Hkv_; + int64_t Sq_; + int64_t Skv_; + float scale_; + int32_t causal_; + int32_t hasMask_; +}; + +} // namespace + +extern "C" __global__ __vector__ void det_attention_backward_rows_kernel( + GM_ADDR q, GM_ADDR k, GM_ADDR v, GM_ADDR dO, GM_ADDR mask, + GM_ADDR p, GM_ADDR ds, GM_ADDR dq, + int64_t B, int64_t Hq, int64_t Hkv, int64_t Sq, int64_t Skv, + float scale, int32_t causal, int32_t hasMask) +{ + AscendC::TPipe pipe; + KernelDetAttentionBackward op(&pipe); + op.Init(q, k, v, dO, mask, p, ds, dq, nullptr, nullptr, B, Hq, Hkv, Sq, Skv, + scale, causal, hasMask); + op.ProcessRows(); +} + +extern "C" __global__ __vector__ void det_attention_backward_dv_kernel( + GM_ADDR q, GM_ADDR k, GM_ADDR v, GM_ADDR dO, GM_ADDR mask, + GM_ADDR p, GM_ADDR ds, GM_ADDR dv, + int64_t B, int64_t Hq, int64_t Hkv, int64_t Sq, int64_t Skv, + float scale, int32_t causal, int32_t hasMask) +{ + AscendC::TPipe pipe; + KernelDetAttentionBackward op(&pipe); + op.Init(q, k, v, dO, mask, p, ds, nullptr, nullptr, dv, B, Hq, Hkv, Sq, Skv, + scale, causal, hasMask); + op.ProcessDVAll(); +} + +extern "C" __global__ __vector__ void det_attention_backward_dk_kernel( + GM_ADDR q, GM_ADDR k, GM_ADDR v, GM_ADDR dO, GM_ADDR mask, + GM_ADDR p, GM_ADDR ds, GM_ADDR dk, + int64_t B, int64_t Hq, int64_t Hkv, int64_t Sq, int64_t Skv, + float scale, int32_t causal, int32_t hasMask) +{ + AscendC::TPipe pipe; + KernelDetAttentionBackward op(&pipe); + op.Init(q, k, v, dO, mask, p, ds, nullptr, dk, nullptr, B, Hq, Hkv, Sq, Skv, + scale, causal, hasMask); + op.ProcessDKAll(); +} + +std::vector deterministic_attention_backward_ascend( + torch::Tensor grad_out, + torch::Tensor q, + torch::Tensor k, + torch::Tensor v, + bool causal, + double scale, + c10::optional key_padding_mask) +{ + TORCH_CHECK(q.is_privateuseone() && k.is_privateuseone() && v.is_privateuseone() && + grad_out.is_privateuseone(), + "attention backward inputs must be on an NPU device"); + TORCH_CHECK(q.dim() == 4 && k.dim() == 4 && v.dim() == 4 && grad_out.dim() == 4, + "q/k/v/dO must be 4-D [B, H, S, D]"); + TORCH_CHECK(q.is_contiguous() && k.is_contiguous() && v.is_contiguous() && + grad_out.is_contiguous(), + "q/k/v/dO must be contiguous"); + TORCH_CHECK(q.scalar_type() == at::kBFloat16 || q.scalar_type() == at::kHalf, + "q must be bf16 or fp16"); + TORCH_CHECK(k.scalar_type() == q.scalar_type() && v.scalar_type() == q.scalar_type(), + "q/k/v must share the same dtype"); + TORCH_CHECK(q.size(3) == HEAD_DIM && k.size(3) == HEAD_DIM && v.size(3) == HEAD_DIM, + "head dim D must be 128"); + TORCH_CHECK(k.device() == q.device() && v.device() == q.device() && + grad_out.device() == q.device(), + "all inputs must be on the same NPU device"); + TORCH_CHECK(v.shape == k.shape && grad_out.shape == q.shape, + "v must match k and grad_out must match q"); + TORCH_CHECK(q.size(0) == k.size(0) && q.size(0) == v.size(0) && + q.size(0) == grad_out.size(0), + "batch size mismatch"); + TORCH_CHECK(q.size(1) > 0 && k.size(1) > 0 && q.size(1) % k.size(1) == 0, + "Hkv > 0 and Hq % Hkv == 0 required"); + TORCH_CHECK(q.size(2) > 0 && k.size(2) > 0, "Sq and Skv must be positive"); + if (q.numel() == 0) { + return {torch::empty_like(q), torch::empty_like(k), torch::empty_like(v)}; + } + + // The upstream gradient stays FP32 (never pre-cast): the kernel reads it + // as float and accumulates in FP32. + grad_out = grad_out.to(at::kFloat).contiguous(); + + const int64_t B = q.size(0); + const int64_t Hq = q.size(1); + const int64_t Hkv = k.size(1); + const int64_t Sq = q.size(2); + const int64_t Skv = k.size(2); + + torch::Tensor mask; + bool hasMask = key_padding_mask.has_value() && key_padding_mask->defined(); + if (hasMask) { + mask = key_padding_mask->to(torch::kBool).contiguous(); + TORCH_CHECK(mask.is_privateuseone(), "key_padding_mask must be on an NPU device"); + TORCH_CHECK(mask.dim() == 2 && mask.size(0) == B && mask.size(1) == Skv, + "key_padding_mask must be [B, Skv]"); + } + + torch::Tensor p = torch::empty({B, Hq, Sq, Skv}, q.options().dtype(at::kFloat)); + torch::Tensor ds = torch::empty({B, Hq, Sq, Skv}, q.options().dtype(at::kFloat)); + torch::Tensor dq = torch::empty_like(q); + torch::Tensor dk = torch::empty_like(k); + torch::Tensor dv = torch::empty_like(v); + + auto aclStream = c10_npu::getCurrentNPUStream().stream(true); + const int64_t rows = B * Hq * Sq; + const int64_t keys = B * Hkv * Skv; + uint8_t* maskPtr = hasMask ? reinterpret_cast(mask.mutable_data_ptr()) : nullptr; + uint8_t* qPtr = reinterpret_cast(q.mutable_data_ptr()); + uint8_t* kPtr = reinterpret_cast(k.mutable_data_ptr()); + uint8_t* vPtr = reinterpret_cast(v.mutable_data_ptr()); + uint8_t* dOPtr = reinterpret_cast(grad_out.mutable_data_ptr()); + uint8_t* pPtr = reinterpret_cast(p.mutable_data_ptr()); + uint8_t* dsPtr = reinterpret_cast(ds.mutable_data_ptr()); + uint8_t* dqPtr = reinterpret_cast(dq.mutable_data_ptr()); + uint8_t* dkPtr = reinterpret_cast(dk.mutable_data_ptr()); + uint8_t* dvPtr = reinterpret_cast(dv.mutable_data_ptr()); + + const uint32_t rowBlocks = static_cast(std::min(rows, int64_t(512))); + const uint32_t keyBlocks = static_cast(std::min(keys, int64_t(512))); + + if (q.scalar_type() == at::kBFloat16) { + det_attention_backward_rows_kernel<<>>( + qPtr, kPtr, vPtr, dOPtr, maskPtr, pPtr, dsPtr, dqPtr, + B, Hq, Hkv, Sq, Skv, static_cast(scale), causal ? 1 : 0, + hasMask ? 1 : 0); + det_attention_backward_dv_kernel<<>>( + qPtr, kPtr, vPtr, dOPtr, maskPtr, pPtr, dsPtr, dvPtr, + B, Hq, Hkv, Sq, Skv, static_cast(scale), causal ? 1 : 0, + hasMask ? 1 : 0); + det_attention_backward_dk_kernel<<>>( + qPtr, kPtr, vPtr, dOPtr, maskPtr, pPtr, dsPtr, dkPtr, + B, Hq, Hkv, Sq, Skv, static_cast(scale), causal ? 1 : 0, + hasMask ? 1 : 0); + } else { + TORCH_CHECK(false, "fp16 attention backward not yet implemented"); + } + return {dq, dk, dv}; +} diff --git a/csrc/ascend/attention/prefix_shared_attention_ascend.asc b/csrc/ascend/attention/prefix_shared_attention_ascend.asc new file mode 100644 index 00000000..6b9e292b --- /dev/null +++ b/csrc/ascend/attention/prefix_shared_attention_ascend.asc @@ -0,0 +1,391 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (c) 2026 RL-Kernel Contributors +// +// Prefix-shared fused attention, Ascend C (CANN) forward kernel. +// +// out = softmax(Q K^T * scale) @ V +// +// Mirrors the CUDA kernel in csrc/cuda/attention/prefix_shared_attention.cu +// (the GRPO decode workload): every one of the G query groups attends over +// the same shared prompt-prefix key/value sequence, so K and V are stored +// once per batch instead of once per group. +// - layout : q [bs, G, len_q, D], k/v [bs, len_kv, D] contiguous, D = 128 +// - numerics: bf16 in/out; fp32 online-softmax accumulation (per-row max / +// sum-exp rescale per key tile), the same flash-style single +// pass as the CUDA kernel; no causal mask, no key-padding mask +// (same surface as the CUDA op). +// - blocking: each (bs, g, 64-row query block) is processed end-to-end by +// one AI-core block over fixed 64-key tiles. The per-row +// reduction order depends only on len_kv, so row outputs are +// batch-invariant: they never depend on batch size, batch +// position, or how many blocks were launched (items are strided +// across blocks). +// +// Build: see setup.py (AscendBuildExtension, bisheng -x asc), gated by +// KERNEL_ALIGN_FORCE_ASCEND=1. Requires CANN toolkit + torch_npu. +// Python bindings live in csrc/ascend/ops_npu.asc (single PYBIND11_MODULE). + +#include +#include + +#include "kernel_operator.h" + +#include + +#include "torch_npu/csrc/core/npu/NPUStream.h" + +namespace { + +// Fixed head dimension (matches the CUDA op gate). +constexpr uint32_t HEAD_DIM = 128; +// Query rows per block (CUDA BLOCK_Q). +constexpr uint32_t BLOCK_Q = 64; +// Keys per tile (CUDA BLOCK_KV). Fixed for all runs; this is what makes the +// per-row reduction order batch-invariant. +constexpr uint32_t TILE_N = 64; +// 1/sqrt(HEAD_DIM); matches the CUDA kernel's rsqrtf(dim) softmax scale. +constexpr float SCALE = 0.088388348f; +// Cap on launched blocks. Work items are strided across blocks, so launching +// fewer blocks than items is fine and never changes per-row numerics. +constexpr int64_t MAX_BLOCKS = 512; +constexpr float NEG_INF = -3.402823466e+38f; // -FLT_MAX + +template +class KernelPrefixSharedAttention { +public: + __aicore__ inline KernelPrefixSharedAttention(AscendC::TPipe* pipe) : pipe_(pipe) {} + + __aicore__ inline void Init(GM_ADDR q, + GM_ADDR k, + GM_ADDR v, + GM_ADDR out, + int64_t bs, + int64_t G, + int64_t lenQ, + int64_t lenKv) + { + bs_ = bs; + G_ = G; + lenQ_ = lenQ; + lenKv_ = lenKv; + qGm_.SetGlobalBuffer(reinterpret_cast<__gm__ T*>(q)); + kGm_.SetGlobalBuffer(reinterpret_cast<__gm__ T*>(k)); + vGm_.SetGlobalBuffer(reinterpret_cast<__gm__ T*>(v)); + outGm_.SetGlobalBuffer(reinterpret_cast<__gm__ T*>(out)); + + // UB budget stays well under 192 KB: + // q/k/v/acc fp32 tiles 4 x 32 KB + bf16 staging 16 KB + // + prod/work/scores/state/scalar ~2 KB. + pipe_->InitBuffer(qBufF_, BLOCK_Q * HEAD_DIM * sizeof(float)); + pipe_->InitBuffer(kBufF_, TILE_N * HEAD_DIM * sizeof(float)); + pipe_->InitBuffer(vBufF_, TILE_N * HEAD_DIM * sizeof(float)); + pipe_->InitBuffer(accBufF_, BLOCK_Q * HEAD_DIM * sizeof(float)); + pipe_->InitBuffer(bufT_, BLOCK_Q * HEAD_DIM * sizeof(T)); + pipe_->InitBuffer(prodBufF_, HEAD_DIM * sizeof(float)); + pipe_->InitBuffer(workBufF_, HEAD_DIM * sizeof(float)); + pipe_->InitBuffer(scoresBuf_, TILE_N * sizeof(float)); + pipe_->InitBuffer(rowMaxBuf_, BLOCK_Q * sizeof(float)); + pipe_->InitBuffer(rowSumExpBuf_, BLOCK_Q * sizeof(float)); + // 64 B: floats [0,8) hold the per-row online-softmax rescale exp. + pipe_->InitBuffer(scalarBuf_, 64); + + // Intra-core pipeline events. NOTE: AscendC::SyncAll() is a cross-core + // barrier and deadlocks when more blocks are launched than there are + // physical cores (resident blocks wait for unscheduled ones), so all + // synchronization here uses per-pipe SetFlag/WaitFlag instead. + eventVS_ = pipe_->FetchEventID(AscendC::HardEvent::V_S); + eventSV_ = pipe_->FetchEventID(AscendC::HardEvent::S_V); + eventMTE2S_ = pipe_->FetchEventID(AscendC::HardEvent::MTE2_S); + eventVMTE2_ = pipe_->FetchEventID(AscendC::HardEvent::V_MTE2); + eventVMTE3_ = pipe_->FetchEventID(AscendC::HardEvent::V_MTE3); + eventMTE3S_ = pipe_->FetchEventID(AscendC::HardEvent::MTE3_S); + } + + __aicore__ inline void Process() + { + const int64_t qBlocks = (lenQ_ + BLOCK_Q - 1) / BLOCK_Q; + const int64_t items = bs_ * G_ * qBlocks; + for (int64_t item = AscendC::GetBlockIdx(); item < items; + item += AscendC::GetBlockNum()) { + const int64_t qb = item % qBlocks; + const int64_t g = (item / qBlocks) % G_; + const int64_t b = item / (qBlocks * G_); + ProcessBlock(b, g, qb); + } + } + +private: + __aicore__ inline void LoadQTile(int64_t b, int64_t g, int64_t rowStart, uint32_t numRows) + { + const int64_t offset = ((b * G_ + g) * lenQ_ + rowStart) * HEAD_DIM; + AscendC::LocalTensor qT = bufT_.Get(); + AscendC::DataCopyExtParams cp{ + 1, static_cast(numRows * HEAD_DIM * sizeof(T)), 0, 0, 0}; + AscendC::DataCopyPadExtParams pp{false, 0, 0, 0}; + AscendC::DataCopyPad(qT, qGm_[offset], cp, pp); + AscendC::SetFlag(eventMTE2S_); + AscendC::WaitFlag(eventMTE2S_); + AscendC::Cast(qBufF_.Get(), qT, AscendC::RoundMode::CAST_NONE, + numRows * HEAD_DIM); + } + + __aicore__ inline void LoadKTile(int64_t b, int64_t start, uint32_t count) + { + // bufT_ staging may hold data the vector pipe is still reading (the + // Q cast or the previous tile's V cast). + AscendC::SetFlag(eventVMTE2_); + AscendC::WaitFlag(eventVMTE2_); + const int64_t offset = (b * lenKv_ + start) * HEAD_DIM; + AscendC::LocalTensor kT = bufT_.Get(); + AscendC::DataCopyExtParams cp{ + 1, static_cast(count * HEAD_DIM * sizeof(T)), 0, 0, 0}; + AscendC::DataCopyPadExtParams pp{false, 0, 0, 0}; + AscendC::DataCopyPad(kT, kGm_[offset], cp, pp); + AscendC::SetFlag(eventMTE2S_); + AscendC::WaitFlag(eventMTE2S_); + AscendC::Cast(kBufF_.Get(), kT, AscendC::RoundMode::CAST_NONE, + count * HEAD_DIM); + } + + __aicore__ inline void LoadVTile(int64_t b, int64_t start, uint32_t count) + { + // bufT_ staging may hold data the vector pipe is still reading (the + // K cast of this tile). + AscendC::SetFlag(eventVMTE2_); + AscendC::WaitFlag(eventVMTE2_); + const int64_t offset = (b * lenKv_ + start) * HEAD_DIM; + AscendC::LocalTensor vT = bufT_.Get(); + AscendC::DataCopyExtParams cp{ + 1, static_cast(count * HEAD_DIM * sizeof(T)), 0, 0, 0}; + AscendC::DataCopyPadExtParams pp{false, 0, 0, 0}; + AscendC::DataCopyPad(vT, vGm_[offset], cp, pp); + AscendC::SetFlag(eventMTE2S_); + AscendC::WaitFlag(eventMTE2S_); + AscendC::Cast(vBufF_.Get(), vT, AscendC::RoundMode::CAST_NONE, + count * HEAD_DIM); + } + + // Online-softmax step for query row r over one 64-key tile: recompute the + // row max, rescale the running (sum-exp, accumulator), then fold the tile + // into both with P = exp(scores - mNew) and P . V. + __aicore__ inline void ProcessRowTile(uint32_t r, uint32_t count) + { + AscendC::LocalTensor qRow = qBufF_.Get()[r * HEAD_DIM]; + AscendC::LocalTensor accRow = accBufF_.Get()[r * HEAD_DIM]; + AscendC::LocalTensor kTile = kBufF_.Get(); + AscendC::LocalTensor vTile = vBufF_.Get(); + AscendC::LocalTensor scores = scoresBuf_.Get(); + AscendC::LocalTensor prod = prodBufF_.Get(); + AscendC::LocalTensor work = workBufF_.Get(); + AscendC::LocalTensor scalar = scalarBuf_.Get(); + AscendC::LocalTensor rowMax = rowMaxBuf_.Get(); + AscendC::LocalTensor rowSumExp = rowSumExpBuf_.Get(); + + // scores[j] = SCALE * (q_r . k_j) for j in [0, count); past-the-end + // lanes become NEG_INF so they drop out of max/exp exactly. + for (uint32_t j = 0; j < count; ++j) { + AscendC::Mul(prod, qRow, kTile[j * HEAD_DIM], HEAD_DIM); + AscendC::ReduceSum(scalar, prod, work, HEAD_DIM); + WaitVector(); + scores.SetValue(j, scalar.GetValue(0) * SCALE); + } + for (uint32_t j = count; j < TILE_N; ++j) { + scores.SetValue(j, NEG_INF); + } + + AscendC::SetFlag(eventSV_); + AscendC::WaitFlag(eventSV_); + AscendC::ReduceMax(scalar, scores, work, TILE_N, false); + WaitVector(); + const float mOld = rowMax.GetValue(r); + const float tileMax = scalar.GetValue(0); + const float mNew = mOld > tileMax ? mOld : tileMax; + + // rescale = exp(mOld - mNew), computed through the vector Exp (aicore + // scalar code has no expf); 0 on the first tile, where mOld == NEG_INF. + scalar.SetValue(0, mOld - mNew); + AscendC::SetFlag(eventSV_); + AscendC::WaitFlag(eventSV_); + AscendC::Exp(scalar, scalar, 8); + WaitVector(); + const float rescale = scalar.GetValue(0); + + // p[j] = exp(scores[j] - mNew); past-the-end lanes stay exp(-inf) = 0. + AscendC::Adds(scores, scores, -mNew, TILE_N); + AscendC::Exp(scores, scores, TILE_N); + AscendC::ReduceSum(scalar, scores, work, TILE_N); + WaitVector(); + const float tileSumExp = scalar.GetValue(0); + + rowMax.SetValue(r, mNew); + rowSumExp.SetValue(r, rowSumExp.GetValue(r) * rescale + tileSumExp); + + // acc_r *= rescale, then acc_r += sum_j p[j] * v_j. p[j] == 0 exactly + // on past-the-end lanes, and adding a zero term is a no-op, so the + // skip below is exact. + AscendC::Muls(accRow, accRow, rescale, HEAD_DIM); + for (uint32_t j = 0; j < count; ++j) { + const float pj = scores.GetValue(j); + if (pj == 0.0f) { + continue; + } + AscendC::Muls(prod, vTile[j * HEAD_DIM], pj, HEAD_DIM); + AscendC::Add(accRow, accRow, prod, HEAD_DIM); + } + } + + __aicore__ inline void ProcessBlock(int64_t b, int64_t g, int64_t qb) + { + const int64_t rowStart = qb * BLOCK_Q; + const int64_t remaining = lenQ_ - rowStart; + const uint32_t numRows = static_cast(remaining < BLOCK_Q ? remaining : BLOCK_Q); + + LoadQTile(b, g, rowStart, numRows); + + // Real zeroing: the buffers start as uninitialized UB and + // 0 * inf == NaN in the online-softmax rescale. + AscendC::Duplicate(accBufF_.Get(), 0.0f, numRows * HEAD_DIM); + AscendC::Duplicate(rowMaxBuf_.Get(), NEG_INF, numRows); + AscendC::Duplicate(rowSumExpBuf_.Get(), 0.0f, numRows); + + const int64_t numTiles = (lenKv_ + TILE_N - 1) / TILE_N; + for (int64_t tile = 0; tile < numTiles; ++tile) { + const int64_t start = tile * TILE_N; + const uint32_t count = TileCount(start); + LoadKTile(b, start, count); + LoadVTile(b, start, count); + for (uint32_t r = 0; r < numRows; ++r) { + ProcessRowTile(r, count); + } + } + WriteOutputs(b, g, rowStart, numRows); + } + + __aicore__ inline void WriteOutputs(int64_t b, int64_t g, int64_t rowStart, uint32_t numRows) + { + AscendC::LocalTensor acc = accBufF_.Get(); + AscendC::LocalTensor rowSumExp = rowSumExpBuf_.Get(); + AscendC::LocalTensor outT = bufT_.Get(); + + // out_r = acc_r / sumExp_r, then one bf16 cast and one copy-out for + // the whole contiguous row block. sumExp > 0 always (every block + // attends over at least one real key; len_kv >= 1 is host-checked), + // the guard just mirrors the CUDA op's defensive division. + for (uint32_t r = 0; r < numRows; ++r) { + const float sumExp = rowSumExp.GetValue(r); + const float invDenom = (sumExp > 0.0f) ? (1.0f / sumExp) : 0.0f; + AscendC::Muls(acc[r * HEAD_DIM], acc[r * HEAD_DIM], invDenom, HEAD_DIM); + } + + AscendC::Cast(outT, acc, AscendC::RoundMode::CAST_ROUND, numRows * HEAD_DIM); + AscendC::SetFlag(eventVMTE3_); + AscendC::WaitFlag(eventVMTE3_); + const int64_t offset = ((b * G_ + g) * lenQ_ + rowStart) * HEAD_DIM; + AscendC::DataCopyExtParams outCp{ + 1, static_cast(numRows * HEAD_DIM * sizeof(T)), 0, 0, 0}; + // UB -> GM has no pad-params overload (CANN 8.5.1): the row block is + // always contiguous, so no padding is needed anyway. + AscendC::DataCopyPad(outGm_[offset], outT, outCp); + // Drain MTE3 before the next block stages new values into the shared + // buffers; the scalar pipe issues all later MTE2 copies in order, so + // this wait alone orders them after the copy-out. + AscendC::SetFlag(eventMTE3S_); + AscendC::WaitFlag(eventMTE3S_); + } + + // Wait until all outstanding vector-pipe results are readable as scalars. + __aicore__ inline void WaitVector() + { + AscendC::SetFlag(eventVS_); + AscendC::WaitFlag(eventVS_); + } + + __aicore__ inline uint32_t TileCount(int64_t start) const + { + const int64_t remaining = lenKv_ - start; + return static_cast(remaining < TILE_N ? remaining : TILE_N); + } + + AscendC::TPipe* pipe_; + AscendC::GlobalTensor qGm_; + AscendC::GlobalTensor kGm_; + AscendC::GlobalTensor vGm_; + AscendC::GlobalTensor outGm_; + AscendC::TBuf qBufF_; + AscendC::TBuf kBufF_; + AscendC::TBuf vBufF_; + AscendC::TBuf accBufF_; + AscendC::TBuf bufT_; + AscendC::TBuf prodBufF_; + AscendC::TBuf workBufF_; + AscendC::TBuf scoresBuf_; + AscendC::TBuf rowMaxBuf_; + AscendC::TBuf rowSumExpBuf_; + AscendC::TBuf scalarBuf_; + AscendC::TEventID eventVS_; + AscendC::TEventID eventSV_; + AscendC::TEventID eventMTE2S_; + AscendC::TEventID eventVMTE2_; + AscendC::TEventID eventVMTE3_; + AscendC::TEventID eventMTE3S_; + int64_t bs_; + int64_t G_; + int64_t lenQ_; + int64_t lenKv_; +}; + +} // namespace + +extern "C" __global__ __vector__ void prefix_shared_attention_ascend_kernel_bf16( + GM_ADDR q, GM_ADDR k, GM_ADDR v, GM_ADDR out, + int64_t bs, int64_t G, int64_t lenQ, int64_t lenKv) +{ + AscendC::TPipe pipe; + KernelPrefixSharedAttention op(&pipe); + op.Init(q, k, v, out, bs, G, lenQ, lenKv); + op.Process(); +} + +torch::Tensor prefix_shared_attention_ascend_forward( + torch::Tensor q, + torch::Tensor k, + torch::Tensor v) +{ + TORCH_CHECK(q.is_privateuseone() && k.is_privateuseone() && v.is_privateuseone(), + "q, k, v must be on an NPU device"); + TORCH_CHECK(q.dim() == 4 && k.dim() == 3 && v.dim() == 3, + "q must be 4-D [bs, G, len_q, D]; k and v must be 3-D [bs, len_kv, D]"); + TORCH_CHECK(q.is_contiguous() && k.is_contiguous() && v.is_contiguous(), + "q, k, v must be contiguous"); + TORCH_CHECK(q.scalar_type() == at::kBFloat16 && + k.scalar_type() == at::kBFloat16 && v.scalar_type() == at::kBFloat16, + "prefix-shared attention requires bf16 (matches the CUDA op)"); + TORCH_CHECK(q.size(3) == HEAD_DIM && k.size(2) == HEAD_DIM && v.size(2) == HEAD_DIM, + "head dim D must be 128"); + TORCH_CHECK(q.size(0) == k.size(0) && q.size(0) == v.size(0), + "batch size mismatch between q/k/v"); + TORCH_CHECK(k.size(1) == v.size(1), "k/v must share the same key length"); + TORCH_CHECK(q.size(2) >= 1 && k.size(1) >= 1, "len_q and len_kv must be positive"); + + const int64_t bs = q.size(0); + const int64_t G = q.size(1); + const int64_t lenQ = q.size(2); + const int64_t lenKv = k.size(1); + + torch::Tensor out = at::empty({bs, G, lenQ, HEAD_DIM}, q.options()); + + // stream(true): flush the task queue before launch so the kernel cannot + // overtake earlier NPU work; the output was allocated with at::empty (no + // queued initializer) for the same reason. + auto aclStream = c10_npu::getCurrentNPUStream().stream(true); + const int64_t qBlocks = (lenQ + BLOCK_Q - 1) / BLOCK_Q; + const int64_t items = bs * G * qBlocks; + const uint32_t blockNum = static_cast(std::min(items, MAX_BLOCKS)); + prefix_shared_attention_ascend_kernel_bf16<<>>( + reinterpret_cast(q.mutable_data_ptr()), + reinterpret_cast(k.mutable_data_ptr()), + reinterpret_cast(v.mutable_data_ptr()), + reinterpret_cast(out.mutable_data_ptr()), + bs, G, lenQ, lenKv); + return out; +} diff --git a/csrc/ascend/batch_invariant_logp_ascend.asc b/csrc/ascend/batch_invariant_logp_ascend.asc index dead4cbe..f0c3e994 100644 --- a/csrc/ascend/batch_invariant_logp_ascend.asc +++ b/csrc/ascend/batch_invariant_logp_ascend.asc @@ -308,9 +308,6 @@ std::vector batch_invariant_logp_ascend_forward(torch::Tensor log return {logp, lse}; } -PYBIND11_MODULE(TORCH_EXTENSION_NAME, m) -{ - m.def("batch_invariant_logp_ascend", - &batch_invariant_logp_ascend_forward, - "Batch-invariant selected-token log-probability (Ascend C forward)"); -} + +// The PYBIND11_MODULE for rl_engine._C_npu lives in npu_module.cpp so that +// every Ascend op shares one compiled module. diff --git a/csrc/ascend/bindings.asc b/csrc/ascend/bindings.asc new file mode 100644 index 00000000..dd243e18 --- /dev/null +++ b/csrc/ascend/bindings.asc @@ -0,0 +1,4 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (c) 2026 RL-Kernel Contributors +// One Python module initializer for all Ascend translation units: it lives in +// npu_module.cpp, so keep this TU free of any PYBIND11_MODULE definition. diff --git a/csrc/ascend/distributed/deterministic_collective_ascend.asc b/csrc/ascend/distributed/deterministic_collective_ascend.asc new file mode 100644 index 00000000..a5377732 --- /dev/null +++ b/csrc/ascend/distributed/deterministic_collective_ascend.asc @@ -0,0 +1,444 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (c) 2026 RL-Kernel Contributors +// +// Deterministic (TP-invariant) collectives, Ascend NPU reduction kernel. +// +// out = fixed_tree(rank0, rank1, ...) (ordered adds in the input dtype) +// +// Mirrors the CUDA kernel in csrc/cuda/distributed/deterministic_collective.cu: +// TP sizes 1, 2, 4, and 8 reduce with nested prefixes of the same balanced +// tree -- every node evaluates the lower logical subtree before the higher +// one, and each add rounds once in the input dtype (round-to-nearest-even), +// so the result is bitwise identical on every rank and across TP +// configurations when inputs follow the TBIK-compatible subtree contract. +// +// Unlike the CUDA version there is no cross-device IPC staging on Ascend: the +// Python wrapper gathers every rank's staged input with an HCCL all_gather +// (pure data movement, bitwise exact) into a [world_size, N] buffer, and this +// kernel performs the fixed-tree reduction locally. The reduction order is +// still fully deterministic -- it never depends on the HCCL algorithm. +// +// fp16/bf16 tree levels round per-add like the CUDA ordered_add: the fp32 +// partials (exact sums) are cast back with CAST_RINT, the vector unit's +// round-to-nearest-even conversion (fp16/bf16 vector Adds either do not +// exist or are not exposed by the Add API on this CANN). +// +// Supported dtypes: float32, float16, bfloat16 (same gate as the CUDA op). +// +// Build: see setup.py (AscendBuildExtension, bisheng -x asc), gated by +// KERNEL_ALIGN_FORCE_ASCEND=1. Requires CANN toolkit + torch_npu. +// Python bindings live in csrc/ascend/ops_npu.asc (single PYBIND11_MODULE). + +#include +#include +#include + +#include "kernel_operator.h" + +#include + +#include "torch_npu/csrc/core/npu/NPUStream.h" + +namespace { +constexpr int kMaxWorldSize = 8; +constexpr uint32_t TILE_FP32 = 1024; +constexpr uint32_t TILE_FP16 = 2048; +constexpr int64_t MAX_BLOCKS = 512; + +// In-place balanced-tree sum over `count` elements of the per-rank UB tiles. +// The rank tiles live back-to-back in one UB buffer; the tree folds even/odd +// pairs in place and finishes by adding the final pair into the out tile. +// This is exactly the CUDA fixed_tree_reduce order: +// ws=2: t0 + t1 +// ws=4: (t0 + t1) + (t2 + t3) +// ws=8: ((t0 + t1) + (t2 + t3)) + ((t4 + t5) + (t6 + t7)) +template +class KernelDeterministicCollectiveReduce { +public: + __aicore__ inline KernelDeterministicCollectiveReduce(AscendC::TPipe* pipe) + : pipe_(pipe) {} + + __aicore__ inline void Init(GM_ADDR gathered, + GM_ADDR out, + int64_t sliceElements, + int64_t sliceOffset, + int64_t rankStride, + int64_t worldSize) + { + sliceElements_ = sliceElements; + sliceOffset_ = sliceOffset; + rankStride_ = rankStride; + worldSize_ = worldSize; + gatheredGm_.SetGlobalBuffer(reinterpret_cast<__gm__ T*>(gathered)); + outGm_.SetGlobalBuffer(reinterpret_cast<__gm__ T*>(out)); + + constexpr uint32_t TILE = std::is_same_v ? TILE_FP32 : TILE_FP16; + pipe_->InitBuffer(rankBuf_, kMaxWorldSize * TILE * sizeof(T)); + pipe_->InitBuffer(rankBufF_, kMaxWorldSize * TILE * sizeof(float)); + pipe_->InitBuffer(outBufF_, TILE * sizeof(float)); + pipe_->InitBuffer(outBuf_, TILE * sizeof(T)); + + // Intra-core pipeline events. NOTE: AscendC::SyncAll() is a cross-core + // barrier and deadlocks when more blocks are launched than there are + // physical cores (resident blocks wait for unscheduled ones), so all + // synchronization here uses per-pipe SetFlag/WaitFlag instead. + eventMTE2S_ = pipe_->FetchEventID(AscendC::HardEvent::MTE2_S); + eventVMTE2_ = pipe_->FetchEventID(AscendC::HardEvent::V_MTE2); + eventVMTE3_ = pipe_->FetchEventID(AscendC::HardEvent::V_MTE3); + eventMTE3S_ = pipe_->FetchEventID(AscendC::HardEvent::MTE3_S); + } + + __aicore__ inline void Process() + { + constexpr uint32_t TILE = std::is_same_v ? TILE_FP32 : TILE_FP16; + const int64_t numTiles = (sliceElements_ + TILE - 1) / TILE; + for (int64_t tile = AscendC::GetBlockIdx(); tile < numTiles; + tile += AscendC::GetBlockNum()) { + ProcessTile(tile); + } + } + +private: + // The rank-r tile views inside the shared rank buffers. GetWithOffset is + // the intrinsic-checker-friendly aliasing view (operator[] views are + // rejected by the vector-binary checks on this CANN version). + __aicore__ inline AscendC::LocalTensor RankBuf(int64_t rank) + { + constexpr uint32_t TILE = std::is_same_v ? TILE_FP32 : TILE_FP16; + return rankBuf_.GetWithOffset(TILE, static_cast(rank * TILE * sizeof(T))); + } + __aicore__ inline AscendC::LocalTensor RankBufF(int64_t rank) + { + constexpr uint32_t TILE = std::is_same_v ? TILE_FP32 : TILE_FP16; + return rankBufF_.GetWithOffset( + TILE, static_cast(rank * TILE * sizeof(float))); + } + + __aicore__ inline void ProcessTile(int64_t tile) + { + constexpr uint32_t TILE = std::is_same_v ? TILE_FP32 : TILE_FP16; + const int64_t start = tile * TILE; + const int64_t remaining = sliceElements_ - start; + const uint32_t count = + static_cast(remaining < TILE ? remaining : TILE); + + // gathered holds [world_size, N] with the slice of interest at + // sliceOffset_ + rank * sliceElements_. Every rank's tile must be in + // UB before any Add; the V_MTE2 wait keeps the next tile's loads from + // clobbering buffers the vector pipe is still reading. + for (int64_t rank = 0; rank < worldSize_; ++rank) { + AscendC::SetFlag(eventVMTE2_); + AscendC::WaitFlag(eventVMTE2_); + const int64_t offset = sliceOffset_ + rank * rankStride_ + start; + AscendC::DataCopyExtParams cp{ + 1, static_cast(count * sizeof(T)), 0, 0, 0}; + AscendC::DataCopyPadExtParams pp{false, 0, 0, 0}; + AscendC::DataCopyPad(RankBuf(rank), gatheredGm_[offset], cp, pp); + } + AscendC::SetFlag(eventMTE2S_); + AscendC::WaitFlag(eventMTE2S_); + + if constexpr (std::is_same_v) { + AscendC::LocalTensor out = outBufF_.Get(); + if (worldSize_ == 2) { + AscendC::Add(out, RankBuf(0), RankBuf(1), count); + } else if (worldSize_ == 4) { + AscendC::Add(RankBuf(0), RankBuf(0), RankBuf(1), count); + AscendC::Add(RankBuf(2), RankBuf(2), RankBuf(3), count); + AscendC::Add(out, RankBuf(0), RankBuf(2), count); + } else if (worldSize_ == 8) { + AscendC::Add(RankBuf(0), RankBuf(0), RankBuf(1), count); + AscendC::Add(RankBuf(2), RankBuf(2), RankBuf(3), count); + AscendC::Add(RankBuf(4), RankBuf(4), RankBuf(5), count); + AscendC::Add(RankBuf(6), RankBuf(6), RankBuf(7), count); + AscendC::Add(RankBuf(0), RankBuf(0), RankBuf(2), count); + AscendC::Add(RankBuf(4), RankBuf(4), RankBuf(6), count); + AscendC::Add(out, RankBuf(0), RankBuf(4), count); + } + AscendC::SetFlag(eventVMTE3_); + AscendC::WaitFlag(eventVMTE3_); + AscendC::DataCopyExtParams cpOut{ + 1, static_cast(count * sizeof(T)), 0, 0, 0}; + if (worldSize_ == 1) { + AscendC::DataCopyPad(outGm_[start], RankBuf(0), cpOut); + } else { + AscendC::DataCopyPad(outGm_[start], out, cpOut); + } + } else { + // fp16/bf16: the CUDA ordered_add rounds after EVERY tree add in + // the input dtype, so each level folds the fp32-exact partials + // (CAST_RINT == IEEE round-to-nearest-even on this CANN) before + // the next level adds them. The RankBuf tiles double as the + // per-level partial storage. + for (int64_t rank = 0; rank < worldSize_; ++rank) { + AscendC::Cast(RankBufF(rank), RankBuf(rank), AscendC::RoundMode::CAST_NONE, count); + } + // Level 1: pair folds. + if (worldSize_ >= 2) { + AscendC::Add(RankBufF(0), RankBufF(0), RankBufF(1), count); + AscendC::Cast(RankBuf(0), RankBufF(0), AscendC::RoundMode::CAST_RINT, count); + } + if (worldSize_ >= 4) { + AscendC::Add(RankBufF(2), RankBufF(2), RankBufF(3), count); + AscendC::Cast(RankBuf(2), RankBufF(2), AscendC::RoundMode::CAST_RINT, count); + } + if (worldSize_ == 8) { + AscendC::Add(RankBufF(4), RankBufF(4), RankBufF(5), count); + AscendC::Cast(RankBuf(4), RankBufF(4), AscendC::RoundMode::CAST_RINT, count); + AscendC::Add(RankBufF(6), RankBufF(6), RankBufF(7), count); + AscendC::Cast(RankBuf(6), RankBufF(6), AscendC::RoundMode::CAST_RINT, count); + } + // Level 2: (01 + 23) and (45 + 67). + if (worldSize_ >= 4) { + AscendC::Cast(RankBufF(0), RankBuf(0), AscendC::RoundMode::CAST_NONE, count); + AscendC::Cast(RankBufF(2), RankBuf(2), AscendC::RoundMode::CAST_NONE, count); + AscendC::Add(RankBufF(0), RankBufF(0), RankBufF(2), count); + AscendC::Cast(RankBuf(0), RankBufF(0), AscendC::RoundMode::CAST_RINT, count); + } + if (worldSize_ == 8) { + AscendC::Cast(RankBufF(4), RankBuf(4), AscendC::RoundMode::CAST_NONE, count); + AscendC::Cast(RankBufF(6), RankBuf(6), AscendC::RoundMode::CAST_NONE, count); + AscendC::Add(RankBufF(4), RankBufF(4), RankBufF(6), count); + AscendC::Cast(RankBuf(4), RankBufF(4), AscendC::RoundMode::CAST_RINT, count); + } + // Level 3: (03 + 47). + AscendC::SetFlag(eventVMTE3_); + AscendC::WaitFlag(eventVMTE3_); + AscendC::DataCopyExtParams cpOut{ + 1, static_cast(count * sizeof(T)), 0, 0, 0}; + if (worldSize_ == 8) { + AscendC::Cast(RankBufF(0), RankBuf(0), AscendC::RoundMode::CAST_NONE, count); + AscendC::Cast(RankBufF(4), RankBuf(4), AscendC::RoundMode::CAST_NONE, count); + AscendC::Add(RankBufF(0), RankBufF(0), RankBufF(4), count); + AscendC::Cast(outBuf_.Get(), RankBufF(0), AscendC::RoundMode::CAST_RINT, count); + AscendC::SetFlag(eventVMTE3_); + AscendC::WaitFlag(eventVMTE3_); + AscendC::DataCopyPad(outGm_[start], outBuf_.Get(), cpOut); + } else { + // ws 1/2/4: the final partial already lives in RankBuf(0) (or + // is the staged input itself for ws == 1); copy it out + // directly (UB->UB DataCopy has no bf16 form on this CANN). + AscendC::DataCopyPad(outGm_[start], RankBuf(0), cpOut); + } + } + + // Drain MTE3 before the next tile } + + // Drain MTE3 before the next tile stages new values into the shared + // buffers; the scalar pipe issues all later MTE2 copies in order, so + // this wait alone orders them after the copy-out. + AscendC::SetFlag(eventMTE3S_); + AscendC::WaitFlag(eventMTE3S_); + } + + AscendC::TPipe* pipe_; + AscendC::GlobalTensor gatheredGm_; + AscendC::GlobalTensor outGm_; + AscendC::TBuf rankBuf_; + AscendC::TBuf rankBufF_; + AscendC::TBuf outBufF_; + AscendC::TBuf outBuf_; + AscendC::TEventID eventMTE2S_; + AscendC::TEventID eventVMTE2_; + AscendC::TEventID eventVMTE3_; + AscendC::TEventID eventMTE3S_; + int64_t sliceElements_; + int64_t sliceOffset_; + int64_t rankStride_; + int64_t worldSize_; +}; + +} // namespace + +extern "C" __global__ __vector__ void deterministic_collective_reduce_kernel_fp32( + GM_ADDR gathered, GM_ADDR out, int64_t sliceElements, int64_t sliceOffset, + int64_t rankStride, int64_t worldSize) +{ + AscendC::TPipe pipe; + KernelDeterministicCollectiveReduce op(&pipe); + op.Init(gathered, out, sliceElements, sliceOffset, rankStride, worldSize); + op.Process(); +} + +extern "C" __global__ __vector__ void deterministic_collective_reduce_kernel_fp16( + GM_ADDR gathered, GM_ADDR out, int64_t sliceElements, int64_t sliceOffset, + int64_t rankStride, int64_t worldSize) +{ + AscendC::TPipe pipe; + KernelDeterministicCollectiveReduce op(&pipe); + op.Init(gathered, out, sliceElements, sliceOffset, rankStride, worldSize); + op.Process(); +} + +extern "C" __global__ __vector__ void deterministic_collective_reduce_kernel_bf16( + GM_ADDR gathered, GM_ADDR out, int64_t sliceElements, int64_t sliceOffset, + int64_t rankStride, int64_t worldSize) +{ + AscendC::TPipe pipe; + KernelDeterministicCollectiveReduce op(&pipe); + op.Init(gathered, out, sliceElements, sliceOffset, rankStride, worldSize); + op.Process(); +} + +namespace { + +// --------------------------------------------------------------------------- +// Host-side state: staging buffer + reduction dispatch. +// --------------------------------------------------------------------------- + +// --------------------------------------------------------------------------- +// Host-side state: staging buffer + reduction dispatch. +// --------------------------------------------------------------------------- + +class DeterministicCollectiveState { +public: + DeterministicCollectiveState(torch::Tensor& staging, int64_t worldSize, int64_t rank) + : rank_(rank), + world_size_(worldSize), + capacity_bytes_(staging.numel() * staging.element_size()) + { + TORCH_CHECK(staging.is_privateuseone(), "collective staging buffer must be NPU"); + TORCH_CHECK(staging.is_contiguous(), "collective staging buffer must be contiguous"); + TORCH_CHECK( + staging.scalar_type() == torch::kUInt8, + "collective staging buffer must have dtype torch.uint8"); + TORCH_CHECK(capacity_bytes_ > 0, "collective staging capacity must be positive"); + TORCH_CHECK( + world_size_ == 1 || world_size_ == 2 || world_size_ == 4 || world_size_ == 8, + "deterministic collectives require world size 1, 2, 4, or 8; got ", + world_size_); + TORCH_CHECK( + rank_ >= 0 && rank_ < world_size_, + "deterministic collective rank must be in [0, ", + world_size_, + ")"); + staging_ = staging; + } + + void stage(torch::Tensor& input) { + TORCH_CHECK(input.is_privateuseone(), "input must be an NPU tensor"); + TORCH_CHECK(input.is_contiguous(), "input must be contiguous"); + TORCH_CHECK( + input.get_device() == staging_.get_device(), + "input must be on the staging device"); + const int64_t input_bytes = input.numel() * input.element_size(); + TORCH_CHECK( + input_bytes <= capacity_bytes_, + "input requires ", + input_bytes, + " bytes but staging capacity is ", + capacity_bytes_); + if (input_bytes > 0) { + // Copy on the current NPU stream; the wrapper synchronizes the + // device before the cross-rank gather reads the staging buffer. + staging_.narrow(0, 0, input_bytes) + .view(input.scalar_type()) + .copy_(input); + } + staged_bytes_ = input_bytes; + staged_numel_ = input.numel(); + staged_scalar_type_ = input.scalar_type(); + has_staged_input_ = true; + } + + void reduce(torch::Tensor& gathered, torch::Tensor& output, int64_t sliceOffset) const { + TORCH_CHECK(has_staged_input_, "stage() must be called before reduce()"); + TORCH_CHECK( + gathered.is_privateuseone() && output.is_privateuseone(), + "gathered and output must be NPU tensors"); + TORCH_CHECK( + gathered.is_contiguous() && output.is_contiguous(), + "gathered and output must be contiguous"); + TORCH_CHECK( + output.scalar_type() == staged_scalar_type_, + "reduce output dtype must match the staged input dtype"); + TORCH_CHECK( + gathered.scalar_type() == staged_scalar_type_, + "gathered dtype must match the staged input dtype"); + const int64_t slice_elements = output.numel(); + TORCH_CHECK( + gathered.numel() == staged_numel_ * world_size_, + "gathered must contain one staged input per rank: expected ", + staged_numel_ * world_size_, + " elements, got ", + gathered.numel()); + TORCH_CHECK( + sliceOffset >= 0 && sliceOffset + slice_elements <= staged_numel_, + "reduce slice offset is out of the gathered buffer"); + + if (slice_elements == 0) { + return; + } + auto aclStream = c10_npu::getCurrentNPUStream().stream(true); + const uint32_t tile = + staged_scalar_type_ == at::ScalarType::Float ? TILE_FP32 : TILE_FP16; + const int64_t numTiles = (slice_elements + tile - 1) / tile; + const uint32_t blockNum = static_cast(std::min(numTiles, MAX_BLOCKS)); + + uint8_t* gatheredPtr = reinterpret_cast(gathered.mutable_data_ptr()); + uint8_t* outPtr = reinterpret_cast(output.mutable_data_ptr()); + + switch (staged_scalar_type_) { + case at::ScalarType::Float: + deterministic_collective_reduce_kernel_fp32<<>>( + gatheredPtr, outPtr, slice_elements, sliceOffset, staged_numel_, world_size_); + break; + case at::ScalarType::Half: + deterministic_collective_reduce_kernel_fp16<<>>( + gatheredPtr, outPtr, slice_elements, sliceOffset, staged_numel_, world_size_); + break; + case at::ScalarType::BFloat16: + deterministic_collective_reduce_kernel_bf16<<>>( + gatheredPtr, outPtr, slice_elements, sliceOffset, staged_numel_, world_size_); + break; + default: + TORCH_CHECK( + false, + "deterministic reduce supports float32, float16, and bfloat16; got ", + staged_scalar_type_); + } + } + +private: + int64_t rank_; + int64_t world_size_; + int64_t capacity_bytes_; + int64_t staged_bytes_{0}; + int64_t staged_numel_{0}; + at::ScalarType staged_scalar_type_{at::ScalarType::Undefined}; + bool has_staged_input_{false}; + torch::Tensor staging_; +}; + +DeterministicCollectiveState* state_from_handle(int64_t handle) { + TORCH_CHECK(handle != 0, "deterministic collective handle is closed"); + return reinterpret_cast(handle); +} + +} // namespace + +// Host API (registered in csrc/ascend/ops_npu.asc). +int64_t deterministic_collective_create(torch::Tensor staging, int64_t worldSize, int64_t rank) +{ + auto state = std::make_unique(staging, worldSize, rank); + return reinterpret_cast(state.release()); +} + +void deterministic_collective_destroy(int64_t handle) +{ + delete state_from_handle(handle); +} + +void deterministic_collective_stage(int64_t handle, torch::Tensor input) +{ + state_from_handle(handle)->stage(input); +} + +void deterministic_collective_reduce( + int64_t handle, + torch::Tensor gathered, + torch::Tensor output, + int64_t sliceOffset) +{ + state_from_handle(handle)->reduce(gathered, output, sliceOffset); +} diff --git a/csrc/ascend/embedding_ascend.asc b/csrc/ascend/embedding_ascend.asc new file mode 100644 index 00000000..503a5f7f --- /dev/null +++ b/csrc/ascend/embedding_ascend.asc @@ -0,0 +1,279 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (c) 2026 RL-Kernel Contributors +// +// Batch-invariant token embedding, Ascend C (CANN) forward kernel. +// +// out[t, :] = weight[token_ids[t], :] +// +// Mirrors the SM90 CUDA kernel in csrc/cuda/embedding_lm_head_sm90.cu: +// - input : token_ids [*lead] (cast to int64), weight [V, H] contiguous +// fp32 / bf16 / fp16 +// - output : [*lead, H] in the weight's native dtype (bit copy), or fp32 +// when output_fp32 (handled by the host wrapper, see below) +// - every token id must be in [0, V); the host wrapper checks this. +// +// Bitwise identity with the CUDA kernel: the SM90 forward is a pure row +// gather (output[idx] = static_cast(weight[...])). This kernel is +// a pure byte copy of the same rows in the native dtype, and the fp32-output +// path upcasts the gathered result afterwards. Upcasting bf16/fp16 to fp32 +// is exact (every value is representable), so both paths are bitwise +// identical to the CUDA kernel for identical inputs. There is no arithmetic +// anywhere in the op, so there is no reduction order to drift. +// +// Batch-invariance: every token row is copied end-to-end by exactly one AI +// core block with a fixed tile size. The copy sequence for a row depends +// only on H, never on the total token count or on the block the row happens +// to land on. Rows are strided across blocks, so launching fewer blocks than +// rows is fine and never changes any row's bytes. +// +// Build: see setup.py (AscendBuildExtension, bisheng -x asc), gated by +// KERNEL_ALIGN_FORCE_ASCEND=1. Requires CANN toolkit + torch_npu. + +#include + +#include "kernel_operator.h" + +#include + +#include "torch_npu/csrc/core/npu/NPUStream.h" + +namespace { + +// Elements per hidden tile. Fixed for all rows and token counts; this is what +// makes the copy sequence batch-invariant. UB budget (in tile + out tile) +// stays far under the 192 KB UB of current SoCs. +constexpr uint32_t TILE_LENGTH = 4096; +// Cap on launched blocks. Rows are strided across blocks, so launching fewer +// blocks than rows is fine and never changes per-row numerics. +constexpr int64_t MAX_BLOCKS = 128; + +template +class KernelEmbedding { +public: + __aicore__ inline KernelEmbedding(AscendC::TPipe* pipe) : pipe_(pipe) {} + + __aicore__ inline void Init(GM_ADDR tokenIds, + GM_ADDR weight, + GM_ADDR output, + int64_t numTokens, + int64_t hiddenSize) + { + numTokens_ = numTokens; + hiddenSize_ = hiddenSize; + tokenIdsGm_.SetGlobalBuffer(reinterpret_cast<__gm__ int64_t*>(tokenIds)); + weightGm_.SetGlobalBuffer(reinterpret_cast<__gm__ T*>(weight)); + outputGm_.SetGlobalBuffer(reinterpret_cast<__gm__ T*>(output)); + pipe_->InitBuffer(inQueue_, 1, TILE_LENGTH * sizeof(T)); + pipe_->InitBuffer(outQueue_, 1, TILE_LENGTH * sizeof(T)); + // 32 B window for reading token_ids[row] via DataCopyPad (GM scalar + // GetValue/SetValue are unreliable on hardware; see cannbot + // ascendc-precision-debug common-traps). + pipe_->InitBuffer(idsBuf_, 32); + + // Intra-core pipeline events. NOTE: AscendC::SyncAll() is a cross-core + // barrier and deadlocks when more blocks are launched than there are + // physical cores (resident blocks wait for unscheduled ones), so all + // synchronization here uses per-pipe SetFlag/WaitFlag instead. + eventMTE2S_ = pipe_->FetchEventID(AscendC::HardEvent::MTE2_S); + } + + __aicore__ inline void Process() + { + for (int64_t row = AscendC::GetBlockIdx(); row < numTokens_; + row += AscendC::GetBlockNum()) { + ProcessRow(row); + } + } + +private: + // Copy one hidden tile of row `row` (gathered from weight row `tokenId`) + // through UB. The copy is a pure byte move; the fixed tile order is what + // keeps the kernel batch-invariant. + __aicore__ inline void ProcessRow(int64_t row) + { + const int64_t tokenId = LoadTokenId(row); + const int64_t tileCount = (hiddenSize_ + TILE_LENGTH - 1) / TILE_LENGTH; + for (int64_t tile = 0; tile < tileCount; ++tile) { + const int64_t start = tile * TILE_LENGTH; + const uint32_t count = TileCount(start); + + // Canonical GM -> UB -> GM pipeline (same shape as the official + // Ascend C elementwise samples). The queues own all cross-pipe + // ordering: inQueue.EnQue/DeQue syncs MTE2 copy-in -> vector, + // outQueue.EnQue/DeQue syncs vector -> MTE3 copy-out, and + // FreeTensor orders the next tile's writes against the previous + // tile's reads, so the shared UB tiles are never reused while a + // pipe is still draining them. + AscendC::LocalTensor inTile = inQueue_.AllocTensor(); + AscendC::DataCopyExtParams inParams{ + 1, static_cast(count * sizeof(T)), 0, 0, 0}; + AscendC::DataCopyPadExtParams padParams{false, 0, 0, 0}; + AscendC::DataCopyPad( + inTile, weightGm_[tokenId * hiddenSize_ + start], inParams, padParams); + inQueue_.EnQue(inTile); + inTile = inQueue_.DeQue(); + + AscendC::LocalTensor outTile = outQueue_.AllocTensor(); + // The vector-pipe UB copy needs 32 B-aligned element counts. + // Over-copying within UB is harmless: the copy-out below writes + // only `count` elements to GM, so the tail never escapes. + AscendC::DataCopy(outTile, inTile, VecAlignCount(count)); + outQueue_.EnQue(outTile); + outTile = outQueue_.DeQue(); + + AscendC::DataCopyExtParams outParams{ + 1, static_cast(count * sizeof(T)), 0, 0, 0}; + AscendC::DataCopyPad( + outputGm_[row * hiddenSize_ + start], outTile, outParams); + outQueue_.FreeTensor(outTile); + inQueue_.FreeTensor(inTile); + } + } + + // Read token_ids[row] through a 32-byte-aligned DataCopyPad window. + __aicore__ inline int64_t LoadTokenId(int64_t row) + { + const int64_t alignedRow = row & ~3LL; // 4 x int64 per 32 B + const int64_t remaining = numTokens_ - alignedRow; + const uint32_t winCount = static_cast(remaining < 4 ? remaining : 4); + AscendC::LocalTensor idsLocal = idsBuf_.Get(); + AscendC::DataCopyExtParams copyParams{ + 1, static_cast(winCount * sizeof(int64_t)), 0, 0, 0}; + AscendC::DataCopyPadExtParams padParams{false, 0, 0, 0}; + AscendC::DataCopyPad(idsLocal, tokenIdsGm_[alignedRow], copyParams, padParams); + AscendC::SetFlag(eventMTE2S_); // copy-in -> scalar read + AscendC::WaitFlag(eventMTE2S_); + return static_cast( + idsLocal.GetValue(static_cast(row - alignedRow))); + } + + __aicore__ inline uint32_t TileCount(int64_t start) const + { + const int64_t remaining = hiddenSize_ - start; + return static_cast(remaining < TILE_LENGTH ? remaining : TILE_LENGTH); + } + + // Round an element count up to a 32 B boundary (vector-pipe minimum). + __aicore__ inline uint32_t VecAlignCount(uint32_t count) const + { + constexpr uint32_t elemsPer32B = 32 / sizeof(T); + return (count + elemsPer32B - 1) / elemsPer32B * elemsPer32B; + } + + AscendC::TPipe* pipe_; + AscendC::GlobalTensor tokenIdsGm_; + AscendC::GlobalTensor weightGm_; + AscendC::GlobalTensor outputGm_; + AscendC::TQue inQueue_; + AscendC::TQue outQueue_; + AscendC::TBuf idsBuf_; + AscendC::TEventID eventMTE2S_; + int64_t numTokens_; + int64_t hiddenSize_; +}; + +} // namespace + +extern "C" __global__ __vector__ void embedding_ascend_kernel_fp32( + GM_ADDR tokenIds, GM_ADDR weight, GM_ADDR output, + int64_t numTokens, int64_t hiddenSize) +{ + AscendC::TPipe pipe; + KernelEmbedding op(&pipe); + op.Init(tokenIds, weight, output, numTokens, hiddenSize); + op.Process(); +} + +extern "C" __global__ __vector__ void embedding_ascend_kernel_bf16( + GM_ADDR tokenIds, GM_ADDR weight, GM_ADDR output, + int64_t numTokens, int64_t hiddenSize) +{ + AscendC::TPipe pipe; + KernelEmbedding op(&pipe); + op.Init(tokenIds, weight, output, numTokens, hiddenSize); + op.Process(); +} + +extern "C" __global__ __vector__ void embedding_ascend_kernel_fp16( + GM_ADDR tokenIds, GM_ADDR weight, GM_ADDR output, + int64_t numTokens, int64_t hiddenSize) +{ + AscendC::TPipe pipe; + KernelEmbedding op(&pipe); + op.Init(tokenIds, weight, output, numTokens, hiddenSize); + op.Process(); +} + +torch::Tensor embedding_ascend_forward(torch::Tensor token_ids, torch::Tensor weight, + bool output_fp32) +{ + TORCH_CHECK(token_ids.is_privateuseone(), "token_ids must be on an NPU device"); + TORCH_CHECK(weight.is_privateuseone(), "weight must be on an NPU device"); + TORCH_CHECK(token_ids.device() == weight.device(), + "token_ids and weight must be on the same NPU device"); + TORCH_CHECK(weight.dim() == 2, "embedding weight must be [vocab, hidden]"); + TORCH_CHECK(weight.is_contiguous(), "embedding weight must be contiguous"); + TORCH_CHECK(weight.scalar_type() == at::kBFloat16 || weight.scalar_type() == at::kFloat || + weight.scalar_type() == at::kHalf, + "embedding_ascend supports fp32, fp16, and bf16 weights"); + + const int64_t vocabSize = weight.size(0); + const int64_t hiddenSize = weight.size(1); + const int64_t numTokens = token_ids.numel(); + auto ids = token_ids.reshape({numTokens}).to(at::kLong).contiguous(); + if (numTokens > 0) { + const int64_t minId = ids.min().item(); + const int64_t maxId = ids.max().item(); + TORCH_CHECK(minId >= 0 && maxId < vocabSize, + "embedding_ascend token ids must be in [0, ", vocabSize - 1, + "], got [", minId, ", ", maxId, "]"); + } + + std::vector outSizes; + outSizes.reserve(static_cast(token_ids.dim()) + 1); + for (int64_t i = 0; i < token_ids.dim(); ++i) { + outSizes.push_back(token_ids.size(i)); + } + outSizes.push_back(hiddenSize); + + // Gather in the weight's native dtype first: the kernel is a pure byte + // copy, and upcasting bf16/fp16 rows to fp32 afterwards is exact (every + // value is representable), so this is bitwise identical to the SM90 + // kernel's in-kernel static_cast -- but the kernel surface stays a single + // native-dtype copy path. + auto outOptions = weight.options().dtype(weight.scalar_type()); + auto output = torch::empty(outSizes, outOptions); + if (numTokens == 0 || hiddenSize == 0) { + return output_fp32 ? output.to(at::kFloat) : output; + } + + // stream(true): flush the task queue before launch so the kernel cannot + // overtake earlier NPU work; outputs were allocated with at::empty (no + // queued initializer) for the same reason. + auto aclStream = c10_npu::getCurrentNPUStream().stream(true); + const uint32_t blockNum = static_cast(std::min(numTokens, MAX_BLOCKS)); + + if (weight.scalar_type() == at::kBFloat16) { + embedding_ascend_kernel_bf16<<>>( + reinterpret_cast(ids.mutable_data_ptr()), + reinterpret_cast(weight.mutable_data_ptr()), + reinterpret_cast(output.mutable_data_ptr()), + numTokens, hiddenSize); + } else if (weight.scalar_type() == at::kHalf) { + embedding_ascend_kernel_fp16<<>>( + reinterpret_cast(ids.mutable_data_ptr()), + reinterpret_cast(weight.mutable_data_ptr()), + reinterpret_cast(output.mutable_data_ptr()), + numTokens, hiddenSize); + } else { + embedding_ascend_kernel_fp32<<>>( + reinterpret_cast(ids.mutable_data_ptr()), + reinterpret_cast(weight.mutable_data_ptr()), + reinterpret_cast(output.mutable_data_ptr()), + numTokens, hiddenSize); + } + return output_fp32 ? output.to(at::kFloat) : output; +} + +// The PYBIND11_MODULE for rl_engine._C_npu lives in npu_module.cpp so that +// every Ascend op shares one compiled module. diff --git a/csrc/ascend/fused_linear_logp_ascend.asc b/csrc/ascend/fused_linear_logp_ascend.asc new file mode 100644 index 00000000..6f4f1d32 --- /dev/null +++ b/csrc/ascend/fused_linear_logp_ascend.asc @@ -0,0 +1,434 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (c) 2026 RL-Kernel Contributors +// +// Batch-invariant fused linear log-probability, Ascend C (CANN) forward +// kernel. +// +// logp[n] = log_softmax(hidden[n] @ W^T + b)[target[n]] +// +// Mirrors the SM90 CUDA fused kernel's bitwise reduction contract +// (csrc/cuda/fused_linear_logp_sm90.cu, contract v1), without materializing +// the [N, V] logits: +// - vocab rows are scanned in ascending index order (the CUDA contract's +// "cross-split ascending-index sequential chains"); +// - the softmax statistics use the online rescale chain +// newM = max(m, z); sum = sum * exp(m - newM) + exp(z - newM); +// exactly like the CUDA per-split merge; +// - each per-row dot is a fixed tile order over D with fp32 accumulation +// (per-tile ReduceSum tree + sequential scalar chain); +// - bias is added in fp32; padding lanes are -inf so exp() is exact 0; +// - final clamp logp = min(zt - lse, 0), matching the CUDA contract. +// +// The hardware reduction trees and transcendental implementations are the +// Ascend vector unit's own (fixed per D), so cross-platform bitwise parity +// with the CUDA kernel is not claimed -- the guarantee is the same one the +// CUDA kernel provides on its platform: batch-invariant determinism. +// +// Batch-invariance: every row is processed end-to-end by exactly one AI core +// block with a fixed tile size and a fixed vocab/D scan order; rows are +// strided across blocks (MAX_BLOCKS cap), so a row's logp depends only on +// (D, V), never on N or block assignment. +// +// Build: see setup.py (AscendBuildExtension, bisheng -x asc), gated by +// KERNEL_ALIGN_FORCE_ASCEND=1. Requires CANN toolkit + torch_npu. + +#include + +#include "kernel_operator.h" + +#include + +#include "torch_npu/csrc/core/npu/NPUStream.h" + +namespace { + +// Elements per hidden tile. Fixed for all rows and batch sizes; this is what +// makes the reduction order batch-invariant. UB budget (hidden row cache + +// weight tile + fp32 views + reduce scratch) stays well under the 192 KB UB +// of current SoCs for D <= TILE_LENGTH (Qwen3: D = 4096). +constexpr uint32_t TILE_LENGTH = 4096; +// Cap on launched blocks. Rows are strided across blocks, so launching fewer +// blocks than rows is fine and never changes per-row numerics. +constexpr int64_t MAX_BLOCKS = 128; +constexpr float NEG_INF = -3.402823466e+38f; // -FLT_MAX + +template +class KernelFusedLinearLogp { +public: + __aicore__ inline KernelFusedLinearLogp(AscendC::TPipe* pipe) : pipe_(pipe) {} + + __aicore__ inline void Init(GM_ADDR hidden, + GM_ADDR weight, + GM_ADDR bias, + GM_ADDR target, + GM_ADDR logp, + int64_t numRows, + int64_t vocabSize, + int64_t hiddenSize, + bool hasBias) + { + numRows_ = numRows; + vocabSize_ = vocabSize; + hiddenSize_ = hiddenSize; + hasBias_ = hasBias; + // Hidden row cache: one fp32 tile; valid only when D <= TILE_LENGTH. + cacheHidden_ = hiddenSize_ <= static_cast(TILE_LENGTH); + hiddenGm_.SetGlobalBuffer(reinterpret_cast<__gm__ T*>(hidden)); + weightGm_.SetGlobalBuffer(reinterpret_cast<__gm__ T*>(weight)); + biasGm_.SetGlobalBuffer(reinterpret_cast<__gm__ float*>(bias)); + targetGm_.SetGlobalBuffer(reinterpret_cast<__gm__ int64_t*>(target)); + logpGm_.SetGlobalBuffer(reinterpret_cast<__gm__ float*>(logp)); + pipe_->InitBuffer(wQueue_, 1, TILE_LENGTH * sizeof(T)); + pipe_->InitBuffer(hFp32Buf_, TILE_LENGTH * sizeof(float)); + pipe_->InitBuffer(wFp32Buf_, TILE_LENGTH * sizeof(float)); + pipe_->InitBuffer(prodBuf_, TILE_LENGTH * sizeof(float)); + pipe_->InitBuffer(reduceBuf_, TILE_LENGTH * sizeof(float)); + // 64 B: floats [0,8) for reduce/log scratch, floats [8,16) for the + // output staging slots (both halves 32-byte aligned for DataCopyPad). + pipe_->InitBuffer(scalarBuf_, 64); + // 32 B windows for scalar reads via DataCopyPad (GM scalar + // GetValue/SetValue are unreliable on hardware; see cannbot + // ascendc-precision-debug common-traps). + pipe_->InitBuffer(targetBuf_, 32); + pipe_->InitBuffer(biasBuf_, 32); + + // Intra-core pipeline events. NOTE: AscendC::SyncAll() is a cross-core + // barrier and deadlocks when more blocks are launched than there are + // physical cores (resident blocks wait for unscheduled ones), so all + // synchronization here uses per-pipe SetFlag/WaitFlag instead. + eventVS_ = pipe_->FetchEventID(AscendC::HardEvent::V_S); + eventSV_ = pipe_->FetchEventID(AscendC::HardEvent::S_V); + eventMTE2V_ = pipe_->FetchEventID(AscendC::HardEvent::MTE2_V); + eventMTE2S_ = pipe_->FetchEventID(AscendC::HardEvent::MTE2_S); + eventSMTE3_ = pipe_->FetchEventID(AscendC::HardEvent::S_MTE3); + eventMTE3S_ = pipe_->FetchEventID(AscendC::HardEvent::MTE3_S); + } + + __aicore__ inline void Process() + { + for (int64_t row = AscendC::GetBlockIdx(); row < numRows_; + row += AscendC::GetBlockNum()) { + ProcessRow(row); + } + } + +private: + // Load one tile into the queue and return its fp32 view (cast when T is + // not fp32; in-place otherwise). + __aicore__ inline AscendC::LocalTensor LoadTileFp32(AscendC::GlobalTensor gm, + int64_t offset, + uint32_t count, + AscendC::LocalTensor fp32View) + { + AscendC::LocalTensor tile = wQueue_.AllocTensor(); + AscendC::DataCopyExtParams copyParams{ + 1, static_cast(count * sizeof(T)), 0, 0, 0}; + AscendC::DataCopyPadExtParams padParams{false, 0, 0, 0}; + AscendC::DataCopyPad(tile, gm[offset], copyParams, padParams); + wQueue_.EnQue(tile); + tile = wQueue_.DeQue(); + AscendC::SetFlag(eventMTE2V_); + AscendC::WaitFlag(eventMTE2V_); + if constexpr (std::is_same_v) { + AscendC::DataCopy(fp32View, tile, VecAlignCount(count)); + } else { + AscendC::Cast(fp32View, tile, AscendC::RoundMode::CAST_NONE, count); + } + wQueue_.FreeTensor(tile); + return fp32View; + } + + // fp32 dot of the hidden row and weight row over one D tile. + __aicore__ inline float DotTile(AscendC::LocalTensor hTile, + AscendC::LocalTensor wTile, + uint32_t count) + { + AscendC::LocalTensor prod = prodBuf_.Get(); + AscendC::Mul(prod, hTile, wTile, count); + AscendC::LocalTensor rTmp = reduceBuf_.Get(); + AscendC::LocalTensor scalar = scalarBuf_.Get(); + AscendC::ReduceSum(scalar, prod, rTmp, static_cast(count)); + WaitVector(); // vector -> scalar read + return scalar.GetValue(0); + } + + // Per-vocab-row dot over D with a fixed tile order. + __aicore__ inline float DotRow(int64_t row, int64_t col) + { + const int64_t tileCount = (hiddenSize_ + TILE_LENGTH - 1) / TILE_LENGTH; + AscendC::LocalTensor hFp32 = hFp32Buf_.Get(); + float acc = 0.0f; + for (int64_t tile = 0; tile < tileCount; ++tile) { + const int64_t start = tile * TILE_LENGTH; + const uint32_t count = TileCount(start); + AscendC::LocalTensor wFp32 = wFp32Buf_.Get(); + if (!(cacheHidden_ && tile == 0)) { + // Reload the hidden tile when D > TILE_LENGTH (or on demand). + LoadTileFp32(hiddenGm_, row * hiddenSize_ + start, count, hFp32); + } + LoadTileFp32(weightGm_, col * hiddenSize_ + start, count, wFp32); + acc += DotTile(hFp32, wFp32, count); + } + return acc; + } + + __aicore__ inline void ProcessRow(int64_t row) + { + const int64_t target = LoadTarget(row); + const bool valid = target >= 0 && target < vocabSize_; + float m = NEG_INF; + float sumExp = 0.0f; + float zt = 0.0f; + + // Cache the hidden row (D <= TILE_LENGTH fast path). + if (cacheHidden_) { + LoadTileFp32(hiddenGm_, row * hiddenSize_, TileCount(0), hFp32Buf_.Get()); + } + + // Vocab rows in ascending index order, online rescale chain. + for (int64_t v = 0; v < vocabSize_; ++v) { + float z = DotRow(row, v); + if (hasBias_) { + z += LoadBias(v); + } + if (v == target) { + zt = z; + } + const float newM = z > m ? z : m; + // Two scalar exps per vocab row via one padded vector Exp. + AscendC::LocalTensor scalar = scalarBuf_.Get(); + scalar.SetValue(0, m - newM); + scalar.SetValue(1, z - newM); + AscendC::SetFlag(eventSV_); + AscendC::WaitFlag(eventSV_); + AscendC::Exp(scalar, scalar, 8); + WaitVector(); // vector -> scalar read + sumExp = sumExp * scalar.GetValue(0) + scalar.GetValue(1); + m = newM; + } + + // lse = m + log(sumExp) via a padded 1-element vector Log. + AscendC::LocalTensor scalar = scalarBuf_.Get(); + scalar.SetValue(0, sumExp); + AscendC::SetFlag(eventSV_); + AscendC::WaitFlag(eventSV_); + AscendC::Log(scalar, scalar, 8); + WaitVector(); // vector -> scalar read + const float lse = m + scalar.GetValue(0); + + // Stage the output in UB, then DataCopyPad to GM. GlobalTensor.SetValue + // is unreliable on hardware (cannbot ascendc-precision-debug + // common-traps), so scalar GM stores are not used. Out-of-range + // targets produce 0.0; the final clamp matches the CUDA contract. + float logp = 0.0f; + if (valid) { + logp = zt - lse; + logp = logp < 0.0f ? logp : 0.0f; + } + scalar.SetValue(0, logp); + AscendC::SetFlag(eventSMTE3_); + AscendC::WaitFlag(eventSMTE3_); + AscendC::DataCopyExtParams outParams{1, sizeof(float), 0, 0, 0}; + AscendC::DataCopyPad(logpGm_[row], scalar[0], outParams); + // Drain MTE3 before the next row stages new values into scalarBuf_. + AscendC::SetFlag(eventMTE3S_); + AscendC::WaitFlag(eventMTE3S_); + } + + // Wait until all outstanding vector-pipe results are readable as scalars. + __aicore__ inline void WaitVector() + { + AscendC::SetFlag(eventVS_); + AscendC::WaitFlag(eventVS_); + } + + // Read target[row] through a 32-byte-aligned DataCopyPad window. + __aicore__ inline int64_t LoadTarget(int64_t row) + { + const int64_t alignedRow = row & ~3LL; // 4 x int64 per 32 B + const int64_t remaining = numRows_ - alignedRow; + const uint32_t winCount = static_cast(remaining < 4 ? remaining : 4); + AscendC::LocalTensor tLocal = targetBuf_.Get(); + AscendC::DataCopyExtParams copyParams{ + 1, static_cast(winCount * sizeof(int64_t)), 0, 0, 0}; + AscendC::DataCopyPadExtParams padParams{false, 0, 0, 0}; + AscendC::DataCopyPad(tLocal, targetGm_[alignedRow], copyParams, padParams); + AscendC::SetFlag(eventMTE2S_); // copy-in -> scalar read + AscendC::WaitFlag(eventMTE2S_); + return static_cast(tLocal.GetValue(static_cast(row - alignedRow))); + } + + // Read bias[col] through a 32-byte-aligned DataCopyPad window. + __aicore__ inline float LoadBias(int64_t col) + { + const int64_t alignedCol = col & ~7LL; // 8 x fp32 per 32 B + const int64_t remaining = vocabSize_ - alignedCol; + const uint32_t winCount = static_cast(remaining < 8 ? remaining : 8); + AscendC::LocalTensor bLocal = biasBuf_.Get(); + AscendC::DataCopyExtParams copyParams{ + 1, static_cast(winCount * sizeof(float)), 0, 0, 0}; + AscendC::DataCopyPadExtParams padParams{false, 0, 0, 0}; + AscendC::DataCopyPad(bLocal, biasGm_[alignedCol], copyParams, padParams); + AscendC::SetFlag(eventMTE2S_); // copy-in -> scalar read + AscendC::WaitFlag(eventMTE2S_); + return bLocal.GetValue(static_cast(col - alignedCol)); + } + + __aicore__ inline uint32_t TileCount(int64_t start) const + { + const int64_t remaining = hiddenSize_ - start; + return static_cast(remaining < TILE_LENGTH ? remaining : TILE_LENGTH); + } + + // Round an element count up to a 32 B boundary (vector-pipe minimum). + __aicore__ inline uint32_t VecAlignCount(uint32_t count) const + { + constexpr uint32_t elemsPer32B = 32 / sizeof(T); + return (count + elemsPer32B - 1) / elemsPer32B * elemsPer32B; + } + + AscendC::TPipe* pipe_; + AscendC::GlobalTensor hiddenGm_; + AscendC::GlobalTensor weightGm_; + AscendC::GlobalTensor biasGm_; + AscendC::GlobalTensor targetGm_; + AscendC::GlobalTensor logpGm_; + AscendC::TQue wQueue_; + AscendC::TBuf hFp32Buf_; + AscendC::TBuf wFp32Buf_; + AscendC::TBuf prodBuf_; + AscendC::TBuf reduceBuf_; + AscendC::TBuf scalarBuf_; + AscendC::TBuf targetBuf_; + AscendC::TBuf biasBuf_; + AscendC::TEventID eventVS_; + AscendC::TEventID eventSV_; + AscendC::TEventID eventMTE2V_; + AscendC::TEventID eventMTE2S_; + AscendC::TEventID eventSMTE3_; + AscendC::TEventID eventMTE3S_; + int64_t numRows_; + int64_t vocabSize_; + int64_t hiddenSize_; + bool hasBias_; + bool cacheHidden_; +}; + +} // namespace + +extern "C" __global__ __vector__ void fused_linear_logp_ascend_kernel_fp32( + GM_ADDR hidden, GM_ADDR weight, GM_ADDR bias, GM_ADDR target, GM_ADDR logp, + int64_t numRows, int64_t vocabSize, int64_t hiddenSize, bool hasBias) +{ + AscendC::TPipe pipe; + KernelFusedLinearLogp op(&pipe); + op.Init(hidden, weight, bias, target, logp, numRows, vocabSize, hiddenSize, hasBias); + op.Process(); +} + +extern "C" __global__ __vector__ void fused_linear_logp_ascend_kernel_bf16( + GM_ADDR hidden, GM_ADDR weight, GM_ADDR bias, GM_ADDR target, GM_ADDR logp, + int64_t numRows, int64_t vocabSize, int64_t hiddenSize, bool hasBias) +{ + AscendC::TPipe pipe; + KernelFusedLinearLogp op(&pipe); + op.Init(hidden, weight, bias, target, logp, numRows, vocabSize, hiddenSize, hasBias); + op.Process(); +} + +extern "C" __global__ __vector__ void fused_linear_logp_ascend_kernel_fp16( + GM_ADDR hidden, GM_ADDR weight, GM_ADDR bias, GM_ADDR target, GM_ADDR logp, + int64_t numRows, int64_t vocabSize, int64_t hiddenSize, bool hasBias) +{ + AscendC::TPipe pipe; + KernelFusedLinearLogp op(&pipe); + op.Init(hidden, weight, bias, target, logp, numRows, vocabSize, hiddenSize, hasBias); + op.Process(); +} + +torch::Tensor fused_linear_logp_ascend_forward(torch::Tensor hidden, + torch::Tensor weight, + torch::optional bias, + torch::Tensor target) +{ + TORCH_CHECK(hidden.is_privateuseone(), "hidden must be on an NPU device"); + TORCH_CHECK(weight.is_privateuseone(), "lm_head_weight must be on an NPU device"); + TORCH_CHECK(hidden.device() == weight.device(), + "hidden and lm_head_weight must be on the same NPU device"); + TORCH_CHECK(hidden.dim() == 2, "hidden must be 2-D [N, D]"); + TORCH_CHECK(weight.dim() == 2, "lm_head_weight must be 2-D [V, D]"); + TORCH_CHECK(hidden.size(-1) == weight.size(1), "hidden/weight hidden-dim mismatch"); + TORCH_CHECK(hidden.scalar_type() == at::kFloat || hidden.scalar_type() == at::kHalf || + hidden.scalar_type() == at::kBFloat16, + "fused_linear_logp_ascend supports fp32, fp16, and bf16 hidden states"); + TORCH_CHECK(weight.scalar_type() == hidden.scalar_type(), + "fused_linear_logp_ascend requires weight to match the hidden dtype"); + TORCH_CHECK(hidden.size(-1) > 0, "hidden dimension must be positive"); + TORCH_CHECK(target.is_privateuseone(), "target must be on the same NPU device as hidden"); + TORCH_CHECK(target.dim() == 1, "target must be 1-D [N]"); + TORCH_CHECK(target.scalar_type() == at::kLong, "target must be int64"); + TORCH_CHECK(target.numel() == hidden.size(0), "target must have one entry per row"); + + const int64_t numRows = hidden.size(0); + const int64_t hiddenSize = hidden.size(1); + const int64_t vocabSize = weight.size(0); + + torch::Tensor biasF; + uint8_t* biasPtr = nullptr; + bool hasBias = false; + if (bias.has_value()) { + TORCH_CHECK(bias->is_privateuseone(), "bias must be on an NPU device"); + TORCH_CHECK(bias->device() == hidden.device(), "bias must be on the same NPU device"); + TORCH_CHECK(bias->dim() == 1 && bias->numel() == vocabSize, + "bias must be 1-D [V]"); + biasF = bias->reshape({vocabSize}).to(at::kFloat).contiguous(); + biasPtr = reinterpret_cast(biasF.mutable_data_ptr()); + hasBias = true; + } + + // fp32 output, matching the gold reference's contract. + torch::Tensor logp = at::empty({numRows}, hidden.options().dtype(at::kFloat)); + if (numRows == 0 || vocabSize == 0) { + return logp; + } + + auto hiddenContig = hidden.contiguous(); + auto weightContig = weight.contiguous(); + auto targetContig = target.contiguous(); + + // stream(true): flush the task queue before launch so the kernel cannot + // overtake earlier NPU work; outputs were allocated with at::empty (no + // queued initializer) for the same reason. + auto aclStream = c10_npu::getCurrentNPUStream().stream(true); + const uint32_t blockNum = static_cast(std::min(numRows, MAX_BLOCKS)); + + if (hidden.scalar_type() == at::kBFloat16) { + fused_linear_logp_ascend_kernel_bf16<<>>( + reinterpret_cast(hiddenContig.mutable_data_ptr()), + reinterpret_cast(weightContig.mutable_data_ptr()), + biasPtr, + reinterpret_cast(targetContig.mutable_data_ptr()), + reinterpret_cast(logp.mutable_data_ptr()), + numRows, vocabSize, hiddenSize, hasBias); + } else if (hidden.scalar_type() == at::kHalf) { + fused_linear_logp_ascend_kernel_fp16<<>>( + reinterpret_cast(hiddenContig.mutable_data_ptr()), + reinterpret_cast(weightContig.mutable_data_ptr()), + biasPtr, + reinterpret_cast(targetContig.mutable_data_ptr()), + reinterpret_cast(logp.mutable_data_ptr()), + numRows, vocabSize, hiddenSize, hasBias); + } else { + fused_linear_logp_ascend_kernel_fp32<<>>( + reinterpret_cast(hiddenContig.mutable_data_ptr()), + reinterpret_cast(weightContig.mutable_data_ptr()), + biasPtr, + reinterpret_cast(targetContig.mutable_data_ptr()), + reinterpret_cast(logp.mutable_data_ptr()), + numRows, vocabSize, hiddenSize, hasBias); + } + return logp; +} + +// The PYBIND11_MODULE for rl_engine._C_npu lives in npu_module.cpp so that +// every Ascend op shares one compiled module. diff --git a/csrc/ascend/fused_logp_ascend.asc b/csrc/ascend/fused_logp_ascend.asc new file mode 100644 index 00000000..0b4c1aec --- /dev/null +++ b/csrc/ascend/fused_logp_ascend.asc @@ -0,0 +1,332 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (c) 2026 RL-Kernel Contributors +// +// Batch-invariant fused selected-token log-probability, Ascend C (CANN) +// forward kernel. +// +// logp[n] = logits[n, target[n]] - logsumexp(logits[n, :]) +// +// Mirrors the deterministic CUDA kernel in csrc/deterministic_logp_kernel.cu +// (DeterministicLogpCUDAOp): +// - input : logits [N, V] contiguous, bf16 / fp16 / fp32; target [N] +// int64, one per row +// - output : logp [N] fp32 (the CUDA deterministic op always returns fp32) +// - target[n] outside [0, V) -> logp[n] = 0.0 (same as the CUDA kernel) +// +// The math follows the same two-pass fixed-order reduction as the CUDA +// kernel: row max over a fixed tile order, then sum(exp(x - max)) over the +// same fixed tile order, lse = max + log(sum), logp = selected - lse. The +// fp32 accumulation and the formula match the CUDA kernel exactly; the +// hardware reduction trees and the transcendental implementations are the +// Ascend vector unit's own (fixed per V), so cross-platform bitwise parity +// with the CUDA kernel is not claimed -- the guarantee here is the same one +// the CUDA kernel provides on its platform: batch-invariant determinism. +// +// Batch-invariance: every row is processed end-to-end by exactly one AI core +// block with a fixed tile size and a fixed reduction order. The instruction +// sequence for a row depends only on V, never on N or on the block the row +// happens to land on, so a row's output is bitwise identical across batch +// sizes, row positions, and block assignments on the NPU. +// +// Build: see setup.py (AscendBuildExtension, bisheng -x asc), gated by +// KERNEL_ALIGN_FORCE_ASCEND=1. Requires CANN toolkit + torch_npu. + +#include + +#include "kernel_operator.h" + +#include + +#include "torch_npu/csrc/core/npu/NPUStream.h" + +namespace { + +// Elements per vocab tile. Fixed for all rows and batch sizes; this is what +// makes the reduction order batch-invariant. UB budget (input tile + fp32 +// tile + reduce scratch) stays well under the 192 KB UB of current SoCs. +constexpr uint32_t TILE_LENGTH = 4096; +// Cap on launched blocks. Rows are strided across blocks, so launching fewer +// blocks than rows is fine and never changes per-row numerics. +constexpr int64_t MAX_BLOCKS = 128; +constexpr float NEG_INF = -3.402823466e+38f; // -FLT_MAX + +template +class KernelFusedLogp { +public: + __aicore__ inline KernelFusedLogp(AscendC::TPipe* pipe) : pipe_(pipe) {} + + __aicore__ inline void Init(GM_ADDR logits, + GM_ADDR target, + GM_ADDR logp, + int64_t numRows, + int64_t vocabSize) + { + numRows_ = numRows; + vocabSize_ = vocabSize; + logitsGm_.SetGlobalBuffer(reinterpret_cast<__gm__ T*>(logits)); + targetGm_.SetGlobalBuffer(reinterpret_cast<__gm__ int64_t*>(target)); + logpGm_.SetGlobalBuffer(reinterpret_cast<__gm__ float*>(logp)); + pipe_->InitBuffer(inQueue_, 1, TILE_LENGTH * sizeof(T)); + pipe_->InitBuffer(fp32Buf_, TILE_LENGTH * sizeof(float)); + pipe_->InitBuffer(reduceBuf_, TILE_LENGTH * sizeof(float)); + // 64 B: floats [0,8) for reduce/log scratch, floats [8,16) for the + // output staging slots (both halves 32-byte aligned for DataCopyPad). + pipe_->InitBuffer(scalarBuf_, 64); + // 32 B window for reading target[row] via DataCopyPad (GM scalar + // GetValue/SetValue are unreliable on hardware; see cannbot + // ascendc-precision-debug common-traps). + pipe_->InitBuffer(targetBuf_, 32); + + // Intra-core pipeline events. NOTE: AscendC::SyncAll() is a cross-core + // barrier and deadlocks when more blocks are launched than there are + // physical cores (resident blocks wait for unscheduled ones), so all + // synchronization here uses per-pipe SetFlag/WaitFlag instead. + eventVS_ = pipe_->FetchEventID(AscendC::HardEvent::V_S); + eventSV_ = pipe_->FetchEventID(AscendC::HardEvent::S_V); + eventMTE2S_ = pipe_->FetchEventID(AscendC::HardEvent::MTE2_S); + eventSMTE3_ = pipe_->FetchEventID(AscendC::HardEvent::S_MTE3); + eventMTE3S_ = pipe_->FetchEventID(AscendC::HardEvent::MTE3_S); + } + + __aicore__ inline void Process() + { + for (int64_t row = AscendC::GetBlockIdx(); row < numRows_; + row += AscendC::GetBlockNum()) { + ProcessRow(row); + } + } + +private: + // Load one vocab tile into UB and return its fp32 view. When T is fp32 the + // queue buffer is used in place; otherwise the tile is cast into fp32Buf_. + __aicore__ inline AscendC::LocalTensor LoadTileFp32(int64_t row, + int64_t start, + uint32_t count) + { + AscendC::LocalTensor xLocal = inQueue_.AllocTensor(); + AscendC::DataCopyExtParams copyParams{ + 1, static_cast(count * sizeof(T)), 0, 0, 0}; + AscendC::DataCopyPadExtParams padParams{false, 0, 0, 0}; + AscendC::DataCopyPad(xLocal, logitsGm_[row * vocabSize_ + start], copyParams, padParams); + inQueue_.EnQue(xLocal); + xLocal = inQueue_.DeQue(); + inTile_ = xLocal; + + if constexpr (std::is_same_v) { + return xLocal; + } else { + AscendC::LocalTensor fLocal = fp32Buf_.Get(); + AscendC::Cast(fLocal, xLocal, AscendC::RoundMode::CAST_NONE, count); + return fLocal; + } + } + + // Release the queue-owned input buffer of the current tile. + __aicore__ inline void FreeTile() + { + inQueue_.FreeTensor(inTile_); + } + + __aicore__ inline void ProcessRow(int64_t row) + { + const int64_t target = LoadTarget(row); + const bool valid = target >= 0 && target < vocabSize_; + const int64_t tileCount = (vocabSize_ + TILE_LENGTH - 1) / TILE_LENGTH; + float selected = 0.0f; + + // Pass 1: row max (fixed tile order). Also grab logits[target] on the fly. + float rowMax = NEG_INF; + for (int64_t tile = 0; tile < tileCount; ++tile) { + const int64_t start = tile * TILE_LENGTH; + const uint32_t count = TileCount(start); + AscendC::LocalTensor fLocal = LoadTileFp32(row, start, count); + + AscendC::LocalTensor rTmp = reduceBuf_.Get(); + AscendC::LocalTensor scalar = scalarBuf_.Get(); + AscendC::ReduceMax(scalar, fLocal, rTmp, static_cast(count), false); + WaitVector(); // vector -> scalar read + const float tileMax = scalar.GetValue(0); + rowMax = tileMax > rowMax ? tileMax : rowMax; + + if (valid && target >= start && target < start + count) { + selected = fLocal.GetValue(static_cast(target - start)); + } + FreeTile(); + } + + // Pass 2: sum(exp(x - rowMax)) with the same fixed tile order. + float sumExp = 0.0f; + for (int64_t tile = 0; tile < tileCount; ++tile) { + const int64_t start = tile * TILE_LENGTH; + const uint32_t count = TileCount(start); + AscendC::LocalTensor fLocal = LoadTileFp32(row, start, count); + + AscendC::Adds(fLocal, fLocal, -rowMax, count); // x - rowMax + AscendC::Exp(fLocal, fLocal, count); + AscendC::LocalTensor rTmp = reduceBuf_.Get(); + AscendC::LocalTensor scalar = scalarBuf_.Get(); + AscendC::ReduceSum(scalar, fLocal, rTmp, static_cast(count)); + WaitVector(); // vector -> scalar read + sumExp += scalar.GetValue(0); + FreeTile(); + } + + // lse = rowMax + log(sumExp). The scalar unit has no log, so run a + // 1-element vector Log (count padded to 8; scalarBuf_ is 32 B aligned). + AscendC::LocalTensor scalar = scalarBuf_.Get(); + scalar.SetValue(0, sumExp); + AscendC::SetFlag(eventSV_); // scalar write -> vector op + AscendC::WaitFlag(eventSV_); + AscendC::Log(scalar, scalar, 8); + WaitVector(); // vector -> scalar read + const float lse = rowMax + scalar.GetValue(0); + + // Stage the output in UB, then DataCopyPad to GM. GlobalTensor.SetValue + // is unreliable on hardware (cannbot ascendc-precision-debug + // common-traps), so scalar GM stores are not used. Out-of-range + // targets produce 0.0, matching the CUDA deterministic kernel. + scalar.SetValue(0, valid ? (selected - lse) : 0.0f); + AscendC::SetFlag(eventSMTE3_); // scalar write -> copy-out + AscendC::WaitFlag(eventSMTE3_); + AscendC::DataCopyExtParams outParams{1, sizeof(float), 0, 0, 0}; + AscendC::DataCopyPad(logpGm_[row], scalar[0], outParams); + // Drain MTE3 before the next row stages new values into scalarBuf_. + AscendC::SetFlag(eventMTE3S_); + AscendC::WaitFlag(eventMTE3S_); + } + + // Wait until all outstanding vector-pipe results are readable as scalars. + __aicore__ inline void WaitVector() + { + AscendC::SetFlag(eventVS_); + AscendC::WaitFlag(eventVS_); + } + + // Read target[row] through a 32-byte-aligned DataCopyPad window. + __aicore__ inline int64_t LoadTarget(int64_t row) + { + const int64_t alignedRow = row & ~3LL; // 4 x int64 per 32 B + const int64_t remaining = numRows_ - alignedRow; + const uint32_t winCount = static_cast(remaining < 4 ? remaining : 4); + AscendC::LocalTensor tLocal = targetBuf_.Get(); + AscendC::DataCopyExtParams copyParams{ + 1, static_cast(winCount * sizeof(int64_t)), 0, 0, 0}; + AscendC::DataCopyPadExtParams padParams{false, 0, 0, 0}; + AscendC::DataCopyPad(tLocal, targetGm_[alignedRow], copyParams, padParams); + AscendC::SetFlag(eventMTE2S_); // copy-in -> scalar read + AscendC::WaitFlag(eventMTE2S_); + return static_cast(tLocal.GetValue(static_cast(row - alignedRow))); + } + + __aicore__ inline uint32_t TileCount(int64_t start) const + { + const int64_t remaining = vocabSize_ - start; + return static_cast(remaining < TILE_LENGTH ? remaining : TILE_LENGTH); + } + + AscendC::TPipe* pipe_; + AscendC::GlobalTensor logitsGm_; + AscendC::GlobalTensor targetGm_; + AscendC::GlobalTensor logpGm_; + AscendC::TQue inQueue_; + AscendC::TBuf fp32Buf_; + AscendC::TBuf reduceBuf_; + AscendC::TBuf scalarBuf_; + AscendC::TBuf targetBuf_; + AscendC::LocalTensor inTile_; + AscendC::TEventID eventVS_; + AscendC::TEventID eventSV_; + AscendC::TEventID eventMTE2S_; + AscendC::TEventID eventSMTE3_; + AscendC::TEventID eventMTE3S_; + int64_t numRows_; + int64_t vocabSize_; +}; + +} // namespace + +extern "C" __global__ __vector__ void fused_logp_ascend_kernel_fp32( + GM_ADDR logits, GM_ADDR target, GM_ADDR logp, + int64_t numRows, int64_t vocabSize) +{ + AscendC::TPipe pipe; + KernelFusedLogp op(&pipe); + op.Init(logits, target, logp, numRows, vocabSize); + op.Process(); +} + +extern "C" __global__ __vector__ void fused_logp_ascend_kernel_bf16( + GM_ADDR logits, GM_ADDR target, GM_ADDR logp, + int64_t numRows, int64_t vocabSize) +{ + AscendC::TPipe pipe; + KernelFusedLogp op(&pipe); + op.Init(logits, target, logp, numRows, vocabSize); + op.Process(); +} + +extern "C" __global__ __vector__ void fused_logp_ascend_kernel_fp16( + GM_ADDR logits, GM_ADDR target, GM_ADDR logp, + int64_t numRows, int64_t vocabSize) +{ + AscendC::TPipe pipe; + KernelFusedLogp op(&pipe); + op.Init(logits, target, logp, numRows, vocabSize); + op.Process(); +} + +torch::Tensor fused_logp_ascend_forward(torch::Tensor logits, torch::Tensor target) +{ + TORCH_CHECK(logits.is_privateuseone(), "logits must be on an NPU device"); + TORCH_CHECK(logits.dim() == 2, "logits must be 2-D [N, V]"); + TORCH_CHECK(logits.is_contiguous(), "logits must be contiguous"); + TORCH_CHECK(logits.scalar_type() == at::kBFloat16 || logits.scalar_type() == at::kFloat || + logits.scalar_type() == at::kHalf, + "fused_logp_ascend supports fp32, fp16, and bf16 logits"); + TORCH_CHECK(logits.size(-1) > 0, "vocab size must be positive"); + TORCH_CHECK(target.is_privateuseone(), "target must be on the same NPU device as logits"); + TORCH_CHECK(target.dim() == 1, "target must be 1-D [N]"); + TORCH_CHECK(target.scalar_type() == at::kLong, "target must be int64"); + TORCH_CHECK(target.numel() == logits.size(0), "target must have one entry per row"); + + const int64_t numRows = logits.size(0); + const int64_t vocabSize = logits.size(1); + + // fp32 output, matching the CUDA deterministic logp op's contract. + torch::Tensor logp = at::empty({numRows}, logits.options().dtype(at::kFloat)); + if (numRows == 0) { + return logp; + } + + torch::Tensor targetContig = target.contiguous(); + + // stream(true): flush the task queue before launch so the kernel cannot + // overtake earlier NPU work; outputs were allocated with at::empty (no + // queued initializer) for the same reason. + auto aclStream = c10_npu::getCurrentNPUStream().stream(true); + const uint32_t blockNum = static_cast(std::min(numRows, MAX_BLOCKS)); + + if (logits.scalar_type() == at::kBFloat16) { + fused_logp_ascend_kernel_bf16<<>>( + reinterpret_cast(logits.mutable_data_ptr()), + reinterpret_cast(targetContig.mutable_data_ptr()), + reinterpret_cast(logp.mutable_data_ptr()), + numRows, vocabSize); + } else if (logits.scalar_type() == at::kHalf) { + fused_logp_ascend_kernel_fp16<<>>( + reinterpret_cast(logits.mutable_data_ptr()), + reinterpret_cast(targetContig.mutable_data_ptr()), + reinterpret_cast(logp.mutable_data_ptr()), + numRows, vocabSize); + } else { + fused_logp_ascend_kernel_fp32<<>>( + reinterpret_cast(logits.mutable_data_ptr()), + reinterpret_cast(targetContig.mutable_data_ptr()), + reinterpret_cast(logp.mutable_data_ptr()), + numRows, vocabSize); + } + return logp; +} + +// The PYBIND11_MODULE for rl_engine._C_npu lives in npu_module.cpp so that +// every Ascend op shares one compiled module. diff --git a/csrc/ascend/gemm/det_gemm_ascend.asc b/csrc/ascend/gemm/det_gemm_ascend.asc new file mode 100644 index 00000000..935b0820 --- /dev/null +++ b/csrc/ascend/gemm/det_gemm_ascend.asc @@ -0,0 +1,610 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (c) 2026 RL-Kernel Contributors +// +// Batch-invariant deterministic GEMM, Ascend C (CANN) kernel. +// +// Mirrors the CUDA kernel in csrc/cuda/gemm/det_gemm_kernel.cu: +// - C[m,n] = sum_r lhs[m,r] * rhs(r,n), BF16 in / FP32 accumulation / +// BF16 out (or FP32 out when outFp32), no split-K. +// - The reduction over R follows the CUDA mid-split K tree exactly: +// 32-element leaves are summed in FP32 in ascending order, rounded once +// to BF16 (round-to-nearest-even), and merged pairwise by a mid-split +// binary tree of BF16 adds (each add done in FP32 with one rounding). +// A contiguous half-R GEMM is one child of the tree, so simulated TP=2 +// (a + b) matches TP=1, the same contract as the CUDA/Triton kernels. +// - Every output row-tile is reduced end-to-end by exactly one AI-core +// block, with a fixed ascending leaf order, so per-element numerics are +// batch-invariant: they depend only on R, never on M, the block the tile +// lands on, or how many blocks were launched (tiles are strided across +// blocks under a MAX_BLOCKS cap). +// +// The Ascend vector unit's fixed 32-lane MAC order inside a leaf differs from +// CUDA's sequential leaf accumulation, so cross-platform bitwise parity with +// the CUDA kernel is not claimed -- the guarantee is the same one the CUDA +// kernel provides on its platform: batch-invariant determinism and the +// contiguous-half-K TP contract. +// +// DataCopyPad hardware semantics (validated on CANN 9.0.0, Atlas A2): +// - blockLen is the VALID byte count per block, excluding padding. +// - leftPadding/rightPadding are ELEMENT counts of the tensor type, and +// each may cover at most 32 bytes (16 BF16 elements). +// - srcStride is the GM GAP in bytes after each block; dstStride is the +// UB GAP in 32-byte units. A contiguous tile uses 0/0, NOT the block +// length (that misreading overruns UB at tile 25+ and raises the +// "MTE write address out of range" vector-core exception). +// - MTE2 destinations sit in VECIN-position buffers, MTE3 sources in +// VECOUT-position buffers, matching the API contract. +// - Cast fp32->fp32 with CAST_NONE emits NO instruction on A2; same-type +// copies use AscendC::Copy instead. +// +// Validated on device: tests/test_det_gemm_ascend.py (tree-reference +// correctness, batch and TP-shard bitwise invariance, backward correctness +// and layout contracts) plus R={1,15,16,17,31,32,33,63,64,65,96,32768}, +// N={1,15,16,17,127,128,129,257} for both RHS layouts and output layouts; +// fwd(A,B) == fwd_rhs_transposed(A,B.t()) and db_transposed == db.t() +// bitwise. The device output equals an independent exact simulation of the +// leaf-space tree (ascending FP32 leaf sums, BF16 RNE at every node). +// +// Entry points mirror the CUDA surface 1:1: +// fwd: C = A @ B | da: dA = dC @ B^T | db: dB = A^T @ dC +// db_transposed stores dB born contiguous in the canonical [N,K] layout. +// +// Build: see setup.py (AscendBuildExtension, bisheng -x asc), gated by +// KERNEL_ALIGN_FORCE_ASCEND=1. Requires CANN toolkit + torch_npu. +// +// Entry points mirror the CUDA surface 1:1: +// fwd: C = A @ B | da: dA = dC @ B^T | db: dB = A^T @ dC +// db_transposed stores dB born contiguous in the canonical [N,K] layout. +// +// Build: see setup.py (AscendBuildExtension, bisheng -x asc), gated by +// KERNEL_ALIGN_FORCE_ASCEND=1. Requires CANN toolkit + torch_npu. + +#include +#include +#include + +#include "kernel_operator.h" + +#include + +#include "torch_npu/csrc/core/npu/NPUStream.h" + +namespace { + +// Elements per reduction leaf. Must match K_TREE_LEAF of the CUDA kernel so +// an aligned-R tree equals the CUDA tile tree structure. +constexpr uint32_t TREE_LEAF = 32; +// Output columns per block iteration. +constexpr uint32_t N_TILE = 128; +// Live stack levels of the online mid-split tree. The training contract caps +// a rank's GEMM reduction at 32768, so ceil(log2(32768 / 32)) = 10 levels +// cover every configured shape (mirrors TREE_DEPTH of the CUDA kernel). +constexpr uint32_t TREE_DEPTH = 10; +// Cap on launched blocks. Tiles are strided across blocks, so launching fewer +// blocks than tiles is fine and never changes per-element numerics. +constexpr int64_t MAX_BLOCKS = 128; + +class KernelDetGemm { +public: + __aicore__ inline KernelDetGemm(AscendC::TPipe* pipe) : pipe_(pipe) {} + + __aicore__ inline void Init(GM_ADDR lhs, + GM_ADDR rhs, + GM_ADDR out, + int64_t M, + int64_t N, + int64_t R, + int32_t rhsTransposed, + int32_t outTransposed, + int32_t outFp32) + { + M_ = M; + N_ = N; + R_ = R; + rhsTransposed_ = rhsTransposed != 0; + outTransposed_ = outTransposed != 0; + outFp32_ = outFp32 != 0; + lhsGm_.SetGlobalBuffer(reinterpret_cast<__gm__ bfloat16_t*>(lhs)); + rhsGm_.SetGlobalBuffer(reinterpret_cast<__gm__ bfloat16_t*>(rhs)); + outGm_.SetGlobalBuffer(reinterpret_cast<__gm__ uint8_t*>(out)); + + // Static payload: 44480 B (all allocations are 32 B aligned). + // Includes 512 B NK gather offsets, 8 KiB output gather offsets, + // and 4 KiB aligned slots for transposed output. + pipe_->InitBuffer(aBufT_, TREE_LEAF * sizeof(bfloat16_t)); + pipe_->InitBuffer(aBufF_, TREE_LEAF * sizeof(float)); + pipe_->InitBuffer(rhsBufT_, TREE_LEAF * N_TILE * sizeof(bfloat16_t)); + pipe_->InitBuffer(rhsBufF_, TREE_LEAF * N_TILE * sizeof(float)); + pipe_->InitBuffer(prodBufF_, N_TILE * sizeof(float)); + pipe_->InitBuffer(leafAccF_, N_TILE * sizeof(float)); + pipe_->InitBuffer(leafBf16_, N_TILE * sizeof(bfloat16_t)); + pipe_->InitBuffer(treeStkF_, TREE_DEPTH * N_TILE * sizeof(float)); + pipe_->InitBuffer(outBuf_, N_TILE * sizeof(float)); // bf16/fp32 staging + pipe_->InitBuffer(nkOffsets_, N_TILE * sizeof(uint32_t)); + pipe_->InitBuffer(storeOffsets_, N_TILE * 16 * sizeof(uint32_t)); + pipe_->InitBuffer(storeSlots_, N_TILE * 32); + + // Tiles are independent: only intra-core synchronization is needed. + eventVS_ = pipe_->FetchEventID(AscendC::HardEvent::V_S); + eventSV_ = pipe_->FetchEventID(AscendC::HardEvent::S_V); + eventMTE2V_ = pipe_->FetchEventID(AscendC::HardEvent::MTE2_V); + eventVMTE2_ = pipe_->FetchEventID(AscendC::HardEvent::V_MTE2); + eventVMTE3_ = pipe_->FetchEventID(AscendC::HardEvent::V_MTE3); + eventMTE3S_ = pipe_->FetchEventID(AscendC::HardEvent::MTE3_S); + + // Gather offsets are byte offsets, initialized once per core. + if (rhsTransposed_) { + auto offsets = nkOffsets_.Get(); + for (uint32_t n = 0; n < N_TILE; ++n) { + offsets.SetValue(n, n * TREE_LEAF * sizeof(float)); + } + } + if (outTransposed_) { + const uint32_t elemBytes = outFp32_ ? 4 : 2; + const uint32_t slotElems = 32 / elemBytes; + auto offsets = storeOffsets_.Get(); + for (uint32_t i = 0; i < N_TILE * slotElems; ++i) { + offsets.SetValue(i, (i / slotElems) * elemBytes); + } + } + AscendC::SetFlag(eventSV_); + AscendC::WaitFlag(eventSV_); + } + + __aicore__ inline void Process() + { + const int64_t nTiles = (N_ + N_TILE - 1) / N_TILE; + const int64_t items = M_ * nTiles; + for (int64_t item = AscendC::GetBlockIdx(); item < items; + item += AscendC::GetBlockNum()) { + const int64_t row = item / nTiles; + const int64_t tile = item - row * nTiles; + ProcessRowTile(row, tile * N_TILE); + } + } + +private: + // Number of completed left-subtree merges for leaf `leaf` of `n` leaves: + // walk the mid-split path from the root; every right turn merges the + // sibling subtree to the left, and a left turn starts a fresh left + // subtree (reset). Mirrors mid_tree_merge_count of the CUDA kernel. + __aicore__ inline uint32_t MidTreeMergeCount(uint32_t leaf, uint32_t n) const + { + uint32_t lo = 0; + uint32_t hi = n; + uint32_t count = 0; + while (hi - lo > 1) { + const uint32_t mid = lo + (hi - lo) / 2; + if (leaf < mid) { + hi = mid; + count = 0; + } else { + lo = mid; + ++count; + } + } + return count; + } + + // Wait until all outstanding vector-pipe results are readable as scalars. + __aicore__ inline void WaitVector() + { + AscendC::SetFlag(eventVS_); + AscendC::WaitFlag(eventVS_); + } + + __aicore__ inline uint32_t LeafCount() const + { + return static_cast((R_ + TREE_LEAF - 1) / TREE_LEAF); + } + + // Elements in the last leaf (TREE_LEAF for all but the tail). + __aicore__ inline uint32_t LastLeafLen() const + { + const uint32_t rem = static_cast(R_ % TREE_LEAF); + return rem == 0 ? TREE_LEAF : rem; + } + + // BF16 alignment: 16 elements = 32 bytes. + __aicore__ inline uint32_t AlignBf16(uint32_t count) const + { + return (count + 15u) & ~15u; + } + + // The caller zeroes the full allocation before MTE2 starts. Hardware + // padding only fills the final partial 32 B block, never the whole tile. + __aicore__ inline void LoadLhsLeaf( + int64_t row, int64_t leafStart, uint32_t leafLen) + { + AscendC::DataCopyExtParams cp{ + 1, static_cast(leafLen * sizeof(bfloat16_t)), 0, 0, 0}; + AscendC::DataCopyPadExtParams pp{ + true, 0, static_cast(AlignBf16(leafLen) - leafLen), 0}; + AscendC::DataCopyPad(aBufT_.Get(), + lhsGm_[row * R_ + leafStart], cp, pp); + } + + __aicore__ inline void LoadRhsTile( + int64_t tileStart, int64_t leafStart, uint32_t leafLen) + { + const uint32_t validCols = + static_cast(N_ - tileStart < N_TILE ? N_ - tileStart : N_TILE); + auto rT = rhsBufT_.Get(); + if (rhsTransposed_) { + // Physical [N,R] -> UB [N_TILE,TREE_LEAF]. + // srcStride is the GM gap AFTER the valid leaf. + // dstStride is the UB gap AFTER its 32 B padded footprint. + const uint32_t alignedLeaf = AlignBf16(leafLen); + AscendC::DataCopyExtParams cp{ + static_cast(validCols), + static_cast(leafLen * sizeof(bfloat16_t)), + static_cast((R_ - leafLen) * sizeof(bfloat16_t)), + (TREE_LEAF - alignedLeaf) / 16, 0}; + AscendC::DataCopyPadExtParams pp{ + true, 0, static_cast(alignedLeaf - leafLen), 0}; + AscendC::DataCopyPad( + rT, rhsGm_[tileStart * R_ + leafStart], cp, pp); + return; + } + + // Physical [R,N] -> UB [TREE_LEAF,N_TILE]. + // Only real rows and columns are copied. Everything else stays zero. + const uint32_t alignedCols = AlignBf16(validCols); + const uint64_t srcGap = + static_cast(N_ - validCols) * sizeof(bfloat16_t); + AscendC::DataCopyPadExtParams pp{ + true, 0, static_cast(alignedCols - validCols), 0}; + if (srcGap <= 0xffffffffULL) { + AscendC::DataCopyExtParams cp{ + static_cast(leafLen), + static_cast(validCols * sizeof(bfloat16_t)), + static_cast(srcGap), + (N_TILE - alignedCols) / 16, 0}; + AscendC::DataCopyPad( + rT, rhsGm_[leafStart * N_ + tileStart], cp, pp); + } else { + // DataCopyExtParams has uint32_t strides, NOT uint16_t strides. + // Avoid narrowing a larger GM gap. + AscendC::DataCopyExtParams cp{ + 1, static_cast(validCols * sizeof(bfloat16_t)), 0, 0, 0}; + for (uint32_t j = 0; j < leafLen; ++j) { + AscendC::DataCopyPad( + rT[j * N_TILE], + rhsGm_[(leafStart + j) * N_ + tileStart], cp, pp); + } + } + } + + // Copy 128 FP32 values without arithmetic or a same-dtype Cast. + // 64 FP32 lanes/repeat; two repeats; contiguous 32 B blocks. + __aicore__ inline void CopyFp32Tile( + const AscendC::LocalTensor& dst, + const AscendC::LocalTensor& src) + { + AscendC::Copy(dst, src, static_cast(64), 2, {1, 1, 8, 8}); + } + + // Contiguous output uses one exact-length copy from an aligned UB base. + // Transposed output expands each value into its own 32 B aligned slot. + __aicore__ inline void StoreTile(int64_t row, int64_t tileStart) + { + const uint32_t validCols = + static_cast(N_ - tileStart < N_TILE ? N_ - tileStart : N_TILE); + const uint32_t elemBytes = outFp32_ ? 4 : 2; + if (!outTransposed_) { + AscendC::SetFlag(eventVMTE3_); + AscendC::WaitFlag(eventVMTE3_); + AscendC::DataCopyExtParams cp{1, validCols * elemBytes, 0, 0, 0}; + AscendC::DataCopyPad( + outGm_[(row * N_ + tileStart) * elemBytes], + outBuf_.Get(), cp); + return; + } + + // Gather integer bit patterns; no float arithmetic or BF16 recasting. + auto offsets = storeOffsets_.Get(); + if (outFp32_) { + AscendC::Gather(storeSlots_.Get(), outBuf_.Get(), + offsets, 0, N_TILE * 8); + } else { + AscendC::Gather(storeSlots_.Get(), outBuf_.Get(), + offsets, 0, N_TILE * 16); + } + AscendC::SetFlag(eventVMTE3_); + AscendC::WaitFlag(eventVMTE3_); + auto slots = storeSlots_.Get(); + const uint64_t dstGap = static_cast(M_ - 1) * elemBytes; + if (dstGap <= 0xffffffffULL) { + // MTE3 consumes one 32 B slot per short source block. srcStride=0 + // means no EXTRA gap beyond that rounded footprint. + AscendC::DataCopyExtParams cp{ + static_cast(validCols), elemBytes, 0, + static_cast(dstGap), 0}; + AscendC::DataCopyPad( + outGm_[(tileStart * M_ + row) * elemBytes], slots, cp); + } else { + AscendC::DataCopyExtParams cp{1, elemBytes, 0, 0, 0}; + for (uint32_t n = 0; n < validCols; ++n) { + AscendC::DataCopyPad( + outGm_[((tileStart + n) * M_ + row) * elemBytes], + slots[n * 32], cp); + } + } + } + + __aicore__ inline void ProcessRowTile(int64_t row, int64_t tileStart) + { + const uint32_t numLeaves = LeafCount(); + AscendC::LocalTensor aF = aBufF_.Get(); + AscendC::LocalTensor rF = rhsBufF_.Get(); + AscendC::LocalTensor prod = prodBufF_.Get(); + AscendC::LocalTensor acc = leafAccF_.Get(); + AscendC::LocalTensor leafBf = leafBf16_.Get(); + AscendC::LocalTensor stk = treeStkF_.Get(); + uint32_t sp = 0; + + for (uint32_t leaf = 0; leaf < numLeaves; ++leaf) { // fixed ascending order + const int64_t leafStart = static_cast(leaf) * TREE_LEAF; + const uint32_t leafLen = (leaf + 1 == numLeaves) ? LastLeafLen() : TREE_LEAF; + + // Explicitly clear padding and missing rows/columns. The V barrier + // protects buffers still read by the preceding leaf's Cast. + AscendC::PipeBarrier(); + AscendC::Duplicate(aBufT_.Get(), static_cast(0), + TREE_LEAF); + AscendC::Duplicate(rhsBufT_.Get(), static_cast(0), + TREE_LEAF * N_TILE); + // MTE2 must not race these vector writes. + AscendC::SetFlag(eventVMTE2_); + AscendC::WaitFlag(eventVMTE2_); + LoadLhsLeaf(row, leafStart, leafLen); + LoadRhsTile(tileStart, leafStart, leafLen); + AscendC::SetFlag(eventMTE2V_); + AscendC::WaitFlag(eventMTE2V_); + // Drain the scalar pipe's UB reads of aF from the previous leaf + // before the vector pipe overwrites it (same lanes). + AscendC::SetFlag(eventSV_); + AscendC::WaitFlag(eventSV_); + AscendC::Cast(aF, aBufT_.Get(), AscendC::RoundMode::CAST_NONE, + TREE_LEAF); + AscendC::Cast(rF, rhsBufT_.Get(), AscendC::RoundMode::CAST_NONE, + TREE_LEAF * N_TILE); + WaitVector(); // casts visible to the scalar pipe + + // Leaf sum: FP32 accumulation in ascending r order, one 128-wide + // multiply/add row at a time (fixed per-leaf order). + AscendC::Duplicate(acc, 0.0f, N_TILE); + AscendC::PipeBarrier(); + for (uint32_t j = 0; j < leafLen; ++j) { + const float aVal = aF.GetValue(j); + if (rhsTransposed_) { + // rF is column-major here. Gather the j-th value of + // each column: rF[n * TREE_LEAF + j]. + AscendC::Gather(prod, rF, nkOffsets_.Get(), + j * sizeof(float), N_TILE); + AscendC::PipeBarrier(); + AscendC::Muls(prod, prod, aVal, N_TILE); + } else { + AscendC::Muls(prod, rF[j * N_TILE], aVal, N_TILE); + } + AscendC::PipeBarrier(); + AscendC::Add(acc, acc, prod, N_TILE); + AscendC::PipeBarrier(); + } + + // tree_v = bf16(leaf) (round-to-nearest-even); keep the exact + // FP32 value of the rounded node in acc for the tree merges. + AscendC::Cast(leafBf, acc, AscendC::RoundMode::CAST_RINT, N_TILE); + AscendC::PipeBarrier(); + AscendC::Cast(acc, leafBf, AscendC::RoundMode::CAST_NONE, N_TILE); + AscendC::PipeBarrier(); + + // Mid-split tree: merge with the completed left siblings, each + // merge = FP32 add + one BF16 rounding (CUDA __hadd2 semantics). + const uint32_t mergeCount = MidTreeMergeCount(leaf, numLeaves); + for (uint32_t merge = 0; merge < mergeCount; ++merge) { + AscendC::Add(acc, acc, stk[(sp - 1) * N_TILE], N_TILE); + AscendC::PipeBarrier(); + AscendC::Cast(leafBf, acc, AscendC::RoundMode::CAST_RINT, N_TILE); + AscendC::PipeBarrier(); + AscendC::Cast(acc, leafBf, AscendC::RoundMode::CAST_NONE, N_TILE); + AscendC::PipeBarrier(); + --sp; + } + if (leaf + 1 < numLeaves) { + // Push tree_v (exact FP32 of the rounded node). + CopyFp32Tile(stk[sp * N_TILE], acc); + AscendC::PipeBarrier(); + ++sp; + } + } + + // Stage the root in UB, then copy out (bf16 out re-rounds the exact + // FP32 root; fp32 out stores it directly). + AscendC::LocalTensor outBf = outBuf_.Get(); + AscendC::LocalTensor outF = outBuf_.Get(); + if (outFp32_) { + CopyFp32Tile(outF, acc); + } else { + AscendC::Cast(outBf, acc, AscendC::RoundMode::CAST_RINT, N_TILE); + } + AscendC::PipeBarrier(); + StoreTile(row, tileStart); + // Drain MTE3 before the next tile stages new values into the shared + // buffers; the scalar pipe issues all later MTE2 copies in order, so + // this wait alone orders them after the copy-outs. + AscendC::SetFlag(eventMTE3S_); + AscendC::WaitFlag(eventMTE3S_); + // Complete the dependency to future vector writes to output slots. + AscendC::SetFlag(eventSV_); + AscendC::WaitFlag(eventSV_); + } + + AscendC::TPipe* pipe_; + AscendC::GlobalTensor lhsGm_; + AscendC::GlobalTensor rhsGm_; + AscendC::GlobalTensor outGm_; + AscendC::TBuf aBufT_; + AscendC::TBuf aBufF_; + AscendC::TBuf rhsBufT_; + AscendC::TBuf rhsBufF_; + AscendC::TBuf prodBufF_; + AscendC::TBuf leafAccF_; + AscendC::TBuf leafBf16_; + AscendC::TBuf treeStkF_; + AscendC::TBuf outBuf_; + AscendC::TBuf nkOffsets_; + AscendC::TBuf storeOffsets_; + AscendC::TBuf storeSlots_; + AscendC::TEventID eventVS_; + AscendC::TEventID eventSV_; + AscendC::TEventID eventMTE2V_; + AscendC::TEventID eventVMTE2_; + AscendC::TEventID eventVMTE3_; + AscendC::TEventID eventMTE3S_; + int64_t M_; + int64_t N_; + int64_t R_; + bool rhsTransposed_; + bool outTransposed_; + bool outFp32_; +}; + +} // namespace + +extern "C" __global__ __vector__ void det_gemm_ascend_kernel_bf16( + GM_ADDR lhs, GM_ADDR rhs, GM_ADDR out, + int64_t M, int64_t N, int64_t R, + int32_t rhsTransposed, int32_t outTransposed, int32_t outFp32) +{ + AscendC::TPipe pipe; + KernelDetGemm op(&pipe); + op.Init(lhs, rhs, out, M, N, R, rhsTransposed, outTransposed, outFp32); + op.Process(); +} + +namespace { + +// Shared host-side dispatch: checks, output allocation, fixed launch shape. +torch::Tensor det_gemm_ascend_dispatch(const torch::Tensor& lhs, + const torch::Tensor& rhs, + int64_t M, + int64_t N, + int64_t R, + bool rhsTransposed, + bool outTransposed, + bool outFp32) +{ + TORCH_CHECK(lhs.is_privateuseone() && rhs.is_privateuseone(), + "det_gemm_ascend: inputs must be on an NPU device"); + TORCH_CHECK(lhs.device() == rhs.device(), + "det_gemm_ascend: inputs must be on the same NPU device"); + TORCH_CHECK(lhs.scalar_type() == at::kBFloat16 && rhs.scalar_type() == at::kBFloat16, + "det_gemm_ascend: inputs must be bf16"); + TORCH_CHECK(lhs.is_contiguous() && rhs.is_contiguous(), + "det_gemm_ascend: inputs must be contiguous"); + TORCH_CHECK(M > 0 && N > 0 && R > 0, "det_gemm_ascend: M, N, R must be positive"); + // The training contract caps a rank's GEMM reduction at 32768 (matches the + // CUDA TREE_DEPTH budget). + TORCH_CHECK(R <= 32768, "det_gemm_ascend: R must be <= 32768"); + + // Select the input device before allocation and stream lookup. + const c10::DeviceGuard deviceGuard(lhs.device()); + auto options = lhs.options().dtype(outFp32 ? torch::kFloat32 : torch::kBFloat16); + auto out = outTransposed ? torch::empty({N, M}, options) : torch::empty({M, N}, options); + + // stream(true): flush the task queue before launch so the kernel cannot + // overtake earlier NPU work; outputs were allocated with at::empty (no + // queued initializer) for the same reason. + auto aclStream = c10_npu::getCurrentNPUStream().stream(true); + const int64_t nTiles = (N + N_TILE - 1) / N_TILE; + const int64_t items = M * nTiles; + const uint32_t blockNum = static_cast(std::min(items, MAX_BLOCKS)); + + det_gemm_ascend_kernel_bf16<<>>( + reinterpret_cast(lhs.mutable_data_ptr()), + reinterpret_cast(rhs.mutable_data_ptr()), + reinterpret_cast(out.mutable_data_ptr()), + M, N, R, rhsTransposed ? 1 : 0, outTransposed ? 1 : 0, outFp32 ? 1 : 0); + return out; +} + +torch::Tensor det_gemm_ascend_fwd_impl(torch::Tensor a, + torch::Tensor b, + bool rhsTransposed, + bool outputFp32) +{ + TORCH_CHECK(a.dim() == 2 && b.dim() == 2, + "det_gemm_ascend: expect 2D A[M,R] and B[R,N] (or Bt[N,R])"); + const int64_t M = a.size(0); + const int64_t R = a.size(1); + const int64_t N = rhsTransposed ? b.size(0) : b.size(1); + const int64_t rhsR = rhsTransposed ? b.size(1) : b.size(0); + TORCH_CHECK(rhsR == R, "det_gemm_ascend: reduction dim mismatch"); + return det_gemm_ascend_dispatch(a, b, M, N, R, rhsTransposed, false, outputFp32); +} + +} // namespace + +// The PYBIND11_MODULE for rl_engine._C_npu lives in npu_module.cpp so that +// every Ascend op shares one compiled module. The host forward functions +// below mirror the CUDA det_gemm entry points 1:1. + +torch::Tensor det_gemm_ascend_fwd(torch::Tensor a, torch::Tensor b) +{ + a = a.contiguous(); + b = b.contiguous(); + return det_gemm_ascend_fwd_impl(a, b, false, false); +} + +torch::Tensor det_gemm_ascend_fwd_rhs_transposed(torch::Tensor a, torch::Tensor bt) +{ + a = a.contiguous(); + bt = bt.contiguous(); + return det_gemm_ascend_fwd_impl(a, bt, true, false); +} + +torch::Tensor det_gemm_ascend_fwd_fp32(torch::Tensor a, torch::Tensor b) +{ + a = a.contiguous(); + b = b.contiguous(); + return det_gemm_ascend_fwd_impl(a, b, false, true); +} + +torch::Tensor det_gemm_ascend_da(torch::Tensor dc, torch::Tensor b) +{ + // dA = dC @ B^T: reduce over N with the physical [K,N] operand as the + // transposed rhs (the kNK contract of the CUDA kernel). + TORCH_CHECK(dc.dim() == 2 && b.dim() == 2, + "det_gemm_ascend_da: expect dC[M,N] and B[K,N]"); + TORCH_CHECK(b.size(1) == dc.size(1), "det_gemm_ascend_da: N mismatch"); + dc = dc.contiguous(); + b = b.contiguous(); + return det_gemm_ascend_dispatch(dc, b, dc.size(0), b.size(0), dc.size(1), true, false, false); +} + +torch::Tensor det_gemm_ascend_db(torch::Tensor a, torch::Tensor dc) +{ + // dB = A^T @ dC: reduce over M; A^T materialized like the CUDA wrapper. + TORCH_CHECK(a.dim() == 2 && dc.dim() == 2, + "det_gemm_ascend_db: expect A[M,K] and dC[M,N]"); + TORCH_CHECK(dc.size(0) == a.size(0), "det_gemm_ascend_db: M mismatch"); + auto at = a.t().contiguous(); + dc = dc.contiguous(); + return det_gemm_ascend_dispatch(at, dc, a.size(1), dc.size(1), a.size(0), false, false, + false); +} + +torch::Tensor det_gemm_ascend_db_transposed(torch::Tensor a, torch::Tensor dc) +{ + // dB born contiguous in the canonical [N,K] weight layout: the same + // A^T @ dC tree evaluation with only the final address mapping changed + // (the kNM output contract of the CUDA kernel). + TORCH_CHECK(a.dim() == 2 && dc.dim() == 2, + "det_gemm_ascend_db_transposed: expect A[M,K] and dC[M,N]"); + TORCH_CHECK(dc.size(0) == a.size(0), "det_gemm_ascend_db_transposed: M mismatch"); + auto at = a.t().contiguous(); + dc = dc.contiguous(); + return det_gemm_ascend_dispatch(at, dc, a.size(1), dc.size(1), a.size(0), false, true, + false); +} diff --git a/csrc/ascend/lm_head_ascend.asc b/csrc/ascend/lm_head_ascend.asc new file mode 100644 index 00000000..8eeb0287 --- /dev/null +++ b/csrc/ascend/lm_head_ascend.asc @@ -0,0 +1,404 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (c) 2026 RL-Kernel Contributors +// +// Batch-invariant LM-head projection, Ascend C (CANN) forward kernel. +// +// out[n, v] = dot(hidden[n, :], weight[v, :]) (+ bias[v]) +// +// Mirrors the SM90 CUDA kernel in csrc/cuda/embedding_lm_head_sm90.cu: +// - input : hidden [N, H] contiguous, fp32 / bf16 / fp16; weight [V, H] +// contiguous, same dtype; bias [V] optional (cast to fp32) +// - output : [N, V] in the compute dtype (hidden dtype, or fp32 when +// output_fp32 -- the host wrapper pre-casts the inputs, exactly +// like the CUDA wrapper) +// - every output element owns its full hidden-dimension reduction inside +// one block: no Split-K, no second-pass merge, so the reduction order +// depends only on H, never on N or the block the element lands on. +// +// The math follows the CUDA kernel's structure: fp32 accumulation over a +// fixed tile order (products -> per-tile sum -> sequential scalar +// accumulation), bias added in fp32, final cast to the output dtype with +// round-to-nearest. The per-tile sums use the Ascend vector unit's fixed +// hardware reduction tree (fixed per tile size) instead of CUDA's +// warp-shuffle tree, so cross-platform bitwise parity with the CUDA kernel +// is not claimed -- the guarantee is the same one the CUDA kernel provides +// on its platform: batch-invariant determinism. +// +// Batch-invariance: one output element per block iteration, fixed tile +// order over H, rows/elements strided across blocks (MAX_BLOCKS cap), so a +// row's logits are bitwise identical across batch sizes, row positions, and +// block assignments on the NPU. +// +// Build: see setup.py (AscendBuildExtension, bisheng -x asc), gated by +// KERNEL_ALIGN_FORCE_ASCEND=1. Requires CANN toolkit + torch_npu. + +#include + +#include "kernel_operator.h" + +#include + +#include "torch_npu/csrc/core/npu/NPUStream.h" + +namespace { + +// Elements per hidden tile. Fixed for all elements and batch sizes; this is +// what makes the reduction order batch-invariant. UB budget (two native +// tiles + four fp32 tiles) stays well under the 192 KB UB of current SoCs. +constexpr uint32_t TILE_LENGTH = 4096; +// Cap on launched blocks. Elements are strided across blocks, so launching +// fewer blocks than elements is fine and never changes per-element numerics. +constexpr int64_t MAX_BLOCKS = 128; + +template +class KernelLmHead { +public: + __aicore__ inline KernelLmHead(AscendC::TPipe* pipe) : pipe_(pipe) {} + + __aicore__ inline void Init(GM_ADDR hidden, + GM_ADDR weight, + GM_ADDR bias, + GM_ADDR output, + int64_t numRows, + int64_t vocabSize, + int64_t hiddenSize, + bool hasBias) + { + numRows_ = numRows; + vocabSize_ = vocabSize; + hiddenSize_ = hiddenSize; + hasBias_ = hasBias; + hiddenGm_.SetGlobalBuffer(reinterpret_cast<__gm__ T*>(hidden)); + weightGm_.SetGlobalBuffer(reinterpret_cast<__gm__ T*>(weight)); + biasGm_.SetGlobalBuffer(reinterpret_cast<__gm__ float*>(bias)); + outputGm_.SetGlobalBuffer(reinterpret_cast<__gm__ T*>(output)); + pipe_->InitBuffer(hQueue_, 1, TILE_LENGTH * sizeof(T)); + pipe_->InitBuffer(wQueue_, 1, TILE_LENGTH * sizeof(T)); + pipe_->InitBuffer(hFp32Buf_, TILE_LENGTH * sizeof(float)); + pipe_->InitBuffer(wFp32Buf_, TILE_LENGTH * sizeof(float)); + pipe_->InitBuffer(prodBuf_, TILE_LENGTH * sizeof(float)); + pipe_->InitBuffer(reduceBuf_, TILE_LENGTH * sizeof(float)); + // 64 B: floats [0,8) for reduce/log scratch, floats [8,16) for the + // output staging slots (both halves 32-byte aligned for DataCopyPad). + pipe_->InitBuffer(scalarBuf_, 64); + // 32 B window for reading bias[v] via DataCopyPad (GM scalar + // GetValue/SetValue are unreliable on hardware; see cannbot + // ascendc-precision-debug common-traps). + pipe_->InitBuffer(biasBuf_, 32); + + // Intra-core pipeline events. NOTE: AscendC::SyncAll() is a cross-core + // barrier and deadlocks when more blocks are launched than there are + // physical cores (resident blocks wait for unscheduled ones), so all + // synchronization here uses per-pipe SetFlag/WaitFlag instead. + eventVS_ = pipe_->FetchEventID(AscendC::HardEvent::V_S); + eventSV_ = pipe_->FetchEventID(AscendC::HardEvent::S_V); + eventMTE2V_ = pipe_->FetchEventID(AscendC::HardEvent::MTE2_V); + eventMTE2S_ = pipe_->FetchEventID(AscendC::HardEvent::MTE2_S); + eventSMTE3_ = pipe_->FetchEventID(AscendC::HardEvent::S_MTE3); + eventMTE3S_ = pipe_->FetchEventID(AscendC::HardEvent::MTE3_S); + } + + __aicore__ inline void Process() + { + const int64_t total = numRows_ * vocabSize_; + for (int64_t idx = AscendC::GetBlockIdx(); idx < total; + idx += AscendC::GetBlockNum()) { + const int64_t row = idx / vocabSize_; + const int64_t col = idx - row * vocabSize_; + ProcessElement(row, col); + } + } + +private: + // Load one hidden/weight tile pair into fp32 UB buffers. + __aicore__ inline void LoadTilesFp32(int64_t row, + int64_t col, + int64_t start, + uint32_t count) + { + AscendC::DataCopyExtParams copyParams{ + 1, static_cast(count * sizeof(T)), 0, 0, 0}; + AscendC::DataCopyPadExtParams padParams{false, 0, 0, 0}; + + AscendC::LocalTensor hTile = hQueue_.AllocTensor(); + AscendC::DataCopyPad(hTile, hiddenGm_[row * hiddenSize_ + start], copyParams, padParams); + hQueue_.EnQue(hTile); + hTile = hQueue_.DeQue(); + + AscendC::LocalTensor wTile = wQueue_.AllocTensor(); + AscendC::DataCopyPad(wTile, weightGm_[col * hiddenSize_ + start], copyParams, padParams); + wQueue_.EnQue(wTile); + wTile = wQueue_.DeQue(); + + AscendC::LocalTensor hFp32 = hFp32Buf_.Get(); + AscendC::LocalTensor wFp32 = wFp32Buf_.Get(); + if constexpr (std::is_same_v) { + // No cast needed: copy the native tiles into the fp32 buffers. + // (The queue tiles ARE fp32 views; staging keeps all later vector + // work on the dedicated buffers with a single ordering path.) + AscendC::SetFlag(eventMTE2V_); + AscendC::WaitFlag(eventMTE2V_); + AscendC::DataCopy(hFp32, hTile, VecAlignCount(count)); + AscendC::DataCopy(wFp32, wTile, VecAlignCount(count)); + } else { + AscendC::SetFlag(eventMTE2V_); + AscendC::WaitFlag(eventMTE2V_); + AscendC::Cast(hFp32, hTile, AscendC::RoundMode::CAST_NONE, count); + AscendC::Cast(wFp32, wTile, AscendC::RoundMode::CAST_NONE, count); + } + hQueue_.FreeTensor(hTile); + wQueue_.FreeTensor(wTile); + } + + // Wait until all outstanding vector-pipe results are readable as scalars. + __aicore__ inline void WaitVector() + { + AscendC::SetFlag(eventVS_); + AscendC::WaitFlag(eventVS_); + } + + __aicore__ inline void ProcessElement(int64_t row, int64_t col) + { + const int64_t tileCount = (hiddenSize_ + TILE_LENGTH - 1) / TILE_LENGTH; + float acc = 0.0f; + for (int64_t tile = 0; tile < tileCount; ++tile) { + const int64_t start = tile * TILE_LENGTH; + const uint32_t count = TileCount(start); + LoadTilesFp32(row, col, start, count); + + AscendC::LocalTensor hFp32 = hFp32Buf_.Get(); + AscendC::LocalTensor wFp32 = wFp32Buf_.Get(); + AscendC::LocalTensor prod = prodBuf_.Get(); + AscendC::Mul(prod, hFp32, wFp32, count); + + AscendC::LocalTensor rTmp = reduceBuf_.Get(); + AscendC::LocalTensor scalar = scalarBuf_.Get(); + AscendC::ReduceSum(scalar, prod, rTmp, static_cast(count)); + WaitVector(); // vector -> scalar read + // Sequential accumulation over tiles in fixed order. + acc += scalar.GetValue(0); + } + + if (hasBias_) { + acc += LoadBias(col); + } + + // Stage the output in UB, then DataCopyPad to GM. GlobalTensor.SetValue + // is unreliable on hardware (cannbot ascendc-precision-debug + // common-traps), so scalar GM stores are not used. + AscendC::LocalTensor scalar = scalarBuf_.Get(); + scalar.SetValue(0, acc); + AscendC::SetFlag(eventSV_); // scalar write -> vector op + AscendC::WaitFlag(eventSV_); + if constexpr (std::is_same_v) { + AscendC::SetFlag(eventSMTE3_); + AscendC::WaitFlag(eventSMTE3_); + AscendC::DataCopyExtParams outParams{1, sizeof(float), 0, 0, 0}; + AscendC::DataCopyPad(outputGm_[row * vocabSize_ + col], scalar[0], outParams); + } else { + // Cast the fp32 scalar to the output dtype (round-to-nearest), + // padded to a 32 B vector-pipe minimum. + AscendC::LocalTensor tScalar = scalarBuf_.Get(); + const uint32_t castCount = 32 / sizeof(T); + AscendC::Cast(tScalar, scalar, AscendC::RoundMode::CAST_RINT, castCount); + WaitVector(); // vector cast -> scalar read + AscendC::SetFlag(eventSMTE3_); + AscendC::WaitFlag(eventSMTE3_); + AscendC::DataCopyExtParams outParams{1, sizeof(T), 0, 0, 0}; + AscendC::DataCopyPad(outputGm_[row * vocabSize_ + col], tScalar[0], outParams); + } + // Drain MTE3 before the next element stages new values into scalarBuf_. + AscendC::SetFlag(eventMTE3S_); + AscendC::WaitFlag(eventMTE3S_); + } + + // Read bias[col] through a 32-byte-aligned DataCopyPad window. + __aicore__ inline float LoadBias(int64_t col) + { + const int64_t alignedCol = col & ~7LL; // 8 x fp32 per 32 B + const int64_t remaining = vocabSize_ - alignedCol; + const uint32_t winCount = static_cast(remaining < 8 ? remaining : 8); + AscendC::LocalTensor bLocal = biasBuf_.Get(); + AscendC::DataCopyExtParams copyParams{ + 1, static_cast(winCount * sizeof(float)), 0, 0, 0}; + AscendC::DataCopyPadExtParams padParams{false, 0, 0, 0}; + AscendC::DataCopyPad(bLocal, biasGm_[alignedCol], copyParams, padParams); + AscendC::SetFlag(eventMTE2S_); // copy-in -> scalar read + AscendC::WaitFlag(eventMTE2S_); + return bLocal.GetValue(static_cast(col - alignedCol)); + } + + __aicore__ inline uint32_t TileCount(int64_t start) const + { + const int64_t remaining = hiddenSize_ - start; + return static_cast(remaining < TILE_LENGTH ? remaining : TILE_LENGTH); + } + + // Round an element count up to a 32 B boundary (vector-pipe minimum). + __aicore__ inline uint32_t VecAlignCount(uint32_t count) const + { + constexpr uint32_t elemsPer32B = 32 / sizeof(T); + return (count + elemsPer32B - 1) / elemsPer32B * elemsPer32B; + } + + AscendC::TPipe* pipe_; + AscendC::GlobalTensor hiddenGm_; + AscendC::GlobalTensor weightGm_; + AscendC::GlobalTensor biasGm_; + AscendC::GlobalTensor outputGm_; + AscendC::TQue hQueue_; + AscendC::TQue wQueue_; + AscendC::TBuf hFp32Buf_; + AscendC::TBuf wFp32Buf_; + AscendC::TBuf prodBuf_; + AscendC::TBuf reduceBuf_; + AscendC::TBuf scalarBuf_; + AscendC::TBuf biasBuf_; + AscendC::TEventID eventVS_; + AscendC::TEventID eventSV_; + AscendC::TEventID eventMTE2V_; + AscendC::TEventID eventMTE2S_; + AscendC::TEventID eventSMTE3_; + AscendC::TEventID eventMTE3S_; + int64_t numRows_; + int64_t vocabSize_; + int64_t hiddenSize_; + bool hasBias_; +}; + +} // namespace + +extern "C" __global__ __vector__ void lm_head_ascend_kernel_fp32( + GM_ADDR hidden, GM_ADDR weight, GM_ADDR bias, GM_ADDR output, + int64_t numRows, int64_t vocabSize, int64_t hiddenSize, bool hasBias) +{ + AscendC::TPipe pipe; + KernelLmHead op(&pipe); + op.Init(hidden, weight, bias, output, numRows, vocabSize, hiddenSize, hasBias); + op.Process(); +} + +extern "C" __global__ __vector__ void lm_head_ascend_kernel_bf16( + GM_ADDR hidden, GM_ADDR weight, GM_ADDR bias, GM_ADDR output, + int64_t numRows, int64_t vocabSize, int64_t hiddenSize, bool hasBias) +{ + AscendC::TPipe pipe; + KernelLmHead op(&pipe); + op.Init(hidden, weight, bias, output, numRows, vocabSize, hiddenSize, hasBias); + op.Process(); +} + +extern "C" __global__ __vector__ void lm_head_ascend_kernel_fp16( + GM_ADDR hidden, GM_ADDR weight, GM_ADDR bias, GM_ADDR output, + int64_t numRows, int64_t vocabSize, int64_t hiddenSize, bool hasBias) +{ + AscendC::TPipe pipe; + KernelLmHead op(&pipe); + op.Init(hidden, weight, bias, output, numRows, vocabSize, hiddenSize, hasBias); + op.Process(); +} + +torch::Tensor lm_head_ascend_forward(torch::Tensor hidden, + torch::Tensor weight, + torch::optional bias, + bool output_fp32) +{ + TORCH_CHECK(hidden.is_privateuseone(), "hidden must be on an NPU device"); + TORCH_CHECK(weight.is_privateuseone(), "weight must be on an NPU device"); + TORCH_CHECK(hidden.device() == weight.device(), + "hidden and weight must be on the same NPU device"); + TORCH_CHECK(hidden.dim() >= 2, "hidden must have shape [..., hidden]"); + TORCH_CHECK(weight.dim() == 2, "lm_head weight must be [vocab, hidden]"); + TORCH_CHECK(hidden.size(-1) == weight.size(1), "hidden/weight hidden-dim mismatch"); + TORCH_CHECK(hidden.scalar_type() == at::kFloat || hidden.scalar_type() == at::kHalf || + hidden.scalar_type() == at::kBFloat16, + "lm_head_ascend supports fp32, fp16, and bf16 hidden states"); + TORCH_CHECK(weight.scalar_type() == hidden.scalar_type(), + "lm_head_ascend requires weight to match the hidden dtype"); + + const int64_t hiddenSize = hidden.size(-1); + TORCH_CHECK(hiddenSize > 0, "lm_head hidden dimension must be non-zero"); + const int64_t vocabSize = weight.size(0); + const int64_t numRows = hidden.numel() / hiddenSize; + // Mirror the CUDA wrapper: pre-cast the inputs to the compute dtype. + const at::ScalarType computeDtype = output_fp32 ? at::kFloat : hidden.scalar_type(); + + auto hidden2d = hidden.reshape({numRows, hiddenSize}).to(computeDtype).contiguous(); + auto weight2d = weight.to(computeDtype).contiguous(); + torch::Tensor biasF; + uint8_t* biasPtr = nullptr; + bool hasBias = false; + if (bias.has_value()) { + TORCH_CHECK(bias->is_privateuseone(), "lm_head bias must be on an NPU device"); + TORCH_CHECK(bias->device() == hidden.device(), + "lm_head bias must be on the same NPU device as hidden"); + TORCH_CHECK(bias->dim() == 1, "lm_head bias must be 1-D [vocab]"); + TORCH_CHECK(bias->numel() == vocabSize, "lm_head bias must have vocab elements"); + TORCH_CHECK(bias->scalar_type() == at::kFloat || bias->scalar_type() == at::kHalf || + bias->scalar_type() == at::kBFloat16, + "lm_head_ascend supports fp32, fp16, and bf16 bias"); + biasF = bias->reshape({vocabSize}).to(at::kFloat).contiguous(); + biasPtr = reinterpret_cast(biasF.mutable_data_ptr()); + hasBias = true; + } + + auto outOptions = hidden.options().dtype(computeDtype); + std::vector outSizes; + outSizes.reserve(static_cast(hidden.dim())); + for (int64_t i = 0; i < hidden.dim() - 1; ++i) { + outSizes.push_back(hidden.size(i)); + } + outSizes.push_back(vocabSize); + auto output = torch::empty(outSizes, outOptions); + if (numRows == 0 || vocabSize == 0) { + return output; + } + + // stream(true): flush the task queue before launch so the kernel cannot + // overtake earlier NPU work; outputs were allocated with at::empty (no + // queued initializer) for the same reason. + auto aclStream = c10_npu::getCurrentNPUStream().stream(true); + const int64_t total = numRows * vocabSize; + const uint32_t blockNum = static_cast(std::min(total, MAX_BLOCKS)); + + if (computeDtype == at::kBFloat16) { + lm_head_ascend_kernel_bf16<<>>( + reinterpret_cast(hidden2d.mutable_data_ptr()), + reinterpret_cast(weight2d.mutable_data_ptr()), + biasPtr, + reinterpret_cast(output.mutable_data_ptr()), + numRows, vocabSize, hiddenSize, hasBias); + } else if (computeDtype == at::kHalf) { + lm_head_ascend_kernel_fp16<<>>( + reinterpret_cast(hidden2d.mutable_data_ptr()), + reinterpret_cast(weight2d.mutable_data_ptr()), + biasPtr, + reinterpret_cast(output.mutable_data_ptr()), + numRows, vocabSize, hiddenSize, hasBias); + } else { + lm_head_ascend_kernel_fp32<<>>( + reinterpret_cast(hidden2d.mutable_data_ptr()), + reinterpret_cast(weight2d.mutable_data_ptr()), + biasPtr, + reinterpret_cast(output.mutable_data_ptr()), + numRows, vocabSize, hiddenSize, hasBias); + } + return output; +} + +torch::Tensor det_gemm_rowwise_ascend_fwd_fp32(torch::Tensor a, torch::Tensor b) +{ + TORCH_CHECK(a.dim() == 2 && b.dim() == 2, + "det_gemm_rowwise_ascend_fwd_fp32 expects [M,K] @ [K,N]"); + TORCH_CHECK(a.size(1) == b.size(0), + "det_gemm_rowwise_ascend_fwd_fp32: K mismatch"); + // lm_head_ascend_forward reduces each output element with one fixed + // per-row order and FP32 accumulation. Passing B^T as [N,K] exposes that + // reduction as a general GEMM, mirroring the CUDA det_gemm_rowwise_fwd_fp32 + // wrapper over the SM90 lm_head kernel. + return lm_head_ascend_forward( + a, b.transpose(0, 1).contiguous(), torch::optional{}, true); +} + +// The PYBIND11_MODULE for rl_engine._C_npu lives in npu_module.cpp so that +// every Ascend op shares one compiled module. diff --git a/csrc/ascend/npu_module.cpp b/csrc/ascend/npu_module.cpp new file mode 100644 index 00000000..c1c498e4 --- /dev/null +++ b/csrc/ascend/npu_module.cpp @@ -0,0 +1,164 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (c) 2026 RL-Kernel Contributors + +#include + +#include + +std::vector batch_invariant_logp_ascend_forward(torch::Tensor logits, + torch::Tensor target, + int64_t ignore_index); + +torch::Tensor rope_apply_ascend_forward(torch::Tensor x, + torch::Tensor cos, + torch::Tensor sin, + double sin_sign); + +std::vector deterministic_attention_ascend_forward( + torch::Tensor q, + torch::Tensor k, + torch::Tensor v, + bool causal, + double scale, + c10::optional key_padding_mask, + bool outFp32 = false); +std::vector deterministic_attention_backward_ascend( + torch::Tensor grad_out, + torch::Tensor q, + torch::Tensor k, + torch::Tensor v, + bool causal, + double scale, + c10::optional key_padding_mask); + +torch::Tensor prefix_shared_attention_ascend_forward( + torch::Tensor q, torch::Tensor k, torch::Tensor v); + +torch::Tensor embedding_ascend_forward(torch::Tensor token_ids, + torch::Tensor weight, + bool output_fp32); + +torch::Tensor fused_logp_ascend_forward(torch::Tensor logits, + torch::Tensor target); + +torch::Tensor lm_head_ascend_forward(torch::Tensor hidden, + torch::Tensor weight, + torch::optional bias, + bool output_fp32); + +torch::Tensor det_gemm_rowwise_ascend_fwd_fp32(torch::Tensor a, torch::Tensor b); + +torch::Tensor fused_linear_logp_ascend_forward(torch::Tensor hidden, + torch::Tensor weight, + torch::optional bias, + torch::Tensor target); + +torch::Tensor swiglu_ascend_forward(torch::Tensor gate, torch::Tensor up); +std::vector swiglu_ascend_backward( + torch::Tensor grad, torch::Tensor gate, torch::Tensor up); + +torch::Tensor silu_ascend_forward(torch::Tensor x); +torch::Tensor silu_ascend_backward(torch::Tensor grad, torch::Tensor x); + +torch::Tensor det_gemm_ascend_fwd(torch::Tensor a, torch::Tensor b); +torch::Tensor det_gemm_ascend_fwd_rhs_transposed(torch::Tensor a, torch::Tensor bt); +torch::Tensor det_gemm_ascend_fwd_fp32(torch::Tensor a, torch::Tensor b); +torch::Tensor det_gemm_ascend_da(torch::Tensor dc, torch::Tensor b); +torch::Tensor det_gemm_ascend_db(torch::Tensor a, torch::Tensor dc); +torch::Tensor det_gemm_ascend_db_transposed(torch::Tensor a, torch::Tensor dc); + +torch::Tensor rmsnorm_ascend_forward(torch::Tensor x, + torch::Tensor weight, + torch::Tensor rstd); + +int64_t deterministic_collective_create( + torch::Tensor staging, int64_t world_size, int64_t rank); +void deterministic_collective_destroy(int64_t handle); +void deterministic_collective_stage(int64_t handle, torch::Tensor input); +void deterministic_collective_reduce( + int64_t handle, torch::Tensor gathered, torch::Tensor output, int64_t slice_offset); + +PYBIND11_MODULE(TORCH_EXTENSION_NAME, m) +{ + m.def("batch_invariant_logp_ascend", + &batch_invariant_logp_ascend_forward, + "Batch-invariant selected-token log-probability (Ascend C forward)"); + m.def("rope_apply_ascend", + &rope_apply_ascend_forward, + "GPT-NeoX/HF rotate-half RoPE apply (Ascend C forward/backward primitive)"); + m.def("deterministic_attention_ascend", + &deterministic_attention_ascend_forward, + py::arg("q"), + py::arg("k"), + py::arg("v"), + py::arg("causal"), + py::arg("scale"), + py::arg("key_padding_mask") = py::none(), + py::arg("outFp32") = false, + "Deterministic batch-invariant standard-softmax attention (Ascend C forward)"); + m.def("deterministic_attention_backward_ascend", + &deterministic_attention_backward_ascend, + py::arg("grad_out"), + py::arg("q"), + py::arg("k"), + py::arg("v"), + py::arg("causal"), + py::arg("scale"), + py::arg("key_padding_mask") = py::none(), + "Deterministic batch-invariant standard-softmax attention backward (Ascend C)"); + m.def("prefix_shared_attention_ascend", + &prefix_shared_attention_ascend_forward, + "Prefix-shared fused attention (Ascend C forward)"); + m.def("deterministic_collective_create", + &deterministic_collective_create, + "Deterministic TP-invariant collective state (Ascend)"); + m.def("deterministic_collective_destroy", + &deterministic_collective_destroy, + "Release a deterministic collective state (Ascend)"); + m.def("deterministic_collective_stage", + &deterministic_collective_stage, + "Stage a tensor into the collective staging buffer (Ascend)"); + m.def("deterministic_collective_reduce", + &deterministic_collective_reduce, + "Fixed-tree ordered reduction over gathered rank tensors (Ascend)"); + m.def("rmsnorm_ascend", + &rmsnorm_ascend_forward, + "Batch-invariant RMSNorm (Ascend C forward, rstd precomputed)"); + m.def("embedding_ascend", + &embedding_ascend_forward, + "Batch-invariant token embedding (Ascend C forward)"); + m.def("fused_logp_ascend", + &fused_logp_ascend_forward, + "Batch-invariant fused selected-token log-probability (Ascend C forward)"); + m.def("lm_head_ascend", + &lm_head_ascend_forward, + "Batch-invariant LM-head projection (Ascend C forward)"); + m.def("fused_linear_logp_ascend", + &fused_linear_logp_ascend_forward, + "Batch-invariant fused linear log-probability (Ascend C forward)"); + m.def("swiglu_forward", &swiglu_ascend_forward, "SwiGLU forward (Ascend C)"); + m.def("swiglu_backward", &swiglu_ascend_backward, "SwiGLU backward (Ascend C)"); + m.def("silu_forward", &silu_ascend_forward, "SiLU forward (Ascend C)"); + m.def("silu_backward", &silu_ascend_backward, "SiLU backward (Ascend C)"); + m.def("det_gemm_rowwise_ascend_fwd_fp32", + &det_gemm_rowwise_ascend_fwd_fp32, + "Rowwise FP32-accumulation deterministic GEMM (Ascend C)"); + m.def("det_gemm_ascend_fwd", + &det_gemm_ascend_fwd, + "Batch-invariant deterministic GEMM (Ascend C forward, bf16)"); + m.def("det_gemm_ascend_fwd_rhs_transposed", + &det_gemm_ascend_fwd_rhs_transposed, + "Batch-invariant deterministic GEMM with physical [N,K] rhs (Ascend C)"); + m.def("det_gemm_ascend_fwd_fp32", + &det_gemm_ascend_fwd_fp32, + "Batch-invariant deterministic GEMM with FP32 output (Ascend C)"); + m.def("det_gemm_ascend_da", + &det_gemm_ascend_da, + "Batch-invariant deterministic GEMM input gradient dA = dC @ B^T (Ascend C)"); + m.def("det_gemm_ascend_db", + &det_gemm_ascend_db, + "Batch-invariant deterministic GEMM weight gradient dB = A^T @ dC (Ascend C)"); + m.def("det_gemm_ascend_db_transposed", + &det_gemm_ascend_db_transposed, + "Batch-invariant deterministic GEMM weight gradient born [N,K] (Ascend C)"); +} diff --git a/csrc/ascend/ops_npu.asc b/csrc/ascend/ops_npu.asc new file mode 100644 index 00000000..dee44dc2 --- /dev/null +++ b/csrc/ascend/ops_npu.asc @@ -0,0 +1,11 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (c) 2026 RL-Kernel Contributors +// +// Aggregator for the Ascend C (CANN) extension module rl_engine._C_npu. +// +// bisheng's "-x asc" driver compiles every .asc source it is given and links +// them into a single shared object; a PYBIND11_MODULE in more than one source +// would define duplicate PyInit symbols. The pybind module is therefore +// defined exactly once, in csrc/ascend/npu_module.cpp, and the per-operator +// .asc files below only provide kernel + host forward functions. + diff --git a/csrc/ascend/rmsnorm_ascend.asc b/csrc/ascend/rmsnorm_ascend.asc new file mode 100644 index 00000000..b88341ef --- /dev/null +++ b/csrc/ascend/rmsnorm_ascend.asc @@ -0,0 +1,425 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (c) 2026 RL-Kernel Contributors +// +// Batch-invariant RMSNorm, Ascend C (CANN) forward kernel. +// +// y[n, :] = x[n, :] * rstd[n] * weight[:] +// rstd[n] = rsqrt(mean(x[n, :]^2) + eps) (precomputed on the host side) +// +// The row scale rstd is computed by the caller with the exact PyTorch ops of +// the reference (rl_engine/kernels/ops/pytorch/norm/rms_norm.py: +// x.float().pow(2).mean(-1) followed by torch.rsqrt(var + eps)). Keeping the +// reduction and rsqrt on that identical code path makes the fused result +// bitwise identical to the reference: this kernel only performs elementwise +// fp32 multiplies (order-free IEEE ops) and a round-to-nearest-even cast, +// so no in-kernel reduction order or approximate rsqrt can introduce drift. +// +// Mirrors the CUDA kernel in csrc/cuda/rmsnorm.cu: +// - input : x [N, H] contiguous, fp32 / bf16 / fp16; weight [H] same dtype; +// rstd [N] fp32 (saved by the caller for the autograd backward) +// - output : y [N, H] same dtype as x +// +// Batch-invariance: rstd[n] depends only on row n (torch's last-dim mean +// order is a function of H alone), and every row is processed end-to-end by +// exactly one AI core block with a fixed tile size. The instruction sequence +// for a row depends only on H, never on N or on the block the row lands on. +// +// Build: see setup.py (AscendBuildExtension, bisheng -x asc), gated by +// KERNEL_ALIGN_FORCE_ASCEND=1. Requires CANN toolkit + torch_npu. + +#include + +#include "kernel_operator.h" + +#include + +#include "torch_npu/csrc/core/npu/NPUStream.h" + +namespace { + +// Elements per hidden tile. Fixed for all rows and batch sizes; this is what +// keeps the elementwise pass batch-invariant. UB budget (in/out/weight tiles +// + fp32 tile + fp32 weight tile + rstd staging) stays well under the +// 192 KB UB of DAV_2201 SoCs even for fp32 in/out tiles. +constexpr uint32_t TILE_LENGTH = 4096; +// Cap on launched blocks. Rows are strided across blocks, so launching fewer +// blocks than rows is fine and never changes per-row numerics. +constexpr int64_t MAX_BLOCKS = 128; +// Cap on rows coalesced into one tile in the small-row path. Bounds the +// rstd staging buffer. +constexpr int64_t MAX_CHUNK_ROWS = 64; + +template +class KernelRmsNorm { +public: + __aicore__ inline KernelRmsNorm(AscendC::TPipe* pipe) : pipe_(pipe) {} + + __aicore__ inline void Init(GM_ADDR x, + GM_ADDR weight, + GM_ADDR rstd, + GM_ADDR y, + int64_t numRows, + int64_t hiddenSize) + { + numRows_ = numRows; + hiddenSize_ = hiddenSize; + singleTile_ = hiddenSize <= static_cast(TILE_LENGTH); + // Small rows are dominated by per-row pipeline flag round-trips, so + // process rowsPerChunk_ contiguous rows per iteration and amortize + // the syncs. Vector ops require 32 B-aligned addresses, hence the + // H % 8 == 0 gate (offset r * H floats stays aligned for every r). + rowsPerChunk_ = 1; + if (singleTile_ && hiddenSize % 8 == 0) { + rowsPerChunk_ = TILE_LENGTH / hiddenSize; + if (rowsPerChunk_ > MAX_CHUNK_ROWS) { + rowsPerChunk_ = MAX_CHUNK_ROWS; + } + } + xGm_.SetGlobalBuffer(reinterpret_cast<__gm__ T*>(x)); + weightGm_.SetGlobalBuffer(reinterpret_cast<__gm__ T*>(weight)); + rstdGm_.SetGlobalBuffer(reinterpret_cast<__gm__ float*>(rstd)); + yGm_.SetGlobalBuffer(reinterpret_cast<__gm__ T*>(y)); + pipe_->InitBuffer(inQueue_, 1, TILE_LENGTH * sizeof(T)); + pipe_->InitBuffer(outQueue_, 1, TILE_LENGTH * sizeof(T)); + pipe_->InitBuffer(wQueue_, 1, TILE_LENGTH * sizeof(T)); + pipe_->InitBuffer(fp32Buf_, TILE_LENGTH * sizeof(float)); + pipe_->InitBuffer(wFp32Buf_, TILE_LENGTH * sizeof(float)); + // 2 KB: rstd staging slots for up to MAX_CHUNK_ROWS chunk rows (the + // single-row path uses slot 0 only). Scalar-unit reads are 4-byte + // granular, so contiguous staging is fine. + pipe_->InitBuffer(scalarBuf_, MAX_CHUNK_ROWS * 8 * sizeof(float)); + + // Intra-core pipeline events. NOTE: AscendC::SyncAll() is a cross-core + // barrier and deadlocks when more blocks are launched than there are + // physical cores, so all synchronization here uses per-pipe + // SetFlag/WaitFlag instead. + eventSMTE2_ = pipe_->FetchEventID(AscendC::HardEvent::S_MTE2); + eventMTE2S_ = pipe_->FetchEventID(AscendC::HardEvent::MTE2_S); + } + + __aicore__ inline void Process() + { + if (singleTile_) { + // Cache the fp32 weight tile once; every row reuses it. + LoadWeightFp32(0, static_cast(hiddenSize_)); + } + if (rowsPerChunk_ > 1) { + // Contiguous row segment per block: chunk coalescing needs the + // rows of a chunk to be contiguous in GM. + const int64_t blockNum = AscendC::GetBlockNum(); + const int64_t segLen = (numRows_ + blockNum - 1) / blockNum; + const int64_t rowStart = AscendC::GetBlockIdx() * segLen; + const int64_t rowEnd = + (rowStart + segLen < numRows_) ? rowStart + segLen : numRows_; + for (int64_t row = rowStart; row < rowEnd; row += rowsPerChunk_) { + const int64_t remaining = rowEnd - row; + const int64_t chunkRows = + remaining < rowsPerChunk_ ? remaining : rowsPerChunk_; + LoadRstd(row, chunkRows); + ProcessRowChunk(row, chunkRows); + } + return; + } + for (int64_t row = AscendC::GetBlockIdx(); row < numRows_; row += AscendC::GetBlockNum()) { + LoadRstd(row, 1); + ProcessRow(row, scalarBuf_.Get().GetValue(0)); + } + } + +private: + // Load x[row, start:start+count] into UB and return its fp32 view. When T + // is fp32 the queue buffer is used in place; otherwise the tile is cast + // into fp32Buf_. + __aicore__ inline AscendC::LocalTensor LoadTileFp32(int64_t row, + int64_t start, + uint32_t count) + { + AscendC::LocalTensor xLocal = inQueue_.AllocTensor(); + AscendC::DataCopyExtParams copyParams{ + 1, static_cast(count * sizeof(T)), 0, 0, 0}; + AscendC::DataCopyPadExtParams padParams{false, 0, 0, 0}; + AscendC::DataCopyPad(xLocal, xGm_[row * hiddenSize_ + start], copyParams, padParams); + inQueue_.EnQue(xLocal); + xLocal = inQueue_.DeQue(); + inTile_ = xLocal; + + if constexpr (std::is_same_v) { + return xLocal; + } else { + AscendC::LocalTensor fLocal = fp32Buf_.Get(); + AscendC::Cast(fLocal, xLocal, AscendC::RoundMode::CAST_NONE, count); + return fLocal; + } + } + + __aicore__ inline void FreeTile() + { + inQueue_.FreeTensor(inTile_); + } + + // Load weight[start:start+count] and cast it into wFp32Buf_[0:count]. + __aicore__ inline void LoadWeightFp32(int64_t start, uint32_t count) + { + AscendC::LocalTensor wLocal = wQueue_.AllocTensor(); + AscendC::DataCopyExtParams copyParams{ + 1, static_cast(count * sizeof(T)), 0, 0, 0}; + AscendC::DataCopyPadExtParams padParams{false, 0, 0, 0}; + AscendC::DataCopyPad(wLocal, weightGm_[start], copyParams, padParams); + wQueue_.EnQue(wLocal); + wLocal = wQueue_.DeQue(); + AscendC::LocalTensor wFp32 = wFp32Buf_.Get(); + if constexpr (std::is_same_v) { + CopyFp32(wFp32, wLocal, count); + } else { + AscendC::Cast(wFp32, wLocal, AscendC::RoundMode::CAST_NONE, count); + } + wQueue_.FreeTensor(wLocal); + } + + // Contiguous fp32 UB -> UB copy (64 elements per 256 B vector repeat). + __aicore__ inline void CopyFp32(const AscendC::LocalTensor& dst, + const AscendC::LocalTensor& src, + uint32_t count) + { + const uint8_t repeat = static_cast((count + 63) / 64); + AscendC::Copy(dst, src, 64, repeat, AscendC::CopyRepeatParams{1, 1, 8, 8}); + } + + // Load rstd[row0 : row0+rows] into scalarBuf_[0:rows]. + __aicore__ inline void LoadRstd(int64_t row0, int64_t rows) + { + AscendC::LocalTensor scalar = scalarBuf_.Get(); + // Drain the previous chunk's scalar reads before MTE2 overwrites the + // staging slots (scalar unit vs MTE2 are async engines). + AscendC::SetFlag(eventSMTE2_); + AscendC::WaitFlag(eventSMTE2_); + AscendC::DataCopyExtParams inParams{ + 1, static_cast(rows * sizeof(float)), 0, 0, 0}; + AscendC::DataCopyPadExtParams padParams{false, 0, 0, 0}; + AscendC::DataCopyPad(scalar, rstdGm_[row0], inParams, padParams); + AscendC::SetFlag(eventMTE2S_); // copy-in -> scalar read + AscendC::WaitFlag(eventMTE2S_); + } + + __aicore__ inline void ProcessRow(int64_t row, float rstd) + { + if (singleTile_) { + // Fast path: the whole row stays resident in fp32Buf_ across the + // scaling, so x is read from GM only once. + const uint32_t count = static_cast(hiddenSize_); + AscendC::LocalTensor fLocal = LoadTileFp32(row, 0, count); + ScaleStoreTile(row, 0, count, fLocal, rstd); + FreeTile(); + return; + } + + // Fixed tile order over the row; rstd is the same for every tile. + const int64_t tileCount = (hiddenSize_ + TILE_LENGTH - 1) / TILE_LENGTH; + for (int64_t tile = 0; tile < tileCount; ++tile) { + const int64_t start = tile * TILE_LENGTH; + const uint32_t count = TileCount(start); + AscendC::LocalTensor fLocal = LoadTileFp32(row, start, count); + LoadWeightFp32(start, count); + ScaleStoreTile(row, start, count, fLocal, rstd); + FreeTile(); + } + } + + // Chunk path for small rows: R contiguous rows are loaded, scaled and + // stored as one flat tile, with a single sync round-trip per chunk. + // Per-row numerics are identical to ProcessRow (elementwise fp32 ops on + // the row's tile with the row's rstd), so results are unchanged. + __aicore__ inline void ProcessRowChunk(int64_t row0, int64_t rows) + { + const int64_t H = hiddenSize_; + const uint32_t count = static_cast(rows * H); + + // Rows [row0, row0+rows) are contiguous in GM: one flat copy-in. + AscendC::LocalTensor xLocal = inQueue_.AllocTensor(); + AscendC::DataCopyExtParams copyParams{ + 1, static_cast(count * sizeof(T)), 0, 0, 0}; + AscendC::DataCopyPadExtParams padParams{false, 0, 0, 0}; + AscendC::DataCopyPad(xLocal, xGm_[row0 * H], copyParams, padParams); + inQueue_.EnQue(xLocal); + xLocal = inQueue_.DeQue(); + inTile_ = xLocal; + + AscendC::LocalTensor fLocal = fp32Buf_.Get(); + if constexpr (std::is_same_v) { + fLocal = xLocal; + } else { + AscendC::Cast(fLocal, xLocal, AscendC::RoundMode::CAST_NONE, count); + } + + // Scale every row of the chunk with its own rstd (staged by LoadRstd + // before this call); scalar-register operands of Muls/Mul need no + // S_V flag (no UB dependency). + AscendC::LocalTensor scalar = scalarBuf_.Get(); + AscendC::LocalTensor wFp32 = wFp32Buf_.Get(); + for (int64_t r = 0; r < rows; ++r) { + const float rstd = scalar.GetValue(static_cast(r)); + AscendC::Muls(fLocal[r * H], fLocal[r * H], rstd, static_cast(H)); + AscendC::Mul(fLocal[r * H], fLocal[r * H], wFp32, static_cast(H)); + } + + AscendC::LocalTensor yLocal = outQueue_.AllocTensor(); + if constexpr (std::is_same_v) { + CopyFp32(yLocal, fLocal, count); + } else { + AscendC::Cast(yLocal, fLocal, AscendC::RoundMode::CAST_RINT, count); + } + outQueue_.EnQue(yLocal); + yLocal = outQueue_.DeQue(); + AscendC::DataCopyExtParams outParams{ + 1, static_cast(count * sizeof(T)), 0, 0, 0}; + AscendC::DataCopyPad(yGm_[row0 * H], yLocal, outParams); + outQueue_.FreeTensor(yLocal); + FreeTile(); + } + + // y tile = x tile * rstd * w tile, cast back to T and copied to GM. + __aicore__ inline void ScaleStoreTile(int64_t row, + int64_t start, + uint32_t count, + const AscendC::LocalTensor& fLocal, + float rstd) + { + AscendC::LocalTensor wFp32 = wFp32Buf_.Get(); + // Vector pipe is in-order: the LoadWeightFp32 cast into wFp32Buf_ + // needs no extra flag before these reads. + AscendC::Muls(fLocal, fLocal, rstd, count); + AscendC::Mul(fLocal, fLocal, wFp32, count); + + AscendC::LocalTensor yLocal = outQueue_.AllocTensor(); + if constexpr (std::is_same_v) { + CopyFp32(yLocal, fLocal, count); + } else { + AscendC::Cast(yLocal, fLocal, AscendC::RoundMode::CAST_RINT, count); + } + outQueue_.EnQue(yLocal); + yLocal = outQueue_.DeQue(); + AscendC::DataCopyExtParams outParams{ + 1, static_cast(count * sizeof(T)), 0, 0, 0}; + AscendC::DataCopyPad(yGm_[row * hiddenSize_ + start], yLocal, outParams); + outQueue_.FreeTensor(yLocal); + } + + __aicore__ inline uint32_t TileCount(int64_t start) const + { + const int64_t remaining = hiddenSize_ - start; + return static_cast(remaining < TILE_LENGTH ? remaining : TILE_LENGTH); + } + + AscendC::TPipe* pipe_; + AscendC::GlobalTensor xGm_; + AscendC::GlobalTensor weightGm_; + AscendC::GlobalTensor rstdGm_; + AscendC::GlobalTensor yGm_; + AscendC::TQue inQueue_; + AscendC::TQue wQueue_; + AscendC::TQue outQueue_; + AscendC::TBuf fp32Buf_; + AscendC::TBuf wFp32Buf_; + AscendC::TBuf scalarBuf_; + AscendC::LocalTensor inTile_; + AscendC::TEventID eventSMTE2_; + AscendC::TEventID eventMTE2S_; + int64_t numRows_; + int64_t hiddenSize_; + bool singleTile_; + int64_t rowsPerChunk_; +}; + +} // namespace + +extern "C" __global__ __vector__ void rmsnorm_ascend_kernel_fp32( + GM_ADDR x, GM_ADDR weight, GM_ADDR rstd, GM_ADDR y, + int64_t numRows, int64_t hiddenSize) +{ + AscendC::TPipe pipe; + KernelRmsNorm op(&pipe); + op.Init(x, weight, rstd, y, numRows, hiddenSize); + op.Process(); +} + +extern "C" __global__ __vector__ void rmsnorm_ascend_kernel_bf16( + GM_ADDR x, GM_ADDR weight, GM_ADDR rstd, GM_ADDR y, + int64_t numRows, int64_t hiddenSize) +{ + AscendC::TPipe pipe; + KernelRmsNorm op(&pipe); + op.Init(x, weight, rstd, y, numRows, hiddenSize); + op.Process(); +} + +extern "C" __global__ __vector__ void rmsnorm_ascend_kernel_fp16( + GM_ADDR x, GM_ADDR weight, GM_ADDR rstd, GM_ADDR y, + int64_t numRows, int64_t hiddenSize) +{ + AscendC::TPipe pipe; + KernelRmsNorm op(&pipe); + op.Init(x, weight, rstd, y, numRows, hiddenSize); + op.Process(); +} + +torch::Tensor rmsnorm_ascend_forward(torch::Tensor x, + torch::Tensor weight, + torch::Tensor rstd) +{ + TORCH_CHECK(x.is_privateuseone(), "x must be on an NPU device"); + TORCH_CHECK(x.dim() == 2, "x must be 2-D [N, H]"); + TORCH_CHECK(x.is_contiguous(), "x must be contiguous"); + TORCH_CHECK(x.scalar_type() == at::kBFloat16 || x.scalar_type() == at::kFloat || + x.scalar_type() == at::kHalf, + "x must be fp32, bf16 or fp16"); + TORCH_CHECK(x.size(-1) > 0, "hidden size must be positive"); + TORCH_CHECK(weight.is_privateuseone(), "weight must be on the same NPU device as x"); + TORCH_CHECK(weight.dim() == 1 && weight.numel() == x.size(-1), + "weight must be 1-D of size x.size(-1)"); + TORCH_CHECK(weight.scalar_type() == x.scalar_type(), "weight dtype must match x dtype"); + TORCH_CHECK(rstd.is_privateuseone(), "rstd must be on the same NPU device as x"); + TORCH_CHECK(rstd.dim() == 1 && rstd.numel() == x.size(0), + "rstd must be 1-D of size x.size(0)"); + TORCH_CHECK(rstd.scalar_type() == at::kFloat, "rstd must be fp32"); + TORCH_CHECK(rstd.is_contiguous(), "rstd must be contiguous"); + + const int64_t numRows = x.size(0); + const int64_t hiddenSize = x.size(1); + + torch::Tensor y = at::empty_like(x); + if (numRows == 0) { + return y; + } + + torch::Tensor weightContig = weight.contiguous(); + + // stream(true): flush the task queue before launch so the kernel cannot + // overtake earlier NPU work; outputs were allocated with at::empty (no + // queued initializer) for the same reason. + auto aclStream = c10_npu::getCurrentNPUStream().stream(true); + const uint32_t blockNum = static_cast(std::min(numRows, MAX_BLOCKS)); + + if (x.scalar_type() == at::kBFloat16) { + rmsnorm_ascend_kernel_bf16<<>>( + reinterpret_cast(x.mutable_data_ptr()), + reinterpret_cast(weightContig.mutable_data_ptr()), + reinterpret_cast(rstd.data_ptr()), + reinterpret_cast(y.mutable_data_ptr()), + numRows, hiddenSize); + } else if (x.scalar_type() == at::kHalf) { + rmsnorm_ascend_kernel_fp16<<>>( + reinterpret_cast(x.mutable_data_ptr()), + reinterpret_cast(weightContig.mutable_data_ptr()), + reinterpret_cast(rstd.data_ptr()), + reinterpret_cast(y.mutable_data_ptr()), + numRows, hiddenSize); + } else { + rmsnorm_ascend_kernel_fp32<<>>( + reinterpret_cast(x.mutable_data_ptr()), + reinterpret_cast(weightContig.mutable_data_ptr()), + reinterpret_cast(rstd.data_ptr()), + reinterpret_cast(y.mutable_data_ptr()), + numRows, hiddenSize); + } + return y; +} diff --git a/csrc/ascend/rope_ascend.asc b/csrc/ascend/rope_ascend.asc new file mode 100644 index 00000000..c3606909 --- /dev/null +++ b/csrc/ascend/rope_ascend.asc @@ -0,0 +1,296 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (c) 2026 RL-Kernel Contributors +// +// Ascend C RoPE apply primitive (GPT-NeoX / Hugging Face rotate-half). +// +// The Python wrapper builds fp32 cos/sin tables from positions and theta, then +// flattens x to [n_rows, D]. For pair i in [0, D/2): +// +// out[i] = x[i] * cos[i] - x[i+D/2] * sin[i] * sin_sign +// out[i+D/2] = x[i+D/2] * cos[i] + x[i] * sin[i] * sin_sign +// +// sin_sign=+1 is the forward rotation and sin_sign=-1 is its transpose, used +// for grad_x. Each row is processed by one block with a fixed tile order, so +// adding or moving other batch rows cannot alter the instruction sequence. + +#include +#include + +#include "kernel_operator.h" + +#include + +#include "torch_npu/csrc/core/npu/NPUStream.h" + +namespace { + +constexpr uint32_t ROPE_TILE_HALF = 4096; +constexpr int64_t ROPE_MAX_BLOCKS = 128; + +template +class KernelRopeApply { +public: + __aicore__ inline KernelRopeApply(AscendC::TPipe* pipe) : pipe_(pipe) {} + + __aicore__ inline void Init(GM_ADDR x, + GM_ADDR cos, + GM_ADDR sin, + GM_ADDR out, + int64_t numRows, + int64_t tableRows, + int64_t headDim, + float sinSign) + { + numRows_ = numRows; + tableRows_ = tableRows; + headDim_ = headDim; + halfDim_ = headDim / 2; + sinSign_ = sinSign; + xGm_.SetGlobalBuffer(reinterpret_cast<__gm__ T*>(x)); + cosGm_.SetGlobalBuffer(reinterpret_cast<__gm__ float*>(cos)); + sinGm_.SetGlobalBuffer(reinterpret_cast<__gm__ float*>(sin)); + outGm_.SetGlobalBuffer(reinterpret_cast<__gm__ T*>(out)); + + pipe_->InitBuffer(x1InBuf_, ROPE_TILE_HALF * sizeof(T)); + pipe_->InitBuffer(x2InBuf_, ROPE_TILE_HALF * sizeof(T)); + pipe_->InitBuffer(x1FpBuf_, ROPE_TILE_HALF * sizeof(float)); + pipe_->InitBuffer(x2FpBuf_, ROPE_TILE_HALF * sizeof(float)); + pipe_->InitBuffer(cosBuf_, ROPE_TILE_HALF * sizeof(float)); + pipe_->InitBuffer(sinBuf_, ROPE_TILE_HALF * sizeof(float)); + pipe_->InitBuffer(out1FpBuf_, ROPE_TILE_HALF * sizeof(float)); + pipe_->InitBuffer(out2FpBuf_, ROPE_TILE_HALF * sizeof(float)); + pipe_->InitBuffer(tmpFpBuf_, ROPE_TILE_HALF * sizeof(float)); + pipe_->InitBuffer(out1Buf_, ROPE_TILE_HALF * sizeof(T)); + pipe_->InitBuffer(out2Buf_, ROPE_TILE_HALF * sizeof(T)); + + // Mark the reusable input and output buffers as initially available. + AscendC::SetFlag(0); + AscendC::SetFlag(0); + } + + __aicore__ inline void Process() + { + for (int64_t row = AscendC::GetBlockIdx(); row < numRows_; row += AscendC::GetBlockNum()) { + ProcessRow(row); + } + // Drain the final tile before the block exits and its UB is reclaimed. + AscendC::WaitFlag(0); + AscendC::WaitFlag(0); + } + +private: + __aicore__ inline void ProcessRow(int64_t row) + { + const int64_t tableRow = row % tableRows_; + for (int64_t start = 0; start < halfDim_; start += ROPE_TILE_HALF) { + const int64_t remaining = halfDim_ - start; + const uint32_t count = static_cast( + remaining < ROPE_TILE_HALF ? remaining : ROPE_TILE_HALF); + + // Previous vector reads are complete before MTE2 reuses input/cache buffers. + AscendC::WaitFlag(0); + CopyIn(row, tableRow, start, count); + AscendC::SetFlag(0); + AscendC::WaitFlag(0); + + // Previous MTE3 reads are complete before V reuses output buffers. + AscendC::WaitFlag(0); + Compute(count); + + // The next MTE2 tile may reuse its buffers after all vector reads finish. + AscendC::SetFlag(0); + AscendC::SetFlag(0); + AscendC::WaitFlag(0); + CopyOut(row, start, count); + AscendC::SetFlag(0); + } + } + + __aicore__ inline void CopyIn(int64_t row, + int64_t tableRow, + int64_t start, + uint32_t count) + { + AscendC::DataCopyExtParams xParams{ + 1, static_cast(count * sizeof(T)), 0, 0, 0}; + AscendC::DataCopyExtParams fpParams{ + 1, static_cast(count * sizeof(float)), 0, 0, 0}; + AscendC::DataCopyPadExtParams xPad{false, 0, 0, 0}; + AscendC::DataCopyPadExtParams fpPad{false, 0, 0, 0}; + + const int64_t xBase = row * headDim_ + start; + if constexpr (std::is_same_v) { + AscendC::DataCopyPad(x1FpBuf_.Get(), xGm_[xBase], xParams, xPad); + AscendC::DataCopyPad( + x2FpBuf_.Get(), xGm_[xBase + halfDim_], xParams, xPad); + } else { + AscendC::DataCopyPad(x1InBuf_.Get(), xGm_[xBase], xParams, xPad); + AscendC::DataCopyPad(x2InBuf_.Get(), xGm_[xBase + halfDim_], xParams, xPad); + } + + const int64_t cacheBase = tableRow * halfDim_ + start; + AscendC::DataCopyPad(cosBuf_.Get(), cosGm_[cacheBase], fpParams, fpPad); + AscendC::DataCopyPad(sinBuf_.Get(), sinGm_[cacheBase], fpParams, fpPad); + } + + __aicore__ inline void Compute(uint32_t count) + { + AscendC::LocalTensor x1 = x1FpBuf_.Get(); + AscendC::LocalTensor x2 = x2FpBuf_.Get(); + if constexpr (!std::is_same_v) { + AscendC::Cast(x1, x1InBuf_.Get(), AscendC::RoundMode::CAST_NONE, count); + AscendC::Cast(x2, x2InBuf_.Get(), AscendC::RoundMode::CAST_NONE, count); + } + + AscendC::LocalTensor cos = cosBuf_.Get(); + AscendC::LocalTensor sin = sinBuf_.Get(); + AscendC::LocalTensor out1 = out1FpBuf_.Get(); + AscendC::LocalTensor out2 = out2FpBuf_.Get(); + AscendC::LocalTensor tmp = tmpFpBuf_.Get(); + + AscendC::Muls(sin, sin, sinSign_, count); + AscendC::Mul(out1, x1, cos, count); + AscendC::Mul(tmp, x2, sin, count); + AscendC::Sub(out1, out1, tmp, count); + AscendC::Mul(out2, x2, cos, count); + AscendC::Mul(tmp, x1, sin, count); + AscendC::Add(out2, out2, tmp, count); + + if constexpr (!std::is_same_v) { + AscendC::Cast( + out1Buf_.Get(), out1, AscendC::RoundMode::CAST_RINT, count); + AscendC::Cast( + out2Buf_.Get(), out2, AscendC::RoundMode::CAST_RINT, count); + } + } + + __aicore__ inline void CopyOut(int64_t row, int64_t start, uint32_t count) + { + AscendC::DataCopyExtParams outParams{ + 1, static_cast(count * sizeof(T)), 0, 0, 0}; + const int64_t outBase = row * headDim_ + start; + if constexpr (std::is_same_v) { + AscendC::DataCopyPad(outGm_[outBase], out1FpBuf_.Get(), outParams); + AscendC::DataCopyPad( + outGm_[outBase + halfDim_], out2FpBuf_.Get(), outParams); + } else { + AscendC::DataCopyPad(outGm_[outBase], out1Buf_.Get(), outParams); + AscendC::DataCopyPad(outGm_[outBase + halfDim_], out2Buf_.Get(), outParams); + } + } + + AscendC::TPipe* pipe_; + AscendC::GlobalTensor xGm_; + AscendC::GlobalTensor cosGm_; + AscendC::GlobalTensor sinGm_; + AscendC::GlobalTensor outGm_; + AscendC::TBuf x1InBuf_; + AscendC::TBuf x2InBuf_; + AscendC::TBuf x1FpBuf_; + AscendC::TBuf x2FpBuf_; + AscendC::TBuf cosBuf_; + AscendC::TBuf sinBuf_; + AscendC::TBuf out1FpBuf_; + AscendC::TBuf out2FpBuf_; + AscendC::TBuf tmpFpBuf_; + AscendC::TBuf out1Buf_; + AscendC::TBuf out2Buf_; + int64_t numRows_; + int64_t tableRows_; + int64_t headDim_; + int64_t halfDim_; + float sinSign_; +}; + +} // namespace + +extern "C" __global__ __vector__ void rope_apply_ascend_kernel_fp32( + GM_ADDR x, GM_ADDR cos, GM_ADDR sin, GM_ADDR out, + int64_t numRows, int64_t tableRows, int64_t headDim, float sinSign) +{ + AscendC::TPipe pipe; + KernelRopeApply op(&pipe); + op.Init(x, cos, sin, out, numRows, tableRows, headDim, sinSign); + op.Process(); +} + +extern "C" __global__ __vector__ void rope_apply_ascend_kernel_fp16( + GM_ADDR x, GM_ADDR cos, GM_ADDR sin, GM_ADDR out, + int64_t numRows, int64_t tableRows, int64_t headDim, float sinSign) +{ + AscendC::TPipe pipe; + KernelRopeApply op(&pipe); + op.Init(x, cos, sin, out, numRows, tableRows, headDim, sinSign); + op.Process(); +} + +extern "C" __global__ __vector__ void rope_apply_ascend_kernel_bf16( + GM_ADDR x, GM_ADDR cos, GM_ADDR sin, GM_ADDR out, + int64_t numRows, int64_t tableRows, int64_t headDim, float sinSign) +{ + AscendC::TPipe pipe; + KernelRopeApply op(&pipe); + op.Init(x, cos, sin, out, numRows, tableRows, headDim, sinSign); + op.Process(); +} + +torch::Tensor rope_apply_ascend_forward(torch::Tensor x, + torch::Tensor cos, + torch::Tensor sin, + double sin_sign) +{ + TORCH_CHECK(x.is_privateuseone(), "rope: x must be on an NPU device"); + TORCH_CHECK(x.dim() == 2, "rope: x must be 2-D [n_rows, D]"); + TORCH_CHECK(x.is_contiguous(), "rope: x must be contiguous"); + TORCH_CHECK( + x.scalar_type() == at::kHalf || x.scalar_type() == at::kBFloat16 || + x.scalar_type() == at::kFloat, + "rope: x must be fp16, bf16, or fp32"); + TORCH_CHECK(cos.is_privateuseone() && sin.is_privateuseone(), + "rope: cos/sin must be on an NPU device"); + TORCH_CHECK(cos.device() == x.device() && sin.device() == x.device(), + "rope: x, cos, and sin must be on the same NPU device"); + TORCH_CHECK(cos.scalar_type() == at::kFloat && sin.scalar_type() == at::kFloat, + "rope: cos/sin must be fp32"); + TORCH_CHECK(cos.dim() == 2 && sin.dim() == 2, + "rope: cos/sin must be 2-D [table_rows, D/2]"); + TORCH_CHECK(cos.is_contiguous() && sin.is_contiguous(), + "rope: cos/sin must be contiguous"); + TORCH_CHECK(cos.sizes() == sin.sizes(), "rope: cos/sin shapes must match"); + + const int64_t numRows = x.size(0); + const int64_t headDim = x.size(1); + TORCH_CHECK(headDim > 0 && headDim % 2 == 0, + "rope: head_dim must be a positive even number"); + TORCH_CHECK(cos.size(1) == headDim / 2, + "rope: cos/sin last dimension must equal head_dim/2"); + + torch::Tensor out = at::empty_like(x); + if (numRows == 0) { + return out; + } + + const int64_t tableRows = cos.size(0); + TORCH_CHECK(tableRows > 0, "rope: cos/sin table must contain at least one row"); + TORCH_CHECK(numRows % tableRows == 0, + "rope: n_rows must be divisible by the cos/sin table row count"); + auto aclStream = c10_npu::getCurrentNPUStream().stream(true); + const uint32_t blockNum = static_cast(std::min(numRows, ROPE_MAX_BLOCKS)); + const float sign = static_cast(sin_sign); + + auto* xPtr = reinterpret_cast(x.mutable_data_ptr()); + auto* cosPtr = reinterpret_cast(cos.mutable_data_ptr()); + auto* sinPtr = reinterpret_cast(sin.mutable_data_ptr()); + auto* outPtr = reinterpret_cast(out.mutable_data_ptr()); + if (x.scalar_type() == at::kFloat) { + rope_apply_ascend_kernel_fp32<<>>( + xPtr, cosPtr, sinPtr, outPtr, numRows, tableRows, headDim, sign); + } else if (x.scalar_type() == at::kHalf) { + rope_apply_ascend_kernel_fp16<<>>( + xPtr, cosPtr, sinPtr, outPtr, numRows, tableRows, headDim, sign); + } else { + rope_apply_ascend_kernel_bf16<<>>( + xPtr, cosPtr, sinPtr, outPtr, numRows, tableRows, headDim, sign); + } + return out; +} diff --git a/docs/design/ws1-ascend-closeout-plan.md b/docs/design/ws1-ascend-closeout-plan.md new file mode 100644 index 00000000..7c4e51d3 --- /dev/null +++ b/docs/design/ws1-ascend-closeout-plan.md @@ -0,0 +1,142 @@ +# WS1 #266 closeout on Ascend NPU (`ascend_bf16`) + +Issue [#266](https://github.com/RL-Align/RL-Kernel/issues/266) is the single +execution and acceptance entry for WS1: single-GPU **model-level** train–inference +consistency for the **full Qwen3-8B Dense** model, judged by C1–C11 (#267–#277). +Its two required profiles are `cuda_bf16` and `triton_cuda_bf16`. + +This document covers the Ascend version: a third required profile, **`ascend_bf16`** +(backend family `ascend`), carried through every one of C1–C11 on the same shared +contract and the same harnesses. + +## What is and is not claimed + +| | | +| --- | --- | +| **In scope** | Single-NPU model-level train–inference consistency for full Qwen3-8B Dense on the in-repo Ascend C (CANN) operator stack, under the same `tolerance_contract.json`, the same #150 matrix, the same #152 KV path, and the same four judgments. | +| **Out of scope** | Multi-NPU (TP/CP/SP/DP) → WS2. vime / real vLLM / real Megatron integration → WS3. FP8, MoE, throughput KPIs. | +| **Not claimed** | Cross-platform bitwise parity with CUDA or Triton. Ascend's vector units have their own reduction order; the guarantee is the same one each platform provides for itself — batch-invariant determinism under the shared contract. This mirrors the Triton-vs-CUDA situation, which #266 already treats as two independent profiles rather than one comparison. | + +`ascend_bf16` is **required**, not optional. A missing or unexecuted Ascend cell is +**red**, never N/A and never a fallback to another vendor's kernel — the same rule +#266 applies to a missing Triton candidate. + +## Chain node → Ascend kernel + +All eleven required C2 chain nodes resolve to the `ascend` candidate: + +| Chain node | Op | Ascend kernel | +| --- | --- | --- | +| `embedding` | `AscendEmbeddingOp` | `csrc/ascend/embedding_ascend.asc` | +| `rms_norm` | `RMSNormAscendOp` | `csrc/ascend/rmsnorm_ascend.asc` | +| `det_gemm` | `DetGemmAscendOp` | `csrc/ascend/gemm/det_gemm_ascend.asc` (PR #405) | +| `qk_norm` | `RMSNormAscendOp` | same RMSNorm kernel, per-head | +| `rope` | `RoPEAscendOp` | `csrc/ascend/rope_ascend.asc` | +| `attention` | `DeterministicAttentionAscendOp` | `csrc/ascend/attention/deterministic_attention_ascend.asc` | +| `swiglu` | `SwiGLUAscendOp` | `csrc/ascend/activation.asc` | +| `silu` | `SiLUAscendOp` | `csrc/ascend/activation.asc` — **new in this PR** | +| `lm_head` | `AscendLMHeadOp` | `csrc/ascend/lm_head_ascend.asc` | +| `logprob` | `FusedLogpAscendOp` | `csrc/ascend/fused_logp_ascend.asc` | +| `batch_invariant_logp` | `BatchInvariantLogpAscendOp` | `csrc/ascend/batch_invariant_logp_ascend.asc` | + +Two kernel-level gaps had to be closed before the profile could be wired: + +- **`silu`.** A required C2 chain node with no Ascend kernel. Added to + `csrc/ascend/activation.asc` alongside SwiGLU, sharing its tile geometry and its + FP32 sigmoid sequence, so `silu(x)` is bitwise equal to `swiglu(x, ones)`. + Substituting SwiGLU-with-a-unit-operand at the dispatch layer was rejected: the + node would then report SwiGLU provenance, which C1 treats as an undeclared backend. +- **FP32-accumulation GEMM.** The canonical row-fold VJP (the construction that makes + a shared parameter's gradient depend only on logical row identity, not on batching) + needs a deterministic **FP32-in** GEMM. The Ascend det_gemm kernel is BF16-in only. + `det_gemm_rowwise_ascend_fwd_fp32` exposes the existing `lm_head_ascend` kernel — + which already accepts FP32 and reduces each output element in one fixed per-row + order — as a general GEMM by passing `Bᵀ`. This is exactly how CUDA builds + `det_gemm_rowwise_fwd_fp32` from its SM90 lm_head kernel. Casting the VJP down to + BF16 instead would have kept determinism but broken the contract's FP32-accumulation + rule and the `gradient_accuracy` judgment. + +## C1–C11 disposition + +| ID | Issue | Ascend delivery | +| --- | --- | --- | +| **C1** | #267 | `tolerance_contract.json` declares `ascend_bf16 → backend_family "ascend"`; `tolerance.py` requires it in `_validate_policy`. Thresholds, dtype policy, comparison roles and the three aggregates are unchanged — there is no Ascend-private relaxation (`backend_private_tolerance_relaxation` stays `false`). TF32 is "disabled" by construction: Ascend has no TF32 mode, and `disable_tf32("npu")` reports `candidate_tf32_enabled=False`. | +| **C2** | #268 | `ws1_manifest.json` gains the `ascend_bf16` profile with all 11 required nodes `declared`, plus 23 representative cases mirroring the CUDA set one-for-one (same fixtures, same tiers, same shapes), each pinning a real `.asc` entry point. `version` bumps to `ws1-c2-v8` and `fixture_identity_sha256` is regenerated. `workload_id` is deliberately **unchanged**: the logical workload, fixtures and seed are identical, so existing CUDA/Triton evidence stays bound to the same workload. | +| **C3** | #269 | `scripts/check_forward_invariance.py` accepts `--backend-profile ascend_bf16` and runs on the profile's own device. Report provenance carries the NPU name and SoC key. | +| **C4** | #270 | `scripts/check_gradient_invariance.py` likewise; `gradient_adapter_status_matrix` now sweeps all three profiles and every required adapter resolves an `ascend` candidate with no red rows. | +| **C5** | #271 | `elementwise_inventory.py` gains an `ascend_verdict` column. Items whose audit argument is backend-independent (residual add, scale, bias, dtype cast) carry over as `pass`; real kernels (rope, silu, swiglu, mask_fill) are `tracked_red` until C3/C4 have executed on an NPU host. | +| **C6** | #272 | `kv_consistency.assert_decode_prefill_consistent` resolves the device from the profile instead of assuming CUDA; `scripts/check_decode_prefill.py` takes `ascend_bf16`. | +| **C7** | #273 | Same for `assert_stateful_kv_consistent` / `scripts/check_stateful_kv.py`. B2 stays explicitly absent, as on CUDA. | +| **C8** | #274 | `four_judgment_matrix.PROFILES` includes `ascend_bf16`, and `build_classified_matrix(profiles=…)` can be scoped to one host's profiles. `scripts/sweep_ws1_four_judgments.py --profile ascend_bf16 --execute` runs the Ascend grid. | +| **C9** | #275 | `qwen3_dense.py` is device-agnostic: the runtime observation check asserts the profile's own device type, `_family` maps `ascend`, and the canonical backward paths gained Ascend branches (`canonical_ascend_rmsnorm`, the row-fold LM head and linear with `family="ascend"` provenance). | +| **C10** | #276 | `chain_gate.py` and `scripts/ws1_chain_gate.py` run the full #150 matrix + train/infer parity on the NPU. Evidence records `gpu_name` (the NPU) and the SoC as the architecture key. | +| **C11** | #277 | `ci/run_ws1_ascend_ci.sh` is the NPU host entry (build → linkage check → tests → C2 evidence → C3/C4 → C6/C7 → C8 → C10), and `.github/workflows/ws1-chain-npu.yml` runs it on a self-hosted Ascend runner. `ci/run_ws1_chain_gate.sh` is now profile-parameterised through `WS1_PROFILES`. | + +## Why a per-host profile split + +A machine has a GPU or an NPU, not both. Sweeping all three profiles on one host +would force the absent vendor's cells to red for a reason that is not a defect. +So the C8 sweep, the candidate-evidence script and the chain-gate CI script all take +an explicit profile list, and each vendor's job proves its own profiles. **C11 closes +only when every required profile has gone green on its own hardware** — the split is +in where the work runs, never in what is required. + +## Accelerator abstraction + +`rl_engine/kernels/gtest/accelerator.py` holds the vendor-dependent facts the gates +need: availability, device resolution, device name, architecture key (`sm90` on CUDA, +the SoC string on Ascend), TF32 policy, seeding, synchronization and cache release. +It fails closed — asking for `ascend_bf16` on a host with no NPU raises +`AcceleratorUnavailable`, and pointing an Ascend profile at `cuda:0` is rejected +before any device probe rather than silently running the wrong kernels. + +## Running the gates on an Ascend host + +```bash +# Atlas A2 / 910B, CANN + torch_npu installed +export WS1_WEIGHTS_PATH=/path/to/Qwen3-8B # pinned snapshot +bash ci/run_ws1_ascend_ci.sh # everything below, in order +``` + +Individual gates: + +```bash +KERNEL_ALIGN_FORCE_ASCEND=1 pip install -e . --no-build-isolation --no-deps + +# Operator tests +pytest -q tests/test_silu_ascend.py tests/test_det_gemm_ascend.py +pytest -q tests/test_ws1_ascend_closeout.py # CPU-only wiring checks + +# C2 runtime candidate evidence +python scripts/ws1_candidate_evidence.py --profile ascend_bf16 --all --check-grad + +# C3 / C4 +python scripts/check_forward_invariance.py --op silu --candidate ascend --backend-profile ascend_bf16 +python scripts/check_gradient_invariance.py --op silu --candidate ascend --backend-profile ascend_bf16 + +# C6 / C7 +python scripts/check_decode_prefill.py --backend-profile ascend_bf16 +python scripts/check_stateful_kv.py --backend-profile ascend_bf16 + +# C8 +python scripts/sweep_ws1_four_judgments.py --execute --profile ascend_bf16 --json + +# C9 / C10 +python scripts/ws1_chain_fwd_bwd.py --backend-profile ascend_bf16 --weights-path "$WS1_WEIGHTS_PATH" +WS1_PROFILES=ascend_bf16 bash ci/run_ws1_chain_gate.sh +``` + +## Status + +Everything above is wired and green on the CPU-side checks. The on-device +evidence — C2 runtime provenance, C3/C4, C6/C7, the C8 grid and the C10 full-model +gate — has **not** been collected yet: it needs an Ascend host. Until it is, the +Ascend C5 rows stay `tracked_red` and the C8 Ascend cells stay red, which is the +correct pre-execution state and not a claim of failure. + +## See also + +- `docs/design/ws1-c2-268-workload-plan.md` — the workload identity this profile reuses +- `docs/design/ws1-c4-270-gradient-plan.md` — the gradient harness contract +- `docs/design/ws1-c6-c11-closeout-plan.md` — the CUDA/Triton closeout plan +- `docs/operators/det-gemm.md`, `docs/operators/activation.md` — the Ascend kernels diff --git a/docs/operators/activation.md b/docs/operators/activation.md index a6f2cf46..df9fb3ed 100644 --- a/docs/operators/activation.md +++ b/docs/operators/activation.md @@ -2,7 +2,7 @@ The activation operators are the element-wise core of the Qwen3/Llama gated MLP. They implement the WS1 dual-path contract (issue #108): pure-PyTorch fp32 ground truth, plus -CUDA and Triton candidates that validate against it. +CUDA, Triton and Ascend C candidates that validate against it. - **SiLU** (`NativeSiLUOp` / `SiLUCudaOp` / `TritonSiLUOp`): `silu(x) = x * sigmoid(x)` — the `hidden_act="silu"` gate. @@ -44,6 +44,7 @@ All backends expose the WS1 dual-path contract: | PyTorch fallback | `NativeSiLUOp` / `NativeSwiGLUOp` | None | fp32 ground-truth reference; CPU and any GPU. | | CUDA | `SiLUCudaOp` / `SwiGLUCudaOp` | `_C.silu_*` / `_C.swiglu_*` | General CUDA (fp16/bf16/fp32); math in fp32. | | Triton | `TritonSiLUOp` / `TritonSwiGLUOp` | Triton JIT | Portable GPU baseline; same fp32 math contract. | +| Ascend C | `SiLUAscendOp` / `SwiGLUAscendOp` | `_C_npu.silu_*` / `_C_npu.swiglu_*` | NPU forward and backward; fp16/bf16/fp32 inputs, FP32 math. | ## Tensor Contract @@ -67,10 +68,65 @@ mutation, device/dtype follow the inputs. | `cuda` | CUDA → Triton → PyTorch native | | `rocm` | Triton → PyTorch native | | `cpu` | PyTorch native | +| `npu` | Ascend C → PyTorch native | If the CUDA extension is not built (or symbols are missing), the registry falls back to Triton, then to the native gold. +On NPU, a missing Ascend extension or missing SiLU/SwiGLU symbols causes the registry +to select PyTorch native. Construct `SiLUAscendOp` / `SwiGLUAscendOp` directly when the +Ascend C kernel is required; the constructors raise if a native symbol is missing. + +Both NPU kernels share one tile geometry (`TILE_LENGTH = 2048`, `MAX_BLOCKS = 32`) and +the same FP32 `1 / (1 + exp(-x))` sequence, so `silu(x)` is bitwise equal to +`swiglu(x, ones)` on this hardware. Each element is evaluated by a fixed expression +independent of tensor size and of the launched block count, which is what makes the +op batch-invariant for the WS1 `ascend_bf16` profile (`silu` is a required C2 chain +node, so the profile needs its own kernel rather than a SwiGLU with a unit operand). + +## Ascend C Build and Validation + +On a Linux Ascend host with matching PyTorch, `torch_npu` and CANN installed, source +the CANN environment and build the existing NPU extension: + +```bash +source /usr/local/Ascend/ascend-toolkit/set_env.sh +KERNEL_ALIGN_FORCE_ASCEND=1 KERNEL_ALIGN_ASCEND_ARCH=dav-2201 \ + python -m pip install --no-build-isolation -e . +python -m pytest tests/test_swiglu.py -v +python scripts/check_operator.py --op swiglu --candidate ascend --dtype bf16 --device npu --check-grad +``` + +The kernel uses the A2/A3 vector programming model (`dav-2201`). Other architectures +require separate build and device validation. `setup.py` automatically includes all +`csrc/ascend/*.asc` files; `bindings.asc` defines the shared `_C_npu` module entry. + +```python +import torch +import torch_npu +from rl_engine.kernels.registry import kernel_registry + +swiglu = kernel_registry.get_op("swiglu", device="npu") +gate = torch.randn(2, 12288, device="npu", dtype=torch.bfloat16, requires_grad=True) +up = torch.randn_like(gate, requires_grad=True) +out = swiglu(gate, up) +out.float().sum().backward() +``` + +The wrapper accepts scalars, empty tensors and strided views. It makes inputs and +upstream gradients contiguous before invoking the kernels. Both kernels use fixed +2048-element tiles, bounded UB storage, FP32 intermediates and a single output cast. +FP32-to-FP16/BF16 uses ties-to-even rounding (`CAST_RINT`), as specified by the +[Ascend C Cast API](https://www.hiascend.com/document/detail/zh/CANNCommunityEdition/81RC1alpha002/apiref/ascendcopapi/atlasascendc_api_07_0073.html). +`forward_fp32` returns FP32 while retaining gradients to the original inputs. +Only first-order autograd is supported by this backend. + +The tests cover dtype accuracy, both input gradients, tails, multiple tiles, +noncontiguous views, input validation, batch-position invariance, stream ordering +and multiple devices. CPU runs check Python integration and skip hardware tests; +on an NPU host a missing extension fails the hardware tests. Hardware compilation, +numerical acceptance and performance must be validated on the target NPU. + ## Accuracy Reference semantics (`forward_fp32`, fp32 accumulation): @@ -125,10 +181,15 @@ native forward+backward, registry dispatch, and the issue-#108 `OP_SPECS` harnes - `rl_engine/kernels/ops/pytorch/activation/swiglu.py` — gold - `rl_engine/kernels/ops/cuda/activation/swiglu.py` — CUDA wrappers - `rl_engine/kernels/ops/triton/activation/swiglu.py` — Triton kernels +- `rl_engine/kernels/ops/ascend/activation/swiglu.py` — Ascend SwiGLU autograd wrapper +- `rl_engine/kernels/ops/ascend/activation/silu.py` — Ascend SiLU autograd wrapper +- `csrc/ascend/activation.asc` — Ascend C SiLU + SwiGLU forward/backward kernels +- `csrc/ascend/bindings.asc` — shared NPU extension bindings - `csrc/cuda/activation.cu` — CUDA kernels - `rl_engine/kernels/registry.py` - `rl_engine/kernels/gtest/operator_specs.py` - `tests/test_swiglu.py` +- `tests/test_silu_ascend.py` ## Known Limitations diff --git a/docs/operators/attention.md b/docs/operators/attention.md index e1aff500..074bf904 100644 --- a/docs/operators/attention.md +++ b/docs/operators/attention.md @@ -49,6 +49,7 @@ The op exposes the WS1 dual-path contract: | --- | --- | --- | --- | | PyTorch fallback | `NativeAttentionOp` | None | fp32 ground-truth reference; CPU and any GPU. | | CUDA deterministic | `DeterministicAttentionOp` | `_C.deterministic_attention_forward/backward` | Batch-invariant CUDA implementation (issue #147). | +| Ascend NPU deterministic | `DeterministicAttentionAscendOp` | `_C_npu.deterministic_attention_ascend` | Batch-invariant Ascend C implementation (issue #147). | ## Tensor Contract @@ -81,6 +82,20 @@ the inputs' device. 1. `CUDA_DETERMINISTIC_ATTENTION` — `DeterministicAttentionOp` (batch-invariant, fixed-order). 2. `PYTORCH_NATIVE_ATTENTION` — `NativeAttentionOp` (fallback). +On `npu` the priority is: + +1. `ASCEND_DETERMINISTIC_ATTENTION` — `DeterministicAttentionAscendOp` (batch-invariant, + fixed-order Ascend C forward; bf16/fp16 inputs, head dim 128). +2. `PYTORCH_NATIVE_ATTENTION` — `NativeAttentionOp` (fallback). + +The Ascend kernel implements the same algorithm as the Triton reference and the CUDA op: +every `(b, q_head, row)` is processed end-to-end by exactly one AI-core block, streaming the +keys in a fixed 64-key tile order with a two-pass (max, then sum-exp + P·V) reduction. There +is **no split-K** and no second-pass merge of per-split `(m, l, u)` summaries, so the +reduction tree for a row depends only on `Skv`, `D` and the masks — never on batch size or +block assignment. The backward recomputes the native reference forward under autograd +(Triton is unavailable on NPU), matching the Triton op's portable backward. + Calling it (`__call__` -> `forward(...)`) computes in the input dtype; `forward_fp32(...)` is the explicit fp32 golden path (NativeAttentionOp only). The production `"attn"` op_type (SDPA-based `PYTORCH_ATTN`, FlashAttention, etc.) is a separate dispatch chain and is unaffected. diff --git a/docs/operators/det-gemm.md b/docs/operators/det-gemm.md index cd48d26a..66206aa1 100644 --- a/docs/operators/det-gemm.md +++ b/docs/operators/det-gemm.md @@ -27,10 +27,41 @@ rows around it. |---|---|---| | CUDA (`DetGemmOp`) | yes | Hand-written kernel. First milestone is a naive FP32 implementation (correctness first); a tensor-core (`mma.sync`) pass matching `prefix_shared_attention.cu` follows. NVIDIA SM80+. | | Triton (`TritonDetGemmOp`) | yes | Autotune disabled, BLOCK pinned, no split-K. Portable / ROCm fallback and cross-backend reference. | +| Ascend (`DetGemmAscendOp`) | yes | Ascend C (CANN) forward + backward. Mirrors the CUDA kernel's mid-split K tree: 32-element FP32 leaves rounded to BF16, merged with BF16 adds in fixed ascending leaf order; every output row-tile is reduced end-to-end by one AI-core block with a `MAX_BLOCKS`-capped strided launch, so no split-K merge exists. | | PyTorch (`NativeGemmOp`) | **no** | Plain `torch.matmul`. Reference & benchmark target ONLY — cuBLAS is not batch-invariant. Excluded from registry dispatch. | Registry dispatch for `det_gemm` includes only the deterministic backends -(CUDA → Triton). The PyTorch op must be called explicitly. +(CUDA → Triton on CUDA/ROCm, Ascend on NPU). The PyTorch op must be called +explicitly. + +### Ascend NPU backend + +`DetGemmAscendOp` implements the same strict contract on the NPU through +`_C_npu.det_gemm_ascend_*` (Ascend C, built with `KERNEL_ALIGN_FORCE_ASCEND=1`): + +- **Entry points mirror the CUDA surface 1:1**: `det_gemm_ascend_fwd`, + `fwd_rhs_transposed`, `fwd_fp32`, `da`, `db`, `db_transposed`. Backward + reuses the forward kernel on transposed operands (`dA = dC @ Bᵀ`, + `dB = Aᵀ @ dC`; `db_transposed` stores the weight gradient born contiguous + in the canonical `[N,K]` layout). +- **Reduction tree**: 32-element leaves accumulate in FP32 in ascending order + and round once to BF16 (round-to-nearest-even); leaves merge through the + CUDA `mid_tree_merge_count` mid-split tree of BF16 adds. A contiguous + half-K GEMM is one child of the tree, so simulated TP=2 (a+b) matches + TP=1 bitwise — the same TP contract as CUDA/Triton. +- **Batch-invariance**: each `(row, 128-column tile)` is processed end-to-end + by one AI-core block in fixed leaf order; tiles are strided across at most + 128 blocks, so per-element numerics depend only on `R`, never on `M` or + block assignment. +- The Ascend vector unit's fixed 32-lane MAC order inside a leaf differs from + CUDA's sequential leaf accumulation, so cross-platform bitwise parity with + the CUDA kernel is not claimed — the guarantee is batch-invariant + determinism and the contiguous-half-K TP property (the same platform-level + contract every other Ascend kernel in this repo provides). + +Dtypes: BF16 in, FP32 accumulation, BF16 out (`forward_fp32` returns FP32). +The reduction dimension is capped at 32768 (the training contract), matching +the CUDA tree-depth budget. ## Usage diff --git a/docs/operators/embedding.md b/docs/operators/embedding.md index 1923ec84..cb142464 100644 --- a/docs/operators/embedding.md +++ b/docs/operators/embedding.md @@ -33,6 +33,7 @@ The op exposes the WS1 dual-path contract: | --- | --- | --- | --- | | PyTorch fallback | `NativeEmbeddingOp` | None | fp32 ground-truth reference; CPU and any GPU. | | CUDA SM90 (H200/Hopper) | `SM90EmbeddingOp` | `_C.embedding_sm90_forward` | Single-card batch-invariant forward backend; deterministic duplicate-id backward in the wrapper. | +| Ascend NPU | `AscendEmbeddingOp` | `_C_npu.embedding_ascend` | Batch-invariant Ascend C forward (pure row copy); reuses the SM90 op's deterministic sorted-segment backward. | | Triton | `TritonEmbeddingOp` | `_embedding_fwd`, `_embedding_bwd` | CUDA gather with deterministic, atomic-free sorted-segment backward. | | ROCm | N/A | N/A | Falls back to the PyTorch native reference. | @@ -55,6 +56,21 @@ CPU, ROCm, and CUDA devices without the SM90 extension, dispatch uses the PyTorc the CUDA SM90 single-card batch-invariant backend is prepended and the native op remains the fallback. +On `npu` the priority is: + +1. `ASCEND_EMBEDDING` — `AscendEmbeddingOp` (batch-invariant Ascend C forward, bf16/fp16/fp32). +2. `PYTORCH_NATIVE_EMBEDDING` — `NativeEmbeddingOp` (fallback). + +The Ascend kernel implements the same semantics as the SM90 CUDA kernel: a pure row +gather (`out[t, :] = weight[token_ids[t], :]`). Every token row is copied end-to-end by +exactly one AI-core block with a fixed tile size, so the copy sequence for a row depends +only on `hidden`, never on the token count or block assignment. Because the copy performs +no arithmetic, the Ascend output is **bitwise identical** to the CUDA kernel (and to the +PyTorch reference) for identical inputs at every supported dtype; the fp32-output path +upcasts the gathered rows afterwards, which is exact for bf16/fp16. The backward reuses the +SM90 op's deterministic sorted-segment dweight (stable-sorted ids, fixed addition order), +so duplicate-id gradients match the CUDA op bit for bit. + ## Accuracy Reference semantics (`forward_fp32`): @@ -90,7 +106,8 @@ nondeterminism for repeated token ids at the cost of throughput. python -m pytest \ tests/test_embedding.py \ tests/test_triton_embedding.py \ - tests/test_canonical_embedding.py -v + tests/test_canonical_embedding.py \ + tests/test_embedding_ascend.py -v ``` Covers: correctness vs direct indexing (bitwise), dtype paths, non-int64 id tolerance, @@ -107,10 +124,14 @@ Triton sorted-segment backward and canonical logical-row ordering. - `rl_engine/kernels/ops/cuda/linear/embedding.py` - `rl_engine/kernels/ops/canonical_embedding.py` - `csrc/cuda/embedding_lm_head_sm90.cu` +- `rl_engine/kernels/ops/ascend/linear/embedding.py` — Ascend deterministic op +- `csrc/ascend/embedding_ascend.asc` — Ascend C forward kernel +- `csrc/ascend/npu_module.cpp` — shared pybind entry for `rl_engine._C_npu` - `rl_engine/kernels/registry.py` - `tests/test_embedding.py` - `tests/test_triton_embedding.py` - `tests/test_canonical_embedding.py` +- `tests/test_embedding_ascend.py` ## Known Limitations diff --git a/docs/operators/fused-logp.md b/docs/operators/fused-logp.md index 7fc8008a..912cecb7 100644 --- a/docs/operators/fused-logp.md +++ b/docs/operators/fused-logp.md @@ -31,6 +31,7 @@ reference = logp_ref.forward_fp32(logits, token_ids) | --- | --- | --- | --- | | CUDA SM90 | `FusedLogpSM90Op` | `_C.fused_logp_sm90` | Experimental TMA-oriented path for 2D contiguous bf16 logits on Hopper-class GPUs. It is disabled by default and requires `RL_KERNEL_ENABLE_EXPERIMENTAL_SM90_LOGP=1`; otherwise the wrapper delegates to the CUDA generic fallback. | | CUDA generic | `FusedLogpGenericOp` | `_C.fused_logp` | Generic compiled extension fallback. | +| Ascend NPU | `FusedLogpAscendOp` | `_C_npu.fused_logp_ascend` | Batch-invariant Ascend C forward: two-pass (row max, then sum-exp) fp32 reduction with a fixed tile order, mirroring the CUDA deterministic kernel. Output is fp32, matching `DeterministicLogpCUDAOp`'s contract; out-of-range targets yield 0.0. | | PyTorch native | `NativeLogpOp` | None | PyTorch baseline/reference path. | ## Tensor Contract @@ -62,9 +63,14 @@ operator accuracy tests continue to validate native/CUDA fused API compatibility ## Implementation Files - `rl_engine/kernels/registry.py` -- `rl_engine/kernels/ops/pytorch/loss/logp.py` -- `rl_engine/kernels/ops/cuda/loss/logp.py` +- `rl_engine/kernels/ops/pytorch/loss/logp.py` — PyTorch native reference +- `rl_engine/kernels/ops/cuda/loss/logp.py` — CUDA fused LogP (SM90 + generic) +- `rl_engine/kernels/ops/ascend/loss/logp.py` — Ascend deterministic op - `csrc/ops.cpp` - `csrc/fused_logp_kernel.cu` - `csrc/cuda/fused_logp_sm90.cu` +- `csrc/deterministic_logp_kernel.cu` — CUDA deterministic kernel (reference reduction) +- `csrc/ascend/fused_logp_ascend.asc` — Ascend C forward kernel +- `csrc/ascend/npu_module.cpp` — shared pybind entry for `rl_engine._C_npu` - `tests/test_logp.py` +- `tests/test_logp_ascend.py` — Ascend correctness + batch-invariance tests diff --git a/docs/operators/linear-logp.md b/docs/operators/linear-logp.md index 4b5231ef..0dd65bde 100644 --- a/docs/operators/linear-logp.md +++ b/docs/operators/linear-logp.md @@ -39,6 +39,7 @@ logp.sum().backward() # gradients flow into hidden, lm_head_weight, bias | --- | --- | --- | | CUDA SM90 (Hopper) | `FusedLinearLogpSM90Op` | TMA-streamed, Double Buffering, tensor-core forward (`mma.sync.m16n8k16`), online softmax in smem; chunked backward. Compiles for `sm_90a`; validated fp32-accurate on H100. Falls back to Triton/native for fp32/fp16 inputs or hidden dims not divisible by 32. | | CUDA / ROCm (Triton) | `TritonLinearLogpOp` | Triton online-softmax forward; Liger-style chunked backward (cuBLAS matmuls, deterministic). Phase 1. | +| Ascend NPU | `FusedLinearLogpAscendOp` | Batch-invariant Ascend C forward mirroring the SM90 reduction contract: ascending vocab-row scan with the online rescale chain, per-row fp32 dots over a fixed D-tile order, `min(zt - lse, 0)` clamp; the shared chunked backward. Output fp32. | | PyTorch native | `NativeLinearLogpOp` | Naive `F.linear` + `log_softmax` + `gather` reference; CPU / Triton-less fallback. | The SM90 backend (`csrc/cuda/fused_linear_logp_sm90.cu`) streams hidden/weight @@ -168,10 +169,14 @@ For 4-GPU tensor-parallel validation, use ## Implementation Files - `rl_engine/kernels/ops/triton/loss/linear_logp.py` -- `rl_engine/kernels/ops/pytorch/loss/linear_logp.py` -- `rl_engine/kernels/ops/cuda/loss/linear_logp.py` (SM90 wrapper + chunked backward) -- `csrc/cuda/fused_linear_logp_sm90.cu`, `csrc/ops.cpp`, `setup.py` (SM90 kernel + build) +- `rl_engine/kernels/ops/pytorch/loss/linear_logp.py` — native reference, chunked backward, TP helpers +- `rl_engine/kernels/ops/cuda/loss/linear_logp.py` — CUDA fused implementation (SM90 wrapper + chunked backward) +- `rl_engine/kernels/ops/ascend/loss/linear_logp.py` — Ascend deterministic op +- `csrc/cuda/fused_linear_logp_sm90.cu`, `csrc/ops.cpp`, `setup.py` — SM90 kernel + build +- `csrc/ascend/fused_linear_logp_ascend.asc` — Ascend C forward kernel +- `csrc/ascend/npu_module.cpp` — shared pybind entry for `rl_engine._C_npu` - `rl_engine/kernels/registry.py` - `tests/test_linear_logp.py` +- `tests/test_linear_logp_ascend.py` — Ascend correctness + batch-invariance tests - `benchmarks/benchmark_linear_logp.py` - `docs/design/fused-linear-logp.md` diff --git a/docs/operators/lm_head.md b/docs/operators/lm_head.md index 901d1759..f8a16c24 100644 --- a/docs/operators/lm_head.md +++ b/docs/operators/lm_head.md @@ -37,6 +37,7 @@ The op exposes the WS1 dual-path contract: | --- | --- | --- | --- | | PyTorch fallback | `NativeLMHeadOp` | None | fp32 ground-truth reference; CPU and any GPU. | | CUDA SM90 (H200/Hopper) | `SM90LMHeadOp` | `_C.lm_head_sm90_forward` | Single-card batch-invariant forward backend; no Split-K; bf16 backward uses deterministic GEMM. | +| Ascend NPU | `AscendLMHeadOp` | `_C_npu.lm_head_ascend` | Single-card batch-invariant forward backend: one output element per block, full K reduction in fp32 over a fixed tile order; fp32-formula VJP backward. | | ROCm / Triton | N/A | N/A | Falls back to the PyTorch native reference. | ## Tensor Contract @@ -89,6 +90,21 @@ hidden row and vocab weight row independently, so large-vocab projections are ex to be memory-bandwidth bound compared with a tiled GEMM. This path exists to preserve a fixed hidden-dimension accumulation order for the WS1/H200 correctness gate. +On `npu` the priority is: + +1. `ASCEND_LM_HEAD` — `AscendLMHeadOp` (batch-invariant Ascend C forward; fp32/bf16/fp16). +2. `PYTORCH_NATIVE_LM_HEAD` — `NativeLMHeadOp` (fallback). + +The Ascend kernel implements the same structure as the SM90 CUDA kernel: one output +element per block iteration, the full hidden-dimension reduction inside that block over +a fixed tile order (products -> per-tile sum -> sequential scalar accumulation), bias +added in fp32, final cast to the output dtype. There is no Split-K, so a row's logits +depend only on H — never on N or block assignment — and are bitwise identical across +batch sizes, row positions, and block assignments on the NPU. The per-tile sums use the +Ascend vector unit's fixed hardware tree instead of CUDA's warp-shuffle tree, so the +comparison against the PyTorch reference (torch.mv) is tolerance-based per the +reduction contract, not bitwise. + For bf16 H200 training, `SM90LMHeadOp.backward` routes `dhidden` through `_C.det_gemm_da` and `dweight` through `_C.det_gemm_db` (`hidden.T @ dlogits`, transposed back to the HF `[vocab, hidden]` layout). The wrapper fails fast if those deterministic @@ -97,18 +113,25 @@ GEMM symbols are missing instead of silently falling back to cuBLAS for bf16 gra ## Tests ```bash -python -m pytest tests/test_lm_head.py -v +python -m pytest tests/test_lm_head.py tests/test_lm_head_ascend.py -v ``` Covers fp32 correctness vs the fixed-K reference, precision-context safety, bf16/fp16 accuracy, output shape, bias semantics, Axis-A batch invariance, input purity, gradient flow to `hidden` and `weight`, registry dispatch, and a GPU-only smoke test at the real -Qwen3-8B dimensions. +Qwen3-8B dimensions. The Ascend suite adds: contract-tolerance correctness vs the +reference (fp32/bf16/fp16, with and without bias), fp32-formula VJP backward, bitwise +batch invariance (batch sizes 1 vs {2,4,16,300}, row positions, multi-tile H=10000, +repeated runs), and NPU registry dispatch. ## Implementation Files - `rl_engine/kernels/ops/pytorch/linear/lm_head.py` - `rl_engine/kernels/ops/cuda/linear/lm_head.py` - `csrc/cuda/embedding_lm_head_sm90.cu` +- `rl_engine/kernels/ops/ascend/linear/lm_head.py` — Ascend deterministic op +- `csrc/ascend/lm_head_ascend.asc` — Ascend C forward kernel +- `csrc/ascend/npu_module.cpp` — shared pybind entry for `rl_engine._C_npu` - `rl_engine/kernels/registry.py` - `tests/test_lm_head.py` +- `tests/test_lm_head_ascend.py` diff --git a/docs/operators/rope.md b/docs/operators/rope.md index cfc171b0..d3e3e49f 100644 --- a/docs/operators/rope.md +++ b/docs/operators/rope.md @@ -1,19 +1,18 @@ # RoPE RoPE applies rotary position embeddings to per-head query or key tensors. The -current implementation is a pure PyTorch reference operator for Issue #108 -ground-truth validation; it is not a fused CUDA or Triton kernel. - -This page documents the PyTorch baseline version. +project provides the pure PyTorch ground truth plus CUDA, Triton, and Ascend C +candidate backends using the same GPT-NeoX/Hugging Face rotate-half convention. ## Entry Point ```python from rl_engine.kernels.registry import kernel_registry +from rl_engine.kernels.ops.pytorch.rotary_embedding import NativeRoPEOp rope = kernel_registry.get_op("rope") output = rope.forward(x, positions, theta=1_000_000.0) -reference = rope.forward_fp32(x, positions, theta=1_000_000.0) +reference = NativeRoPEOp().forward_fp32(x, positions, theta=1_000_000.0) ``` The operator can also be imported directly: @@ -29,9 +28,12 @@ rope = NativeRoPEOp() | Backend | Wrapper | Native symbol | Notes | | --- | --- | --- | --- | | PyTorch native | `NativeRoPEOp` | None | Reference baseline for Qwen3-style RoPE. | +| Ascend C | `RoPEAscendOp` | `_C_npu.rope_apply_ascend` | FP16/BF16/FP32, FP32 rotation math, autograd through the inverse rotation. | +| CUDA SM90 | `RoPESM90Op` | `_C.rope_apply_sm90` | Hopper build only. | +| Triton | `TritonRoPEOp` | JIT kernel | CUDA/ROCm candidate. | -`kernel_registry.get_op("rope")` dispatches to the PyTorch native backend on CPU, -CUDA, and ROCm. CUDA/Triton fused RoPE kernels should compare against this reference. +`kernel_registry.get_op("rope")` prefers `RoPEAscendOp` on NPU, the SM90/Triton +candidates on CUDA, and the PyTorch implementation as the portable fallback. ## Tensor Contract @@ -93,6 +95,8 @@ as `[S]` and `[B, S]`, batch invariance, and Qwen3 query/key head shapes. ## Implementation Files - `rl_engine/kernels/ops/pytorch/rotary_embedding/rope.py` +- `rl_engine/kernels/ops/ascend/rotary_embedding/rope.py` +- `csrc/ascend/rope_ascend.asc` - `rl_engine/kernels/ops/pytorch/rotary_embedding/__init__.py` - `rl_engine/kernels/registry.py` - `tests/test_rope.py` diff --git a/rl_engine/_C_npu.pyi b/rl_engine/_C_npu.pyi index bff5e2e7..a60d2ece 100644 --- a/rl_engine/_C_npu.pyi +++ b/rl_engine/_C_npu.pyi @@ -3,8 +3,111 @@ # Built only when KERNEL_ALIGN_FORCE_ASCEND=1 on a machine with CANN + torch_npu. import torch +def swiglu_forward(gate: torch.Tensor, up: torch.Tensor) -> torch.Tensor: ... +def swiglu_backward( + grad_out: torch.Tensor, gate: torch.Tensor, up: torch.Tensor +) -> list[torch.Tensor]: ... +def silu_forward(x: torch.Tensor) -> torch.Tensor: ... +def silu_backward(grad_out: torch.Tensor, x: torch.Tensor) -> torch.Tensor: ... def batch_invariant_logp_ascend( logits: torch.Tensor, target: torch.Tensor, ignore_index: int, ) -> list[torch.Tensor]: ... +def rope_apply_ascend( + x: torch.Tensor, + cos: torch.Tensor, + sin: torch.Tensor, + sin_sign: float, +) -> torch.Tensor: ... + +def deterministic_attention_ascend( + q: torch.Tensor, + k: torch.Tensor, + v: torch.Tensor, + causal: bool, + scale: float, + key_padding_mask: torch.Tensor | None, +) -> list[torch.Tensor]: ... + +def prefix_shared_attention_ascend( + q: torch.Tensor, + k: torch.Tensor, + v: torch.Tensor, +) -> torch.Tensor: ... + +def deterministic_collective_create( + staging: torch.Tensor, + world_size: int, + rank: int, +) -> int: ... + +def deterministic_collective_destroy(handle: int) -> None: ... + +def deterministic_collective_stage(handle: int, input: torch.Tensor) -> None: ... + +def deterministic_collective_reduce( + handle: int, + gathered: torch.Tensor, + output: torch.Tensor, + slice_offset: int, +) -> None: ... + +def rmsnorm_ascend( + x: torch.Tensor, + weight: torch.Tensor, + rstd: torch.Tensor, +) -> torch.Tensor: ... + +def embedding_ascend( + token_ids: torch.Tensor, + weight: torch.Tensor, + output_fp32: bool, +) -> torch.Tensor: ... + +def fused_logp_ascend( + logits: torch.Tensor, + target: torch.Tensor, +) -> torch.Tensor: ... +def lm_head_ascend( + hidden: torch.Tensor, + weight: torch.Tensor, + bias: torch.Tensor | None, + output_fp32: bool, +) -> torch.Tensor: ... + +def fused_linear_logp_ascend( + hidden: torch.Tensor, + weight: torch.Tensor, + bias: torch.Tensor | None, + target: torch.Tensor, +) -> torch.Tensor: ... + +def det_gemm_ascend_fwd( + a: torch.Tensor, + b: torch.Tensor, +) -> torch.Tensor: ... +def det_gemm_ascend_fwd_rhs_transposed( + a: torch.Tensor, + bt: torch.Tensor, +) -> torch.Tensor: ... +def det_gemm_ascend_fwd_fp32( + a: torch.Tensor, + b: torch.Tensor, +) -> torch.Tensor: ... +def det_gemm_ascend_da( + dc: torch.Tensor, + b: torch.Tensor, +) -> torch.Tensor: ... +def det_gemm_ascend_db( + a: torch.Tensor, + dc: torch.Tensor, +) -> torch.Tensor: ... +def det_gemm_ascend_db_transposed( + a: torch.Tensor, + dc: torch.Tensor, +) -> torch.Tensor: ... + +def det_gemm_rowwise_ascend_fwd_fp32( + a: torch.Tensor, b: torch.Tensor +) -> torch.Tensor: ... diff --git a/rl_engine/alignment/qwen3_dense.py b/rl_engine/alignment/qwen3_dense.py index bd6b2e1e..7f9fb175 100644 --- a/rl_engine/alignment/qwen3_dense.py +++ b/rl_engine/alignment/qwen3_dense.py @@ -18,6 +18,11 @@ import torch +from rl_engine.kernels.gtest.accelerator import ( + candidate_family, + device_type_for_profile, + disable_tf32, +) from rl_engine.kernels.gtest.gradient_adapters import resolve_profile_candidate from rl_engine.kernels.gtest.operator_specs import OP_SPECS, _load_object from rl_engine.kernels.ops.canonical_backward import active_session @@ -27,7 +32,11 @@ canonical_cuda_lm_head_fp32, canonical_row_lm_head, ) -from rl_engine.kernels.ops.canonical_rmsnorm import canonical_cuda_rmsnorm, canonical_row_rmsnorm +from rl_engine.kernels.ops.canonical_rmsnorm import ( + canonical_ascend_rmsnorm, + canonical_cuda_rmsnorm, + canonical_row_rmsnorm, +) from rl_engine.kernels.ops.pytorch.attention.stateful_kv import StatefulKVCache from rl_engine.testing.ws1_workload import WS1Manifest, load_manifest, weight_snapshot_hash @@ -285,11 +294,15 @@ def observe(self, kind: str, output: torch.Tensor) -> None: ) if not isinstance(output, torch.Tensor): raise TypeError(f"profile node {kind!r} did not return a Tensor") - if declared["status"] != "gold_reference" and output.device.type != "cuda": - raise RuntimeError( - f"profile {self.backend_profile!r} node {kind!r} returned " - f"non-CUDA output on {output.device}" - ) + if declared["status"] != "gold_reference": + # A gold_reference node is the PyTorch harness path and may run on + # CPU; a real candidate must land on its profile's accelerator. + expected_device = device_type_for_profile(self.backend_profile) + if output.device.type != expected_device: + raise RuntimeError( + f"profile {self.backend_profile!r} node {kind!r} returned " + f"non-{expected_device} output on {output.device}" + ) previous = self.observations.get(kind) count = 1 if previous is None else int(previous["execution_count"]) + 1 self.observations[kind] = { @@ -367,7 +380,7 @@ def load_profile_ops( if status == "missing_required": raise RuntimeError( f"profile {backend_profile!r} node {kind!r} is missing_required; " - "C9 treats a missing Triton/CUDA node as red" + "C9 treats a missing required node as red on every profile" ) expected = resolved.get("expected_backend_id") path = resolved.get("candidate_path") @@ -401,11 +414,7 @@ def _adapter_stub(op_name: str, chain_node: str) -> Any: def _family(candidate: str) -> str: - if candidate.startswith("cuda"): - return "cuda" - if candidate == "triton": - return "triton" - return candidate + return candidate_family(candidate) def _object_path(value: Any) -> str: @@ -506,6 +515,28 @@ def from_hf( return cls(tensors, source=f"hf:{path}", content_hash=spec.weight_content_hash) +class Qwen3DenseWeightsOffloaded(Qwen3DenseWeights): + """CPU-resident weights paged onto the accelerator per access. + + The C10 FP32 reference on 64 GB HBM hosts uses this: the FP32 weights + (~32 GB) plus their FP32 gradients (~32 GB) plus activations cannot all + be resident, but the reference forward touches one weight at a time. + Each ``__getitem__`` issues an exact device copy; the autograd graph + keeps each copy alive until its VJP consumes it, so the peak HBM is the + sum of one copy per use (~36 GB) and the FP32 gradients accumulate on + the CPU-resident leaves instead of on the accelerator. Copies are + exact, so the forward and backward numerics are bitwise identical to + the resident-FP32 model. + """ + + def __init__(self, weights: Qwen3DenseWeights, device: torch.device | str): + super().__init__(weights.tensors, weights.source, weights.content_hash) + self._device = torch.device(device) + + def __getitem__(self, key: str) -> torch.Tensor: + return self.tensors[key].to(self._device) + + def _sha256_file(path: Path) -> str: digest = hashlib.sha256() with path.open("rb") as handle: @@ -617,9 +648,7 @@ def __init__( self._vjp_inputs: dict[str, list[dict[str, Any]]] = {} self._vjp_grads: dict[str, dict[int, torch.Tensor]] = {} self._vjp_hooks: list[Any] = [] - torch.backends.cuda.matmul.allow_tf32 = False - if hasattr(torch.backends, "cudnn"): - torch.backends.cudnn.allow_tf32 = False + disable_tf32(device_type_for_profile(self.profile_ops.backend_profile)) @property def backend_profile(self) -> str: @@ -713,7 +742,7 @@ def forward( torch.is_grad_enabled() and active_session() is not None and keys is not None - and lm_family == "triton" + and lm_family in ("triton", "ascend") ): score_logits = canonical_row_lm_head( hidden, @@ -721,6 +750,7 @@ def forward( keys.reshape(-1, 2), forward_op=lm_head_op.forward_fp32, matmul_op=self.profile_ops.get("det_gemm").forward_accum_fp32, + family=lm_family, ) else: score_logits = lm_head_op.forward_fp32( @@ -920,13 +950,14 @@ def forward_chunked_training( self.weights["lm_head.weight"], keys.reshape(-1, 2), ) - elif active_session() is not None and lm_family == "triton": + elif active_session() is not None and lm_family in ("triton", "ascend"): score_logits = canonical_row_lm_head( final_hidden, self.weights["lm_head.weight"], keys.reshape(-1, 2), forward_op=lm_head_op.forward_fp32, matmul_op=self.profile_ops.get("det_gemm").forward_accum_fp32, + family=lm_family, ) else: score_logits = lm_head_op.forward_fp32( @@ -1188,6 +1219,14 @@ def _rms(self, x: torch.Tensor, weight: torch.Tensor, *, node: str) -> torch.Ten parameter_id=node, forward_op=op.forward, ).view_as(x) + elif family == "ascend": + out = canonical_ascend_rmsnorm( + x_rows, + weight.contiguous(), + eps=self.spec.rms_norm_eps, + logical_keys=row_keys, + parameter_id=node, + ).view_as(x) else: out = op.forward(x, weight, eps=self.spec.rms_norm_eps) else: @@ -1231,6 +1270,14 @@ def _qk_norm(self, x: torch.Tensor, weight: torch.Tensor, *, node: str) -> torch parameter_id=node, forward_op=op.forward, ).view_as(flat) + elif family == "ascend": + out = canonical_ascend_rmsnorm( + flat_rows, + weight.contiguous(), + eps=self.spec.rms_norm_eps, + logical_keys=head_keys, + parameter_id=node, + ).view_as(flat) else: out = op.forward(flat, weight, eps=self.spec.rms_norm_eps) else: diff --git a/rl_engine/distributed/collectives.py b/rl_engine/distributed/collectives.py index 8b7b99ef..ee0a51fe 100644 --- a/rl_engine/distributed/collectives.py +++ b/rl_engine/distributed/collectives.py @@ -119,8 +119,18 @@ def deterministic_all_reduce_staged( return _deterministic_staged_all_reduce(staging, collective_handle) +def _npu_available() -> bool: + """torch.npu only exists after torch_npu is imported; probe defensively.""" + try: + import torch_npu # noqa: F401 + + return hasattr(torch, "npu") and torch.npu.is_available() + except Exception: + return False + + class DeterministicCollective: - """Correctness-first TP-invariant CUDA collectives for one eight-GPU node. + """Correctness-first TP-invariant collectives for one eight-device node. TP sizes 1, 2, 4, and 8 use nested prefixes of the same balanced tree. A reduction is cross-TP bitwise invariant when every rank input is the @@ -128,10 +138,15 @@ class DeterministicCollective: reduction, as produced by a TBIK-compatible row-parallel kernel. Every node evaluates the lower logical subtree before the higher one. - One instance owns a symmetric CUDA IPC staging buffer. All ranks must call - its methods in the same order with matching shapes and dtypes. Device-side - IPC sequence fences order staging and payload access without a steady-state - host barrier and advance correctly during CUDA Graph replay. + CUDA: one instance owns a symmetric CUDA IPC staging buffer; the reduction + kernel reads every peer's staged data directly. Ascend NPU: no device IPC + exists, so each reduction first gathers every rank's staged input with an + HCCL all_gather (bitwise-exact data movement) and then applies the same + fixed-tree kernel locally -- the reduction order never depends on the HCCL + algorithm. All ranks must call the methods in the same order with matching + shapes and dtypes. Calls are host-synchronizing by design; the first + version prioritizes determinism and lifetime safety over overlap or + throughput. """ backend_id = "cuda_ipc_fixed_tree" @@ -145,8 +160,6 @@ def __init__( ) -> None: if not dist.is_available() or not dist.is_initialized(): raise RuntimeError("torch.distributed must be initialized before collectives") - if not torch.cuda.is_available(): - raise RuntimeError("deterministic collectives require CUDA") if max_size_bytes <= 0: raise ValueError("max_size_bytes must be positive") @@ -158,23 +171,64 @@ def __init__( "deterministic collectives require world_size in " f"{_SUPPORTED_WORLD_SIZES}, got {self.world_size}" ) + self.max_size_bytes = int(max_size_bytes) - if device is None: + is_cuda = torch.cuda.is_available() + is_npu = _npu_available() + if is_cuda: + self._backend = "cuda" normalized_device = torch.device("cuda", torch.cuda.current_device()) - elif isinstance(device, int): - normalized_device = torch.device("cuda", device) + if device is not None: + normalized_device = ( + torch.device("cuda", device) + if isinstance(device, int) + else torch.device(device) + ) + if normalized_device.type != "cuda": + raise ValueError( + f"deterministic collectives require a CUDA device, got {device!r}" + ) + if normalized_device.index is None: + normalized_device = torch.device("cuda", torch.cuda.current_device()) + if normalized_device.index != torch.cuda.current_device(): + raise ValueError( + "the collective device must be the current CUDA device; call " + f"torch.cuda.set_device({normalized_device.index}) first" + ) + self._load_cuda_extension() + self.device = normalized_device + self._create_cuda_state() + elif is_npu: + self._backend = "npu" + normalized_device = torch.device("npu", torch.npu.current_device()) + if device is not None: + normalized_device = ( + torch.device("npu", device) if isinstance(device, int) else torch.device(device) + ) + if normalized_device.type != "npu": + raise ValueError( + f"deterministic collectives require an NPU device, got {device!r}" + ) + if normalized_device.index is None: + normalized_device = torch.device("npu", torch.npu.current_device()) + if normalized_device.index != torch.npu.current_device(): + raise ValueError( + "the collective device must be the current NPU device; call " + f"torch.npu.set_device({normalized_device.index}) first" + ) + self._load_npu_extension() + self.device = normalized_device + self._create_npu_state() else: - normalized_device = torch.device(device) - if normalized_device.type != "cuda": - raise ValueError(f"deterministic collectives require a CUDA device, got {device!r}") - if normalized_device.index is None: - normalized_device = torch.device("cuda", torch.cuda.current_device()) - if normalized_device.index != torch.cuda.current_device(): - raise ValueError( - "the collective device must be the current CUDA device; call " - f"torch.cuda.set_device({normalized_device.index}) first" - ) + raise RuntimeError("deterministic collectives require CUDA or Ascend NPU devices") + + self._synchronize_ranks() + # ------------------------------------------------------------------ # + # Backend setup + # ------------------------------------------------------------------ # + + def _load_cuda_extension(self) -> None: try: from rl_engine import _C except ImportError as exc: @@ -200,10 +254,31 @@ def __init__( "the RL-Kernel CUDA extension lacks deterministic collectives: " + ", ".join(missing) ) - - self.device = normalized_device - self.max_size_bytes = int(max_size_bytes) self._extension = _C + + def _load_npu_extension(self) -> None: + try: + from rl_engine import _C_npu + except ImportError as exc: + raise RuntimeError( + "the RL-Kernel Ascend extension is required; rebuild with " + "KERNEL_ALIGN_FORCE_ASCEND=1 and `pip install --no-build-isolation -e .`" + ) from exc + required_symbols = ( + "deterministic_collective_create", + "deterministic_collective_destroy", + "deterministic_collective_stage", + "deterministic_collective_reduce", + ) + missing = [name for name in required_symbols if not hasattr(_C_npu, name)] + if missing: + raise RuntimeError( + "the RL-Kernel Ascend extension lacks deterministic collectives: " + + ", ".join(missing) + ) + self._extension = _C_npu + + def _create_cuda_state(self) -> None: self._lock = threading.Lock() self._handle = 0 self._validated_signatures: set[tuple[Any, ...]] = set() @@ -221,11 +296,50 @@ def __init__( "capacity": self.max_size_bytes, "hostname": socket.gethostname(), } + gathered_meta = self._exchange_meta(local_meta) + self._validate_meta(gathered_meta) + + handles = [meta["handle"] for meta in gathered_meta] + offsets = [meta["offset"] for meta in gathered_meta] + self._handle = self._extension.deterministic_collective_create( + self._staging, + handles, + offsets, + self.rank, + ) + + def _create_npu_state(self) -> None: + self._lock = threading.Lock() + self._handle = 0 + self._staging = torch.empty( + self.max_size_bytes, + dtype=torch.uint8, + device=self.device, + ) + + # No device IPC on Ascend: only the capacity / hostname invariants are + # exchanged (the data itself moves through HCCL all_gather per call). + local_meta = { + "capacity": self.max_size_bytes, + "hostname": socket.gethostname(), + } + gathered_meta = self._exchange_meta(local_meta) + self._validate_meta(gathered_meta) + + self._handle = self._extension.deterministic_collective_create( + self._staging, + self.world_size, + self.rank, + ) + + def _exchange_meta(self, local_meta: dict[str, Any]) -> list[dict[str, Any]]: gathered_meta: list[dict[str, Any] | None] = [None] * self.world_size dist.all_gather_object(gathered_meta, local_meta, group=self.group) if any(meta is None for meta in gathered_meta): - raise RuntimeError("failed to exchange CUDA IPC metadata") - complete_meta = [meta for meta in gathered_meta if meta is not None] + raise RuntimeError("failed to exchange collective metadata") + return [meta for meta in gathered_meta if meta is not None] + + def _validate_meta(self, complete_meta: list[dict[str, Any]]) -> None: hostnames = {meta["hostname"] for meta in complete_meta} if len(hostnames) != 1: raise ValueError("deterministic collectives require all ranks on one host") @@ -233,15 +347,9 @@ def __init__( if capacities != {self.max_size_bytes}: raise ValueError("all ranks must use the same max_size_bytes") - handles = [meta["handle"] for meta in complete_meta] - offsets = [meta["offset"] for meta in complete_meta] - self._handle = self._extension.deterministic_collective_create( - self._staging, - handles, - offsets, - self.rank, - ) - self._synchronize_ranks() + # ------------------------------------------------------------------ # + # Public collectives + # ------------------------------------------------------------------ # def prepare_direct_staging_views( self, @@ -291,8 +399,9 @@ def all_reduce( """Return the TBIK-compatible fixed-tree sum on every rank. Supported dtypes are float32, float16, and bfloat16. ``out`` may alias - ``input``; the input is staged before the output kernel starts. Cross-TP - invariance requires inputs to follow the class-level subtree contract. + ``input``; the input is staged before the reduction kernel starts. + Cross-TP invariance requires inputs to follow the class-level subtree + contract. """ self._check_open() @@ -304,7 +413,13 @@ def all_reduce( with self._lock: if validate_signature: self._validate_matching_signature("all_reduce", input) - self._extension.deterministic_collective_all_reduce_fused(self._handle, input, out) + if self._backend == "cuda": + self._extension.deterministic_collective_all_reduce_fused(self._handle, input, out) + else: + self._extension.deterministic_collective_stage(self._handle, input) + self._synchronize_ranks() + self._run_reduction(input, out, slice_offset=0) + self._synchronize_ranks() return out def all_gather( @@ -330,7 +445,16 @@ def all_gather( with self._lock: if validate_signature: self._validate_matching_signature("all_gather", input) - self._extension.deterministic_collective_all_gather_fused(self._handle, input, out) + if self._backend == "cuda": + self._extension.deterministic_collective_all_gather_fused(self._handle, input, out) + else: + self._extension.deterministic_collective_stage(self._handle, input) + self._synchronize_ranks() + staged = self._staged_view(input) + shard = input.size(0) + slices = [out[index : index + shard] for index in range(0, out.size(0), shard)] + dist.all_gather(slices, staged, group=self.group) + self._synchronize_ranks() return out def all_gather_many( @@ -392,7 +516,12 @@ def reduce_scatter( if validate_signature: self._validate_matching_signature("reduce_scatter", input) self._extension.deterministic_collective_stage(self._handle, input) - self._extension.deterministic_collective_reduce_scatter(self._handle, out) + self._synchronize_ranks() + if self._backend == "cuda": + self._extension.deterministic_collective_reduce_scatter(self._handle, out) + else: + self._run_reduction(input, out, slice_offset=self.rank * out.numel()) + self._synchronize_ranks() return out def reduce_scatter_many( @@ -409,13 +538,50 @@ def reduce_scatter_many( self.reduce_scatter(input, validate_signature=validate_signature) for input in inputs ) + # ------------------------------------------------------------------ # + # Backend helpers + # ------------------------------------------------------------------ # + + def _staged_view(self, input: torch.Tensor) -> torch.Tensor: + """The staged input as a typed view of the staging buffer.""" + return self._staging.narrow(0, 0, input.numel() * input.element_size()).view(input.dtype) + + def _run_reduction( + self, + input: torch.Tensor, + out: torch.Tensor, + *, + slice_offset: int, + ) -> None: + """NPU reduction: HCCL-gather every rank's staged input, then apply the + fixed-tree kernel locally over the [world_size, N] gathered buffer.""" + gathered = torch.empty( + (self.world_size, input.numel()), + dtype=input.dtype, + device=input.device, + ) + dist.all_gather( + list(gathered.unbind(0)), + self._staged_view(input), + group=self.group, + ) + self._extension.deterministic_collective_reduce( + self._handle, + gathered, + out, + slice_offset, + ) + def close(self) -> None: - """Release imported CUDA IPC mappings after the last collective call.""" + """Release the collective state (and CUDA IPC mappings) after the last call.""" handle = getattr(self, "_handle", 0) if not handle: return - torch.cuda.synchronize(self.device) + if self._backend == "cuda": + torch.cuda.synchronize(self.device) + else: + torch.npu.synchronize(self.device) self._handle = 0 self._extension.deterministic_collective_destroy(handle) @@ -442,7 +608,7 @@ def _check_open(self) -> None: raise RuntimeError("deterministic collective is closed") def _validate_reduction_input(self, input: torch.Tensor) -> None: - if not input.is_cuda or input.device != self.device: + if input.device != self.device: raise ValueError(f"input must be on {self.device}, got {input.device}") if not input.is_contiguous(): raise ValueError("input must be contiguous") @@ -458,7 +624,7 @@ def _validate_reduction_input(self, input: torch.Tensor) -> None: ) def _validate_gather_input(self, input: torch.Tensor) -> None: - if not input.is_cuda or input.device != self.device: + if input.device != self.device: raise ValueError(f"input must be on {self.device}, got {input.device}") if not input.is_contiguous(): raise ValueError("input must be contiguous") @@ -537,11 +703,15 @@ def _validate_many_capacity(self, inputs: tuple[torch.Tensor, ...]) -> None: ) def _synchronize_ranks(self) -> None: - torch.cuda.synchronize(self.device) - backend = dist.get_backend(self.group) - if backend == dist.Backend.NCCL or str(backend).lower() == "nccl": - dist.barrier(group=self.group, device_ids=[self.device.index]) + if self._backend == "cuda": + torch.cuda.synchronize(self.device) + backend = dist.get_backend(self.group) + if backend == dist.Backend.NCCL or str(backend).lower() == "nccl": + dist.barrier(group=self.group, device_ids=[self.device.index]) + else: + dist.barrier(group=self.group) else: + torch.npu.synchronize(self.device) dist.barrier(group=self.group) diff --git a/rl_engine/kernels/gtest/accelerator.py b/rl_engine/kernels/gtest/accelerator.py new file mode 100644 index 00000000..cd0ee68e --- /dev/null +++ b/rl_engine/kernels/gtest/accelerator.py @@ -0,0 +1,291 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 RL-Kernel Contributors + +"""Accelerator abstraction shared by the WS1 gates (C3-C11 of #266). + +The WS1 harness was written against CUDA. Adding the Ascend BF16 profile means +every gate needs the same small set of device facts on either vendor: +availability, the device handle, a human-readable device name, an architecture +key for evidence, TF32 policy enforcement, seeding, and synchronization. + +The rules the contract cares about are vendor-independent and enforced here: + +- A required profile never silently falls back. Asking for an Ascend profile on + a host with no NPU is an error, not a CPU run. +- TF32 is disabled on every backend. CUDA has real TF32 switches; Ascend has no + TF32 equivalent at all, so the policy is satisfied by construction and both + report ``candidate_tf32_enabled=False``. +- ``arch_key`` is the evidence field that pins "which silicon": the SM version + on CUDA (``sm90``), the SoC version on Ascend (``ascend910b``). +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +import torch + +# backend_profile id -> (torch device type, contract backend_family). +PROFILE_DEVICE_TYPES: dict[str, str] = { + "cuda_bf16": "cuda", + "triton_cuda_bf16": "cuda", + "ascend_bf16": "npu", +} +PROFILE_FAMILIES: dict[str, str] = { + "cuda_bf16": "cuda", + "triton_cuda_bf16": "triton", + "ascend_bf16": "ascend", +} +ACCELERATOR_TYPES = ("cuda", "npu") + + +class AcceleratorUnavailable(RuntimeError): + """A required profile's accelerator is absent; the gate must fail, not fall back.""" + + +def _npu() -> Any: + """Return the ``torch.npu`` namespace, or None when Ascend is unavailable. + + ``torch.npu`` is installed onto the torch module by importing torch_npu, so + it cannot be referenced statically. Every NPU call in this module goes + through here. + """ + + try: + import torch_npu # noqa: F401 + except Exception: + return None + return getattr(torch, "npu", None) + + +def npu_available() -> bool: + npu = _npu() + try: + return bool(npu is not None and npu.is_available()) + except Exception: + return False + + +def is_available(device_type: str) -> bool: + if device_type == "cuda": + return bool(torch.cuda.is_available()) + if device_type == "npu": + return npu_available() + return False + + +def device_type_for_profile(profile: str) -> str: + """Return the torch device type a backend profile executes on.""" + + try: + return PROFILE_DEVICE_TYPES[profile] + except KeyError: + raise ValueError(f"unknown backend_profile {profile!r}") from None + + +def family_for_profile(profile: str) -> str: + """Return the C1 ``backend_family`` a backend profile must report.""" + + try: + return PROFILE_FAMILIES[profile] + except KeyError: + raise ValueError(f"unknown backend_profile {profile!r}") from None + + +def candidate_family(candidate: str) -> str: + """Map a C2 ``expected_backend_id`` to its contract backend family.""" + + if candidate.startswith("cuda"): + return "cuda" + if candidate == "triton": + return "triton" + if candidate.startswith("ascend") or candidate == "npu": + return "ascend" + return candidate + + +@dataclass(frozen=True) +class AcceleratorInfo: + """Device facts a WS1 report persists so evidence names real silicon.""" + + device_type: str + device: torch.device + name: str + arch_key: str + runtime_version: str | None + + @property + def device_str(self) -> str: + return str(self.device) + + def to_dict(self) -> dict[str, Any]: + return { + "device_type": self.device_type, + "device": str(self.device), + "name": self.name, + "arch_key": self.arch_key, + "runtime_version": self.runtime_version, + } + + +def resolve_device( + device: torch.device | str | None, *, profile: str | None = None +) -> torch.device: + """Resolve the device a gate runs on, failing closed when it is absent. + + ``device=None`` picks the profile's device type. An explicit device that + disagrees with the profile is an error: running an Ascend profile on CUDA + would be exactly the undeclared fallback the contract forbids. + """ + + if device is None: + if profile is None: + raise ValueError("resolve_device needs a device or a profile") + device_type = device_type_for_profile(profile) + else: + # torch.device() only knows "npu" once torch_npu has registered it, so + # read the type from the string before handing it to torch. + device_type = str(device).split(":", 1)[0] + if profile is not None: + expected = device_type_for_profile(profile) + if device_type != expected: + raise AcceleratorUnavailable( + f"profile {profile!r} executes on {expected!r}, got device {device}" + ) + if device_type not in ACCELERATOR_TYPES: + raise AcceleratorUnavailable(f"WS1 gates require an accelerator device, got {device}") + if not is_available(device_type): + hint = ( + "install torch_npu and run on an Ascend host" + if device_type == "npu" + else "run on a CUDA host" + ) + raise AcceleratorUnavailable( + f"{device_type} is not available; {hint}. Required profiles never " + "fall back to CPU." + ) + resolved = torch.device(device_type) if device is None else torch.device(device) + if resolved.index is None: + resolved = torch.device(resolved.type, current_device(resolved.type)) + return resolved + + +def current_device(device_type: str) -> int: + if device_type == "cuda": + return int(torch.cuda.current_device()) + if device_type == "npu": + return int(_npu().current_device()) + return 0 + + +def set_device(device: torch.device) -> None: + if device.index is None: + return + if device.type == "cuda": + torch.cuda.set_device(device) + elif device.type == "npu": + _npu().set_device(device) + + +def device_name(device: torch.device) -> str: + if device.type == "cuda": + return str(torch.cuda.get_device_name(device)) + if device.type == "npu": + try: + return str(_npu().get_device_name(device.index or 0)) + except Exception: + return "Ascend NPU" + return device.type + + +def arch_key(device: torch.device) -> str: + """Architecture key for evidence: ``sm90`` on CUDA, ``ascend910b`` on NPU.""" + + if device.type == "cuda": + major, minor = torch.cuda.get_device_capability(device) + return f"sm{major}{minor}" + if device.type == "npu": + # torch_npu exposes the SoC through several names across releases; + # fall back to the device name, which already carries "Ascend910B*". + npu = _npu() + for getter in ("get_soc_version", "get_device_name"): + fn = getattr(npu, getter, None) + if fn is None: + continue + try: + value = fn(device.index or 0) if getter == "get_device_name" else fn() + except Exception: + continue + text = str(value).strip().lower().replace(" ", "").replace("-", "") + if text: + return text + return device.type + + +def compute_capability(device: torch.device) -> str: + """Dotted capability string for CUDA; the SoC key on Ascend.""" + + if device.type == "cuda": + return ".".join(str(x) for x in torch.cuda.get_device_capability(device)) + return arch_key(device) + + +def runtime_version(device_type: str) -> str | None: + if device_type == "cuda": + return getattr(torch.version, "cuda", None) + if device_type == "npu": + try: + import torch_npu + + return str(getattr(torch_npu, "__version__", None) or "") or None + except Exception: + return None + return None + + +def describe(device: torch.device) -> AcceleratorInfo: + return AcceleratorInfo( + device_type=device.type, + device=device, + name=device_name(device), + arch_key=arch_key(device), + runtime_version=runtime_version(device.type), + ) + + +def disable_tf32(device_type: str) -> bool: + """Enforce the contract TF32 policy and report the resulting candidate flag. + + Returns the value a report must persist as ``candidate_tf32_enabled``. + Ascend has no TF32 mode, so the policy holds with nothing to switch off. + """ + + if device_type == "cuda": + torch.backends.cuda.matmul.allow_tf32 = False + if hasattr(torch.backends, "cudnn"): + torch.backends.cudnn.allow_tf32 = False + return bool(torch.backends.cuda.matmul.allow_tf32) + return False + + +def manual_seed_all(device_type: str, seed: int) -> None: + torch.manual_seed(seed) + if device_type == "cuda" and torch.cuda.is_available(): + torch.cuda.manual_seed_all(seed) + elif device_type == "npu" and npu_available(): + _npu().manual_seed_all(seed) + + +def synchronize(device: torch.device) -> None: + if device.type == "cuda" and torch.cuda.is_available(): + torch.cuda.synchronize(device) + elif device.type == "npu" and npu_available(): + _npu().synchronize(device) + + +def empty_cache(device_type: str) -> None: + if device_type == "cuda" and torch.cuda.is_available(): + torch.cuda.empty_cache() + elif device_type == "npu" and npu_available(): + _npu().empty_cache() diff --git a/rl_engine/kernels/gtest/chain_gate.py b/rl_engine/kernels/gtest/chain_gate.py index e330fa36..1cd39077 100644 --- a/rl_engine/kernels/gtest/chain_gate.py +++ b/rl_engine/kernels/gtest/chain_gate.py @@ -27,8 +27,18 @@ Qwen3DenseBIModel, Qwen3DenseSpec, Qwen3DenseWeights, + Qwen3DenseWeightsOffloaded, load_profile_ops, ) +from rl_engine.kernels.gtest.accelerator import ( + ACCELERATOR_TYPES, + compute_capability, + device_name, + device_type_for_profile, + empty_cache, + is_available, + manual_seed_all, +) from rl_engine.kernels.gtest.chain_gradients import GRADIENT_SCOPE, REQUIRED_GRAD_NAMES from rl_engine.kernels.gtest.forward_invariance import ( TensorComparisonDetail, @@ -233,14 +243,27 @@ def run_fp32_reference_cell( """Run BN/full on the FP32 gold topology. Separate from the candidate model.""" m = manifest if manifest is not None else load_manifest() - reference = build_model( - backend_profile=backend_profile, - weights_mode=weights_mode, - weights_path=weights_path, - device=device, - dtype=torch.float32, - manifest=m, - allow_pytorch_gold=True, + spec = Qwen3DenseSpec.from_manifest(m) + # The FP32 reference runs with CPU-resident weights paged onto the NPU + # per access: the resident FP32 weights + FP32 gradients OOM the 64 GB + # HBM (~4.7 GiB short). The paging copies are exact, so the reference + # numerics are bitwise identical to the resident-FP32 model. + if weights_mode == "synthetic": + cpu_weights = Qwen3DenseWeights.synthetic( + spec, device="cpu", dtype=torch.float32, seed=m.seed + ) + else: + if not weights_path: + raise RuntimeError("C10/C11 require --weights-path to the pinned Qwen3-8B snapshot") + cpu_weights = Qwen3DenseWeights.from_hf( + spec, weights_path, device="cpu", dtype=torch.float32 + ) + ops = load_profile_ops(backend_profile, m, allow_pytorch_gold=True) + reference = Qwen3DenseBIModel( + spec, + Qwen3DenseWeightsOffloaded(cpu_weights, device), + ops, + execution_dtype=torch.float32, ) batch = build_logical_batch(m) _configure_required_gradients(reference, enabled=run_backward) @@ -255,8 +278,7 @@ def run_fp32_reference_cell( ) _configure_required_gradients(reference, enabled=False) del reference - if device.type == "cuda" and torch.cuda.is_available(): - torch.cuda.empty_cache() + empty_cache(device.type) return cell @@ -278,9 +300,7 @@ def run_chain_gate( batch = build_logical_batch(m) cells: dict[str, CellOutput] = {} resolved_seed = m.seed if execution_seed is None else int(execution_seed) - torch.manual_seed(resolved_seed) - if torch.cuda.is_available(): - torch.cuda.manual_seed_all(resolved_seed) + manual_seed_all(device_type_for_profile(backend_profile), resolved_seed) reset_backward_runtime() _configure_required_gradients(model, enabled=run_backward) @@ -624,10 +644,7 @@ def run_chain_gate( output_dtype=policy.output_dtype_default, ) device = next(iter(model.weights.tensors.values())).device - cc = None - if device.type == "cuda" and torch.cuda.is_available(): - major, minor = torch.cuda.get_device_capability(device) - cc = f"{major}.{minor}" + cc = compute_capability(device) if device.type in ACCELERATOR_TYPES else None # Cross-cell logprob aggregates (BN vs B1) as the named chain metrics. lhs, rhs, mask = _aligned_logp_vectors( @@ -1583,9 +1600,11 @@ def _logp_aggregate_verdict( def _gpu_name(device: torch.device) -> str | None: - if device.type != "cuda" or not torch.cuda.is_available(): + """Accelerator name for evidence: the GPU on CUDA, the NPU on Ascend.""" + + if device.type not in ACCELERATOR_TYPES or not is_available(device.type): return None - return torch.cuda.get_device_name(device) + return device_name(device) def _workflow_url() -> str | None: @@ -1743,6 +1762,11 @@ def _aligned_logp_vectors( def _device(model: Qwen3DenseBIModel) -> torch.device: + # The offloaded FP32 reference keeps its weights CPU-resident; the + # execution device is the paging target, not the parameter device. + offload_device = getattr(model.weights, "_device", None) + if offload_device is not None: + return torch.device(offload_device) return next(iter(model.weights.tensors.values())).device diff --git a/rl_engine/kernels/gtest/elementwise_inventory.py b/rl_engine/kernels/gtest/elementwise_inventory.py index 16d50c3f..ebc4ce63 100644 --- a/rl_engine/kernels/gtest/elementwise_inventory.py +++ b/rl_engine/kernels/gtest/elementwise_inventory.py @@ -26,6 +26,7 @@ class InventoryItem: reduction: str cuda_verdict: Verdict triton_verdict: Verdict + ascend_verdict: Verdict evidence: str blocker: str | None = None @@ -39,6 +40,7 @@ def to_dict(self) -> dict[str, object]: "reduction": self.reduction, "cuda_verdict": self.cuda_verdict, "triton_verdict": self.triton_verdict, + "ascend_verdict": self.ascend_verdict, "evidence": self.evidence, "blocker": self.blocker, } @@ -57,10 +59,13 @@ def to_dict(self) -> dict[str, object]: category="rope", on_chain=True, differentiable=True, - entry_point="rl_engine.kernels.ops.{cuda,triton,pytorch}.rotary_embedding.rope", + entry_point="rl_engine.kernels.ops.{cuda,triton,ascend,pytorch}.rotary_embedding.rope", reduction="none (rotate_half, position-local)", cuda_verdict="pass", triton_verdict="pass", + ascend_verdict="tracked_red", + # ascend: Ascend C rope kernel declared in C2 ascend_bf16; C3/C4 evidence pending an NPU + # host evidence=( "C3/C4 adapters registered; Triton green on sm86+; " "CUDA cuda-sm90 C3/C4 and C8 four-judgment green on H20" @@ -71,10 +76,13 @@ def to_dict(self) -> dict[str, object]: category="activation", on_chain=True, differentiable=True, - entry_point="rl_engine.kernels.ops.{cuda,triton,pytorch}.activation.swiglu.SiLU*", + entry_point="rl_engine.kernels.ops.{cuda,triton,ascend,pytorch}.activation.*.SiLU*", reduction="none (pointwise)", cuda_verdict="pass", triton_verdict="pass", + ascend_verdict="tracked_red", + # ascend: csrc/ascend/activation.asc silu kernel declared; C3/C4 evidence pending an NPU + # host evidence="C3 and C4 both green on cuda_bf16 and triton_cuda_bf16 (sm86)", ), InventoryItem( @@ -82,10 +90,13 @@ def to_dict(self) -> dict[str, object]: category="activation", on_chain=True, differentiable=True, - entry_point="rl_engine.kernels.ops.{cuda,triton,pytorch}.activation.swiglu.SwiGLU*", + entry_point="rl_engine.kernels.ops.{cuda,triton,ascend,pytorch}.activation.*.SwiGLU*", reduction="none (pointwise gate*silu(up))", cuda_verdict="pass", triton_verdict="pass", + ascend_verdict="tracked_red", + # ascend: csrc/ascend/activation.asc swiglu kernel declared; C3/C4 evidence pending an NPU + # host evidence="C3 and C4 both green on cuda_bf16 and triton_cuda_bf16 (sm86)", ), InventoryItem( @@ -97,6 +108,8 @@ def to_dict(self) -> dict[str, object]: reduction="none (elementwise add, no cross-batch reduction)", cuda_verdict="pass", triton_verdict="pass", + ascend_verdict="pass", + # ascend: Audit is backend-independent: torch.add over matching logical tokens on NPU too evidence=( "Audit: residual is x + y with matching logical tokens; " "no tile/batch-shape reduction. Covered by C3 token restore of surrounding ops" @@ -111,7 +124,10 @@ def to_dict(self) -> dict[str, object]: reduction="none (broadcast scalar)", cuda_verdict="pass", triton_verdict="pass", - evidence="Pinned in Native/CUDA/Triton attention; independent of batch/layout", + ascend_verdict="pass", + # ascend: Ascend attention pins the same 1/sqrt(head_dim) scalar; independent of + # batch/layout + evidence="Pinned in Native/CUDA/Triton/Ascend attention; independent of batch/layout", ), InventoryItem( name="bias", @@ -122,6 +138,8 @@ def to_dict(self) -> dict[str, object]: reduction="none (absent on the official fingerprint)", cuda_verdict="pass", triton_verdict="pass", + ascend_verdict="pass", + # ascend: Backend-independent: the official fingerprint has no attention or LM-head bias evidence="C2 config_fingerprint.attention_bias is false; adapters pass bias=None", ), InventoryItem( @@ -133,6 +151,9 @@ def to_dict(self) -> dict[str, object]: reduction="none (masked fill to -inf before softmax)", cuda_verdict="pass", triton_verdict="pass", + ascend_verdict="tracked_red", + # ascend: Ascend deterministic attention takes the same key_padding_mask; padded_left + # evidence pending an NPU host evidence=( "CUDA and Triton C3 padded_left are bitwise 0; Triton rebases the " "contiguous valid KV interval to logical reduction lanes" @@ -147,6 +168,8 @@ def to_dict(self) -> dict[str, object]: reduction="none (policy cast, not a shape-dependent path)", cuda_verdict="pass", triton_verdict="pass", + ascend_verdict="pass", + # ascend: Same C1 policy; Ascend has no TF32 mode, so the TF32 clause holds by construction evidence="tolerance_contract.json policy; C3/C4 provenance rejects dtype drift", ), ) @@ -164,7 +187,17 @@ def unresolved_needs_fix() -> tuple[InventoryItem, ...]: return tuple( item for item in ELEMENTWISE_INVENTORY - if item.cuda_verdict == "blocker" or item.triton_verdict == "blocker" + if "blocker" in (item.cuda_verdict, item.triton_verdict, item.ascend_verdict) + ) + + +def unexecuted_cells() -> tuple[InventoryItem, ...]: + """Items still awaiting on-device C3/C4 evidence on some profile.""" + + return tuple( + item + for item in ELEMENTWISE_INVENTORY + if "tracked_red" in (item.cuda_verdict, item.triton_verdict, item.ascend_verdict) ) @@ -177,5 +210,6 @@ def unresolved_needs_fix() -> tuple[InventoryItem, ...]: "InventoryItem", "inventory_items", "inventory_names", + "unexecuted_cells", "unresolved_needs_fix", ] diff --git a/rl_engine/kernels/gtest/four_judgment_matrix.py b/rl_engine/kernels/gtest/four_judgment_matrix.py index 0ac3f2ae..971da99f 100644 --- a/rl_engine/kernels/gtest/four_judgment_matrix.py +++ b/rl_engine/kernels/gtest/four_judgment_matrix.py @@ -11,7 +11,7 @@ from __future__ import annotations from dataclasses import dataclass, field -from typing import Any +from typing import Any, Sequence from rl_engine.kernels.gtest.gradient_adapters import GRADIENT_ADAPTERS, resolve_profile_candidate from rl_engine.testing.ws1_workload import WS1Manifest, load_manifest @@ -22,7 +22,7 @@ "gradient_accuracy", "gradient_invariance", ) -PROFILES = ("cuda_bf16", "triton_cuda_bf16") +PROFILES = ("cuda_bf16", "triton_cuda_bf16", "ascend_bf16") TIERS = ("short", "primary") CELL_STATUSES = ( "green", @@ -156,13 +156,24 @@ def classify_adapter_cell( def build_classified_matrix( - manifest: WS1Manifest | None = None, *, allow_sm90: bool = False + manifest: WS1Manifest | None = None, + *, + allow_sm90: bool = False, + profiles: Sequence[str] = PROFILES, ) -> MatrixReport: - """Build the full C8 grid and classify every cell (no GPU).""" + """Build the C8 grid and classify every cell (no GPU). + + ``profiles`` narrows the grid to the backend profiles a given host can + actually execute. Each required profile still has to go green somewhere: + C11 only closes when every profile's own job passes. + """ m = manifest if manifest is not None else load_manifest() + unknown = [p for p in profiles if p not in PROFILES] + if unknown: + raise ValueError(f"unknown backend profiles {unknown}") cells: list[MatrixCell] = [] - for profile in PROFILES: + for profile in profiles: for op_name in C8_REQUIRED_OPS: status, detail, candidate = classify_adapter_cell( op_name, profile, m, allow_sm90=allow_sm90 diff --git a/rl_engine/kernels/gtest/gradient_adapters.py b/rl_engine/kernels/gtest/gradient_adapters.py index da172821..74bb2c0a 100644 --- a/rl_engine/kernels/gtest/gradient_adapters.py +++ b/rl_engine/kernels/gtest/gradient_adapters.py @@ -16,6 +16,7 @@ import torch +from rl_engine.kernels.gtest.accelerator import candidate_family from rl_engine.kernels.gtest.forward_invariance import ConfigSpec, RuntimeObservation from rl_engine.kernels.gtest.gradient_invariance import ( GradientObservation, @@ -1218,11 +1219,7 @@ def _run_adapter( def _candidate_family(candidate: str) -> str: - if candidate.startswith("cuda"): - return "cuda" - if candidate == "triton": - return "triton" - return candidate + return candidate_family(candidate) def resolve_profile_candidate( @@ -1272,7 +1269,7 @@ def resolve_profile_candidate( def gradient_adapter_status_matrix( manifest: WS1Manifest | None = None, - profiles: Sequence[str] = ("cuda_bf16", "triton_cuda_bf16"), + profiles: Sequence[str] = ("cuda_bf16", "triton_cuda_bf16", "ascend_bf16"), ) -> tuple[AdapterStatusRow, ...]: m = manifest if manifest is not None else load_manifest() rows: list[AdapterStatusRow] = [] diff --git a/rl_engine/kernels/gtest/kv_consistency.py b/rl_engine/kernels/gtest/kv_consistency.py index 4d482e25..93486c37 100644 --- a/rl_engine/kernels/gtest/kv_consistency.py +++ b/rl_engine/kernels/gtest/kv_consistency.py @@ -15,6 +15,12 @@ import torch +from rl_engine.kernels.gtest.accelerator import ( + ACCELERATOR_TYPES, + candidate_family, + compute_capability, + resolve_device, +) from rl_engine.kernels.gtest.forward_invariance import ( TensorComparisonDetail, _compare_logical_tensors, @@ -302,10 +308,10 @@ def assert_decode_prefill_consistent( operator = attn_op if attn_op is not None else NativeAttentionOp() family = "pytorch" if cand_id == "pytorch" else _candidate_family(cand_id) - if require_declared_candidate and device is None: - if not torch.cuda.is_available(): - raise RuntimeError("C6 declared-candidate gate requires CUDA; CPU-only is not a pass") - run_device = torch.device("cuda") + if require_declared_candidate: + # The declared-candidate gate runs on the profile's own accelerator and + # never degrades to CPU: a CPU pass would not be evidence at all. + run_device = resolve_device(device, profile=backend_profile) else: run_device = torch.device(device or "cpu") @@ -392,10 +398,7 @@ def assert_decode_prefill_consistent( ) ) - cc = None - if run_device.type == "cuda" and torch.cuda.is_available(): - major, minor = torch.cuda.get_device_capability(run_device) - cc = f"{major}.{minor}" + cc = compute_capability(run_device) if run_device.type in ACCELERATOR_TYPES else None if require_declared_candidate: provenance = make_profile_provenance( @@ -456,12 +459,7 @@ def assert_stateful_kv_consistent( cand_id = resolved["candidate"] operator = attn_op if attn_op is not None else load_attention_operator(cand_id) family = str(m.backend_profiles[backend_profile]["backend_family"]) - if device is None: - if not torch.cuda.is_available(): - raise RuntimeError("C7 declared-candidate gate requires CUDA") - run_device = torch.device("cuda") - else: - run_device = torch.device(device) + run_device = resolve_device(device, profile=backend_profile) else: cand_id = candidate or "pytorch" operator = attn_op if attn_op is not None else NativeAttentionOp() @@ -582,11 +580,7 @@ def assert_stateful_kv_consistent( def _candidate_family(candidate: str) -> str: - if candidate.startswith("cuda"): - return "cuda" - if candidate == "triton": - return "triton" - return candidate + return candidate_family(candidate) def _torch_dtype(name: str) -> torch.dtype: diff --git a/rl_engine/kernels/gtest/operator_inputs.py b/rl_engine/kernels/gtest/operator_inputs.py index 34de892a..ca3b7c12 100644 --- a/rl_engine/kernels/gtest/operator_inputs.py +++ b/rl_engine/kernels/gtest/operator_inputs.py @@ -31,6 +31,7 @@ def make_operator_inputs( "matmul": _make_matmul_inputs, "det_gemm": _make_det_gemm_inputs, "attention": _make_attention_inputs, + "prefix_shared_attention": _make_prefix_shared_attention_inputs, "cp_attention": _make_cp_attention_inputs, "logp": _make_logp_inputs, "linear_logp": _make_linear_logp_inputs, @@ -59,6 +60,8 @@ def operator_shape_name(op_name: str, args: argparse.Namespace) -> str: "matmul": f"{batch}x{seq}x{_matmul_k(args)}x{_matmul_n(args)}", "det_gemm": f"{batch}x{seq}x{_matmul_k(args)}x{_matmul_n(args)}", "attention": f"{batch}x{DEFAULT_N_HEADS}x{seq}x{DEFAULT_HEAD_DIM}", + "prefix_shared_attention": f"{batch}x{_arg_int(args, 'n_heads', DEFAULT_N_HEADS)}" + f"x{seq}x{DEFAULT_HEAD_DIM}", "cp_attention": f"{batch}x{DEFAULT_N_HEADS}x{seq}x{DEFAULT_HEAD_DIM}xcp2", "logp": f"{batch}x{seq}x{vocab}", "linear_logp": f"{batch}x{seq}x{_normalized_dim(args)}x{vocab}", @@ -177,6 +180,20 @@ def _make_attention_inputs( return inputs +def _make_prefix_shared_attention_inputs( + args: argparse.Namespace, dtype: torch.dtype, device: torch.device +) -> dict[str, Any]: + """Prefix-shared layout: k/v are 3-D [B, Skv, D] shared by all G groups.""" + batch, seq = _batch_seq(args) + skv = _arg_int(args, "skv", seq) + n_groups = _arg_int(args, "n_heads", DEFAULT_N_HEADS) + return { + "q": _floating_tensor((batch, n_groups, seq, DEFAULT_HEAD_DIM), args, dtype, device, 0), + "k": _floating_tensor((batch, skv, DEFAULT_HEAD_DIM), args, dtype, device, 1), + "v": _floating_tensor((batch, skv, DEFAULT_HEAD_DIM), args, dtype, device, 2), + } + + def _make_cp_attention_inputs( args: argparse.Namespace, dtype: torch.dtype, device: torch.device ) -> dict[str, Any]: diff --git a/rl_engine/kernels/gtest/operator_specs.py b/rl_engine/kernels/gtest/operator_specs.py index ca4a462e..4b86c2b7 100644 --- a/rl_engine/kernels/gtest/operator_specs.py +++ b/rl_engine/kernels/gtest/operator_specs.py @@ -42,6 +42,7 @@ def _load_object(path: str) -> Any: "triton": "rl_engine.kernels.ops.triton.rmsnorm_triton.RMSNormTritonOp", "cuda": "rl_engine.kernels.ops.cuda.norm.rmsnorm.RMSNormCudaOp", "cuda-sm90": "rl_engine.kernels.ops.cuda.norm.rmsnorm.RMSNormCudaOp", + "ascend": "rl_engine.kernels.ops.ascend.norm.rmsnorm.RMSNormAscendOp", }, grad_input_names=("x", "weight"), ), @@ -55,6 +56,7 @@ def _load_object(path: str) -> Any: "triton": "rl_engine.kernels.ops.triton.rmsnorm_triton.RMSNormTritonOp", "cuda": "rl_engine.kernels.ops.cuda.norm.rmsnorm.RMSNormCudaOp", "cuda-sm90": "rl_engine.kernels.ops.cuda.norm.rmsnorm.RMSNormCudaOp", + "ascend": "rl_engine.kernels.ops.ascend.norm.rmsnorm.RMSNormAscendOp", }, grad_input_names=("x", "weight"), ), @@ -73,9 +75,32 @@ def _load_object(path: str) -> Any: "rl_engine.kernels.ops.cuda.attention.deterministic_attn." "DeterministicAttentionOp" ), + "ascend": ( + "rl_engine.kernels.ops.ascend.attention.deterministic_attn." + "DeterministicAttentionAscendOp" + ), }, grad_input_names=("q", "k", "v"), ), + # GRPO decode: every G group attends over one shared K/V sequence + # ([B, Skv, D] instead of [B, Hkv, Skv, D]). Forward-only (no backward), + # same surface as the CUDA PrefixSharedAttentionOp. + "prefix_shared_attention": OperatorSpec( + name="prefix_shared_attention", + op_class="attention", + gold_path="rl_engine.kernels.gtest.operator_specs.GtestPrefixSharedAttentionOp", + gold_method="forward_fp32", + candidate_paths={ + "pytorch": "rl_engine.kernels.gtest.operator_specs.GtestPrefixSharedAttentionOp", + "cuda": ( + "rl_engine.kernels.ops.cuda.attention.prefix_shared_attn." "PrefixSharedAttentionOp" + ), + "ascend": ( + "rl_engine.kernels.ops.ascend.attention.prefix_shared_attn." + "PrefixSharedAttentionAscendOp" + ), + }, + ), "cp_attention": OperatorSpec( name="cp_attention", op_class="attention", @@ -103,6 +128,7 @@ def _load_object(path: str) -> Any: "cuda": "rl_engine.kernels.ops.cuda.loss.logp.FusedLogpGenericOp", "cuda-generic": "rl_engine.kernels.ops.cuda.loss.logp.FusedLogpGenericOp", "cuda-sm90": "rl_engine.kernels.ops.cuda.loss.logp.FusedLogpSM90Op", + "ascend": "rl_engine.kernels.ops.ascend.loss.logp.FusedLogpAscendOp", }, grad_input_names=("logits",), ), @@ -115,6 +141,7 @@ def _load_object(path: str) -> Any: "pytorch": "rl_engine.kernels.ops.pytorch.loss.linear_logp.NativeLinearLogpOp", "triton": "rl_engine.kernels.ops.triton.loss.linear_logp.TritonLinearLogpOp", "cuda-sm90": "rl_engine.kernels.ops.cuda.loss.linear_logp.FusedLinearLogpSM90Op", + "ascend": "rl_engine.kernels.ops.ascend.loss.linear_logp.FusedLinearLogpAscendOp", }, grad_input_names=("hidden", "lm_head_weight"), ), @@ -127,6 +154,7 @@ def _load_object(path: str) -> Any: "pytorch": "rl_engine.kernels.ops.pytorch.linear.embedding.NativeEmbeddingOp", "triton": "rl_engine.kernels.ops.triton.linear.embedding.TritonEmbeddingOp", "cuda-sm90": "rl_engine.kernels.ops.cuda.linear.embedding.SM90EmbeddingOp", + "ascend": "rl_engine.kernels.ops.ascend.linear.embedding.AscendEmbeddingOp", }, grad_input_names=("weight",), ), @@ -139,18 +167,24 @@ def _load_object(path: str) -> Any: "pytorch": "rl_engine.kernels.ops.pytorch.linear.lm_head.NativeLMHeadOp", "triton": "rl_engine.kernels.ops.triton.linear.lm_head.TritonLMHeadOp", "cuda-sm90": "rl_engine.kernels.ops.cuda.linear.lm_head.SM90LMHeadOp", + "ascend": "rl_engine.kernels.ops.ascend.linear.lm_head.AscendLMHeadOp", }, grad_input_names=("hidden", "weight"), ), "det_gemm": OperatorSpec( name="det_gemm", op_class="reduction", - gold_path="rl_engine.kernels.ops.pytorch.matmul.det_gemm.NativeGemmOp", + # The deterministic GEMM rounds every leaf and merge node to BF16, so + # the accuracy gold must be the same leaf-space tree, not the + # single-rounding torch.matmul (which fails structurally at + # near-cancellation outputs on random inputs). + gold_path="rl_engine.kernels.ops.pytorch.matmul.det_gemm.DetGemmTreeReferenceOp", gold_method="__call__", candidate_paths={ "pytorch": "rl_engine.kernels.ops.pytorch.matmul.det_gemm.NativeGemmOp", "cuda": "rl_engine.kernels.ops.cuda.matmul.det_gemm.DetGemmOp", "triton": "rl_engine.kernels.ops.triton.matmul.det_gemm.TritonDetGemmOp", + "ascend": "rl_engine.kernels.ops.ascend.matmul.det_gemm.DetGemmAscendOp", }, grad_input_names=("a", "b"), ), @@ -163,6 +197,7 @@ def _load_object(path: str) -> Any: "pytorch": "rl_engine.kernels.ops.pytorch.rotary_embedding.rope.NativeRoPEOp", "triton": "rl_engine.kernels.ops.triton.rotary_embedding.rope.TritonRoPEOp", "cuda-sm90": "rl_engine.kernels.ops.cuda.rotary_embedding.rope.RoPESM90Op", + "ascend": "rl_engine.kernels.ops.ascend.rotary_embedding.rope.RoPEAscendOp", }, grad_input_names=("x",), ), @@ -175,6 +210,7 @@ def _load_object(path: str) -> Any: "pytorch": "rl_engine.kernels.ops.pytorch.activation.swiglu.NativeSiLUOp", "triton": "rl_engine.kernels.ops.triton.activation.swiglu.TritonSiLUOp", "cuda": "rl_engine.kernels.ops.cuda.activation.swiglu.SiLUCudaOp", + "ascend": "rl_engine.kernels.ops.ascend.activation.silu.SiLUAscendOp", }, grad_input_names=("x",), ), @@ -187,6 +223,7 @@ def _load_object(path: str) -> Any: "pytorch": "rl_engine.kernels.ops.pytorch.activation.swiglu.NativeSwiGLUOp", "triton": "rl_engine.kernels.ops.triton.activation.swiglu.TritonSwiGLUOp", "cuda": "rl_engine.kernels.ops.cuda.activation.swiglu.SwiGLUCudaOp", + "ascend": "rl_engine.kernels.ops.ascend.activation.swiglu.SwiGLUAscendOp", }, grad_input_names=("gate", "up"), ), @@ -243,6 +280,26 @@ def forward_fp32(self, x: torch.Tensor, mask: torch.Tensor) -> torch.Tensor: return packed +class GtestPrefixSharedAttentionOp: + """gtest view of the prefix-shared layout: expand the shared K/V over the + G groups and reuse the standard fp32 attention reference (non-causal, + default scale), which is the gold for the CUDA/Ascend prefix-shared ops. + """ + + op_class = "attention" + + def __init__(self) -> None: + from rl_engine.kernels.ops.pytorch.attention.standard_attn import NativeAttentionOp + + self._op = NativeAttentionOp() + + def __call__(self, q: torch.Tensor, k: torch.Tensor, v: torch.Tensor) -> torch.Tensor: + return self._op(q, k.unsqueeze(1), v.unsqueeze(1), causal=False) + + def forward_fp32(self, q: torch.Tensor, k: torch.Tensor, v: torch.Tensor) -> torch.Tensor: + return self._op.forward_fp32(q, k.unsqueeze(1), v.unsqueeze(1), causal=False) + + class _LogpSM90CandidateAdapter: def __init__(self, candidate: Any) -> None: self._candidate = candidate diff --git a/rl_engine/kernels/gtest/tolerance.py b/rl_engine/kernels/gtest/tolerance.py index 4fb5bcbf..8f80a913 100644 --- a/rl_engine/kernels/gtest/tolerance.py +++ b/rl_engine/kernels/gtest/tolerance.py @@ -708,6 +708,7 @@ def _validate_policy(policy: Mapping[str, Any]) -> None: required_profile_families = { "cuda_bf16": "cuda", "triton_cuda_bf16": "triton", + "ascend_bf16": "ascend", } for required_profile, expected_family in required_profile_families.items(): if required_profile not in profiles: diff --git a/rl_engine/kernels/gtest/tolerance_contract.json b/rl_engine/kernels/gtest/tolerance_contract.json index 2e9e434f..ed19f9fe 100644 --- a/rl_engine/kernels/gtest/tolerance_contract.json +++ b/rl_engine/kernels/gtest/tolerance_contract.json @@ -18,10 +18,11 @@ "candidate_execution": "disabled", "policy": "Repo-wide single policy: TF32 is disabled for FP32 reference and for candidate execution under this contract." }, - "backend_profiles": ["cuda_bf16", "triton_cuda_bf16"], + "backend_profiles": ["cuda_bf16", "triton_cuda_bf16", "ascend_bf16"], "backend_profile_contracts": { "cuda_bf16": {"backend_family": "cuda"}, - "triton_cuda_bf16": {"backend_family": "triton"} + "triton_cuda_bf16": {"backend_family": "triton"}, + "ascend_bf16": {"backend_family": "ascend"} }, "backend_private_tolerance_relaxation": false }, diff --git a/rl_engine/kernels/ops/ascend/__init__.py b/rl_engine/kernels/ops/ascend/__init__.py index ab85458d..f44592c3 100644 --- a/rl_engine/kernels/ops/ascend/__init__.py +++ b/rl_engine/kernels/ops/ascend/__init__.py @@ -1,4 +1,9 @@ # SPDX-License-Identifier: Apache-2.0 # Copyright (c) 2026 RL-Kernel Contributors +from . import activation # noqa: F401 +from . import linear # noqa: F401 from . import loss # noqa: F401 +from . import matmul # noqa: F401 +from . import norm # noqa: F401 +from . import rotary_embedding # noqa: F401 diff --git a/rl_engine/kernels/ops/ascend/activation/__init__.py b/rl_engine/kernels/ops/ascend/activation/__init__.py new file mode 100644 index 00000000..f5bf647e --- /dev/null +++ b/rl_engine/kernels/ops/ascend/activation/__init__.py @@ -0,0 +1,7 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 RL-Kernel Contributors + +from .silu import SiLUAscendOp +from .swiglu import SwiGLUAscendOp + +__all__ = ["SiLUAscendOp", "SwiGLUAscendOp"] diff --git a/rl_engine/kernels/ops/ascend/activation/silu.py b/rl_engine/kernels/ops/ascend/activation/silu.py new file mode 100644 index 00000000..19f8741b --- /dev/null +++ b/rl_engine/kernels/ops/ascend/activation/silu.py @@ -0,0 +1,78 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 RL-Kernel Contributors +"""Ascend C SiLU, with FP32 math and fused forward/backward kernels.""" + +from __future__ import annotations + +from typing import Any + +import torch +from torch import Tensor +from torch.autograd.function import once_differentiable + +_C_npu: Any = None +try: + from rl_engine import _C_npu +except ImportError: # pragma: no cover - extension requires CANN + torch_npu + pass + +_SUPPORTED_DTYPES = (torch.float16, torch.bfloat16, torch.float32) + + +def _validate_inputs(x: Tensor) -> None: + if x.device.type != "npu": + raise RuntimeError("SiLUAscendOp requires NPU tensors.") + if x.dtype not in _SUPPORTED_DTYPES: + raise TypeError(f"x must have dtype fp16, bf16, or fp32, got {x.dtype}.") + + +class _SiLUAscendFunction(torch.autograd.Function): + @staticmethod + def forward(ctx, x: Tensor) -> Tensor: + x_c = x.contiguous() + result = _C_npu.silu_forward(x_c) + ctx.save_for_backward(x_c) + return result + + @staticmethod + @once_differentiable + def backward(ctx, grad_out: Tensor): + (x,) = ctx.saved_tensors + if not ctx.needs_input_grad[0]: + return None + return _C_npu.silu_backward(grad_out.contiguous(), x) + + +class SiLUAscendOp: + """``x * sigmoid(x)`` on NPU, with first-order autograd. + + Shape-agnostic elementwise op: every element is evaluated by the same fixed + FP32 expression regardless of tensor size or launched block count, so the + result is batch-invariant. Empty tensors and strided views are supported; + the native kernels receive contiguous tensors. + """ + + op_class = "elementwise" + + def __init__(self) -> None: + if _C_npu is None or not all( + hasattr(_C_npu, name) for name in ("silu_forward", "silu_backward") + ): + raise RuntimeError( + "Ascend C SiLU kernels are not compiled into rl_engine._C_npu. " + "Rebuild on an Ascend host with CANN and torch_npu: " + "KERNEL_ALIGN_FORCE_ASCEND=1 pip install --no-build-isolation -e ." + ) + + def __call__(self, x: Tensor) -> Tensor: + return self.forward(x) + + def forward(self, x: Tensor) -> Tensor: + """Compute in FP32 and return the input dtype.""" + _validate_inputs(x) + return _SiLUAscendFunction.apply(x) + + def forward_fp32(self, x: Tensor) -> Tensor: + """Compute and return FP32, preserving gradients to the original input.""" + _validate_inputs(x) + return _SiLUAscendFunction.apply(x.float()) diff --git a/rl_engine/kernels/ops/ascend/activation/swiglu.py b/rl_engine/kernels/ops/ascend/activation/swiglu.py new file mode 100644 index 00000000..1901e68d --- /dev/null +++ b/rl_engine/kernels/ops/ascend/activation/swiglu.py @@ -0,0 +1,88 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 RL-Kernel Contributors +"""Ascend C SwiGLU, with FP32 math and fused forward/backward kernels.""" + +from __future__ import annotations + +from typing import Any + +import torch +from torch import Tensor +from torch.autograd.function import once_differentiable + +_C_npu: Any = None +try: + from rl_engine import _C_npu +except ImportError: # pragma: no cover - extension requires CANN + torch_npu + pass + +_SUPPORTED_DTYPES = (torch.float16, torch.bfloat16, torch.float32) + + +def _validate_inputs(gate: Tensor, up: Tensor) -> None: + if gate.device.type != "npu" or up.device.type != "npu": + raise RuntimeError("SwiGLUAscendOp requires NPU tensors.") + if gate.device != up.device: + raise RuntimeError("gate and up must be on the same NPU device.") + if gate.shape != up.shape: + raise ValueError("gate and up must share shape.") + for name, value in (("gate", gate), ("up", up)): + if value.dtype not in _SUPPORTED_DTYPES: + raise TypeError(f"{name} must have dtype fp16, bf16, or fp32, got {value.dtype}.") + if gate.dtype != up.dtype: + raise TypeError("gate and up must share dtype.") + + +class _SwiGLUAscendFunction(torch.autograd.Function): + @staticmethod + def forward(ctx, gate: Tensor, up: Tensor) -> Tensor: + gate_c, up_c = gate.contiguous(), up.contiguous() + result = _C_npu.swiglu_forward(gate_c, up_c) + ctx.save_for_backward(gate_c, up_c) + return result + + @staticmethod + @once_differentiable + def backward(ctx, grad_out: Tensor): + gate, up = ctx.saved_tensors + d_gate = d_up = None + if any(ctx.needs_input_grad): + grads = _C_npu.swiglu_backward(grad_out.contiguous(), gate, up) + if ctx.needs_input_grad[0]: + d_gate = grads[0] + if ctx.needs_input_grad[1]: + d_up = grads[1] + return d_gate, d_up + + +class SwiGLUAscendOp: + """``(gate * sigmoid(gate)) * up`` on NPU, with first-order autograd. + + Inputs share shape, dtype and device. Arbitrary shapes, empty tensors and + strided views are supported; the native kernels receive contiguous tensors. + """ + + op_class = "elementwise" + + def __init__(self) -> None: + if _C_npu is None or not all( + hasattr(_C_npu, name) for name in ("swiglu_forward", "swiglu_backward") + ): + raise RuntimeError( + "Ascend C SwiGLU kernels are not compiled into rl_engine._C_npu. " + "Rebuild on an Ascend host with CANN and torch_npu: " + "KERNEL_ALIGN_FORCE_ASCEND=1 pip install --no-build-isolation -e ." + ) + + def __call__(self, gate: Tensor, up: Tensor) -> Tensor: + return self.forward(gate, up) + + def forward(self, gate: Tensor, up: Tensor) -> Tensor: + """Compute in FP32 and return the input dtype.""" + _validate_inputs(gate, up) + return _SwiGLUAscendFunction.apply(gate, up) + + def forward_fp32(self, gate: Tensor, up: Tensor) -> Tensor: + """Compute and return FP32, preserving gradients to the original inputs.""" + _validate_inputs(gate, up) + return _SwiGLUAscendFunction.apply(gate.float(), up.float()) diff --git a/rl_engine/kernels/ops/ascend/attention/__init__.py b/rl_engine/kernels/ops/ascend/attention/__init__.py new file mode 100644 index 00000000..d7beaa91 --- /dev/null +++ b/rl_engine/kernels/ops/ascend/attention/__init__.py @@ -0,0 +1,9 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 RL-Kernel Contributors + +from rl_engine.kernels.ops.ascend.attention.deterministic_attn import DeterministicAttentionAscendOp +from rl_engine.kernels.ops.ascend.attention.prefix_shared_attn import ( + PrefixSharedAttentionAscendOp, +) + +__all__ = ["DeterministicAttentionAscendOp", "PrefixSharedAttentionAscendOp"] diff --git a/rl_engine/kernels/ops/ascend/attention/deterministic_attn.py b/rl_engine/kernels/ops/ascend/attention/deterministic_attn.py new file mode 100644 index 00000000..bb1e3a1d --- /dev/null +++ b/rl_engine/kernels/ops/ascend/attention/deterministic_attn.py @@ -0,0 +1,291 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 RL-Kernel Contributors + +"""Ascend NPU deterministic standard-softmax attention (issue #147). + +Forward: QK -> masked softmax+LSE -> PV (all FP32 intermediate) on an +Ascend C kernel (`_C_npu.deterministic_attention_ascend`). Every +(b, q_head, row) is reduced end-to-end by one AI-core block with a fixed +64-key tile order and no split-K merge, so per-row numerics are +batch-invariant (the same algorithm as the Triton reference and the CUDA +deterministic op). + +Backward (bf16): a dedicated Ascend C kernel +(`_C_npu.deterministic_attention_backward_ascend`) recomputes P/dS and the +dq/dk/dv VJP with fixed keyBegin-anchored logical reduction orders, so the +gradients are bitwise invariant to the batch layout and to where padding +sits. FP16 (an optional contract row) falls back to the torch VJP of the +fp32 reference forward (`NativeAttentionOp.forward_fp32`), reusing the +forward-saved q/k/v/mask. +""" + +from __future__ import annotations + +import math +from typing import Any, Optional + +import torch +from torch.autograd import Function +from torch.autograd.function import once_differentiable + +from rl_engine.kernels.ops.pytorch.attention.standard_attn import NativeAttentionOp +from rl_engine.utils.logger import logger + +_C_npu: Any = None +try: + from rl_engine import _C_npu + + _NPU_EXT_AVAILABLE = True +except ImportError: # pragma: no cover - Ascend extension not built + _NPU_EXT_AVAILABLE = False + +_HEAD_DIM = 128 + + +class _DeterministicAttentionAscendFn(Function): + @staticmethod + def forward( + ctx, + q: torch.Tensor, + k: torch.Tensor, + v: torch.Tensor, + causal: bool, + scale: float, + key_padding_mask: Optional[torch.Tensor], + output_fp32: bool = False, + ) -> tuple[torch.Tensor, torch.Tensor]: + q_c = q.contiguous() + k_c = k.contiguous() + v_c = v.contiguous() + mask_c = key_padding_mask.contiguous() if key_padding_mask is not None else None + + out, lse = _C_npu.deterministic_attention_ascend( + q_c, k_c, v_c, causal, float(scale), mask_c, output_fp32 + ) + + ctx.save_for_backward(q_c, k_c, v_c, mask_c) + ctx.causal = causal + ctx.scale = scale + ctx.has_mask = mask_c is not None + ctx.mark_non_differentiable(lse) + return out, lse + + @staticmethod + @once_differentiable + def backward(ctx, grad_out: torch.Tensor, grad_lse: torch.Tensor): + del grad_lse # lse is non-differentiable; always None upstream + q, k, v, mask = ctx.saved_tensors + + if q.dtype == torch.float16: + # The dedicated kernel is bf16-only; fp16 (an optional contract + # row) falls back to the torch VJP of the fp32 reference forward. + # The VJP runs on the LOGICALLY COMPACTED tokens: left/right + # padding shifts the valid values inside the torch softmax/matmul + # reduction trees and flips ULPs in the gradients, which the + # model-level gradient invariance then amplifies. Compacting the + # valid tokens first makes the VJP's reductions padding-invariant; + # the padded positions get zero grads on the scatter-back. + if ctx.has_mask: + valid = mask + counts = valid.sum(dim=1) # [B] + # Compact to a FIXED width (the full sequence length) so the + # VJP runs on the same shapes for every cell -- the torch + # reductions inside the reference VJP are also shape-dependent + # on NPU, and a per-cell max count would reintroduce the + # row-count dependence. + B, Hq, S, D = q.shape + width = S + Hkv = k.shape[1] + q_c = torch.zeros(B, Hq, width, D, dtype=q.dtype, device=q.device) + k_c = torch.zeros(B, Hkv, width, D, dtype=k.dtype, device=k.device) + v_c = torch.zeros(B, Hkv, width, D, dtype=v.dtype, device=v.device) + g_c = torch.zeros(B, Hq, width, D, dtype=grad_out.dtype, device=grad_out.device) + for b in range(B): + idx = valid[b].nonzero().flatten() + c = int(counts[b].item()) + q_c[b, :, :c] = q[b, :, idx] + k_c[b, :, :c] = k[b, :, idx] + v_c[b, :, :c] = v[b, :, idx] + g_c[b, :, :c] = grad_out[b, :, idx] + with torch.enable_grad(): + q_ref = q_c.detach().requires_grad_(True) + k_ref = k_c.detach().requires_grad_(True) + v_ref = v_c.detach().requires_grad_(True) + out = NativeAttentionOp().forward_fp32( + q_ref, + k_ref, + v_ref, + causal=ctx.causal, + scale=ctx.scale, + key_padding_mask=None, + ) + dq_c, dk_c, dv_c = torch.autograd.grad(out, (q_ref, k_ref, v_ref), g_c) + dq = torch.zeros_like(q) + dk = torch.zeros_like(k) + dv = torch.zeros_like(v) + for b in range(B): + idx = valid[b].nonzero().flatten() + c = int(counts[b].item()) + dq[b, :, idx] = dq_c[b, :, :c] + dk[b, :, idx] = dk_c[b, :, :c] + dv[b, :, idx] = dv_c[b, :, :c] + return dq, dk, dv, None, None, None, None + + with torch.enable_grad(): + q_ref = q.detach().requires_grad_(True) + k_ref = k.detach().requires_grad_(True) + v_ref = v.detach().requires_grad_(True) + out = NativeAttentionOp().forward_fp32( + q_ref, + k_ref, + v_ref, + causal=ctx.causal, + scale=ctx.scale, + key_padding_mask=None, + ) + dq, dk, dv = torch.autograd.grad(out, (q_ref, k_ref, v_ref), grad_out) + return dq, dk, dv, None, None, None, None + + # bf16: the dedicated Ascend C backward kernel. Every reduction (the + # softmax row sums, the dQ accumulation, and the per-key dV/dK sums) + # follows the fixed keyBegin-anchored logical orders, so the + # gradients are bitwise invariant to the batch layout and to where + # padding sits -- no torch reductions are involved. + dq, dk, dv = _C_npu.deterministic_attention_backward_ascend( + grad_out.contiguous(), + q, + k, + v, + ctx.causal, + float(ctx.scale), + mask if ctx.has_mask else None, + ) + return dq, dk, dv, None, None, None, None + + +class DeterministicAttentionAscendOp: + """Batch-invariant standard softmax attention on Ascend NPU. + + Public surface matches ``NativeAttentionOp`` / ``DeterministicAttentionOp`` + so the #108 harness can call ``forward(**inputs)`` with ``key_padding_mask``. + Out-of-domain inputs are rejected up front (the registry-level + ``PYTORCH_NATIVE_ATTENTION`` entry covers unavailable-kernel fallback). + """ + + def __init__(self) -> None: + if not _NPU_EXT_AVAILABLE or not hasattr(_C_npu, "deterministic_attention_ascend"): + raise RuntimeError( + "deterministic_attention_ascend is not compiled into the extension. " + "Rebuild with KERNEL_ALIGN_FORCE_ASCEND=1 on an Ascend NPU host: " + "'pip install -e .'" + ) + logger.info( + "Successfully linked to precompiled _C_npu.deterministic_attention_ascend kernel." + ) + + def __call__( + self, + q: torch.Tensor, + k: torch.Tensor, + v: torch.Tensor, + *, + causal: bool = True, + scale: Optional[float] = None, + key_padding_mask: Optional[torch.Tensor] = None, + ) -> torch.Tensor: + return self.forward(q, k, v, causal=causal, scale=scale, key_padding_mask=key_padding_mask) + + def forward( + self, + q: torch.Tensor, + k: torch.Tensor, + v: torch.Tensor, + *, + causal: bool = True, + scale: Optional[float] = None, + key_padding_mask: Optional[torch.Tensor] = None, + ) -> torch.Tensor: + """Harness / registry main path: return out only. Differentiable.""" + out, _lse = self.forward_with_lse( + q, k, v, causal=causal, scale=scale, key_padding_mask=key_padding_mask + ) + return out + + def forward_fp32( + self, + q: torch.Tensor, + k: torch.Tensor, + v: torch.Tensor, + *, + causal: bool = True, + scale: Optional[float] = None, + key_padding_mask: Optional[torch.Tensor] = None, + ) -> torch.Tensor: + """FP32-output attention: the kernel emits the exact FP32 accumulator + (the twin of the CUDA op's forward_fp32 composite edge).""" + self._validate_inputs(q, k, v, key_padding_mask) + resolved_scale = scale if scale is not None else (1.0 / math.sqrt(q.shape[-1])) + out, _lse = _DeterministicAttentionAscendFn.apply( + q, k, v, causal, resolved_scale, key_padding_mask, True + ) + return out + + def forward_with_lse( + self, + q: torch.Tensor, + k: torch.Tensor, + v: torch.Tensor, + *, + causal: bool = True, + scale: Optional[float] = None, + key_padding_mask: Optional[torch.Tensor] = None, + ) -> tuple[torch.Tensor, torch.Tensor]: + """Return (out, lse) with FP32 LSE for debug / handoff hooks.""" + self._validate_inputs(q, k, v, key_padding_mask) + resolved_scale = scale if scale is not None else (1.0 / math.sqrt(q.shape[-1])) + out, lse = _DeterministicAttentionAscendFn.apply( + q, k, v, causal, resolved_scale, key_padding_mask, False + ) + return out, lse + + @staticmethod + def _validate_inputs( + q: torch.Tensor, + k: torch.Tensor, + v: torch.Tensor, + key_padding_mask: Optional[torch.Tensor], + ) -> None: + if q.dim() != 4 or k.dim() != 4 or v.dim() != 4: + raise ValueError( + f"q/k/v must be 4-D [B, H, S, D], got q={tuple(q.shape)}, " + f"k={tuple(k.shape)}, v={tuple(v.shape)}" + ) + b, hq, sq, d = q.shape + hkv, skv = k.shape[1], k.shape[2] + if k.shape[0] != b or v.shape[0] != b: + raise ValueError("batch size mismatch between q/k/v") + if v.shape[1] != hkv or v.shape[2] != skv or k.shape[3] != d or v.shape[3] != d: + raise ValueError( + f"k/v shape mismatch: k={tuple(k.shape)}, v={tuple(v.shape)}, " + f"expected k/v [B={b}, Hkv, Skv, D={d}]" + ) + if d != _HEAD_DIM: + raise ValueError(f"head dim D must be {_HEAD_DIM}, got {d}") + if hq % hkv != 0: + raise ValueError(f"Hq={hq} not divisible by Hkv={hkv} (GQA group)") + if q.dtype not in (torch.float16, torch.bfloat16): + raise ValueError(f"only FP16/BF16 supported, got {q.dtype}") + if k.dtype != q.dtype or v.dtype != q.dtype: + raise ValueError("q, k, v must share the same dtype") + if not (q.device.type == "npu" and k.device.type == "npu" and v.device.type == "npu"): + raise ValueError("q, k, v must be NPU tensors") + if key_padding_mask is not None: + if key_padding_mask.dtype != torch.bool: + raise ValueError("key_padding_mask must be bool") + if key_padding_mask.shape != (b, skv): + raise ValueError( + f"key_padding_mask must be [B, Skv]=[{b}, {skv}], " + f"got {tuple(key_padding_mask.shape)}" + ) + if sq < 1 or skv < 1: + raise ValueError(f"Sq and Skv must be positive, got Sq={sq}, Skv={skv}") diff --git a/rl_engine/kernels/ops/ascend/attention/prefix_shared_attn.py b/rl_engine/kernels/ops/ascend/attention/prefix_shared_attn.py new file mode 100644 index 00000000..2f8e0cd2 --- /dev/null +++ b/rl_engine/kernels/ops/ascend/attention/prefix_shared_attn.py @@ -0,0 +1,119 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 RL-Kernel Contributors + +"""Ascend NPU prefix-shared fused attention (GRPO decode workload). + +Port of the CUDA op in rl_engine/kernels/ops/cuda/attention/prefix_shared_attn.py: +in GRPO, the G generated responses share the exact same prompt-prefix KV cache, +so K/V are stored once per batch and broadcast across all G query groups. + +Forward: softmax(Q K^T * scale) @ V on an Ascend C kernel +(`_C_npu.prefix_shared_attention_ascend`) with fp32 online-softmax +accumulation. bf16 in/out, non-causal, no key-padding mask, head dim fixed at +128 -- the same surface as the CUDA `PrefixSharedAttentionOp`, which is +forward-only and so is this port. +""" + +from __future__ import annotations + +from typing import Any + +import torch + +from rl_engine.utils.logger import logger + +_C_npu: Any = None +try: + from rl_engine import _C_npu + + _NPU_EXT_AVAILABLE = True +except ImportError: # pragma: no cover - Ascend extension not built + _NPU_EXT_AVAILABLE = False + +_HEAD_DIM = 128 + + +class PrefixSharedAttentionAscendOp: + """Prefix-shared softmax attention on Ascend NPU. + + q [bs, G, len_q, D] attends over a single shared k/v sequence + [bs, len_kv, D] that every G group reuses. Mirrors the CUDA + ``PrefixSharedAttentionOp`` surface (``op(q, k, v) -> out``). + """ + + def __init__(self) -> None: + if not _NPU_EXT_AVAILABLE or not hasattr(_C_npu, "prefix_shared_attention_ascend"): + raise RuntimeError( + "prefix_shared_attention_ascend is not compiled into the extension. " + "Rebuild with KERNEL_ALIGN_FORCE_ASCEND=1 on an Ascend NPU host: " + "'pip install -e .'" + ) + logger.info( + "Successfully linked to precompiled _C_npu.prefix_shared_attention_ascend kernel." + ) + + def __call__( + self, + q: torch.Tensor, + k: torch.Tensor, + v: torch.Tensor, + ) -> torch.Tensor: + """ + Prefix-shared attention forward pass. + + Args: + q: Query tensor of shape [bs, G, len_q, head_dim] + k: Shared Key tensor of shape [bs, len_kv, head_dim] + v: Shared Value tensor of shape [bs, len_kv, head_dim] + + Returns: + Output tensor of shape [bs, G, len_q, head_dim] + """ + return self.forward(q, k, v) + + def forward( + self, + q: torch.Tensor, + k: torch.Tensor, + v: torch.Tensor, + ) -> torch.Tensor: + self._validate_inputs(q, k, v) + return _C_npu.prefix_shared_attention_ascend( + q.contiguous(), k.contiguous(), v.contiguous() + ) + + @staticmethod + def _validate_inputs(q: torch.Tensor, k: torch.Tensor, v: torch.Tensor) -> None: + if q.dim() != 4 or k.dim() != 3 or v.dim() != 3: + raise ValueError( + f"q must be 4-D [B, G, Sq, D] and k/v 3-D [B, Skv, D], got " + f"q={tuple(q.shape)}, k={tuple(k.shape)}, v={tuple(v.shape)}" + ) + b, g, sq, d = q.shape + skv = k.shape[1] + if k.shape[0] != b or v.shape[0] != b: + raise ValueError("batch size mismatch between q/k/v") + if k.shape[2] != d or v.shape[2] != d: + raise ValueError( + f"k/v head dim mismatch: k={tuple(k.shape)}, v={tuple(v.shape)}, " + f"expected D={d}" + ) + if v.shape[1] != skv: + raise ValueError( + f"k/v key length mismatch: k={tuple(k.shape)}, v={tuple(v.shape)}" + ) + if d != _HEAD_DIM: + raise ValueError(f"head dim D must be {_HEAD_DIM}, got {d}") + if q.dtype != torch.bfloat16 or k.dtype != torch.bfloat16 or v.dtype != torch.bfloat16: + raise ValueError( + f"only BF16 is supported (matches the CUDA op), got " + f"q={q.dtype}, k={k.dtype}, v={v.dtype}" + ) + if not ( + q.device.type == "npu" and k.device.type == "npu" and v.device.type == "npu" + ): + raise ValueError("q, k, v must be NPU tensors") + if sq < 1 or skv < 1: + raise ValueError(f"Sq and Skv must be positive, got Sq={sq}, Skv={skv}") + if g < 1: + raise ValueError(f"G must be positive, got G={g}") diff --git a/rl_engine/kernels/ops/ascend/linear/__init__.py b/rl_engine/kernels/ops/ascend/linear/__init__.py new file mode 100644 index 00000000..dd74a18f --- /dev/null +++ b/rl_engine/kernels/ops/ascend/linear/__init__.py @@ -0,0 +1,5 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 RL-Kernel Contributors + +from . import embedding # noqa: F401 +from . import lm_head # noqa: F401 diff --git a/rl_engine/kernels/ops/ascend/linear/embedding.py b/rl_engine/kernels/ops/ascend/linear/embedding.py new file mode 100644 index 00000000..cf8c721a --- /dev/null +++ b/rl_engine/kernels/ops/ascend/linear/embedding.py @@ -0,0 +1,127 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 RL-Kernel Contributors + +from __future__ import annotations + +from typing import Any + +import torch + +from rl_engine.kernels.ops.backward_runtime import record_backward +from rl_engine.utils.logger import logger + +_C_npu: Any = None +try: + from rl_engine import _C_npu + + _NPU_EXT_AVAILABLE = True +except ImportError: # pragma: no cover - Ascend extension not built + _NPU_EXT_AVAILABLE = False + +_SUPPORTED_DTYPES = {torch.float32, torch.float16, torch.bfloat16} + + +def _deterministic_embedding_grad_weight( + ids: torch.Tensor, + grad_rows: torch.Tensor, + *, + weight_shape: tuple[int, ...], + weight_dtype: torch.dtype, +) -> torch.Tensor: + # Bitwise-identical backward by construction: the SM90 CUDA op's backward + # is itself pure PyTorch (sorted-segment dweight), so the Ascend op reuses + # the exact same function. Every op in it (mask, stable argsort, + # unique_consecutive, fixed-order accumulation) is deterministic on NPU, + # hence grad_weight matches the CUDA op bit for bit on identical inputs. + from rl_engine.kernels.ops.cuda.linear.embedding import ( + _deterministic_embedding_grad_weight as _cuda_grad_weight, + ) + + return _cuda_grad_weight( + ids, + grad_rows, + weight_shape=weight_shape, + weight_dtype=weight_dtype, + ) + + +class _AscendEmbeddingFunction(torch.autograd.Function): + @staticmethod + def forward(ctx, token_ids: torch.Tensor, weight: torch.Tensor, output_fp32: bool): + ctx.save_for_backward(token_ids) + ctx.weight_shape = tuple(weight.shape) + ctx.weight_dtype = weight.dtype + ctx.output_fp32 = bool(output_fp32) + return _C_npu.embedding_ascend(token_ids, weight.contiguous(), bool(output_fp32)) + + @staticmethod + def backward(ctx, grad_output: torch.Tensor): + (token_ids,) = ctx.saved_tensors + grad_weight = None + if ctx.needs_input_grad[1]: + ids = token_ids.reshape(-1).to(device=grad_output.device, dtype=torch.long) + hidden_size = int(ctx.weight_shape[1]) + grad_rows = grad_output.reshape(ids.numel(), hidden_size) + grad_weight = _deterministic_embedding_grad_weight( + ids, + grad_rows, + weight_shape=ctx.weight_shape, + weight_dtype=ctx.weight_dtype, + ) + record_backward( + "embedding", + kernel_id=( + "rl_engine.kernels.ops.ascend.linear.embedding." + "_deterministic_embedding_grad_weight" + ), + impl="ascend_sorted_segment_dweight", + family="ascend", + ) + return None, grad_weight, None + + +class AscendEmbeddingOp(torch.nn.Module): + """Single-card batch-invariant Ascend C embedding op. + + Forward is a pure row gather (a byte copy of weight rows), so it is + bitwise identical to the SM90 CUDA embedding kernel on identical inputs; + backward reuses the same sorted-segment dweight formula as the CUDA op. + """ + + op_class = "elementwise" + is_batch_invariant = True + + def __init__(self) -> None: + super().__init__() + if not _NPU_EXT_AVAILABLE or not hasattr(_C_npu, "embedding_ascend"): + raise RuntimeError( + "embedding_ascend is not compiled into the extension. " + "Rebuild on an Ascend NPU host with KERNEL_ALIGN_FORCE_ASCEND=1." + ) + logger.info("Successfully linked to precompiled _C_npu.embedding_ascend kernel.") + + def forward(self, token_ids: torch.Tensor, weight: torch.Tensor) -> torch.Tensor: + if not self._can_use_ascend(token_ids, weight): + raise RuntimeError( + "AscendEmbeddingOp requires Ascend NPU bf16/fp16/fp32 inputs; " + "Native/Triton fallback is forbidden" + ) + return _AscendEmbeddingFunction.apply(token_ids, weight, False) + + def forward_fp32(self, token_ids: torch.Tensor, weight: torch.Tensor) -> torch.Tensor: + if not self._can_use_ascend(token_ids, weight): + raise RuntimeError( + "AscendEmbeddingOp requires Ascend NPU bf16/fp16/fp32 inputs; " + "Native/Triton fallback is forbidden" + ) + return _AscendEmbeddingFunction.apply(token_ids, weight, True) + + @staticmethod + def _can_use_ascend(token_ids: torch.Tensor, weight: torch.Tensor) -> bool: + return ( + token_ids.device.type == "npu" + and weight.device.type == "npu" + and token_ids.device == weight.device + and weight.dim() == 2 + and weight.dtype in _SUPPORTED_DTYPES + ) diff --git a/rl_engine/kernels/ops/ascend/linear/lm_head.py b/rl_engine/kernels/ops/ascend/linear/lm_head.py new file mode 100644 index 00000000..722fe892 --- /dev/null +++ b/rl_engine/kernels/ops/ascend/linear/lm_head.py @@ -0,0 +1,159 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 RL-Kernel Contributors + +from __future__ import annotations + +from typing import Any, Optional + +import torch + +from rl_engine.kernels.ops.backward_runtime import record_backward +from rl_engine.utils.logger import logger + +_C_npu: Any = None +try: + from rl_engine import _C_npu + + _NPU_EXT_AVAILABLE = True +except ImportError: # pragma: no cover - Ascend extension not built + _NPU_EXT_AVAILABLE = False + +_SUPPORTED_DTYPES = {torch.float32, torch.float16, torch.bfloat16} + + +class _AscendLMHeadFunction(torch.autograd.Function): + """Autograd bridge for the Ascend batch-invariant LM-head forward. + + The VJP is the standard linear formula computed in fp32 on the NPU + (``grad_hidden = grad @ W``, ``grad_weight = grad^T @ H``, bias = the + fixed-order row sum), then cast back to the input dtypes. The CUDA op + uses its declared deterministic GEMM for the backward; on NPU the + plain torch matmuls are the deterministic-in-practice equivalent, and + the gtest compares gradients at the reduction contract tolerance. + """ + + @staticmethod + def forward(ctx, hidden, weight, bias, output_fp32: bool): + bias_to_save = ( + bias if bias is not None else torch.empty(0, device=hidden.device, dtype=hidden.dtype) + ) + ctx.save_for_backward(hidden, weight, bias_to_save) + ctx.has_bias = bias is not None + ctx.output_fp32 = bool(output_fp32) + return _C_npu.lm_head_ascend(hidden, weight.contiguous(), bias, bool(output_fp32)) + + @staticmethod + def backward(ctx, grad_output: torch.Tensor): + hidden, weight, bias = ctx.saved_tensors + grad_2d = grad_output.reshape(-1, weight.size(0)).contiguous().float() + hidden_2d = hidden.reshape(-1, hidden.size(-1)).contiguous().float() + weight_f = weight.contiguous().float() + grad_hidden = grad_weight = grad_bias = None + if ctx.needs_input_grad[0]: + grad_hidden = grad_2d @ weight_f + grad_hidden = grad_hidden.reshape_as(hidden).to(hidden.dtype) + if ctx.needs_input_grad[1]: + grad_weight = (grad_2d.t() @ hidden_2d).to(weight.dtype) + if ctx.has_bias and ctx.needs_input_grad[2]: + rows = grad_output.reshape(-1, weight.size(0)).float() + acc = torch.zeros((rows.shape[1],), device=rows.device, dtype=torch.float32) + for index in range(rows.shape[0]): + acc = acc + rows[index] + grad_bias = acc.to(bias.dtype) + record_backward( + "lm_head", + kernel_id="rl_engine.kernels.ops.ascend.linear.lm_head._AscendLMHeadFunction", + impl="ascend_fp32_matmul_vjp", + family="ascend", + ) + return grad_hidden, grad_weight, grad_bias, None + + +class AscendLMHeadOp: + """Single-card batch-invariant Ascend LM-head op. + + The Ascend C forward mirrors the SM90 CUDA kernel's structure: one + output element per block iteration, full hidden-dimension fp32 reduction + inside that block over a fixed tile order, bias added in fp32, final + cast to the output dtype. There is no Split-K and no algorithm + selection, so a row's logits do not depend on batch layout. + """ + + op_class = "reduction" + is_batch_invariant = True + backward_impl = "ascend_fp32_matmul_vjp" + + def __init__(self) -> None: + if not _NPU_EXT_AVAILABLE or not hasattr(_C_npu, "lm_head_ascend"): + raise RuntimeError( + "lm_head_ascend is not compiled into the extension. " + "Rebuild on an Ascend NPU host with KERNEL_ALIGN_FORCE_ASCEND=1." + ) + logger.info("Successfully linked to precompiled _C_npu.lm_head_ascend kernel.") + + def __call__( + self, + hidden: torch.Tensor, + weight: torch.Tensor, + *, + bias: Optional[torch.Tensor] = None, + ) -> torch.Tensor: + return self.forward(hidden, weight, bias=bias) + + def forward( + self, + hidden: torch.Tensor, + weight: torch.Tensor, + *, + bias: Optional[torch.Tensor] = None, + ) -> torch.Tensor: + if not self._can_use_ascend(hidden, weight, bias): + raise RuntimeError( + "AscendLMHeadOp requires Ascend NPU bf16/fp16/fp32 inputs; " + "Native/Triton fallback is forbidden" + ) + return _AscendLMHeadFunction.apply(hidden, weight, bias, False) + + def forward_fp32( + self, + hidden: torch.Tensor, + weight: torch.Tensor, + *, + bias: Optional[torch.Tensor] = None, + ) -> torch.Tensor: + if not self._can_use_ascend(hidden, weight, bias): + raise RuntimeError( + "AscendLMHeadOp requires Ascend NPU bf16/fp16/fp32 inputs; " + "Native/Triton fallback is forbidden" + ) + return _AscendLMHeadFunction.apply(hidden, weight, bias, True) + + def parameter_vjp_contributions_fp32(self, *, hidden, weight, grad_output, bias=None): + del weight, bias + rows_h = hidden.reshape(-1, hidden.size(-1)).float() + rows_g = grad_output.reshape(-1, grad_output.size(-1)).float() + return {"weight": rows_g[:, :, None] * rows_h[:, None, :]} + + @staticmethod + def _can_use_ascend( + hidden: torch.Tensor, + weight: torch.Tensor, + bias: Optional[torch.Tensor], + ) -> bool: + bias_ok = bias is None or ( + bias.device.type == "npu" + and bias.device == hidden.device + and bias.dim() == 1 + and bias.dtype in _SUPPORTED_DTYPES + ) + return ( + hidden.device.type == "npu" + and weight.device.type == "npu" + and hidden.device == weight.device + and hidden.dim() >= 2 + and weight.dim() == 2 + and hidden.size(-1) == weight.size(1) + and hidden.dtype in _SUPPORTED_DTYPES + and weight.dtype in _SUPPORTED_DTYPES + and bias_ok + ) diff --git a/rl_engine/kernels/ops/ascend/loss/__init__.py b/rl_engine/kernels/ops/ascend/loss/__init__.py index 86cf4c9d..36bec4f7 100644 --- a/rl_engine/kernels/ops/ascend/loss/__init__.py +++ b/rl_engine/kernels/ops/ascend/loss/__init__.py @@ -1,2 +1,6 @@ # SPDX-License-Identifier: Apache-2.0 # Copyright (c) 2026 RL-Kernel Contributors + +from . import batch_invariant_logp # noqa: F401 +from . import linear_logp # noqa: F401 +from . import logp # noqa: F401 diff --git a/rl_engine/kernels/ops/ascend/loss/linear_logp.py b/rl_engine/kernels/ops/ascend/loss/linear_logp.py new file mode 100644 index 00000000..af44bc90 --- /dev/null +++ b/rl_engine/kernels/ops/ascend/loss/linear_logp.py @@ -0,0 +1,173 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 RL-Kernel Contributors + +from __future__ import annotations + +from typing import Any, Optional + +import torch + +from rl_engine.utils.logger import logger + +_C_npu: Any = None +try: + from rl_engine import _C_npu + + _NPU_EXT_AVAILABLE = True +except ImportError: # pragma: no cover - Ascend extension not built + _NPU_EXT_AVAILABLE = False + +_SUPPORTED_DTYPES = {torch.float32, torch.float16, torch.bfloat16} + + +class _FusedLinearLogpAscendFunction(torch.autograd.Function): + """Autograd bridge for the Ascend fused linear log-prob forward. + + The backward is the shared Liger-style chunked formula from + ``rl_engine.kernels.ops.pytorch.loss.linear_logp.chunked_linear_logp_backward`` + (the same formula the CUDA SM90 op falls back to), so gradients follow + the CUDA op's portable backward exactly. + """ + + @staticmethod + def forward(ctx, hidden, lm_head_weight, target_ids): + hidden_2d = hidden.reshape(-1, hidden.size(-1)).contiguous() + weight = lm_head_weight.contiguous() + target_1d = ( + target_ids.reshape(-1).to(device=hidden_2d.device, dtype=torch.long).contiguous() + ) + output = _C_npu.fused_linear_logp_ascend(hidden_2d, weight, None, target_1d) + ctx.save_for_backward(hidden_2d, weight, target_1d) + ctx.lead_shape = hidden.shape[:-1] + ctx.hidden_dtype = hidden.dtype + ctx.weight_dtype = lm_head_weight.dtype + return output.reshape(hidden.shape[:-1]) + + @staticmethod + def backward(ctx, grad_logp): + from rl_engine.kernels.ops.pytorch.loss.linear_logp import chunked_linear_logp_backward + + hidden_2d, weight, target_1d = ctx.saved_tensors + grad_hidden, grad_weight, _ = chunked_linear_logp_backward( + grad_logp, + hidden_2d, + weight, + target_1d, + hidden_2d, # bias placeholder; has_bias=False + has_bias=False, + lead_shape=ctx.lead_shape, + hidden_dtype=ctx.hidden_dtype, + weight_dtype=ctx.weight_dtype, + bias_dtype=None, + ) + return grad_hidden, grad_weight, None + + +class FusedLinearLogpAscendOp: + """Batch-invariant fused linear log-prob for Ascend NPU. + + Computes ``log_softmax(hidden @ W^T + b)[target]`` without materializing + the ``[N, V]`` logits. The Ascend C forward mirrors the SM90 kernel's + reduction contract: ascending vocab-row scan with the online rescale + chain, per-row fp32 dots over a fixed D-tile order, final + ``min(zt - lse, 0)`` clamp; the output is fp32. + """ + + is_fused_logp = True + is_batch_invariant = True + + def __init__(self) -> None: + if not _NPU_EXT_AVAILABLE or not hasattr(_C_npu, "fused_linear_logp_ascend"): + raise RuntimeError( + "fused_linear_logp_ascend is not compiled into the extension. Rebuild with " + "KERNEL_ALIGN_FORCE_ASCEND=1 on an Ascend NPU host: 'pip install -e .'" + ) + self.op = _C_npu.fused_linear_logp_ascend + logger.info("Successfully linked to precompiled _C_npu.fused_linear_logp_ascend kernel.") + + def __call__( + self, + hidden: torch.Tensor, + lm_head_weight: torch.Tensor, + target_ids: torch.Tensor, + bias: Optional[torch.Tensor] = None, + *, + tp_group: Any = None, + vocab_start_index: int = 0, + global_vocab_size: Optional[int] = None, + ) -> torch.Tensor: + return self.apply( + hidden, + lm_head_weight, + target_ids, + bias, + tp_group=tp_group, + vocab_start_index=vocab_start_index, + global_vocab_size=global_vocab_size, + ) + + def apply( + self, + hidden: torch.Tensor, + lm_head_weight: torch.Tensor, + target_ids: torch.Tensor, + bias: Optional[torch.Tensor] = None, + *, + tp_group: Any = None, + vocab_start_index: int = 0, + global_vocab_size: Optional[int] = None, + ) -> torch.Tensor: + from rl_engine.kernels.ops.pytorch.loss.linear_logp import ( + NativeLinearLogpOp, + should_use_tensor_parallel_linear_logp, + ) + + if lm_head_weight.size(-1) != hidden.size(-1): + raise ValueError( + f"hidden dim {hidden.size(-1)} must match lm_head_weight dim " + f"{lm_head_weight.size(-1)}" + ) + if lm_head_weight.device != hidden.device: + raise ValueError( + f"lm_head_weight device {lm_head_weight.device} must match hidden " + f"device {hidden.device}" + ) + if hidden.shape[:-1] != target_ids.shape: + raise ValueError( + f"hidden leading shape {tuple(hidden.shape[:-1])} must match " + f"target_ids shape {tuple(target_ids.shape)}" + ) + # Tensor-parallel and bias paths are not covered by the Ascend forward; + # delegate to the native reference (same fallback as the CUDA op). + if ( + should_use_tensor_parallel_linear_logp( + tp_group, + int(vocab_start_index), + global_vocab_size, + lm_head_weight.size(0), + ) + or bias is not None + ): + return NativeLinearLogpOp().apply( + hidden, + lm_head_weight, + target_ids, + bias, + tp_group=tp_group, + vocab_start_index=vocab_start_index, + global_vocab_size=global_vocab_size, + ) + if not self._ascend_supported(hidden, lm_head_weight): + return NativeLinearLogpOp().apply(hidden, lm_head_weight, target_ids) + return _FusedLinearLogpAscendFunction.apply(hidden, lm_head_weight, target_ids) + + @staticmethod + def _ascend_supported(hidden: torch.Tensor, lm_head_weight: torch.Tensor) -> bool: + return ( + hidden.device.type == "npu" + and lm_head_weight.device.type == "npu" + and hidden.is_contiguous() + and lm_head_weight.is_contiguous() + and hidden.dtype in _SUPPORTED_DTYPES + and lm_head_weight.dtype == hidden.dtype + ) diff --git a/rl_engine/kernels/ops/ascend/loss/logp.py b/rl_engine/kernels/ops/ascend/loss/logp.py new file mode 100644 index 00000000..08b68736 --- /dev/null +++ b/rl_engine/kernels/ops/ascend/loss/logp.py @@ -0,0 +1,100 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 RL-Kernel Contributors + +from __future__ import annotations + +from typing import Any + +import torch + +from rl_engine.utils.logger import logger + +_C_npu: Any = None +try: + from rl_engine import _C_npu + + _NPU_EXT_AVAILABLE = True +except ImportError: # pragma: no cover - Ascend extension not built + _NPU_EXT_AVAILABLE = False + + +class _FusedLogpAscendAutograd(torch.autograd.Function): + """Autograd bridge for the Ascend fused selected-logprob forward. + + Mirrors the CUDA ``_FusedLogpAutograd``: the VJP is row-local + (``dlogits = grad * (one_hot(target) - softmax)``), computed in FP32 and + cast only the final input VJP back to the input dtype. There is no + cross-token reduction, so Batch/Chunk layout cannot change the result. + """ + + @staticmethod + def forward(ctx, logits: torch.Tensor, token_ids: torch.Tensor): + logits_2d = logits.reshape(-1, logits.size(-1)).contiguous() + labels = token_ids.reshape(-1).to(device=logits.device, dtype=torch.long).contiguous() + output = _C_npu.fused_logp_ascend(logits_2d, labels) + ctx.save_for_backward(logits_2d, labels) + ctx.input_shape = tuple(logits.shape) + ctx.input_dtype = logits.dtype + return output.reshape(logits.shape[:-1]) + + @staticmethod + def backward(ctx, grad_output: torch.Tensor): + logits, labels = ctx.saved_tensors + probs = torch.softmax(logits.float(), dim=-1) + rows = torch.arange(logits.size(0), device=logits.device) + probs[rows, labels] -= 1.0 + grad = -grad_output.reshape(-1, 1).float() * probs + return grad.to(ctx.input_dtype).reshape(ctx.input_shape), None + + +class FusedLogpAscendOp: + """Batch-invariant fused LogP for Ascend NPU. + + The Ascend C forward mirrors the deterministic CUDA kernel's two-pass + (row max, then sum-exp) fp32 reduction with a fixed tile order; the + output is fp32, matching ``DeterministicLogpCUDAOp``'s contract. + """ + + is_fused_logp = True + is_batch_invariant = True + + def __init__(self): + if not _NPU_EXT_AVAILABLE or not hasattr(_C_npu, "fused_logp_ascend"): + raise RuntimeError( + "fused_logp_ascend is not compiled into the extension. Rebuild with " + "KERNEL_ALIGN_FORCE_ASCEND=1 on an Ascend NPU host: 'pip install -e .'" + ) + self.op = _C_npu.fused_logp_ascend + logger.info("Successfully linked to precompiled _C_npu.fused_logp_ascend kernel.") + + def __call__(self, logits: torch.Tensor, token_ids: torch.Tensor) -> torch.Tensor: + return self.apply(logits, token_ids) + + def _ascend_supported(self, logits: torch.Tensor) -> bool: + """NPU tensors only; bf16/fp16/fp32 (mirrors the CUDA kernel's gate).""" + return ( + logits.device.type == "npu" + and logits.is_contiguous() + and logits.dtype in (torch.bfloat16, torch.float16, torch.float32) + ) + + def apply(self, logits: torch.Tensor, token_ids: torch.Tensor) -> torch.Tensor: + # The model feeds non-contiguous slices (e.g. score_logits[:, :-1]); + # materialize them so the batch-invariant Ascend kernel runs instead + # of the native fallback, whose per-row numerics can depend on the + # batch layout (the B1-vs-BN singleton invariance requires the + # kernel path everywhere). + logits = logits.contiguous() + if not self._ascend_supported(logits): + from rl_engine.kernels.ops.pytorch.loss.logp import NativeLogpOp + + return NativeLogpOp()(logits, token_ids) + return _FusedLogpAscendAutograd.apply(logits, token_ids) + + def apply_fp32(self, logits: torch.Tensor, token_ids: torch.Tensor) -> torch.Tensor: + logits = logits.contiguous() + if not self._ascend_supported(logits): + from rl_engine.kernels.ops.pytorch.loss.logp import NativeLogpOp + + return NativeLogpOp().forward_fp32(logits, token_ids) + return _FusedLogpAscendAutograd.apply(logits, token_ids) diff --git a/rl_engine/kernels/ops/ascend/matmul/__init__.py b/rl_engine/kernels/ops/ascend/matmul/__init__.py new file mode 100644 index 00000000..fe0cd3c4 --- /dev/null +++ b/rl_engine/kernels/ops/ascend/matmul/__init__.py @@ -0,0 +1,6 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 RL-Kernel Contributors + +from rl_engine.kernels.ops.ascend.matmul.det_gemm import DetGemmAscendOp + +__all__ = ["DetGemmAscendOp"] diff --git a/rl_engine/kernels/ops/ascend/matmul/det_gemm.py b/rl_engine/kernels/ops/ascend/matmul/det_gemm.py new file mode 100644 index 00000000..e705e380 --- /dev/null +++ b/rl_engine/kernels/ops/ascend/matmul/det_gemm.py @@ -0,0 +1,228 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 RL-Kernel Contributors + +"""Ascend NPU batch-invariant deterministic GEMM (WS1 #146). + +Forward: hand-written Ascend C kernel (`_C_npu.det_gemm_ascend_*`). Every +output row-tile is reduced end-to-end by exactly one AI-core block with a +fixed ascending 32-element leaf order and the CUDA mid-split BF16-add tree, +so per-element numerics are batch-invariant (the same algorithm as the CUDA +`det_gemm_kernel.cu` and the Triton tree reference). + +Backward: reuses the forward kernel on transposed operands, exactly like the +CUDA op: dA = dC @ B^T, dB = A^T @ dC (with the canonical [N,K] layout +variant for native weights). +""" + +from __future__ import annotations + +from typing import Any + +import torch +from torch.autograd import Function +from torch.autograd.function import once_differentiable + +from rl_engine.kernels.ops.backward_runtime import record_backward +from rl_engine.utils.logger import logger + +_C_npu: Any = None +try: + from rl_engine import _C_npu + + _NPU_EXT_AVAILABLE = True +except ImportError: # pragma: no cover - Ascend extension not built + _NPU_EXT_AVAILABLE = False + +_REQUIRED = ( + "det_gemm_ascend_fwd", + "det_gemm_ascend_fwd_rhs_transposed", + "det_gemm_ascend_fwd_fp32", + "det_gemm_ascend_da", + "det_gemm_ascend_db", + "det_gemm_ascend_db_transposed", +) + + +class _DetGemmAscendFn(Function): + @staticmethod + def forward(ctx, a, b, output_fp32=False): + ctx.save_for_backward(a, b) + if output_fp32: + return _C_npu.det_gemm_ascend_fwd_fp32(a, b) + return _C_npu.det_gemm_ascend_fwd(a, b) + + @staticmethod + @once_differentiable + def backward(ctx, grad_out): + # FP32-accumulation rowwise backward (the canonical row-fold VJP). + # The BF16 mid-split tree grads round at every node, which the + # gradient-accuracy judgment compares against unrounded FP32 + # reference grads (a structural 2.0-4.0 residual at near-cancellation + # outputs); the rowwise kernels reduce each output row in one fixed + # per-row order with FP32 accumulation, so the gradients are both + # batch-invariant and ULP-close to the FP32 reference. + a, b = ctx.saved_tensors + grad_fp32 = grad_out.contiguous().float() + da = ( + _rowwise_fp32(grad_fp32, b.float().t().contiguous()).to(a.dtype) + if ctx.needs_input_grad[0] + else None + ) + db = ( + _rowwise_fp32(a.float().t().contiguous(), grad_fp32).to(b.dtype) + if ctx.needs_input_grad[1] + else None + ) + record_backward( + "det_gemm", + kernel_id="rl_engine._C_npu.det_gemm_rowwise_ascend_fwd_fp32", + impl="ascend_rowwise_fp32_accum_det_gemm", + family="ascend", + ) + return da, db, None + + +class _DetLinearAscendFn(Function): + @staticmethod + def forward(ctx, a, weight): + ctx.save_for_backward(a, weight) + return _C_npu.det_gemm_ascend_fwd_rhs_transposed(a, weight) + + @staticmethod + @once_differentiable + def backward(ctx, grad_out): + a, weight = ctx.saved_tensors + grad_out = grad_out.contiguous() + if grad_out.dtype != torch.bfloat16: + grad_out = grad_out.to(torch.bfloat16) + # weight is physical [N,K]: reading it as logical [K'=N, N'=K] yields + # dA = dC @ weight, the same trick the CUDA linear backward uses. + da = _C_npu.det_gemm_ascend_fwd(grad_out, weight) if ctx.needs_input_grad[0] else None + dweight = ( + _C_npu.det_gemm_ascend_db_transposed(a, grad_out) if ctx.needs_input_grad[1] else None + ) + record_backward( + "det_gemm", + kernel_id=( + "rl_engine._C_npu.det_gemm_ascend_fwd+" + "rl_engine._C_npu.det_gemm_ascend_db_transposed" + ), + impl="ascend_det_gemm_linear", + family="ascend", + ) + return da, dweight + + +def _rowwise_fp32(a: torch.Tensor, b: torch.Tensor) -> torch.Tensor: + if not hasattr(_C_npu, "det_gemm_rowwise_ascend_fwd_fp32"): + raise RuntimeError( + "FP32 rowwise deterministic GEMM requires a rebuilt Ascend extension; " + "rebuild with KERNEL_ALIGN_FORCE_ASCEND=1 on an Ascend NPU host" + ) + return _C_npu.det_gemm_rowwise_ascend_fwd_fp32(a.float().contiguous(), b.float().contiguous()) + + +class _DetGemmAscendAccumFn(Function): + @staticmethod + def forward(ctx, a, b): + ctx.save_for_backward(a, b) + return _rowwise_fp32(a, b) + + @staticmethod + @once_differentiable + def backward(ctx, grad_out): + a, b = ctx.saved_tensors + grad_fp32 = grad_out.contiguous().float() + da = ( + _rowwise_fp32(grad_fp32, b.float().t().contiguous()).to(a.dtype) + if ctx.needs_input_grad[0] + else None + ) + db = ( + _rowwise_fp32(a.float().t().contiguous(), grad_fp32).to(b.dtype) + if ctx.needs_input_grad[1] + else None + ) + record_backward( + "det_gemm", + kernel_id="rl_engine._C_npu.det_gemm_rowwise_ascend_fwd_fp32", + impl="ascend_rowwise_fp32_accum_det_gemm", + family="ascend", + ) + return da, db + + +class DetGemmAscendOp: + """Batch-invariant deterministic GEMM on Ascend NPU. + + a:[M,K] bf16, b:[K,N] bf16 -> [M,N] bf16. Strict backend: out-of-domain + inputs are rejected up front and no non-strict fallback exists (the same + refusal contract as the CUDA DetGemmOp). + """ + + def __init__(self) -> None: + if not _NPU_EXT_AVAILABLE or _C_npu is None: + raise RuntimeError( + "strict RL-Kernel Ascend GEMM requires the compiled _C_npu " + "extension; rebuild with KERNEL_ALIGN_FORCE_ASCEND=1 on an " + "Ascend NPU host: 'pip install -e .'" + ) + missing = [name for name in _REQUIRED if not hasattr(_C_npu, name)] + if missing: + raise RuntimeError(f"missing {', '.join(missing)} in _C_npu; rebuild the extension") + self.has_hardware_op = True + logger.info("Successfully linked to precompiled _C_npu.det_gemm_ascend kernels.") + + def __call__(self, a: torch.Tensor, b: torch.Tensor) -> torch.Tensor: + assert a.dtype == torch.bfloat16 and b.dtype == torch.bfloat16, "BF16 only" + assert a.device.type == "npu" and b.device.type == "npu", "Inputs must be on NPU" + return _DetGemmAscendFn.apply(a.contiguous(), b.contiguous(), False) + + def forward_fp32(self, a: torch.Tensor, b: torch.Tensor) -> torch.Tensor: + assert a.dtype == torch.bfloat16 and b.dtype == torch.bfloat16, "BF16 only" + assert a.device.type == "npu" and b.device.type == "npu", "Inputs must be on NPU" + return _DetGemmAscendFn.apply(a.contiguous(), b.contiguous(), True) + + def forward_accum_fp32(self, a: torch.Tensor, b: torch.Tensor) -> torch.Tensor: + """FP32-accumulation rowwise GEMM, the twin of the CUDA op's entry. + + The canonical row-fold VJP drives its matmuls through this path. It + does not round intermediate nodes to BF16, so gradients keep the FP32 + accumulation the contract requires; determinism comes from the fixed + per-row reduction order of the underlying kernel rather than from the + BF16 mid-split tree used by the BF16 forward. + """ + if a.dtype not in (torch.bfloat16, torch.float32) or b.dtype not in ( + torch.bfloat16, + torch.float32, + ): + raise TypeError("FP32-accumulation GEMM requires BF16 or FP32 inputs") + assert a.device.type == "npu" and b.device.type == "npu", "Inputs must be on NPU" + return _DetGemmAscendAccumFn.apply(a.contiguous(), b.contiguous()) + + def linear(self, a: torch.Tensor, weight: torch.Tensor) -> torch.Tensor: + """Apply a native [N,K] linear weight without materializing weight.T.""" + assert a.dtype == torch.bfloat16 and weight.dtype == torch.bfloat16, "BF16 only" + assert a.device.type == "npu" and weight.device.type == "npu", "Inputs must be on NPU" + return _DetLinearAscendFn.apply(a.contiguous(), weight.contiguous()) + + def parameter_vjp_contributions_fp32( + self, *, a: torch.Tensor, b: torch.Tensor, grad_output: torch.Tensor + ) -> dict[str, torch.Tensor]: + """Canonical row-fold parameter contribution (the CUDA twin). + + dW[k,n] = sum_tokens a[t,k] * dC[t,n]: each token's FP32 outer + product is returned per row, and the C4 harness accumulates the + per-row contributions in FP32 across call spans, so chunked / + padded / permuted layouts sum the same row contributions in the + same order and produce a bitwise-identical weight gradient. + """ + del b + rows_a = a.float() + rows_g = grad_output.float() + return {"b": rows_a[:, :, None] * rows_g[:, None, :]} + + +def deterministic_gemm_ascend(a: torch.Tensor, b: torch.Tensor) -> torch.Tensor: + """Functional entry. a:[M,K] bf16, b:[K,N] bf16 -> [M,N] bf16.""" + return _DetGemmAscendFn.apply(a, b, False) diff --git a/rl_engine/kernels/ops/ascend/norm/__init__.py b/rl_engine/kernels/ops/ascend/norm/__init__.py new file mode 100644 index 00000000..86cf4c9d --- /dev/null +++ b/rl_engine/kernels/ops/ascend/norm/__init__.py @@ -0,0 +1,2 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 RL-Kernel Contributors diff --git a/rl_engine/kernels/ops/ascend/norm/rmsnorm.py b/rl_engine/kernels/ops/ascend/norm/rmsnorm.py new file mode 100644 index 00000000..4299749f --- /dev/null +++ b/rl_engine/kernels/ops/ascend/norm/rmsnorm.py @@ -0,0 +1,222 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 RL-Kernel Contributors + +from __future__ import annotations + +from typing import Any + +import torch + +from rl_engine.utils.logger import logger + +_C_npu: Any = None +try: + from rl_engine import _C_npu + + _NPU_EXT_AVAILABLE = True +except ImportError: # pragma: no cover - Ascend extension not built + _NPU_EXT_AVAILABLE = False + + +def _ascend_supported(x: torch.Tensor) -> bool: + """Whether the Ascend C forward can run this input directly. + + NPU tensors only, fp32/bf16/fp16 only (mirrors the CUDA kernel's gate). + """ + return x.device.type == "npu" and x.dtype in ( + torch.float32, + torch.bfloat16, + torch.float16, + ) + + +def _fallback_op(): + """Portable op for inputs the Ascend forward cannot take. + + Triton rejects non-CUDA devices, so on NPU the only fallback is native. + """ + from rl_engine.kernels.ops.pytorch.norm.rms_norm import NativeRMSNormOp + + return NativeRMSNormOp() + + +def _fixed_row_sum(values: torch.Tensor) -> torch.Tensor: + """Sum the last dimension with an explicit adjacent-pair FP32 tree. + + A fixed reduction width passed to torch.sum is insufficient on NPU: + dispatch can also depend on the number of rows. Each step here is an + elementwise add; the pairs depend only on the hidden dimension. Carry + an odd final element unchanged rather than dropping or duplicating it. + """ + if values.ndim == 0 or values.shape[-1] == 0: + raise ValueError("row reduction requires a non-empty last dimension") + partial = values.float() + while partial.shape[-1] > 1: + paired = (partial.shape[-1] // 2) * 2 + reduced = partial[..., :paired:2] + partial[..., 1:paired:2] + if paired != partial.shape[-1]: + reduced = torch.cat((reduced, partial[..., -1:]), dim=-1) + partial = reduced + return partial[..., 0] + + +def _rms_norm_backward_rows( + x_2d: torch.Tensor, + weight: torch.Tensor, + rstd: torch.Tensor, + grad_out_2d: torch.Tensor, +) -> tuple[torch.Tensor, torch.Tensor]: + """RMSNorm dx and unreduced FP32 dweight rows using forward-saved rstd. + + With y = x * rstd * w and s = sum(dy * w * x, dim=-1): + dx = rstd * (dy * w) - x * rstd^3 * s / H + dweight_rows = dy * x * rstd + + Both ordinary and canonical backward use this row-local computation. + Parameter gradients are reduced by the caller, after all logical rows + are available in the canonical case. + """ + dy_f = grad_out_2d.float() + x_f = x_2d.float() + w_f = weight.float() + rstd_f = rstd.float() + + dyw = dy_f * w_f + s = _fixed_row_sum(dyw * x_f) + hidden = x_2d.size(-1) + dx = rstd_f.unsqueeze(-1) * dyw - x_f * (rstd_f.pow(3) / hidden).unsqueeze(-1) * s.unsqueeze(-1) + rows = dy_f * x_f * rstd_f.unsqueeze(-1) + return dx.to(x_2d.dtype), rows + + +def _rms_norm_backward( + x_2d: torch.Tensor, + weight: torch.Tensor, + rstd: torch.Tensor, + grad_out_2d: torch.Tensor, +) -> tuple[torch.Tensor, torch.Tensor]: + from rl_engine.kernels.ops.vjp_fp32 import reduce_rows_fp32 + + dx, rows = _rms_norm_backward_rows(x_2d, weight, rstd, grad_out_2d) + return dx, reduce_rows_fp32(rows).to(weight.dtype) + + +def _fixed_rstd(x32: torch.Tensor, eps: float) -> torch.Tensor: + """Shape-invariant per-row rstd. + + torch mean/sum select shape-dependent reduction kernels on NPU and flip + single-ULP results between batch layouts (e.g. [1,7,H] vs [1,20,H]), + which breaks the chunked-vs-full model invariance. The rowwise FP32 + GEMM reduces each output row in one fixed per-row order regardless of + the batch layout, so the sum of squares -- and hence the rstd -- is + bitwise identical for every layout. + """ + from rl_engine.kernels.ops.pytorch.norm.rms_norm import shape_invariant_rstd + + return shape_invariant_rstd(x32, float(eps)).contiguous() + +class _RMSNormAscendFunction(torch.autograd.Function): + # Autograd wrapper: reference-formula rstd + Ascend C fused scale/cast + # forward, and the PyTorch-formula backward reusing the forward-saved + # rstd (same fp32 VJP as the PyTorch reference, like the CUDA op). + + @staticmethod + def forward(ctx, x, weight, eps): + lead_shape = x.shape[:-1] + hidden = x.size(-1) + + x_2d = x.reshape(-1, hidden).contiguous() + + # rstd is computed with the exact torch ops of the PyTorch reference + # (rl_engine/kernels/ops/pytorch/norm/rms_norm.py): fp32 mean of + # squares + torch.rsqrt. The Ascend C kernel then only performs the + # elementwise y = x * rstd * w scale and the round-to-nearest-even + # cast, which are order-free IEEE ops — this makes the fused output + # bitwise identical to NativeRMSNormOp instead of approximating its + # sum-of-squares/rsqrt arithmetic in-kernel. + x_f = x_2d.float() + rstd = _fixed_rstd(x_f, float(eps)) + + y = _C_npu.rmsnorm_ascend(x_2d, weight, rstd) + + ctx.save_for_backward(x_2d, weight, rstd) + ctx.eps = eps + ctx.lead_shape = lead_shape + return y.reshape(lead_shape + (hidden,)) + + @staticmethod + def backward(ctx, grad_output): + x_2d, weight, rstd = ctx.saved_tensors + hidden = x_2d.size(-1) + + grad_out_2d = grad_output.reshape(-1, hidden).contiguous() + dx, dw = _rms_norm_backward(x_2d, weight, rstd, grad_out_2d) + + dx = dx.reshape(ctx.lead_shape + (hidden,)) + return dx, dw, None + + +class RMSNormAscendOp: + # Ascend C batch-invariant RMSNorm (forward kernel). + + def __init__(self) -> None: + if not _NPU_EXT_AVAILABLE or not hasattr(_C_npu, "rmsnorm_ascend"): + raise RuntimeError( + "rmsnorm_ascend is not compiled into the extension. Rebuild with " + "KERNEL_ALIGN_FORCE_ASCEND=1 on an Ascend NPU host: 'pip install -e .'" + ) + logger.info("Successfully linked to precompiled _C_npu.rmsnorm_ascend kernel.") + + def __call__( + self, + x: torch.Tensor, + weight: torch.Tensor, + *, + eps: float = 1e-6, + ) -> torch.Tensor: + return self.forward(x, weight, eps=eps) + + def forward( + self, + x: torch.Tensor, + weight: torch.Tensor, + *, + eps: float = 1e-6, + ) -> torch.Tensor: + if weight.dim() != 1 or weight.shape[0] != x.shape[-1]: + raise ValueError( + f"weight must be 1-D of size x.shape[-1]={x.shape[-1]}, " + f"got tuple(weight.shape)={tuple(weight.shape)}" + ) + + if not _ascend_supported(x) or weight.dtype != x.dtype: + return _fallback_op()(x, weight, eps=eps) + + return _RMSNormAscendFunction.apply(x, weight, eps) + + def parameter_vjp_contributions_fp32( + self, *, x: torch.Tensor, weight: torch.Tensor, grad_output: torch.Tensor, eps: float = 1e-6 + ) -> dict[str, torch.Tensor]: + """Canonical row-fold parameter contribution (the CUDA twin). + + dweight = sum_rows grad * x * rstd: each row's FP32 contribution is + returned separately, and the C4 harness accumulates the per-row + contributions in FP32 across call spans, so chunked / padded / + permuted / singleton-aggregated layouts sum the same row + contributions in the same order and produce a bitwise-identical + weight gradient. + """ + del weight + x32 = x.float() + rstd = _fixed_rstd(x32, float(eps)) + rows = grad_output.float() * x32 * rstd.unsqueeze(-1) + return {"weight": rows} + + +def rmsnorm_ascend( + x: torch.Tensor, + weight: torch.Tensor, + *, + eps: float = 1e-6, +) -> torch.Tensor: + return RMSNormAscendOp()(x, weight, eps=eps) diff --git a/rl_engine/kernels/ops/ascend/rotary_embedding/__init__.py b/rl_engine/kernels/ops/ascend/rotary_embedding/__init__.py new file mode 100644 index 00000000..1c0317a2 --- /dev/null +++ b/rl_engine/kernels/ops/ascend/rotary_embedding/__init__.py @@ -0,0 +1,4 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 RL-Kernel Contributors + +from .rope import RoPEAscendOp diff --git a/rl_engine/kernels/ops/ascend/rotary_embedding/rope.py b/rl_engine/kernels/ops/ascend/rotary_embedding/rope.py new file mode 100644 index 00000000..89b93111 --- /dev/null +++ b/rl_engine/kernels/ops/ascend/rotary_embedding/rope.py @@ -0,0 +1,179 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 RL-Kernel Contributors +"""Ascend C RoPE backend (GPT-NeoX/Hugging Face rotate-half convention).""" + +from __future__ import annotations + +from typing import Any + +import torch +from torch import Tensor + +from rl_engine.utils.logger import logger + +_C_npu: Any = None +try: + from rl_engine import _C_npu + + _NPU_EXT_AVAILABLE = True +except ImportError: # pragma: no cover - Ascend extension not built + _NPU_EXT_AVAILABLE = False + + +def _build_cos_sin( + positions: Tensor, + half: int, + theta: float, + device: torch.device, +) -> tuple[Tensor, Tensor]: + """Build fp32 [table_rows, half] caches with the reference RoPE formula.""" + inv_freq = 1.0 / ( + theta ** (torch.arange(0, half, dtype=torch.float32, device=device) / half) + ) + freqs = positions.to(device=device, dtype=torch.float32).reshape(-1, 1) * inv_freq + return freqs.cos().contiguous(), freqs.sin().contiguous() + + +def _rope_table( + x: Tensor, positions: Tensor, theta: float +) -> tuple[Tensor, Tensor, Tensor]: + """Flatten x so row modulo table length selects the correct position cache.""" + if x.dim() < 2: + raise ValueError( + f"x must have at least 2 dimensions, got shape {tuple(x.shape)}" + ) + dim = x.shape[-1] + if dim <= 0 or dim % 2 != 0: + raise ValueError(f"RoPE head_dim must be a positive even number, got {dim}") + + if positions.dim() == 1: + table_len = int(positions.shape[0]) + x_2d = x.contiguous().reshape(-1, dim) + if table_len == 0: + if x_2d.shape[0] != 0: + raise ValueError("positions cannot be empty when x contains rows") + elif x_2d.shape[0] % table_len != 0: + raise ValueError( + f"row count {x_2d.shape[0]} not divisible by seq length {table_len}; " + "expected a [..., S, D] contiguous layout." + ) + cos, sin = _build_cos_sin(positions, dim // 2, float(theta), x.device) + return x_2d, cos, sin + + if positions.dim() != 2: + raise ValueError( + f"positions must be [S] or [B, S], got shape {tuple(positions.shape)}" + ) + batch, seq = positions.shape + if x.shape[0] != batch or x.shape[-2] != seq: + raise ValueError( + f"positions {tuple(positions.shape)} is incompatible with x {tuple(x.shape)}; " + "expected x [B, ..., S, D]" + ) + if x.dim() == 4: + x_2d = x.permute(1, 0, 2, 3).contiguous().reshape(-1, dim) + elif x.dim() == 3: + x_2d = x.contiguous().reshape(-1, dim) + else: + raise ValueError( + f"RoPE [B, S] positions require x [B, S, D] or [B, H, S, D], got {x.dim()}D" + ) + + table_len = batch * seq + if table_len == 0: + if x_2d.shape[0] != 0: + raise ValueError("positions cannot be empty when x contains rows") + elif x_2d.shape[0] % table_len != 0: + raise ValueError(f"row count {x_2d.shape[0]} not divisible by B*S={table_len}") + cos, sin = _build_cos_sin(positions, dim // 2, float(theta), x.device) + return x_2d, cos, sin + + +def _restore_rope(out_2d: Tensor, x: Tensor, positions: Tensor) -> Tensor: + if positions.dim() == 1 or x.dim() != 4: + return out_2d.reshape(x.shape) + heads, batch, seq, dim = x.shape[1], x.shape[0], x.shape[2], x.shape[3] + return out_2d.reshape(heads, batch, seq, dim).permute(1, 0, 2, 3).contiguous() + + +class _RoPEAscendFunction(torch.autograd.Function): + @staticmethod + def forward(ctx, x: Tensor, positions: Tensor, theta: float) -> Tensor: + x_2d, cos, sin = _rope_table(x, positions, theta) + ctx.save_for_backward(cos, sin) + ctx.x_shape = tuple(x.shape) + ctx.pos_dim = positions.dim() + out_2d = _C_npu.rope_apply_ascend(x_2d, cos, sin, 1.0) + return _restore_rope(out_2d, x, positions) + + @staticmethod + def backward(ctx, grad_out: Tensor): + cos, sin = ctx.saved_tensors + grad_x = None + if ctx.needs_input_grad[0]: + if ctx.pos_dim == 2 and len(ctx.x_shape) == 4: + grad_2d = ( + grad_out.permute(1, 0, 2, 3) + .contiguous() + .reshape(-1, ctx.x_shape[-1]) + ) + out_2d = _C_npu.rope_apply_ascend(grad_2d, cos, sin, -1.0) + heads, batch, seq, dim = ( + ctx.x_shape[1], + ctx.x_shape[0], + ctx.x_shape[2], + ctx.x_shape[3], + ) + grad_x = ( + out_2d.reshape(heads, batch, seq, dim) + .permute(1, 0, 2, 3) + .contiguous() + ) + else: + grad_2d = grad_out.contiguous().reshape(-1, grad_out.shape[-1]) + grad_x = _C_npu.rope_apply_ascend(grad_2d, cos, sin, -1.0).reshape( + grad_out.shape + ) + return grad_x, None, None + + +class RoPEAscendOp: + """Differentiable Ascend C RoPE backend for fp16, bf16, and fp32 inputs.""" + + op_class = "elementwise" + + def __init__(self) -> None: + if not _NPU_EXT_AVAILABLE or not hasattr(_C_npu, "rope_apply_ascend"): + raise RuntimeError( + "rope_apply_ascend is not compiled into _C_npu. Rebuild on an Ascend host with " + "'KERNEL_ALIGN_FORCE_ASCEND=1 pip install --no-build-isolation -e .'." + ) + logger.info( + "Successfully linked to precompiled _C_npu.rope_apply_ascend kernel." + ) + + def __call__( + self, + x: Tensor, + positions: Tensor, + *, + theta: float = 1_000_000.0, + ) -> Tensor: + return self.forward(x, positions, theta=theta) + + def forward( + self, + x: Tensor, + positions: Tensor, + *, + theta: float = 1_000_000.0, + ) -> Tensor: + if x.device.type != "npu": + raise RuntimeError( + f"RoPEAscendOp requires an NPU tensor, got device '{x.device}'." + ) + if x.dtype not in (torch.float16, torch.bfloat16, torch.float32): + raise TypeError( + f"RoPEAscendOp supports fp16, bf16, and fp32, got {x.dtype}." + ) + return _RoPEAscendFunction.apply(x, positions, float(theta)) diff --git a/rl_engine/kernels/ops/canonical_embedding.py b/rl_engine/kernels/ops/canonical_embedding.py index c2d268d0..0caed792 100644 --- a/rl_engine/kernels/ops/canonical_embedding.py +++ b/rl_engine/kernels/ops/canonical_embedding.py @@ -16,6 +16,11 @@ def _canonical_embedding_family(family: str) -> str: requested = str(family) normalized = "cuda" if requested.startswith("cuda") else requested + if normalized == "ascend": + # The Ascend embedding's deterministic grad-weight reuses the CUDA + # construction bit-for-bit (see ascend/linear/embedding.py), so the + # canonical family normalizes to the same implementation. + return "cuda" if normalized not in {"cuda", "pytorch", "triton"}: raise RuntimeError(f"unsupported canonical embedding backend family: {requested!r}") return normalized diff --git a/rl_engine/kernels/ops/canonical_linear.py b/rl_engine/kernels/ops/canonical_linear.py index ca6889b1..772b84d0 100644 --- a/rl_engine/kernels/ops/canonical_linear.py +++ b/rl_engine/kernels/ops/canonical_linear.py @@ -18,6 +18,14 @@ def _gemm_fp32(a: torch.Tensor, b: torch.Tensor, family: str) -> torch.Tensor: return _C.det_gemm_rowwise_fwd_fp32(a.contiguous(), b.contiguous()) if family == "triton": return _triton_gemm(a, b, output_dtype=torch.float32) + if family == "ascend": + from rl_engine.kernels.ops.ascend.matmul.det_gemm import _rowwise_fp32 + + return _rowwise_fp32(a, b) + if family == "pytorch": + # LOCAL-DEVIATION: the gold topology resolves family='pytorch' + # on the bf16-reference path only. + return a @ b raise ValueError(f"unsupported canonical linear family {family!r}") @@ -58,6 +66,13 @@ def reducer(rows, grads): impl="triton_det_gemm_canonical_rowfold", family="triton", ) + elif ctx.family == "ascend": + record_backward( + "det_gemm", + kernel_id="rl_engine._C_npu.det_gemm_rowwise_ascend_fwd_fp32", + impl="ascend_det_gemm_canonical_rowfold", + family="ascend", + ) return da, dweight, None, None, None diff --git a/rl_engine/kernels/ops/canonical_lm_head.py b/rl_engine/kernels/ops/canonical_lm_head.py index c56d6d0a..4574905c 100644 --- a/rl_engine/kernels/ops/canonical_lm_head.py +++ b/rl_engine/kernels/ops/canonical_lm_head.py @@ -61,7 +61,7 @@ def canonical_cuda_lm_head_fp32( class _CanonicalRowLMHead(torch.autograd.Function): @staticmethod - def forward(ctx, hidden, weight, logical_keys, parameter_id, forward_op, matmul_op): + def forward(ctx, hidden, weight, logical_keys, parameter_id, forward_op, matmul_op, provenance): session = active_session() if session is None: raise RuntimeError("canonical LM-head requires an active backward session") @@ -72,6 +72,7 @@ def forward(ctx, hidden, weight, logical_keys, parameter_id, forward_op, matmul_ ctx.parameter_id = str(parameter_id) ctx.slot = session.register(ctx.parameter_id, logical_keys) ctx.matmul_op = matmul_op + ctx.provenance = provenance return output @staticmethod @@ -92,13 +93,24 @@ def reducer(rows, grads): grad_weight = ctx.session.submit_linear( ctx.parameter_id, ctx.slot, hidden_rows, grad_rows, reducer ) - record_backward( - "lm_head", - kernel_id="rl_engine.kernels.ops.triton.matmul.det_gemm._triton_gemm", - impl="triton_lm_head_canonical_rowfold", - family="triton", - ) - return grad_hidden, grad_weight, None, None, None, None + record_backward("lm_head", **ctx.provenance) + return grad_hidden, grad_weight, None, None, None, None, None + + +# Row-fold backward provenance per backend family. The fold itself is +# backend-agnostic; only the kernels it drives differ. +_ROW_LM_HEAD_PROVENANCE = { + "triton": { + "kernel_id": "rl_engine.kernels.ops.triton.matmul.det_gemm._triton_gemm", + "impl": "triton_lm_head_canonical_rowfold", + "family": "triton", + }, + "ascend": { + "kernel_id": "csrc/ascend/gemm/det_gemm_ascend.asc:det_gemm_ascend_fwd_fp32", + "impl": "ascend_lm_head_canonical_rowfold", + "family": "ascend", + }, +} def canonical_row_lm_head( @@ -109,7 +121,12 @@ def canonical_row_lm_head( forward_op, matmul_op, parameter_id: str = "lm_head", + family: str = "triton", ) -> torch.Tensor: + try: + provenance = _ROW_LM_HEAD_PROVENANCE[family] + except KeyError: + raise ValueError(f"no row-fold LM-head provenance for family {family!r}") from None return _CanonicalRowLMHead.apply( - hidden, weight, logical_keys, parameter_id, forward_op, matmul_op + hidden, weight, logical_keys, parameter_id, forward_op, matmul_op, provenance ) diff --git a/rl_engine/kernels/ops/canonical_rmsnorm.py b/rl_engine/kernels/ops/canonical_rmsnorm.py index 5a11a96d..614d880a 100644 --- a/rl_engine/kernels/ops/canonical_rmsnorm.py +++ b/rl_engine/kernels/ops/canonical_rmsnorm.py @@ -58,7 +58,76 @@ def canonical_cuda_rmsnorm( return _CanonicalCudaRMSNorm.apply(x, weight, eps, logical_keys, parameter_id) -__all__ = ["canonical_cuda_rmsnorm"] +__all__ = ["canonical_cuda_rmsnorm", "canonical_ascend_rmsnorm", "canonical_row_rmsnorm"] + + +class _CanonicalAscendRMSNorm(torch.autograd.Function): + """Ascend twin of _CanonicalCudaRMSNorm. + + Forward reuses the Ascend op's split: the reference FP32 rstd, then the + Ascend C kernel for the elementwise scale/cast. Backward folds the weight + gradient over logical rows through the session, so dw depends only on the + logical row identity and not on how rows were batched. + """ + + @staticmethod + def forward(ctx, x, weight, eps, logical_keys, parameter_id): + from rl_engine.kernels.ops.ascend.norm.rmsnorm import _C_npu + + session = active_session() + if session is None: + raise RuntimeError("canonical RMSNorm requires an active backward session") + x_c = x.contiguous() + weight_c = weight.contiguous() + from rl_engine.kernels.ops.ascend.norm.rmsnorm import _fixed_rstd + + rstd = _fixed_rstd(x_c.float(), float(eps)) + y = _C_npu.rmsnorm_ascend(x_c, weight_c, rstd) + ctx.save_for_backward(x_c, weight_c, rstd) + ctx.session = session + ctx.parameter_id = str(parameter_id) + ctx.slot = session.register(ctx.parameter_id, logical_keys) + return y + + @staticmethod + def backward(ctx, grad_out): + from rl_engine.kernels.ops.ascend.norm.rmsnorm import _rms_norm_backward_rows + + x, weight, rstd = ctx.saved_tensors + dy = grad_out.contiguous() + # Forward rstd alone is not sufficient: the dx dot product must also + # have a row-count-independent reduction before gradients reach + # earlier layers' canonical parameter contributions. + dx, rows = _rms_norm_backward_rows(x, weight, rstd, dy) + dw = None + if ctx.needs_input_grad[1]: + dw = ctx.session.submit_rows( + ctx.parameter_id, + ctx.slot, + rows, + lambda ordered: reduce_rows_fp32(ordered).to(weight.dtype), + ) + record_backward( + "rms_norm", + kernel_id=( + "csrc/ascend/rmsnorm_ascend.asc:rmsnorm_ascend_forward" + "+rl_engine.kernels.ops.vjp_fp32.reduce_rows_fp32" + ), + impl="ascend_rmsnorm_canonical_rowfold", + family="ascend", + ) + return dx, dw, None, None, None + + +def canonical_ascend_rmsnorm( + x: torch.Tensor, + weight: torch.Tensor, + *, + eps: float, + logical_keys: torch.Tensor, + parameter_id: str, +) -> torch.Tensor: + return _CanonicalAscendRMSNorm.apply(x, weight, eps, logical_keys, parameter_id) class _CanonicalRowRMSNorm(torch.autograd.Function): diff --git a/rl_engine/kernels/ops/pytorch/matmul/det_gemm.py b/rl_engine/kernels/ops/pytorch/matmul/det_gemm.py index 9c617aa6..3a101d78 100644 --- a/rl_engine/kernels/ops/pytorch/matmul/det_gemm.py +++ b/rl_engine/kernels/ops/pytorch/matmul/det_gemm.py @@ -23,5 +23,44 @@ def __call__(self, a, b): return torch.matmul(a, b) +_K_TREE_LEAF = 32 + + +class DetGemmTreeReferenceOp: + """Deterministic leaf-space mid-split tree, the canonical gold. + + The WS1 deterministic GEMM rounds every 32-element leaf and every tree + merge node to BF16, so comparing a candidate against the single-rounding + torch.matmul reference fails structurally at near-cancellation outputs. + This op evaluates the exact contract tree (32-element leaves summed in + FP32, BF16 RNE at every leaf and every mid-split merge, splitting in + LEAF space) and is the accuracy gold for the deterministic GEMM across + all backend profiles. Differentiable, so the gradient-accuracy judgment + also compares against the tree's own VJP. + """ + + def __init__(self): + logger.info("DetGemmTreeReferenceOp ready (leaf-space mid-split tree gold).") + + def __call__(self, a, b): + a = a.contiguous() + b = b.contiguous() + k = a.size(1) + num_leaves = (k + _K_TREE_LEAF - 1) // _K_TREE_LEAF + + def reduce_range(lo: int, hi: int) -> torch.Tensor: + # [lo, hi) is a range of LEAF indices. + if hi - lo == 1: + start = lo * _K_TREE_LEAF + end = min(start + _K_TREE_LEAF, k) + return (a[:, start:end].float() @ b[start:end, :].float()).to( + torch.bfloat16 + ) + midpoint = lo + (hi - lo) // 2 + return reduce_range(lo, midpoint) + reduce_range(midpoint, hi) + + return reduce_range(0, num_leaves) + + def native_gemm(a, b): return torch.matmul(a, b) diff --git a/rl_engine/kernels/ops/pytorch/norm/rms_norm.py b/rl_engine/kernels/ops/pytorch/norm/rms_norm.py index b891a7cf..6c3bca81 100644 --- a/rl_engine/kernels/ops/pytorch/norm/rms_norm.py +++ b/rl_engine/kernels/ops/pytorch/norm/rms_norm.py @@ -76,6 +76,28 @@ def strict_add_rms_norm( return _strict_add_rms_norm(x, residual, weight, eps) + + +def shape_invariant_rstd(x_f: torch.Tensor, eps: float) -> torch.Tensor: + """Shape-invariant per-row rstd (the shared RMSNorm statistic). + + torch mean/sum select shape-dependent reduction kernels on NPU and flip + single-ULP results between batch layouts (e.g. [1,7,H] vs [1,20,H]), + which breaks the chunked-vs-full model invariance. This reduction sums + in FIXED 32-wide chunks first, so the intermediate shapes -- and hence + the reduction kernels -- never depend on the batch layout, and the + result is bitwise identical for every layout on every device. + """ + hidden = x_f.shape[-1] + if hidden % 32 != 0: + var = x_f.pow(2).mean(dim=-1) + return torch.rsqrt(var + float(eps)) + sq = x_f.pow(2).reshape(*x_f.shape[:-1], -1, 32) + partial = sq.sum(dim=-1) # [*, C] — fixed 32-wide chunks + sumsq = partial.sum(dim=-1) # [*lead] + var = sumsq / float(hidden) + return torch.rsqrt(var + float(eps)) + class NativeRMSNormOp: """ Pure Pytorch native RMSNorm reference @@ -134,7 +156,7 @@ def _rms_norm( f"got tuple(weight.shape)={tuple(weight.shape)}" ) x_f = x.float() - var = x_f.pow(2).mean(dim=-1, keepdim=True) - normed = x_f * torch.rsqrt(var + eps) + rstd = shape_invariant_rstd(x_f, float(eps)).unsqueeze(-1) + normed = x_f * rstd out = normed * weight.float() return out.to(output_dtype) diff --git a/rl_engine/kernels/registry.py b/rl_engine/kernels/registry.py index e07879d7..9eeb1c42 100644 --- a/rl_engine/kernels/registry.py +++ b/rl_engine/kernels/registry.py @@ -109,6 +109,7 @@ class OpBackend(Enum, metaclass=_KernelEnumMeta): # Batch-invariant deterministic GEMM (WS1 #146) CUDA_DET_GEMM = "rl_engine.kernels.ops.cuda.matmul.det_gemm.DetGemmOp" TRITON_DET_GEMM = "rl_engine.kernels.ops.triton.matmul.det_gemm.TritonDetGemmOp" + ASCEND_DET_GEMM = "rl_engine.kernels.ops.ascend.matmul.det_gemm.DetGemmAscendOp" # NON-deterministic reference (torch.matmul); reference/benchmark ONLY, # intentionally excluded from det_gemm dispatch (cuBLAS breaks invariance). PYTORCH_GEMM = "rl_engine.kernels.ops.pytorch.matmul.det_gemm.NativeGemmOp" @@ -125,6 +126,13 @@ class OpBackend(Enum, metaclass=_KernelEnumMeta): ASCEND_BATCH_INVARIANT_LOGP = ( "rl_engine.kernels.ops.ascend.loss.batch_invariant_logp.BatchInvariantLogpAscendOp" ) + ASCEND_RMS_NORM = "rl_engine.kernels.ops.ascend.norm.rmsnorm.RMSNormAscendOp" + ASCEND_EMBEDDING = "rl_engine.kernels.ops.ascend.linear.embedding.AscendEmbeddingOp" + ASCEND_FUSED_LOGP = "rl_engine.kernels.ops.ascend.loss.logp.FusedLogpAscendOp" + ASCEND_LM_HEAD = "rl_engine.kernels.ops.ascend.linear.lm_head.AscendLMHeadOp" + ASCEND_FUSED_LINEAR_LOGP = ( + "rl_engine.kernels.ops.ascend.loss.linear_logp.FusedLinearLogpAscendOp" + ) # Deterministic vocab-parallel TP logprob reference (WS2 #241 PR3) PYTORCH_VOCAB_PARALLEL_LOGP = ( "rl_engine.kernels.ops.pytorch.loss.vocab_parallel_logp.VocabParallelLogprobOp" @@ -139,6 +147,11 @@ class OpBackend(Enum, metaclass=_KernelEnumMeta): PYTORCH_DISTRIBUTED_GRPO_LOSS = ( "rl_engine.kernels.ops.pytorch.loss.distributed_grpo_loss.DistributedGRPOLossOp" ) + # Ascend NPU deterministic (batch-invariant, no split-K) standard-softmax + # attention (issue #147); Ascend C forward + reference backward. + ASCEND_DETERMINISTIC_ATTENTION = ( + "rl_engine.kernels.ops.ascend.attention.deterministic_attn.DeterministicAttentionAscendOp" + ) # RMSNorm(pre-norm / QK-Norm) - pure Pytorch reference(ws1 ground-truth) TRITON_RMS_NORM = "rl_engine.kernels.ops.triton.rmsnorm_triton.RMSNormTritonOp" @@ -153,10 +166,13 @@ class OpBackend(Enum, metaclass=_KernelEnumMeta): PYTORCH_NATIVE_ROPE = "rl_engine.kernels.ops.pytorch.rotary_embedding.rope.NativeRoPEOp" TRITON_ROPE = "rl_engine.kernels.ops.triton.rotary_embedding.rope.TritonRoPEOp" CUDA_ROPE_SM90 = "rl_engine.kernels.ops.cuda.rotary_embedding.rope.RoPESM90Op" + ASCEND_ROPE = "rl_engine.kernels.ops.ascend.rotary_embedding.rope.RoPEAscendOp" PYTORCH_NATIVE_SILU = "rl_engine.kernels.ops.pytorch.activation.swiglu.NativeSiLUOp" PYTORCH_NATIVE_SWIGLU = "rl_engine.kernels.ops.pytorch.activation.swiglu.NativeSwiGLUOp" CUDA_SILU = "rl_engine.kernels.ops.cuda.activation.swiglu.SiLUCudaOp" CUDA_SWIGLU = "rl_engine.kernels.ops.cuda.activation.swiglu.SwiGLUCudaOp" + ASCEND_SWIGLU = "rl_engine.kernels.ops.ascend.activation.swiglu.SwiGLUAscendOp" + ASCEND_SILU = "rl_engine.kernels.ops.ascend.activation.silu.SiLUAscendOp" TRITON_SILU = "rl_engine.kernels.ops.triton.activation.swiglu.TritonSiLUOp" TRITON_SWIGLU = "rl_engine.kernels.ops.triton.activation.swiglu.TritonSwiGLUOp" @@ -703,9 +719,22 @@ def __init__(self): "silu": [OpBackend.PYTORCH_NATIVE_SILU], "swiglu": [OpBackend.PYTORCH_NATIVE_SWIGLU], }, + # Ascend NPU: op types without an entry fall back to their CPU + # candidates (see the runtime override below), so only + # Ascend-accelerated ops are listed. + "npu": { + "batch_invariant_logp": [ + OpBackend.ASCEND_BATCH_INVARIANT_LOGP, + OpBackend.PYTORCH_BATCH_INVARIANT_LOGP, + ], + "attention": [ + OpBackend.ASCEND_DETERMINISTIC_ATTENTION, + OpBackend.PYTORCH_NATIVE_ATTENTION, + ], + }, } # Preserve the former CPU fallback behavior for every operator on NPU, - # then override only the operator with an Ascend-specific backend. + # then override only the operators with an Ascend-specific backend. self._priority_map["npu"] = { op_type: candidates.copy() for op_type, candidates in self._priority_map["cpu"].items() } @@ -713,6 +742,45 @@ def __init__(self): OpBackend.ASCEND_BATCH_INVARIANT_LOGP, OpBackend.PYTORCH_BATCH_INVARIANT_LOGP, ] + self._priority_map["npu"]["rope"] = [ + OpBackend.ASCEND_ROPE, + OpBackend.PYTORCH_NATIVE_ROPE, + ] + self._priority_map["npu"]["attention"] = [ + OpBackend.ASCEND_DETERMINISTIC_ATTENTION, + OpBackend.PYTORCH_NATIVE_ATTENTION, + ] + self._priority_map["npu"]["rms_norm"] = [ + OpBackend.ASCEND_RMS_NORM, + OpBackend.PYTORCH_NATIVE_RMS_NORM, + ] + self._priority_map["npu"]["embedding"] = [ + OpBackend.ASCEND_EMBEDDING, + OpBackend.PYTORCH_NATIVE_EMBEDDING, + ] + self._priority_map["npu"]["logp"] = [ + OpBackend.ASCEND_FUSED_LOGP, + OpBackend.PYTORCH_NATIVE, + ] + self._priority_map["npu"]["lm_head"] = [ + OpBackend.ASCEND_LM_HEAD, + OpBackend.PYTORCH_NATIVE_LM_HEAD, + ] + self._priority_map["npu"]["linear_logp"] = [ + OpBackend.ASCEND_FUSED_LINEAR_LOGP, + OpBackend.PYTORCH_LINEAR_LOGP, + ] + self._priority_map["npu"]["swiglu"] = [ + OpBackend.ASCEND_SWIGLU, + OpBackend.PYTORCH_NATIVE_SWIGLU, + ] + self._priority_map["npu"]["silu"] = [ + OpBackend.ASCEND_SILU, + OpBackend.PYTORCH_NATIVE_SILU, + ] + self._priority_map["npu"]["det_gemm"] = [ + OpBackend.ASCEND_DET_GEMM, + ] logger.info(f"KernelRegistry initialized for {device_ctx.device_type}") self._adjust_priority_for_hardware() self._adjust_priority_from_env() diff --git a/rl_engine/testing/ws1_manifest.json b/rl_engine/testing/ws1_manifest.json index 8b9ad9f8..e7cd363b 100644 --- a/rl_engine/testing/ws1_manifest.json +++ b/rl_engine/testing/ws1_manifest.json @@ -1,5 +1,5 @@ { - "version": "ws1-c2-v7", + "version": "ws1-c2-v8", "workload_id": "ws1-qwen3-8b-dense-primary-v6", "seed": 20260812, "model_identity": { @@ -385,7 +385,18 @@ "lm-head-short-t8-cuda-v2", "lm-head-short-t8-triton-v2", "batch-invariant-logp-short-vocab151936-t4-cuda-v1", - "batch-invariant-logp-short-vocab151936-t4-triton-v1" + "batch-invariant-logp-short-vocab151936-t4-triton-v1", + "gemm-short-m8-k4096-n4096-ascend-v2", + "logp-short-vocab151936-t4-ascend-v2", + "attn-short-prefill-gqa-b1-sq8-skv8-ascend-v2", + "rms-norm-short-t8-ascend-v2", + "qk-norm-short-t8-ascend-v2", + "silu-short-t8-ascend-v2", + "swiglu-short-t8-ascend-v2", + "rope-short-t8-ascend-v2", + "embedding-short-t8-ascend-v2", + "lm-head-short-t8-ascend-v2", + "batch-invariant-logp-short-vocab151936-t4-ascend-v1" ] }, "long_full_model_fixture": { @@ -429,7 +440,8 @@ "note": "Long fixed sequence on the same full architecture and pinned weight snapshot.", "candidate_case_ids": [ "attn-long-decode-gqa-b1-sq1-skv32-cuda-v2", - "attn-long-decode-gqa-b1-sq1-skv32-triton-v2" + "attn-long-decode-gqa-b1-sq1-skv32-triton-v2", + "attn-long-decode-gqa-b1-sq1-skv32-ascend-v2" ] }, "representative_full_model_fixture": { @@ -465,7 +477,18 @@ "logp-primary-vocab151936-t27-cuda-v1", "logp-primary-vocab151936-t27-triton-v1", "batch-invariant-logp-primary-vocab151936-t27-cuda-v1", - "batch-invariant-logp-primary-vocab151936-t27-triton-v1" + "batch-invariant-logp-primary-vocab151936-t27-triton-v1", + "gemm-primary-m59-k4096-n12288-ascend-v2", + "attn-primary-prefill-gqa-b4-sq19-skv19-ascend-v2", + "rms-norm-primary-t59-ascend-v2", + "qk-norm-primary-t59-ascend-v2", + "silu-primary-t59-ascend-v2", + "swiglu-primary-t59-ascend-v2", + "rope-primary-t59-ascend-v2", + "embedding-primary-t59-ascend-v2", + "lm-head-primary-t59-ascend-v2", + "logp-primary-vocab151936-t27-ascend-v1", + "batch-invariant-logp-primary-vocab151936-t27-ascend-v1" ] }, "prompt_lens": [ @@ -739,6 +762,89 @@ "status": "declared" } ] + }, + "ascend_bf16": { + "backend_family": "ascend", + "execution_dtype": "bfloat16", + "required_nodes": [ + { + "node": "embedding", + "expected_backend_id": "ascend", + "expected_kernel_config_id": "ascend_embedding", + "algorithm_property": "deterministic_table_lookup", + "status": "declared" + }, + { + "node": "rms_norm", + "expected_backend_id": "ascend", + "expected_kernel_config_id": "ascend_rmsnorm_bf16", + "algorithm_property": "fused_batch_invariant_rmsnorm", + "status": "declared" + }, + { + "node": "det_gemm", + "expected_backend_id": "ascend", + "expected_kernel_config_id": "ascend_det_gemm_no_splitk", + "algorithm_property": "no_split_k_deterministic_gemm", + "status": "declared" + }, + { + "node": "qk_norm", + "expected_backend_id": "ascend", + "expected_kernel_config_id": "ascend_rmsnorm_qk", + "algorithm_property": "per_head_rms_on_q_k", + "status": "declared" + }, + { + "node": "rope", + "expected_backend_id": "ascend", + "expected_kernel_config_id": "ascend_rope", + "algorithm_property": "rotate_half_theta_1e6", + "status": "declared" + }, + { + "node": "attention", + "expected_backend_id": "ascend", + "expected_kernel_config_id": "ascend_deterministic_attn_no_splitkv", + "algorithm_property": "no_split_kv_batch_invariant_attention", + "status": "declared" + }, + { + "node": "swiglu", + "expected_backend_id": "ascend", + "expected_kernel_config_id": "ascend_swiglu", + "algorithm_property": "elementwise_swiglu", + "status": "declared" + }, + { + "node": "silu", + "expected_backend_id": "ascend", + "expected_kernel_config_id": "ascend_silu", + "algorithm_property": "elementwise_silu", + "status": "declared" + }, + { + "node": "lm_head", + "expected_backend_id": "ascend", + "expected_kernel_config_id": "ascend_lm_head", + "algorithm_property": "deterministic_untied_lm_head", + "status": "declared" + }, + { + "node": "logprob", + "expected_backend_id": "ascend", + "expected_kernel_config_id": "ascend_fused_logp", + "algorithm_property": "deterministic_selected_logprob", + "status": "declared" + }, + { + "node": "batch_invariant_logp", + "expected_backend_id": "ascend", + "expected_kernel_config_id": "ascend_batch_invariant_logp", + "algorithm_property": "batch_invariant_logprob_reduction", + "status": "declared" + } + ] } }, "representative_cases": [ @@ -2169,9 +2275,723 @@ "algorithm_source": "rl_engine/kernels/ops/triton/loss/batch_invariant_logp.py:_batch_invariant_logp_kernel", "runtime_evidence_command": "python scripts/ws1_candidate_evidence.py --case-id batch-invariant-logp-primary-vocab151936-t27-triton-v1" } + }, + { + "case_id": "gemm-short-m8-k4096-n4096-ascend-v2", + "family": "gemm", + "revision": 2, + "fixture_id": "short_full_model_seq8", + "operator_spec": "det_gemm", + "shape": { + "M": 8, + "K": 4096, + "N": 4096, + "note": "Short-fixture flattened-token M; non-tile-aligned on full-model projection K/N." + }, + "expected_backend_id": "ascend", + "expected_kernel_config_id": "rl_engine.kernels.ops.ascend.matmul.det_gemm.DetGemmAscendOp", + "actual_backend_id": "ascend", + "actual_kernel_config_id": "rl_engine.kernels.ops.ascend.matmul.det_gemm.DetGemmAscendOp", + "provenance_status": "runtime_evidence_required", + "algorithm_property": "no_split_k_deterministic_gemm", + "profile_ids": [ + "ascend_bf16" + ], + "architecture_identity": "full_qwen3_8b_dense", + "provenance_evidence": { + "kind": "runtime_execution_via_operator_specs", + "registry": "rl_engine/kernels/gtest/operator_specs.py", + "candidate_name": "ascend", + "resolved_path": "rl_engine.kernels.ops.ascend.matmul.det_gemm.DetGemmAscendOp", + "algorithm_source": "csrc/ascend/gemm/det_gemm_ascend.asc:det_gemm_ascend_fwd", + "runtime_evidence_command": "python scripts/ws1_candidate_evidence.py --device npu --case-id gemm-short-m8-k4096-n4096-ascend-v2" + } + }, + { + "case_id": "gemm-primary-m59-k4096-n12288-ascend-v2", + "family": "gemm", + "revision": 2, + "fixture_id": "rep_full_model_seq16", + "operator_spec": "det_gemm", + "shape": { + "M": 59, + "K": 4096, + "N": 12288, + "note": "Primary varlen fixture total tokens; full gate/up projection width." + }, + "expected_backend_id": "ascend", + "expected_kernel_config_id": "rl_engine.kernels.ops.ascend.matmul.det_gemm.DetGemmAscendOp", + "actual_backend_id": "ascend", + "actual_kernel_config_id": "rl_engine.kernels.ops.ascend.matmul.det_gemm.DetGemmAscendOp", + "provenance_status": "runtime_evidence_required", + "algorithm_property": "no_split_k_deterministic_gemm", + "profile_ids": [ + "ascend_bf16" + ], + "architecture_identity": "full_qwen3_8b_dense", + "provenance_evidence": { + "kind": "runtime_execution_via_operator_specs", + "registry": "rl_engine/kernels/gtest/operator_specs.py", + "candidate_name": "ascend", + "resolved_path": "rl_engine.kernels.ops.ascend.matmul.det_gemm.DetGemmAscendOp", + "algorithm_source": "csrc/ascend/gemm/det_gemm_ascend.asc:det_gemm_ascend_fwd", + "runtime_evidence_command": "python scripts/ws1_candidate_evidence.py --device npu --case-id gemm-primary-m59-k4096-n12288-ascend-v2" + } + }, + { + "case_id": "attn-primary-prefill-gqa-b4-sq19-skv19-ascend-v2", + "family": "attention", + "revision": 2, + "fixture_id": "rep_full_model_seq16", + "operator_spec": "attention", + "shape": { + "B": 4, + "Hq": 32, + "Hkv": 8, + "Sq": 19, + "Skv": 19, + "D": 128, + "mode": "prefill", + "note": "Primary max-varlen prefill; non-tile-aligned sequence and official GQA." + }, + "expected_backend_id": "ascend", + "expected_kernel_config_id": "rl_engine.kernels.ops.ascend.attention.deterministic_attn.DeterministicAttentionAscendOp", + "actual_backend_id": "ascend", + "actual_kernel_config_id": "rl_engine.kernels.ops.ascend.attention.deterministic_attn.DeterministicAttentionAscendOp", + "provenance_status": "runtime_evidence_required", + "algorithm_property": "no_split_kv_batch_invariant_attention", + "profile_ids": [ + "ascend_bf16" + ], + "architecture_identity": "full_qwen3_8b_dense", + "provenance_evidence": { + "kind": "runtime_execution_via_operator_specs", + "registry": "rl_engine/kernels/gtest/operator_specs.py", + "candidate_name": "ascend", + "resolved_path": "rl_engine.kernels.ops.ascend.attention.deterministic_attn.DeterministicAttentionAscendOp", + "algorithm_source": "csrc/ascend/attention/deterministic_attention_ascend.asc:deterministic_attention_ascend_forward", + "runtime_evidence_command": "python scripts/ws1_candidate_evidence.py --device npu --case-id attn-primary-prefill-gqa-b4-sq19-skv19-ascend-v2" + } + }, + { + "case_id": "attn-long-decode-gqa-b1-sq1-skv32-ascend-v2", + "family": "attention", + "revision": 2, + "fixture_id": "long_full_model_seq32", + "operator_spec": "attention", + "shape": { + "B": 1, + "Hq": 32, + "Hkv": 8, + "Sq": 1, + "Skv": 32, + "D": 128, + "mode": "decode", + "note": "Decode step over the fixed long fixture KV length." + }, + "expected_backend_id": "ascend", + "expected_kernel_config_id": "rl_engine.kernels.ops.ascend.attention.deterministic_attn.DeterministicAttentionAscendOp", + "actual_backend_id": "ascend", + "actual_kernel_config_id": "rl_engine.kernels.ops.ascend.attention.deterministic_attn.DeterministicAttentionAscendOp", + "provenance_status": "runtime_evidence_required", + "algorithm_property": "no_split_kv_batch_invariant_attention", + "profile_ids": [ + "ascend_bf16" + ], + "architecture_identity": "full_qwen3_8b_dense", + "provenance_evidence": { + "kind": "runtime_execution_via_operator_specs", + "registry": "rl_engine/kernels/gtest/operator_specs.py", + "candidate_name": "ascend", + "resolved_path": "rl_engine.kernels.ops.ascend.attention.deterministic_attn.DeterministicAttentionAscendOp", + "algorithm_source": "csrc/ascend/attention/deterministic_attention_ascend.asc:deterministic_attention_ascend_forward", + "runtime_evidence_command": "python scripts/ws1_candidate_evidence.py --device npu --case-id attn-long-decode-gqa-b1-sq1-skv32-ascend-v2" + } + }, + { + "case_id": "logp-short-vocab151936-t4-ascend-v2", + "family": "logprob", + "revision": 2, + "fixture_id": "short_full_model_seq8", + "operator_spec": "logp", + "shape": { + "B": 1, + "T": 4, + "vocab": 151936, + "note": "Short-fixture active selected tokens; full vocab crosses the CUDA reduction boundary." + }, + "expected_backend_id": "ascend", + "expected_kernel_config_id": "rl_engine.kernels.ops.ascend.loss.logp.FusedLogpAscendOp", + "actual_backend_id": "ascend", + "actual_kernel_config_id": "rl_engine.kernels.ops.ascend.loss.logp.FusedLogpAscendOp", + "provenance_status": "runtime_evidence_required", + "algorithm_property": "deterministic_selected_logprob", + "profile_ids": [ + "ascend_bf16" + ], + "architecture_identity": "full_qwen3_8b_dense", + "provenance_evidence": { + "kind": "runtime_execution_via_operator_specs", + "registry": "rl_engine/kernels/gtest/operator_specs.py", + "candidate_name": "ascend", + "resolved_path": "rl_engine.kernels.ops.ascend.loss.logp.FusedLogpAscendOp", + "algorithm_source": "csrc/ascend/fused_logp_ascend.asc:fused_logp_ascend_forward", + "runtime_evidence_command": "python scripts/ws1_candidate_evidence.py --device npu --case-id logp-short-vocab151936-t4-ascend-v2" + } + }, + { + "case_id": "attn-short-prefill-gqa-b1-sq8-skv8-ascend-v2", + "family": "attention", + "revision": 2, + "fixture_id": "short_full_model_seq8", + "operator_spec": "attention", + "op_name": "attention", + "shape": { + "B": 1, + "Hq": 32, + "Hkv": 8, + "Sq": 8, + "Skv": 8, + "D": 128, + "mode": "prefill", + "note": "Short-fixture prefill; official GQA head_dim=128." + }, + "expected_backend_id": "ascend", + "expected_kernel_config_id": "rl_engine.kernels.ops.ascend.attention.deterministic_attn.DeterministicAttentionAscendOp", + "actual_backend_id": "ascend", + "actual_kernel_config_id": "rl_engine.kernels.ops.ascend.attention.deterministic_attn.DeterministicAttentionAscendOp", + "provenance_status": "runtime_evidence_required", + "algorithm_property": "no_split_kv_batch_invariant_attention", + "profile_ids": [ + "ascend_bf16" + ], + "architecture_identity": "full_qwen3_8b_dense", + "provenance_evidence": { + "kind": "runtime_execution_via_operator_specs", + "registry": "rl_engine/kernels/gtest/operator_specs.py", + "candidate_name": "ascend", + "resolved_path": "rl_engine.kernels.ops.ascend.attention.deterministic_attn.DeterministicAttentionAscendOp", + "algorithm_source": "csrc/ascend/attention/deterministic_attention_ascend.asc:deterministic_attention_ascend_forward", + "runtime_evidence_command": "python scripts/ws1_candidate_evidence.py --device npu --case-id attn-short-prefill-gqa-b1-sq8-skv8-ascend-v2" + } + }, + { + "case_id": "rms-norm-short-t8-ascend-v2", + "family": "norm", + "revision": 2, + "fixture_id": "short_full_model_seq8", + "operator_spec": "rms_norm", + "op_name": "rms_norm", + "shape": { + "T": 8, + "note": "Short-fixture flattened tokens on the official Qwen3-8B Dense width." + }, + "expected_backend_id": "ascend", + "expected_kernel_config_id": "rl_engine.kernels.ops.ascend.norm.rmsnorm.RMSNormAscendOp", + "actual_backend_id": "ascend", + "actual_kernel_config_id": "rl_engine.kernels.ops.ascend.norm.rmsnorm.RMSNormAscendOp", + "provenance_status": "runtime_evidence_required", + "algorithm_property": "fused_batch_invariant_rmsnorm", + "profile_ids": [ + "ascend_bf16" + ], + "architecture_identity": "full_qwen3_8b_dense", + "provenance_evidence": { + "kind": "runtime_execution_via_operator_specs", + "registry": "rl_engine/kernels/gtest/operator_specs.py", + "candidate_name": "ascend", + "resolved_path": "rl_engine.kernels.ops.ascend.norm.rmsnorm.RMSNormAscendOp", + "algorithm_source": "csrc/ascend/rmsnorm_ascend.asc:rmsnorm_ascend_forward", + "runtime_evidence_command": "python scripts/ws1_candidate_evidence.py --device npu --case-id rms-norm-short-t8-ascend-v2" + } + }, + { + "case_id": "rms-norm-primary-t59-ascend-v2", + "family": "norm", + "revision": 2, + "fixture_id": "rep_full_model_seq16", + "operator_spec": "rms_norm", + "op_name": "rms_norm", + "shape": { + "T": 59, + "note": "Primary varlen fixture total tokens on the official Qwen3-8B Dense width." + }, + "expected_backend_id": "ascend", + "expected_kernel_config_id": "rl_engine.kernels.ops.ascend.norm.rmsnorm.RMSNormAscendOp", + "actual_backend_id": "ascend", + "actual_kernel_config_id": "rl_engine.kernels.ops.ascend.norm.rmsnorm.RMSNormAscendOp", + "provenance_status": "runtime_evidence_required", + "algorithm_property": "fused_batch_invariant_rmsnorm", + "profile_ids": [ + "ascend_bf16" + ], + "architecture_identity": "full_qwen3_8b_dense", + "provenance_evidence": { + "kind": "runtime_execution_via_operator_specs", + "registry": "rl_engine/kernels/gtest/operator_specs.py", + "candidate_name": "ascend", + "resolved_path": "rl_engine.kernels.ops.ascend.norm.rmsnorm.RMSNormAscendOp", + "algorithm_source": "csrc/ascend/rmsnorm_ascend.asc:rmsnorm_ascend_forward", + "runtime_evidence_command": "python scripts/ws1_candidate_evidence.py --device npu --case-id rms-norm-primary-t59-ascend-v2" + } + }, + { + "case_id": "qk-norm-short-t8-ascend-v2", + "family": "norm", + "revision": 2, + "fixture_id": "short_full_model_seq8", + "operator_spec": "qk_norm", + "op_name": "qk_norm", + "shape": { + "T": 8, + "note": "Per-head RMSNorm (head_dim=128) over the fixture token rows; not a hidden-width RMSNorm stand-in.", + "head_dim": 128 + }, + "expected_backend_id": "ascend", + "expected_kernel_config_id": "rl_engine.kernels.ops.ascend.norm.rmsnorm.RMSNormAscendOp", + "actual_backend_id": "ascend", + "actual_kernel_config_id": "rl_engine.kernels.ops.ascend.norm.rmsnorm.RMSNormAscendOp", + "provenance_status": "runtime_evidence_required", + "algorithm_property": "per_head_rms_on_q_k", + "profile_ids": [ + "ascend_bf16" + ], + "architecture_identity": "full_qwen3_8b_dense", + "provenance_evidence": { + "kind": "runtime_execution_via_operator_specs", + "registry": "rl_engine/kernels/gtest/operator_specs.py", + "candidate_name": "ascend", + "resolved_path": "rl_engine.kernels.ops.ascend.norm.rmsnorm.RMSNormAscendOp", + "algorithm_source": "csrc/ascend/rmsnorm_ascend.asc:rmsnorm_ascend_forward", + "runtime_evidence_command": "python scripts/ws1_candidate_evidence.py --device npu --case-id qk-norm-short-t8-ascend-v2" + } + }, + { + "case_id": "qk-norm-primary-t59-ascend-v2", + "family": "norm", + "revision": 2, + "fixture_id": "rep_full_model_seq16", + "operator_spec": "qk_norm", + "op_name": "qk_norm", + "shape": { + "T": 59, + "note": "Per-head RMSNorm (head_dim=128) over the fixture token rows; not a hidden-width RMSNorm stand-in.", + "head_dim": 128 + }, + "expected_backend_id": "ascend", + "expected_kernel_config_id": "rl_engine.kernels.ops.ascend.norm.rmsnorm.RMSNormAscendOp", + "actual_backend_id": "ascend", + "actual_kernel_config_id": "rl_engine.kernels.ops.ascend.norm.rmsnorm.RMSNormAscendOp", + "provenance_status": "runtime_evidence_required", + "algorithm_property": "per_head_rms_on_q_k", + "profile_ids": [ + "ascend_bf16" + ], + "architecture_identity": "full_qwen3_8b_dense", + "provenance_evidence": { + "kind": "runtime_execution_via_operator_specs", + "registry": "rl_engine/kernels/gtest/operator_specs.py", + "candidate_name": "ascend", + "resolved_path": "rl_engine.kernels.ops.ascend.norm.rmsnorm.RMSNormAscendOp", + "algorithm_source": "csrc/ascend/rmsnorm_ascend.asc:rmsnorm_ascend_forward", + "runtime_evidence_command": "python scripts/ws1_candidate_evidence.py --device npu --case-id qk-norm-primary-t59-ascend-v2" + } + }, + { + "case_id": "silu-short-t8-ascend-v2", + "family": "elementwise", + "revision": 2, + "fixture_id": "short_full_model_seq8", + "operator_spec": "silu", + "op_name": "silu", + "shape": { + "T": 8, + "note": "Short-fixture flattened tokens on the official Qwen3-8B Dense width." + }, + "expected_backend_id": "ascend", + "expected_kernel_config_id": "rl_engine.kernels.ops.ascend.activation.silu.SiLUAscendOp", + "actual_backend_id": "ascend", + "actual_kernel_config_id": "rl_engine.kernels.ops.ascend.activation.silu.SiLUAscendOp", + "provenance_status": "runtime_evidence_required", + "algorithm_property": "elementwise_silu", + "profile_ids": [ + "ascend_bf16" + ], + "architecture_identity": "full_qwen3_8b_dense", + "provenance_evidence": { + "kind": "runtime_execution_via_operator_specs", + "registry": "rl_engine/kernels/gtest/operator_specs.py", + "candidate_name": "ascend", + "resolved_path": "rl_engine.kernels.ops.ascend.activation.silu.SiLUAscendOp", + "algorithm_source": "csrc/ascend/activation.asc:silu_ascend_forward", + "runtime_evidence_command": "python scripts/ws1_candidate_evidence.py --device npu --case-id silu-short-t8-ascend-v2" + } + }, + { + "case_id": "silu-primary-t59-ascend-v2", + "family": "elementwise", + "revision": 2, + "fixture_id": "rep_full_model_seq16", + "operator_spec": "silu", + "op_name": "silu", + "shape": { + "T": 59, + "note": "Primary varlen fixture total tokens on the official Qwen3-8B Dense width." + }, + "expected_backend_id": "ascend", + "expected_kernel_config_id": "rl_engine.kernels.ops.ascend.activation.silu.SiLUAscendOp", + "actual_backend_id": "ascend", + "actual_kernel_config_id": "rl_engine.kernels.ops.ascend.activation.silu.SiLUAscendOp", + "provenance_status": "runtime_evidence_required", + "algorithm_property": "elementwise_silu", + "profile_ids": [ + "ascend_bf16" + ], + "architecture_identity": "full_qwen3_8b_dense", + "provenance_evidence": { + "kind": "runtime_execution_via_operator_specs", + "registry": "rl_engine/kernels/gtest/operator_specs.py", + "candidate_name": "ascend", + "resolved_path": "rl_engine.kernels.ops.ascend.activation.silu.SiLUAscendOp", + "algorithm_source": "csrc/ascend/activation.asc:silu_ascend_forward", + "runtime_evidence_command": "python scripts/ws1_candidate_evidence.py --device npu --case-id silu-primary-t59-ascend-v2" + } + }, + { + "case_id": "swiglu-short-t8-ascend-v2", + "family": "elementwise", + "revision": 2, + "fixture_id": "short_full_model_seq8", + "operator_spec": "swiglu", + "op_name": "swiglu", + "shape": { + "T": 8, + "note": "Short-fixture flattened tokens on the official Qwen3-8B Dense width." + }, + "expected_backend_id": "ascend", + "expected_kernel_config_id": "rl_engine.kernels.ops.ascend.activation.swiglu.SwiGLUAscendOp", + "actual_backend_id": "ascend", + "actual_kernel_config_id": "rl_engine.kernels.ops.ascend.activation.swiglu.SwiGLUAscendOp", + "provenance_status": "runtime_evidence_required", + "algorithm_property": "elementwise_swiglu", + "profile_ids": [ + "ascend_bf16" + ], + "architecture_identity": "full_qwen3_8b_dense", + "provenance_evidence": { + "kind": "runtime_execution_via_operator_specs", + "registry": "rl_engine/kernels/gtest/operator_specs.py", + "candidate_name": "ascend", + "resolved_path": "rl_engine.kernels.ops.ascend.activation.swiglu.SwiGLUAscendOp", + "algorithm_source": "csrc/ascend/activation.asc:swiglu_ascend_forward", + "runtime_evidence_command": "python scripts/ws1_candidate_evidence.py --device npu --case-id swiglu-short-t8-ascend-v2" + } + }, + { + "case_id": "swiglu-primary-t59-ascend-v2", + "family": "elementwise", + "revision": 2, + "fixture_id": "rep_full_model_seq16", + "operator_spec": "swiglu", + "op_name": "swiglu", + "shape": { + "T": 59, + "note": "Primary varlen fixture total tokens on the official Qwen3-8B Dense width." + }, + "expected_backend_id": "ascend", + "expected_kernel_config_id": "rl_engine.kernels.ops.ascend.activation.swiglu.SwiGLUAscendOp", + "actual_backend_id": "ascend", + "actual_kernel_config_id": "rl_engine.kernels.ops.ascend.activation.swiglu.SwiGLUAscendOp", + "provenance_status": "runtime_evidence_required", + "algorithm_property": "elementwise_swiglu", + "profile_ids": [ + "ascend_bf16" + ], + "architecture_identity": "full_qwen3_8b_dense", + "provenance_evidence": { + "kind": "runtime_execution_via_operator_specs", + "registry": "rl_engine/kernels/gtest/operator_specs.py", + "candidate_name": "ascend", + "resolved_path": "rl_engine.kernels.ops.ascend.activation.swiglu.SwiGLUAscendOp", + "algorithm_source": "csrc/ascend/activation.asc:swiglu_ascend_forward", + "runtime_evidence_command": "python scripts/ws1_candidate_evidence.py --device npu --case-id swiglu-primary-t59-ascend-v2" + } + }, + { + "case_id": "rope-short-t8-ascend-v2", + "family": "elementwise", + "revision": 2, + "fixture_id": "short_full_model_seq8", + "operator_spec": "rope", + "op_name": "rope", + "shape": { + "T": 8, + "note": "Short-fixture flattened tokens on the official Qwen3-8B Dense width." + }, + "expected_backend_id": "ascend", + "expected_kernel_config_id": "rl_engine.kernels.ops.ascend.rotary_embedding.rope.RoPEAscendOp", + "actual_backend_id": "ascend", + "actual_kernel_config_id": "rl_engine.kernels.ops.ascend.rotary_embedding.rope.RoPEAscendOp", + "provenance_status": "runtime_evidence_required", + "algorithm_property": "rotate_half_theta_1e6", + "profile_ids": [ + "ascend_bf16" + ], + "architecture_identity": "full_qwen3_8b_dense", + "provenance_evidence": { + "kind": "runtime_execution_via_operator_specs", + "registry": "rl_engine/kernels/gtest/operator_specs.py", + "candidate_name": "ascend", + "resolved_path": "rl_engine.kernels.ops.ascend.rotary_embedding.rope.RoPEAscendOp", + "algorithm_source": "csrc/ascend/rope_ascend.asc:rope_apply_ascend_forward", + "runtime_evidence_command": "python scripts/ws1_candidate_evidence.py --device npu --case-id rope-short-t8-ascend-v2" + } + }, + { + "case_id": "rope-primary-t59-ascend-v2", + "family": "elementwise", + "revision": 2, + "fixture_id": "rep_full_model_seq16", + "operator_spec": "rope", + "op_name": "rope", + "shape": { + "T": 59, + "note": "Primary varlen fixture total tokens on the official Qwen3-8B Dense width." + }, + "expected_backend_id": "ascend", + "expected_kernel_config_id": "rl_engine.kernels.ops.ascend.rotary_embedding.rope.RoPEAscendOp", + "actual_backend_id": "ascend", + "actual_kernel_config_id": "rl_engine.kernels.ops.ascend.rotary_embedding.rope.RoPEAscendOp", + "provenance_status": "runtime_evidence_required", + "algorithm_property": "rotate_half_theta_1e6", + "profile_ids": [ + "ascend_bf16" + ], + "architecture_identity": "full_qwen3_8b_dense", + "provenance_evidence": { + "kind": "runtime_execution_via_operator_specs", + "registry": "rl_engine/kernels/gtest/operator_specs.py", + "candidate_name": "ascend", + "resolved_path": "rl_engine.kernels.ops.ascend.rotary_embedding.rope.RoPEAscendOp", + "algorithm_source": "csrc/ascend/rope_ascend.asc:rope_apply_ascend_forward", + "runtime_evidence_command": "python scripts/ws1_candidate_evidence.py --device npu --case-id rope-primary-t59-ascend-v2" + } + }, + { + "case_id": "embedding-short-t8-ascend-v2", + "family": "embedding", + "revision": 2, + "fixture_id": "short_full_model_seq8", + "operator_spec": "embedding", + "op_name": "embedding", + "shape": { + "T": 8, + "note": "Short-fixture flattened tokens on the official Qwen3-8B Dense width." + }, + "expected_backend_id": "ascend", + "expected_kernel_config_id": "rl_engine.kernels.ops.ascend.linear.embedding.AscendEmbeddingOp", + "actual_backend_id": "ascend", + "actual_kernel_config_id": "rl_engine.kernels.ops.ascend.linear.embedding.AscendEmbeddingOp", + "provenance_status": "runtime_evidence_required", + "algorithm_property": "deterministic_table_lookup", + "profile_ids": [ + "ascend_bf16" + ], + "architecture_identity": "full_qwen3_8b_dense", + "provenance_evidence": { + "kind": "runtime_execution_via_operator_specs", + "registry": "rl_engine/kernels/gtest/operator_specs.py", + "candidate_name": "ascend", + "resolved_path": "rl_engine.kernels.ops.ascend.linear.embedding.AscendEmbeddingOp", + "algorithm_source": "csrc/ascend/embedding_ascend.asc:embedding_ascend_forward", + "runtime_evidence_command": "python scripts/ws1_candidate_evidence.py --device npu --case-id embedding-short-t8-ascend-v2" + } + }, + { + "case_id": "embedding-primary-t59-ascend-v2", + "family": "embedding", + "revision": 2, + "fixture_id": "rep_full_model_seq16", + "operator_spec": "embedding", + "op_name": "embedding", + "shape": { + "T": 59, + "note": "Primary varlen fixture total tokens on the official Qwen3-8B Dense width." + }, + "expected_backend_id": "ascend", + "expected_kernel_config_id": "rl_engine.kernels.ops.ascend.linear.embedding.AscendEmbeddingOp", + "actual_backend_id": "ascend", + "actual_kernel_config_id": "rl_engine.kernels.ops.ascend.linear.embedding.AscendEmbeddingOp", + "provenance_status": "runtime_evidence_required", + "algorithm_property": "deterministic_table_lookup", + "profile_ids": [ + "ascend_bf16" + ], + "architecture_identity": "full_qwen3_8b_dense", + "provenance_evidence": { + "kind": "runtime_execution_via_operator_specs", + "registry": "rl_engine/kernels/gtest/operator_specs.py", + "candidate_name": "ascend", + "resolved_path": "rl_engine.kernels.ops.ascend.linear.embedding.AscendEmbeddingOp", + "algorithm_source": "csrc/ascend/embedding_ascend.asc:embedding_ascend_forward", + "runtime_evidence_command": "python scripts/ws1_candidate_evidence.py --device npu --case-id embedding-primary-t59-ascend-v2" + } + }, + { + "case_id": "lm-head-short-t8-ascend-v2", + "family": "lm_head", + "revision": 2, + "fixture_id": "short_full_model_seq8", + "operator_spec": "lm_head", + "op_name": "lm_head", + "shape": { + "T": 8, + "note": "Short-fixture flattened tokens on the official Qwen3-8B Dense width." + }, + "expected_backend_id": "ascend", + "expected_kernel_config_id": "rl_engine.kernels.ops.ascend.linear.lm_head.AscendLMHeadOp", + "actual_backend_id": "ascend", + "actual_kernel_config_id": "rl_engine.kernels.ops.ascend.linear.lm_head.AscendLMHeadOp", + "provenance_status": "runtime_evidence_required", + "algorithm_property": "deterministic_untied_lm_head", + "profile_ids": [ + "ascend_bf16" + ], + "architecture_identity": "full_qwen3_8b_dense", + "provenance_evidence": { + "kind": "runtime_execution_via_operator_specs", + "registry": "rl_engine/kernels/gtest/operator_specs.py", + "candidate_name": "ascend", + "resolved_path": "rl_engine.kernels.ops.ascend.linear.lm_head.AscendLMHeadOp", + "algorithm_source": "csrc/ascend/lm_head_ascend.asc:lm_head_ascend_forward", + "runtime_evidence_command": "python scripts/ws1_candidate_evidence.py --device npu --case-id lm-head-short-t8-ascend-v2" + } + }, + { + "case_id": "lm-head-primary-t59-ascend-v2", + "family": "lm_head", + "revision": 2, + "fixture_id": "rep_full_model_seq16", + "operator_spec": "lm_head", + "op_name": "lm_head", + "shape": { + "T": 59, + "note": "Primary varlen fixture total tokens on the official Qwen3-8B Dense width." + }, + "expected_backend_id": "ascend", + "expected_kernel_config_id": "rl_engine.kernels.ops.ascend.linear.lm_head.AscendLMHeadOp", + "actual_backend_id": "ascend", + "actual_kernel_config_id": "rl_engine.kernels.ops.ascend.linear.lm_head.AscendLMHeadOp", + "provenance_status": "runtime_evidence_required", + "algorithm_property": "deterministic_untied_lm_head", + "profile_ids": [ + "ascend_bf16" + ], + "architecture_identity": "full_qwen3_8b_dense", + "provenance_evidence": { + "kind": "runtime_execution_via_operator_specs", + "registry": "rl_engine/kernels/gtest/operator_specs.py", + "candidate_name": "ascend", + "resolved_path": "rl_engine.kernels.ops.ascend.linear.lm_head.AscendLMHeadOp", + "algorithm_source": "csrc/ascend/lm_head_ascend.asc:lm_head_ascend_forward", + "runtime_evidence_command": "python scripts/ws1_candidate_evidence.py --device npu --case-id lm-head-primary-t59-ascend-v2" + } + }, + { + "case_id": "logp-primary-vocab151936-t27-ascend-v1", + "family": "logprob", + "revision": 1, + "fixture_id": "rep_full_model_seq16", + "operator_spec": "logp", + "shape": { + "B": 4, + "T": 27, + "vocab": 151936, + "note": "Selected-token logprob on the fixture completion tokens; full vocab." + }, + "expected_backend_id": "ascend", + "expected_kernel_config_id": "rl_engine.kernels.ops.ascend.loss.logp.FusedLogpAscendOp", + "actual_backend_id": "ascend", + "actual_kernel_config_id": "rl_engine.kernels.ops.ascend.loss.logp.FusedLogpAscendOp", + "provenance_status": "runtime_evidence_required", + "algorithm_property": "deterministic_selected_logprob", + "profile_ids": [ + "ascend_bf16" + ], + "architecture_identity": "full_qwen3_8b_dense", + "provenance_evidence": { + "kind": "runtime_execution_via_operator_specs", + "registry": "rl_engine/kernels/gtest/operator_specs.py", + "candidate_name": "ascend", + "resolved_path": "rl_engine.kernels.ops.ascend.loss.logp.FusedLogpAscendOp", + "algorithm_source": "csrc/ascend/fused_logp_ascend.asc:fused_logp_ascend_forward", + "runtime_evidence_command": "python scripts/ws1_candidate_evidence.py --device npu --case-id logp-primary-vocab151936-t27-ascend-v1" + } + }, + { + "case_id": "batch-invariant-logp-short-vocab151936-t4-ascend-v1", + "family": "batch_invariant_logp", + "revision": 1, + "fixture_id": "short_full_model_seq8", + "operator_spec": "batch_invariant_logp", + "shape": { + "B": 1, + "T": 4, + "vocab": 151936, + "note": "Selected-token logprob on the fixture completion tokens; full vocab." + }, + "expected_backend_id": "ascend", + "expected_kernel_config_id": "rl_engine.kernels.ops.ascend.loss.batch_invariant_logp.BatchInvariantLogpAscendOp", + "actual_backend_id": "ascend", + "actual_kernel_config_id": "rl_engine.kernels.ops.ascend.loss.batch_invariant_logp.BatchInvariantLogpAscendOp", + "provenance_status": "runtime_evidence_required", + "algorithm_property": "batch_invariant_logprob_reduction", + "profile_ids": [ + "ascend_bf16" + ], + "architecture_identity": "full_qwen3_8b_dense", + "provenance_evidence": { + "kind": "runtime_execution_via_operator_specs", + "registry": "rl_engine/kernels/gtest/operator_specs.py", + "candidate_name": "ascend", + "resolved_path": "rl_engine.kernels.ops.ascend.loss.batch_invariant_logp.BatchInvariantLogpAscendOp", + "algorithm_source": "csrc/ascend/batch_invariant_logp_ascend.asc:batch_invariant_logp_ascend_forward", + "runtime_evidence_command": "python scripts/ws1_candidate_evidence.py --device npu --case-id batch-invariant-logp-short-vocab151936-t4-ascend-v1" + } + }, + { + "case_id": "batch-invariant-logp-primary-vocab151936-t27-ascend-v1", + "family": "batch_invariant_logp", + "revision": 1, + "fixture_id": "rep_full_model_seq16", + "operator_spec": "batch_invariant_logp", + "shape": { + "B": 4, + "T": 27, + "vocab": 151936, + "note": "Selected-token logprob on the fixture completion tokens; full vocab." + }, + "expected_backend_id": "ascend", + "expected_kernel_config_id": "rl_engine.kernels.ops.ascend.loss.batch_invariant_logp.BatchInvariantLogpAscendOp", + "actual_backend_id": "ascend", + "actual_kernel_config_id": "rl_engine.kernels.ops.ascend.loss.batch_invariant_logp.BatchInvariantLogpAscendOp", + "provenance_status": "runtime_evidence_required", + "algorithm_property": "batch_invariant_logprob_reduction", + "profile_ids": [ + "ascend_bf16" + ], + "architecture_identity": "full_qwen3_8b_dense", + "provenance_evidence": { + "kind": "runtime_execution_via_operator_specs", + "registry": "rl_engine/kernels/gtest/operator_specs.py", + "candidate_name": "ascend", + "resolved_path": "rl_engine.kernels.ops.ascend.loss.batch_invariant_logp.BatchInvariantLogpAscendOp", + "algorithm_source": "csrc/ascend/batch_invariant_logp_ascend.asc:batch_invariant_logp_ascend_forward", + "runtime_evidence_command": "python scripts/ws1_candidate_evidence.py --device npu --case-id batch-invariant-logp-primary-vocab151936-t27-ascend-v1" + } } ], - "fixture_identity_sha256": "3fa8a5913795a4a0011e038a5a33831dc63b096fce67c9817766f493dd66c222", + "fixture_identity_sha256": "fe17c160af1c3ca87bb8b2c043494480c591af991675aa6c9b4a975c7a9d16d4", "provenance_boundary": { "c2_scope": "logical_workload_identity_and_executed_representative_candidate_binding", "not_in_c2": [ diff --git a/rl_engine/testing/ws1_workload.py b/rl_engine/testing/ws1_workload.py index 61bf87d2..376a7a66 100644 --- a/rl_engine/testing/ws1_workload.py +++ b/rl_engine/testing/ws1_workload.py @@ -41,7 +41,7 @@ "BN/chunked", ) -_REQUIRED_PROFILES = ("cuda_bf16", "triton_cuda_bf16") +_REQUIRED_PROFILES = ("cuda_bf16", "triton_cuda_bf16", "ascend_bf16") _REQUIRED_CHAIN_NODES = ( "embedding", diff --git a/rl_engine/tests/test_dispatch.py b/rl_engine/tests/test_dispatch.py index e98cddab..b63a6788 100644 --- a/rl_engine/tests/test_dispatch.py +++ b/rl_engine/tests/test_dispatch.py @@ -194,11 +194,35 @@ def fake_load_backend(backend): registry.get_op("rms_norm") assert registry._priority_map["npu"].keys() == registry._priority_map["cpu"].keys() - assert loaded[0] == OpBackend.PYTORCH_NATIVE_RMS_NORM + assert loaded[0] == OpBackend.ASCEND_RMS_NORM assert registry._priority_map["npu"]["batch_invariant_logp"] == [ OpBackend.ASCEND_BATCH_INVARIANT_LOGP, OpBackend.PYTORCH_BATCH_INVARIANT_LOGP, ] + assert registry._priority_map["npu"]["rope"] == [ + OpBackend.ASCEND_ROPE, + OpBackend.PYTORCH_NATIVE_ROPE, + ] + assert registry._priority_map["npu"]["rms_norm"] == [ + OpBackend.ASCEND_RMS_NORM, + OpBackend.PYTORCH_NATIVE_RMS_NORM, + ] + assert registry._priority_map["npu"]["embedding"] == [ + OpBackend.ASCEND_EMBEDDING, + OpBackend.PYTORCH_NATIVE_EMBEDDING, + ] + assert registry._priority_map["npu"]["logp"] == [ + OpBackend.ASCEND_FUSED_LOGP, + OpBackend.PYTORCH_NATIVE, + ] + assert registry._priority_map["npu"]["lm_head"] == [ + OpBackend.ASCEND_LM_HEAD, + OpBackend.PYTORCH_NATIVE_LM_HEAD, + ] + assert registry._priority_map["npu"]["linear_logp"] == [ + OpBackend.ASCEND_FUSED_LINEAR_LOGP, + OpBackend.PYTORCH_LINEAR_LOGP, + ] def test_npu_available_handles_runtime_failure(monkeypatch): diff --git a/scripts/check_decode_prefill.py b/scripts/check_decode_prefill.py index 407bcfe3..9eb888b3 100755 --- a/scripts/check_decode_prefill.py +++ b/scripts/check_decode_prefill.py @@ -11,12 +11,14 @@ import pathlib import sys -import torch - REPO_ROOT = pathlib.Path(__file__).resolve().parents[1] if str(REPO_ROOT) not in sys.path: sys.path.insert(0, str(REPO_ROOT)) +from rl_engine.kernels.gtest.accelerator import ( # noqa: E402 + disable_tf32, + resolve_device, +) from rl_engine.kernels.gtest.kv_consistency import ( # noqa: E402 assert_decode_prefill_consistent, build_decode_prefill_cases, @@ -29,20 +31,27 @@ def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description="WS1 C6 direct decode-prefill gate") parser.add_argument( "--backend-profile", - choices=("cuda_bf16", "triton_cuda_bf16"), + choices=("cuda_bf16", "triton_cuda_bf16", "ascend_bf16"), required=True, ) parser.add_argument("--candidate", default=None) + parser.add_argument( + "--device", + default=None, + help="Defaults to the backend profile's own accelerator (cuda or npu).", + ) parser.add_argument("--json", action="store_true") return parser.parse_args() def main() -> int: args = parse_args() - if not torch.cuda.is_available(): - print("ERROR: C6 declared-candidate gate requires CUDA", file=sys.stderr) + try: + device = resolve_device(args.device, profile=args.backend_profile) + except RuntimeError as exc: + print(f"ERROR: C6 declared-candidate gate needs a real device: {exc}", file=sys.stderr) return 2 - torch.backends.cuda.matmul.allow_tf32 = False + disable_tf32(device.type) contract = load_contract() manifest = load_manifest() report = assert_decode_prefill_consistent( @@ -50,6 +59,7 @@ def main() -> int: candidate=args.candidate, contract=contract, manifest=manifest, + device=device, require_declared_candidate=True, ) if args.json: diff --git a/scripts/check_forward_invariance.py b/scripts/check_forward_invariance.py index b85186ac..8736bc7b 100644 --- a/scripts/check_forward_invariance.py +++ b/scripts/check_forward_invariance.py @@ -23,6 +23,13 @@ assert_forward_batch_invariant, load_contract, ) +from rl_engine.kernels.gtest.accelerator import ( # noqa: E402 + arch_key, + candidate_family, + device_name, + disable_tf32, + resolve_device, +) from rl_engine.kernels.gtest.forward_invariance import build_config_matrix # noqa: E402 from rl_engine.kernels.gtest.gradient_adapters import ( # noqa: E402 GRADIENT_ADAPTERS, @@ -42,11 +49,7 @@ def _object_path(value: Any) -> str: def _candidate_family(candidate: str) -> str: - if candidate.startswith("cuda"): - return "cuda" - if candidate == "triton": - return "triton" - return candidate + return candidate_family(candidate) def _validate_candidate_selection( @@ -111,14 +114,18 @@ def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description="WS1 C3 forward invariance GPU gate") parser.add_argument("--op", choices=sorted(runnable), default="rms_norm") parser.add_argument( - "--candidate", required=True, help="Manifest-declared CUDA/Triton candidate" + "--candidate", required=True, help="Manifest-declared CUDA/Triton/Ascend candidate" ) parser.add_argument( "--backend-profile", - choices=("cuda_bf16", "triton_cuda_bf16"), + choices=("cuda_bf16", "triton_cuda_bf16", "ascend_bf16"), required=True, ) - parser.add_argument("--device", default="cuda") + parser.add_argument( + "--device", + default=None, + help="Defaults to the backend profile's own accelerator (cuda or npu).", + ) parser.add_argument("--hidden", type=int, default=64) parser.add_argument("--vocab", type=int, default=256) parser.add_argument("--n-heads", type=int, default=4) @@ -130,9 +137,10 @@ def parse_args() -> argparse.Namespace: def main() -> None: args = parse_args() - device = torch.device(args.device) - if device.type != "cuda" or not torch.cuda.is_available(): - raise SystemExit("ERROR: C3 required-profile evidence requires an available CUDA device") + try: + device = resolve_device(args.device, profile=args.backend_profile) + except RuntimeError as exc: + raise SystemExit(f"ERROR: C3 required-profile evidence needs a real device: {exc}") from exc if args.vocab <= 240: raise SystemExit("ERROR: --vocab must cover every fixed C2 workload token id") @@ -151,9 +159,8 @@ def main() -> None: op_name=args.op, candidate=args.candidate, ) - cc_tuple = torch.cuda.get_device_capability(device) - cc = f"sm{cc_tuple[0]}{cc_tuple[1]}" - if args.candidate == "cuda-sm90" and cc_tuple[0] != 9: + cc = arch_key(device) + if args.candidate == "cuda-sm90" and cc != "sm90": raise SystemExit( "ERROR: cuda-sm90 candidate requested on non-SM90 hardware; fallback forbidden" ) @@ -162,8 +169,7 @@ def main() -> None: gold_fn = load_adapter_gold(args.op) policy = resolve_dtype_policy(contract) family = _candidate_family(args.candidate) - torch.backends.cuda.matmul.allow_tf32 = False - torch.backends.cudnn.allow_tf32 = False + tf32_enabled = disable_tf32(device.type) provenance = BackendProvenance( backend_profile=args.backend_profile, @@ -173,8 +179,8 @@ def main() -> None: accumulation_dtype=policy.accumulation_dtype, output_dtype=policy.output_dtype_default, reference_dtype=policy.reference_dtype, - candidate_tf32_enabled=torch.backends.cuda.matmul.allow_tf32, - reference_tf32_enabled=torch.backends.cuda.matmul.allow_tf32, + candidate_tf32_enabled=tf32_enabled, + reference_tf32_enabled=tf32_enabled, ) kernel_id = _object_path(candidate_op) shape_kwargs = { @@ -217,7 +223,7 @@ def main() -> None: op_name=args.op, include_logprob_smoke=adapter.op_class == "logprob", candidate_id=f"{kernel_id}::{resolved.get('expected_backend_id')}", - device=f"{device}:{torch.cuda.get_device_name(device)}", + device=f"{device}:{device_name(device)}", compute_capability=cc, observed_actual_backend=family, observed_kernel_id=kernel_id, diff --git a/scripts/check_gradient_invariance.py b/scripts/check_gradient_invariance.py index 0b47a5e4..606be9a5 100644 --- a/scripts/check_gradient_invariance.py +++ b/scripts/check_gradient_invariance.py @@ -23,6 +23,13 @@ assert_gradient_batch_invariant, load_contract, ) +from rl_engine.kernels.gtest.accelerator import ( # noqa: E402 + arch_key, + candidate_family, + device_name, + disable_tf32, + resolve_device, +) from rl_engine.kernels.gtest.gradient_adapters import ( # noqa: E402 GRADIENT_ADAPTERS, get_adapter, @@ -42,11 +49,7 @@ def _object_path(value: Any) -> str: def _candidate_family(candidate: str) -> str: - if candidate.startswith("cuda"): - return "cuda" - if candidate == "triton": - return "triton" - return candidate + return candidate_family(candidate) def _validate_candidate_selection( @@ -123,14 +126,18 @@ def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description="WS1 C4 gradient invariance GPU gate") parser.add_argument("--op", choices=sorted(runnable), default="rms_norm") parser.add_argument( - "--candidate", required=True, help="Manifest-declared CUDA/Triton candidate" + "--candidate", required=True, help="Manifest-declared CUDA/Triton/Ascend candidate" ) parser.add_argument( "--backend-profile", - choices=("cuda_bf16", "triton_cuda_bf16"), + choices=("cuda_bf16", "triton_cuda_bf16", "ascend_bf16"), required=True, ) - parser.add_argument("--device", default="cuda") + parser.add_argument( + "--device", + default=None, + help="Defaults to the backend profile's own accelerator (cuda or npu).", + ) parser.add_argument("--hidden", type=int, default=64) parser.add_argument("--vocab", type=int, default=256) # Real BI kernels constrain these: the deterministic CUDA attention accepts @@ -145,9 +152,10 @@ def parse_args() -> argparse.Namespace: def main() -> None: args = parse_args() - device = torch.device(args.device) - if device.type != "cuda" or not torch.cuda.is_available(): - raise SystemExit("ERROR: C4 required-profile evidence requires an available CUDA device") + try: + device = resolve_device(args.device, profile=args.backend_profile) + except RuntimeError as exc: + raise SystemExit(f"ERROR: C4 required-profile evidence needs a real device: {exc}") from exc contract = load_contract() manifest = load_manifest() @@ -168,12 +176,11 @@ def main() -> None: op_name=args.op, candidate=args.candidate, ) - cc_tuple = torch.cuda.get_device_capability(device) - cc = f"sm{cc_tuple[0]}{cc_tuple[1]}" + cc = arch_key(device) # Check the hardware before loading: an SM90 candidate raises a build-time # RuntimeError from the extension, which would bury the real reason under a # traceback instead of naming the unmet requirement. - if args.candidate == "cuda-sm90" and cc_tuple[0] != 9: + if args.candidate == "cuda-sm90" and cc != "sm90": raise SystemExit( f"ERROR: cuda-sm90 candidate requested on {cc} hardware; fallback forbidden. " "This cell needs a Hopper GPU with KERNEL_ALIGN_FORCE_SM90=1" @@ -183,8 +190,7 @@ def main() -> None: gold_fn = load_adapter_gold(args.op) policy = resolve_dtype_policy(contract) family = _candidate_family(args.candidate) - torch.backends.cuda.matmul.allow_tf32 = False - torch.backends.cudnn.allow_tf32 = False + tf32_enabled = disable_tf32(device.type) provenance = BackendProvenance( backend_profile=args.backend_profile, @@ -194,8 +200,8 @@ def main() -> None: accumulation_dtype=policy.accumulation_dtype, output_dtype=policy.output_dtype_default, reference_dtype=policy.reference_dtype, - candidate_tf32_enabled=torch.backends.cuda.matmul.allow_tf32, - reference_tf32_enabled=torch.backends.cuda.matmul.allow_tf32, + candidate_tf32_enabled=tf32_enabled, + reference_tf32_enabled=tf32_enabled, ) kernel_id = _object_path(candidate_op) shape_kwargs = { @@ -234,7 +240,7 @@ def main() -> None: dtype=torch.bfloat16, op_name=args.op, candidate_id=f"{kernel_id}::{resolved.get('expected_backend_id')}", - device=f"{device}:{torch.cuda.get_device_name(device)}", + device=f"{device}:{device_name(device)}", compute_capability=cc, observed_actual_backend=family, observed_kernel_id=kernel_id, diff --git a/scripts/check_operator.py b/scripts/check_operator.py index 9dbca48d..fff1e4f7 100644 --- a/scripts/check_operator.py +++ b/scripts/check_operator.py @@ -35,12 +35,38 @@ def _parse_dtype(value: str) -> torch.dtype: raise ValueError(f"unsupported dtype: {value}") +def _npu_available() -> bool: + """torch.npu only exists after torch_npu is imported; probe defensively.""" + try: + import torch_npu # noqa: F401 + except ImportError: + return False + return hasattr(torch, "npu") and torch.npu.is_available() + + def _select_device(value: str) -> torch.device: if value == "auto": - return torch.device("cuda" if torch.cuda.is_available() else "cpu") + if torch.cuda.is_available(): + return torch.device("cuda") + if _npu_available(): + return torch.device("npu") + return torch.device("cpu") + if value == "npu" and not _npu_available(): + raise RuntimeError("--device npu was requested, but no Ascend NPU is available") device = torch.device(value) if device.type == "cuda" and not torch.cuda.is_available(): raise RuntimeError("--device cuda was requested, but CUDA is not available") + if device.type == "npu": + # torch.npu only exists after torch_npu is imported (mirrors the + # defensive probe in rl_engine.platforms.device). + try: + import torch_npu # noqa: F401 + except ImportError as exc: + raise RuntimeError( + "--device npu was requested, but torch_npu is not installed" + ) from exc + if not torch.npu.is_available(): + raise RuntimeError("--device npu was requested, but no NPU is available") return device @@ -73,7 +99,8 @@ def parse_args() -> argparse.Namespace: parser.add_argument( "--candidate", default="pytorch", - help="Candidate backend to validate, for example pytorch, cuda, cuda-sm90, triton.", + help="Candidate backend to validate, for example pytorch, cuda, cuda-sm90, triton, " + "ascend.", ) parser.add_argument("--dtype", choices=("fp32", "bf16", "fp16"), default="fp32") parser.add_argument("--device", default="auto") diff --git a/scripts/check_stateful_kv.py b/scripts/check_stateful_kv.py index f938ab0f..345093c8 100755 --- a/scripts/check_stateful_kv.py +++ b/scripts/check_stateful_kv.py @@ -11,12 +11,14 @@ import pathlib import sys -import torch - REPO_ROOT = pathlib.Path(__file__).resolve().parents[1] if str(REPO_ROOT) not in sys.path: sys.path.insert(0, str(REPO_ROOT)) +from rl_engine.kernels.gtest.accelerator import ( # noqa: E402 + disable_tf32, + resolve_device, +) from rl_engine.kernels.gtest.kv_consistency import ( # noqa: E402 B2_PRODUCTION_KV_STATUS, assert_stateful_kv_consistent, @@ -29,25 +31,33 @@ def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description="WS1 C7 stateful KV + generate-rescore gate") parser.add_argument( "--backend-profile", - choices=("cuda_bf16", "triton_cuda_bf16"), + choices=("cuda_bf16", "triton_cuda_bf16", "ascend_bf16"), required=True, ) parser.add_argument("--candidate", default=None) + parser.add_argument( + "--device", + default=None, + help="Defaults to the backend profile's own accelerator (cuda or npu).", + ) parser.add_argument("--json", action="store_true") return parser.parse_args() def main() -> int: args = parse_args() - if not torch.cuda.is_available(): - print("ERROR: C7 declared-candidate gate requires CUDA", file=sys.stderr) + try: + device = resolve_device(args.device, profile=args.backend_profile) + except RuntimeError as exc: + print(f"ERROR: C7 declared-candidate gate needs a real device: {exc}", file=sys.stderr) return 2 - torch.backends.cuda.matmul.allow_tf32 = False + disable_tf32(device.type) report = assert_stateful_kv_consistent( backend_profile=args.backend_profile, candidate=args.candidate, contract=load_contract(), manifest=load_manifest(), + device=device, require_declared_candidate=True, ) if args.json: diff --git a/scripts/sweep_gradient_invariance.py b/scripts/sweep_gradient_invariance.py index 7099cf5d..6d1b36d4 100644 --- a/scripts/sweep_gradient_invariance.py +++ b/scripts/sweep_gradient_invariance.py @@ -39,7 +39,7 @@ from rl_engine.testing.ws1_workload import load_manifest # noqa: E402 GATE = REPO_ROOT / "scripts" / "check_gradient_invariance.py" -PROFILES = ("cuda_bf16", "triton_cuda_bf16") +PROFILES = ("cuda_bf16", "triton_cuda_bf16", "ascend_bf16") @dataclass diff --git a/scripts/sweep_ws1_four_judgments.py b/scripts/sweep_ws1_four_judgments.py index 8625111c..2859861e 100644 --- a/scripts/sweep_ws1_four_judgments.py +++ b/scripts/sweep_ws1_four_judgments.py @@ -17,7 +17,7 @@ import subprocess import sys from collections import defaultdict -from typing import Any +from typing import Any, Sequence REPO_ROOT = pathlib.Path(__file__).resolve().parents[1] if str(REPO_ROOT) not in sys.path: @@ -134,12 +134,12 @@ def _is_hopper() -> bool: return bool(torch.cuda.is_available() and torch.cuda.get_device_capability(0)[0] == 9) -def _execute_matrix(base: MatrixReport) -> MatrixReport: +def _execute_matrix(base: MatrixReport, profiles: Sequence[str] = PROFILES) -> MatrixReport: manifest = load_manifest() if _is_hopper(): - base = build_classified_matrix(manifest, allow_sm90=True) + base = build_classified_matrix(manifest, allow_sm90=True, profiles=profiles) invariance: dict[tuple[str, str], dict[str, tuple[str, str, dict[str, str] | None]]] = {} - for profile in PROFILES: + for profile in profiles: for op_name in C8_REQUIRED_OPS: sample = next( cell for cell in base.cells if cell.profile == profile and cell.op_name == op_name @@ -275,9 +275,26 @@ def _environment() -> dict[str, Any]: try: import torch + from rl_engine.kernels.gtest.accelerator import ( + compute_capability, + device_name, + is_available, + npu_available, + ) + info["pytorch"] = torch.__version__ info["cuda_runtime"] = getattr(torch.version, "cuda", None) - if torch.cuda.is_available(): + if npu_available(): + npu = torch.device("npu", 0) + info["npu_name"] = device_name(npu) + info["npu_soc"] = compute_capability(npu) + try: + import torch_npu + + info["torch_npu"] = getattr(torch_npu, "__version__", "unknown") + except Exception: + info["torch_npu"] = None + if is_available("cuda"): info["gpu_name"] = torch.cuda.get_device_name(0) info["compute_capability"] = ".".join( str(x) for x in torch.cuda.get_device_capability(0) @@ -369,7 +386,16 @@ def main() -> None: parser.add_argument( "--execute", action="store_true", - help="Run C3/C4 on runnable cells (requires CUDA). Default is classify-only.", + help="Run C3/C4 on runnable cells (requires the profile's accelerator). " + "Default is classify-only.", + ) + parser.add_argument( + "--profile", + action="append", + choices=PROFILES, + help="Backend profile to cover; repeatable. Defaults to every required " + "profile. One host rarely has both a GPU and an NPU, so each vendor's " + "CI job passes its own profiles here and C11 needs all jobs green.", ) parser.add_argument("--json", action="store_true") parser.add_argument( @@ -379,9 +405,10 @@ def main() -> None: ) args = parser.parse_args() - report = build_classified_matrix() + profiles = tuple(args.profile) if args.profile else PROFILES + report = build_classified_matrix(profiles=profiles) if args.execute: - report = _execute_matrix(report) + report = _execute_matrix(report, profiles) if args.json: payload = _execute_payload(report) if args.execute else report.to_dict() print(json.dumps(payload, indent=2)) diff --git a/scripts/ws1_candidate_evidence.py b/scripts/ws1_candidate_evidence.py index 760823c7..22c33ca7 100755 --- a/scripts/ws1_candidate_evidence.py +++ b/scripts/ws1_candidate_evidence.py @@ -124,7 +124,7 @@ def run_case( grad_mode="random", grad_seed=seed + 1000, ) - torch.cuda.synchronize(device) + synchronize(device) candidate_report = report.candidates[0] output_checks = [ { @@ -158,16 +158,34 @@ def run_case( } +from rl_engine.kernels.gtest.accelerator import ( # noqa: E402 + arch_key, + device_name, + device_type_for_profile, + empty_cache, + is_available, + resolve_device, + runtime_version, + synchronize, +) + + def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( - description="Run manifest-pinned WS1 representative candidates on a real GPU." + description="Run manifest-pinned WS1 representative candidates on a real accelerator." ) parser.add_argument("--manifest", type=Path, default=None) parser.add_argument( "--profile", action="append", - choices=("cuda_bf16", "triton_cuda_bf16"), - help="Profile to run; repeatable. Defaults to both required profiles.", + choices=("cuda_bf16", "triton_cuda_bf16", "ascend_bf16"), + help="Profile to run; repeatable. Defaults to the profiles this host can run.", + ) + parser.add_argument( + "--device", + default=None, + help="Device to run on (e.g. cuda:0 or npu:0). Defaults to the selected " + "profiles' accelerator.", ) parser.add_argument("--case-id", action="append", help="Optional case_id filter.") parser.add_argument( @@ -189,13 +207,34 @@ def build_parser() -> argparse.ArgumentParser: def main(argv: list[str] | None = None) -> int: args = build_parser().parse_args(argv) - if not torch.cuda.is_available(): - print("error: CUDA is required for runtime candidate evidence", file=sys.stderr) - return 2 try: manifest = load_manifest(args.manifest) - profiles = set(args.profile or ("cuda_bf16", "triton_cuda_bf16")) + if args.profile: + profiles = set(args.profile) + else: + # One host has either a GPU or an NPU, never both; default to the + # profiles its accelerator can actually execute rather than + # reporting a fabricated pass for the other vendor. + profiles = { + name + for name in ("cuda_bf16", "triton_cuda_bf16", "ascend_bf16") + if is_available(device_type_for_profile(name)) + } + if not profiles: + print( + "error: no accelerator available for runtime candidate evidence", + file=sys.stderr, + ) + return 2 + device_types = {device_type_for_profile(name) for name in profiles} + if len(device_types) > 1: + print( + f"error: profiles {sorted(profiles)} span device types " + f"{sorted(device_types)}; run one device type per invocation", + file=sys.stderr, + ) + return 2 selected_ids = set(args.case_id or ()) default_families = {"gemm", "attention", "logprob"} cases = [ @@ -209,7 +248,7 @@ def main(argv: list[str] | None = None) -> int: if selected_ids - resolved_ids: unknown = sorted(selected_ids - resolved_ids) raise WorkloadError(f"unknown or profile-filtered case IDs: {unknown}") - device = torch.device("cuda:0") + device = resolve_device(args.device, profile=sorted(profiles)[0]) log_stream = sys.stderr if args.emit_json == "-" else sys.stdout with contextlib.redirect_stdout(log_stream): results = [] @@ -223,7 +262,7 @@ def main(argv: list[str] | None = None) -> int: check_grad=args.check_grad, ) ) - torch.cuda.empty_cache() + empty_cache(device.type) except RuntimeError as exc: message = str(exc) if "out of memory" not in message.lower(): @@ -232,8 +271,7 @@ def main(argv: list[str] | None = None) -> int: # candidate/reference pair. Preserve the case-level # evidence and continue; this is a resource blocker, never # a pass or a silent fallback. - if torch.cuda.is_available(): - torch.cuda.empty_cache() + empty_cache(device.type) results.append( { "case_id": case["case_id"], @@ -252,7 +290,6 @@ def main(argv: list[str] | None = None) -> int: } ) fixture_identity_sha256 = manifest.raw["fixture_identity_sha256"] - props = torch.cuda.get_device_properties(device) payload = { "schema_version": "ws1-c2-runtime-provenance-v1", "workload_id": manifest.workload_id, @@ -260,14 +297,16 @@ def main(argv: list[str] | None = None) -> int: "execution_dtype": "bfloat16", "device": { "index": device.index, - "name": props.name, - "compute_capability": f"sm{props.major}{props.minor}", + "type": device.type, + "name": device_name(device), + "compute_capability": arch_key(device), "execution_world_size": 1, }, "software": { "python": platform.python_version(), "torch": torch.__version__, "cuda_runtime": torch.version.cuda, + "accelerator_runtime": runtime_version(device.type), }, "profiles": sorted(profiles), "passed": bool(results) diff --git a/scripts/ws1_chain_fwd_bwd.py b/scripts/ws1_chain_fwd_bwd.py index 3f2a79c1..06701fd1 100755 --- a/scripts/ws1_chain_fwd_bwd.py +++ b/scripts/ws1_chain_fwd_bwd.py @@ -23,6 +23,12 @@ sys.path.insert(0, str(REPO_ROOT)) from rl_engine.alignment.qwen3_dense import Qwen3DenseSpec # noqa: E402 +from rl_engine.kernels.gtest.accelerator import ( # noqa: E402 + compute_capability, + disable_tf32, + manual_seed_all, + resolve_device, +) from rl_engine.kernels.gtest.chain_gate import build_model # noqa: E402 from rl_engine.testing.ws1_workload import ( # noqa: E402 apply_padding, @@ -35,9 +41,14 @@ def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description="WS1 C9 full Qwen3-8B Dense fwd+bwd") parser.add_argument( "--backend-profile", - choices=("cuda_bf16", "triton_cuda_bf16"), + choices=("cuda_bf16", "triton_cuda_bf16", "ascend_bf16"), required=True, ) + parser.add_argument( + "--device", + default=None, + help="Defaults to the backend profile's own accelerator (cuda or npu).", + ) parser.add_argument("--dtype", default="bfloat16", choices=("bfloat16",)) parser.add_argument("--seed", type=int, default=None) parser.add_argument( @@ -53,16 +64,16 @@ def parse_args() -> argparse.Namespace: def main() -> int: args = parse_args() - if not torch.cuda.is_available(): - print("ERROR: C9 fwd+bwd requires CUDA", file=sys.stderr) + try: + device = resolve_device(args.device, profile=args.backend_profile) + except RuntimeError as exc: + print(f"ERROR: C9 fwd+bwd needs a real device: {exc}", file=sys.stderr) return 2 - torch.backends.cuda.matmul.allow_tf32 = False + disable_tf32(device.type) manifest = load_manifest() execution_seed = manifest.seed if args.seed is None else int(args.seed) - torch.manual_seed(execution_seed) - torch.cuda.manual_seed_all(execution_seed) + manual_seed_all(device.type, execution_seed) spec = Qwen3DenseSpec.from_manifest(manifest) - device = torch.device("cuda") log_stream = sys.stderr if args.json else sys.stdout with contextlib.redirect_stdout(log_stream): model = build_model( @@ -107,7 +118,7 @@ def main() -> int: "provenance": model.profile_ops.provenance, "runtime_backend_observations": (model.profile_ops.validated_runtime_observations()), "device": str(device), - "cc": ".".join(str(x) for x in torch.cuda.get_device_capability(0)), + "cc": compute_capability(device), "seed": execution_seed, "workload_seed": manifest.seed, "git_sha": subprocess.check_output( diff --git a/scripts/ws1_chain_gate.py b/scripts/ws1_chain_gate.py index 9590c604..d8a41f3e 100755 --- a/scripts/ws1_chain_gate.py +++ b/scripts/ws1_chain_gate.py @@ -24,6 +24,10 @@ if str(REPO_ROOT) not in sys.path: sys.path.insert(0, str(REPO_ROOT)) +from rl_engine.kernels.gtest.accelerator import ( # noqa: E402 + disable_tf32, + resolve_device, +) from rl_engine.kernels.gtest.chain_gate import ( # noqa: E402 build_model, run_chain_gate, @@ -37,9 +41,14 @@ def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description="WS1 C10/C11 full-model chain gate") parser.add_argument( "--backend-profile", - choices=("cuda_bf16", "triton_cuda_bf16"), + choices=("cuda_bf16", "triton_cuda_bf16", "ascend_bf16"), required=True, ) + parser.add_argument( + "--device", + default=None, + help="Defaults to the backend profile's own accelerator (cuda or npu).", + ) parser.add_argument("--model", default="qwen3-8b-dense", choices=("qwen3-8b-dense",)) parser.add_argument("--dtype", default="bfloat16", choices=("bfloat16",)) parser.add_argument( @@ -83,9 +92,12 @@ def _file_sha(path: pathlib.Path) -> str: def main() -> int: args = parse_args() - if not torch.cuda.is_available(): + try: + device = resolve_device(args.device, profile=args.backend_profile) + except RuntimeError as exc: print( - "ERROR: C10/C11 full-model gate requires CUDA; CPU-only is not a pass", + f"ERROR: C10/C11 full-model gate needs a real device; " + f"CPU-only is not a pass: {exc}", file=sys.stderr, ) return 2 @@ -95,12 +107,11 @@ def main() -> int: file=sys.stderr, ) return 2 - torch.backends.cuda.matmul.allow_tf32 = False + disable_tf32(device.type) manifest = load_manifest() contract = load_contract() execution_seed = manifest.seed if args.seed is None else int(args.seed) log_stream = sys.stderr if args.json else sys.stdout - device = torch.device("cuda") with contextlib.redirect_stdout(log_stream): reference_cell = run_fp32_reference_cell( backend_profile=args.backend_profile, diff --git a/setup.py b/setup.py index 542b4077..aef36c9a 100644 --- a/setup.py +++ b/setup.py @@ -1,444 +1,493 @@ -# SPDX-License-Identifier: Apache-2.0 -# Copyright (c) 2026 RL-Kernel Contributors - -import importlib.util -import os -import sysconfig -import warnings -from distutils.errors import CompileError -from distutils.spawn import find_executable -from pathlib import Path - -from setuptools import Extension, find_packages, setup - - -def _load_envs_module(): - envs_path = Path(__file__).with_name("envs.py") - spec = importlib.util.spec_from_file_location("_rl_kernel_envs", envs_path) - if spec is None or spec.loader is None: - raise RuntimeError(f"failed to load environment helpers from {envs_path}") - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - return module - - -envs = _load_envs_module() - - -def _load_torch_extension_tools(): - try: - import torch - except ModuleNotFoundError as exc: - if exc.name != "torch": - raise - return None, None, None - - from torch.utils.cpp_extension import BuildExtension, CUDAExtension - - # CUDAExtension is also the supported extension entry point for ROCm - # PyTorch builds. BuildExtension dispatches .cu/.hip sources to hipcc when - # torch.version.hip is set. - return torch, BuildExtension, CUDAExtension - - -def _native_extension_required() -> bool: - """Whether the caller explicitly requested a native extension build.""" - return ( - envs.env_flag(envs.RL_KERNEL_REQUIRE_EXT) - or bool(os.environ.get("PYTORCH_ROCM_ARCH", "").strip()) - or bool(os.environ.get("TORCH_CUDA_ARCH_LIST", "").strip()) - or envs.env_flag("FORCE_CUDA") - ) - - -def _cuda_define_from_env(name: str, macro: str) -> list[str]: - value = os.environ.get(name) - if value is None: - return [] - parsed = int(value) - if parsed <= 0: - raise ValueError(f"{name} must be positive, got {value!r}") - return [f"-D{macro}={parsed}"] - - -_ROCM_UNSUPPORTED_NVCC_FLAG_PREFIXES = ( - "-Xfatbin", - "-compress-all", - "-gencode", - "--generate-code", - "--expt-", - "-lineinfo", - "-allow-unsupported-compiler", - "-D_ALLOW_COMPILER_AND_STL_VERSION_MISMATCH", -) -_ROCM_NVCC_FLAGS_WITH_SEPARATE_VALUE = { - "-Xfatbin", - "-gencode", - "--generate-code", -} - - -def _filter_rocm_incompatible_nvcc_flags(flags: list[str]) -> list[str]: - """Remove CUDA-only device compiler flags before BuildExtension calls hipcc.""" - filtered_flags = [] - skip_next = False - for flag in flags: - if skip_next: - skip_next = False - continue - if flag in _ROCM_NVCC_FLAGS_WITH_SEPARATE_VALUE: - skip_next = True - continue - if flag.startswith(_ROCM_UNSUPPORTED_NVCC_FLAG_PREFIXES): - continue - filtered_flags.append(flag) - return filtered_flags - - -def get_extensions(): - torch, _, CUDAExtension = _load_torch_extension_tools() - if torch is None: - message = ( - "PyTorch is unavailable, so rl_engine._C cannot be built. Install a matching " - "CUDA/ROCm PyTorch build first, then run " - "`RL_KERNEL_REQUIRE_EXT=1 python -m pip install --no-build-isolation -e .`." - ) - if _native_extension_required(): - raise RuntimeError(message) - warnings.warn( - f"{message} Continuing with the pure-Python fallback because no native extension " - "was explicitly requested.", - RuntimeWarning, - stacklevel=2, - ) - return [] - - extensions = [] - torch_lib_dir = os.path.join(os.path.dirname(torch.__file__), "lib") - torch_rpath = ["-Wl,-rpath,$ORIGIN/../torch/lib"] - if os.environ.get("KERNEL_ALIGN_DEV_RPATH") == "1": - torch_rpath.append(f"-Wl,-rpath,{torch_lib_dir}") - is_rocm = getattr(torch.version, "hip", None) is not None - - # CUDAExtension is intentionally used for both CUDA and ROCm. On ROCm, - # PyTorch's BuildExtension hipifies CUDA sources and invokes hipcc; it also - # consumes PYTORCH_ROCM_ARCH (one or more ';'-separated gfx targets) to add - # --offload-arch. Do not require a visible GPU when a ROCm target was - # explicitly selected. - no_rocm_arch = not os.environ.get("PYTORCH_ROCM_ARCH", "").strip() - if is_rocm and no_rocm_arch and torch.cuda.device_count() == 0: - raise RuntimeError( - "ROCm builds without a visible GPU require PYTORCH_ROCM_ARCH. " - "Set one or more ';'-separated targets, for example " - "PYTORCH_ROCM_ARCH='gfx942;gfx950'." - ) - - if is_rocm or torch.cuda.is_available(): - cuda_sources = [ - "csrc/ops.cpp", - "csrc/fused_logp_kernel.cu", - "csrc/deterministic_logp_kernel.cu", - "csrc/cuda/gemm/det_gemm_kernel.cu", - "csrc/cuda/rmsnorm.cu", - "csrc/cuda/activation.cu", - "csrc/cuda/attention/deterministic_attention.cu", - ] - if is_rocm: - # ROCm-tuned WS2 vocab-parallel logprob kernels; the shared - # deterministic_logp_kernel.cu keeps the SM90-tuned CUDA path. - cuda_sources.extend( - [ - "csrc/hip/hip_deterministic_logp_kernel.hip", - "csrc/rocm/distributed/deterministic_collective.hip", - ] - ) - else: - # CUDA IPC and the fixed-tree collective implementation are not - # part of the ROCm extension. - cuda_sources.append("csrc/cuda/distributed/deterministic_collective.cu") - # This source contains NVIDIA PTX (cp.async, ldmatrix, and mma.sync). - # The ROCm dispatcher falls back to PyTorch SDPA for this operator. - cuda_sources.append("csrc/cuda/attention/prefix_shared_attention.cu") - - nvcc_flags = ["-O3", "-Xfatbin", "-compress-all"] - if envs.env_flag(envs.KERNEL_ALIGN_USE_FAST_MATH): - nvcc_flags.append("--use_fast_math") - if not is_rocm: - cc_major, cc_minor = torch.cuda.get_device_capability() - enable_sm90 = os.environ.get("KERNEL_ALIGN_FORCE_SM90") == "1" - if not enable_sm90: - # SM90 build emits 90a below; mixing plain compute_90 breaks TMA ptxas. - nvcc_flags.append( - f"-gencode=arch=compute_{cc_major}{cc_minor},code=sm_{cc_major}{cc_minor}" - ) - nvcc_flags.append("--expt-relaxed-constexpr") - nvcc_flags.append("--expt-extended-lambda") - nvcc_flags.extend( - _cuda_define_from_env( - "FUSED_LOGP_TWOPASS_BLOCK_SIZE", - "FUSED_LOGP_TWOPASS_BLOCK_SIZE", - ) - ) - nvcc_flags.extend( - _cuda_define_from_env( - "FUSED_LOGP_ONLINE_BLOCK_SIZE", - "FUSED_LOGP_ONLINE_BLOCK_SIZE", - ) - ) - nvcc_flags.extend( - _cuda_define_from_env( - "FUSED_LOGP_ONLINE_SPARSE_LARGE_VOCAB_BLOCK_SIZE", - "FUSED_LOGP_ONLINE_SPARSE_LARGE_VOCAB_BLOCK_SIZE", - ) - ) - nvcc_flags.extend( - _cuda_define_from_env( - "FUSED_LOGP_ONLINE_LARGE_ROW_BYTES_THRESHOLD", - "FUSED_LOGP_ONLINE_LARGE_ROW_BYTES_THRESHOLD", - ) - ) - nvcc_flags.extend( - _cuda_define_from_env( - "FUSED_LOGP_ONLINE_SPARSE_DENSITY_NUMERATOR", - "FUSED_LOGP_ONLINE_SPARSE_DENSITY_NUMERATOR", - ) - ) - nvcc_flags.extend( - _cuda_define_from_env( - "FUSED_LOGP_ONLINE_SPARSE_DENSITY_DENOMINATOR", - "FUSED_LOGP_ONLINE_SPARSE_DENSITY_DENOMINATOR", - ) - ) - nvcc_flags.extend( - _cuda_define_from_env( - "FUSED_LOGP_ONLINE_MIN_BLOCKS_PER_SM", - "FUSED_LOGP_ONLINE_MIN_BLOCKS_PER_SM", - ) - ) - if is_rocm: - for tile_knob in ( - "DETERMINISTIC_LOGP_TILE_BLOCK_SIZE", - "DETERMINISTIC_LOGP_TILE_VECTOR_ELEMENTS", - "DETERMINISTIC_LOGP_BACKWARD_BLOCK_SIZE", - ): - nvcc_flags.extend(_cuda_define_from_env(tile_knob, tile_knob)) - else: - # Same idea for the CUDA tile-stats kernel: the defaults are tuned for - # sm_90, and a different architecture or vocabulary split may prefer - # another block size or vector width. - for tile_knob in ( - "DETERMINISTIC_LOGP_TILE_BLOCK_SIZE_NARROW", - "DETERMINISTIC_LOGP_TILE_BLOCK_SIZE_WIDE", - "DETERMINISTIC_LOGP_TILE_VECTOR_BYTES", - ): - nvcc_flags.extend(_cuda_define_from_env(tile_knob, tile_knob)) - if not is_rocm and envs.env_flag(envs.KERNEL_ALIGN_NCU_LINEINFO): - nvcc_flags.append("-lineinfo") - if ( - not is_rocm - and os.name == "nt" - and envs.env_flag(envs.KERNEL_ALIGN_ALLOW_UNSUPPORTED_MSVC) - ): - nvcc_flags.append("-allow-unsupported-compiler") - nvcc_flags.append("-D_ALLOW_COMPILER_AND_STL_VERSION_MISMATCH") - - platform_define = "-DKERNEL_ALIGN_WITH_ROCM" if is_rocm else "-DKERNEL_ALIGN_WITH_CUDA" - cxx_flags = ["-O3", "-std=c++17", platform_define] - extra_link_args = list(torch_rpath) - if os.name != "nt" and not is_rocm: - # CUDA IPC metadata queries use the driver API (cuPointerGetAttribute). - extra_link_args.append("-lcuda") - - if not is_rocm: - sm90_srcs = [ - "csrc/cuda/fused_logp_sm90.cu", - "csrc/cuda/fused_linear_logp_sm90.cu", # TMA + WGMMA fused linear log-prob - "csrc/cuda/batch_invariant_logp_kernel_sm90.cu", # TMA batch-invariant logp - "csrc/cuda/rope_sm90.cu", # RoPE rotate-half apply, gated to SM90 build - # Single-card batch-invariant embedding/lm-head. - "csrc/cuda/embedding_lm_head_sm90.cu", - ] - enable_sm90 = envs.env_flag(envs.KERNEL_ALIGN_FORCE_SM90) - present_sm90 = [s for s in sm90_srcs if os.path.exists(s)] - if enable_sm90 and present_sm90: - tma_arch = f"{cc_major}{cc_minor}a" # WGMMA/TMA require the arch-native 'a' variant - cuda_sources.extend(present_sm90) - nvcc_flags.append(f"-gencode=arch=compute_{tma_arch},code=sm_{tma_arch}") - cxx_flags.append("-DKERNEL_ALIGN_WITH_SM90") - if "-lcuda" not in extra_link_args: - extra_link_args.append("-lcuda") - - # det_gemm SM90 (mma.sync + TMA) path: independent of the fused_logp - # SM90 sources, which currently fail ptxas on CUDA 12.4 (shared::cta in - # the shared tma_utils.cuh). det_gemm uses its own gemm/det_gemm_tma.cuh. - enable_det_gemm_sm90 = os.environ.get("KERNEL_ALIGN_DET_GEMM_SM90") == "1" - if enable_det_gemm_sm90: - tma_arch = f"{cc_major}{cc_minor}a" - arch_flag = f"-gencode=arch=compute_{tma_arch},code=sm_{tma_arch}" - if arch_flag not in nvcc_flags: - nvcc_flags.append(arch_flag) - if "-lcuda" not in extra_link_args: - extra_link_args.append("-lcuda") - nvcc_flags.append("-DRL_KERNEL_ENABLE_SM90") - cxx_flags.append("-DRL_KERNEL_ENABLE_SM90") - - if is_rocm: - nvcc_flags = _filter_rocm_incompatible_nvcc_flags(nvcc_flags) - - extensions.append( - CUDAExtension( - name="rl_engine._C", - sources=cuda_sources, - include_dirs=[], - extra_compile_args={ - "cxx": cxx_flags, - "nvcc": nvcc_flags, - }, - extra_link_args=extra_link_args, - ) - ) - - extensions.extend(_ascend_extensions()) - - if _native_extension_required() and not extensions: - raise RuntimeError( - "rl_engine._C was requested but no CUDA/ROCm build environment is available. " - "Use a matching GPU-enabled PyTorch build; for a GPU-less ROCm build, set " - "PYTORCH_ROCM_ARCH to the target architecture." - ) - - return extensions - - -def _ascend_extensions(): - """Ascend C (CANN) kernels, built with bisheng. Gated on KERNEL_ALIGN_FORCE_ASCEND=1. - - Follows the official torch_npu cpp_extension_asc pattern: .asc sources - (kernel + host + pybind) are compiled by the CANN bisheng compiler into a - single rl_engine._C_npu extension module. Requires CANN toolkit (bisheng on - PATH or ASCEND_HOME_PATH set) and torch_npu. - """ - if not envs.env_flag(envs.KERNEL_ALIGN_FORCE_ASCEND): - return [] - try: - import torch # noqa: F401 - import torch_npu # noqa: F401 - except ImportError as e: - raise RuntimeError( - "KERNEL_ALIGN_FORCE_ASCEND=1 requires torch and torch_npu to be installed" - ) from e - - asc_srcs = sorted(str(p) for p in Path("csrc/ascend").glob("*.asc")) - if not asc_srcs: - raise RuntimeError("KERNEL_ALIGN_FORCE_ASCEND=1 but no .asc sources under csrc/ascend/") - return [Extension(name="rl_engine._C_npu", sources=asc_srcs, language="asc")] - - -def _bisheng_compile_cmd(ext, ext_fullpath): - """Single-command bisheng build for an Ascend C extension (see op-plugin example).""" - import torch - import torch.utils.cpp_extension as cpp_extension - import torch_npu - - if find_executable("bisheng") is None: - raise RuntimeError( - "bisheng compiler not found on PATH; source the CANN toolkit environment first" - ) - - soc = os.environ.get(envs.KERNEL_ALIGN_ASCEND_ARCH, "dav-2201") # A2/A3; A5: dav-3510 - abi_value = "1" if torch._C._GLIBCXX_USE_CXX11_ABI else "0" - module_name = ext.name.rsplit(".", 1)[-1] - - torch_npu_dir = os.path.dirname(os.path.realpath(torch_npu.__file__)) - ascend_home = os.environ.get("ASCEND_HOME_PATH", "/usr/local/Ascend/ascend-toolkit/latest") - - include_dirs = [ - *cpp_extension.include_paths(), - sysconfig.get_config_var("INCLUDEPY"), - os.path.join(torch_npu_dir, "include"), - os.path.join(torch_npu_dir, "include", "third_party", "acl", "inc"), - os.path.join(ascend_home, "include"), - ] - lib_dirs = [ - sysconfig.get_config_var("LIBDIR"), - os.path.join(os.path.dirname(torch.__file__), "lib"), - os.path.join(torch_npu_dir, "lib"), - os.path.join(ascend_home, "lib64"), - ] - - cmd = [ - "bisheng", - "-x", - "asc", - f"--npu-arch={soc}", - "-shared", - "-fPIC", - "-std=c++17", - "-O2", - f"-D_GLIBCXX_USE_CXX11_ABI={abi_value}", - f"-DTORCH_EXTENSION_NAME={module_name}", - "-lascendcl", - "-ltorch_npu", - "-ltorch", - "-ltorch_cpu", - "-ltorch_python", - "-lc10", - *ext.sources, - "-o", - ext_fullpath, - ] - cmd += [f"-I{d}" for d in include_dirs if d] - cmd += [f"-L{d}" for d in lib_dirs if d] - return cmd - - -def get_cmdclass(): - _, BuildExtension, _ = _load_torch_extension_tools() - if BuildExtension is None: - return {} - - class AscendBuildExtension(BuildExtension): - """torch BuildExtension + bisheng path for language="asc" extensions.""" - - def build_extension(self, ext): - if getattr(ext, "language", None) != "asc": - super().build_extension(ext) - return - ext_fullpath = self.get_ext_fullpath(ext.name) - os.makedirs(os.path.dirname(ext_fullpath), exist_ok=True) - try: - self.spawn(_bisheng_compile_cmd(ext, ext_fullpath)) - except Exception as e: - raise CompileError(str(e)) from e - - return {"build_ext": AscendBuildExtension} - - -setup( - name="rl-engine", - version="0.1.0", - packages=find_packages(include=["rl_engine", "rl_engine.*"]), - install_requires=[ - "torch>=2.4.1", - "tabulate", - "numpy", - "accelerate", - "transformers==5.13.1", - ], - ext_modules=get_extensions(), - cmdclass=get_cmdclass(), - extras_require={ - "cuda": ["flashinfer"], - "rocm": ["aiter"], - "vllm": ["vllm>=0.6.0"], - "drift-viewer": ["Pillow>=10", "PySide6>=6.6"], - }, - entry_points={ - "console_scripts": [ - "rlk-drift-view=rl_engine.alignment.cross_config.drift_viewer:main", - ], - }, - python_requires=">=3.10", - include_package_data=True, - zip_safe=False, -) +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 RL-Kernel Contributors + +import importlib.util +import os +import sysconfig +import warnings +from distutils.errors import CompileError +from distutils.spawn import find_executable +from pathlib import Path + +from setuptools import Extension, find_packages, setup + + +def _load_envs_module(): + envs_path = Path(__file__).with_name("envs.py") + spec = importlib.util.spec_from_file_location("_rl_kernel_envs", envs_path) + if spec is None or spec.loader is None: + raise RuntimeError(f"failed to load environment helpers from {envs_path}") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +envs = _load_envs_module() + + +def _load_torch_extension_tools(): + try: + import torch + except ModuleNotFoundError as exc: + if exc.name != "torch": + raise + return None, None, None + + from torch.utils.cpp_extension import BuildExtension, CUDAExtension + + # CUDAExtension is also the supported extension entry point for ROCm + # PyTorch builds. BuildExtension dispatches .cu/.hip sources to hipcc when + # torch.version.hip is set. + return torch, BuildExtension, CUDAExtension + + +def _native_extension_required() -> bool: + """Whether the caller explicitly requested a native extension build.""" + return ( + envs.env_flag(envs.RL_KERNEL_REQUIRE_EXT) + or bool(os.environ.get("PYTORCH_ROCM_ARCH", "").strip()) + or bool(os.environ.get("TORCH_CUDA_ARCH_LIST", "").strip()) + or envs.env_flag("FORCE_CUDA") + ) + + +def _cuda_define_from_env(name: str, macro: str) -> list[str]: + value = os.environ.get(name) + if value is None: + return [] + parsed = int(value) + if parsed <= 0: + raise ValueError(f"{name} must be positive, got {value!r}") + return [f"-D{macro}={parsed}"] + + +_ROCM_UNSUPPORTED_NVCC_FLAG_PREFIXES = ( + "-Xfatbin", + "-compress-all", + "-gencode", + "--generate-code", + "--expt-", + "-lineinfo", + "-allow-unsupported-compiler", + "-D_ALLOW_COMPILER_AND_STL_VERSION_MISMATCH", +) +_ROCM_NVCC_FLAGS_WITH_SEPARATE_VALUE = { + "-Xfatbin", + "-gencode", + "--generate-code", +} + + +def _filter_rocm_incompatible_nvcc_flags(flags: list[str]) -> list[str]: + """Remove CUDA-only device compiler flags before BuildExtension calls hipcc.""" + filtered_flags = [] + skip_next = False + for flag in flags: + if skip_next: + skip_next = False + continue + if flag in _ROCM_NVCC_FLAGS_WITH_SEPARATE_VALUE: + skip_next = True + continue + if flag.startswith(_ROCM_UNSUPPORTED_NVCC_FLAG_PREFIXES): + continue + filtered_flags.append(flag) + return filtered_flags + + +def get_extensions(): + torch, _, CUDAExtension = _load_torch_extension_tools() + if torch is None: + message = ( + "PyTorch is unavailable, so rl_engine._C cannot be built. Install a matching " + "CUDA/ROCm PyTorch build first, then run " + "`RL_KERNEL_REQUIRE_EXT=1 python -m pip install --no-build-isolation -e .`." + ) + if _native_extension_required(): + raise RuntimeError(message) + warnings.warn( + f"{message} Continuing with the pure-Python fallback because no native extension " + "was explicitly requested.", + RuntimeWarning, + stacklevel=2, + ) + return [] + + extensions = [] + torch_lib_dir = os.path.join(os.path.dirname(torch.__file__), "lib") + torch_rpath = ["-Wl,-rpath,$ORIGIN/../torch/lib"] + if os.environ.get("KERNEL_ALIGN_DEV_RPATH") == "1": + torch_rpath.append(f"-Wl,-rpath,{torch_lib_dir}") + is_rocm = getattr(torch.version, "hip", None) is not None + + # CUDAExtension is intentionally used for both CUDA and ROCm. On ROCm, + # PyTorch's BuildExtension hipifies CUDA sources and invokes hipcc; it also + # consumes PYTORCH_ROCM_ARCH (one or more ';'-separated gfx targets) to add + # --offload-arch. Do not require a visible GPU when a ROCm target was + # explicitly selected. + no_rocm_arch = not os.environ.get("PYTORCH_ROCM_ARCH", "").strip() + if is_rocm and no_rocm_arch and torch.cuda.device_count() == 0: + raise RuntimeError( + "ROCm builds without a visible GPU require PYTORCH_ROCM_ARCH. " + "Set one or more ';'-separated targets, for example " + "PYTORCH_ROCM_ARCH='gfx942;gfx950'." + ) + + if is_rocm or torch.cuda.is_available(): + cuda_sources = [ + "csrc/ops.cpp", + "csrc/fused_logp_kernel.cu", + "csrc/deterministic_logp_kernel.cu", + "csrc/cuda/gemm/det_gemm_kernel.cu", + "csrc/cuda/rmsnorm.cu", + "csrc/cuda/activation.cu", + "csrc/cuda/attention/deterministic_attention.cu", + ] + if is_rocm: + # ROCm-tuned WS2 vocab-parallel logprob kernels; the shared + # deterministic_logp_kernel.cu keeps the SM90-tuned CUDA path. + cuda_sources.extend( + [ + "csrc/hip/hip_deterministic_logp_kernel.hip", + "csrc/rocm/distributed/deterministic_collective.hip", + ] + ) + else: + # CUDA IPC and the fixed-tree collective implementation are not + # part of the ROCm extension. + cuda_sources.append("csrc/cuda/distributed/deterministic_collective.cu") + # This source contains NVIDIA PTX (cp.async, ldmatrix, and mma.sync). + # The ROCm dispatcher falls back to PyTorch SDPA for this operator. + cuda_sources.append("csrc/cuda/attention/prefix_shared_attention.cu") + + nvcc_flags = ["-O3", "-Xfatbin", "-compress-all"] + if envs.env_flag(envs.KERNEL_ALIGN_USE_FAST_MATH): + nvcc_flags.append("--use_fast_math") + if not is_rocm: + cc_major, cc_minor = torch.cuda.get_device_capability() + enable_sm90 = os.environ.get("KERNEL_ALIGN_FORCE_SM90") == "1" + if not enable_sm90: + # SM90 build emits 90a below; mixing plain compute_90 breaks TMA ptxas. + nvcc_flags.append( + f"-gencode=arch=compute_{cc_major}{cc_minor},code=sm_{cc_major}{cc_minor}" + ) + nvcc_flags.append("--expt-relaxed-constexpr") + nvcc_flags.append("--expt-extended-lambda") + nvcc_flags.extend( + _cuda_define_from_env( + "FUSED_LOGP_TWOPASS_BLOCK_SIZE", + "FUSED_LOGP_TWOPASS_BLOCK_SIZE", + ) + ) + nvcc_flags.extend( + _cuda_define_from_env( + "FUSED_LOGP_ONLINE_BLOCK_SIZE", + "FUSED_LOGP_ONLINE_BLOCK_SIZE", + ) + ) + nvcc_flags.extend( + _cuda_define_from_env( + "FUSED_LOGP_ONLINE_SPARSE_LARGE_VOCAB_BLOCK_SIZE", + "FUSED_LOGP_ONLINE_SPARSE_LARGE_VOCAB_BLOCK_SIZE", + ) + ) + nvcc_flags.extend( + _cuda_define_from_env( + "FUSED_LOGP_ONLINE_LARGE_ROW_BYTES_THRESHOLD", + "FUSED_LOGP_ONLINE_LARGE_ROW_BYTES_THRESHOLD", + ) + ) + nvcc_flags.extend( + _cuda_define_from_env( + "FUSED_LOGP_ONLINE_SPARSE_DENSITY_NUMERATOR", + "FUSED_LOGP_ONLINE_SPARSE_DENSITY_NUMERATOR", + ) + ) + nvcc_flags.extend( + _cuda_define_from_env( + "FUSED_LOGP_ONLINE_SPARSE_DENSITY_DENOMINATOR", + "FUSED_LOGP_ONLINE_SPARSE_DENSITY_DENOMINATOR", + ) + ) + nvcc_flags.extend( + _cuda_define_from_env( + "FUSED_LOGP_ONLINE_MIN_BLOCKS_PER_SM", + "FUSED_LOGP_ONLINE_MIN_BLOCKS_PER_SM", + ) + ) + if is_rocm: + for tile_knob in ( + "DETERMINISTIC_LOGP_TILE_BLOCK_SIZE", + "DETERMINISTIC_LOGP_TILE_VECTOR_ELEMENTS", + "DETERMINISTIC_LOGP_BACKWARD_BLOCK_SIZE", + ): + nvcc_flags.extend(_cuda_define_from_env(tile_knob, tile_knob)) + else: + # Same idea for the CUDA tile-stats kernel: the defaults are tuned for + # sm_90, and a different architecture or vocabulary split may prefer + # another block size or vector width. + for tile_knob in ( + "DETERMINISTIC_LOGP_TILE_BLOCK_SIZE_NARROW", + "DETERMINISTIC_LOGP_TILE_BLOCK_SIZE_WIDE", + "DETERMINISTIC_LOGP_TILE_VECTOR_BYTES", + ): + nvcc_flags.extend(_cuda_define_from_env(tile_knob, tile_knob)) + if not is_rocm and envs.env_flag(envs.KERNEL_ALIGN_NCU_LINEINFO): + nvcc_flags.append("-lineinfo") + if ( + not is_rocm + and os.name == "nt" + and envs.env_flag(envs.KERNEL_ALIGN_ALLOW_UNSUPPORTED_MSVC) + ): + nvcc_flags.append("-allow-unsupported-compiler") + nvcc_flags.append("-D_ALLOW_COMPILER_AND_STL_VERSION_MISMATCH") + + platform_define = "-DKERNEL_ALIGN_WITH_ROCM" if is_rocm else "-DKERNEL_ALIGN_WITH_CUDA" + cxx_flags = ["-O3", "-std=c++17", platform_define] + extra_link_args = list(torch_rpath) + if os.name != "nt" and not is_rocm: + # CUDA IPC metadata queries use the driver API (cuPointerGetAttribute). + extra_link_args.append("-lcuda") + + if not is_rocm: + sm90_srcs = [ + "csrc/cuda/fused_logp_sm90.cu", + "csrc/cuda/fused_linear_logp_sm90.cu", # TMA + WGMMA fused linear log-prob + "csrc/cuda/batch_invariant_logp_kernel_sm90.cu", # TMA batch-invariant logp + "csrc/cuda/rope_sm90.cu", # RoPE rotate-half apply, gated to SM90 build + # Single-card batch-invariant embedding/lm-head. + "csrc/cuda/embedding_lm_head_sm90.cu", + ] + enable_sm90 = envs.env_flag(envs.KERNEL_ALIGN_FORCE_SM90) + present_sm90 = [s for s in sm90_srcs if os.path.exists(s)] + if enable_sm90 and present_sm90: + tma_arch = f"{cc_major}{cc_minor}a" # WGMMA/TMA require the arch-native 'a' variant + cuda_sources.extend(present_sm90) + nvcc_flags.append(f"-gencode=arch=compute_{tma_arch},code=sm_{tma_arch}") + cxx_flags.append("-DKERNEL_ALIGN_WITH_SM90") + if "-lcuda" not in extra_link_args: + extra_link_args.append("-lcuda") + + # det_gemm SM90 (mma.sync + TMA) path: independent of the fused_logp + # SM90 sources, which currently fail ptxas on CUDA 12.4 (shared::cta in + # the shared tma_utils.cuh). det_gemm uses its own gemm/det_gemm_tma.cuh. + enable_det_gemm_sm90 = os.environ.get("KERNEL_ALIGN_DET_GEMM_SM90") == "1" + if enable_det_gemm_sm90: + tma_arch = f"{cc_major}{cc_minor}a" + arch_flag = f"-gencode=arch=compute_{tma_arch},code=sm_{tma_arch}" + if arch_flag not in nvcc_flags: + nvcc_flags.append(arch_flag) + if "-lcuda" not in extra_link_args: + extra_link_args.append("-lcuda") + nvcc_flags.append("-DRL_KERNEL_ENABLE_SM90") + cxx_flags.append("-DRL_KERNEL_ENABLE_SM90") + + if is_rocm: + nvcc_flags = _filter_rocm_incompatible_nvcc_flags(nvcc_flags) + + extensions.append( + CUDAExtension( + name="rl_engine._C", + sources=cuda_sources, + include_dirs=[], + extra_compile_args={ + "cxx": cxx_flags, + "nvcc": nvcc_flags, + }, + extra_link_args=extra_link_args, + ) + ) + + extensions.extend(_ascend_extensions()) + + if _native_extension_required() and not extensions: + raise RuntimeError( + "rl_engine._C was requested but no CUDA/ROCm build environment is available. " + "Use a matching GPU-enabled PyTorch build; for a GPU-less ROCm build, set " + "PYTORCH_ROCM_ARCH to the target architecture." + ) + + return extensions + + +def _ascend_extensions(): + """Ascend C (CANN) kernels, built with bisheng. Gated on KERNEL_ALIGN_FORCE_ASCEND=1. + + Follows the official torch_npu cpp_extension_asc pattern: .asc sources + (kernel + host + pybind) are compiled by the CANN bisheng compiler into a + single rl_engine._C_npu extension module. Requires CANN toolkit (bisheng on + PATH or ASCEND_HOME_PATH set) and torch_npu. + """ + if not envs.env_flag(envs.KERNEL_ALIGN_FORCE_ASCEND): + return [] + try: + import torch # noqa: F401 + import torch_npu # noqa: F401 + except ImportError as e: + raise RuntimeError( + "KERNEL_ALIGN_FORCE_ASCEND=1 requires torch and torch_npu to be installed" + ) from e + + asc_srcs = sorted(str(p) for p in Path("csrc/ascend").glob("**/*.asc")) + if not asc_srcs: + raise RuntimeError("KERNEL_ALIGN_FORCE_ASCEND=1 but no .asc sources under csrc/ascend/") + sources: list[str] = asc_srcs + # Some kernels ship a C++ pybind host alongside the .asc sources; include + # it when present (compiled per-source by _bisheng_compile_cmd). + host_cpp = Path("csrc/ascend/npu_module.cpp") + if host_cpp.is_file(): + sources = [str(host_cpp), *asc_srcs] + return [Extension(name="rl_engine._C_npu", sources=sources, language="asc")] + + +def _bisheng_compile_cmd(ext, ext_fullpath): + """Single-command bisheng build for an Ascend C extension (see op-plugin example).""" + import torch + import torch.utils.cpp_extension as cpp_extension + import torch_npu + + if find_executable("bisheng") is None: + raise RuntimeError( + "bisheng compiler not found on PATH; source the CANN toolkit environment first" + ) + + soc = os.environ.get(envs.KERNEL_ALIGN_ASCEND_ARCH, "dav-2201") # A2/A3; A5: dav-3510 + abi_value = "1" if torch._C._GLIBCXX_USE_CXX11_ABI else "0" + module_name = ext.name.rsplit(".", 1)[-1] + + torch_npu_dir = os.path.dirname(os.path.realpath(torch_npu.__file__)) + ascend_home = os.environ.get("ASCEND_HOME_PATH", "/usr/local/Ascend/ascend-toolkit/latest") + + include_dirs = [ + *cpp_extension.include_paths(), + sysconfig.get_config_var("INCLUDEPY"), + os.path.join(torch_npu_dir, "include"), + os.path.join(torch_npu_dir, "include", "third_party", "acl", "inc"), + os.path.join(ascend_home, "include"), + ] + lib_dirs = [ + sysconfig.get_config_var("LIBDIR"), + os.path.join(os.path.dirname(torch.__file__), "lib"), + os.path.join(torch_npu_dir, "lib"), + os.path.join(ascend_home, "lib64"), + ] + + if any(not str(src).endswith(".asc") for src in ext.sources): + # Mixed extension (C++ pybind host + .asc kernels): the -x asc driver + # cannot compile C++, so compile each source to an object and link them + # in a second step. + import subprocess + import tempfile + + build_temp = tempfile.mkdtemp(prefix="rl_kernel_ascend_") + objects = [] + for src in ext.sources: + src = str(src) + obj = os.path.join(build_temp, Path(src).name + ".o") + src_cmd = [ + "bisheng", + "-std=c++17", + "-O2", + "-fPIC", + "-c", + f"-D_GLIBCXX_USE_CXX11_ABI={abi_value}", + f"-DTORCH_EXTENSION_NAME={module_name}", + ] + if src.endswith(".asc"): + src_cmd += ["-x", "asc", f"--npu-arch={soc}"] + src_cmd += [f"-I{d}" for d in include_dirs if d] + src_cmd += [src, "-o", obj] + subprocess.check_call(src_cmd) + objects.append(obj) + cmd = [ + "bisheng", + "-shared", + *objects, + "-lascendcl", + "-ltorch_npu", + "-ltorch", + "-ltorch_cpu", + "-ltorch_python", + "-lc10", + "-o", + ext_fullpath, + ] + cmd += [f"-L{d}" for d in lib_dirs if d] + return cmd + + cmd = [ + "bisheng", + "-x", + "asc", + f"--npu-arch={soc}", + "-shared", + "-fPIC", + "-std=c++17", + "-O2", + f"-D_GLIBCXX_USE_CXX11_ABI={abi_value}", + f"-DTORCH_EXTENSION_NAME={module_name}", + "-lascendcl", + "-ltorch_npu", + "-ltorch", + "-ltorch_cpu", + "-ltorch_python", + "-lc10", + *ext.sources, + "-o", + ext_fullpath, + ] + cmd += [f"-I{d}" for d in include_dirs if d] + cmd += [f"-L{d}" for d in lib_dirs if d] + return cmd + + +def get_cmdclass(): + _, BuildExtension, _ = _load_torch_extension_tools() + if BuildExtension is None: + return {} + + class AscendBuildExtension(BuildExtension): + """torch BuildExtension + bisheng path for language="asc" extensions.""" + + def build_extension(self, ext): + if getattr(ext, "language", None) != "asc": + super().build_extension(ext) + return + ext_fullpath = self.get_ext_fullpath(ext.name) + os.makedirs(os.path.dirname(ext_fullpath), exist_ok=True) + try: + self.spawn(_bisheng_compile_cmd(ext, ext_fullpath)) + except Exception as e: + raise CompileError(str(e)) from e + + return {"build_ext": AscendBuildExtension} + + +setup( + name="rl-engine", + version="0.1.0", + packages=find_packages(include=["rl_engine", "rl_engine.*"]), + install_requires=[ + "torch>=2.4.1", + "tabulate", + "numpy", + "accelerate", + "transformers==5.13.1", + ], + ext_modules=get_extensions(), + cmdclass=get_cmdclass(), + extras_require={ + "cuda": ["flashinfer"], + "rocm": ["aiter"], + "vllm": ["vllm>=0.6.0"], + "drift-viewer": ["Pillow>=10", "PySide6>=6.6"], + }, + entry_points={ + "console_scripts": [ + "rlk-drift-view=rl_engine.alignment.cross_config.drift_viewer:main", + ], + }, + python_requires=">=3.10", + include_package_data=True, + zip_safe=False, +) diff --git a/tests/distributed/test_deterministic_all_gather_ascend.py b/tests/distributed/test_deterministic_all_gather_ascend.py new file mode 100644 index 00000000..0428e1d9 --- /dev/null +++ b/tests/distributed/test_deterministic_all_gather_ascend.py @@ -0,0 +1,147 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 RL-Kernel Contributors + +"""Cross-TP deterministic all-gather on Ascend NPUs. + +Same contract as the CUDA test: each rank holds a rank-ordered dimension-0 +shard of a global tensor; the gather must reconstruct the global tensor +bitwise, identically on every rank, and repeatably. +""" + +from __future__ import annotations + +import os +import socket +from datetime import timedelta + +import pytest +import torch +import torch.distributed as dist +import torch.multiprocessing as mp + +from rl_engine.distributed import DeterministicCollective + +_MAX_WORLD_SIZE = 8 +_TP_SIZES = (1, 2, 4, 8) +_EXTERNAL_WORLD_SIZE = int(os.environ.get("WORLD_SIZE", "1")) + + +def _npu_visible_count() -> int: + try: + import torch_npu # noqa: F401 + + return torch.npu.device_count() + except Exception: + return 0 + + +def _ascend_extension_available() -> bool: + if _npu_visible_count() == 0: + return False + try: + from rl_engine import _C_npu + + return hasattr(_C_npu, "deterministic_collective_reduce") + except Exception: + return False + + +pytestmark = [ + pytest.mark.skipif( + _EXTERNAL_WORLD_SIZE != 1, + reason="this cross-TP test owns its worker processes; run pytest directly", + ), + pytest.mark.skipif( + _npu_visible_count() < _MAX_WORLD_SIZE, + reason="requires eight visible Ascend NPUs", + ), + pytest.mark.skipif( + not _ascend_extension_available(), + reason="deterministic_collective_reduce not compiled into _C_npu", + ), +] + + +def _all_gather_tensors( + value: torch.Tensor, + group: dist.ProcessGroup, + world_size: int, +) -> list[torch.Tensor]: + gathered = [torch.empty_like(value) for _ in range(world_size)] + dist.all_gather(gathered, value, group=group) + return gathered + + +def _make_global_input(device: torch.device, dtype: torch.dtype) -> torch.Tensor: + if dtype == torch.int64: + return torch.arange( + _MAX_WORLD_SIZE * 13 * 7, + device=device, + dtype=dtype, + ).reshape(_MAX_WORLD_SIZE * 13, 7) + generator = torch.Generator().manual_seed(20260817) + return torch.randn( + _MAX_WORLD_SIZE * 13, + 7, + dtype=torch.float32, + generator=generator, + ).to(device=device, dtype=dtype) + + +def _worker(rank: int, port: int) -> None: + torch.npu.set_device(rank) + device = torch.device("npu", rank) + dist.init_process_group( + backend="hccl", + init_method=f"tcp://127.0.0.1:{port}", + rank=rank, + world_size=_MAX_WORLD_SIZE, + timeout=timedelta(minutes=5), + ) + try: + groups = {tp_size: dist.new_group(ranks=list(range(tp_size))) for tp_size in _TP_SIZES} + for tp_size, group in groups.items(): + if rank < tp_size: + with DeterministicCollective( + group=group, + device=device, + max_size_bytes=1024 * 1024, + ) as collective: + group_rank = dist.get_rank(group=group) + for dtype in (torch.float32, torch.bfloat16, torch.int64): + expected = _make_global_input(device, dtype) + input = expected.chunk(tp_size, dim=0)[group_rank].contiguous() + + output = collective.all_gather(input) + assert torch.equal(output, expected) + + peer_outputs = _all_gather_tensors(output, group, tp_size) + assert all(torch.equal(peer_output, output) for peer_output in peer_outputs) + + baseline = output.clone() + for _ in range(3): + repeated = collective.all_gather(input) + assert torch.equal(repeated, baseline) + + provided = torch.empty_like(expected) + returned = collective.all_gather(input, out=provided) + assert returned is provided + assert torch.equal(provided, expected) + dist.barrier() + finally: + dist.destroy_process_group() + + +def _find_free_port() -> int: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.bind(("127.0.0.1", 0)) + return int(sock.getsockname()[1]) + + +def test_deterministic_all_gather_cross_tp_ascend() -> None: + mp.spawn( + _worker, + args=(_find_free_port(),), + nprocs=_MAX_WORLD_SIZE, + join=True, + ) diff --git a/tests/distributed/test_deterministic_all_reduce_ascend.py b/tests/distributed/test_deterministic_all_reduce_ascend.py new file mode 100644 index 00000000..c5fff0ab --- /dev/null +++ b/tests/distributed/test_deterministic_all_reduce_ascend.py @@ -0,0 +1,149 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 RL-Kernel Contributors + +"""Cross-TP deterministic all-reduce on Ascend NPUs. + +Same contract as the CUDA test: eight ranks reduce with nested prefixes of the +same balanced tree for TP sizes 1/2/4/8; the result must be the canonical +8-leaf tree sum, bitwise identical across ranks, repeatable, and safe when +``out`` aliases ``input``. +""" + +from __future__ import annotations + +import os +import socket +from datetime import timedelta + +import pytest +import torch +import torch.distributed as dist +import torch.multiprocessing as mp + +from rl_engine.distributed import DeterministicCollective + +_MAX_WORLD_SIZE = 8 +_TP_SIZES = (1, 2, 4, 8) +_EXTERNAL_WORLD_SIZE = int(os.environ.get("WORLD_SIZE", "1")) + + +def _npu_visible_count() -> int: + try: + import torch_npu # noqa: F401 + + return torch.npu.device_count() + except Exception: + return 0 + + +def _ascend_extension_available() -> bool: + if _npu_visible_count() == 0: + return False + try: + from rl_engine import _C_npu + + return hasattr(_C_npu, "deterministic_collective_reduce") + except Exception: + return False + + +pytestmark = [ + pytest.mark.skipif( + _EXTERNAL_WORLD_SIZE != 1, + reason="this cross-TP test owns its worker processes; run pytest directly", + ), + pytest.mark.skipif( + _npu_visible_count() < _MAX_WORLD_SIZE, + reason="requires eight visible Ascend NPUs", + ), + pytest.mark.skipif( + not _ascend_extension_available(), + reason="deterministic_collective_reduce not compiled into _C_npu", + ), +] + + +def _fixed_tree_reference(values: list[torch.Tensor]) -> torch.Tensor: + level = values + while len(level) > 1: + level = [level[index] + level[index + 1] for index in range(0, len(level), 2)] + return level[0] + + +def _all_gather_tensors( + value: torch.Tensor, + group: dist.ProcessGroup, + world_size: int, +) -> list[torch.Tensor]: + gathered = [torch.empty_like(value) for _ in range(world_size)] + dist.all_gather(gathered, value, group=group) + return gathered + + +def _worker(rank: int, port: int) -> None: + torch.npu.set_device(rank) + device = torch.device("npu", rank) + dist.init_process_group( + backend="hccl", + init_method=f"tcp://127.0.0.1:{port}", + rank=rank, + world_size=_MAX_WORLD_SIZE, + timeout=timedelta(minutes=5), + ) + try: + groups = {tp_size: dist.new_group(ranks=list(range(tp_size))) for tp_size in _TP_SIZES} + for tp_size, group in groups.items(): + if rank < tp_size: + with DeterministicCollective( + group=group, + device=device, + max_size_bytes=1024 * 1024, + ) as collective: + group_rank = dist.get_rank(group=group) + leaves_per_rank = _MAX_WORLD_SIZE // tp_size + start = group_rank * leaves_per_rank + for dtype in (torch.float32, torch.float16, torch.bfloat16): + generator = torch.Generator().manual_seed(20260815) + leaves_tensor = torch.randn( + _MAX_WORLD_SIZE, + 257, + dtype=torch.float32, + generator=generator, + ).to(device=device, dtype=dtype) + leaves = list(leaves_tensor.unbind()) + input = _fixed_tree_reference(leaves[start : start + leaves_per_rank]) + expected = _fixed_tree_reference(leaves) + + output = collective.all_reduce(input) + assert torch.equal(output, expected) + + peer_outputs = _all_gather_tensors(output, group, tp_size) + assert all(torch.equal(peer_output, output) for peer_output in peer_outputs) + + baseline = output.clone() + for _ in range(3): + repeated = collective.all_reduce(input) + assert torch.equal(repeated, baseline) + + inplace = input.clone() + returned = collective.all_reduce(inplace, out=inplace) + assert returned is inplace + assert torch.equal(inplace, expected) + dist.barrier() + finally: + dist.destroy_process_group() + + +def _find_free_port() -> int: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.bind(("127.0.0.1", 0)) + return int(sock.getsockname()[1]) + + +def test_deterministic_all_reduce_cross_tp_ascend() -> None: + mp.spawn( + _worker, + args=(_find_free_port(),), + nprocs=_MAX_WORLD_SIZE, + join=True, + ) diff --git a/tests/distributed/test_deterministic_reduce_scatter_ascend.py b/tests/distributed/test_deterministic_reduce_scatter_ascend.py new file mode 100644 index 00000000..cebab4cd --- /dev/null +++ b/tests/distributed/test_deterministic_reduce_scatter_ascend.py @@ -0,0 +1,151 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 RL-Kernel Contributors + +"""Cross-TP deterministic reduce-scatter on Ascend NPUs. + +Same contract as the CUDA test: eight ranks reduce with nested prefixes of the +same balanced tree for TP sizes 1/2/4/8; each rank's output must be the +corresponding shard of the canonical 8-leaf tree sum, bitwise identical, +repeatable, and safe when ``out`` is provided. +""" + +from __future__ import annotations + +import os +import socket +from datetime import timedelta + +import pytest +import torch +import torch.distributed as dist +import torch.multiprocessing as mp + +from rl_engine.distributed import DeterministicCollective + +_MAX_WORLD_SIZE = 8 +_TP_SIZES = (1, 2, 4, 8) +_EXTERNAL_WORLD_SIZE = int(os.environ.get("WORLD_SIZE", "1")) + + +def _npu_visible_count() -> int: + try: + import torch_npu # noqa: F401 + + return torch.npu.device_count() + except Exception: + return 0 + + +def _ascend_extension_available() -> bool: + if _npu_visible_count() == 0: + return False + try: + from rl_engine import _C_npu + + return hasattr(_C_npu, "deterministic_collective_reduce") + except Exception: + return False + + +pytestmark = [ + pytest.mark.skipif( + _EXTERNAL_WORLD_SIZE != 1, + reason="this cross-TP test owns its worker processes; run pytest directly", + ), + pytest.mark.skipif( + _npu_visible_count() < _MAX_WORLD_SIZE, + reason="requires eight visible Ascend NPUs", + ), + pytest.mark.skipif( + not _ascend_extension_available(), + reason="deterministic_collective_reduce not compiled into _C_npu", + ), +] + + +def _fixed_tree_reference(values: list[torch.Tensor]) -> torch.Tensor: + level = values + while len(level) > 1: + level = [level[index] + level[index + 1] for index in range(0, len(level), 2)] + return level[0] + + +def _all_gather_tensors( + value: torch.Tensor, + group: dist.ProcessGroup, + world_size: int, +) -> list[torch.Tensor]: + gathered = [torch.empty_like(value) for _ in range(world_size)] + dist.all_gather(gathered, value, group=group) + return gathered + + +def _worker(rank: int, port: int) -> None: + torch.npu.set_device(rank) + device = torch.device("npu", rank) + dist.init_process_group( + backend="hccl", + init_method=f"tcp://127.0.0.1:{port}", + rank=rank, + world_size=_MAX_WORLD_SIZE, + timeout=timedelta(minutes=5), + ) + try: + groups = {tp_size: dist.new_group(ranks=list(range(tp_size))) for tp_size in _TP_SIZES} + for tp_size, group in groups.items(): + if rank < tp_size: + with DeterministicCollective( + group=group, + device=device, + max_size_bytes=1024 * 1024, + ) as collective: + group_rank = dist.get_rank(group=group) + leaves_per_rank = _MAX_WORLD_SIZE // tp_size + start = group_rank * leaves_per_rank + for dtype in (torch.float32, torch.float16, torch.bfloat16): + generator = torch.Generator().manual_seed(20260816) + leaves_tensor = torch.randn( + _MAX_WORLD_SIZE, + _MAX_WORLD_SIZE * 17, + 19, + dtype=torch.float32, + generator=generator, + ).to(device=device, dtype=dtype) + leaves = list(leaves_tensor.unbind()) + input = _fixed_tree_reference(leaves[start : start + leaves_per_rank]) + reduced = _fixed_tree_reference(leaves) + expected = reduced.chunk(tp_size, dim=0)[group_rank] + + output = collective.reduce_scatter(input) + assert torch.equal(output, expected) + + peer_outputs = _all_gather_tensors(output, group, tp_size) + assert torch.equal(torch.cat(peer_outputs, dim=0), reduced) + + baseline = output.clone() + for _ in range(3): + repeated = collective.reduce_scatter(input) + assert torch.equal(repeated, baseline) + + provided = torch.empty_like(expected) + returned = collective.reduce_scatter(input, out=provided) + assert returned is provided + assert torch.equal(provided, expected) + dist.barrier() + finally: + dist.destroy_process_group() + + +def _find_free_port() -> int: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.bind(("127.0.0.1", 0)) + return int(sock.getsockname()[1]) + + +def test_deterministic_reduce_scatter_cross_tp_ascend() -> None: + mp.spawn( + _worker, + args=(_find_free_port(),), + nprocs=_MAX_WORLD_SIZE, + join=True, + ) diff --git a/tests/test_ascend_rmsnorm_backward_partition.py b/tests/test_ascend_rmsnorm_backward_partition.py new file mode 100644 index 00000000..d425eb68 --- /dev/null +++ b/tests/test_ascend_rmsnorm_backward_partition.py @@ -0,0 +1,168 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 RL-Kernel Contributors + +"""Backward partition regressions; CPU math checks plus real NPU execution. + +Only the Ascend forward extension is substituted on CPU. The production +canonical session, embedding reduction and RMSNorm backward run unchanged. +The NPU parametrization uses the compiled extension without substitutions. +""" + +from __future__ import annotations + +from types import SimpleNamespace + +import numpy as np +import pytest +import torch + +from rl_engine.kernels.ops.ascend.norm import rmsnorm as ascend_rms +from rl_engine.kernels.ops.canonical_backward import canonical_backward_session +from rl_engine.kernels.ops.canonical_embedding import canonical_embedding +from rl_engine.kernels.ops.canonical_rmsnorm import canonical_ascend_rmsnorm + + +@pytest.fixture(params=("cpu", "npu")) +def device(request, monkeypatch): + if request.param == "cpu": + + def forward(x, weight, rstd): + return (x.float() * rstd.unsqueeze(-1) * weight.float()).to(x.dtype) + + monkeypatch.setattr(ascend_rms, "_C_npu", SimpleNamespace(rmsnorm_ascend=forward)) + return torch.device("cpu") + + pytest.importorskip("torch_npu") + if not torch.npu.is_available(): + pytest.skip("NPU is unavailable") + from rl_engine import _C_npu + + # Import again after torch_npu has loaded its shared libraries. The + # module-level optional import may have run before device registration. + monkeypatch.setattr(ascend_rms, "_C_npu", _C_npu) + # If NPU is present, a missing extension is a failure, not a skip. + assert hasattr(ascend_rms._C_npu, "rmsnorm_ascend"), "build the Ascend extension first" + return torch.device("npu") + + +@pytest.mark.parametrize("hidden", (1, 33, 128, 4096)) +def test_row_sum_uses_fixed_fp32_pairs(device, hidden): + generator = torch.Generator().manual_seed(20260812) + values = torch.randn(5, hidden, generator=generator) + if hidden >= 4: + values[:, :4] = torch.tensor([1.0e20, 3.0, -1.0e20, 7.0]) + # Independent scalar FP32 oracle; also exercises odd-width tails. + expected = [] + for row in values.numpy(): + partial = list(row) + while len(partial) > 1: + pairs = [np.float32(partial[i] + partial[i + 1]) for i in range(0, len(partial) - 1, 2)] + if len(partial) % 2: + pairs.append(partial[-1]) + partial = pairs + expected.append(partial[0]) + actual = ascend_rms._fixed_row_sum(values.to(device)).cpu() + assert torch.equal(actual.view(torch.int32), torch.tensor(np.array(expected)).view(torch.int32)) + + # Accumulation must not inherit BF16 input precision. + low_precision = torch.tensor([[256.0, 1.0, 1.0, 1.0]], dtype=torch.bfloat16, device=device) + assert ascend_rms._fixed_row_sum(low_precision).item() == 259.0 + + +@pytest.mark.parametrize("hidden", (128, 4096)) +def test_backward_matches_independent_float64_autograd(device, hidden): + generator = torch.Generator().manual_seed(406) + x_cpu = torch.randn(5, hidden, generator=generator) + weight_cpu = torch.randn(hidden, generator=generator) + dy_cpu = torch.randn(5, hidden, generator=generator) + + x_ref = x_cpu.double().requires_grad_() + weight_ref = weight_cpu.double().requires_grad_() + y_ref = x_ref * torch.rsqrt(x_ref.square().mean(-1, keepdim=True) + 1e-6) * weight_ref + dx_ref, dw_ref = torch.autograd.grad(y_ref, (x_ref, weight_ref), dy_cpu.double()) + + x, weight, dy = (t.to(device) for t in (x_cpu, weight_cpu, dy_cpu)) + rstd = ascend_rms._fixed_rstd(x, 1e-6) + dx, dw = ascend_rms._rms_norm_backward(x, weight, rstd, dy) + torch.testing.assert_close(dx.cpu(), dx_ref.float(), atol=3e-6, rtol=3e-5) + torch.testing.assert_close(dw.cpu(), dw_ref.float(), atol=3e-6, rtol=3e-5) + + +@pytest.mark.parametrize("hidden", (128, 4096)) +@pytest.mark.parametrize("dtype", (torch.float32, torch.bfloat16)) +def test_canonical_embedding_and_norm_gradients_ignore_chunk_boundaries(device, hidden, dtype): + generator = torch.Generator().manual_seed(20260812) + # Match the observed 140-row workload and 7-row chunks. Repeated token + # IDs exercise embedding aggregation, not just disjoint scatter writes. + rows, vocab = 140, 17 + table = torch.randn(vocab, hidden, generator=generator).to(device=device, dtype=dtype) + weights = [ + torch.randn(hidden, generator=generator).to(device=device, dtype=dtype) for _ in range(2) + ] + upstream = torch.randn(rows, hidden, generator=generator).to(device=device, dtype=dtype) + ids = (torch.arange(rows, device=device) % vocab).long() + keys = torch.stack( + (torch.arange(rows, device=device) // 20, torch.arange(rows, device=device) % 20), dim=-1 + ) + # Masked rows may carry finite garbage but must never contribute to dW. + keys[19::20] = -1 + upstream[19::20] = 0 + + def run(partitions): + parameters = [table.detach().clone().requires_grad_()] + parameters.extend(w.detach().clone().requires_grad_() for w in weights) + output_by_row = torch.empty_like(upstream) + dx_by_row = torch.empty_like(upstream) + outputs, gradients, inputs = [], [], [] + with canonical_backward_session() as session: + for selection in partitions: + row_ids = ids.index_select(0, selection) + logical_keys = keys.index_select(0, selection) + x = canonical_embedding( + row_ids, + parameters[0], + logical_keys, + forward_op=lambda token_ids, weight: weight[token_ids], + family="ascend", + ) + x.retain_grad() + y = x + for layer, weight in enumerate(parameters[1:]): + y = canonical_ascend_rmsnorm( + y, + weight, + eps=1e-6, + logical_keys=logical_keys, + parameter_id=f"norm.{layer}", + ) + outputs.append(y) + gradients.append(upstream.index_select(0, selection)) + inputs.append((selection, x)) + output_by_row[selection] = y.detach() + torch.autograd.backward(outputs, gradients) + session.validate_complete() + for selection, x in inputs: + dx_by_row[selection] = x.grad + return [ + output_by_row.cpu(), + dx_by_row.cpu(), + *(parameter.grad.cpu() for parameter in parameters), + ] + + indices = torch.arange(rows, device=device) + expected = run([indices]) + variants = ( + list(indices.split(7)), + list(reversed(indices.split(7))), + list(torch.randperm(rows, generator=generator).to(device).split(11)), + ) + for partitions in variants: + actual = run(partitions) + for name, lhs, rhs in zip( + ("output", "dx", "embedding.dw", "norm.0.dw", "norm.1.dw"), + expected, + actual, + strict=True, + ): + bits = torch.int32 if dtype == torch.float32 else torch.int16 + assert torch.equal(lhs.view(bits), rhs.view(bits)), name diff --git a/tests/test_attention.py b/tests/test_attention.py index 40ee6fd5..3460562f 100644 --- a/tests/test_attention.py +++ b/tests/test_attention.py @@ -434,13 +434,19 @@ def test_gradient_matches_reference(): def test_registry_dispatches_native_attention_op(): - """Resolve attention to the deterministic CUDA op or native fallback.""" + """Resolve attention to the deterministic op of the active platform or native fallback.""" op = kernel_registry.get_op("attention") - # On CUDA with the extension built, the registry prefers DeterministicAttentionOp. - # On CPU or without the CUDA extension, it falls back to NativeAttentionOp. + # On CUDA with the extension built, the registry prefers DeterministicAttentionOp; + # on NPU with the Ascend extension, DeterministicAttentionAscendOp. On CPU or + # without the platform extension, it falls back to NativeAttentionOp. + from rl_engine.kernels.ops.ascend.attention.deterministic_attn import ( + DeterministicAttentionAscendOp, + ) from rl_engine.kernels.ops.cuda.attention.deterministic_attn import DeterministicAttentionOp - assert isinstance(op, (NativeAttentionOp, DeterministicAttentionOp)) + assert isinstance( + op, (NativeAttentionOp, DeterministicAttentionOp, DeterministicAttentionAscendOp) + ) def test_deterministic_attention_op_exposes_shared_strict_identity(): diff --git a/tests/test_attention_ascend.py b/tests/test_attention_ascend.py new file mode 100644 index 00000000..f3ff5cd2 --- /dev/null +++ b/tests/test_attention_ascend.py @@ -0,0 +1,306 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 RL-Kernel Contributors + +"""Tests for the Ascend NPU deterministic standard-softmax attention. + +Validates the same two orthogonal properties as the CUDA deterministic op: +1. **Correctness** - output matches the ``NativeAttentionOp.forward_fp32`` + ground truth within the reduction tolerances. +2. **Batch-invariance** - a query row's output is bitwise identical regardless + of batch size, batch position, or how many AI-core blocks were launched + (each row is reduced end-to-end by one block; no split-K merge exists). +""" + +import math + +import pytest +import torch + +from rl_engine.kernels.gtest.tolerance import load_contract, resolve_tolerance +from rl_engine.kernels.ops.pytorch.attention.standard_attn import NativeAttentionOp + +_D = 128 + +# Accuracy tolerance from the gtest contract, "attention" op class. +_ATOL = {torch.bfloat16: 5.0e-2, torch.float16: 1.0e-3} +_RTOL = {torch.bfloat16: 2.0e-2, torch.float16: 1.0e-3} + +_CONTRACT = load_contract() + + +def _grad_tol(dtype: torch.dtype) -> tuple[float, float]: + """C1 attention gradient_accuracy row -- no private per-kernel thresholds.""" + spec = resolve_tolerance( + _CONTRACT, judgment="gradient_accuracy", op_class="attention", dtype=dtype + ) + return spec.atol, spec.rtol + + +def _npu_available() -> bool: + try: + import torch_npu # noqa: F401 + except ImportError: + return False + return hasattr(torch, "npu") and torch.npu.is_available() + + +def _ascend_kernel_available() -> bool: + if not _npu_available(): + return False + try: + from rl_engine.kernels.ops.ascend.attention.deterministic_attn import ( + _NPU_EXT_AVAILABLE, + _C_npu, + ) + except Exception: + return False + return _NPU_EXT_AVAILABLE and hasattr(_C_npu, "deterministic_attention_ascend") + + +requires_ascend = pytest.mark.skipif( + not _ascend_kernel_available(), + reason="deterministic_attention_ascend kernel not compiled " + "(needs KERNEL_ALIGN_FORCE_ASCEND=1 on an Ascend NPU host).", +) + + +def _get_op(): + from rl_engine.kernels.ops.ascend.attention.deterministic_attn import ( + DeterministicAttentionAscendOp, + ) + + return DeterministicAttentionAscendOp() + + +def _gold(q, k, v, causal=True, scale=None, key_padding_mask=None): + """fp32 ground truth: NativeAttentionOp.forward_fp32.""" + return NativeAttentionOp().forward_fp32( + q, k, v, causal=causal, scale=scale, key_padding_mask=key_padding_mask + ) + + +def _make_qkv(batch, hq, hkv, sq, skv, dtype, seed=0): + # Independent generator per tensor: batch size must not shift the k/v + # content (a shared generator would make k[0] differ between batch sizes, + # breaking the batch-invariance comparisons below). + gq = torch.Generator(device="cpu").manual_seed(seed) + gk = torch.Generator(device="cpu").manual_seed(seed + 1) + gv = torch.Generator(device="cpu").manual_seed(seed + 2) + q = torch.randn(batch, hq, sq, _D, dtype=dtype, generator=gq).to("npu") + k = torch.randn(batch, hkv, skv, _D, dtype=dtype, generator=gk).to("npu") + v = torch.randn(batch, hkv, skv, _D, dtype=dtype, generator=gv).to("npu") + return q, k, v + + +# --------------------------------------------------------------------------- +# Correctness +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("dtype", [torch.bfloat16, torch.float16]) +@requires_ascend +class TestAscendAttentionCorrectness: + def test_prefill_causal(self, dtype): + op = _get_op() + q, k, v = _make_qkv(2, 8, 2, 128, 128, dtype) + out = op(q, k, v, causal=True) + gold = _gold(q, k, v, causal=True) + assert out.dtype == dtype + assert torch.allclose(out.float(), gold, atol=_ATOL[dtype], rtol=_RTOL[dtype]) + + def test_gqa(self, dtype): + op = _get_op() + q, k, v = _make_qkv(2, 8, 2, 64, 64, dtype) # g = 8/2 = 4 + out = op(q, k, v, causal=True) + gold = _gold(q, k, v, causal=True) + assert torch.allclose(out.float(), gold, atol=_ATOL[dtype], rtol=_RTOL[dtype]) + + def test_decode_window(self, dtype): + op = _get_op() + q, k, v = _make_qkv(2, 8, 2, 4, 96, dtype) # Sq < Skv + out = op(q, k, v, causal=True) + gold = _gold(q, k, v, causal=True) + assert torch.allclose(out.float(), gold, atol=_ATOL[dtype], rtol=_RTOL[dtype]) + + def test_non_causal(self, dtype): + op = _get_op() + q, k, v = _make_qkv(2, 8, 2, 64, 64, dtype) + out = op(q, k, v, causal=False) + gold = _gold(q, k, v, causal=False) + assert torch.allclose(out.float(), gold, atol=_ATOL[dtype], rtol=_RTOL[dtype]) + + def test_key_padding_mask(self, dtype): + op = _get_op() + q, k, v = _make_qkv(2, 8, 2, 64, 100, dtype) # Skv not a multiple of 64 + mask = torch.ones(2, 100, dtype=torch.bool, device="npu") + mask[:, 80:] = False + out = op(q, k, v, causal=True, key_padding_mask=mask) + gold = _gold(q, k, v, causal=True, key_padding_mask=mask) + assert torch.allclose(out.float(), gold, atol=_ATOL[dtype], rtol=_RTOL[dtype]) + + def test_fully_masked_row_is_zero(self, dtype): + op = _get_op() + q, k, v = _make_qkv(2, 4, 4, 2, 32, dtype) + mask = torch.ones(2, 32, dtype=torch.bool, device="npu") + mask[1, :] = False # batch 1 has no valid key at all + out, lse = op.forward_with_lse(q, k, v, causal=True, key_padding_mask=mask) + # Batch 1 has zero valid keys -> defined as 0, lse = -inf. + assert torch.equal(out[1], torch.zeros_like(out[1])) + assert torch.all(lse[1] == float("-inf")) + # Batch 0 still finite. + assert torch.isfinite(out[0]).all() + assert torch.isfinite(lse[0]).all() + + def test_explicit_scale(self, dtype): + op = _get_op() + q, k, v = _make_qkv(1, 4, 4, 32, 32, dtype) + out = op(q, k, v, causal=False, scale=0.05) + gold = _gold(q, k, v, causal=False, scale=0.05) + assert torch.allclose(out.float(), gold, atol=_ATOL[dtype], rtol=_RTOL[dtype]) + + def test_forward_with_lse(self, dtype): + op = _get_op() + q, k, v = _make_qkv(1, 4, 4, 64, 64, dtype) + out, lse = op.forward_with_lse(q, k, v, causal=True) + scale = 1.0 / math.sqrt(_D) + qf, kf = q.float(), k.float() + scores = qf @ kf.transpose(-1, -2) * scale + cm = torch.triu(torch.ones(64, 64, dtype=torch.bool, device="npu"), 1) + scores = scores.masked_fill(cm, float("-inf")) + ref_lse = torch.logsumexp(scores, dim=-1) + assert lse.dtype == torch.float32 + assert lse.shape == (1, 4, 64) + assert torch.allclose(lse, ref_lse, atol=1e-3, rtol=1e-3) + + def test_backward_grads(self, dtype): + op = _get_op() + q, k, v = _make_qkv(1, 4, 4, 32, 48, dtype) + q.requires_grad_(True) + k.requires_grad_(True) + v.requires_grad_(True) + out = op(q, k, v, causal=True) + grad_out = torch.randn_like(out) + out.backward(grad_out) + assert all(g is not None for g in (q.grad, k.grad, v.grad)) + assert all(torch.isfinite(g).all() for g in (q.grad, k.grad, v.grad)) + + # Gradient accuracy vs the FP32 reference VJP at the contract row: + # the reference consumes the same (already-quantized) inputs upcast + # to FP32 and keeps its gradients in FP32, while the backward kernel + # accumulates in FP32 and rounds the grads back to the execution + # dtype -- so the comparison is candidate-grad-in-fp32 vs + # reference-grad-in-fp32, never reference grads rounded down first. + atol, rtol = _grad_tol(dtype) + with torch.enable_grad(): + q_ref = q.detach().float().requires_grad_(True) + k_ref = k.detach().float().requires_grad_(True) + v_ref = v.detach().float().requires_grad_(True) + ref_out = NativeAttentionOp().forward_fp32(q_ref, k_ref, v_ref, causal=True) + dq_ref, dk_ref, dv_ref = torch.autograd.grad( + ref_out, (q_ref, k_ref, v_ref), grad_out.float() + ) + assert torch.allclose(q.grad.float(), dq_ref, atol=atol, rtol=rtol) + assert torch.allclose(k.grad.float(), dk_ref, atol=atol, rtol=rtol) + assert torch.allclose(v.grad.float(), dv_ref, atol=atol, rtol=rtol) + + +@requires_ascend +class TestAscendAttentionRejects: + """Out-of-domain inputs must be rejected up front.""" + + def test_rejects_fp32(self): + op = _get_op() + q, k, v = _make_qkv(1, 4, 4, 32, 32, torch.float32) + with pytest.raises(ValueError, match="only FP16/BF16"): + op(q, k, v) + + def test_rejects_bad_head_dim(self): + op = _get_op() + q = torch.randn(1, 4, 32, 64, device="npu", dtype=torch.float16) + k = torch.randn(1, 4, 32, 64, device="npu", dtype=torch.float16) + v = torch.randn(1, 4, 32, 64, device="npu", dtype=torch.float16) + with pytest.raises(ValueError, match="head dim D must be 128"): + op(q, k, v) + + +# --------------------------------------------------------------------------- +# Batch invariance +# --------------------------------------------------------------------------- + + +@requires_ascend +class TestAscendAttentionBatchInvariance: + def _run_row(self, batch, hq, hkv, sq, skv, dtype, pos, seed=7): + """One fixed query row embedded at position `pos` of a random batch.""" + op = _get_op() + q, k, v = _make_qkv(batch, hq, hkv, sq, skv, dtype, seed=seed) + out = op(q, k, v, causal=True) + return out[0, 0, pos, :].clone() + + def test_batch_size_1_vs_n(self): + dtype = torch.float16 + alone = self._run_row(1, 8, 2, 64, 64, dtype, pos=0, seed=7) + for batch in (2, 4, 8): + in_batch = self._run_row(batch, 8, 2, 64, 64, dtype, pos=0, seed=7) + assert torch.equal(alone, in_batch), f"drift at batch_size={batch}" + + def test_different_positions_in_batch(self): + # Non-causal: every position attends to the same full window. Copy the + # same query row content into every position, then require + # bitwise-identical output wherever it sits. (Causal windows differ + # per position, so a causal sweep would compare different reductions.) + dtype = torch.bfloat16 + op = _get_op() + q, k, v = _make_qkv(2, 8, 2, 64, 64, dtype, seed=11) + q[0, :, :, :] = q[0, :, 0:1, :] # same row content at every position + out = op(q, k, v, causal=False) + baseline = out[0, 0, 0, :].clone() + for pos in range(1, 16): + assert torch.equal(baseline, out[0, 0, pos, :]), f"drift at position={pos}" + + def test_block_striding(self): + # 2 * 8 * 128 = 2048 work items > MAX_BLOCKS (512): rows are strided + # across blocks, so numerics must not depend on block assignment. + # The small run (1 * 4 * 32 = 128 items, one block per item) and the + # strided run must give the bitwise-identical row for the same content. + dtype = torch.float16 + op = _get_op() + small_q, small_k, small_v = _make_qkv(1, 4, 2, 32, 32, dtype, seed=3) + small = op(small_q, small_k, small_v, causal=True) + big_q, big_k, big_v = _make_qkv(2, 8, 2, 128, 128, dtype, seed=3) + big_q[:, 0, 0, :] = small_q[0, 0, 0, :] + big_k[:, 0, :32, :] = small_k[0, 0, :, :] + big_v[:, 0, :32, :] = small_v[0, 0, :, :] + big = op(big_q, big_k, big_v, causal=True) + # Row (0, head 0, pos 0): causal window is j <= 0 in both runs, so the + # other 96 keys cannot influence the result. + assert torch.equal(big[0, 0, 0, :], small[0, 0, 0, :]) + + def test_repeated_runs_deterministic(self): + dtype = torch.bfloat16 + q, k, v = _make_qkv(2, 8, 2, 128, 128, dtype, seed=5) + op = _get_op() + first = op(q, k, v, causal=True) + for _ in range(3): + again = op(q, k, v, causal=True) + assert torch.equal(first, again) + + +# --------------------------------------------------------------------------- +# Registry dispatch +# --------------------------------------------------------------------------- + + +@requires_ascend +class TestAscendRegistryDispatch: + def test_get_op_attention(self): + from rl_engine.kernels.registry import kernel_registry + + op = kernel_registry.get_op("attention", device="npu") + assert type(op).__name__ == "DeterministicAttentionAscendOp" + + def test_get_op_attn_falls_back_to_sdpa(self): + from rl_engine.kernels.registry import kernel_registry + + op = kernel_registry.get_op("attn", device="npu") + assert type(op).__name__ == "NativeAttentionOp" diff --git a/tests/test_det_gemm_ascend.py b/tests/test_det_gemm_ascend.py new file mode 100644 index 00000000..21cbec74 --- /dev/null +++ b/tests/test_det_gemm_ascend.py @@ -0,0 +1,309 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 RL-Kernel Contributors + +"""Tests for the Ascend NPU batch-invariant deterministic GEMM (WS1 #146). + +Validates the same properties as the CUDA deterministic op: +1. **Correctness** - output matches the canonical FP32-leaf / BF16-node + midpoint tree reference within the reduction tolerances. +2. **Batch-invariance** - a row's output (and gradient) is bitwise identical + regardless of batch size, batch position, or how many AI-core blocks were + launched (every output row-tile is reduced end-to-end by one block with a + fixed leaf order; no split-K merge exists). +3. **TP-shard invariance** - contiguous half-K shards combined with one BF16 + add reproduce the full GEMM bitwise (the tree's "one child" property). +""" + +import pytest +import torch + +from rl_engine.kernels.ops.ascend.matmul.det_gemm import DetGemmAscendOp + +# Accuracy tolerance from the gtest contract, "reduction" op class, bf16. +_ATOL = 5.0e-2 +_RTOL = 2.0e-2 + +_K_TREE_LEAF = 32 + + +def _npu_available() -> bool: + try: + import torch_npu # noqa: F401 + except ImportError: + return False + return hasattr(torch, "npu") and torch.npu.is_available() + + +def _ascend_kernel_available() -> bool: + if not _npu_available(): + return False + try: + from rl_engine.kernels.ops.ascend.matmul.det_gemm import ( + _NPU_EXT_AVAILABLE, + _C_npu, + ) + except Exception: + return False + return _NPU_EXT_AVAILABLE and hasattr(_C_npu, "det_gemm_ascend_fwd") + + +requires_ascend = pytest.mark.skipif( + not _ascend_kernel_available(), + reason="det_gemm_ascend kernel not compiled " + "(needs KERNEL_ALIGN_FORCE_ASCEND=1 on an Ascend NPU host).", +) + + +def _get_op(): + return DetGemmAscendOp() + + +def _rand(*shape, seed=0): + # Independent generator per call: batch size must not shift the operand + # content (a shared generator would make b[0] differ between batch sizes, + # breaking the batch-invariance comparisons below). + generator = torch.Generator(device="cpu").manual_seed(seed) + return torch.randn(*shape, generator=generator, dtype=torch.bfloat16).to("npu") + + +def _k_tree_gemm(a: torch.Tensor, b: torch.Tensor) -> torch.Tensor: + """Canonical FP32-leaf / BF16-node midpoint tree (the CUDA/Triton reference). + + The tree splits in LEAF space (32-element leaves, the kernel's fixed + reduction granularity), matching the kernel's MidTreeMergeCount exactly. + Splitting in element space would produce sub-32-element leaves whenever + K is not 32 * 2**j (e.g. K=12288 -> 24-element leaves), which is a + different tree than the kernel evaluates. + """ + + a = a.detach().contiguous() + b = b.detach().contiguous() + k = a.size(1) + num_leaves = (k + _K_TREE_LEAF - 1) // _K_TREE_LEAF + + def reduce_range(lo: int, hi: int) -> torch.Tensor: + # [lo, hi) is a range of LEAF indices. + if hi - lo == 1: + start = lo * _K_TREE_LEAF + end = min(start + _K_TREE_LEAF, k) + return (a[:, start:end].float() @ b[start:end, :].float()).to( + torch.bfloat16 + ) + midpoint = lo + (hi - lo) // 2 + return reduce_range(lo, midpoint) + reduce_range(midpoint, hi) + + return reduce_range(0, num_leaves) + + +# --------------------------------------------------------------------------- +# Forward correctness +# --------------------------------------------------------------------------- + + +@requires_ascend +class TestAscendDetGemmCorrectness: + @pytest.mark.parametrize( + "shape", + [ + (128, 128, 128), # aligned, single-tile + (128, 2048, 2048), + (31, 70, 65), # ragged scalar-fallback shape + (1, 32, 32), # single leaf, no tree merges + (4, 12288, 64), # non-power-of-two midpoint tree (Qwen down-proj K) + # Regression sweep: tail-leaf padding, partial-column tiles, and + # the DataCopyPad gap-stride fast paths (both layouts). + (2, 1, 128), # R=1 tail leaf + (2, 17, 32), # R=17 tail leaf, pad < 32 B + (2, 33, 64), # R=33 tail leaf, pad == 32 B boundary + (2, 65, 128), # R=65 non-power-of-two leaf count + (2, 96, 128), # R=96 three full leaves + (2, 32, 129), # N=129 partial last tile + (2, 32, 257), # N=257 three tiles, partial last tile + (1, 32768, 32), # R=32768 max contract depth + ], + ) + def test_forward_matches_tree_reference(self, shape): + m, k, n = shape + op = _get_op() + a, b = _rand(m, k, seed=3), _rand(k, n, seed=4) + out = op(a, b) + ref = _k_tree_gemm(a, b) + assert out.dtype == torch.bfloat16 + assert tuple(out.shape) == (m, n) + torch.testing.assert_close( + out.float(), ref.float(), atol=_ATOL, rtol=_RTOL + ) + + @pytest.mark.parametrize( + "shape", + [ + (128, 128, 128), + (31, 70, 65), + ], + ) + def test_forward_fp32_matches_tree_reference(self, shape): + m, k, n = shape + op = _get_op() + a, b = _rand(m, k, seed=5), _rand(k, n, seed=6) + out = op.forward_fp32(a, b) + ref = _k_tree_gemm(a, b) + assert out.dtype == torch.float32 + # FP32 output carries the exact BF16-rounded root; loose bf16-scale + # tolerance suffices against the reference. + torch.testing.assert_close(out, ref.float(), atol=_ATOL, rtol=_RTOL) + + @pytest.mark.parametrize( + "shape", [(128, 128, 128), (31, 70, 65), (2, 17, 32), (2, 32, 129)] + ) + def test_rhs_transposed_layout_matches_forward_bitwise(self, shape): + m, k, n = shape + op = _get_op() + a = _rand(m, k, seed=7) + bt = _rand(n, k, seed=8) + expected = op(a, bt.t().contiguous()) + actual = _DetGemmAscendFn_rhs_transposed(a, bt) + assert actual.is_contiguous() + assert tuple(actual.shape) == (m, n) + assert torch.equal(actual, expected) + + +def _DetGemmAscendFn_rhs_transposed(a, bt): + from rl_engine.kernels.ops.ascend.matmul.det_gemm import _C_npu + + return _C_npu.det_gemm_ascend_fwd_rhs_transposed(a, bt) + + +# --------------------------------------------------------------------------- +# Batch invariance +# --------------------------------------------------------------------------- + + +@requires_ascend +class TestAscendDetGemmInvariance: + @pytest.mark.parametrize( + "shape", + [ + (4096, 4096, 12288), # qkv + (4096, 4096, 4096), # o_proj + (4096, 4096, 14336), # mlp_up + (4096, 14336, 4096), # mlp_dn + (4096, 4096, 32000), # lm_head + ], + ) + def test_forward_batch_invariance(self, shape): + # A row's output must not change when other rows join the batch. + _, k, n = shape + op = _get_op() + b = _rand(k, n, seed=0) + row = _rand(1, k, seed=1) + out1 = op(row, b) + big = _rand(64, k, seed=2) + big[0] = row[0] + outN = op(big, b) + assert torch.equal(out1[0], outN[0]) + + def test_forward_padding_invariance(self): + # Padding rows must not affect valid rows' output. + op = _get_op() + m, k, n = 100, 4096, 4096 + a, b = _rand(m, k, seed=3), _rand(k, n, seed=4) + base = op(a, b) + a_pad = torch.cat([a, _rand(28, k, seed=5)], dim=0) + padded = op(a_pad, b) + assert torch.equal(base, padded[:m]) + + def test_backward_batch_invariance(self): + # dA for a row must be invariant to the surrounding batch. + op = _get_op() + k, n = 2048, 2048 + b = _rand(k, n, seed=6) + row = _rand(1, k, seed=7).requires_grad_(True) + op(row, b).sum().backward() + g1 = row.grad.clone() + big = _rand(256, k, seed=8) + big[0] = row.detach()[0] + big.requires_grad_(True) + op(big, b).sum().backward() + assert torch.equal(g1[0], big.grad[0]) + + def test_tp2_contiguous_k_shards_match_full_bitwise(self): + # The GEMM tree and the TP collective rank tree must be the same graph: + # TP=2 (a+b) over contiguous half-K shards matches TP=1 bitwise. + op = _get_op() + m, k, n = 4, 12288, 64 + a, b = _rand(m, k, seed=9), _rand(k, n, seed=10) + half = k // 2 + full = op(a, b) + part0 = op(a[:, :half].contiguous(), b[:half].contiguous()) + part1 = op(a[:, half:].contiguous(), b[half:].contiguous()) + sharded = part0 + part1 + assert torch.equal(full, sharded), ( + f"full GEMM differed from TP=2 shards at " + f"{int((full != sharded).sum().item())} elements" + ) + + +# --------------------------------------------------------------------------- +# Backward correctness +# --------------------------------------------------------------------------- + + +@requires_ascend +class TestAscendDetGemmBackward: + def test_backward_matches_tree_reference(self): + op = _get_op() + m, k, n = 64, 1024, 1024 + a = _rand(m, k, seed=11).requires_grad_(True) + b = _rand(k, n, seed=12).requires_grad_(True) + g = _rand(m, n, seed=13) + op(a, b).backward(g) + # The op's autograd backward is the canonical FP32-accumulation + # rowwise VJP (batch-invariant, matches the gradient-accuracy gold); + # the reference is therefore the FP32 matmul VJP, not the BF16 tree + # (whose per-node rounding differs structurally). + expected_da = (g.float() @ b.detach().float().t()).to(torch.bfloat16) + expected_db = (a.detach().float().t() @ g.float()).to(torch.bfloat16) + torch.testing.assert_close( + a.grad.float(), expected_da.float(), atol=_ATOL, rtol=_RTOL + ) + torch.testing.assert_close( + b.grad.float(), expected_db.float(), atol=_ATOL, rtol=_RTOL + ) + + @pytest.mark.parametrize( + "shape", + [ + (1, 128, 128), # short-K weight gradient + (8, 128, 128), + (128, 128, 128), + (128, 96, 64), + (31, 70, 65), + ], + ) + def test_transposed_db_is_canonical_contiguous_and_matches_db(self, shape): + from rl_engine.kernels.ops.ascend.matmul.det_gemm import _C_npu + + tokens, in_features, out_features = shape + a = _rand(tokens, in_features, seed=14) + dc = _rand(tokens, out_features, seed=15) + + expected = _C_npu.det_gemm_ascend_db(a, dc).t().contiguous() + actual = _C_npu.det_gemm_ascend_db_transposed(a, dc) + + assert tuple(actual.shape) == (out_features, in_features) + assert tuple(actual.stride()) == (in_features, 1) + assert actual.is_contiguous() + assert torch.equal(actual, expected) + + def test_da_physical_transpose_contract_matches_fwd_bitwise(self): + from rl_engine.kernels.ops.ascend.matmul.det_gemm import _C_npu + + op = _get_op() + m, k, n = 128, 128, 128 + dc = _rand(m, n, seed=16) + b = _rand(k, n, seed=17) + + expected = op(dc, b.t().contiguous()) + actual = _C_npu.det_gemm_ascend_da(dc, b) + + assert torch.equal(actual, expected) diff --git a/tests/test_embedding_ascend.py b/tests/test_embedding_ascend.py new file mode 100644 index 00000000..ad63488e --- /dev/null +++ b/tests/test_embedding_ascend.py @@ -0,0 +1,280 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 RL-Kernel Contributors + +"""Tests for the Ascend NPU deterministic token embedding. + +Validates the same two orthogonal properties as the CUDA deterministic op, +but with a stronger correctness claim than the attention op: embedding is a +pure row gather (a bit copy, no arithmetic), so the Ascend output is +**bitwise identical** to the ``NativeEmbeddingOp`` PyTorch reference at every +dtype -- there is no reduction tolerance to calibrate. + +1. **Correctness** - ``forward``/``forward_fp32`` match the PyTorch reference + bitwise (``torch.equal``), and the deterministic sorted-segment backward + reproduces the fixed-order duplicate-id sum bitwise in the gradient dtype. +2. **Batch-invariance** - a token's gathered row is bitwise identical + regardless of batch size, batch position, or how many AI-core blocks were + launched (each row is copied end-to-end by one block). +""" + +import pytest +import torch + +from rl_engine.kernels.ops.cuda.linear.embedding import _deterministic_embedding_grad_weight +from rl_engine.kernels.ops.pytorch.linear.embedding import NativeEmbeddingOp + +_VOCAB = 128 +_HIDDEN = 64 + +# Gradient tolerances from the gtest contract, "elementwise" op class. +_GRAD_ATOL = { + torch.float32: 1.0e-5, + torch.bfloat16: 2.0e-2, + torch.float16: 1.0e-3, +} +_GRAD_RTOL = { + torch.float32: 1.0e-5, + torch.bfloat16: 1.6e-2, + torch.float16: 1.0e-3, +} + + +def _npu_available() -> bool: + try: + import torch_npu # noqa: F401 + except ImportError: + return False + return hasattr(torch, "npu") and torch.npu.is_available() + + +def _ascend_kernel_available() -> bool: + if not _npu_available(): + return False + try: + from rl_engine.kernels.ops.ascend.linear.embedding import _NPU_EXT_AVAILABLE, _C_npu + except Exception: + return False + return _NPU_EXT_AVAILABLE and hasattr(_C_npu, "embedding_ascend") + + +requires_ascend = pytest.mark.skipif( + not _ascend_kernel_available(), + reason="embedding_ascend kernel not compiled " + "(needs KERNEL_ALIGN_FORCE_ASCEND=1 on an Ascend NPU host).", +) + + +def _get_op(): + from rl_engine.kernels.ops.ascend.linear.embedding import AscendEmbeddingOp + + return AscendEmbeddingOp() + + +def _make_inputs(shape, vocab=_VOCAB, hidden=_HIDDEN, dtype=torch.float32, seed=0): + generator = torch.Generator(device="cpu").manual_seed(seed) + weight = torch.randn(vocab, hidden, dtype=dtype, generator=generator).to("npu") + token_ids = torch.randint(0, vocab, shape, generator=generator).long().to("npu") + return token_ids, weight + + +# --------------------------------------------------------------------------- +# Correctness +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("dtype", [torch.float32, torch.bfloat16, torch.float16]) +@requires_ascend +class TestAscendEmbeddingCorrectness: + def test_forward_matches_pytorch_reference_bitwise(self, dtype): + """Ascend forward == NativeEmbeddingOp.forward, bitwise (pure gather).""" + op = _get_op() + token_ids, weight = _make_inputs((3, 5), dtype=dtype) + out = op(token_ids, weight) + ref = NativeEmbeddingOp().forward(token_ids, weight) + assert out.dtype == dtype + assert torch.equal(out, ref) + + def test_forward_matches_direct_indexing_bitwise(self, dtype): + op = _get_op() + token_ids, weight = _make_inputs((3, 5), dtype=dtype) + out = op(token_ids, weight) + assert torch.equal(out, weight[token_ids]) + + def test_forward_fp32_matches_reference_bitwise(self, dtype): + """Ascend forward_fp32 == NativeEmbeddingOp.forward_fp32, bitwise.""" + op = _get_op() + token_ids, weight = _make_inputs((3, 5), dtype=dtype) + out = op.forward_fp32(token_ids, weight) + ref = NativeEmbeddingOp().forward_fp32(token_ids, weight) + assert out.dtype == torch.float32 + assert torch.equal(out, ref) + + def test_output_shape_leading_dims(self, dtype): + op = _get_op() + token_ids, weight = _make_inputs((2, 4, 3), dtype=dtype) + out = op(token_ids, weight) + assert out.shape == (2, 4, 3, _HIDDEN) + + def test_backward_matches_fixed_order_sum_bitwise(self, dtype): + """The sorted-segment dweight equals the input-order row sum, bitwise. + + The backward is the same deterministic formula the SM90 CUDA op uses + (stable-sorted segments, fixed addition order), so this asserts the + Ascend op reproduces that exact arithmetic on NPU. + """ + op = _get_op() + token_ids, weight = _make_inputs((3, 5), dtype=dtype) + flat = token_ids.reshape(-1) + flat[1::3] = flat[0] # force duplicates of the first token id + grad_out = torch.randn(3, 5, _HIDDEN, device="npu", dtype=dtype) + + weight_g = weight.clone().requires_grad_() + op(flat.reshape(3, 5), weight_g).backward(grad_out) + grad_asc = weight_g.grad + + grad_weight = _deterministic_embedding_grad_weight( + flat, + grad_out.reshape(flat.numel(), _HIDDEN), + weight_shape=tuple(weight.shape), + weight_dtype=dtype, + ) + assert torch.equal(grad_asc, grad_weight) + + def test_backward_matches_native_reference(self, dtype): + """vs the native op's backward at the elementwise gradient contract. + + Not bitwise by design: the deterministic formula accumulates + duplicate-id rows in the grad dtype (one rounding per add) while the + native backward accumulates in fp32, and the native reduction order + is unspecified. Two duplicates keep the drift within the contract. + """ + op = _get_op() + token_ids, weight = _make_inputs((3, 5), dtype=dtype) + flat = token_ids.reshape(-1) + flat[1] = flat[0] # a single duplicate exercises multi-row accumulation + grad_out = torch.randn(3, 5, _HIDDEN, device="npu", dtype=dtype) + + weight_a = weight.clone().requires_grad_() + op(flat.reshape(3, 5), weight_a).backward(grad_out) + + weight_n = weight.clone().requires_grad_() + NativeEmbeddingOp().forward(flat.reshape(3, 5), weight_n).backward(grad_out) + + assert torch.allclose( + weight_a.grad.float(), + weight_n.grad.float(), + atol=_GRAD_ATOL[dtype], + rtol=_GRAD_RTOL[dtype], + ) + + def test_unused_rows_stay_zero(self, dtype): + op = _get_op() + token_ids, weight = _make_inputs((1, 2), dtype=dtype) + grad_out = torch.randn(1, 2, _HIDDEN, device="npu", dtype=dtype) + weight_g = weight.clone().requires_grad_() + op(token_ids, weight_g).backward(grad_out) + used = set(token_ids.reshape(-1).cpu().tolist()) + for row in range(_VOCAB): + if row not in used: + assert torch.equal( + weight_g.grad[row], torch.zeros(_HIDDEN, device="npu", dtype=dtype) + ) + + +# --------------------------------------------------------------------------- +# Input guards +# --------------------------------------------------------------------------- + + +@requires_ascend +class TestAscendEmbeddingGuards: + def test_rejects_non_npu(self): + op = _get_op() + token_ids, weight = _make_inputs((2, 3)) + with pytest.raises(RuntimeError): + op(token_ids.cpu(), weight.cpu()) + + +# --------------------------------------------------------------------------- +# Batch invariance +# --------------------------------------------------------------------------- + + +@requires_ascend +class TestAscendEmbeddingBatchInvariance: + def _run_row(self, batch, seq, dtype, pos, seed=7): + """One fixed token embedded at position `pos` of a random batch.""" + op = _get_op() + token_ids, weight = _make_inputs((batch, seq), dtype=dtype, seed=seed) + out = op(token_ids, weight) + return out[0, pos, :].clone() + + def test_batch_size_1_vs_n(self): + dtype = torch.float16 + alone = self._run_row(1, 8, dtype, pos=0, seed=7) + for batch in (2, 4, 8): + in_batch = self._run_row(batch, 8, dtype, pos=0, seed=7) + assert torch.equal(alone, in_batch), f"drift at batch_size={batch}" + + def test_different_positions_in_batch(self): + # The same weight row gathered at every position of a batch must be + # bitwise-identical regardless of where the token lands. + dtype = torch.bfloat16 + op = _get_op() + token_ids, weight = _make_inputs((2, 16), dtype=dtype, seed=11) + fixed_id = token_ids[0, 0] + token_ids[0, :] = fixed_id # one token id repeated across positions + out = op(token_ids, weight) + ref = weight[fixed_id] + for pos in range(16): + assert torch.equal(out[0, pos, :], ref), f"drift at position={pos}" + + def test_block_striding(self): + # 1024 tokens > MAX_BLOCKS (128): rows are strided across blocks, so + # the copied bytes must not depend on block assignment. The same + # (weight row, token id) gathered in a small run and in the strided + # run must be bitwise-identical. + dtype = torch.bfloat16 + op = _get_op() + small_ids, small_weight = _make_inputs((1,), dtype=dtype, seed=3) + small = op(small_ids, small_weight) + big_ids, big_weight = _make_inputs((1024,), dtype=dtype, seed=4) + big_ids[511] = small_ids[0] + big_weight[:] = small_weight # same table content + big = op(big_ids, big_weight) + assert torch.equal(big[511, :], small[0, :]) + + def test_multi_tile_rows(self): + # hidden > TILE_LENGTH would need a 4096+ column table; use a + # multi-tile-equivalent via a large hidden with the tile loop. + # (TILE_LENGTH = 4096; hidden = 12288 exercises 3 tiles per row.) + dtype = torch.float16 + op = _get_op() + generator = torch.Generator(device="cpu").manual_seed(9) + weight = torch.randn(256, 12288, dtype=dtype, generator=generator).to("npu") + token_ids = torch.randint(0, 256, (2, 3), generator=generator).long().to("npu") + out = op(token_ids, weight) + assert torch.equal(out, weight[token_ids]) + + def test_repeated_runs_deterministic(self): + dtype = torch.bfloat16 + token_ids, weight = _make_inputs((3, 5), dtype=dtype, seed=5) + op = _get_op() + first = op(token_ids, weight) + for _ in range(3): + again = op(token_ids, weight) + assert torch.equal(first, again) + + +# --------------------------------------------------------------------------- +# Registry dispatch +# --------------------------------------------------------------------------- + + +@requires_ascend +class TestAscendRegistryDispatch: + def test_get_op_embedding(self): + from rl_engine.kernels.registry import kernel_registry + + op = kernel_registry.get_op("embedding", device="npu") + assert type(op).__name__ == "AscendEmbeddingOp" diff --git a/tests/test_linear_logp_ascend.py b/tests/test_linear_logp_ascend.py new file mode 100644 index 00000000..7ece62b4 --- /dev/null +++ b/tests/test_linear_logp_ascend.py @@ -0,0 +1,236 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 RL-Kernel Contributors + +"""Tests for the Ascend NPU batch-invariant fused linear log-prob. + +Validates the same two orthogonal properties as the CUDA deterministic op: + +1. **Correctness** - output matches a hand-computed fp32 reference + (``hidden.float() @ weight.float().T`` + ``log_softmax`` + gather + + clamp) within an honest reduction tolerance (~2e-4 at D=4096, pure + fp32-tree drift). The gtest's own forward comparison is stricter than + any independent kernel can meet (see Notes in the PR description): the + fp32 logprob tolerance is 1e-5 while two different fp32 reduction trees + over D=4096 drift ~1e-4, and the gold's dtype path accumulates the + matmul in bf16/fp16 while this kernel (like the CUDA SM90 kernel) + accumulates in fp32. +2. **Batch-invariance** - a row's logp is bitwise identical regardless of + batch size, batch position, or how many AI-core blocks were launched + (each row is reduced end-to-end by one block over a fixed vocab scan). +""" + +import pytest +import torch + +_VOCAB = 129 +_HIDDEN = 1000 + +# Honest forward tolerance vs the fp32 reference: pure fp32 reduction-tree +# drift (measured 2.1e-4 at D=4096, V=257). +_FWD_ATOL = 5.0e-4 +_FWD_RTOL = 1.0e-5 +# Gradient tolerance: the chunked backward casts to the input dtype, so +# low-precision grads compare at their own quantization level. +_GRAD_ATOL = {torch.float32: 5.0e-4, torch.bfloat16: 2.0e-2, torch.float16: 1.0e-2} + + +def _npu_available() -> bool: + try: + import torch_npu # noqa: F401 + except ImportError: + return False + return hasattr(torch, "npu") and torch.npu.is_available() + + +def _ascend_kernel_available() -> bool: + if not _npu_available(): + return False + try: + from rl_engine.kernels.ops.ascend.loss.linear_logp import _NPU_EXT_AVAILABLE, _C_npu + except Exception: + return False + return _NPU_EXT_AVAILABLE and hasattr(_C_npu, "fused_linear_logp_ascend") + + +requires_ascend = pytest.mark.skipif( + not _ascend_kernel_available(), + reason="fused_linear_logp_ascend kernel not compiled " + "(needs KERNEL_ALIGN_FORCE_ASCEND=1 on an Ascend NPU host).", +) + + +def _get_op(): + from rl_engine.kernels.ops.ascend.loss.linear_logp import FusedLinearLogpAscendOp + + return FusedLinearLogpAscendOp() + + +def _make_inputs(shape, vocab=_VOCAB, hidden=_HIDDEN, dtype=torch.float32, seed=0): + generator = torch.Generator(device="cpu").manual_seed(seed) + hidden_t = torch.randn(*shape, hidden, dtype=dtype, generator=generator).to("npu") + weight = torch.randn(vocab, hidden, dtype=dtype, generator=generator).to("npu") + target_ids = torch.randint(0, vocab, shape, generator=generator).long().to("npu") + return hidden_t, weight, target_ids + + +def _ref_fp32(hidden, weight, target_ids, bias=None): + """Hand-written fp32 reference matching the WS1 fp32-reference policy.""" + logits = hidden.float().reshape(-1, hidden.size(-1)) @ weight.float().t() + if bias is not None: + logits = logits + bias.float() + flat = target_ids.reshape(-1) + logp = torch.log_softmax(logits, dim=-1) + selected = logp.gather(1, flat.unsqueeze(1)).squeeze(1) + return selected.clamp(max=0).reshape(hidden.shape[:-1]) + + +# --------------------------------------------------------------------------- +# Correctness +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("dtype", [torch.float32, torch.bfloat16, torch.float16]) +@requires_ascend +class TestAscendFusedLinearLogpCorrectness: + def test_forward_matches_fp32_reference(self, dtype): + op = _get_op() + hidden, weight, target_ids = _make_inputs((3, 5), dtype=dtype) + out = op(hidden, weight, target_ids) + ref = _ref_fp32(hidden, weight, target_ids) + assert out.dtype == torch.float32 + assert out.shape == (3, 5) + assert torch.allclose(out, ref, atol=_FWD_ATOL, rtol=_FWD_RTOL) + + def test_forward_large_shape(self, dtype): + # gtest shape: D=4096 (single cached hidden tile), V=257. + op = _get_op() + hidden, weight, target_ids = _make_inputs((2, 16), vocab=257, hidden=4096, dtype=dtype) + out = op(hidden, weight, target_ids) + ref = _ref_fp32(hidden, weight, target_ids) + assert torch.allclose(out, ref, atol=_FWD_ATOL, rtol=_FWD_RTOL) + + def test_out_of_range_target_is_zero(self, dtype): + op = _get_op() + hidden, weight, target_ids = _make_inputs((2, 4), vocab=32, dtype=dtype) + target_ids = target_ids.reshape(-1) + target_ids[1] = 32 + 5 # out of [0, V) + out = op(hidden, weight, target_ids.reshape(2, 4)) + assert out.reshape(-1)[1].item() == 0.0 + + def test_bias_falls_back_to_native(self, dtype): + from rl_engine.kernels.ops.pytorch.loss.linear_logp import NativeLinearLogpOp + + op = _get_op() + hidden, weight, target_ids = _make_inputs((2, 3), dtype=dtype) + bias = torch.randn(_VOCAB, device="npu", dtype=dtype) + out = op(hidden, weight, target_ids, bias) + ref = NativeLinearLogpOp().apply(hidden, weight, target_ids, bias) + assert torch.allclose(out.float(), ref.float(), atol=1e-5, rtol=1e-5) + + def test_backward_matches_fp32_reference(self, dtype): + op = _get_op() + hidden, weight, target_ids = _make_inputs((3, 5), dtype=dtype) + grad_out = torch.randn(3, 5, device="npu", dtype=dtype) + + h_a = hidden.clone().requires_grad_() + w_a = weight.clone().requires_grad_() + op(h_a, w_a, target_ids).backward(grad_out) + + h_f = hidden.float().clone().requires_grad_() + w_f = weight.float().clone().requires_grad_() + _ref_fp32(h_f, w_f, target_ids).backward(grad_out.float()) + + # Compare at the quantized level for low-precision inputs: both + # backends compute the VJP in fp32 and cast to the input dtype, so + # the fp32 tree drift collapses into (usually identical) quantized + # bits; the tolerance only absorbs the rare 1-ULP straddle. + assert torch.allclose( + h_a.grad.float(), h_f.grad.to(dtype).float(), atol=_GRAD_ATOL[dtype], rtol=1.0e-4 + ) + assert torch.allclose( + w_a.grad.float(), w_f.grad.to(dtype).float(), atol=_GRAD_ATOL[dtype], rtol=1.0e-4 + ) + + def test_backward_has_no_cross_row_leak(self, dtype): + """Row-local VJP: the same row's grad is bitwise identical wherever the + row sits in the batch.""" + op = _get_op() + hidden, weight, target_ids = _make_inputs((4, 8), dtype=dtype) + hidden[1].copy_(hidden[0]) + target_ids[1] = target_ids[0] + + h_g = hidden.clone().requires_grad_() + grad_out = torch.randn(4, 8, device="npu", dtype=dtype) + grad_out[1] = grad_out[0] + op(h_g, weight, target_ids).backward(grad_out) + grad = h_g.grad + assert torch.equal(grad[0], grad[1]) + for row in range(2, 4): + assert not torch.equal(grad[0], grad[row]) + + +# --------------------------------------------------------------------------- +# Batch invariance +# --------------------------------------------------------------------------- + + +@requires_ascend +class TestAscendFusedLinearLogpBatchInvariance: + def test_batch_size_1_vs_n(self): + # One fixed row embedded in batches of growing size: its logp must be + # bitwise identical regardless of batch size. + dtype = torch.bfloat16 + op = _get_op() + alone_hidden, weight, alone_ids = _make_inputs((1,), dtype=dtype, seed=7) + alone = op(alone_hidden, weight, alone_ids)[0] + for batch in (2, 4, 16, 300): # 300 rows -> > MAX_BLOCKS strided blocks + hidden, _, target_ids = _make_inputs((batch,), dtype=dtype, seed=7) + hidden[0].copy_(alone_hidden[0]) + target_ids[0] = alone_ids[0] + in_batch = op(hidden, weight, target_ids)[0] + assert torch.equal(alone, in_batch), f"drift at batch_size={batch}" + + def test_different_positions_in_batch(self): + # The same row content copied to every position reduces bitwise + # identically regardless of where it lands. + dtype = torch.float16 + op = _get_op() + hidden, weight, target_ids = _make_inputs((8,), dtype=dtype, seed=11) + base, base_id = hidden[0].clone(), int(target_ids[0]) + for pos in range(1, 8): + hidden[pos].copy_(base) + target_ids[pos] = base_id + out = op(hidden, weight, target_ids) + for pos in range(1, 8): + assert torch.equal(out[pos], out[0]), f"drift at position={pos}" + + def test_multi_tile_rows(self): + # hidden > TILE_LENGTH (4096): the per-row dots span multiple tiles. + dtype = torch.float32 + op = _get_op() + hidden, weight, target_ids = _make_inputs((4,), hidden=10000, dtype=dtype, seed=5) + out = op(hidden, weight, target_ids) + assert torch.equal(out, op(hidden, weight, target_ids)) + + def test_repeated_runs_deterministic(self): + dtype = torch.bfloat16 + hidden, weight, target_ids = _make_inputs((3, 5), dtype=dtype, seed=5) + op = _get_op() + first = op(hidden, weight, target_ids) + for _ in range(3): + again = op(hidden, weight, target_ids) + assert torch.equal(first, again) + + +# --------------------------------------------------------------------------- +# Registry dispatch +# --------------------------------------------------------------------------- + + +@requires_ascend +class TestAscendRegistryDispatch: + def test_get_op_linear_logp(self): + from rl_engine.kernels.registry import kernel_registry + + op = kernel_registry.get_op("linear_logp", device="npu") + assert type(op).__name__ == "FusedLinearLogpAscendOp" diff --git a/tests/test_lm_head_ascend.py b/tests/test_lm_head_ascend.py new file mode 100644 index 00000000..43cf353c --- /dev/null +++ b/tests/test_lm_head_ascend.py @@ -0,0 +1,230 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 RL-Kernel Contributors + +"""Tests for the Ascend NPU batch-invariant LM-head projection. + +Validates the same two orthogonal properties as the CUDA deterministic op: + +1. **Correctness** - output matches the ``NativeLMHeadOp.forward_fp32`` ground + truth within the reduction contract tolerance. The Ascend C kernel mirrors + the SM90 CUDA kernel's structure (one output element per block, full + hidden-dimension fp32 reduction over a fixed tile order); the hardware + reduction trees differ from CUDA's (and from torch.mv's), so the + comparison is tolerance-based. +2. **Batch-invariance** - a row's logits are bitwise identical regardless of + batch size, batch position, or how many AI-core blocks were launched + (each element is reduced end-to-end by one block; no Split-K merge). +""" + +import pytest +import torch + +from rl_engine.kernels.ops.pytorch.linear.lm_head import NativeLMHeadOp + +# Accuracy tolerances from the gtest contract, "reduction" op class. +_ATOL = { + torch.float32: 1.0e-4, + torch.bfloat16: 5.0e-2, + torch.float16: 1.0e-3, +} +_RTOL = { + torch.float32: 1.0e-4, + torch.bfloat16: 2.0e-2, + torch.float16: 1.0e-3, +} +# Gradient tolerances from the gtest contract, "gradient_accuracy" reduction row. +_GRAD_ATOL = { + torch.float32: 1.0e-4, + torch.bfloat16: 1.0e-1, + torch.float16: 1.0e-3, +} +_GRAD_RTOL = { + torch.float32: 1.0e-4, + torch.bfloat16: 2.0e-2, + torch.float16: 1.0e-3, +} + + +def _npu_available() -> bool: + try: + import torch_npu # noqa: F401 + except ImportError: + return False + return hasattr(torch, "npu") and torch.npu.is_available() + + +def _ascend_kernel_available() -> bool: + if not _npu_available(): + return False + try: + from rl_engine.kernels.ops.ascend.linear.lm_head import _NPU_EXT_AVAILABLE, _C_npu + except Exception: + return False + return _NPU_EXT_AVAILABLE and hasattr(_C_npu, "lm_head_ascend") + + +requires_ascend = pytest.mark.skipif( + not _ascend_kernel_available(), + reason="lm_head_ascend kernel not compiled " + "(needs KERNEL_ALIGN_FORCE_ASCEND=1 on an Ascend NPU host).", +) + + +def _get_op(): + from rl_engine.kernels.ops.ascend.linear.lm_head import AscendLMHeadOp + + return AscendLMHeadOp() + + +def _make_inputs(shape, vocab, hidden, dtype, seed=0, with_bias=False): + generator = torch.Generator(device="cpu").manual_seed(seed) + hidden_t = torch.randn(*shape, hidden, dtype=dtype, generator=generator).to("npu") + weight = torch.randn(vocab, hidden, dtype=dtype, generator=generator).to("npu") + bias = torch.randn(vocab, dtype=dtype, generator=generator).to("npu") if with_bias else None + return hidden_t, weight, bias + + +# --------------------------------------------------------------------------- +# Correctness +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("dtype", [torch.float32, torch.bfloat16, torch.float16]) +@requires_ascend +class TestAscendLMHeadCorrectness: + def test_forward_matches_pytorch_reference(self, dtype): + op = _get_op() + hidden, weight, _ = _make_inputs((3, 5), 129, 1000, dtype) + out = op(hidden, weight) + ref = NativeLMHeadOp().forward_fp32(hidden, weight) + assert out.dtype == dtype + assert out.shape == (3, 5, 129) + assert torch.allclose(out.float(), ref.float(), atol=_ATOL[dtype], rtol=_RTOL[dtype]) + + def test_forward_fp32_matches_reference(self, dtype): + op = _get_op() + hidden, weight, _ = _make_inputs((3, 5), 129, 1000, dtype) + out = op.forward_fp32(hidden, weight) + ref = NativeLMHeadOp().forward_fp32(hidden, weight) + assert out.dtype == torch.float32 + assert torch.allclose(out, ref, atol=_ATOL[dtype], rtol=_RTOL[dtype]) + + def test_forward_with_bias(self, dtype): + op = _get_op() + hidden, weight, bias = _make_inputs((3, 5), 129, 1000, dtype, with_bias=True) + out = op(hidden, weight, bias=bias) + ref = NativeLMHeadOp().forward_fp32(hidden, weight, bias=bias) + assert torch.allclose(out.float(), ref.float(), atol=_ATOL[dtype], rtol=_RTOL[dtype]) + + def test_output_shape_leading_dims(self, dtype): + op = _get_op() + hidden, weight, _ = _make_inputs((2, 4, 3), 17, 64, dtype) + out = op(hidden, weight) + assert out.shape == (2, 4, 3, 17) + + def test_backward_matches_native_reference(self, dtype): + # The native reference runs the fp32 path: torch.mv on NPU rejects + # bf16, and the fp32 path keeps both VJPs in the same accumulation + # dtype so the comparison isolates the matmul-tree drift. + op = _get_op() + hidden, weight, _ = _make_inputs((3, 5), 129, 1000, dtype) + grad_out = torch.randn(3, 5, 129, device="npu", dtype=dtype) + + h_a = hidden.clone().requires_grad_() + w_a = weight.clone().requires_grad_() + op(h_a, w_a).backward(grad_out) + + h_n = hidden.clone().requires_grad_() + w_n = weight.clone().requires_grad_() + NativeLMHeadOp().forward_fp32(h_n, w_n).backward(grad_out) + + assert torch.allclose( + h_a.grad.float(), h_n.grad.float(), atol=_GRAD_ATOL[dtype], rtol=_GRAD_RTOL[dtype] + ) + assert torch.allclose( + w_a.grad.float(), w_n.grad.float(), atol=_GRAD_ATOL[dtype], rtol=_GRAD_RTOL[dtype] + ) + + def test_backward_with_bias(self, dtype): + op = _get_op() + hidden, weight, bias = _make_inputs((3, 5), 129, 1000, dtype, with_bias=True) + grad_out = torch.randn(3, 5, 129, device="npu", dtype=dtype) + + h_a = hidden.clone().requires_grad_() + w_a = weight.clone().requires_grad_() + b_a = bias.clone().requires_grad_() + op(h_a, w_a, bias=b_a).backward(grad_out) + assert b_a.grad is not None + assert b_a.grad.shape == (129,) + assert torch.isfinite(b_a.grad).all() + + b_n = bias.clone().requires_grad_() + NativeLMHeadOp().forward_fp32(hidden, weight, bias=b_n).backward(grad_out) + assert torch.allclose( + b_a.grad.float(), b_n.grad.float(), atol=_GRAD_ATOL[dtype], rtol=_GRAD_RTOL[dtype] + ) + + +# --------------------------------------------------------------------------- +# Batch invariance +# --------------------------------------------------------------------------- + + +@requires_ascend +class TestAscendLMHeadBatchInvariance: + def test_batch_size_1_vs_n(self): + # One fixed hidden row embedded in batches of growing size: its logits + # must be bitwise identical regardless of batch size. + dtype = torch.bfloat16 + op = _get_op() + alone_hidden, weight, _ = _make_inputs((1,), 129, 1000, dtype, seed=7) + alone = op(alone_hidden, weight)[0] + for batch in (2, 4, 16, 300): # 300 rows -> > MAX_BLOCKS strided blocks + hidden, _, _ = _make_inputs((batch,), 129, 1000, dtype, seed=7) + hidden[0].copy_(alone_hidden[0]) + in_batch = op(hidden, weight)[0] # same weight table throughout + assert torch.equal(alone, in_batch), f"drift at batch_size={batch}" + + def test_different_positions_in_batch(self): + # The same row content copied to every position projects bitwise + # identically regardless of where it lands. + dtype = torch.float16 + op = _get_op() + hidden, weight, _ = _make_inputs((8,), 129, 1000, dtype, seed=11) + base = hidden[0].clone() + for pos in range(1, 8): + hidden[pos].copy_(base) + out = op(hidden, weight) + for pos in range(1, 8): + assert torch.equal(out[pos], out[0]), f"drift at position={pos}" + + def test_multi_tile_rows(self): + # hidden > TILE_LENGTH (4096): the reduction spans multiple tiles. + dtype = torch.float32 + op = _get_op() + hidden, weight, _ = _make_inputs((4,), 129, 10000, dtype, seed=5) + out = op(hidden, weight) + assert torch.equal(out, op(hidden, weight)) + + def test_repeated_runs_deterministic(self): + dtype = torch.bfloat16 + hidden, weight, _ = _make_inputs((3, 5), 129, 1000, dtype, seed=5) + op = _get_op() + first = op(hidden, weight) + for _ in range(3): + again = op(hidden, weight) + assert torch.equal(first, again) + + +# --------------------------------------------------------------------------- +# Registry dispatch +# --------------------------------------------------------------------------- + + +@requires_ascend +class TestAscendRegistryDispatch: + def test_get_op_lm_head(self): + from rl_engine.kernels.registry import kernel_registry + + op = kernel_registry.get_op("lm_head", device="npu") + assert type(op).__name__ == "AscendLMHeadOp" diff --git a/tests/test_logp_ascend.py b/tests/test_logp_ascend.py new file mode 100644 index 00000000..9197c433 --- /dev/null +++ b/tests/test_logp_ascend.py @@ -0,0 +1,230 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 RL-Kernel Contributors + +"""Tests for the Ascend NPU batch-invariant fused selected-token logp. + +Validates the same two orthogonal properties as the CUDA deterministic op: + +1. **Correctness** - output matches the ``NativeLogpOp.forward_fp32`` ground + truth within the logprob contract tolerance. The Ascend C kernel mirrors + the CUDA deterministic kernel's two-pass (row max, then sum-exp) fp32 + reduction with a fixed tile order; the hardware reduction trees differ + from CUDA's, so the comparison is tolerance-based (fp32 drift ~1e-7). +2. **Batch-invariance** - a row's logp is bitwise identical regardless of + batch size, batch position, or how many AI-core blocks were launched + (each row is reduced end-to-end by one block; no split-K merge exists). +""" + +import pytest +import torch + +from rl_engine.kernels.ops.pytorch.loss.logp import NativeLogpOp + +# Accuracy tolerances from the gtest contract, "logprob" op class. +_ATOL = { + torch.float32: 1.0e-5, + torch.bfloat16: 6.0e-2, + torch.float16: 5.0e-3, +} + + +def _npu_available() -> bool: + try: + import torch_npu # noqa: F401 + except ImportError: + return False + return hasattr(torch, "npu") and torch.npu.is_available() + + +def _ascend_kernel_available() -> bool: + if not _npu_available(): + return False + try: + from rl_engine.kernels.ops.ascend.loss.logp import _NPU_EXT_AVAILABLE, _C_npu + except Exception: + return False + return _NPU_EXT_AVAILABLE and hasattr(_C_npu, "fused_logp_ascend") + + +requires_ascend = pytest.mark.skipif( + not _ascend_kernel_available(), + reason="fused_logp_ascend kernel not compiled " + "(needs KERNEL_ALIGN_FORCE_ASCEND=1 on an Ascend NPU host).", +) + + +def _get_op(): + from rl_engine.kernels.ops.ascend.loss.logp import FusedLogpAscendOp + + return FusedLogpAscendOp() + + +def _make_inputs(shape, vocab, dtype, seed=0): + generator = torch.Generator(device="cpu").manual_seed(seed) + logits = torch.randn(*shape, vocab, dtype=dtype, generator=generator).to("npu") + token_ids = torch.randint(0, vocab, shape, generator=generator).long().to("npu") + return logits, token_ids + + +# --------------------------------------------------------------------------- +# Correctness +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("dtype", [torch.float32, torch.bfloat16, torch.float16]) +@requires_ascend +class TestAscendFusedLogpCorrectness: + def test_forward_matches_pytorch_reference(self, dtype): + op = _get_op() + logits, token_ids = _make_inputs((3, 5), 257, dtype) + out = op(logits, token_ids) + ref = NativeLogpOp().forward_fp32(logits, token_ids) + assert out.dtype == torch.float32 # matches DeterministicLogpCUDAOp's contract + assert out.shape == (3, 5) + assert torch.allclose(out.float(), ref, atol=_ATOL[dtype], rtol=0.0) + + def test_apply_fp32_matches_reference(self, dtype): + op = _get_op() + logits, token_ids = _make_inputs((3, 5), 257, dtype) + out = op.apply_fp32(logits, token_ids) + ref = NativeLogpOp().forward_fp32(logits, token_ids) + assert torch.allclose(out.float(), ref, atol=_ATOL[dtype], rtol=0.0) + + def test_out_of_range_target_is_zero(self, dtype): + op = _get_op() + logits, token_ids = _make_inputs((2, 4), 32, dtype) + token_ids = token_ids.reshape(-1) + token_ids[1] = 32 + 5 # out of [0, V) + out = op(logits, token_ids.reshape(2, 4)) + assert out.reshape(-1)[1].item() == 0.0 + + def test_backward_matches_native_reference(self, dtype): + op = _get_op() + logits, token_ids = _make_inputs((3, 5), 257, dtype) + + logits_a = logits.clone().requires_grad_() + op(logits_a, token_ids).backward(torch.ones(3, 5, device="npu", dtype=dtype)) + + logits_n = logits.clone().requires_grad_() + NativeLogpOp()(logits_n, token_ids).backward(torch.ones(3, 5, device="npu", dtype=dtype)) + + assert torch.allclose( + logits_a.grad.float(), logits_n.grad.float(), atol=1.0e-4, rtol=1.0e-4 + ) + + def test_backward_has_no_cross_row_leak(self, dtype): + """Row-local VJP: the same row's grad is bitwise identical wherever the + row sits in the batch.""" + op = _get_op() + logits, token_ids = _make_inputs((4, 8), 257, dtype) + logits[1].copy_(logits[0]) + token_ids[1] = token_ids[0] + + logits_g = logits.clone().requires_grad_() + grad_out = torch.randn(4, 8, device="npu", dtype=dtype) + grad_out[1] = grad_out[0] # identical (logits, target, dy) triples + op(logits_g, token_ids).backward(grad_out) + grad = logits_g.grad + # Rows 0 and 1 saw identical inputs, so their VJPs must be bitwise + # identical; rows 2/3 stay independent. + assert torch.equal(grad[0], grad[1]) + for row in range(2, 4): + assert not torch.equal(grad[0], grad[row]) + +# --------------------------------------------------------------------------- +# Fallback +# --------------------------------------------------------------------------- + + +@requires_ascend +class TestAscendFusedLogpFallback: + def test_rejects_non_npu_falls_back_to_native(self): + op = _get_op() + logits, token_ids = _make_inputs((2, 3), 17, torch.float32) + out = op(logits.cpu(), token_ids.cpu()) + ref = NativeLogpOp()(logits.cpu(), token_ids.cpu()) + assert torch.equal(out, ref) + + +# --------------------------------------------------------------------------- +# Batch invariance +# --------------------------------------------------------------------------- + + +@requires_ascend +class TestAscendFusedLogpBatchInvariance: + def _run_row(self, batch, vocab, dtype, pos, seed=7): + """One fixed row embedded at position `pos` of a random batch.""" + op = _get_op() + logits, token_ids = _make_inputs((batch,), vocab, dtype, seed=seed) + out = op(logits, token_ids) + return out[pos].clone() + + def test_batch_size_1_vs_n(self): + # One fixed (row, target) pair embedded in batches of growing size: + # its logp must be bitwise identical regardless of batch size. + dtype = torch.bfloat16 + op = _get_op() + alone_logits, alone_ids = _make_inputs((1,), 257, dtype, seed=7) + alone = op(alone_logits, alone_ids)[0] + for batch in (2, 4, 16, 300): # 300 > MAX_BLOCKS -> strided blocks + logits, token_ids = _make_inputs((batch,), 257, dtype, seed=7) + logits[0].copy_(alone_logits[0]) + token_ids[0] = alone_ids[0] + in_batch = op(logits, token_ids)[0] + assert torch.equal(alone, in_batch), f"drift at batch_size={batch}" + + def test_different_positions_in_batch(self): + # The same row content copied to every position reduces bitwise + # identically regardless of where it lands. + dtype = torch.float16 + op = _get_op() + logits, token_ids = _make_inputs((8,), 257, dtype, seed=11) + base, base_id = logits[0].clone(), int(token_ids[0]) + for pos in range(1, 8): + logits[pos].copy_(base) + token_ids[pos] = base_id + out = op(logits, token_ids) + for pos in range(1, 8): + assert torch.equal(out[pos], out[0]), f"drift at position={pos}" + + def test_block_striding(self): + # 300 rows > MAX_BLOCKS (128): rows are strided across blocks, so + # numerics must not depend on block assignment. + dtype = torch.bfloat16 + op = _get_op() + logits, token_ids = _make_inputs((300,), 257, dtype, seed=13) + out = op(logits, token_ids) + again = op(logits, token_ids) + assert torch.equal(out, again) + + def test_multi_tile_rows(self): + # vocab > TILE_LENGTH (4096): rows span multiple fixed-order tiles. + dtype = torch.float32 + op = _get_op() + logits, token_ids = _make_inputs((4,), 10000, dtype, seed=5) + out = op(logits, token_ids) + assert torch.equal(out, op(logits, token_ids)) + + def test_repeated_runs_deterministic(self): + dtype = torch.bfloat16 + logits, token_ids = _make_inputs((3, 5), 257, dtype, seed=5) + op = _get_op() + first = op(logits, token_ids) + for _ in range(3): + again = op(logits, token_ids) + assert torch.equal(first, again) + + +# --------------------------------------------------------------------------- +# Registry dispatch +# --------------------------------------------------------------------------- + + +@requires_ascend +class TestAscendRegistryDispatch: + def test_get_op_logp(self): + from rl_engine.kernels.registry import kernel_registry + + op = kernel_registry.get_op("logp", device="npu") + assert type(op).__name__ == "FusedLogpAscendOp" diff --git a/tests/test_prefix_shared_attention_ascend.py b/tests/test_prefix_shared_attention_ascend.py new file mode 100644 index 00000000..e9c6301d --- /dev/null +++ b/tests/test_prefix_shared_attention_ascend.py @@ -0,0 +1,250 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 RL-Kernel Contributors + +"""Tests for the Ascend NPU prefix-shared fused attention. + +Validates the same properties as the CUDA prefix-shared op: +1. **Correctness** - output matches the ``NativeAttentionOp.forward_fp32`` + ground truth (full softmax over the shared K/V, no causal mask) within the + reduction tolerances. +2. **Batch-invariance** - a query row's output is bitwise identical regardless + of batch size, batch position, or how many AI-core blocks were launched + (each (bs, g, 64-row block) item is processed end-to-end by one block). +""" + +import math + +import pytest +import torch + +from rl_engine.kernels.ops.pytorch.attention.standard_attn import NativeAttentionOp + +_D = 128 + +# Accuracy tolerance from the gtest contract, "attention" op class. +_ATOL = {torch.bfloat16: 5.0e-2} +_RTOL = {torch.bfloat16: 2.0e-2} + + +def _npu_available() -> bool: + try: + import torch_npu # noqa: F401 + except ImportError: + return False + return hasattr(torch, "npu") and torch.npu.is_available() + + +def _ascend_kernel_available() -> bool: + if not _npu_available(): + return False + try: + from rl_engine.kernels.ops.ascend.attention.prefix_shared_attn import ( + _C_npu, + _NPU_EXT_AVAILABLE, + ) + except Exception: + return False + return _NPU_EXT_AVAILABLE and hasattr(_C_npu, "prefix_shared_attention_ascend") + + +requires_ascend = pytest.mark.skipif( + not _ascend_kernel_available(), + reason="prefix_shared_attention_ascend kernel not compiled " + "(needs KERNEL_ALIGN_FORCE_ASCEND=1 on an Ascend NPU host).", +) + + +def _get_op(): + from rl_engine.kernels.ops.ascend.attention.prefix_shared_attn import ( + PrefixSharedAttentionAscendOp, + ) + + return PrefixSharedAttentionAscendOp() + + +def _gold(q, k, v): + """fp32 ground truth: softmax(Q K^T / sqrt(D)) V over the shared K/V.""" + return NativeAttentionOp().forward_fp32( + q, + k.unsqueeze(1), # [bs, 1, Skv, D]: every G group shares the same KV head + v.unsqueeze(1), + causal=False, + scale=1.0 / math.sqrt(_D), + ) + + +def _make_qkv(batch, groups, sq, skv, dtype, seed=0): + generator = torch.Generator(device="cpu").manual_seed(seed) + q = torch.randn(batch, groups, sq, _D, dtype=dtype, generator=generator).to("npu") + k = torch.randn(batch, skv, _D, dtype=dtype, generator=generator).to("npu") + v = torch.randn(batch, skv, _D, dtype=dtype, generator=generator).to("npu") + return q, k, v + + +# --------------------------------------------------------------------------- +# Correctness +# --------------------------------------------------------------------------- + + +@requires_ascend +class TestAscendPrefixSharedAttentionCorrectness: + def test_basic(self): + op = _get_op() + q, k, v = _make_qkv(2, 4, 65, 130, torch.bfloat16) # ragged Sq/Skv + out = op(q, k, v) + gold = _gold(q, k, v) + assert out.dtype == torch.bfloat16 + assert out.shape == (2, 4, 65, _D) + assert torch.allclose( + out.float(), gold, atol=_ATOL[torch.bfloat16], rtol=_RTOL[torch.bfloat16] + ) + + def test_exact_tiles(self): + op = _get_op() + q, k, v = _make_qkv(2, 8, 64, 64, torch.bfloat16) # one Q block, one KV tile + out = op(q, k, v) + gold = _gold(q, k, v) + assert torch.allclose( + out.float(), gold, atol=_ATOL[torch.bfloat16], rtol=_RTOL[torch.bfloat16] + ) + + def test_decode_window(self): + op = _get_op() + q, k, v = _make_qkv(1, 16, 1, 512, torch.bfloat16) # Sq << Skv, 8 KV tiles + out = op(q, k, v) + gold = _gold(q, k, v) + assert torch.allclose( + out.float(), gold, atol=_ATOL[torch.bfloat16], rtol=_RTOL[torch.bfloat16] + ) + + def test_long_prefix_multi_tile(self): + op = _get_op() + q, k, v = _make_qkv(1, 2, 32, 1024, torch.bfloat16) # 16 KV tiles + out = op(q, k, v) + gold = _gold(q, k, v) + assert torch.allclose( + out.float(), gold, atol=_ATOL[torch.bfloat16], rtol=_RTOL[torch.bfloat16] + ) + + def test_shared_kv_across_groups(self): + # Every G group attends over the exact same K/V; a G-sweep must match + # the per-group reference (and be bitwise equal across G for equal q). + op = _get_op() + q, k, v = _make_qkv(1, 4, 32, 96, torch.bfloat16) + q[0, 1, :, :] = q[0, 0, :, :] # force identical query rows in 2 groups + out = op(q, k, v) + assert torch.equal(out[0, 0], out[0, 1]) + gold = _gold(q, k, v) + assert torch.allclose( + out.float(), gold, atol=_ATOL[torch.bfloat16], rtol=_RTOL[torch.bfloat16] + ) + + +# --------------------------------------------------------------------------- +# Batch invariance +# --------------------------------------------------------------------------- + + +@requires_ascend +class TestAscendPrefixSharedAttentionBatchInvariance: + def test_batch_size_1_vs_n(self): + # The same (b=0, g=0, row 0) content computed alone vs embedded in a + # larger batch must be bitwise identical. (Content is copied in + # explicitly: CPU bf16 randn consumes two fp32 draws per element, so + # same-seed tensors of different batch sizes would not line up.) + dtype = torch.bfloat16 + op = _get_op() + q1, k1, v1 = _make_qkv(1, 4, 64, 64, dtype, seed=7) + alone = op(q1, k1, v1)[0, 0, 0, :].clone() + for batch in (2, 4, 8): + q, k, v = _make_qkv(batch, 4, 64, 64, dtype, seed=7) + q[0, 0, 0, :] = q1[0, 0, 0, :] + k[0, :, :] = k1[0, :, :] + v[0, :, :] = v1[0, :, :] + in_batch = op(q, k, v)[0, 0, 0, :].clone() + assert torch.equal(alone, in_batch), f"drift at batch_size={batch}" + + def test_different_positions_in_batch(self): + # One fixed query row embedded at several positions (spanning both + # 64-row query blocks) must give the bitwise-identical output at each. + dtype = torch.bfloat16 + op = _get_op() + q1, k1, v1 = _make_qkv(1, 1, 1, 64, dtype, seed=11) + baseline = op(q1, k1, v1)[0, 0, 0, :].clone() + q, k, v = _make_qkv(2, 4, 128, 64, dtype, seed=11) + k[0, :, :] = k1[0, :, :] + v[0, :, :] = v1[0, :, :] + for pos in (0, 63, 64, 127): + q[0, 0, pos, :] = q1[0, 0, 0, :] + out = op(q, k, v) + for pos in (0, 63, 64, 127): + assert torch.equal(baseline, out[0, 0, pos, :]), f"drift at position={pos}" + + def test_block_striding(self): + # The strided run below has 8 * 16 * (320/64) = 640 work items > + # MAX_BLOCKS (512), so items are strided across blocks; numerics must + # not depend on block assignment. The 1-item run and the strided run + # must give the bitwise-identical rows for the same content. + dtype = torch.bfloat16 + op = _get_op() + small_q, small_k, small_v = _make_qkv(1, 1, 64, 64, dtype, seed=3) + small = op(small_q, small_k, small_v) + big_q, big_k, big_v = _make_qkv(8, 16, 320, 64, dtype, seed=3) + big_q[0, 0, :64, :] = small_q[0, 0, :, :] + big_k[0, :, :] = small_k[0, :, :] + big_v[0, :, :] = small_v[0, :, :] + big = op(big_q, big_k, big_v) + assert torch.equal(big[0, 0, :64, :], small[0, 0, :, :]) + + def test_repeated_runs_deterministic(self): + dtype = torch.bfloat16 + q, k, v = _make_qkv(2, 4, 128, 128, dtype, seed=5) + op = _get_op() + first = op(q, k, v) + for _ in range(3): + again = op(q, k, v) + assert torch.equal(first, again) + + +# --------------------------------------------------------------------------- +# Validation +# --------------------------------------------------------------------------- + + +@requires_ascend +class TestAscendPrefixSharedAttentionValidation: + def test_rejects_fp32(self): + op = _get_op() + q, k, v = _make_qkv(1, 4, 32, 32, torch.float32) + with pytest.raises(ValueError, match="only BF16"): + op(q, k, v) + + def test_rejects_fp16(self): + op = _get_op() + q, k, v = _make_qkv(1, 4, 32, 32, torch.float16) + with pytest.raises(ValueError, match="only BF16"): + op(q, k, v) + + def test_rejects_bad_head_dim(self): + op = _get_op() + q = torch.randn(1, 4, 32, 64, device="npu", dtype=torch.bfloat16) + k = torch.randn(1, 32, 64, device="npu", dtype=torch.bfloat16) + v = torch.randn(1, 32, 64, device="npu", dtype=torch.bfloat16) + with pytest.raises(ValueError, match="head dim D must be 128"): + op(q, k, v) + + def test_rejects_4d_kv(self): + op = _get_op() + q = torch.randn(1, 4, 32, 128, device="npu", dtype=torch.bfloat16) + k = torch.randn(1, 1, 32, 128, device="npu", dtype=torch.bfloat16) + v = torch.randn(1, 1, 32, 128, device="npu", dtype=torch.bfloat16) + with pytest.raises(ValueError, match="k/v 3-D"): + op(q, k, v) + + def test_rejects_kv_length_mismatch(self): + op = _get_op() + q = torch.randn(1, 4, 32, 128, device="npu", dtype=torch.bfloat16) + k = torch.randn(1, 32, 128, device="npu", dtype=torch.bfloat16) + v = torch.randn(1, 64, 128, device="npu", dtype=torch.bfloat16) + with pytest.raises(ValueError, match="key length mismatch"): + op(q, k, v) diff --git a/tests/test_rms_norm.py b/tests/test_rms_norm.py index fb62993e..e8dcaa0a 100644 --- a/tests/test_rms_norm.py +++ b/tests/test_rms_norm.py @@ -37,10 +37,18 @@ def _rand(shape, *, seed, dtype=torch.float32): def _manual_rms_norm(x, weight, *, eps=_EPS): - """Independent hand-written fp32 reference (NOT the op under test).""" + """Independent hand-written fp32 reference (NOT the op under test). + + Uses the shared shape-invariant rstd: torch's mean/sum reductions pick + shape-dependent kernels on NPU, so the reference formula must use the + same fixed-order reduction as the implementation (see + rl_engine.kernels.ops.pytorch.norm.rms_norm.shape_invariant_rstd). + """ + from rl_engine.kernels.ops.pytorch.norm.rms_norm import shape_invariant_rstd + x_f = x.float() - var = x_f.pow(2).mean(dim=-1, keepdim=True) - return x_f * torch.rsqrt(var + eps) * weight.float() + rstd = shape_invariant_rstd(x_f, float(eps)).unsqueeze(-1) + return x_f * rstd * weight.float() def _dtype_tolerance(dtype): @@ -239,7 +247,7 @@ def test_backward_batch_invariance_slice(): assert torch.equal(x_slice.grad, grad_x_full_sliced) -# 10. Registry dispatch resolves to the native op +# 10. Registry dispatch resolves to the hardware op when available def test_registry_dispatches_rms_norm(): from rl_engine.kernels.registry import kernel_registry @@ -254,6 +262,11 @@ def test_registry_dispatches_rms_norm(): elif torch.cuda.is_available() and _HAS_CUDA_RMSNORM: assert isinstance(op, RMSNormCudaOp) assert hasattr(op, "forward") + elif _ascend_rmsnorm_available(): + from rl_engine.kernels.ops.ascend.norm.rmsnorm import RMSNormAscendOp + + assert isinstance(op, RMSNormAscendOp) + assert hasattr(op, "forward") else: assert isinstance(op, NativeRMSNormOp) assert hasattr(op, "forward") and hasattr(op, "forward_fp32") @@ -438,3 +451,118 @@ def test_cuda_rms_norm_masked_dw_layout_invariance(): torch.testing.assert_close(dw1.float(), ref_dw1.float(), atol=atol, rtol=rtol) torch.testing.assert_close(dw2.float(), ref_dw2.float(), atol=atol, rtol=rtol) torch.testing.assert_close(dw3.float(), ref_dw3.float(), atol=atol, rtol=rtol) + + +# --------------------------------------------------------------------------- +# Ascend C kernel (rl_engine._C_npu.rmsnorm_ascend) +# --------------------------------------------------------------------------- + +from rl_engine.platforms.device import _npu_available # noqa: E402 + + +def _ascend_rmsnorm_available() -> bool: + if not _npu_available(): + return False + try: + from rl_engine.kernels.ops.ascend.norm.rmsnorm import _NPU_EXT_AVAILABLE, _C_npu + except Exception: + return False + return _NPU_EXT_AVAILABLE and hasattr(_C_npu, "rmsnorm_ascend") + + +requires_ascend_rmsnorm = pytest.mark.skipif( + not _ascend_rmsnorm_available(), + reason="rmsnorm_ascend kernel not compiled " + "(needs KERNEL_ALIGN_FORCE_ASCEND=1 on an Ascend NPU host).", +) + + +@requires_ascend_rmsnorm +@pytest.mark.parametrize("hidden", [_HIDDEN, _HEAD_DIM, 1000, 12288]) +@pytest.mark.parametrize("dtype", [torch.float32, torch.float16, torch.bfloat16]) +def test_ascend_rms_norm_forward_matches_manual_reference(hidden, dtype): + """Ascend forward vs the hand-written fp32 reference (tolerance-based).""" + from rl_engine.kernels.ops.ascend.norm.rmsnorm import RMSNormAscendOp + + torch.manual_seed(0) + x = torch.randn((32, hidden), device="npu", dtype=torch.float32).to(dtype) + w = torch.randn((hidden,), device="npu", dtype=torch.float32).to(dtype) + + y = RMSNormAscendOp()(x, w, eps=_EPS) + ref = _manual_rms_norm(x, w) + + atol, rtol = _dtype_tolerance(dtype) + torch.testing.assert_close(y.float(), ref, atol=atol, rtol=rtol) + + +@requires_ascend_rmsnorm +@pytest.mark.parametrize("hidden", [_HIDDEN, _HEAD_DIM, 1000, 12288]) +@pytest.mark.parametrize("dtype", [torch.float32, torch.float16, torch.bfloat16]) +@pytest.mark.parametrize("rows", [1, 7, 64, 257]) +def test_ascend_rms_norm_bitwise_identical_to_native(hidden, dtype, rows): + """Ascend forward must be bitwise identical to the PyTorch reference. + + The row scale rstd is computed with the exact reference torch ops + (fp32 mean of squares + torch.rsqrt), so the fused kernel — which only + performs order-free elementwise multiplies and an RNE cast — must match + NativeRMSNormOp bit-for-bit on every dtype, including the H > tile and + H % 8 != 0 paths. + """ + from rl_engine.kernels.ops.ascend.norm.rmsnorm import RMSNormAscendOp + + torch.manual_seed(0) + op = RMSNormAscendOp() + native = NativeRMSNormOp() + + x = torch.randn((rows, hidden), device="npu", dtype=torch.float32).to(dtype) + w = torch.randn((hidden,), device="npu", dtype=torch.float32).to(dtype) + + y_ascend = op(x, w, eps=_EPS) + y_native = native(x, w, eps=_EPS) + + assert y_ascend.dtype == y_native.dtype == dtype + assert torch.equal(y_ascend, y_native) + + +@requires_ascend_rmsnorm +@pytest.mark.parametrize("hidden", [_HIDDEN, _HEAD_DIM, 12288]) +@pytest.mark.parametrize("dtype", [torch.float32, torch.bfloat16]) +def test_ascend_rms_norm_backward_matches_native(hidden, dtype): + """Ascend forward + VJP backward vs the native op's forward/backward.""" + from rl_engine.kernels.ops.ascend.norm.rmsnorm import RMSNormAscendOp + + torch.manual_seed(0) + op = RMSNormAscendOp() + native = NativeRMSNormOp() + x = torch.randn((64, hidden), device="npu", dtype=torch.float32).to(dtype) + w = torch.randn((hidden,), device="npu", dtype=torch.float32).to(dtype) + dy = torch.randn((64, hidden), device="npu", dtype=torch.float32).to(dtype) + + y_a, dx_a, dw_a = _run_forward_backward(lambda a, b: op(a, b, eps=_EPS), x, w, dy) + y_n, dx_n, dw_n = _run_forward_backward(lambda a, b: native(a, b, eps=_EPS), x, w, dy) + + atol, rtol = _dtype_tolerance(dtype) + torch.testing.assert_close(y_a.float(), y_n.float(), atol=atol, rtol=rtol) + torch.testing.assert_close(dx_a.float(), dx_n.float(), atol=atol, rtol=rtol) + # dw accumulates over rows, so allow the tolerance to grow with sqrt(rows). + torch.testing.assert_close(dw_a.float(), dw_n.float(), atol=8 * atol, rtol=rtol) + + +@requires_ascend_rmsnorm +@pytest.mark.parametrize("hidden", [_HIDDEN, _HEAD_DIM, 12288]) +@pytest.mark.parametrize("dtype", [torch.float32, torch.bfloat16]) +def test_ascend_rms_norm_batch_invariance_bitwise(hidden, dtype): + """A row's output must not depend on how many rows share the batch.""" + from rl_engine.kernels.ops.ascend.norm.rmsnorm import RMSNormAscendOp + + torch.manual_seed(0) + op = RMSNormAscendOp() + x_full = torch.randn((64, hidden), device="npu", dtype=torch.float32).to(dtype) + w = torch.randn((hidden,), device="npu", dtype=torch.float32).to(dtype) + + y_full = op(x_full, w, eps=_EPS) + y_slice = op(x_full[:7].clone(), w, eps=_EPS) + y_single = op(x_full[13:14].clone(), w, eps=_EPS) + + assert torch.equal(y_full[:7], y_slice) + assert torch.equal(y_full[13:14], y_single) diff --git a/tests/test_rope.py b/tests/test_rope.py index 67f4a294..38534483 100644 --- a/tests/test_rope.py +++ b/tests/test_rope.py @@ -15,6 +15,7 @@ import torch from rl_engine.kernels.ops.pytorch.rotary_embedding.rope import NativeRoPEOp +from rl_engine.platforms.device import _npu_available # --------------------------------------------------------------------------- # Fixtures & helpers @@ -74,7 +75,9 @@ def test_forward_fp32_returns_fp32_even_with_bf16_input(self): def test_call_equals_forward(self): op = NativeRoPEOp() x, pos = _make_inputs(2, 32, 16, QWEN3_HEAD_DIM) - assert torch.equal(op(x, pos, theta=QWEN3_THETA), op.forward(x, pos, theta=QWEN3_THETA)) + assert torch.equal( + op(x, pos, theta=QWEN3_THETA), op.forward(x, pos, theta=QWEN3_THETA) + ) def test_pure_function_no_inplace(self): op = NativeRoPEOp() @@ -143,7 +146,9 @@ def test_batch1_vs_batchN_bitwise(self): full_out = op.forward_fp32(x, pos) for i in range(x.shape[0]): single_out = op.forward_fp32(x[i : i + 1], pos) - assert torch.equal(full_out[i], single_out[0]), f"Batch invariance broken at row {i}" + assert torch.equal( + full_out[i], single_out[0] + ), f"Batch invariance broken at row {i}" def test_batch_invariance_with_padding(self): """Padded batch (extra rows) must not affect valid rows.""" @@ -230,7 +235,8 @@ def test_forward_vs_fp32_within_tolerance(self, dtype, atol, rtol): out_fp32 = op.forward_fp32(x_typed, pos) diff = (out_typed - out_fp32).abs().max().item() assert torch.allclose(out_typed, out_fp32, atol=atol, rtol=rtol), ( - f"dtype={dtype}, max_abs_error={diff:.3e} exceeds " f"atol={atol}, rtol={rtol}" + f"dtype={dtype}, max_abs_error={diff:.3e} exceeds " + f"atol={atol}, rtol={rtol}" ) @@ -285,7 +291,9 @@ def test_packed_logical_positions_match_per_sample_rope(self): assert not torch.equal(packed_out, naive_out) -@pytest.mark.skipif(not torch.cuda.is_available(), reason="candidate RoPE requires CUDA") +@pytest.mark.skipif( + not torch.cuda.is_available(), reason="candidate RoPE requires CUDA" +) class TestCandidateRoPELayouts: def _candidates(self): from rl_engine.kernels.ops.triton.rotary_embedding.rope import TritonRoPEOp @@ -328,3 +336,118 @@ def test_packed_logical_positions_on_candidates(self): for name, op in self._candidates(): got = op.forward(packed, packed_pos, theta=QWEN3_THETA).float() assert torch.allclose(got, gold, atol=2e-2, rtol=1.6e-2), name + + +# --------------------------------------------------------------------------- +# Ascend C candidate +# --------------------------------------------------------------------------- + + +def _ascend_rope_available() -> bool: + if not _npu_available(): + return False + try: + from rl_engine.kernels.ops.ascend.rotary_embedding.rope import ( + _C_npu, + _NPU_EXT_AVAILABLE, + ) + except Exception: + return False + return _NPU_EXT_AVAILABLE and hasattr(_C_npu, "rope_apply_ascend") + + +requires_ascend_rope = pytest.mark.skipif( + not _ascend_rope_available(), + reason="rope_apply_ascend is not compiled (requires an Ascend NPU build).", +) + + +def _make_ascend_inputs(batch: int, heads: int, seq: int, dim: int, dtype: torch.dtype): + x, positions = _make_inputs(batch, heads, seq, dim, seed=2026) + return x.to(dtype=dtype, device="npu"), positions.to(device="npu") + + +def test_ascend_per_batch_table_layout_round_trips_without_an_npu(): + from rl_engine.kernels.ops.ascend.rotary_embedding.rope import ( + _restore_rope, + _rope_table, + ) + + x = torch.arange(2 * 3 * 4 * 8, dtype=torch.float32).reshape(2, 3, 4, 8) + positions = torch.stack([torch.arange(4), torch.arange(4) + 17]) + x_2d, cos, sin = _rope_table(x, positions, QWEN3_THETA) + + assert torch.equal(x_2d, x.permute(1, 0, 2, 3).reshape(-1, 8)) + assert cos.shape == sin.shape == (8, 4) + assert torch.equal(_restore_rope(x_2d, x, positions), x) + + +def test_ascend_per_batch_table_rejects_incompatible_shapes_without_an_npu(): + from rl_engine.kernels.ops.ascend.rotary_embedding.rope import _rope_table + + with pytest.raises(ValueError, match="incompatible"): + _rope_table( + torch.randn(2, 3, 4, 8), + torch.zeros(3, 4, dtype=torch.long), + QWEN3_THETA, + ) + + +@requires_ascend_rope +class TestRoPEAscend: + def _op(self): + from rl_engine.kernels.ops.ascend.rotary_embedding.rope import RoPEAscendOp + + return RoPEAscendOp() + + @pytest.mark.parametrize( + "dtype,atol,rtol", + [ + (torch.float32, 1e-5, 1e-5), + (torch.float16, 1e-3, 1e-3), + (torch.bfloat16, 2e-2, 1.6e-2), + ], + ) + def test_forward_matches_fp32_reference(self, dtype, atol, rtol): + x, positions = _make_ascend_inputs(2, 8, 17, 128, dtype) + actual = self._op()(x, positions, theta=QWEN3_THETA) + expected = NativeRoPEOp().forward_fp32(x, positions, theta=QWEN3_THETA) + assert actual.shape == x.shape + assert actual.dtype == dtype + assert torch.allclose(actual.float(), expected, atol=atol, rtol=rtol) + + def test_per_batch_positions_match_reference(self): + x, positions = _make_ascend_inputs(3, 8, 11, 128, torch.bfloat16) + positions = torch.stack([positions + batch * 97 for batch in range(x.shape[0])]) + actual = self._op()(x, positions, theta=QWEN3_THETA) + expected = NativeRoPEOp().forward_fp32(x, positions, theta=QWEN3_THETA) + assert torch.allclose(actual.float(), expected, atol=2e-2, rtol=1.6e-2) + + def test_batch_invariance_is_bitwise(self): + x, positions = _make_ascend_inputs(4, 8, 13, 128, torch.bfloat16) + full = self._op()(x, positions, theta=QWEN3_THETA) + for batch in range(x.shape[0]): + single = self._op()(x[batch : batch + 1], positions, theta=QWEN3_THETA) + assert torch.equal(full[batch], single[0]) + + def test_backward_matches_transposed_reference_rotation(self): + x, positions = _make_ascend_inputs(2, 4, 9, 128, torch.float32) + grad_out = torch.randn_like(x) + + actual_x = x.detach().clone().requires_grad_(True) + self._op()(actual_x, positions, theta=QWEN3_THETA).backward(grad_out) + + expected_x = x.detach().clone().requires_grad_(True) + NativeRoPEOp().forward_fp32(expected_x, positions, theta=QWEN3_THETA).backward( + grad_out + ) + assert actual_x.grad is not None + assert expected_x.grad is not None + assert torch.allclose(actual_x.grad, expected_x.grad, atol=1e-5, rtol=1e-5) + + def test_empty_batch(self): + x = torch.empty(0, 8, 0, 128, device="npu", dtype=torch.bfloat16) + positions = torch.empty(0, device="npu", dtype=torch.long) + out = self._op()(x, positions) + assert out.shape == x.shape + assert out.dtype == x.dtype diff --git a/tests/test_silu_ascend.py b/tests/test_silu_ascend.py new file mode 100644 index 00000000..88f85a80 --- /dev/null +++ b/tests/test_silu_ascend.py @@ -0,0 +1,161 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 RL-Kernel Contributors + +"""Tests for the Ascend C SiLU kernel (WS1 #266 C5/C8, ascend_bf16 chain node). + +SiLU is a required C2 chain node, so the Ascend profile needs its own kernel +rather than borrowing SwiGLU with a ones operand. The properties checked are +the ones the contract judges: + +1. **Accuracy** - forward and backward match the FP32 PyTorch reference within + the elementwise tolerances. +2. **Batch invariance** - an element's value and gradient are bitwise identical + regardless of tensor size, its position, or how many AI-core blocks ran. +3. **Consistency with SwiGLU** - ``silu(x)`` equals ``swiglu(x, ones)`` bitwise, + since both evaluate the same FP32 sigmoid sequence. +""" + +from __future__ import annotations + +import pytest +import torch + +from rl_engine.kernels.ops.pytorch.activation.swiglu import NativeSiLUOp + +# Elementwise tolerances from the gtest contract, bf16. +_ATOL = 5.0e-2 +_RTOL = 2.0e-2 + +# Deliberately spans tile boundaries: TILE_LENGTH is 2048 and MAX_BLOCKS is 32, +# so 2047/2048/2049 and a size beyond one full strided sweep exercise the tail +# path, the exact-tile path, and the multi-pass loop. +_SIZES = (1, 31, 32, 2047, 2048, 2049, 4096, 70000) + + +def _npu_available() -> bool: + try: + import torch_npu # noqa: F401 + except ImportError: + return False + return hasattr(torch, "npu") and torch.npu.is_available() + + +def _ascend_kernel_available() -> bool: + if not _npu_available(): + return False + try: + from rl_engine.kernels.ops.ascend.activation.silu import _C_npu + except Exception: + return False + return _C_npu is not None and hasattr(_C_npu, "silu_forward") + + +requires_ascend = pytest.mark.skipif( + not _ascend_kernel_available(), + reason="silu Ascend kernel not compiled " + "(needs KERNEL_ALIGN_FORCE_ASCEND=1 on an Ascend NPU host).", +) + + +def _get_op(): + from rl_engine.kernels.ops.ascend.activation.silu import SiLUAscendOp + + return SiLUAscendOp() + + +def _rand(*shape, seed=0, dtype=torch.bfloat16): + # Independent generator per call so a different tensor size cannot shift + # the content of the leading elements the invariance checks compare. + generator = torch.Generator(device="cpu").manual_seed(seed) + return torch.randn(*shape, generator=generator, dtype=dtype).to("npu") + + +@requires_ascend +class TestAscendSiLUCorrectness: + @pytest.mark.parametrize("n", _SIZES) + def test_forward_matches_fp32_reference(self, n): + x = _rand(n) + out = _get_op().forward(x) + expected = NativeSiLUOp()(x.float().cpu()).to(torch.bfloat16) + assert out.dtype == torch.bfloat16 + torch.testing.assert_close(out.cpu(), expected, atol=_ATOL, rtol=_RTOL) + + @pytest.mark.parametrize("dtype", [torch.bfloat16, torch.float16, torch.float32]) + def test_supported_dtypes_round_trip(self, dtype): + x = _rand(4096, dtype=dtype) + out = _get_op().forward(x) + assert out.dtype == dtype + expected = NativeSiLUOp()(x.float().cpu()).to(dtype) + torch.testing.assert_close(out.cpu(), expected, atol=_ATOL, rtol=_RTOL) + + def test_forward_fp32_returns_fp32(self): + x = _rand(1024) + out = _get_op().forward_fp32(x) + assert out.dtype == torch.float32 + + def test_empty_tensor_is_supported(self): + out = _get_op().forward(_rand(0)) + assert out.numel() == 0 + + def test_multi_dimensional_shapes_are_preserved(self): + x = _rand(3, 17, 128) + assert _get_op().forward(x).shape == x.shape + + def test_backward_matches_fp32_reference(self): + x = _rand(4096) + xa = x.clone().requires_grad_(True) + _get_op().forward(xa).backward(torch.ones_like(xa)) + + ref = x.float().cpu().requires_grad_(True) + NativeSiLUOp()(ref).backward(torch.ones_like(ref)) + torch.testing.assert_close( + xa.grad.float().cpu(), ref.grad.to(torch.bfloat16).float(), atol=_ATOL, rtol=_RTOL + ) + + +@requires_ascend +class TestAscendSiLUInvariance: + @pytest.mark.parametrize("n", [2047, 2048, 4096, 70000]) + def test_forward_is_batch_invariant(self, n): + """The first 1024 elements must not change when more elements join. + + Different sizes launch a different number of AI-core blocks and a + different number of strided passes; a batch-invariant elementwise op + evaluates each element identically regardless. + """ + op = _get_op() + small = _rand(1024, seed=7) + large = torch.cat([small, _rand(n, seed=8)]) + assert torch.equal(op.forward(small), op.forward(large)[:1024]) + + def test_backward_is_batch_invariant(self): + op = _get_op() + base = _rand(1024, seed=11) + + def grad_of(x): + xa = x.clone().requires_grad_(True) + op.forward(xa).backward(torch.ones_like(xa)) + return xa.grad + + large = torch.cat([base, _rand(4096, seed=12)]) + assert torch.equal(grad_of(base), grad_of(large)[:1024]) + + def test_matches_swiglu_with_unit_up_bitwise(self): + """silu(x) == swiglu(x, ones): both run the same FP32 sigmoid sequence.""" + + from rl_engine.kernels.ops.ascend.activation.swiglu import SwiGLUAscendOp + + x = _rand(4096, seed=3) + ones = torch.ones_like(x) + assert torch.equal(_get_op().forward(x), SwiGLUAscendOp().forward(x, ones)) + + +@requires_ascend +class TestAscendSiLUContract: + def test_cpu_tensors_are_rejected(self): + with pytest.raises(RuntimeError, match="NPU"): + _get_op().forward(torch.randn(16, dtype=torch.bfloat16)) + + def test_unsupported_dtype_is_rejected(self): + with pytest.raises(TypeError): + _get_op().forward(_rand(16).to(torch.int32)) diff --git a/tests/test_swiglu.py b/tests/test_swiglu.py index 22b26336..245740fb 100644 --- a/tests/test_swiglu.py +++ b/tests/test_swiglu.py @@ -1,30 +1,35 @@ # SPDX-License-Identifier: Apache-2.0 # Copyright (c) 2026 RL-Kernel Contributors -"""SiLU / SwiGLU tests: native gold + CUDA / Triton candidates vs ground truth. +"""SiLU / SwiGLU tests: native gold + CUDA / Triton / Ascend C candidates. Covers: - Native correctness (fp32 formula, dtype path, shape guard) - Axis A batch invariance (slice + padding, forward + backward) - CUDA / Triton forward+backward vs NativeSiLUOp / NativeSwiGLUOp (issue #108 harness) +- Ascend C SwiGLU integration, forward/backward accuracy and NPU acceptance - Registry dispatch + OP_SPECS candidate paths """ from __future__ import annotations import argparse +from types import SimpleNamespace import pytest import torch from rl_engine.kernels.gtest.op_checks import run_operator_suite from rl_engine.kernels.gtest.operator_specs import ( + OP_SPECS, make_candidate, make_operator_case, operator_names, ) +from rl_engine.kernels.ops.ascend.activation import swiglu as ascend from rl_engine.kernels.ops.pytorch.activation.swiglu import NativeSiLUOp, NativeSwiGLUOp -from rl_engine.kernels.registry import kernel_registry +from rl_engine.kernels.registry import KernelRegistry, OpBackend, kernel_registry +from rl_engine.platforms.device import _npu_available, device_ctx try: from rl_engine.kernels.ops.triton.activation.swiglu import TritonSiLUOp, TritonSwiGLUOp @@ -49,6 +54,7 @@ # Qwen3-8B SwiGLU intermediate dim (gate/up_proj output width). _INTERMEDIATE = 12288 +_ASCEND_DTYPES = (torch.float32, torch.float16, torch.bfloat16) # Shared helper @@ -70,6 +76,7 @@ def _dtype_tolerance(dtype: torch.dtype) -> tuple[float, float]: requires_cuda = pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA is required") +requires_npu = pytest.mark.skipif(not _npu_available(), reason="Ascend NPU required") requires_cuda_activation = pytest.mark.skipif( not (torch.cuda.is_available() and _HAS_CUDA_ACTIVATION), reason="CUDA SiLU/SwiGLU extension is not available", @@ -681,3 +688,233 @@ def test_silu_swiglu_cuda_triton_issue_108_harness(candidate, op_name, dtype): f"{op_name}/{candidate}/{dtype} failed against gold: " f"{report.candidates[0].cases[0].outputs}" ) + + +# --------------------------------------------------------------------------- +# Ascend C SwiGLU integration and on-device acceptance +# --------------------------------------------------------------------------- +# NPU tests skip only when no NPU is available. On NPU hosts, a missing +# extension fails these tests instead of silently exercising PyTorch fallback. + + +@pytest.mark.parametrize("symbols", [(), ("swiglu_forward",), ("swiglu_backward",)]) +def test_missing_extension_symbols_raise_actionable_error(monkeypatch, symbols): + monkeypatch.setattr(ascend, "_C_npu", SimpleNamespace(**dict.fromkeys(symbols))) + with pytest.raises(RuntimeError, match="KERNEL_ALIGN_FORCE_ASCEND=1"): + ascend.SwiGLUAscendOp() + + +def test_npu_registry_selects_ascend_and_falls_back_without_extension(monkeypatch): + monkeypatch.setattr(device_ctx, "device_type", "npu") + symbols = SimpleNamespace(swiglu_forward=object(), swiglu_backward=object()) + monkeypatch.setattr(ascend, "_C_npu", symbols) + assert isinstance(KernelRegistry().get_op("swiglu"), ascend.SwiGLUAscendOp) + assert isinstance(KernelRegistry().get_op("swiglu", device="cpu"), NativeSwiGLUOp) + monkeypatch.setattr(ascend, "_C_npu", None) + assert isinstance(KernelRegistry().get_op("swiglu"), NativeSwiGLUOp) + + +def test_ascend_candidate_is_exposed_to_accuracy_harness(): + assert OP_SPECS["swiglu"].candidate_paths["ascend"] == OpBackend.ASCEND_SWIGLU.value + + +@pytest.mark.parametrize("method", ["forward", "forward_fp32"]) +def test_ascend_wrapper_rejects_cpu_inputs(monkeypatch, method): + monkeypatch.setattr( + ascend, "_C_npu", SimpleNamespace(swiglu_forward=object(), swiglu_backward=object()) + ) + with pytest.raises(RuntimeError, match="requires NPU tensors"): + getattr(ascend.SwiGLUAscendOp(), method)(torch.ones(3), torch.ones(3)) + + +@pytest.mark.parametrize("needs_grad", [(True, True), (True, False), (False, True)]) +@pytest.mark.parametrize("fp32_output", [False, True]) +def test_autograd_wrapper_contiguity_and_gradient_routing(monkeypatch, needs_grad, fp32_output): + """Exercise the Python autograd boundary; this does not emulate Ascend C.""" + calls = [] + + def forward(gate, up): + assert gate.is_contiguous() and up.is_contiguous() + calls.append(("forward", gate.dtype)) + return NativeSwiGLUOp()(gate, up) + + def backward(grad_out, gate, up): + assert all(x.is_contiguous() for x in (grad_out, gate, up)) + calls.append(("backward", grad_out.dtype)) + g, u, dy = gate.float(), up.float(), grad_out.float() + s = torch.sigmoid(g) + return ((dy * u) * (s * (1 + g * (1 - s)))).to(gate.dtype), (dy * (g * s)).to(up.dtype) + + monkeypatch.setattr( + ascend, "_C_npu", SimpleNamespace(swiglu_forward=forward, swiglu_backward=backward) + ) + # CPU-only test of the wrapper; real device guards are tested separately. + monkeypatch.setattr(ascend, "_validate_inputs", lambda gate, up: None) + gate = torch.randn(7, 5, dtype=torch.bfloat16).t().requires_grad_(needs_grad[0]) + up = torch.randn(7, 5, dtype=torch.bfloat16).t().requires_grad_(needs_grad[1]) + grad_out = torch.randn(7, 5).t() + method = "forward_fp32" if fp32_output else "forward" + result = getattr(ascend.SwiGLUAscendOp(), method)(gate, up) + result.backward(grad_out.to(result.dtype)) + + ref_gate = gate.detach().clone().requires_grad_(needs_grad[0]) + ref_up = up.detach().clone().requires_grad_(needs_grad[1]) + ref = getattr(NativeSwiGLUOp(), method)(ref_gate, ref_up) + ref.backward(grad_out.to(ref.dtype)) + torch.testing.assert_close(result, ref, rtol=0, atol=0) + for actual, expected in ((gate.grad, ref_gate.grad), (up.grad, ref_up.grad)): + if expected is None: + assert actual is None + else: + torch.testing.assert_close(actual, expected, rtol=2e-2, atol=2e-2) + expected_dtype = torch.float32 if fp32_output else torch.bfloat16 + assert calls == [("forward", expected_dtype), ("backward", expected_dtype)] + + +@pytest.fixture +def npu_op(): + return ascend.SwiGLUAscendOp() + + +def _ascend_dtype_tolerance(dtype): + return { + torch.float32: (1e-5, 1e-5), + torch.float16: (1e-3, 1e-3), + torch.bfloat16: (2e-2, 1.6e-2), + }[dtype] + + +@requires_npu +@pytest.mark.parametrize("dtype", _ASCEND_DTYPES) +@pytest.mark.parametrize( + "shape", + [ + (), + (0, 17), + (1,), + (7,), + (15,), + (31,), + (33,), + (2047,), + (2048,), + (2049,), + (2, 3, 65), + (2, 12288), + (65539,), + ], +) +def test_npu_forward_backward_and_fp32_path(npu_op, dtype, shape): + generator = torch.Generator().manual_seed(29) + gate_cpu = torch.randn(shape, generator=generator).to(dtype).requires_grad_() + up_cpu = torch.randn(shape, generator=generator).to(dtype).requires_grad_() + grad_cpu = torch.randn(shape, generator=generator) + gate = gate_cpu.detach().to("npu").requires_grad_() + up = up_cpu.detach().to("npu").requires_grad_() + for method in ("forward", "forward_fp32"): + gate.grad = up.grad = gate_cpu.grad = up_cpu.grad = None + out = getattr(npu_op, method)(gate, up) + ref = getattr(NativeSwiGLUOp(), method)(gate_cpu, up_cpu) + assert out.device == gate.device and out.shape == gate.shape + assert out.dtype == ref.dtype + out.backward(grad_cpu.to(device="npu", dtype=out.dtype)) + ref.backward(grad_cpu.to(ref.dtype)) + rtol, atol = _ascend_dtype_tolerance(out.dtype) + torch.testing.assert_close(out.cpu(), ref, rtol=rtol, atol=atol) + rtol, atol = _ascend_dtype_tolerance(dtype) + torch.testing.assert_close(gate.grad.cpu(), gate_cpu.grad, rtol=rtol, atol=atol) + torch.testing.assert_close(up.grad.cpu(), up_cpu.grad, rtol=rtol, atol=atol) + torch.testing.assert_close(gate.detach().cpu(), gate_cpu.detach(), rtol=0, atol=0) + torch.testing.assert_close(up.detach().cpu(), up_cpu.detach(), rtol=0, atol=0) + + +@requires_npu +@pytest.mark.parametrize("dtype", _ASCEND_DTYPES) +def test_npu_strided_inputs_and_upstream_gradient(npu_op, dtype): + # Chunked gate/up projections and a transposed upstream gradient. + packed = torch.randn(5, 66, dtype=dtype, device="npu", requires_grad=True) + gate, up = packed.chunk(2, dim=-1) + assert not gate.is_contiguous() and not up.is_contiguous() + dy = torch.randn(33, 5, dtype=dtype, device="npu").t() + result = npu_op(gate, up) + result.backward(dy) + ref_packed = packed.detach().cpu().requires_grad_() + ref = NativeSwiGLUOp()(*ref_packed.chunk(2, dim=-1)) + ref.backward(dy.cpu()) + rtol, atol = _ascend_dtype_tolerance(dtype) + torch.testing.assert_close(result.cpu(), ref, rtol=rtol, atol=atol) + torch.testing.assert_close(packed.grad.cpu(), ref_packed.grad, rtol=rtol, atol=atol) + + +@requires_npu +@pytest.mark.parametrize("dtype", _ASCEND_DTYPES) +def test_npu_batch_position_and_repeat_invariance(npu_op, dtype): + gate = torch.randn(33, dtype=dtype, device="npu") + up = torch.randn_like(gate) + dy = torch.randn_like(gate) + + def evaluate(g, u, grad): + g = g.detach().requires_grad_() + u = u.detach().requires_grad_() + out = npu_op(g, u) + return (out.detach(), *torch.autograd.grad(out, (g, u), grad)) + + expected = evaluate(gate, up, dy) + for rows, position in ((1, 0), (7, 3), (65, 64), (65, 64)): + g = torch.randn(rows, 33, dtype=dtype, device="npu") + u, grad = torch.randn_like(g), torch.randn_like(g) + g[position], u[position], grad[position] = gate, up, dy + actual = evaluate(g, u, grad) + for a, e in zip(actual, expected): + assert torch.equal(a[position], e) + + +@requires_npu +def test_npu_input_validation_and_native_boundary(npu_op): + x = torch.ones(7, device="npu") + with pytest.raises(ValueError, match="share shape"): + npu_op(x, x[:3]) + with pytest.raises(TypeError, match="share dtype"): + npu_op(x, x.half()) + with pytest.raises(TypeError, match="fp16, bf16, or fp32"): + npu_op(x.int(), x.int()) + with pytest.raises(RuntimeError, match="contiguous"): + ascend._C_npu.swiglu_forward(x[::2], x[::2]) + with pytest.raises(RuntimeError, match="share shape"): + ascend._C_npu.swiglu_backward(x[:3], x, x) + with pytest.raises(RuntimeError, match="share dtype"): + ascend._C_npu.swiglu_backward(x.half(), x, x) + + +@requires_npu +def test_npu_current_stream_ordering(npu_op): + stream = torch.npu.Stream() + with torch.npu.stream(stream): + gate = torch.randn(4099, device="npu").mul_(2).requires_grad_() + up = torch.randn_like(gate).requires_grad_() + dy = torch.randn_like(gate) + actual = npu_op(gate, up) + grads = torch.autograd.grad(actual, (gate, up), dy) + expected = NativeSwiGLUOp()(gate, up) + ref_grads = torch.autograd.grad(expected, (gate, up), dy) + stream.synchronize() + for actual_tensor, expected_tensor in zip((actual, *grads), (expected, *ref_grads)): + torch.testing.assert_close(actual_tensor, expected_tensor, rtol=1e-5, atol=1e-5) + + +@requires_npu +def test_npu_device_guard_and_cross_device_rejection(npu_op): + if torch.npu.device_count() < 2: + pytest.skip("Two NPUs required") + with torch.npu.device(0): + gate = torch.randn(33, device="npu:1", requires_grad=True) + up = torch.randn_like(gate, requires_grad=True) + out = npu_op(gate, up) + out.sum().backward() + assert torch.npu.current_device() == 0 + assert out.device == gate.device == gate.grad.device == up.grad.device + torch.testing.assert_close(out, NativeSwiGLUOp()(gate, up), rtol=1e-5, atol=1e-5) + with pytest.raises(RuntimeError, match="same NPU device"): + npu_op(gate, up.to("npu:0")) + with pytest.raises(RuntimeError, match="same NPU device"): + ascend._C_npu.swiglu_forward(gate, up.to("npu:0")) diff --git a/tests/test_ws1_ascend_closeout.py b/tests/test_ws1_ascend_closeout.py new file mode 100644 index 00000000..0aed7298 --- /dev/null +++ b/tests/test_ws1_ascend_closeout.py @@ -0,0 +1,353 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 RL-Kernel Contributors + +"""WS1 #266 closeout wiring for the Ascend BF16 profile (CPU-only). + +These tests do not need an NPU. They assert that ``ascend_bf16`` is a +first-class required profile everywhere C1-C11 look, that every declared +candidate names a real importable object, and that the gates fail closed +rather than borrowing another vendor's kernels when no NPU is present. +""" + +from __future__ import annotations + +import importlib +import re +from pathlib import Path + +import pytest + +from rl_engine.kernels.gtest.accelerator import ( + ACCELERATOR_TYPES, + AcceleratorUnavailable, + candidate_family, + device_type_for_profile, + disable_tf32, + family_for_profile, + is_available, + resolve_device, +) +from rl_engine.kernels.gtest.elementwise_inventory import inventory_items, unresolved_needs_fix +from rl_engine.kernels.gtest.four_judgment_matrix import ( + C8_REQUIRED_OPS, + JUDGMENTS, + PROFILES, + TIERS, + build_classified_matrix, + hidden_required_na, + undefined_cells, +) +from rl_engine.kernels.gtest.gradient_adapters import ( + GRADIENT_ADAPTERS, + gradient_adapter_status_matrix, + resolve_profile_candidate, +) +from rl_engine.kernels.gtest.operator_specs import OP_SPECS +from rl_engine.kernels.gtest.tolerance import ( + BackendProvenance, + ContractResolveError, + load_contract, + resolve_dtype_policy, + validate_backend_provenance, +) +from rl_engine.testing.ws1_workload import ( + load_manifest, + manifest_identity_hash, + profile_required_nodes, +) + +REPO_ROOT = Path(__file__).resolve().parents[1] +PROFILE = "ascend_bf16" +REQUIRED_CHAIN_NODES = ( + "embedding", + "rms_norm", + "det_gemm", + "qk_norm", + "rope", + "attention", + "swiglu", + "silu", + "lm_head", + "logprob", + "batch_invariant_logp", +) + + +def _load_object(path: str): + module_path, name = path.rsplit(".", 1) + return getattr(importlib.import_module(module_path), name) + + +# -------------------------------------------------------------------------- +# C1 (#267): contract +# -------------------------------------------------------------------------- + + +def test_c1_contract_declares_ascend_as_a_required_profile(): + contract = load_contract() + policy = resolve_dtype_policy(contract) + assert PROFILE in policy.backend_profiles + contracts = contract["policy"]["backend_profile_contracts"] + assert contracts[PROFILE]["backend_family"] == "ascend" + + +def test_c1_ascend_provenance_validates_and_rejects_borrowed_backends(): + contract = load_contract() + + def provenance(actual: str) -> BackendProvenance: + return BackendProvenance( + backend_profile=PROFILE, + requested_backend="ascend", + actual_backend=actual, + execution_dtype="bfloat16", + accumulation_dtype="float32", + output_dtype="bfloat16", + reference_dtype="float32", + candidate_tf32_enabled=False, + reference_tf32_enabled=False, + ) + + validate_backend_provenance(contract, provenance("ascend")) + # Reporting a CUDA kernel under the Ascend profile is the undeclared + # fallback C1 exists to catch. + with pytest.raises(ContractResolveError): + validate_backend_provenance(contract, provenance("cuda")) + + +def test_c1_ascend_has_no_private_tolerance_relaxation(): + policy = resolve_dtype_policy(load_contract()) + assert policy.backend_private_tolerance_relaxation is False + assert policy.execution_dtype == "bfloat16" + assert policy.reference_dtype == "float32" + + +# -------------------------------------------------------------------------- +# C2 (#268): workload manifest +# -------------------------------------------------------------------------- + + +def test_c2_manifest_declares_every_required_ascend_node(): + manifest = load_manifest() + assert PROFILE in manifest.backend_profiles + profile = manifest.backend_profiles[PROFILE] + assert profile["backend_family"] == "ascend" + assert profile["execution_dtype"] == "bfloat16" + nodes = {n["node"]: n for n in profile_required_nodes(manifest, PROFILE)} + assert set(nodes) == set(REQUIRED_CHAIN_NODES) + for node in nodes.values(): + assert node["status"] == "declared", node + assert node["expected_backend_id"] == "ascend", node + assert node["expected_kernel_config_id"], node + assert node["algorithm_property"], node + + +def test_c2_identity_hash_covers_the_added_profile(): + manifest = load_manifest() + assert manifest.raw["fixture_identity_sha256"] == manifest_identity_hash(manifest.raw) + + +def test_c2_ascend_representative_cases_mirror_the_cuda_tiers(): + manifest = load_manifest() + by_profile: dict[str, set[tuple[str, str]]] = {} + for case in manifest.representative_cases: + op = str(case.get("op_name") or case["operator_spec"]) + fixture = str(case["fixture_id"]) + tier = ( + "short" + if fixture.startswith("short_") + else "primary" if fixture.startswith("rep_") else fixture + ) + for profile in case["profile_ids"]: + by_profile.setdefault(profile, set()).add((op, tier)) + assert by_profile["cuda_bf16"] == by_profile[PROFILE] + + +def test_c2_ascend_cases_pin_real_ascend_kernels_and_sources(): + manifest = load_manifest() + cases = [c for c in manifest.representative_cases if PROFILE in c["profile_ids"]] + assert cases + for case in cases: + assert case["expected_backend_id"] == "ascend" + assert case["actual_backend_id"] == case["expected_backend_id"] + evidence = case["provenance_evidence"] + assert evidence["candidate_name"] == "ascend" + assert evidence["resolved_path"] == case["actual_kernel_config_id"] + # The algorithm source must be an .asc kernel that exists in-tree. + source = evidence["algorithm_source"] + path, _, symbol = source.partition(":") + assert path.endswith(".asc"), source + assert (REPO_ROOT / path).is_file(), source + assert symbol in (REPO_ROOT / path).read_text(encoding="utf-8"), source + assert "--device npu" in evidence["runtime_evidence_command"] + + +# -------------------------------------------------------------------------- +# C3 / C4 (#269, #270): harness adapters +# -------------------------------------------------------------------------- + + +def test_c3_c4_every_required_adapter_resolves_an_ascend_candidate(): + manifest = load_manifest() + for name, adapter in GRADIENT_ADAPTERS.items(): + if adapter.requirement not in ("required",): + continue + resolved = resolve_profile_candidate(adapter, PROFILE, manifest) + assert resolved["status"] == "declared", name + assert resolved["expected_backend_id"] == "ascend", name + assert resolved["candidate_path"], name + assert candidate_family(str(resolved["expected_backend_id"])) == "ascend" + + +def test_c4_adapter_status_matrix_has_no_red_ascend_rows(): + rows = [r for r in gradient_adapter_status_matrix() if r.backend_profile == PROFILE] + assert rows + assert not [r for r in rows if r.tracked_red or r.untracked_red] + + +def test_operator_specs_expose_an_importable_ascend_candidate_per_chain_node(): + manifest = load_manifest() + spec_map = manifest.raw["capabilities"]["operator_spec_map"] + for node in REQUIRED_CHAIN_NODES: + spec = OP_SPECS[spec_map[node]] + assert "ascend" in spec.candidate_paths, node + path = spec.candidate_paths["ascend"] + # Import the class without constructing it: the constructors demand a + # compiled _C_npu, which a CPU test host does not have. + assert _load_object(path).__name__, path + + +# -------------------------------------------------------------------------- +# C5 (#271): elementwise / RoPE residual inventory +# -------------------------------------------------------------------------- + + +def test_c5_inventory_carries_an_ascend_verdict_with_no_blockers(): + items = inventory_items() + assert items + for item in items: + assert item.ascend_verdict in ( + "pass", + "blocker", + "blocked_hardware", + "tracked_red", + "absent_not_required", + ) + assert "ascend_verdict" in item.to_dict() + assert unresolved_needs_fix() == () + + +# -------------------------------------------------------------------------- +# C8 (#274): four-judgment matrix +# -------------------------------------------------------------------------- + + +def test_c8_matrix_includes_ascend_in_the_required_profiles(): + assert PROFILE in PROFILES + report = build_classified_matrix() + keys = { + (c.profile, c.op_name, c.judgment, c.tier) for c in report.cells if c.profile == PROFILE + } + assert keys == { + (PROFILE, op, judgment, tier) + for op in C8_REQUIRED_OPS + for judgment in JUDGMENTS + for tier in TIERS + } + assert undefined_cells(report) == () + assert not [c for c in hidden_required_na(report) if c.profile == PROFILE] + + +def test_c8_matrix_can_be_scoped_to_one_hosts_profiles(): + # A GPU host cannot execute the Ascend cells and vice versa, so each + # vendor's CI job sweeps its own profiles. + report = build_classified_matrix(profiles=(PROFILE,)) + assert {c.profile for c in report.cells} == {PROFILE} + + +def test_c8_ascend_cells_are_never_silently_na(): + report = build_classified_matrix(profiles=(PROFILE,)) + for cell in report.cells: + if cell.op_name == "pack": + continue + assert cell.status != "N/A" or "optional_fused" in (cell.detail or "") + + +# -------------------------------------------------------------------------- +# C9-C11: device abstraction and CI wiring +# -------------------------------------------------------------------------- + + +def test_accelerator_maps_the_ascend_profile_to_the_npu(): + assert device_type_for_profile(PROFILE) == "npu" + assert family_for_profile(PROFILE) == "ascend" + assert device_type_for_profile("cuda_bf16") == "cuda" + assert family_for_profile("triton_cuda_bf16") == "triton" + assert "npu" in ACCELERATOR_TYPES + + +def test_candidate_family_maps_ascend_ids(): + assert candidate_family("ascend") == "ascend" + assert candidate_family("npu") == "ascend" + assert candidate_family("cuda-sm90") == "cuda" + assert candidate_family("triton") == "triton" + + +def test_resolve_device_fails_closed_without_an_npu(): + if is_available("npu"): + pytest.skip("this host has an NPU; the fail-closed path cannot be exercised") + with pytest.raises(AcceleratorUnavailable): + resolve_device(None, profile=PROFILE) + # Pointing an Ascend profile at a CUDA device is the cross-vendor fallback + # the contract forbids, and is rejected before any device probe. + with pytest.raises(AcceleratorUnavailable): + resolve_device("cuda:0", profile=PROFILE) + + +def test_tf32_policy_holds_on_npu_without_a_tf32_switch(): + # Ascend has no TF32 mode, so the contract's "disabled" clause is satisfied + # by construction and the reported flag must still be False. + assert disable_tf32("npu") is False + + +@pytest.mark.parametrize( + "script", + [ + "scripts/check_forward_invariance.py", + "scripts/check_gradient_invariance.py", + "scripts/check_decode_prefill.py", + "scripts/check_stateful_kv.py", + "scripts/ws1_chain_gate.py", + "scripts/ws1_chain_fwd_bwd.py", + "scripts/ws1_candidate_evidence.py", + ], +) +def test_c3_to_c10_clis_accept_the_ascend_profile(script): + source = (REPO_ROOT / script).read_text(encoding="utf-8") + assert PROFILE in source, f"{script} does not offer {PROFILE}" + + +def test_c11_ascend_ci_entry_points_exist_and_target_the_ascend_profile(): + ci_script = REPO_ROOT / "ci" / "run_ws1_ascend_ci.sh" + assert ci_script.is_file() + body = ci_script.read_text(encoding="utf-8") + for expected in ( + "--backend-profile ascend_bf16", + "--profile ascend_bf16", + "KERNEL_ALIGN_FORCE_ASCEND=1", + "run_ws1_chain_gate.sh", + ): + assert expected in body, expected + + workflow = REPO_ROOT / ".github" / "workflows" / "ws1-chain-npu.yml" + assert workflow.is_file() + workflow_body = workflow.read_text(encoding="utf-8") + assert "run_ws1_ascend_ci.sh" in workflow_body + assert "pull_request_target:" not in workflow_body + + +def test_c11_chain_gate_script_is_profile_parameterised(): + body = (REPO_ROOT / "ci" / "run_ws1_chain_gate.sh").read_text(encoding="utf-8") + assert "WS1_PROFILES" in body + # The embedded verifier must know the Ascend family, or an Ascend run would + # be checked against the wrong backward provenance. + assert re.search(r'"ascend_bf16":\s*"ascend"', body) diff --git a/tests/test_ws1_gtest_gpu.py b/tests/test_ws1_gtest_gpu.py index ae4efa51..2f4ec864 100644 --- a/tests/test_ws1_gtest_gpu.py +++ b/tests/test_ws1_gtest_gpu.py @@ -48,6 +48,7 @@ def test_all_ws1_single_ops_are_registered(): "swiglu", "pack", "linear_logp", + "prefix_shared_attention", } <= names diff --git a/tests/test_ws1_workload.py b/tests/test_ws1_workload.py index 00c6325f..33c6c972 100644 --- a/tests/test_ws1_workload.py +++ b/tests/test_ws1_workload.py @@ -517,7 +517,7 @@ def test_candidate_evidence_cli_help_is_available(): timeout=60, ) assert proc.returncode == 0, proc.stderr - assert "representative candidates on a real GPU" in proc.stdout + assert "representative candidates on a real accelerator" in proc.stdout def test_build_chunk_plan_edges():