Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 95 additions & 0 deletions .github/workflows/ws1-chain-npu.yml
Original file line number Diff line number Diff line change
@@ -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
143 changes: 143 additions & 0 deletions ci/run_ws1_ascend_ci.sh
Original file line number Diff line number Diff line change
@@ -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"
28 changes: 21 additions & 7 deletions ci/run_ws1_chain_gate.sh
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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 \
Expand All @@ -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
Expand All @@ -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 \
Expand Down Expand Up @@ -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"):
Expand Down Expand Up @@ -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"
Loading
Loading