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:
co-authored by
Claude Opus 4.7
parent
aae04b1241
commit
6e8fe176be
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user