## 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`. |
||
|---|---|---|
| .. | ||
| Dockerfile | ||
| README.md | ||
| entrypoint.sh | ||
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.