sgl-router: experimental Rust HTTP router for SGLang worker pools (#25851)

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kangyan-Zhou
2026-05-25 15:34:05 +08:00
committed by GitHub
co-authored by Claude Opus 4.7
parent aae04b1241
commit 6e8fe176be
131 changed files with 28623 additions and 55 deletions
+141 -54
View File
@@ -1,35 +1,101 @@
name: PR Test (sgl-router)
# Trigger contract — modeled on `pr-test.yml`:
#
# * No `paths:` filter at the trigger level. The workflow ALWAYS
# fires on every PR synchronize / push, so the run record always
# appears as a check on the PR (no more "workflow silently didn't
# fire" mystery debugging). Path-based skip decisions are made
# inside the `sgl-router-gate` job below, where we can log the
# reason. Same for the `run-ci` label gate — moved off the
# workflow trigger and into the gate job so its outcome is
# observable.
#
# * `push` on `main` keeps firing so post-merge runs still record
# against the default branch.
on:
push:
branches: [main]
paths:
- "experimental/sgl-router/**"
- ".github/workflows/pr-test-sgl-router.yml"
- "scripts/ci/cuda/ci_install_dependency.sh"
pull_request:
branches: [main]
types: [opened, synchronize, reopened, labeled]
paths:
- "experimental/sgl-router/**"
- ".github/workflows/pr-test-sgl-router.yml"
- "scripts/ci/cuda/ci_install_dependency.sh"
workflow_dispatch:
concurrency:
group: sgl-router-${{ github.ref }}
group: sgl-router-${{ github.event_name }}-${{ github.head_ref || github.ref_name || 'default' }}
cancel-in-progress: true
env:
SGLANG_IS_IN_CI: true
jobs:
# Stage 0 — decide whether to run the heavy tiers. Always runs
# (cheap, ubuntu-latest), and emits a single `should_run` output
# consumed by every tier below. Reasons it might evict downstream
# work:
# * `pull_request` event without the `run-ci` label (budget gate)
# * No files matching the sgl-router paths changed in this PR
# Either outcome is logged on the gate job's page, so operators can
# see *why* the tiers were skipped instead of guessing.
sgl-router-gate:
name: gate
runs-on: ubuntu-latest
outputs:
should_run: ${{ steps.decide.outputs.should_run }}
paths_changed: ${{ steps.paths.outputs.sgl_router }}
has_run_ci_label: ${{ steps.label.outputs.has_run_ci }}
steps:
- uses: actions/checkout@v4
# `dorny/paths-filter` computes the diff against the PR base
# (for pull_request) or the push's `before` SHA (for push) and
# exposes `sgl_router=true|false` on whether any tracked path
# changed. Replaces the old workflow-level `paths:` filter so
# the gate is observable.
- name: Detect sgl-router path changes
id: paths
uses: dorny/paths-filter@v3
with:
filters: |
sgl_router:
- 'experimental/sgl-router/**'
- '.github/workflows/pr-test-sgl-router.yml'
- 'scripts/ci/cuda/ci_install_dependency.sh'
# `run-ci` label gate. Cheap workflow runs (e.g. docs-only PRs
# that happen to touch the sgl-router dir) still need the
# opt-in label before consuming the H100 / kind tiers below.
# `push` events on main and `workflow_dispatch` skip this gate.
- name: Check run-ci label
id: label
run: |
if [[ "${{ github.event_name }}" != "pull_request" ]]; then
echo "has_run_ci=true" >> "$GITHUB_OUTPUT"
echo "Non-PR event (${{ github.event_name }}); label gate bypassed."
exit 0
fi
if [[ "${{ contains(github.event.pull_request.labels.*.name, 'run-ci') }}" == "true" ]]; then
echo "has_run_ci=true" >> "$GITHUB_OUTPUT"
echo "PR has run-ci label; downstream tiers will run."
else
echo "has_run_ci=false" >> "$GITHUB_OUTPUT"
echo "::warning::PR is missing the 'run-ci' label; skipping sgl-router tiers. Apply the label to opt in."
fi
- name: Decide
id: decide
run: |
paths='${{ steps.paths.outputs.sgl_router }}'
label='${{ steps.label.outputs.has_run_ci }}'
if [[ "$paths" == "true" && "$label" == "true" ]]; then
echo "should_run=true" >> "$GITHUB_OUTPUT"
echo "Both path filter and run-ci label match; running tiers."
else
echo "should_run=false" >> "$GITHUB_OUTPUT"
echo "Skipping tiers (paths_changed=$paths, has_run_ci=$label)."
fi
sgl-router-lint:
name: tier-1 — lint
if: |
github.event_name != 'pull_request' ||
(github.event.action != 'labeled' && contains(github.event.pull_request.labels.*.name, 'run-ci')) ||
(github.event.action == 'labeled' && github.event.label.name == 'run-ci')
needs: sgl-router-gate
if: needs.sgl-router-gate.outputs.should_run == 'true'
runs-on: ubuntu-latest
env:
# Scoped per-job (not workflow-wide) to avoid SMG's documented breakage
@@ -120,11 +186,8 @@ jobs:
sgl-router-build-and-test:
name: tier-2 — build + test
needs: sgl-router-lint
if: |
github.event_name != 'pull_request' ||
(github.event.action != 'labeled' && contains(github.event.pull_request.labels.*.name, 'run-ci')) ||
(github.event.action == 'labeled' && github.event.label.name == 'run-ci')
needs: [sgl-router-gate, sgl-router-lint]
if: needs.sgl-router-gate.outputs.should_run == 'true'
runs-on: ubuntu-latest
env:
RUSTC_WRAPPER: sccache
@@ -177,10 +240,13 @@ jobs:
- name: cargo test
working-directory: experimental/sgl-router
# Skip tokenizer_parity here: ubuntu-latest has no HuggingFace cache,
# so the matrix would hard-fail (see the test docstring). The e2e job
# runs it after pytest populates the Qwen3-0.6B tokenizer.json.
run: cargo test --release -- --skip tokenizer_parity
# Skip the tokenizer parity_matrix test here: ubuntu-latest has
# no HuggingFace cache, so the matrix would hard-fail (see the
# test docstring). The e2e job runs it after pytest populates
# the Qwen3-0.6B tokenizer.json. Filter is matched against the
# full test path `tokenizer::parity::parity_matrix`; substring
# `parity_matrix` is unique to that test.
run: cargo test --release -- --skip parity_matrix
# Regenerate the cross-impl block-hash parity fixture and fail if it
# differs from the committed file. The Python script replicates
@@ -218,11 +284,8 @@ jobs:
sgl-router-docker-build-test:
name: tier-3 — docker (placeholder)
needs: sgl-router-build-and-test
if: |
github.event_name != 'pull_request' ||
(github.event.action != 'labeled' && contains(github.event.pull_request.labels.*.name, 'run-ci')) ||
(github.event.action == 'labeled' && github.event.label.name == 'run-ci')
needs: [sgl-router-gate, sgl-router-build-and-test]
if: needs.sgl-router-gate.outputs.should_run == 'true'
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
@@ -231,13 +294,16 @@ jobs:
echo "docker-build-test not implemented yet."
exit 0
# tier-3 k8s integration is decoupled from tier-3 e2e: each runs on a
# different runner type (ubuntu kind cluster vs H100), each manages its
# own test scope (k8s_integration/ vs everything-else-in-e2e), and
# each is allowed to fail without blocking the other. They both still
# gate on tier-2's build+test passing — a Rust compile failure blocks
# the GPU/cluster runners from spinning up.
sgl-router-k8s-integration:
name: tier-3 — k8s integration
needs: sgl-router-build-and-test
if: |
github.event_name != 'pull_request' ||
(github.event.action != 'labeled' && contains(github.event.pull_request.labels.*.name, 'run-ci')) ||
(github.event.action == 'labeled' && github.event.label.name == 'run-ci')
needs: [sgl-router-gate, sgl-router-build-and-test]
if: needs.sgl-router-gate.outputs.should_run == 'true'
runs-on: ubuntu-22.04
timeout-minutes: 30
steps:
@@ -267,21 +333,30 @@ jobs:
/tmp/e2e-venv/bin/pip install -r experimental/sgl-router/tests/e2e/k8s_integration/requirements.txt
- name: Run E2E
run: /tmp/e2e-venv/bin/pytest experimental/sgl-router/tests/e2e/k8s_integration/ -v --tb=short
- name: Dump router logs on failure
- name: Dump cluster + router diagnostics on failure
if: failure()
run: kubectl -n sgl-router-test logs deploy/sgl-router --tail=200 || true
run: |
set +e
kubectl -n sgl-router-test get pods -o wide
kubectl -n sgl-router-test describe pod -l app=sgl-router
kubectl -n sgl-router-test describe pod -l app=sglang
kubectl -n sgl-router-test logs deploy/sgl-router --tail=300
kubectl -n sgl-router-test logs deploy/sgl-router --tail=300 --previous
kubectl -n sgl-router-test get events --sort-by=.lastTimestamp
kubectl -n sgl-router-test get endpointslices -o wide
kubectl -n sgl-router-test get svc
true
sgl-router-e2e:
name: tier-3 — e2e
needs: sgl-router-build-and-test
if: |
github.event_name != 'pull_request' ||
(github.event.action != 'labeled' && contains(github.event.pull_request.labels.*.name, 'run-ci')) ||
(github.event.action == 'labeled' && github.event.label.name == 'run-ci')
needs: [sgl-router-gate, sgl-router-build-and-test]
if: needs.sgl-router-gate.outputs.should_run == 'true'
runs-on: 2-gpu-h100
timeout-minutes: 45
steps:
- uses: actions/checkout@v4
- name: Install Rust toolchain
run: bash scripts/ci/utils/install_rust_protoc.sh
- name: Rust cache
uses: Swatinem/rust-cache@v2
with:
@@ -289,7 +364,9 @@ jobs:
shared-key: "sgl-router-cache"
- name: cargo build (release)
working-directory: experimental/sgl-router
run: cargo build --release
run: |
source "$HOME/.cargo/env"
cargo build --release
# Install SGLang from the local checkout in editable mode (no PyPI
# version pin) — mirrors `pr-test-rust.yml` so the router e2e runs
@@ -306,41 +383,51 @@ jobs:
run: |
python3 -m pip install -r experimental/sgl-router/tests/e2e/requirements.txt
# IMPORTANT: --ignore=tests/e2e/k8s_integration. The k8s integration
# suite is owned by the `sgl-router-k8s-integration` job which has
# kind + kubectl installed and a real cluster running. The e2e GPU
# runner has neither, so pytest's recursive collection of
# tests/e2e/ would ERROR every k8s test at setup. Both tiers must
# be allowed to fail independently — k8s flakes (cluster boot,
# image pull) must not surface as e2e failures on H100, and e2e
# flakes (model load, HF auth) must not surface as k8s failures.
- name: Run e2e
working-directory: experimental/sgl-router
env:
HF_TOKEN: ${{ secrets.HF_TOKEN }}
run: python3 -m pytest tests/e2e/ -v -s --tb=short
run: |
python3 -m pytest tests/e2e/ -v -s --tb=short \
--ignore=tests/e2e/k8s_integration
- name: Tokenizer parity matrix (uses HF cache populated by e2e)
working-directory: experimental/sgl-router
env:
HF_TOKEN: ${{ secrets.HF_TOKEN }}
# Runs after pytest so the Qwen3-0.6B tokenizer.json is cached by the
# SGLang worker. Cells without a cached snapshot are skipped; if no
# cells could be checked, the test hard-fails under SGLANG_IS_IN_CI.
run: cargo test --release --test component tokenizer::parity
run: |
source "$HOME/.cargo/env"
cargo test --release --test component tokenizer::parity
sgl-router-finish:
name: finish
needs:
- sgl-router-gate
- sgl-router-lint
- sgl-router-build-and-test
- sgl-router-docker-build-test
- sgl-router-k8s-integration
- sgl-router-e2e
# Gate finish on the same label as upstream jobs to avoid false-green on
# unlabeled PRs (skipped needs => success), and fail when any upstream
# job actually failed or was cancelled.
# `always()` lets `finish` run after upstream `skipped` outcomes
# (when the gate decided to skip). The downstream-skipped path is
# an explicit success — the workflow was correctly bypassed for a
# PR that didn't need full CI. The downstream-failure /
# downstream-cancelled path is a real fail. We also accept the
# gate's `skipped` (impossible today, kept for symmetry) and
# require the gate itself to have succeeded.
if: |
always() &&
needs.sgl-router-gate.result == 'success' &&
!contains(needs.*.result, 'failure') &&
!contains(needs.*.result, 'cancelled') &&
(
github.event_name != 'pull_request' ||
(github.event.action != 'labeled' && contains(github.event.pull_request.labels.*.name, 'run-ci')) ||
(github.event.action == 'labeled' && github.event.label.name == 'run-ci')
)
!contains(needs.*.result, 'cancelled')
runs-on: ubuntu-latest
steps:
- name: All required checks completed