[kernel] Content-addressed JIT build cache, generated from our own ninja (#34274)

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: BBuf <1182563586@qq.com>
This commit is contained in:
DarkSharpness
2026-08-14 13:41:04 +08:00
committed by GitHub
co-authored by Claude BBuf
parent 240a12b302
commit b784726863
12 changed files with 1928 additions and 341 deletions
-341
View File
@@ -1,341 +0,0 @@
"""JIT compilation: load_jit, the build cache, and C++ template arguments."""
from __future__ import annotations
import hashlib
import importlib.util
import logging
import os
import pathlib
import re
from contextlib import contextmanager
from typing import TYPE_CHECKING, List, Tuple, TypeAlias, Union
import torch
from sglang.kernels.jit.utils.arch import get_default_target_flags, get_jit_cuda_arch
from sglang.kernels.jit.utils.common import cache_once, is_hip_runtime
from sglang.kernels.jit.utils.deps import REGISTERED_DEPENDENCIES
if TYPE_CHECKING:
from tvm_ffi import Module
logger = logging.getLogger(__name__)
_QUOTED_INCLUDE_RE = re.compile(r'^\s*#\s*include\s*"([^"]+)"', re.MULTILINE)
_ANGLE_INCLUDE_RE = re.compile(r"^\s*#\s*include\s*<(sgl_kernel/[^>]+)>", re.MULTILINE)
def _local_jit_source_hash(source_files: List[str]) -> str:
"""Hash JIT source contents so TVM-FFI cache keys track included headers."""
digest = hashlib.sha256()
seen: set[pathlib.Path] = set()
stack = [pathlib.Path(path).resolve() for path in source_files]
include_dir = KERNEL_PATH / "include"
while stack:
path = stack.pop()
if path in seen or not path.is_file():
continue
seen.add(path)
data = path.read_bytes()
# Relative to kernel root, not absolute: the key must track source
# content, not install location (differs across runners / job dirs).
try:
ident = str(path.relative_to(KERNEL_PATH))
except ValueError:
ident = path.name
digest.update(ident.encode())
digest.update(b"\0")
digest.update(data)
digest.update(b"\0")
text = data.decode("utf-8", errors="ignore")
for include in _QUOTED_INCLUDE_RE.findall(text):
include_path = (path.parent / include).resolve()
if include_path.is_file():
stack.append(include_path)
for include in _ANGLE_INCLUDE_RE.findall(text):
include_path = (include_dir / include).resolve()
if include_path.is_file():
stack.append(include_path)
return digest.hexdigest()[:16]
@cache_once
def _resolve_kernel_path() -> pathlib.Path:
# Resolve via the package spec so the lookup is location-independent.
# The C++/CUDA sources (``csrc/``) and headers (``include/``) live next to
# this package under ``sglang.kernels.jit`` (RFC #29630).
spec = importlib.util.find_spec("sglang.kernels.jit")
assert spec is not None and spec.origin is not None
cur_dir = pathlib.Path(spec.origin).parent.resolve()
# first, try this directory structure
def _environment_install():
candidate = cur_dir.resolve()
if (candidate / "include").exists() and (candidate / "csrc").exists():
return candidate
return None
def _package_install():
# TODO: support find path by package
return None
path = _environment_install() or _package_install()
if path is None:
raise RuntimeError("Cannot find sglang.kernels.jit path")
return path
KERNEL_PATH = _resolve_kernel_path()
DEFAULT_INCLUDE = [str(KERNEL_PATH / "include")]
DEFAULT_CFLAGS = ["-std=c++20", "-O3"]
DEFAULT_LDFLAGS = []
CPP_TEMPLATE_TYPE: TypeAlias = Union[int, float, str, bool, torch.dtype]
class CPPArgList(list[str]):
def __str__(self) -> str:
return ", ".join(self)
CPP_DTYPE_MAP = {
torch.float64: "double",
torch.float32: "fp32_t",
torch.float16: "fp16_t",
torch.bfloat16: "bf16_t",
# The fnuz variants are the ROCm-side torch dtypes; fp8_*_t resolves to
# the matching HIP type there (see HIP_FP8_TYPE_* in utils.cuh).
torch.float8_e4m3fn: "fp8_e4m3_t",
torch.float8_e4m3fnuz: "fp8_e4m3_t",
torch.float8_e5m2: "fp8_e5m2_t",
torch.float8_e5m2fnuz: "fp8_e5m2_t",
torch.int8: "int8_t",
torch.int16: "int16_t",
torch.int32: "int32_t",
torch.int64: "int64_t",
torch.uint8: "uint8_t",
torch.uint16: "uint16_t",
torch.uint32: "uint32_t",
torch.uint64: "uint64_t",
torch.bool: "bool",
}
def make_cpp_args(*args: CPP_TEMPLATE_TYPE) -> CPPArgList:
def _convert(arg: CPP_TEMPLATE_TYPE) -> str:
if isinstance(arg, bool):
return "true" if arg else "false"
if isinstance(arg, (int, str, float)):
return str(arg)
if isinstance(arg, torch.dtype):
return CPP_DTYPE_MAP[arg]
raise TypeError(f"Unsupported argument type for cpp template: {type(arg)}")
return CPPArgList(_convert(arg) for arg in args)
@cache_once
def _tvm_ffi_version() -> str:
try:
import tvm_ffi
version = getattr(tvm_ffi, "__version__", None)
if version:
return str(version)
except Exception:
pass
try:
from importlib.metadata import version as dist_version
return dist_version("apache-tvm-ffi")
except Exception:
return "unknown"
def _jit_build_dir_name(module_name: str) -> str:
# Key on arch + tvm-ffi ABI too (module_name only hashes sources), so a
# shared cache volume never reuses a cross-arch/ABI .so.
arch = get_jit_cuda_arch().target_name
return f"{module_name}__arch_{arch}__tvmffi_{_tvm_ffi_version()}"
def _make_wrapper(tup: Tuple[str, str]) -> str:
export_name, kernel_name = tup
return f"TVM_FFI_DLL_EXPORT_TYPED_FUNC({export_name}, ({kernel_name}));"
def _make_sources(files: List[str], wrappers: List[Tuple[str, str]]) -> List[str]:
sources = [f'#include "{path}"' for path in files]
sources += ["namespace sglang {"]
sources += [_make_wrapper(tup) for tup in wrappers]
sources += ["} // namespace sglang"]
return sources
# JIT compilation is pure Python/filesystem plumbing (path `.resolve()` calls
# `os.lstat`, etc.) that Dynamo cannot trace. When a lazily-loaded kernel is
# first reached from inside a `@torch.compile`d region, tracing into it produces
# spurious "Dynamo does not know how to trace the builtin `posix.lstat`" graph
# breaks. The load happens once and is memoized, so keep it out of the graph.
@torch.compiler.disable
def load_jit(
*args: str,
cpp_files: List[str] | None = None,
cuda_files: List[str] | None = None,
external_cpp_files: List[str] | None = None,
external_cuda_files: List[str] | None = None,
cpp_wrappers: List[Tuple[str, str]] | None = None,
cuda_wrappers: List[Tuple[str, str]] | None = None,
extra_cflags: List[str] | None = None,
extra_cuda_cflags: List[str] | None = None,
extra_ldflags: List[str] | None = None,
extra_include_paths: List[str] | None = None,
extra_dependencies: List[str] | None = None,
build_directory: str | None = None,
header_only: bool = True,
) -> Module:
"""
Loading a JIT module from C++/CUDA source files.
We define a wrapper as a tuple of (export_name, kernel_name),
where `export_name` is the name used to called from Python,
and `kernel_name` is the name of the kernel class in C++/CUDA source.
:param args: Unique marker of the JIT module. Must be distinct for different kernels.
:type args: str
:param cpp_files: A list of C++ source files.
:type cpp_files: List[str] | None
:param cuda_files: A list of CUDA source files.
:type cuda_files: List[str] | None
:param external_cpp_files: A list of caller-resolved C++ source paths outside
the in-tree JIT source directory.
:type external_cpp_files: List[str] | None
:param external_cuda_files: A list of caller-resolved CUDA source paths outside
the in-tree JIT source directory.
:type external_cuda_files: List[str] | None
:param cpp_wrappers: A list of C++ wrappers, defining the export name and kernel name.
:type cpp_wrappers: List[Tuple[str, str]] | None
:param cuda_wrappers: A list of CUDA wrappers, defining the export name and kernel name.
:type cuda_wrappers: List[Tuple[str, str]] | None
:param extra_cflags: Extra C++ compiler flags.
:type extra_cflags: List[str] | None
:param extra_cuda_cflags: Extra CUDA compiler flags.
:type extra_cuda_cflags: List[str] | None
:param extra_ldflags: Extra linker flags.
:type extra_ldflags: List[str] | None
:param extra_include_paths: Extra include paths.
:type extra_include_paths: List[str] | None
:param extra_dependencies: Extra dependencies for the JIT module, e.g., cutlass.
:type extra_dependencies: List[str] | None
:param build_directory: The build directory for JIT compilation.
:type build_directory: str | None
:param header_only: Whether the module is header-only.
If true, apply the wrappers to export given class/functions.
Otherwise, we must export from C++/CUDA side.
:return: A just-in-time(JIT) compiled module.
:rtype: Module
"""
from tvm_ffi.cpp import load, load_inline
cpp_files = cpp_files or []
cuda_files = cuda_files or []
external_cpp_files = external_cpp_files or []
external_cuda_files = external_cuda_files or []
extra_cflags = extra_cflags or []
extra_cuda_cflags = extra_cuda_cflags or []
extra_ldflags = extra_ldflags or []
extra_include_paths = extra_include_paths or []
if torch.version.hip is not None:
extra_cuda_cflags = [
flag
for flag in extra_cuda_cflags
if flag not in ("--use_fast_math", "-use_fast_math")
]
cpp_files = [str((KERNEL_PATH / "csrc" / f).resolve()) for f in cpp_files] + [
str(pathlib.Path(f).resolve()) for f in external_cpp_files
]
cuda_files = [str((KERNEL_PATH / "csrc" / f).resolve()) for f in cuda_files] + [
str(pathlib.Path(f).resolve()) for f in external_cuda_files
]
for dep in set(extra_dependencies or []):
if dep not in REGISTERED_DEPENDENCIES:
raise ValueError(f"Dependency {dep} is not registered.")
extra_include_paths += REGISTERED_DEPENDENCIES[dep]()
module_name = "sgl_kernel_jit_" + "_".join(str(arg) for arg in args)
if cpp_files or cuda_files:
module_name += "_" + _local_jit_source_hash(cpp_files + cuda_files)
# A built .so under a deterministic dir is content-addressed: load it
# directly to skip ninja, whose mtime check rebuilds every CI run (pip
# install bumps dep header mtimes).
if build_directory is None:
cache_dir = os.environ.get("TVM_FFI_CACHE_DIR", "~/.cache/tvm-ffi")
build_directory = str(
pathlib.Path(cache_dir).expanduser() / _jit_build_dir_name(module_name)
)
prebuilt = pathlib.Path(build_directory) / f"{module_name}.so"
if prebuilt.is_file():
from tvm_ffi import load_module
try:
module = load_module(str(prebuilt))
logger.debug("Reused cached JIT module %s", module_name)
return module
except Exception:
logger.warning(
"Cached JIT module %s failed to load; rebuilding.", module_name
)
if header_only:
cpp_sources = _make_sources(cpp_files, cpp_wrappers or [])
cuda_sources = _make_sources(cuda_files, cuda_wrappers or [])
with _jit_compile_context():
return load_inline(
module_name,
cpp_sources=cpp_sources,
cuda_sources=cuda_sources,
extra_cflags=DEFAULT_CFLAGS + extra_cflags,
extra_cuda_cflags=get_default_target_flags() + extra_cuda_cflags,
extra_ldflags=DEFAULT_LDFLAGS + extra_ldflags,
extra_include_paths=DEFAULT_INCLUDE + extra_include_paths,
build_directory=build_directory,
)
else:
assert cpp_wrappers is None and cuda_wrappers is None
with _jit_compile_context():
return load(
module_name,
cpp_files=cpp_files,
cuda_files=cuda_files,
extra_cflags=DEFAULT_CFLAGS + extra_cflags,
extra_cuda_cflags=get_default_target_flags() + extra_cuda_cflags,
extra_ldflags=DEFAULT_LDFLAGS + extra_ldflags,
extra_include_paths=DEFAULT_INCLUDE + extra_include_paths,
build_directory=build_directory,
)
@contextmanager
def _jit_compile_context():
if is_hip_runtime():
yield # TODO: support ROCm `TVM_FFI_ROCM_ARCH_LIST` if needed
return
env_key = "TVM_FFI_CUDA_ARCH_LIST"
old_value = os.environ.get(env_key, None)
os.environ[env_key] = get_jit_cuda_arch().target_name
try:
yield
finally:
if old_value is None:
os.environ.pop(env_key, None)
else:
os.environ[env_key] = old_value
@@ -0,0 +1,44 @@
"""JIT compilation: source layout, ninja generation, the build cache, load_jit.
The package owns the whole path from a ``load_jit`` call to a loaded module,
including the ``build.ninja`` it compiles through — only tvm-ffi's headers,
its shared library, and ``tvm_ffi.load_module`` are consumed from outside.
Modules, in dependency order (there are no cycles)::
paths where the in-tree sources live, and the default flags
cpp_args rendering Python values as C++ template arguments
spec BuildSpec: one fully-resolved build
toolchain compilers, tvm-ffi locations, platform base flags
ninja generating and running build.ninja, reading its depfiles
cache build_key / deps_key, cache layout, publication
loader load_jit
"""
from sglang.kernels.jit.utils.compile.cpp_args import (
CPP_DTYPE_MAP,
CPP_TEMPLATE_TYPE,
CPPArgList,
make_cpp_args,
)
from sglang.kernels.jit.utils.compile.loader import load_jit
from sglang.kernels.jit.utils.compile.paths import (
DEFAULT_CFLAGS,
DEFAULT_INCLUDE,
DEFAULT_LDFLAGS,
KERNEL_PATH,
)
from sglang.kernels.jit.utils.compile.spec import BuildSpec
__all__ = [
"BuildSpec",
"CPPArgList",
"CPP_DTYPE_MAP",
"CPP_TEMPLATE_TYPE",
"DEFAULT_CFLAGS",
"DEFAULT_INCLUDE",
"DEFAULT_LDFLAGS",
"KERNEL_PATH",
"load_jit",
"make_cpp_args",
]
@@ -0,0 +1,522 @@
"""Content-addressed JIT build cache: key derivation, layout, publication.
The cache answers one question on every ``load_jit``: *is there an already-built
``.so`` that is guaranteed to be identical to what a build right now would
produce?* It does so with two keys, because the full answer is not computable
before the first build:
``build_key`` — everything known *before* compiling: the module args, the
caller's flags, the wrapper exports, the compile target, and the contents
of the direct source files. It selects a directory.
``deps_key`` — the contents of the *transitive* dependency closure, which only
the compiler can enumerate. It selects a leaf inside that directory.
Layout::
$SGLANG_JIT_CACHE_DIR/<target>/<module_name>/build-<build_key>/
deps-<deps_key>/ one leaf per transitive-content state
sgl_deps.json the dependency list of *this* build
<module_name>.so
.staging-<uuid>/ a build in progress
``<target>`` and ``<module_name>`` are for humans (``du -sh`` per arch,
"what is this directory") and carry no correctness weight — every distinguishing
input is folded into ``build_key`` as well.
**Each leaf carries its own dependency list and is never modified after it is
published.** A leaf is a hit only when re-hashing its own list against the files
as they stand *now* reproduces the leaf's own name. That makes the recorded data
self-verifying rather than trusted: a truncated, tampered, or differently
formatted list simply fails to reproduce the name, and a leaf published by a
machine whose dependency graph differs is skipped instead of poisoning this one.
There is deliberately no shared, mutable manifest for writers to merge into.
The known gap is ``__has_include``-style constructs, where the dependency graph
turns on a file's *existence* rather than any listed file's content. The
compiler-version and package-version components of ``build_key`` cover the
realistic instances; the residue is accepted (ccache has carried the same gap
for decades).
"""
from __future__ import annotations
import hashlib
import importlib.util
import logging
import os
import pathlib
import shutil
import subprocess
import sysconfig
from importlib.metadata import PackageNotFoundError
from importlib.metadata import version as dist_version
from typing import Dict, Iterable, List, Optional, Sequence, Tuple
import msgspec
from sglang.kernels.jit.utils.arch import get_jit_cuda_arch
from sglang.kernels.jit.utils.common import cache_once, is_hip_runtime, is_musa_runtime
from sglang.kernels.jit.utils.compile import toolchain
from sglang.kernels.jit.utils.compile.paths import KERNEL_PATH
from sglang.kernels.jit.utils.compile.spec import BuildSpec
from sglang.srt.environ import envs
logger = logging.getLogger(__name__)
_DEPS_FILE = "sgl_deps.json"
_BUILD_KEY_PREFIX = "build-"
_DEPS_KEY_PREFIX = "deps-"
_KEY_HEX_LEN = 16
# Leaves are examined newest-first and the search stops at the first match, so
# the common case costs one read. The cap only bounds the pathological tail:
# 100 leaves all missing costs ~32 ms, and a run that reaches it is about to
# spend seconds rebuilding anyway.
_MAX_LEAVES_SCANNED = 100
class _DepEntry(msgspec.Struct, frozen=True, array_like=True):
"""One dependency, stored install-location independently.
``root`` is an anchor token resolved against the *current* environment, so a
list written by one clone is readable from another.
"""
root: str
relpath: str
digest: str
# ---------------------------------------------------------------------------
# Anchor roots — the only reason a dependency list survives a re-clone
# ---------------------------------------------------------------------------
def _package_dir(package: str) -> Optional[pathlib.Path]:
try:
spec = importlib.util.find_spec(package)
except (ImportError, ValueError):
return None
if spec is None:
return None
if spec.origin is not None:
return pathlib.Path(spec.origin).resolve().parent
locations = list(spec.submodule_search_locations or [])
return pathlib.Path(locations[0]).resolve() if locations else None
@cache_once
def _anchor_roots() -> Tuple[Tuple[str, pathlib.Path], ...]:
"""Anchor tokens ordered most-specific first, so the longest match wins."""
candidates: List[Tuple[str, Optional[pathlib.Path]]] = [
("kernels", KERNEL_PATH),
("tvm_ffi", _package_dir("tvm_ffi")),
("pkg:flashinfer", _package_dir("flashinfer")),
("pkg:deep_gemm", _package_dir("deep_gemm")),
("pkg:nvidia", _package_dir("nvidia")),
("toolkit", toolchain.toolkit_home()),
("sitepkgs", pathlib.Path(sysconfig.get_paths()["purelib"])),
("sys", pathlib.Path("/usr")),
]
# Resolved, because the paths being matched against them are resolved too.
# `/usr/local/cuda` is a symlink to `/usr/local/cuda-<version>`; leaving the
# anchor unresolved makes every toolkit header miss it and fall through to
# `sys`, which bakes the CUDA version into the recorded relpath.
roots = [
(token, path.resolve())
for token, path in candidates
if path is not None and path.exists()
]
roots.sort(key=lambda item: len(str(item[1])), reverse=True)
return tuple(roots)
def _normalize_path(path: pathlib.Path) -> Tuple[str, str]:
"""Split *path* into ``(anchor token, path relative to that anchor)``.
Falls back to ``("abs", <absolute path>)`` for anything outside every known
root — correct, just not portable across machines (which only costs a miss).
"""
for token, root in _anchor_roots():
try:
return token, str(path.relative_to(root))
except ValueError:
continue
return "abs", str(path)
def _resolve_path(*, root: str, relpath: str) -> Optional[pathlib.Path]:
if root == "abs":
return pathlib.Path(relpath)
for token, base in _anchor_roots():
if token == root:
return base / relpath
return None
# ---------------------------------------------------------------------------
# Content digests
# ---------------------------------------------------------------------------
_digest_cache: Dict[pathlib.Path, Optional[str]] = {}
def _file_digest(path: pathlib.Path) -> Optional[str]:
"""Content digest of *path*, or None if it cannot be read.
Memoized per process: a server hashes the same CUTLASS headers for every
kernel it loads, and the union is ~25 MB. The memo means a source edited
while the process is alive is not noticed, which is fine — modules are
resolved once at startup and never re-resolved.
"""
cached = _digest_cache.get(path)
if cached is not None or path in _digest_cache:
return cached
try:
digest = hashlib.sha256(path.read_bytes()).hexdigest()
except OSError:
digest = None
_digest_cache[path] = digest
return digest
def clear_digest_cache() -> None:
"""Drop the memo. For tests that mutate sources between lookups."""
_digest_cache.clear()
def _hash_parts(parts: Iterable[object]) -> str:
digest = hashlib.sha256()
for part in parts:
digest.update(repr(part).encode())
digest.update(b"\0")
return digest.hexdigest()[:_KEY_HEX_LEN]
# ---------------------------------------------------------------------------
# build_key
# ---------------------------------------------------------------------------
_VERSIONED_PACKAGES = (
"apache-tvm-ffi",
"flashinfer-python",
"deep_gemm",
"nvidia-mathdx",
"torch",
)
@cache_once
def _target_tag() -> str:
"""Short, human-readable target name used as a cache path segment.
HIP deliberately uses ``gcnArchName`` rather than the CUDA-shaped
``(major, minor)`` capability: the latter maps gfx940/gfx941/gfx942 onto a
single ``9.4``, which are three different compile targets.
"""
if is_hip_runtime():
return toolchain.gpu_arch_name().split(":")[0] or "unknown"
arch = get_jit_cuda_arch()
prefix = "mp" if is_musa_runtime() else "sm"
return f"{prefix}{arch.major}{arch.minor}{arch.suffix}"
@cache_once
def _environment_fingerprint() -> str:
"""Process-wide part of ``build_key``: target, compilers, package versions.
The target here is the unabridged one — unlike ``_target_tag`` it keeps the
gfx feature suffixes, because this half has to be exact.
Both compilers are fingerprinted: nvcc hands all host code to ``c++``, so a
different host compiler means different system headers and different host
codegen for otherwise identical inputs.
"""
if is_hip_runtime():
target = f"hip:{toolchain.gpu_arch_name()}"
else:
arch = get_jit_cuda_arch()
target = f"{'musa' if is_musa_runtime() else 'cuda'}:{arch.target_name}"
compilers = []
for path in (toolchain.device_compiler_path(), toolchain.host_compiler_path()):
try:
compilers.append(subprocess.check_output([path, "--version"], text=True))
except (OSError, subprocess.SubprocessError) as error:
logger.warning("Cannot fingerprint compiler %s: %s", path, error)
compilers.append("unknown")
versions: List[Tuple[str, str]] = []
for name in _VERSIONED_PACKAGES:
try:
versions.append((name, dist_version(name)))
except (PackageNotFoundError, ValueError):
versions.append((name, "absent"))
return _hash_parts([target, compilers, versions])
def compute_build_key(spec: BuildSpec, *, build_file: str) -> str:
"""Everything that is knowable before the compiler runs.
Two things are hashed, and together they are everything the compiler sees:
*build_file* — the generated ninja text, so no flag can reach the compiler
without reaching the key — and the translation units, whose generated
wrapper source carries the exports that never appear in the build file.
Absolute paths in both are anchor-normalized, so the same tree under a
different clone directory still keys the same.
"""
source_digests = [
(
_normalize_path(pathlib.Path(path).resolve()),
_file_digest(pathlib.Path(path).resolve()),
)
for path in sorted(spec.sources)
]
units = [
(unit.filename if unit.source is None else "", unit.is_cuda, unit.source)
for unit in spec.translation_units()
]
return _hash_parts(
[
spec.module_args,
tuple(source_digests),
_normalize_text(build_file),
_normalize_text(repr(units)),
_environment_fingerprint(),
]
)
def _normalize_text(text: str) -> str:
"""Replace every known root in *text* with its anchor token.
Longest root first, so `/usr/local/cuda-12.9` wins over `/usr`.
"""
for token, root in _anchor_roots():
text = text.replace(str(root), f"<{token}>")
return text
def build_key_dir(*, module_name: str, build_key: str) -> pathlib.Path:
configured = envs.SGLANG_JIT_CACHE_DIR.get() or "~/.cache/sglang/jit"
root = pathlib.Path(configured).expanduser()
return root / _target_tag() / module_name / f"{_BUILD_KEY_PREFIX}{build_key}"
# ---------------------------------------------------------------------------
# deps_key — a leaf that reproduces its own name
# ---------------------------------------------------------------------------
def _deps_key(entries: Sequence[_DepEntry]) -> str:
return _hash_parts([(e.root, e.relpath, e.digest) for e in entries])
def _read_deps(leaf: pathlib.Path) -> Optional[List[_DepEntry]]:
try:
raw = (leaf / _DEPS_FILE).read_bytes()
except OSError:
return None
try:
return msgspec.json.decode(raw, type=List[_DepEntry])
except msgspec.DecodeError:
return None
def _refresh(
entries: Sequence[_DepEntry],
) -> Tuple[Optional[List[_DepEntry]], List[str]]:
"""Re-read every recorded dependency as it stands now.
Returns ``(entries with current digests, names that changed)``; the entries
are None when a recorded dependency has vanished, which is itself a change.
"""
current: List[_DepEntry] = []
changed: List[str] = []
for entry in entries:
path = _resolve_path(root=entry.root, relpath=entry.relpath)
digest = _file_digest(path) if path is not None else None
if digest is None:
return None, [f"{entry.root}:{entry.relpath}"]
if digest != entry.digest:
changed.append(f"{entry.root}:{entry.relpath}")
current.append(_DepEntry(root=entry.root, relpath=entry.relpath, digest=digest))
return current, changed
def find_prebuilt(*, scope: pathlib.Path, module_name: str) -> Optional[pathlib.Path]:
"""The leaf whose recorded dependencies still hash to its own name, if any.
Newest leaves are examined first, so the common case costs one read.
"""
try:
leaves = sorted(
(p for p in scope.iterdir() if p.name.startswith(_DEPS_KEY_PREFIX)),
key=lambda p: p.stat().st_mtime,
reverse=True,
)
except OSError:
return None
if len(leaves) > _MAX_LEAVES_SCANNED:
logger.debug(
"JIT module %s has %d cached leaves; only the newest %d are considered.",
module_name,
len(leaves),
_MAX_LEAVES_SCANNED,
)
reason: List[str] = []
for leaf in leaves[:_MAX_LEAVES_SCANNED]:
entries = _read_deps(leaf)
if entries is None:
continue
current, changed = _refresh(entries)
if current is None:
reason = reason or changed
continue
if f"{_DEPS_KEY_PREFIX}{_deps_key(current)}" != leaf.name:
reason = reason or changed
continue
candidate = leaf / f"{module_name}.so"
if candidate.is_file():
# Keep the mtime ordering meaningful: it is what puts this leaf
# first next time, and what a size-bounded GC would evict by.
# Bookkeeping only, so it must never turn a hit into a failure: the
# cache root can be a read-only mount, and a prune racing the
# is_file() above leaves nothing here to touch.
try:
os.utime(leaf, None)
except OSError:
pass
return candidate
if reason:
log = logger.info if envs.SGLANG_JIT_CACHE_DEBUG.get() else logger.debug
log("Rebuilding JIT module %s: %s changed", module_name, reason[0])
return None
# ---------------------------------------------------------------------------
# Publication
# ---------------------------------------------------------------------------
def commit_build(
spec: BuildSpec,
*,
scope: pathlib.Path,
staging: pathlib.Path,
dependencies: Sequence[pathlib.Path],
) -> Optional[pathlib.Path]:
"""Publish a freshly built *staging* directory into the cache.
Never raises: publication is opportunistic, and a module that fails to
publish is merely rebuilt next time.
"""
entries = _to_entries(dependencies=dependencies, build_dir=staging)
if not _covers_direct_sources(entries=entries, direct_sources=spec.sources):
logger.warning(
"JIT module %s produced no usable dependency information; it will be "
"rebuilt on every load. This is a build-rule problem, not a cache problem.",
spec.module_name,
)
return None
# Written *before* the rename, so the leaf carries its own list the moment
# it becomes visible. Nothing ever rewrites it afterwards.
(staging / _DEPS_FILE).write_bytes(msgspec.json.encode(entries))
leaf = _publish(
staging=staging, leaf=scope / f"{_DEPS_KEY_PREFIX}{_deps_key(entries)}"
)
_prune(scope=scope, keep_newest=leaf)
return leaf / f"{spec.module_name}.so"
def _prune(*, scope: pathlib.Path, keep_newest: pathlib.Path) -> None:
"""Drop the oldest builds of this variant past ``SGLANG_JIT_CACHE_KEEP``.
Unset keeps everything, which is what makes reverting an edit an instant
hit rather than a rebuild — the leaves *are* the history.
Deleting a leaf another process is using is safe: an unlinked ``.so`` stays
mapped for whoever already loaded it, and a lookup that loses its leaf
mid-flight falls through to a rebuild.
"""
keep = envs.SGLANG_JIT_CACHE_KEEP.get()
if keep is None:
return
leaves = sorted(
(
path
for path in scope.iterdir()
if path.name.startswith(_DEPS_KEY_PREFIX) and path != keep_newest
),
key=lambda path: path.stat().st_mtime,
reverse=True,
)
for stale in leaves[max(keep - 1, 0) :]:
logger.debug("Pruning JIT build %s/%s", scope.name, stale.name)
shutil.rmtree(stale, ignore_errors=True)
def _to_entries(
*, dependencies: Sequence[pathlib.Path], build_dir: pathlib.Path
) -> List[_DepEntry]:
"""Turn scanned paths into portable, sorted entries.
Files under *build_dir* (its own generated units and objects) are dropped:
their paths are unstable, and their contents are already a function of
inputs ``build_key`` covers.
"""
seen: Dict[Tuple[str, str], _DepEntry] = {}
for candidate in dependencies:
path = (
candidate.resolve()
if candidate.is_absolute()
else (build_dir / candidate).resolve()
)
if path.is_relative_to(build_dir) or not path.is_file():
continue
digest = _file_digest(path)
if digest is None:
continue
root, relpath = _normalize_path(path)
seen[(root, relpath)] = _DepEntry(root=root, relpath=relpath, digest=digest)
return [seen[key] for key in sorted(seen)]
def _covers_direct_sources(
*, entries: Sequence[_DepEntry], direct_sources: Sequence[str]
) -> bool:
"""Reject a dependency list that does not even mention the direct sources.
A truncated or empty scan would otherwise narrow the checked set below what
``build_key`` already covers, which is the one way bad recorded data could
cause reuse instead of a rebuild.
"""
if not entries:
return False
recorded = {(entry.root, entry.relpath) for entry in entries}
return all(
_normalize_path(pathlib.Path(source).resolve()) in recorded
for source in direct_sources
)
def _publish(*, staging: pathlib.Path, leaf: pathlib.Path) -> pathlib.Path:
"""Move *staging* into place so the leaf appears complete or not at all.
A directory rename is atomic; losing the race means another process built
the identical content first, so its result is used and ours is discarded.
Kept as its own function because that "failure is success" branch is the
part worth pinning with a test.
"""
leaf.parent.mkdir(parents=True, exist_ok=True)
try:
os.rename(staging, leaf)
except OSError:
if not leaf.is_dir():
raise
logger.debug("JIT leaf %s already published by another process", leaf.name)
return leaf
@@ -0,0 +1,50 @@
"""Rendering Python values as C++ template arguments."""
from __future__ import annotations
from typing import TypeAlias, Union
import torch
CPP_TEMPLATE_TYPE: TypeAlias = Union[int, float, str, bool, torch.dtype]
class CPPArgList(list):
def __str__(self) -> str:
return ", ".join(self)
CPP_DTYPE_MAP = {
torch.float64: "double",
torch.float32: "fp32_t",
torch.float16: "fp16_t",
torch.bfloat16: "bf16_t",
# The fnuz variants are the ROCm-side torch dtypes; fp8_*_t resolves to
# the matching HIP type there (see HIP_FP8_TYPE_* in utils.cuh).
torch.float8_e4m3fn: "fp8_e4m3_t",
torch.float8_e4m3fnuz: "fp8_e4m3_t",
torch.float8_e5m2: "fp8_e5m2_t",
torch.float8_e5m2fnuz: "fp8_e5m2_t",
torch.int8: "int8_t",
torch.int16: "int16_t",
torch.int32: "int32_t",
torch.int64: "int64_t",
torch.uint8: "uint8_t",
torch.uint16: "uint16_t",
torch.uint32: "uint32_t",
torch.uint64: "uint64_t",
torch.bool: "bool",
}
def make_cpp_args(*args: CPP_TEMPLATE_TYPE) -> CPPArgList:
def _convert(arg: CPP_TEMPLATE_TYPE) -> str:
if isinstance(arg, bool):
return "true" if arg else "false"
if isinstance(arg, (int, str, float)):
return str(arg)
if isinstance(arg, torch.dtype):
return CPP_DTYPE_MAP[arg]
raise TypeError(f"Unsupported argument type for cpp template: {type(arg)}")
return CPPArgList(_convert(arg) for arg in args)
@@ -0,0 +1,200 @@
"""``load_jit``: resolve a request, reuse a cached build, or make one."""
from __future__ import annotations
import contextlib
import fcntl
import logging
import os
import pathlib
import shutil
import uuid
from typing import TYPE_CHECKING, List, Tuple
import torch
from sglang.kernels.jit.utils.arch import get_default_target_flags
from sglang.kernels.jit.utils.common import is_hip_runtime
from sglang.kernels.jit.utils.compile import cache, ninja
from sglang.kernels.jit.utils.compile.paths import (
DEFAULT_CFLAGS,
DEFAULT_INCLUDE,
DEFAULT_LDFLAGS,
)
from sglang.kernels.jit.utils.compile.spec import BuildSpec, resolve_sources
from sglang.kernels.jit.utils.deps import REGISTERED_DEPENDENCIES
if TYPE_CHECKING:
from tvm_ffi import Module
_DISABLE_TORCH_COMPILE = lambda f: f
else:
# NOTE: this is not friendly to type checking
_DISABLE_TORCH_COMPILE = torch.compiler.disable
logger = logging.getLogger(__name__)
_LOCK_FILE = ".lock"
# JIT compilation is pure Python/filesystem plumbing (path `.resolve()` calls
# `os.lstat`, etc.) that Dynamo cannot trace. When a lazily-loaded kernel is
# first reached from inside a `@torch.compile`d region, tracing into it produces
# spurious "Dynamo does not know how to trace the builtin `posix.lstat`" graph
# breaks. The load happens once and is memoized, so keep it out of the graph.
@_DISABLE_TORCH_COMPILE
def load_jit(
*args: str,
cpp_files: List[str] | None = None,
cuda_files: List[str] | None = None,
cpp_wrappers: List[Tuple[str, str]] | None = None,
cuda_wrappers: List[Tuple[str, str]] | None = None,
extra_cflags: List[str] | None = None,
extra_cuda_cflags: List[str] | None = None,
extra_ldflags: List[str] | None = None,
extra_include_paths: List[str] | None = None,
extra_dependencies: List[str] | None = None,
header_only: bool = True,
) -> Module:
"""Load a JIT module, compiling it if no cached build still applies.
A wrapper is a ``(export_name, kernel_name)`` pair: ``export_name`` is what
Python calls, ``kernel_name`` is the C++ class or function it resolves to.
:param args: Unique marker of the module. Must differ between kernels.
:param cpp_files: C++ sources. Relative names resolve against the in-tree
``csrc/`` directory; pass an absolute path for a source
outside the tree.
:param cuda_files: CUDA sources, resolved like `cpp_files`.
:param cpp_wrappers: C++ exports to generate.
:param cuda_wrappers: CUDA exports to generate.
:param extra_cflags: Extra host compiler flags.
:param extra_cuda_cflags: Extra device compiler flags.
:param extra_ldflags: Extra linker flags.
:param extra_include_paths: Extra include paths.
:param extra_dependencies: Registered header-only dependencies, e.g. cutlass.
:param header_only: Compile through a generated wrapper that exports the
given entry points. Otherwise the sources must export
from the C++ side themselves.
"""
if is_hip_runtime():
extra_cuda_cflags = [
flag
for flag in (extra_cuda_cflags or [])
if flag not in ("--use_fast_math", "-use_fast_math")
]
includes = list(DEFAULT_INCLUDE) + (extra_include_paths or [])
for dep in sorted(set(extra_dependencies or [])):
if dep not in REGISTERED_DEPENDENCIES:
raise ValueError(f"Dependency {dep} is not registered.")
includes += REGISTERED_DEPENDENCIES[dep]()
module_args = tuple(str(arg) for arg in args)
spec = BuildSpec(
module_args=module_args,
cpp_files=resolve_sources(cpp_files),
cuda_files=resolve_sources(cuda_files),
cpp_wrappers=tuple(cpp_wrappers or ()),
cuda_wrappers=tuple(cuda_wrappers or ()),
cflags=tuple(DEFAULT_CFLAGS + (extra_cflags or [])),
cuda_cflags=tuple(get_default_target_flags() + (extra_cuda_cflags or [])),
ldflags=tuple(DEFAULT_LDFLAGS + (extra_ldflags or [])),
include_paths=tuple(includes),
header_only=header_only,
)
# Generated once and threaded through: the cache key is taken over this
# exact text, and this exact text is what gets compiled.
build_file = ninja.generate(spec)
build_key = cache.compute_build_key(spec, build_file=build_file)
scope = cache.build_key_dir(module_name=spec.module_name, build_key=build_key)
prebuilt = cache.find_prebuilt(scope=scope, module_name=spec.module_name)
if prebuilt is not None:
try:
return _load(prebuilt)
except Exception as e:
# Also the benign case where a concurrent GC unlinked the leaf
# between the lookup and the load.
logger.warning(
"Cached JIT module %s failed to load; rebuilding. " "Got error: %s",
spec.module_name,
e,
)
with _build_lock(scope):
# Re-check: whoever held the lock before us has very likely just
# published exactly what we were about to build. This is what turns N
# tensor-parallel ranks starting together into one compile plus N-1
# cache hits instead of N identical compiles.
prebuilt = cache.find_prebuilt(scope=scope, module_name=spec.module_name)
if prebuilt is not None:
try:
return _load(prebuilt)
except Exception as e:
logger.warning(
"Cached JIT module %s failed to load; rebuilding. Got error: %s",
spec.module_name,
e,
)
# A leaf is named after the dependency list it was built from,
# so the rebuild below lands on this exact name and publishing
# it would find the directory already there and keep the broken
# one -- leaving every later process to fail twice and rebuild
# for nothing. We hold the lock, so drop it now.
shutil.rmtree(prebuilt.parent, ignore_errors=True)
# Build into a private staging directory, then publish it by renaming.
# Building in place would let another process observe a leaf that exists
# but is still being linked — and that process is on the fast path, so it
# takes no lock. A rename makes the leaf appear complete or not at all.
#
# The staging name is random rather than pid-based: the cache root is
# meant to be a shared mount, where two machines can hold the same pid.
staging = scope / f".staging-{uuid.uuid4().hex}"
try:
library = ninja.build(spec=spec, build_dir=staging, build_file=build_file)
# Loaded before publishing, so a broken artifact never becomes a
# leaf that later runs have to discover and discard.
module = _load(library)
cache.commit_build(
spec,
scope=scope,
staging=staging,
dependencies=ninja.scan_dependencies(staging),
)
return module
finally:
shutil.rmtree(staging, ignore_errors=True)
@contextlib.contextmanager
def _build_lock(scope: pathlib.Path):
"""Serialize builds of one module variant across processes.
Every tensor-parallel rank on a node reaches the same cold cache at the same
moment. Without this they each run a full compile: measured at 8 ranks,
14.0 s of CPU-oversubscribed nvcc against 9.0 s when one builds and the rest
wait and then hit the cache.
The lock only saves duplicated work; it is not what makes publication safe
(the atomic rename is), and readers on the fast path deliberately do not
take it — that would serialize every warm load. Deadlock is not reachable:
one lock, never nested, and the kernel drops it if the holder dies.
"""
scope.mkdir(parents=True, exist_ok=True)
handle = os.open(scope / _LOCK_FILE, os.O_CREAT | os.O_RDWR, 0o644)
try:
fcntl.flock(handle, fcntl.LOCK_EX)
yield
finally:
os.close(handle) # releases the lock
def _load(library: pathlib.Path) -> Module:
from tvm_ffi import load_module
return load_module(str(library))
@@ -0,0 +1,228 @@
"""Generating and running the ``build.ninja`` for one JIT module.
Owning this file is what makes the cache key exact. Every compiler, flag,
include path and link argument is written here, so the key can be taken over the
generated text itself instead of over an approximation of what a dependency
would have chosen.
Two things fall out of that which were previously impossible:
* ``deps = gcc`` is deliberately *not* emitted. That setting folds the depfile
into ninja's binary ``.ninja_deps`` log and deletes it; plain ``depfile =``
keeps the ``.d`` on disk, which is what the cache reads to learn the real
dependency closure. The dep-log optimization it gives up only matters for
builds with far more translation units than a JIT module has.
* The device rule always writes a depfile. tvm-ffi's HIP branch declares
``depfile = $out.d`` while running a command that never produces one, so ROCm
builds silently carried no header dependencies at all.
"""
from __future__ import annotations
import logging
import os
import pathlib
import shlex
import subprocess
from typing import List
from sglang.kernels.jit.utils.compile import toolchain
from sglang.kernels.jit.utils.compile.spec import BuildSpec
logger = logging.getLogger(__name__)
_BUILD_FILE = "build.ninja"
_NINJA_TIMEOUT_S = 1800
def _escape(path: str) -> str:
"""Escape a path for a ninja *path* field (a build statement's in/out)."""
return path.replace("$", "$$").replace(":", "$:").replace(" ", "$ ")
def _arg(path: str) -> str:
"""Render a path as one shell word inside a rule command.
Ninja's own escaping only survives ninja's parser: `$ ` reaches the command
line as a plain space, and every command runs through a shell, so an
unquoted include or library directory with a space in it arrives at the
compiler as several arguments. Quote for the shell first, then escape what
ninja still reads -- `$` is special everywhere in a build file.
"""
return shlex.quote(path).replace("$", "$$")
def _quote_path_flags(flags: List[str]) -> List[str]:
"""Shell-quote the directory carried by every ``-I``/``-L`` flag.
Applied once, at the end, wherever the flag came from -- this layer, the
toolchain, or the caller -- so a directory with a space in it stays one
argument. Anything else is passed through untouched.
"""
quoted: List[str] = []
for flag in flags:
for prefix in ("-I", "-L"):
if flag.startswith(prefix) and len(flag) > len(prefix):
quoted.append(prefix + _arg(flag[len(prefix) :]))
break
else:
quoted.append(flag)
return quoted
def generate(spec: BuildSpec) -> str:
"""The complete build description for *spec*, as ninja syntax.
Paths of the generated translation units are relative, so the file is
identical no matter which directory the build runs in.
"""
units = spec.translation_units()
with_device = any(unit.is_cuda for unit in units)
host_cc, device_cc = toolchain.compilers()
includes = toolchain.base_include_paths() + list(spec.include_paths)
include_flags = [f"-I{path}" for path in includes]
cxxflags = _quote_path_flags(
toolchain.base_cxx_flags() + list(spec.cflags) + include_flags
)
cudaflags = _quote_path_flags(
toolchain.base_cuda_flags()
+ toolchain.target_flags()
+ list(spec.cuda_cflags)
+ include_flags
)
ldflags = _quote_path_flags(
toolchain.base_link_flags(with_device=with_device) + list(spec.ldflags)
)
lines = [
"ninja_required_version = 1.3",
f"cxx = {_arg(host_cc)}",
f"nvcc = {_arg(device_cc)}",
f"cxxflags = {' '.join(cxxflags)}",
f"cudaflags = {' '.join(cudaflags)}",
f"ldflags = {' '.join(ldflags)}",
"",
"rule compile_cxx",
" depfile = $out.d",
' command = $cxx -MD -MF "$out.d" $cxxflags -c "$in" -o "$out"',
"",
"rule compile_cuda",
" depfile = $out.d",
' command = $nvcc -MD -MF "$out.d" $cudaflags -c "$in" -o "$out"',
"",
"rule link",
' command = $cxx $in $ldflags -o "$out"',
"",
]
objects: List[str] = []
for index, unit in enumerate(units):
obj = f"{unit.stem}_{index}.o"
rule = "compile_cuda" if unit.is_cuda else "compile_cxx"
lines.append(f"build {obj}: {rule} {_escape(unit.filename)}")
objects.append(obj)
lines += [
"",
f"build {spec.module_name}.so: link {' '.join(objects)}",
"",
f"default {spec.module_name}.so",
"",
]
return "\n".join(lines)
def build(*, spec: BuildSpec, build_dir: pathlib.Path, build_file: str) -> pathlib.Path:
"""Write the sources and *build_file* into *build_dir*, then run ninja.
The caller passes the generated text rather than letting this regenerate it,
so the text the cache key was taken over is provably the text that gets
compiled.
*build_dir* is always a fresh staging directory, so there is nothing here to
keep current — every file is written once and compiled once.
"""
build_dir.mkdir(parents=True, exist_ok=True)
for unit in spec.translation_units():
# Only the generated wrappers are materialized; sources that already
# exist are compiled where they are.
if unit.source is not None:
(build_dir / unit.filename).write_text(unit.source)
(build_dir / _BUILD_FILE).write_text(build_file)
command = ["ninja", "-f", _BUILD_FILE]
jobs = os.environ.get("MAX_JOBS")
if jobs:
command += ["-j", jobs]
completed = subprocess.run(
command,
cwd=str(build_dir),
capture_output=True,
text=True,
timeout=_NINJA_TIMEOUT_S,
check=False,
)
if completed.returncode != 0:
raise RuntimeError(
f"Failed to build JIT module {spec.module_name} in {build_dir}\n"
f"stdout:\n{completed.stdout}\nstderr:\n{completed.stderr}"
)
return build_dir / f"{spec.module_name}.so"
def scan_dependencies(build_dir: pathlib.Path) -> List[pathlib.Path]:
"""Every file the compiler read, taken from the depfiles the build left.
Without ``deps = gcc`` ninja leaves each ``.d`` in place, so this is a plain
read of what the compiler itself reported — no preprocessing is re-run and
no include paths are re-guessed.
"""
paths: List[pathlib.Path] = []
seen = set()
for depfile in sorted(build_dir.glob("*.o.d")):
try:
text = depfile.read_text(errors="ignore")
except OSError:
continue
for candidate in _parse_depfile(text):
if candidate in seen:
continue
seen.add(candidate)
paths.append(pathlib.Path(candidate))
return paths
def _parse_depfile(text: str) -> List[str]:
"""Prerequisites from a make-style ``.d`` file.
Handles the escapes both producers emit: ``\\`` line continuations, and
``\\ `` for spaces inside a path.
"""
joined = text.replace("\\\r\n", " ").replace("\\\n", " ")
tokens: List[str] = []
for line in joined.split("\n"):
_, separator, prerequisites = line.partition(":")
if not separator:
continue
current: List[str] = []
index = 0
while index < len(prerequisites):
char = prerequisites[index]
if char == "\\" and index + 1 < len(prerequisites):
if prerequisites[index + 1] in " \\":
current.append(prerequisites[index + 1])
index += 2
continue
if char.isspace():
if current:
tokens.append("".join(current))
current = []
index += 1
continue
current.append(char)
index += 1
if current:
tokens.append("".join(current))
return tokens
@@ -0,0 +1,32 @@
"""Where the in-tree JIT sources live, and the compile defaults applied to them.
Kept in its own module so that both the build cache and the loader can depend
on it without depending on each other.
"""
from __future__ import annotations
import importlib.util
import pathlib
from typing import List
from sglang.kernels.jit.utils.common import cache_once
@cache_once
def _resolve_kernel_path() -> pathlib.Path:
spec = importlib.util.find_spec("sglang.kernels.jit")
assert spec is not None and spec.origin is not None
cur_dir = pathlib.Path(spec.origin).parent.resolve()
candidate = cur_dir.resolve()
if (candidate / "include").exists() and (candidate / "csrc").exists():
return candidate
raise RuntimeError("Cannot find sglang.kernels.jit path")
KERNEL_PATH = _resolve_kernel_path()
DEFAULT_INCLUDE: List[str] = [str(KERNEL_PATH / "include")]
DEFAULT_CFLAGS: List[str] = ["-std=c++20", "-O3"]
DEFAULT_LDFLAGS: List[str] = []
@@ -0,0 +1,134 @@
"""One fully-resolved JIT build, described in one place.
``BuildSpec`` is the hand-off between the halves of ``load_jit``: the cache has
to see *every* input that could change the output in order to key it, and the
ninja generator has to feed those same inputs to the compiler.
Anything added here that affects the generated code must also reach
``cache.compute_build_key``.
"""
from __future__ import annotations
import pathlib
from typing import List, Optional, Tuple
import msgspec
from sglang.kernels.jit.utils.compile.paths import KERNEL_PATH
_MODULE_NAME_PREFIX = "sgl_kernel_jit_"
class TranslationUnit(msgspec.Struct, frozen=True):
"""One file handed to the compiler.
``source`` is set for the wrapper units sglang generates and left None for
sources that already exist on disk; the ninja layer writes the former into
the build directory and points at the latter where they are.
"""
filename: str
is_cuda: bool
source: Optional[str] = None
@property
def stem(self) -> str:
return pathlib.Path(self.filename).stem
class BuildSpec(msgspec.Struct, frozen=True):
"""Everything needed to either key or run one build."""
module_args: Tuple[str, ...]
cpp_files: Tuple[str, ...]
cuda_files: Tuple[str, ...]
cpp_wrappers: Tuple[Tuple[str, str], ...]
cuda_wrappers: Tuple[Tuple[str, str], ...]
cflags: Tuple[str, ...]
cuda_cflags: Tuple[str, ...]
ldflags: Tuple[str, ...]
include_paths: Tuple[str, ...]
header_only: bool
@property
def module_name(self) -> str:
"""Derived: the args are the module's identity, the name just spells it."""
return _MODULE_NAME_PREFIX + "_".join(self.module_args)
@property
def sources(self) -> Tuple[str, ...]:
return self.cpp_files + self.cuda_files
@property
def wrappers(self) -> Tuple[Tuple[str, str], ...]:
return self.cpp_wrappers + self.cuda_wrappers
def translation_units(self) -> List[TranslationUnit]:
"""What the compiler is actually invoked on.
Header-only modules are compiled through a generated wrapper that
includes the sources and exports the requested entry points; everything
else is compiled in place and exports from the C++ side itself.
"""
if not self.header_only:
return [
TranslationUnit(filename=path, is_cuda=path.endswith(".cu"))
for path in self.sources
]
units: List[TranslationUnit] = []
for name, is_cuda, files, wrappers in (
("main.cpp", False, self.cpp_files, self.cpp_wrappers),
("cuda.cu", True, self.cuda_files, self.cuda_wrappers),
):
if not files and not wrappers:
continue
units.append(
TranslationUnit(
filename=name,
is_cuda=is_cuda,
source=_wrapper_source(files, wrappers),
)
)
return units
# What tvm-ffi's own `_decorate_with_tvm_ffi` prepends to every generated unit.
# The wrapper below uses TVM_FFI_DLL_EXPORT_TYPED_FUNC, so it has to include the
# header that defines it rather than rely on the kernel's own include chain
# happening to drag it in — that dependency held for every kernel in tree, but
# it is not something a new kernel's author would know to preserve.
_FFI_INCLUDES = (
"#include <tvm/ffi/container/tensor.h>",
"#include <tvm/ffi/dtype.h>",
"#include <tvm/ffi/error.h>",
"#include <tvm/ffi/extra/c_env_api.h>",
"#include <tvm/ffi/function.h>",
)
def _wrapper_source(
files: Tuple[str, ...], wrappers: Tuple[Tuple[str, str], ...]
) -> str:
lines = list(_FFI_INCLUDES)
lines += [f'#include "{path}"' for path in files]
lines.append("namespace sglang {")
lines += [
f"TVM_FFI_DLL_EXPORT_TYPED_FUNC({export_name}, ({kernel_name}));"
for export_name, kernel_name in wrappers
]
lines.append("} // namespace sglang")
return "\n".join(lines) + "\n"
def resolve_sources(files: List[str] | None) -> Tuple[str, ...]:
"""Absolute paths pass through; anything else is relative to ``csrc/``."""
return tuple(
str(
path.resolve()
if path.is_absolute()
else (KERNEL_PATH / "csrc" / path).resolve()
)
for path in map(pathlib.Path, files or [])
)
@@ -0,0 +1,168 @@
"""The toolchain a JIT build runs on: compilers, tvm-ffi's headers, base flags.
sglang generates its own ``build.ninja`` rather than going through
``tvm_ffi.cpp.load_inline``, so the flags tvm-ffi used to supply implicitly have
to be stated here. That is the point: every flag that reaches the compiler is
now visible in one place and therefore hashable into the build key, instead of
living inside a dependency whose defaults we could only approximate by version
number.
Only tvm-ffi's *locations* are still consumed — its headers, its shared library,
and ``tvm_ffi.load_module`` for loading the result.
"""
from __future__ import annotations
import logging
import os
import pathlib
import shutil
from typing import List, Tuple
import torch
from sglang.kernels.jit.utils.arch import get_jit_cuda_arch
from sglang.kernels.jit.utils.common import cache_once, is_hip_runtime
logger = logging.getLogger(__name__)
@cache_once
def cuda_home() -> str:
"""CUDA install root, resolved the way tvm-ffi resolves it.
`arch._jit_cuda_version` resolves nvcc the same way for its own purposes;
the two must stay in agreement, since one picks the target and the other
compiles for it.
"""
configured = os.environ.get("CUDA_HOME") or os.environ.get("CUDA_PATH")
if configured is not None:
return configured
nvcc_path = shutil.which("nvcc")
if nvcc_path is not None:
return os.path.dirname(os.path.dirname(nvcc_path))
return "/usr/local/cuda"
@cache_once
def rocm_home() -> str:
"""ROCm install root, resolved the way tvm-ffi resolves it."""
return os.environ.get("ROCM_HOME") or os.environ.get("ROCM_PATH") or "/opt/rocm"
@cache_once
def device_compiler_path() -> str:
"""The nvcc/hipcc that JIT builds actually invoke.
Resolved the same way tvm-ffi resolves it, so the binary the cache
fingerprints is the binary that does the compiling.
"""
if is_hip_runtime():
return os.path.join(rocm_home(), "bin", "hipcc")
return os.path.join(cuda_home(), "bin", "nvcc")
@cache_once
def host_compiler_path() -> str:
"""The C++ compiler host code is handed to.
nvcc dispatches all host code to it, so its version decides both which
system headers are pulled in and how that half is codegen'd.
"""
return os.environ.get("CXX", "c++")
@cache_once
def gpu_arch_name() -> str:
"""The compile target as the vendor names it.
On ROCm this is ``gcnArchName`` (``gfx942:sramecc+:xnack-``) rather than the
CUDA-shaped ``(major, minor)`` capability: the latter maps gfx940/gfx941/
gfx942 onto a single ``9.4``, which are three different compile targets.
"""
if not is_hip_runtime():
return get_jit_cuda_arch().target_name
try:
device = torch.cuda.current_device()
return str(torch.cuda.get_device_properties(device).gcnArchName)
except Exception:
logger.warning("Cannot detect ROCm gcnArchName; the JIT cache target degrades.")
return "unknown"
@cache_once
def toolkit_home() -> pathlib.Path:
"""The CUDA/ROCm root, derived from the compiler already resolved."""
return pathlib.Path(device_compiler_path()).parent.parent
@cache_once
def tvm_ffi_paths() -> Tuple[Tuple[str, ...], str, str]:
"""``(include dirs, library dir, library name)`` for linking against tvm-ffi."""
from tvm_ffi.libinfo import (
find_dlpack_include_path,
find_include_path,
find_libtvm_ffi,
)
lib = pathlib.Path(find_libtvm_ffi())
includes = tuple(dict.fromkeys([find_include_path(), find_dlpack_include_path()]))
return includes, str(lib.parent), lib.stem.removeprefix("lib")
def target_flags() -> List[str]:
"""The device flags that pin the build to this GPU.
Emitted from the architecture sglang already detected, rather than left to
the compiler driver to probe: the value is part of the cache key, so it has
to be decided here and not rediscovered at build time.
"""
if is_hip_runtime():
return [f"--offload-arch={gpu_arch_name()}"]
arch = get_jit_cuda_arch()
target = f"{arch.major}{arch.minor}{arch.suffix}"
return [f"-gencode=arch=compute_{target},code=sm_{target}"]
def base_cxx_flags() -> List[str]:
"""Only what the platform requires; `-std`/`-O` arrive with the spec.
Kept disjoint from ``arch.get_default_target_flags`` on purpose — supplying
`-std=c++20` from both is what used to make nvcc warn about an incompatible
redefinition on every single build.
"""
return ["-fPIC"]
def base_cuda_flags() -> List[str]:
if is_hip_runtime():
return ["-fPIC", "-D__HIP_PLATFORM_AMD__=1", "-fno-gpu-rdc"]
return ["-Xcompiler", "-fPIC"]
def base_include_paths() -> List[str]:
includes, _, _ = tvm_ffi_paths()
if is_hip_runtime():
return [*includes, f"{rocm_home()}/include"]
return list(includes)
def base_link_flags(*, with_device: bool) -> List[str]:
"""Link flags for a module, with the GPU runtime only when it has device code.
A module built purely from ``.cpp`` sources must not drag in libcudart: CPU
runners have no CUDA toolkit to link it from, and the module never calls it.
tvm-ffi keyed this off the presence of ``.cu`` sources for the same reason.
"""
_, lib_dir, lib_name = tvm_ffi_paths()
flags = ["-shared", f"-L{lib_dir}", f"-l{lib_name}"]
if not with_device:
return flags
if is_hip_runtime():
return flags + [f"-L{rocm_home()}/lib", "-lamdhip64"]
return flags + [f"-L{cuda_home()}/lib64", "-lcudart"]
def compilers() -> Tuple[str, str]:
"""``(host compiler, device compiler)`` as they will appear in build.ninja."""
return host_compiler_path(), device_compiler_path()
+9
View File
@@ -972,6 +972,15 @@ class Envs:
# Cache directories
# ===================================================================
SGLANG_CACHE_DIR = EnvStr(os.path.expanduser("~/.cache/sglang"))
# JIT kernel build cache. None = unset, resolving to ~/.cache/sglang/jit;
# point it at a persistent mount to share builds across CI jobs.
SGLANG_JIT_CACHE_DIR = EnvStr(None)
# Log, at INFO, which dependency changed whenever a module is rebuilt.
SGLANG_JIT_CACHE_DEBUG = EnvBool(False)
# How many builds to keep per module variant. None = unset = keep all, which
# is what makes reverting an edit an instant hit instead of a rebuild; set
# it to trade that away for disk (1 keeps only the most recent build).
SGLANG_JIT_CACHE_KEEP = EnvInt(None)
# ===================================================================
# Expert-parallel dispatch and MoE execution