Files
MOVZXandHinata b1fdbde895 build: Rewrite artifacts/ when it is missing, and stop failing re-runs
Three faults ended a re-run before step 8, so a deleted artifacts/ was
never written again.

The import check read the first word of stdout, and vLLM logs to stdout.
On a box with mixed 3090 and 3090 Ti devices the device-mismatch WARNING
comes first, so the build stopped with "vllm reports WARNING, expected
0.30.0". Print a BUILD_SMOKE marker and read the version from that line.

Patch 0103 committed .build-products.sha256 into the serving tree, and
write_sums rewrites that file, so every re-run stopped at "modified
tracked files". Drop the file from the patch and exclude the two build
record paths from the check. The series now yields tree
a823fa36d3fd640f87b3de87153268d938130a47; OPS_OBJ is unchanged, so the
compiled kernels stay valid.

Step 8 skipped the pack whenever the kernels came from a tarball, and
judged an existing set by file presence alone. Require the tarball, the
per-file list, the manifest and the sidecar, verify the sidecar, and
pack from the products in the tree when any check fails.

Co-Authored-By: Hinata <hinata@idihore.id>
Signed-off-by: MOVZX <movzx@yahoo.com>
2026-10-18 08:38:14 +00:00

510 lines
32 KiB
Bash
Executable File

