Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,14 @@ jobs:
python -m pip install pre-commit
pre-commit install

# The clippy-rust-workspace pre-commit hook compiles the rust/ workspace
# (debug profile); cache cargo registry + target across runs.
- name: Rust cache (rust/ workspace)
uses: Swatinem/rust-cache@v2
with:
workspaces: rust
shared-key: "rust-workspace-lint"

- name: Run pre-commit checks
run: SKIP=no-commit-to-branch pre-commit run --all-files --show-diff-on-failure

Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/pr-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -309,10 +309,10 @@ jobs:
timeout-minutes: 10
run: bash scripts/ci/utils/install_rust_protoc.sh

- name: Rust cache (sglang-grpc)
- name: Rust cache (rust/ workspace)
uses: Swatinem/rust-cache@v2
with:
workspaces: rust/sglang-grpc
workspaces: rust
shared-key: "sglang-grpc-cpu"
save-if: ${{ matrix.partition == 0 }}

Expand Down
15 changes: 15 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,21 @@ repos:
language: system
files: ^experimental/sgl-router/.*\.rs$
pass_filenames: false
- id: clippy-rust-workspace
name: clippy rust/ workspace (auto-fix)
# Two steps: apply machine-applicable fixes, then fail on anything left
# (combining --fix with -D warnings can abort the fix phase). protoc is
# not required: sglang-grpc's build.rs falls back to a vendored binary.
entry: bash -c 'cd rust && cargo clippy --workspace --fix --allow-dirty --allow-staged && cargo clippy --workspace -- -D warnings'
language: system
files: ^rust/.*\.rs$
pass_filenames: false
- id: rustfmt-rust-workspace
name: rustfmt rust/ workspace
entry: bash -c 'cd rust && cargo fmt'
language: system
files: ^rust/.*\.rs$
pass_filenames: false
- id: mint-broken-links
name: check docs_new links (Mintlify; opt-in, requires `mint` on PATH)
entry: bash -c 'cd docs_new && mint broken-links --check-anchors --check-redirects'
Expand Down
14 changes: 4 additions & 10 deletions python/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -225,16 +225,10 @@ git_describe_command = ["python3", "python/tools/get_version_tag.py"]
# Allow editable installs even when .git metadata is not available.
fallback_version = "0.0.0.dev0"

[[tool.setuptools-rust.ext-modules]]
target = "sglang.srt.grpc._core"
path = "../rust/sglang-grpc/Cargo.toml"
binding = "PyO3"

[[tool.setuptools-rust.ext-modules]]
target = "sglang.srt.multimodal._core"
path = "../rust/sglang-mm/Cargo.toml"
binding = "PyO3"
debug = false
# Rust extension modules are auto-discovered by setup.py from the cargo
# workspace in ../rust ([package.metadata.sglang] python-module in each crate).
# This CUDA pyproject builds all of them; platform variants restrict the set
# via [tool.sglang] rust-extensions (see pyproject_other.toml).

[tool.kernels.dependencies]
"kernels-community/sgl-flash-attn3" = 1
14 changes: 8 additions & 6 deletions python/pyproject_other.toml
Original file line number Diff line number Diff line change
Expand Up @@ -192,12 +192,14 @@ dev_mps = ["sglang[all_mps]", "sglang[test]"]
[project.scripts]
sglang = "sglang.cli.main:main"

# Rust-accelerated multimodal preprocessing (sglang.srt.multimodal._core).
# grpc is intentionally omitted here (it needs proto/tonic); ROCm only builds mm.
[[tool.setuptools-rust.ext-modules]]
target = "sglang.srt.multimodal._core"
path = "../rust/sglang-mm/Cargo.toml"
binding = "PyO3"
# Rust extension modules are auto-discovered by setup.py from the cargo
# workspace in ../rust; this allowlist restricts which of them this platform
# builds (case-insensitive substrings of the target module). grpc is
# intentionally omitted here (it needs proto/tonic); ROCm only builds mm.
# NOTE: setup.py reads this with a line regex, not a TOML parser — keep the
# assignment on a single line.
[tool.sglang]
rust-extensions = ["multimodal"]

