TileOPs-Metax/.github/runner
Ang Gao aa8afb7369
[CI][Runner] Fix benchmark baseline dependencies (#1710)
## Summary
- fix the current nightly benchmark failure in the FP8 GEMM FlashInfer
baseline
- make the `flashinfer-cubin` cubin directories writable by the
unprivileged `ci-runner` user without touching packaged `.cubin` files
- add `mamba-ssm==2.3.1` for the official Mamba Triton benchmark
baseline, including its import-time CUDA extension, addressing #1643 for
the TileOps CI runner image
- add DeepGEMM `v2.1.1.post3` for the grouped-GEMM benchmark baseline
requested in #1643
- document that runner image rollout is manual and happens outside this
repository

## Problem
The nightly benchmark is currently failing in the FP8 GEMM benchmark
cases. Those benchmark cases call the FlashInfer baseline through
`flashinfer.mm_fp8(...)`. Before the benchmark can compare performance,
FlashInfer tries to prepare TRTLLM GEMM cubin entries under the
directory returned by `flashinfer_cubin.get_cubin_dir()`.

In the self-hosted runner image, `flashinfer-cubin` is installed by pip
during the Docker build while the build is still running as root. As a
result, its packaged cubin directories under site-packages are
root-owned. Later, GitHub Actions jobs run as the unprivileged
`ci-runner` user. When the nightly benchmark reaches the FlashInfer FP8
baseline, that user cannot create/populate cubin subdirectories, for
example:

```text
/usr/local/lib/python3.12/dist-packages/flashinfer_cubin/cubins/flashinfer/trtllm/gemm
```

So the benchmark fails with a permission error before the actual
performance comparison runs. A workflow-only cache/env change is not
sufficient for this failure mode because when the `flashinfer-cubin`
package is installed, FlashInfer uses that packaged cubin directory
first. The root-owned directories therefore need to be fixed inside the
runner image.

Separately, #1643 points out that the runner image does not include
`mamba-ssm`, so the Mamba/Mamba-2 benchmarks cannot use the official
`mamba_ssm.ops.triton.*` baseline. A follow-up comment on #1643 also
requests DeepGEMM for the grouped-GEMM benchmark baseline. This PR
handles the TileOPs CI runner image side of those dependency requests.

## Fix
In the final runner image stage, after creating `ci-runner`, resolve
`flashinfer_cubin.get_cubin_dir()` inside the image, pre-create the
TRTLLM GEMM cubin subdirectory, and chown only directory inodes under
the cubin root. This gives FlashInfer writable directories for current
and sibling cubin subtrees without recursively chowning packaged
`.cubin` files, avoiding Docker layer bloat from OverlayFS copy-up.

For the Mamba benchmark baseline, install `mamba-ssm==2.3.1` in the
`fullstack` stage as a benchmark dependency. The install uses
`MAMBA_FORCE_BUILD=TRUE`, `--no-build-isolation`, `--no-deps`, and the
existing torch cu129 constraints. `mamba_ssm` imports
`selective_scan_cuda` at package import time, so the CUDA extension is
built and included even though the TileOps benchmarks use the official
`mamba_ssm.ops.triton.*` modules. I intentionally did not use the
current latest `mamba-ssm==2.3.2.post1` because that release declares
`tilelang==0.1.8` and `apache-tvm-ffi<=0.1.9`, which conflicts with this
runner image's TileOps/tilelang runtime stack.

For the grouped-GEMM benchmark baseline, install DeepGEMM from the
official `deepseek-ai/DeepGEMM` source as a benchmark dependency, pinned
to commit `c9f8b34dcdacc20aa746b786f983492c51072870` (the `v2.1.1.post3`
tag target). The install uses `--no-build-isolation --no-deps` and
fetches only the required header submodules. The PyPI `deep_gemm==1.0.0`
sdist is stale relative to the current upstream version used for this
benchmark surface.

For TileLang source builds, explicitly initialize
`3rdparty/composable_kernel` together with `3rdparty/cutlass` and
`3rdparty/tvm`. Without that, CMake can trigger a hidden network fetch
for the missing submodule during the wheel build. The wheel build also
passes `CMAKE_BUILD_PARALLEL_LEVEL=${MAX_JOBS}` so the local image
rebuild can use the intended build parallelism.

## Rollout note
This repository does not build the self-hosted runner image in CI. After
this lands, a maintainer still needs to rebuild and push the runner
image from a GPU host, then redeploy the self-hosted runner launcher to
use the new tag outside this repository.

Merging this TileOPs PR only changes the image recipe. The live
self-hosted runners keep using their existing image until that manual
rollout is done.

## Test
- `pre-commit run mdformat --files .github/runner/README.md`
- `git diff --check`
- Local GPU-host runner image build completed from the updated recipe
using pre-cloned source/tarball BuildKit contexts for network-heavy
inputs; final image id
`sha256:73768b2992f26cde6703b6eb15bba9f9438048a5a9e416154bf893275776f326`.
- Runtime probe inside the built image passed: torch `2.10.0+cu129`,
CUDA `12.9`, tilelang `0.1.11+cu129.git65dbc983`, FlashInfer cubin write
probe, `selective_scan_cuda`, mamba `2.3.1`, and DeepGEMM `2.1.1`.
- The verified image was retagged locally to
`ghcr.io/tile-ai/tileops-runner:65dbc98-torch2.10`; `ci-runner-gpu2` and
`ci-runner-gpu3` were recreated from that tag and now run image id
`sha256:73768b2992f26cde6703b6eb15bba9f9438048a5a9e416154bf893275776f326`.
2026-07-15 19:24:02 +08:00
..
Dockerfile [CI][Runner] Fix benchmark baseline dependencies (#1710) 2026-07-15 19:24:02 +08:00
README.md [CI][Runner] Fix benchmark baseline dependencies (#1710) 2026-07-15 19:24:02 +08:00
entrypoint.sh [CI][Runner] unset RUNNER_TOKEN before the runner starts (#1688) 2026-07-13 15:40:30 +08:00

README.md

CI runner image

Multi-stage image for the self-hosted GPU runner. It bakes a tilelang wheel (compiled once from a pinned commit, or installed from a PyPI release) plus the test/benchmark stack onto a public CUDA base, so CI never recompiles tilelang per PR.

Built manually on a GPU host (needs nvcc), then pushed to ghcr.io. It is not built in CI.

Prerequisites

  • An NVIDIA GPU host with a CUDA 12.9-capable driver and nvcc.
  • Docker with BuildKit enabled (DOCKER_BUILDKIT=1).
  • Run from the repository root — the build context must contain constraints.txt, scripts/ci/verify_runtime_stack.py, and .github/runner/entrypoint.sh (the Dockerfile copies all three).

Build

Provide tilelang one of two ways — pass exactly one of these build-args:

  • main commit: --build-arg TILELANG_GIT_SHA=<commit> — shallow-fetches and compiles that commit. The Dockerfile carries no commit literal; the commit you pass is the single source of truth, and the image tag records it.
  • release: --build-arg TILELANG_VERSION=<version>pip install tilelang==<version>.
# from the repository root (main-commit mode)
DOCKER_BUILDKIT=1 docker build \
  -f .github/runner/Dockerfile \
  --target final \
  --build-arg TILELANG_GIT_SHA=65dbc9837beedf6882a40a08e18ea571d92fd6a5 \
  -t ghcr.io/tile-ai/tileops-runner:65dbc98 \
  .

Tag with the tilelang commit's short SHA (:65dbc98). If you rebuild the same commit, add a numeric suffix (:65dbc98-2).

Roll out an updated runner image

Changes to this Dockerfile are not picked up by CI automatically. After a Dockerfile change lands, rebuild and tag the image from a GPU host using the build command above, then push it:

docker push ghcr.io/tile-ai/tileops-runner:<new-tag>

Then redeploy the self-hosted runner launcher to use the new tag (maintainer task, done outside this repository). Merging the TileOPs PR only changes the image recipe; the live self-hosted runners keep using their existing image until that manual rollout is done.

Build args

Arg Default Purpose
TILELANG_GIT_SHA (none) tilelang commit to shallow-clone and compile (main mode).
TILELANG_VERSION (none) tilelang PyPI version to pip install (release mode).
BASE_IMAGE nvidia/cuda:12.9.1-devel-ubuntu22.04 Public CUDA devel base (Python 3.12 via deadsnakes).
MAX_JOBS 64 Parallelism for the tilelang / FA2 / FA3 source builds.
NVCC_THREADS 4 Per-nvcc threads.
DEEPGEMM_GIT_SHA c9f8b34dcdacc20aa746b786f983492c51072870 DeepGEMM commit for the grouped-GEMM benchmark baseline (v2.1.1.post3).
RUNNER_VERSION 2.334.0 GitHub Actions runner version baked into final.

Set exactly one of TILELANG_GIT_SHA / TILELANG_VERSION; the build fails fast if neither is set.

Stages (--target)

Stage Contents
runtime Python 3.12 + torch / torchvision / torchaudio 2.10.0 / 0.25.0 / 2.10.0 +cu129 + triton 3.6.0 + tilelang build/runtime deps (incl. apache-tvm-ffi 0.1.11). No tilelang itself.
post-fa3 runtime + pytest / pytest-xdist / ruff + FlashAttention-3 (built from the hopper/ source).
fa2 post-fa3 + FlashAttention-2 (flash-attn 2.8.3, source-built in its own layer so changes to the bench loop never recompile it).
fullstack fa2 + flash-linear-attention 0.4.2 + vLLM 0.19.1 + mamba-ssm 2.3.1 + DeepGEMM 2.1.1.post3, then flashinfer-python/-cubin upgraded to 0.6.11.post2 (--no-deps, so torch stays +cu129). sgl-kernel is not installed.
tilelang fullstack + the tilelang wheel (--no-deps), then the build-time guard. Built last so a SHA bump rebuilds only this layer.
final tilelang + the GitHub Actions runner (no TileOPs source baked).

Build an earlier stage for debugging with --target runtime (etc.).

The tilelang stage ends by running scripts/ci/verify_runtime_stack.py (GPU-free): it fails the build unless tilelang imports, the installed apache-tvm-ffi sits inside the tilelang wheel's declared range, and torch is still the cu129 build.

Verify the built image

docker run --rm --gpus all ghcr.io/tile-ai/tileops-runner:65dbc98 python - <<'PY'
import torch
print("torch", torch.__version__, "cuda", torch.version.cuda)   # expect 2.10.0+cu129, cuda 12.9
import tilelang; print("tilelang", tilelang.__version__)
import flashinfer, flashinfer_cubin
print("flashinfer", flashinfer.__version__)
from pathlib import Path
cubin_dir = Path(flashinfer_cubin.get_cubin_dir())
probe_dir = cubin_dir / "flashinfer" / "trtllm" / "gemm" / "_tileops_write_probe"
probe_dir.mkdir(parents=True, exist_ok=True)
(probe_dir / "probe.txt").write_text("ok")
print("flashinfer cubin write OK")

import selective_scan_cuda
import mamba_ssm
from mamba_ssm.ops.triton.ssd_combined import mamba_chunk_scan_combined
assert mamba_chunk_scan_combined is not None
print("mamba", mamba_ssm.__version__)

import deep_gemm
print("deep_gemm", deep_gemm.__version__)

# cuBLAS probe: matmul / bmm / einsum on the GPU
a = torch.randn(512, 512, device="cuda", dtype=torch.float16)
b = torch.randn(512, 512, device="cuda", dtype=torch.float16)
assert torch.matmul(a, b).isfinite().all()
ab = torch.randn(8, 128, 128, device="cuda", dtype=torch.float16)
assert torch.bmm(ab, ab).isfinite().all()
assert torch.einsum("bik,bkj->bij", ab, ab).isfinite().all()
print("cuBLAS probe OK")
PY

Then run the smoke tests against a checkout of this repo:

docker run --rm --gpus all -v "$PWD:/src" -w /src \
  ghcr.io/tile-ai/tileops-runner:65dbc98 \
  bash -c 'scripts/ci/install_tileops.sh && pytest -m smoke'

install_tileops.sh installs TileOPs --no-deps against the baked stack; it fails fast if tilelang is missing (the image provides it).

Run as a self-hosted runner

entrypoint.sh registers an ephemeral runner (one job per container), then deregisters on exit. Provide a registration token and the target URL; bind-mount the host cache. The entrypoint removes RUNNER_TOKEN from the environment before the runner starts, so jobs cannot read the registration token.

docker run -d --gpus all \
  -e RUNNER_URL=https://github.com/tile-ai/TileOPs \
  -e RUNNER_TOKEN=<registration-token> \
  -e RUNNER_LABELS=self-hosted,tile-ops,venv \
  -v <host-cache-dir>:/ci-cache \
  ghcr.io/tile-ai/tileops-runner:65dbc98

The image sets cache env vars (TILELANG_CACHE_DIR, TRITON_CACHE_DIR, PIP_CACHE_DIR, …) under /ci-cache; the directories are pre-created so the container also works unmounted.

Bumping the tilelang commit

A commit (or release) bump always rebuilds, but never edits the Dockerfile: rebuild with a new --build-arg TILELANG_GIT_SHA=<commit> (or TILELANG_VERSION=<version>) and a new :<short-sha> tag, push to ghcr.io, then point the runner at the new tag. Because tilelang is the last stage, only its layer recompiles — the bench layers (FA2 / FA3 / vLLM / …) stay cached. Switching between a release and a main commit is the same — only the build-arg and tag change.