#!/usr/bin/env bash
# Build the serving tree: vLLM 0.30.0 + patches/, compiled for 4x RTX 3090.
#
# ./build.sh [--force]
# ARTIFACTS=artifacts/build-artifacts-<tag>.tar.gz ./build.sh
#
# Default: everything is compiled here, from this tree's source. No flags, no route to choose.
# step 2 creates ./vllm (gitignored): upstream vLLM v0.30.0 (ced6857afa) + patches/*.patch
# (100 patches), then asserts the result against the expected tree hash. FETCH=0 reuses the
# existing tree; FETCH=1 re-applies the series from the base.
# step 4 a fresh .venv from CPython 3.13 + scripts/requirements-pinned.txt (torch 2.13.0, CUDA 13.0).
# step 6 the CUDA 13.0 compiler from the venv's nvidia wheels, targets arch 8.6 (sm_86 = RTX 3090).
# step 7 uv pip install -e . --no-deps --no-build-isolation: configures with cmake, compiles every
# extension of this tree (CUDA/C++/PTX, plus the Rust frontend when a Rust toolchain exists),
# installs the tree into .venv in place, and gives the tree its version and console script.
#
# ARTIFACTS=<tarball> installs compiled kernels of this same tree instead of compiling them. step 6
# unpacks them after it checks the compiled inputs (OPS_OBJ below), the arch, the CPython minor,
# the CUDA major and the torch version in the tarball's manifest, and hashes every file after the
# unpack. step 7 then installs the tree without compiling (VLLM_TARGET_DEVICE=empty). step 8 writes
# that tarball, so the Dockerfile uses this route when you pass --build-context ops=$PWD/artifacts.
#
# step 8 packs the kernels into artifacts/ for the image build (PACK=0 or --no-pack skips it; the
# Dockerfile sets it, an image build must not grow a 340 MB tarball inside a layer). Delete artifacts/
# and the next run writes it again from the compiled products, with no recompile.
#
# Needs: git curl tar gzip sha256sum cmake >= 3.26 ninja cc c++, and uv (UV=<path>, UV=0 to refuse)
# or python3.13 with the venv module. Optional: cargo 1.95 for the Rust frontend.
# Budget: ~30 GB free disk, ~20 min on 64 cores for a first build. MAX_JOBS caps the compile jobs
# (default: every core; each nvcc job can use 4 GB RAM).
#
# Re-runs: a finished build is recorded in vllm/.build-products (tree head + the two extension
# hashes) and vllm/.build-products.sha256 (every build product). When the tree has not moved, every
# hash still matches, and .venv holds the install, steps 6 and 7 are skipped. A tree move, a changed
# product, a missing product, or a missing install ends the skip. --force (or FORCE=1) skips nothing
# and compiles again.
#
# ccache: used automatically when it is on PATH. vllm/setup.py passes ccache as the cmake compiler
# launcher for C, C++, CUDA and HIP, so a rebuild after a patch change recompiles only the files
# that changed. Objects go to the ccache directory of the current user ($HOME/.ccache by default,
# CCACHE_DIR=<path> to move it). A first build fills the cache and gains nothing; the second one is
# where it pays. Without ccache every kernel compiles from scratch and build.sh says so.
set -euo pipefail
STEPS=8
FORCE="${FORCE:-0}"
PACK="${PACK:-1}"
for opt in "$@"; do
case "$opt" in
--force|-f) FORCE=1 ;;
--no-pack) PACK=0 ;;
-h|--help) echo "usage: ./build.sh [--force] [--no-pack]"; echo " --force compile again even when nothing changed"; echo " --no-pack do not write the compiled kernels to artifacts/"; echo " ARTIFACTS=<tarball> install compiled kernels from artifacts/ instead of compiling"; exit 0 ;;
*) echo "unknown option: $opt (./build.sh --help)"; exit 1 ;;
esac
done
# Every step announces itself before it runs, so a stall or a failure names the step.
step(){ printf '\n==> [%s/%s] %s\n' "$1" "$STEPS" "$2"; }
step 1 "tools and CPython 3.13"
for t in git curl tar gzip sha256sum cmake ninja cc c++; do command -v "$t" >/dev/null || { echo "missing tool: $t"; exit 1; }; done
cm="$(cmake --version | sed -n 's/^cmake version \([0-9][0-9.]*\).*/\1/p' || true)"
[ -n "$cm" ] || { echo "no cmake version from \`cmake --version\`"; exit 1; }
[ "$(printf '%s\n3.26\n' "$cm" | sort -V | head -1)" = 3.26 ] || { echo "building from source needs cmake >= 3.26 (found $cm)"; exit 1; }
PY=python3.13
UV="${UV:-}"; [ -n "$UV" ] || UV="$(command -v uv || true)"
if [ -n "$UV" ]; then
"$UV" python find 3.13 >/dev/null 2>&1 || "$UV" python install 3.13 || { echo "$UV cannot provide CPython 3.13"; exit 1; }
PY="$("$UV" python find 3.13)"
elif command -v python3.13 >/dev/null; then
probe="$(mktemp -d)"; trap 'rm -rf "$probe"' EXIT
python3.13 -m venv "$probe/p" >/dev/null 2>&1 || { echo "python3.13 cannot create a venv: apt install python3.13-venv"; exit 1; }
rm -rf "$probe"; trap - EXIT
else
echo "need uv (https://docs.astral.sh/uv) or python3.13 with the venv module"; exit 1
fi
HERE="$(cd "$(dirname "$0")" && pwd)"; ROOT="$HERE"
abspath(){ case "$1" in /*) printf '%s\n' "$1" ;; *) printf '%s\n' "$PWD/$1" ;; esac; }
SRC="$(abspath "${SRC:-$HERE/vllm}")"
VENV="$(abspath "${VENV:-$HERE/.venv}")"
ARCH="${ARCH:-8.6}"
JOBS="${MAX_JOBS:-$(nproc)}"
cc=""
if command -v ccache >/dev/null; then
cc="$(command -v ccache)"
# CCACHE_DIR is left alone: ccache uses its own per-user directory ($HOME/.cache/ccache,
# /root/.cache/ccache for root), so the cache is shared with every other build on this account.
[ -n "${CCACHE_DIR:-}" ] && mkdir -p "$CCACHE_DIR"
ccd="$( "$cc" --show-config 2>/dev/null | sed -n 's/.*cache_dir = //p' | head -1 )"
[ -n "$ccd" ] || ccd="${CCACHE_DIR:-the ccache default for this user}"
fi
echo " repo=$ROOT SRC=$SRC VENV=$VENV"
echo " python: $PY cmake $cm arch=$ARCH MAX_JOBS=$JOBS"
if [ -n "$cc" ]; then
echo " ccache: $cc dir=$ccd (objects reused on the next build)"
else
echo " ccache: not on PATH, every kernel compiles from scratch"
fi
step 2 "serving tree (upstream v0.30.0 + $(ls "$HERE"/patches/*.patch | wc -l) patches)"
# The tree is not in this repo: this repo ships the patches, not the tree. Fetch applies the series
# to the upstream tag and asserts the result, so a patch series that drifts fails here, not at serve.
BASE=ced6857afa0ea7b2e3f0846a62e1394e90f15607 # tag v0.30.0
EXPECTED_TREE=a823fa36d3fd640f87b3de87153268d938130a47 # BASE + patches/*.patch (106), verified 2026-10-18
# The tree carries no build record: the series of patch 0103 once committed .build-products.sha256,
# which build.sh rewrites, so every re-run failed the "modified tracked files" check below.
URL="${VLLM_UPSTREAM_URL:-https://github.com/vllm-project/vllm.git}"
# git am makes a commit, so git needs a committer identity: the host's ~/.gitconfig is not there (a
# container, a build user, a fresh box). The committer is not in the tree hash, so the assertion
# below does not depend on it.
GITID=( -c user.name="Hinata" -c user.email="hinata@idihore.id" )
git config --global core.autocrlf false 2>/dev/null || true
if [ "${FETCH:-0}" = 1 ] || [ ! -f "$SRC/vllm/__init__.py" ]; then
if [ -e "$SRC" ] && [ ! -d "$SRC/.git" ] && [ ! -f "$SRC/vllm/__init__.py" ]; then
echo "$SRC exists and is not a git checkout; move it away first"; exit 1
fi
echo " base=$BASE tag v0.30.0"
echo " patches=$(ls "$HERE"/patches/*.patch | wc -l) from $HERE/patches"
echo " upstream=$URL"
[ -d "$SRC/.git" ] || { echo " git init $SRC"; git init -q "$SRC"; }
git -C "$SRC" remote add upstream "$URL" 2>/dev/null || git -C "$SRC" remote set-url upstream "$URL"
if ! git -C "$SRC" cat-file -e "$BASE^{commit}" 2>/dev/null; then
echo " fetching v0.30.0 (shallow, ~200 MB, this is the slow part on a fresh host)..."
git -C "$SRC" fetch --quiet --depth 1 "$URL" "refs/tags/v0.30.0:refs/heads/flashnext" \
|| { echo "fetch v0.30.0 from $URL failed (no network?)"; exit 1; }
fi
git -C "$SRC" checkout -q flashnext 2>/dev/null || git -C "$SRC" checkout -q -b flashnext
git -C "$SRC" reset -q --hard "$BASE"
echo " applying patches (git am)..."
git -C "$SRC" "${GITID[@]}" am --keep-non-patch "$HERE"/patches/*.patch >/dev/null
fi
tree="$(git -C "$SRC" rev-parse 'HEAD^{tree}' 2>/dev/null || echo none)"
[ "$tree" = "$EXPECTED_TREE" ] || { echo "tree $tree != expected $EXPECTED_TREE: the series drifted, or the tree is older than the patches. FETCH=1 re-applies it"; exit 1; }
echo " tree ready: $(git -C "$SRC" rev-parse --short HEAD) tree $tree"
# build.sh writes the build record (MARK/SUMS below) inside the tree, so those two paths are never a
# change to the series. Excluding them keeps this check for real drift only, and names the file.
dirty="$(git -C "$SRC" status --porcelain --untracked-files=no -- . ':(exclude).build-products' ':(exclude).build-products.sha256' 2>/dev/null)"
[ -n "$dirty" ] && { echo "$SRC has modified tracked files:"; printf '%s\n' "$dirty" | sed 's/^/ /'; exit 1; }
TREE_HEAD="$(git -C "$SRC" rev-parse --short HEAD 2>/dev/null || echo unknown)"
# The commit SHA of the same tree differs between machines: git am stamps the committer date of the
# box that applied the patches, so the host commit and the container commit of one patch set are
# not the same SHA. The tree object is identical. Compare that, not the SHA.
TREE_OBJ="$(git -C "$SRC" rev-parse 'HEAD^{tree}' 2>/dev/null || echo unknown)"
# The ops cache key: the compiled inputs only. A patch that touches Python alone leaves this key
# alone, so a tarball of prebuilt kernels stays valid for it and the image build keeps the ~2 min
# route. A patch that moves any path below invalidates the tarball, and the build compiles.
# A path missing from the tree yields "missing", which no manifest can match: the fail-safe way.
OPS_PATHS="csrc CMakeLists.txt cmake setup.py pyproject.toml MANIFEST.in requirements tools rust-toolchain.toml vllm/third_party vllm/vllm_flash_attn"
OPS_OBJ="$(for p in $OPS_PATHS; do git -C "$SRC" rev-parse -q --verify "HEAD^{tree}:$p" 2>/dev/null || echo missing; done | LC_ALL=C sha256sum | cut -d' ' -f1)"
MARK="$SRC/.build-products"; SUMS="$SRC/.build-products.sha256"
LIST="$HERE/scripts/build-artifacts.list"
step 3 "venv (fresh, never reused)"
if [ -e "$VENV" ] && [ ! -f "$VENV/.build-created" ]; then echo "$VENV exists and was not created here; pick a fresh VENV path"; exit 1; fi
if [ ! -x "$VENV/bin/python" ]; then
if [ -n "$UV" ]; then
v="$("$UV" venv --python 3.13 "$VENV" 2>&1)" || { echo "uv venv failed: $v"; exit 1; }
echo " $v"
else
"$PY" -m venv "$VENV"
fi
echo "created by build.sh" > "$VENV/.build-created"
else
echo " venv exists, reusing: $VENV"
fi
if [ -x "$VENV/bin/pip" ]; then
"$VENV/bin/python" -m pip install pip==26.2.1
PIP_INSTALL="$VENV/bin/pip install"
elif [ -n "$UV" ]; then
PIP_INSTALL="$UV pip install --python $VENV/bin/python"
else
echo "$VENV has no pip and no uv is available"; exit 1
fi
echo " installer: $PIP_INSTALL"
# Where the wheels come from. uv and pip use the cache directory of the current user, so a build
# under the same account reuses the wheels of every previous build. Nothing here changes them.
cache_of(){ [ -n "${2:-}" ] && { printf '%s\n' "$2"; return; }; printf '%s\n' "${HOME:-/root}/.cache/$1"; }
uv_cd="${UV_CACHE_DIR:-$(cache_of uv "${XDG_CACHE_HOME:-}")}"
if [ -n "$UV" ]; then
uv_n="$(du -sh "$uv_cd" 2>/dev/null | cut -f1 || true)"
echo " uv cache: $uv_cd${uv_n:+ ($uv_n)}"
fi
pip_cd="${PIP_CACHE_DIR:-$(cache_of pip "${XDG_CACHE_HOME:-}")}"
pip_n="$(du -sh "$pip_cd" 2>/dev/null | cut -f1 || true)"
echo " pip cache: $pip_cd${pip_n:+ ($pip_n)}"
pip_install(){ $PIP_INSTALL "$@"; }
step 4 "pinned runtime (torch 2.13.0 + CUDA 13.0 and the vllm deps; $(grep -cv '^\s*\(#\|$\)' "$HERE/scripts/requirements-pinned.txt") pinned packages)"
pip_install -r "${REQ:-$HERE/scripts/requirements-pinned.txt}"
pip_install setuptools-rust==1.13.0 setuptools-scm==10.3.4 vcs-versioning==2.5.0 semantic-version==2.10.0
"$VENV/bin/python" -c "import torch; assert torch.__version__.split('+')[0] == '2.13.0' and torch.version.cuda == '13.0', (torch.__version__, torch.version.cuda)"
step 5 "compile toolchain (CUDA 13.0 from the venv wheels)"
CU="$("$VENV/bin/python" -c 'import sysconfig; print(sysconfig.get_paths()["purelib"])')/nvidia/cu13"
[ -x "$CU/bin/nvcc" ] || { echo "no CUDA toolkit wheel at $CU"; exit 1; }
[ -e "$CU/lib64" ] || ln -s lib "$CU/lib64"
for so in "$CU"/lib/lib*.so.[0-9]*; do base="${so%%.so.*}.so"; [ -e "$base" ] || ln -s "$(basename "$so")" "$base"; done
[ -e "$CU/lib/libcudart.so" ] || { echo "$CU/lib has no libcudart.so.13"; exit 1; }
NVCC="${NVCC:-$CU/bin/nvcc}"
nv_rel="$("$NVCC" --version | sed -n 's/.*release \([0-9][0-9]*\.[0-9][0-9]*\),.*/\1/p')"
[ "$nv_rel" = 13.0 ] || { echo "nvcc is CUDA ${nv_rel:-unknown}, expected 13.0"; exit 1; }
echo " nvcc $nv_rel at $NVCC (start.sh points CUDA_HOME here, for the runtime JIT kernels)"
# The kernels link CUDA::cuda_driver, the driver library (libcuda.so.1). The
# driver package provides it; the CUDA wheel does not. Generate the link stub.
stub_dir="$CU/lib/stubs"
mkdir -p "$stub_dir"
"$HERE/scripts/gen-cuda-stub.sh" "$CU/include/cuda.h" "$stub_dir/libcuda.so" | sed 's/^/ /'
cargo="$(command -v cargo || true)"
if [ -n "$cargo" ]; then
echo " rust frontend: $cargo $(cargo --version 2>/dev/null || echo unknown) -- vllm-rs and _rust_tool_parser will be built"
else
echo " rust frontend: no cargo -> vllm-rs and _rust_tool_parser are skipped."
echo " the shipped serve profile uses the Python tool parsers, so serving is unaffected."
echo " install rustup toolchain 1.95 (vllm/rust-toolchain.toml) and re-run ./build.sh to add them."
fi
# glibc: the products of a packed build carry the newest glibc symbol they need. The box that
# installs them must be at least that new, or the loader fails at serve time, not here. Print it
# and stop, with the reason.
glibc_ver(){ ldd --version 2>/dev/null | sed -n 's/^ldd [^0-9]*\([0-9][0-9.]*\).*/\1/p' | head -1; }
ver_ge(){ [ "$(printf '%s\n%s\n' "$1" "$2" | sort -V | head -1)" = "$2" ]; }
# The build products this tree must have at serve time. build-artifacts.list is the list.
check_list(){ local miss=0 p; while read -r p; do case "$p" in ''|'#'*) continue ;; esac; [ -e "$SRC/${p%/}" ] || { echo "missing build product: $p"; miss=1; }; done < "$LIST"; return $miss; }
write_sums(){ (cd "$SRC" && while read -r p; do case "$p" in ''|'#'*) continue ;; esac; find "${p%/}" -type f; done < "$LIST" | grep -vx 'vllm/_version.py' | LC_ALL=C sort | xargs -d '\n' sha256sum) > "$SUMS.tmp" && mv "$SUMS.tmp" "$SUMS"; }
check_sums(){ [ -s "$SUMS" ] && (cd "$SRC" && sha256sum -c --quiet --strict "$SUMS"); }
# Prebuilt ops: ARTIFACTS=<tarball> installs compiled kernels instead of compiling them. The
# tarball comes from step 8 of a finished build of this exact tree. The checks below are the reason
# a packed build is safe to install: the tree
# head, the CPython minor, the CUDA major, the torch version and the arch must all match, and
# every file is hashed after the unpack. One mismatch stops the build and says which one.
prebuilt=0
if [ -n "${ARTIFACTS:-}" ]; then
step 6 "install prebuilt ops (no compile)"
[ -f "$ARTIFACTS" ] || { echo "ARTIFACTS=$ARTIFACTS is not a file"; exit 1; }
adir="$(cd "$(dirname "$ARTIFACTS")" && pwd)"; abase="$(basename "$ARTIFACTS")"; stem="${abase%.tar.gz}"
[ -f "$adir/$stem.sha256" ] || { echo "$adir/$stem.sha256 is needed: the per-file hashes verified after the unpack"; exit 1; }
[ -f "$adir/$stem.manifest" ] || { echo "$adir/$stem.manifest is needed: it records the tree, arch, python, CUDA and torch the ops were built for"; exit 1; }
if [ -f "$ARTIFACTS.sha256" ]; then
( cd "$adir" && sha256sum -c --quiet "$stem.tar.gz.sha256" ) || { echo "$ARTIFACTS does not match $stem.tar.gz.sha256"; exit 1; }
echo " tarball sha256: ok"
else
echo " tarball sha256: no $stem.tar.gz.sha256 sidecar, the tarball itself is not verified"
fi
# Read the manifest with sed, not with ".": cc= and glibc= carry text with parentheses and
# spaces, which the shell cannot source.
mf(){ sed -n "s/^$1=//p" "$adir/$stem.manifest" | head -1; }
m_tree="$(mf tree)"; m_tree_obj="$(mf tree_obj)"; m_arch="$(mf arch)"; m_py="$(mf python)"; m_cuda="$(mf cuda)"
m_torch="$(mf torch)"; m_glibc="$(mf glibc)"; m_files="$(mf files)"; m_built="$(mf built)"; m_ops_obj="$(mf ops_obj)"
echo " manifest: tree=$m_tree tree_obj=${m_tree_obj:0:7} ops_obj=${m_ops_obj:0:7} arch=$m_arch python=$m_py cuda=$m_cuda torch=$m_torch glibc=$m_glibc files=$m_files built=$m_built"
echo " this tree: tree_obj=${TREE_OBJ:0:7} ops_obj=${OPS_OBJ:0:7}"
fail=0
[ -n "$m_tree_obj" ] || { echo "$adir/$stem.manifest has no tree_obj= line: pack the ops again with ./build.sh"; exit 1; }
# The compiled inputs are what the kernels were built from, so OPS_OBJ is the check. A Python-only
# patch moves the whole tree object, and must not push this build into a 20-40 min compile. The
# commit SHA is printed next to it and is allowed to differ between machines (committer date).
if [ -z "$m_ops_obj" ]; then
echo " manifest has no ops_obj= line, it predates this check: the strict tree_obj test applies"
m_ops_obj="$m_tree_obj"
fi
[ "$m_ops_obj" = "$OPS_OBJ" ] || { echo "the ops were compiled from inputs ${m_ops_obj:0:7}, this tree compiles from ${OPS_OBJ:0:7} (trees $m_tree_obj / $TREE_OBJ, commits $m_tree / $TREE_HEAD): pack them from this tree, or compile"; fail=1; }
venv_py="$("$VENV/bin/python" -c 'import sys; print("%d.%d" % sys.version_info[:2])')"
[ "$m_py" = "$venv_py" ] || { echo "the ops are for CPython $m_py, the venv is $venv_py"; fail=1; }
[ "$m_cuda" = "$nv_rel" ] || { echo "the ops are for CUDA $m_cuda, this build uses CUDA $nv_rel"; fail=1; }
venv_torch="$("$VENV/bin/python" -c 'import torch; print(torch.__version__.split("+")[0])')"
[ "$m_torch" = "$venv_torch" ] || { echo "the ops are for torch $m_torch, the venv has torch $venv_torch"; fail=1; }
[ "$m_arch" = "$ARCH" ] || { echo "the ops are built for arch $m_arch, this build asks for arch $ARCH"; fail=1; }
[ "$fail" = 0 ] || exit 1
if [ -n "${FORCE:-}" ] && [ "$FORCE" = 1 ]; then rm -f "$MARK" "$SUMS"; echo " --force: replacing the products in the tree"; fi
echo " unpacking $(du -h "$ARTIFACTS" | cut -f1) into $SRC"
tar -xzf "$ARTIFACTS" -C "$SRC"
( cd "$SRC" && sha256sum -c --quiet --strict "$adir/$stem.sha256" ) \
|| { echo "a file of the tarball does not match $stem.sha256 after the unpack"; exit 1; }
echo " unpacked: all $m_files file hashes match the manifest"
need="$(readelf -V "$SRC/vllm/_C_stable_libtorch.abi3.so" 2>/dev/null | grep -o 'GLIBC_[0-9.]*' | sed 's/GLIBC_//' | sort -V | tail -1)"
have="$(glibc_ver)"
if [ -n "$need" ] && [ -n "$have" ]; then
ver_ge "$have" "$need" || { echo "these ops need glibc $need, this box has $have: build the image on a base with that glibc, or compile here"; exit 1; }
echo " glibc: ops need $need, this box has $have"
else
echo " glibc check skipped: no readelf or no ldd here"
fi
check_list || { echo "the tarball does not carry every product of $(basename "$LIST")"; exit 1; }
write_sums
prebuilt=1
fi
# A finished build of this tree is reused, so a re-run does not take 20 minutes again. Every check
# below has to pass to skip the compile, and the one that fails says what changed.
reuse=0
if [ "$prebuilt" = 1 ]; then
reuse=0
elif [ -f "$MARK" ] && [ "$FORCE" = 1 ]; then
echo " --force: compiling again; the record in $(basename "$MARK") is not trusted"
elif [ -f "$MARK" ]; then
read -r _ c_sha moe_sha tree_at_build < "$MARK"
reuse=1
if [ "$tree_at_build" != "$TREE_HEAD" ]; then
# A tree move only forces an extension rebuild when a C++/CUDA source actually changed.
# A Python-only patch (the common case) reuses the existing extensions: ninja would
# otherwise recompile everything because git am refreshes every file timestamp.
if git -C "$SRC" diff --name-only "$tree_at_build" "$TREE_HEAD" -- 'csrc/' '*.cu' '*.cuh' '*.cpp' '*.cc' 'CMakeLists.txt' 'setup.py' 'pyproject.toml' 2>/dev/null | grep -q .; then
echo "note: the tree moved $tree_at_build -> $TREE_HEAD and C++/CUDA sources changed; rebuilding the extensions"
reuse=0
else
echo "note: the tree moved $tree_at_build -> $TREE_HEAD but no C++/CUDA sources changed; reusing the extensions"
fi
fi
if [ "$(sha256sum "$SRC/vllm/_C_stable_libtorch.abi3.so" 2>/dev/null | cut -d' ' -f1)" != "$c_sha" ] || [ "$(sha256sum "$SRC/vllm/_moe_C_stable_libtorch.abi3.so" 2>/dev/null | cut -d' ' -f1)" != "$moe_sha" ]; then
echo "note: the extensions are not the ones recorded in $MARK; the compile below rebuilds them"
reuse=0
fi
check_list || { echo "note: build products are missing; the compile below builds them"; reuse=0; }
[ "$reuse" = 1 ] && { check_sums || { echo "note: build products changed since $(basename "$SUMS"); the compile below rebuilds them"; reuse=0; }; }
# A source-only change (a patch that touches no kernel) is invisible to the product hashes above,
# and the editable install of step 7 is what makes the tree importable. So the install must exist.
[ "$reuse" = 1 ] && { ls -d "$VENV"/lib/python3.*/site-packages/vllm-*.dist-info >/dev/null 2>&1 \
|| { echo "note: $VENV has no installed vllm; step 7 installs it"; reuse=0; }; }
if [ "$reuse" = 1 ]; then
echo " existing build products: present, all $(wc -l < "$SUMS") hashes re-verified (built at tree $tree_at_build)"
else
rm -f "$MARK" "$SUMS"; echo " old build record removed, verification runs after the compile"
fi
fi
if [ "$prebuilt" = 1 ]; then
step 7 "install the tree into .venv (editable, prebuilt ops, metadata only)"
# The kernels are already compiled, so setup.py must not compile again. VLLM_TARGET_DEVICE=empty
# makes it skip every compile and write the version file and the console script only.
build_log="$ROOT/build-$(date +%Y%m%d-%H%M%S).log"
echo " full build output -> $build_log"
t0=$(date +%s)
( while sleep 60; do echo " ... $(( ( $(date +%s) - t0 ) / 60 )) min elapsed, installing metadata"; done ) & hb=$!
( cd "$SRC" && env VLLM_TARGET_DEVICE=empty VLLM_VERSION_OVERRIDE=0.30.0 \
sh -c "$PIP_INSTALL -e . --no-deps --no-build-isolation" ) 2>&1 | tee "$build_log" \
|| { kill "$hb" 2>/dev/null; echo "step 7 failed; the install output is in $build_log"; exit 1; }
kill "$hb" 2>/dev/null || true; wait "$hb" 2>/dev/null || true
check_list || { echo "installed, but build products are missing; see $build_log"; exit 1; }
elif [ "$reuse" = 1 ]; then
step 6 "compile (skipped: no change to the tree, the products, or the install)"
step 7 "install (skipped: .venv already holds this tree)"
echo " nothing to compile. ./build.sh --force compiles again."
else
step 6 "build this tree from source (arch $ARCH, $(grep -vc '^\s*#\|^\s*$' "$LIST") products in $(basename "$LIST"))"
echo " setup.py configures cmake and compiles: $(grep -v '^\s*#\|^\s*$' "$LIST" | tr '\n' ' ')"
echo " first build: ~20 min on 64 cores, then a re-run of an unchanged tree skips this step"
echo " the compile runs in step 7: uv prints every compiler line, one status line every 60s"
# Install the tree in place. setup.py does the cmake configure, compiles this tree's kernels,
# and drops the products into $SRC/vllm/. --no-deps keeps the pins from step 4,
# --no-build-isolation compiles against the torch in the venv.
step 7 "install the tree into .venv (editable, compiled)"
build_log="$ROOT/build-$(date +%Y%m%d-%H%M%S).log"
echo " full build output -> $build_log"
stats(){ "$cc" -v -s 2>/dev/null | grep -E 'Hits:|Misses:|Cache size' | tr -s ' ' | tr '\n' ' '; }
before_stats="$(stats || true)"
# -v is what makes the compile visible. Without it uv swallows the build backend's stdout, so a
# 20 minute compile prints nothing between "Building vllm @ file:..." and "Built vllm".
# -v streams every nvcc/cc/cargo line (uv prefixes them with DEBUG). -vv adds uv's own debug noise.
t0=$(date +%s)
# Products to watch for, in the order setup.py produces them. Counted from the list, so the
# heartbeat ends at 15/15 and never counts a file the list does not require.
GCC_VER=""
for v in 14 13 12 11 10; do
if command -v "gcc-$v" >/dev/null 2>&1; then
GCC_VER="$v"
break
fi
done
[ -n "$GCC_VER" ] || { echo "Error: No GCC version < 15 found" >&2; exit 1; }
CC_PATH="/usr/bin/gcc-$GCC_VER"
CXX_PATH="/usr/bin/g++-$GCC_VER"
prod=$(grep -v '^\s*#\|^\s*$' "$LIST" | sed "s#^#$SRC/#")
( while sleep 60; do
e=$(( ( $(date +%s) - t0 ) / 60 ))
p=$(grep -oE '\[[0-9]+/[0-9]+\]' "$build_log" 2>/dev/null | tail -1)
n=0; while read -r f; do [ -e "$f" ] && n=$(( n + 1 )); done <<< "$prod"
echo " ... ${e} min elapsed, cmake ${p:-configuring}, ${n}/$(printf '%s\n' "$prod" | wc -l) build products in place"
done ) & hb=$!
( cd "$SRC" && env CUDA_HOME="$CU" PATH="$(dirname "$NVCC"):$PATH" TORCH_CUDA_ARCH_LIST="$ARCH" \
MAX_JOBS="$JOBS" VLLM_TARGET_DEVICE=cuda VLLM_VERSION_OVERRIDE=0.30.0 \
CC="$CC_PATH" CXX="$CXX_PATH" \
CMAKE_ARGS="-DCMAKE_CUDA_HOST_COMPILER=$CXX_PATH -DCMAKE_C_COMPILER=$CC_PATH -DCMAKE_CXX_COMPILER=$CC_PATH" \
sh -c "$PIP_INSTALL -v -e . --no-deps --no-build-isolation" ) 2>&1 | tee "$build_log" \
|| { kill "$hb" 2>/dev/null; echo "step 7 failed; the compiler output is in $build_log"; exit 1; }
kill "$hb" 2>/dev/null || true; wait "$hb" 2>/dev/null || true
grep -iE "warning: |error: " "$build_log" | grep -v "^\s*$" | head -20 || true
if [ -n "$cc" ]; then
echo " ccache before this build: ${before_stats:-no stats yet}"
echo " ccache after this build: $(stats || true)"
fi
check_list || { echo "installed, but build products are missing; see $build_log"; exit 1; }
write_sums
fi
echo " $(wc -l < "$SUMS") build files verified ($(du -sh "$SRC/vllm/_C_stable_libtorch.abi3.so" | cut -f1) + $(du -sh "$SRC/vllm/_moe_C_stable_libtorch.abi3.so" | cut -f1) extensions)"
# Smoke test. Importing a compiled kernel loads libcuda.so.1, the CUDA driver
# library. A box with a GPU has it (the driver package); a build container does
# not, so the import cannot run there and does not need to. Without a driver,
# check the link layer instead: the products exist (step 6 hashed them) and the
# loader entry that needs the driver is the one we expect. The full import runs
# at serve time, on a box with the driver.
if ldconfig -p 2>/dev/null | grep -q 'libcuda\.so\.1' || [ -e /dev/nvidia0 ]; then
smoke="$(cd / && "$VENV/bin/python" -c '
import torch, vllm
import vllm._C_stable_libtorch, vllm._moe_C_stable_libtorch
import vllm.vllm_flash_attn._vllm_fa2_C, vllm.vllm_flash_attn._vllm_fa3_C
from vllm.tool_parsers import ToolParserManager
ToolParserManager.get_tool_parser("qwen3_coder")
print("BUILD_SMOKE", vllm.__version__, vllm.__file__)' | tee /dev/stderr)" \
|| { echo "the installed tree does not import; see $build_log"; exit 1; }
# vLLM writes its log lines to stdout too: a box with mixed GPUs (3090 + 3090 Ti) prints a WARNING
# there before the version. Read the version from the marker line, never from the first word.
line="$(printf '%s\n' "$smoke" | grep '^BUILD_SMOKE ' | tail -1)"
ver="${line#BUILD_SMOKE }"; ver="${ver%% *}"
[ "$ver" = 0.30.0 ] || { echo "the installed tree does not report 0.30.0, it printed:"; printf '%s\n' "$smoke" | tail -3 | sed 's/^/ /'; exit 1; }
echo " import check: vllm $ver $(printf '%s\n' "$line" | cut -d' ' -f3-)"
else
echo " no CUDA driver here: the import check runs at serve time, on the GPU box"
if command -v readelf >/dev/null; then
# Only the extension that calls the driver API directly needs libcuda.so.1 in its NEEDED list
# (CUTLASS_ENABLE_DIRECT_CUDA_DRIVER_CALL). The MoE kernels do not: they get CUDA through
# libtorch_cuda/libcudart, so libcuda is absent from their NEEDED list and that is correct.
# Requiring it there fails a good build (it did, in Docker). Check it where it is expected,
# and check the torch/CUDA runtime libs everywhere.
for so in vllm/_C_stable_libtorch.abi3.so vllm/_moe_C_stable_libtorch.abi3.so \
vllm/vllm_flash_attn/_vllm_fa2_C.abi3.so vllm/vllm_flash_attn/_vllm_fa3_C.abi3.so; do
[ -e "$SRC/$so" ] || continue
n="$(readelf -d "$SRC/$so" | sed -n 's/.*\[\(lib[^]]*\)\].*/\1/p' | tr '\n' ' ')"
[ -n "$n" ] || { echo "$so has no dynamic section; the file is not a shared object"; exit 1; }
for need in libtorch.so libtorch_cuda.so libcudart.so; do
case " $n" in *" $need"*) ;; *) echo "$so does not ask for $need; the link is wrong"; exit 1 ;; esac
done
case "$so" in
vllm/_C_stable_libtorch.abi3.so) case " $n" in *libcuda.so*) ;; *) echo "$so does not ask for libcuda.so.1; the link is wrong"; exit 1 ;; esac ;;
esac
echo " link check $(basename "$so"):${n:+ }$n"
done
echo " link check: every lib above is found at run time (libcuda.so.1 comes from the driver)"
else
echo " link check skipped: no readelf on this box"
fi
fi
{ echo "source $(sha256sum "$SRC/vllm/_C_stable_libtorch.abi3.so" | cut -d' ' -f1) $(sha256sum "$SRC/vllm/_moe_C_stable_libtorch.abi3.so" | cut -d' ' -f1) $TREE_HEAD"; } > "$MARK"
# step 8: pack the kernels for the image build. The same tree object is what step 6 accepts back,
# so this tarball is the fast route for Docker: ARTIFACTS=artifacts/<name>.tar.gz, no compile.
# The tag carries what the products were built for: sm86 (arch 8.6) + the CPython minor + the CUDA
# major. A 3090 tarball does not work on a 5090; CUDA 13 products do not work with CUDA 12.
# An artifacts set is current when the tarball, its per-file list and its manifest are all there, the
# manifest names this tree, and the tarball still matches its own sha256 sidecar. Delete any of them
# (or the whole folder) and this step writes the set again from the products already in the tree, so a
# lost artifacts/ costs a pack (~45s), never a recompile.
artifacts_current(){ local d="$1" n="$2" o
{ [ -s "$d/$n.tar.gz" ] && [ -s "$d/$n.sha256" ] && [ -s "$d/$n.manifest" ] && [ -s "$d/$n.tar.gz.sha256" ]; } || return 1
[ "$(sed -n 's/^tree_obj=//p' "$d/$n.manifest" | head -1)" = "$TREE_OBJ" ] || return 1
o="$(sed -n 's/^ops_obj=//p' "$d/$n.manifest" | head -1)"; [ -z "$o" ] || [ "$o" = "$OPS_OBJ" ] || return 1
( cd "$d" && sha256sum -c --quiet "$n.tar.gz.sha256" ) ; }
if [ "$PACK" = 0 ]; then
step 8 "pack the kernels for the image build (skipped: PACK=0 / --no-pack)"
else
step 8 "pack the kernels for the image build"
OUT="${OUT:-$HERE/artifacts}"; mkdir -p "$OUT"
pyv="$("$VENV/bin/python" -c 'import sys; print("%d.%d" % sys.version_info[:2])')"
tv="$("$VENV/bin/python" -c 'import torch; print(torch.__version__.split("+")[0])')"
tag="sm${ARCH/./}-py${pyv/./}-cu${nv_rel%%.*}"
name="build-artifacts-$tag"
if artifacts_current "$OUT" "$name"; then
echo " artifacts/$name.tar.gz already holds tree ${TREE_OBJ:0:7}: not packed again"
else
[ "$prebuilt" = 1 ] && echo " these kernels came from a tarball, and artifacts/ holds no set for this tree: packing them back"
files="$( cd "$SRC" && while read -r p; do case "$p" in ''|'#'*) continue ;; esac
find "${p%/}" -type f; done < "$LIST" | LC_ALL=C sort )"
n="$(printf '%s\n' "$files" | grep -c . || true)"
[ "$n" -gt 0 ] || { echo "$(basename "$LIST") names no file under $SRC"; exit 1; }
echo " packing $n files -> artifacts/$name.tar.gz (~45s)"
# tar runs inside $SRC so every member starts with vllm/, which is the layout step 6 unpacks.
( cd "$SRC" && printf '%s\n' "$files" | xargs -d '\n' tar -czf "$OUT/$name.tar.gz" -- ) \
|| { echo "packing failed"; exit 1; }
( cd "$SRC" && printf '%s\n' "$files" | LC_ALL=C sort | xargs -d '\n' sha256sum ) > "$OUT/$name.sha256"
{
echo "tree=$TREE_HEAD"
echo "tree_obj=$TREE_OBJ"
echo "ops_obj=$OPS_OBJ"
echo "tree_full=$(git -C "$SRC" rev-parse HEAD)"
echo "arch=$ARCH"
echo "python=$pyv"
echo "cuda=$nv_rel"
echo "torch=$tv"
echo "cc=$(cc --version 2>/dev/null | head -1 || echo unknown)"
echo "glibc=$(glibc_ver || echo unknown)"
echo "built=$(date -u +%Y-%m-%dT%H:%M:%SZ)"
echo "files=$n"
echo "sha_C=$(sha256sum "$SRC/vllm/_C_stable_libtorch.abi3.so" | cut -d' ' -f1)"
echo "sha_moe=$(sha256sum "$SRC/vllm/_moe_C_stable_libtorch.abi3.so" | cut -d' ' -f1)"
} > "$OUT/$name.manifest"
sha256sum "$OUT/$name.tar.gz" | sed "s| .*$| $name.tar.gz|" > "$OUT/$name.tar.gz.sha256"
ls -l "$OUT/$name.tar.gz" "$OUT/$name.sha256" "$OUT/$name.manifest" | sed 's/^/ /'
fi
fi
echo "done: TREE=$SRC (tree $TREE_HEAD) VENV=$VENV"
echo "next: ./start.sh /path/to/Qwen3.8-Flash-Next-W4A16-Merlin"
echo " docker: docker build --build-context ops=\"$HERE/artifacts\" -t vllm:latest . (no compile)"