[tool.setuptools.package-data]
"sglang" = [
Expand Down
183 changes: 145 additions & 38 deletions python/setup.py
Original file line number Diff line number Diff line change
@@ -1,39 +1,152 @@
"""sglang build hooks.

SGLANG_BUILD_RUST_EXTS controls which Rust extensions are built:
- unset or "all": build every declared Rust extension (the default).
- "none": build no Rust extensions.
- comma-separated names: build only extensions whose target matches one of the
given (case-insensitive) substrings, e.g. "grpc" matches
"sglang.srt.grpc._core".

This is a build-time environment variable, so it is read directly from
os.environ instead of sglang.srt.environ, which is not available until after the
package has been built.
Rust extensions are auto-discovered from the cargo workspace in ../rust: every
crate whose Cargo.toml declares

[package.metadata.sglang]
python-module = "sglang.srt.<pkg>._core" # import path inside the wheel
debug = false # optional RustExtension knob

is built as a PyO3 extension module at that import path. Adding a new extension
crate therefore needs no pyproject changes — declare the metadata in the crate.

Two filters can narrow the discovered set:

- [tool.sglang] rust-extensions in the active pyproject.toml: a list of
case-insensitive substrings of the target module. Platform pyprojects use
this to build a subset (e.g. pyproject_other.toml builds only "multimodal";
grpc needs proto/tonic and is intentionally CUDA-only).
- SGLANG_BUILD_RUST_EXTS env var, applied at build time on top of the above:
unset or "all" builds everything, "none" builds nothing, and a
comma-separated list matches substrings, e.g. "grpc" matches
"sglang.srt.grpc._core". It is read directly from os.environ instead of
sglang.srt.environ, which is not importable until the package is built.
"""

import json
import os
import re
import subprocess
from pathlib import Path

from setuptools import setup

try:
from setuptools_rust import build_rust
from setuptools_rust import Binding, RustExtension, build_rust
except ModuleNotFoundError as exc:
if exc.name != "setuptools_rust":
raise
# Alternate platform pyprojects do not declare Rust extensions.
# Alternate platform pyprojects that build no Rust extensions do not
# install setuptools-rust.
build_rust = None

_BUILD_RUST_EXTS_ENV = "SGLANG_BUILD_RUST_EXTS"
_PYTHON_DIR = Path(__file__).resolve().parent
_RUST_WORKSPACE_DIR = _PYTHON_DIR.parent / "rust"


def _cargo_workspace_metadata():
"""The rust/ cargo workspace as JSON, straight from cargo's own parser."""
manifest_path = _RUST_WORKSPACE_DIR / "Cargo.toml"
if not manifest_path.is_file():
raise RuntimeError(
f"no cargo workspace at {manifest_path} (building outside a repo "
f"checkout?); set {_BUILD_RUST_EXTS_ENV}=none to build without "
"Rust extensions"
)
try:
out = subprocess.run(
[
"cargo",
"metadata",
"--format-version",
"1",
"--no-deps",
"--manifest-path",
str(manifest_path),
],
capture_output=True,
check=True,
text=True,
)
except FileNotFoundError as exc:
raise RuntimeError(
"cargo is required to discover the Rust extension modules in "
f"{_RUST_WORKSPACE_DIR} (and to build them); install a Rust "
f"toolchain, or set {_BUILD_RUST_EXTS_ENV}=none to build without "
"Rust extensions"
) from exc
except subprocess.CalledProcessError as exc:
raise RuntimeError(f"cargo metadata failed:\n{exc.stderr}") from exc
return json.loads(out.stdout)


def _match_by_substring(declared, tokens, source):
"""Match tokens as case-insensitive substrings of extension names."""
matched = set()
unmatched = []
for token in tokens:
hits = {ext.name for ext in declared if token in ext.name.lower()}
if hits:
matched |= hits
else:
unmatched.append(token)
if unmatched:
declared_names = sorted(ext.name for ext in declared)
raise ValueError(
f"{source} matched no discovered Rust extension for: {unmatched}; "
f"discovered extensions are {declared_names}"
)
return [ext for ext in declared if ext.name in matched]


def _selected_rust_extensions(declared):
"""Return the Rust extensions selected by SGLANG_BUILD_RUST_EXTS.
def _discovered_rust_extensions():
"""One RustExtension per workspace crate declaring a python-module."""
extensions = []
for package in sorted(
_cargo_workspace_metadata()["packages"], key=lambda p: p["name"]
):
sglang_meta = (package["metadata"] or {}).get("sglang", {})
if "python-module" not in sglang_meta:
continue
extensions.append(
RustExtension(
target=sglang_meta["python-module"],
path=package["manifest_path"],
binding=Binding.PyO3,
debug=sglang_meta.get("debug"),
)
)
if not extensions:
raise RuntimeError(
f"no crate under {_RUST_WORKSPACE_DIR} declares "
"[package.metadata.sglang] python-module; set "
f"{_BUILD_RUST_EXTS_ENV}=none to build without Rust extensions"
)
return extensions


# Deliberately not a TOML parser (keeps setup.py stdlib-only): the allowlist
# must be written as a single line, e.g. rust-extensions = ["multimodal"].
_ALLOWLIST_RE = re.compile(r"^rust-extensions\s*=\s*\[([^\]]*)\]", re.MULTILINE)


`ext.name` is the fully-qualified target (e.g. "sglang.srt.grpc._core") for
the string-target declarations in pyproject.toml, so comma-separated names
are matched as case-insensitive substrings of it.
"""
def _pyproject_rust_extensions(declared):
"""Apply the active pyproject's [tool.sglang] rust-extensions allowlist."""
pyproject_text = (_PYTHON_DIR / "pyproject.toml").read_text(encoding="utf-8")
match = _ALLOWLIST_RE.search(pyproject_text)
if match is None:
return declared
tokens = re.findall(r'"([^"]*)"', match.group(1))
return _match_by_substring(
declared=declared,
tokens=[token.lower() for token in tokens],
source="[tool.sglang] rust-extensions",
)


def _selected_rust_extensions(declared):
"""Apply the SGLANG_BUILD_RUST_EXTS build-time filter."""
declared = list(declared)
raw = os.environ.get(_BUILD_RUST_EXTS_ENV)
if raw is None:
Expand All @@ -52,23 +165,17 @@ def _selected_rust_extensions(declared):
f"{_BUILD_RUST_EXTS_ENV}={raw!r} has an empty item; unset it or use "
"'all', 'none', or a comma-separated list of extension names"
)
return _match_by_substring(
declared=declared, tokens=tokens, source=_BUILD_RUST_EXTS_ENV
)

matched = set()
unmatched = []
for token in tokens:
hits = {ext.name for ext in declared if token in ext.name.lower()}
if hits:
matched |= hits
else:
unmatched.append(token)
if unmatched:
declared_names = sorted(ext.name for ext in declared)
raise ValueError(
f"{_BUILD_RUST_EXTS_ENV} matched no declared Rust extension for: "
f"{unmatched}; declared extensions are {declared_names}"
)

return [ext for ext in declared if ext.name in matched]
def _declared_rust_extensions():
# "none" short-circuits discovery so builds without a ../rust checkout
# (e.g. from an sdist) still work.
if (os.environ.get(_BUILD_RUST_EXTS_ENV) or "").strip().lower() == "none":
return []
return _pyproject_rust_extensions(_discovered_rust_extensions())


if build_rust is not None:
Expand All @@ -84,9 +191,9 @@ def run(self) -> None:
return
super().run()

_cmdclass = {"build_rust": BuildRust}
setup(
cmdclass={"build_rust": BuildRust},
rust_extensions=_declared_rust_extensions(),
)
else:
_cmdclass = {}


setup(cmdclass=_cmdclass)
setup()
33 changes: 33 additions & 0 deletions rust/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
[workspace]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why not put the workspace files under root dir?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's not pollute the root directory, since we have other rust lib like sglang-gateway sgl-router etc.

resolver = "3"
members = ["sglang-grpc", "sglang-mm"]

[workspace.package]
version = "0.1.0"
edition = "2024"
license = "Apache-2.0"

# Single source of truth for dependency versions across workspace members.
# Members pull these in with `dep = { workspace = true }` (plus extra features
# where needed).
[workspace.dependencies]
async-stream = "0.3"
pyo3 = { version = "0.29.0", features = ["extension-module"] }
serde_json = "1"
tokio = { version = "1", features = ["full"] }
tokio-stream = { version = "0.1", features = ["net"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
uuid = { version = "1", features = ["v4"] }

# Profiles only take effect at the workspace root; per-member profile sections
# are ignored by cargo. Per-package tweaks live in the override tables below.
[profile.release]
lto = true
strip = true
opt-level = 3
codegen-units = 1

[profile.dev]
opt-level = 0
debug = 1
Comment thread
rainj-me marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
[toolchain]
channel = "1.90"
profile = "minimal"
components = ["clippy", "rustfmt"]
Loading
Loading