feat: add packaging scripts, build process, and GPU acceleration documentation
tests / python (3.10) (push) Successful in 12m30s
tests / python (3.11) (push) Successful in 12m33s
tests / web (push) Successful in 13m3s

This commit is contained in:
Netherwarlord
2026-07-28 23:10:29 -04:00
parent de0304ca54
commit ee7b266d45
8 changed files with 601 additions and 0 deletions
+63
View File
@@ -0,0 +1,63 @@
# GPU acceleration
The first run installs `llama-cpp-python` from PyPI, which is a **CPU build**.
It works everywhere and needs no setup, but it is many times slower than your
GPU.
This is not something the download could have decided for you. `llama-cpp-python`
compiles its backend in, so a CPU wheel stays CPU-only no matter what hardware
it later finds — there is no runtime switch. Getting GPU speed means installing
a different wheel, once.
Run the matching command from **inside this folder**, after the first launch has
created `.venv`.
## NVIDIA (CUDA)
Check your driver's CUDA version with `nvidia-smi`, then match it:
```bash
# Windows
.venv\Scripts\python -m pip install --force-reinstall --no-cache-dir \
llama-cpp-python --extra-index-url https://abetlen.github.io/llama-cpp-python/whl/cu124
# macOS / Linux
.venv/bin/python -m pip install --force-reinstall --no-cache-dir \
llama-cpp-python --extra-index-url https://abetlen.github.io/llama-cpp-python/whl/cu124
```
Swap `cu124` for `cu121` or `cu122` if your driver is older.
## Apple Silicon (Metal)
The PyPI wheel already includes Metal support on arm64 Macs, so there is
usually nothing to do. To force a rebuild against the local SDK:
```bash
CMAKE_ARGS="-DGGML_METAL=on" .venv/bin/python -m pip install \
--force-reinstall --no-cache-dir llama-cpp-python
```
MLX is used automatically for MLX-format models if `mlx-lm` is installed; the
requirements file pulls it in on Apple Silicon.
## AMD (ROCm)
No prebuilt wheels are published, so this compiles from source and needs the
ROCm toolkit installed:
```bash
CMAKE_ARGS="-DGGML_HIPBLAS=on" .venv/bin/python -m pip install \
--force-reinstall --no-cache-dir llama-cpp-python
```
## Confirming it worked
Start LM-Gambit and look at the **Engine** panel in the bottom-left. It reports
the detected architecture. Then run one question and compare tokens/second
against what you saw before — that number is the real test.
A caveat worth knowing: the engine detects your *hardware*, not what
`llama-cpp-python` was built with. On an NVIDIA machine it will report `cuda`
even with a CPU wheel installed. If the architecture says `cuda` but throughput
is unchanged, the wheel is still the CPU one.
+86
View File
@@ -0,0 +1,86 @@
@echo off
setlocal enabledelayedexpansion
rem LM-Gambit launcher (Windows).
rem
rem First run creates a virtual environment beside this script and installs the
rem dependencies into it; later runs skip straight to starting the app.
rem
rem The environment is built on YOUR machine rather than shipped prebuilt, and
rem that is the whole point: llama-cpp-python is a compiled package whose GPU
rem support is baked in at install time. A prebuilt bundle would freeze one
rem choice -- almost certainly CPU-only -- for everybody. Installing here means
rem pip picks the build that matches this machine.
rem
rem For NVIDIA or AMD GPU acceleration, see GPU-ACCELERATION.md.
cd /d "%~dp0"
set "VENV=%~dp0.venv"
if exist "%VENV%\Scripts\python.exe" goto :run
echo First run - setting up. This takes a few minutes and happens once.
echo.
rem The Windows launcher is the reliable way to pick a version; fall back to
rem whatever "python" resolves to, which may be the Microsoft Store stub.
set "PY_CMD="
for %%V in (3.13 3.12 3.11 3.10) do (
if not defined PY_CMD (
py -%%V -c "import sys" >nul 2>&1 && set "PY_CMD=py -%%V"
)
)
if not defined PY_CMD (
python -c "import sys; raise SystemExit(0 if sys.version_info >= (3,10) else 1)" >nul 2>&1 && set "PY_CMD=python"
)
if not defined PY_CMD (
echo No suitable Python found.
echo.
echo LM-Gambit needs Python 3.10 or newer.
echo Download: https://www.python.org/downloads/
echo Tick "Add python.exe to PATH" during installation.
echo.
echo If you see a Microsoft Store window when typing "python", that is a
echo placeholder rather than a real install - use the link above.
echo.
pause
exit /b 1
)
for /f "delims=" %%V in ('%PY_CMD% --version 2^>^&1') do echo Using %%V
%PY_CMD% -m venv "%VENV%"
if errorlevel 1 (
echo.
echo Could not create a virtual environment.
echo.
pause
exit /b 1
)
echo Installing dependencies...
"%VENV%\Scripts\python.exe" -m pip install --quiet --upgrade pip
"%VENV%\Scripts\python.exe" -m pip install -r "%~dp0requirements.txt"
if errorlevel 1 (
echo.
echo Dependency installation failed.
echo.
echo The usual culprit is llama-cpp-python, which compiles from source when
echo no prebuilt wheel matches this platform. That needs Visual Studio
echo Build Tools with the "Desktop development with C++" workload:
echo https://visualstudio.microsoft.com/visual-cpp-build-tools/
echo.
echo The setup is resumable - run this again once that is installed.
echo.
rmdir /s /q "%VENV%" 2>nul
pause
exit /b 1
)
echo.
echo Setup complete.
echo.
:run
"%VENV%\Scripts\python.exe" "%~dp0app.py" %*
if errorlevel 1 pause
+96
View File
@@ -0,0 +1,96 @@
#!/usr/bin/env bash
# LM-Gambit launcher (macOS / Linux).
#
# First run creates a virtual environment beside this script and installs the
# dependencies into it; later runs skip straight to starting the app.
#
# The environment is built on YOUR machine rather than shipped prebuilt, and
# that is the whole point: llama-cpp-python is a compiled package whose GPU
# support is baked in at install time. A prebuilt bundle would freeze one
# choice — almost certainly CPU-only — for everybody. Installing here means pip
# picks the build that matches this machine.
#
# For NVIDIA, AMD or Apple GPU acceleration, see GPU-ACCELERATION.md.
set -euo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$HERE"
VENV="$HERE/.venv"
PYTHON_MIN="3.10"
find_python() {
for candidate in python3.13 python3.12 python3.11 python3.10 python3 python; do
if command -v "$candidate" >/dev/null 2>&1; then
if "$candidate" -c "import sys; raise SystemExit(0 if sys.version_info >= (3, 10) else 1)" 2>/dev/null; then
echo "$candidate"
return 0
fi
fi
done
return 1
}
if [ ! -x "$VENV/bin/python" ]; then
echo "First run — setting up. This takes a few minutes and happens once."
echo
if ! PYTHON="$(find_python)"; then
cat >&2 <<EOF
No suitable Python found.
LM-Gambit needs Python $PYTHON_MIN or newer on your PATH.
macOS brew install python@3.12
or download from https://www.python.org/downloads/
Linux sudo apt install python3 python3-venv (Debian/Ubuntu)
sudo dnf install python3 (Fedora)
Then run this script again.
EOF
exit 1
fi
echo "Using $("$PYTHON" --version) at $(command -v "$PYTHON")"
# python3-venv is a separate package on Debian/Ubuntu and its absence is a
# confusing failure, so say what to install rather than let it fall over.
if ! "$PYTHON" -m venv "$VENV" 2>/dev/null; then
cat >&2 <<EOF
Could not create a virtual environment.
On Debian or Ubuntu this usually means the venv module is missing:
sudo apt install python3-venv
EOF
exit 1
fi
echo "Installing dependencies..."
"$VENV/bin/python" -m pip install --quiet --upgrade pip
if ! "$VENV/bin/python" -m pip install -r "$HERE/requirements.txt"; then
cat >&2 <<EOF
Dependency installation failed.
The usual culprit is llama-cpp-python, which compiles from source when no
prebuilt wheel matches this platform. It needs a C compiler:
macOS xcode-select --install
Linux sudo apt install build-essential cmake
The setup is resumable — run this script again once that is in place.
EOF
rm -rf "$VENV"
exit 1
fi
echo
echo "Setup complete."
echo
fi
exec "$VENV/bin/python" "$HERE/app.py" "$@"
+216
View File
@@ -0,0 +1,216 @@
"""Assemble a portable LM-Gambit archive.
python packaging/build.py # for the current platform
python packaging/build.py --platform linux # or windows / macos
python packaging/build.py --skip-web # reuse an existing web/dist
Produces ``dist/LM-Gambit-<version>-<platform>.{zip,tar.gz}`` containing the
application source, the prebuilt web interface, and a launcher that creates a
virtual environment on first run.
**Why not a frozen binary.** ``llama-cpp-python`` compiles its backend in at
install time, so a bundled interpreter would freeze one choice — whatever the
build machine had, which for a CI runner means CPU-only — for every user
forever. Installing on the target machine lets pip pick the wheel that matches
that machine's hardware. The cost is a Python requirement; the alternative is
shipping something that cannot use anybody's GPU.
Only files git tracks are copied, so the archive cannot pick up local models,
results, settings or virtual environments.
"""
from __future__ import annotations
import argparse
import os
import platform
import re
import shutil
import subprocess
import tarfile
import zipfile
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
PACKAGING = ROOT / "packaging"
DIST = ROOT / "dist"
#: Tracked paths that have no business in a distribution.
EXCLUDE_PREFIXES = (
".github/",
"tests_py/",
"packaging/",
"web/src/",
"web/public/",
"web/package.json",
"web/package-lock.json",
"web/tsconfig",
"web/vite.config.ts",
".gitignore",
".python-version",
"requirements-ci.txt",
)
PLATFORMS = {
"windows": {"launcher": "LM-Gambit.bat", "archive": "zip"},
"linux": {"launcher": "LM-Gambit.sh", "archive": "tar.gz"},
"macos": {"launcher": "LM-Gambit.sh", "archive": "tar.gz"},
}
def current_platform() -> str:
system = platform.system().lower()
if system == "windows":
return "windows"
if system == "darwin":
return "macos"
return "linux"
def read_version() -> str:
text = (ROOT / "server" / "__init__.py").read_text(encoding="utf-8")
match = re.search(r'__version__\s*=\s*["\']([^"\']+)["\']', text)
if not match:
raise SystemExit("Could not read __version__ from server/__init__.py")
return match.group(1)
def tracked_files() -> list[str]:
"""Files git tracks, so nothing local leaks into the archive."""
result = subprocess.run(
["git", "ls-files"], cwd=ROOT, capture_output=True, text=True, check=True
)
return [line for line in result.stdout.splitlines() if line.strip()]
def included(path: str) -> bool:
return not any(path.startswith(prefix) for prefix in EXCLUDE_PREFIXES)
def build_web() -> None:
npm = shutil.which("npm") or shutil.which("npm.cmd")
if npm is None:
raise SystemExit("npm not found. Install Node 20+, or pass --skip-web.")
print("Building the web interface...")
for args in (["ci"], ["run", "build"]):
subprocess.run([npm, *args], cwd=ROOT / "web", check=True, shell=False)
def stage(target: str, staging: Path) -> None:
"""Copy the application into a clean staging directory."""
if staging.exists():
shutil.rmtree(staging)
staging.mkdir(parents=True)
copied = 0
for relative in tracked_files():
if not included(relative):
continue
source = ROOT / relative
if not source.is_file():
continue
destination = staging / relative
destination.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(source, destination)
copied += 1
# web/dist is gitignored but is the whole point of shipping a build.
dist_source = ROOT / "web" / "dist"
if not (dist_source / "index.html").is_file():
raise SystemExit(
"web/dist is missing or incomplete. Run without --skip-web, or "
"build it with: cd web && npm ci && npm run build"
)
shutil.copytree(dist_source, staging / "web" / "dist")
launcher = PLATFORMS[target]["launcher"]
_copy_launcher(PACKAGING / launcher, staging / launcher)
shutil.copy2(PACKAGING / "GPU-ACCELERATION.md", staging / "GPU-ACCELERATION.md")
print(f"Staged {copied} tracked files plus the web build.")
def _copy_launcher(source: Path, destination: Path) -> None:
"""Copy a launcher with the line endings its interpreter requires.
``.gitattributes`` pins these too, but the archive must be correct no
matter how the tree was checked out: a CRLF ``.sh`` fails on Linux with
"bad interpreter: ...^M", which is a baffling error for someone who just
downloaded a release. Normalising here means a stale clone or an unusual
``core.autocrlf`` cannot ship a broken launcher.
"""
text = source.read_bytes().replace(b"\r\n", b"\n")
if destination.suffix == ".bat":
text = text.replace(b"\n", b"\r\n")
destination.write_bytes(text)
if destination.suffix == ".sh":
os.chmod(destination, 0o755)
def archive(target: str, staging: Path, version: str) -> Path:
DIST.mkdir(exist_ok=True)
stem = f"LM-Gambit-{version}-{target}"
if PLATFORMS[target]["archive"] == "zip":
out = DIST / f"{stem}.zip"
out.unlink(missing_ok=True)
with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED) as zf:
for path in sorted(staging.rglob("*")):
if path.is_file():
zf.write(path, Path(stem) / path.relative_to(staging))
return out
out = DIST / f"{stem}.tar.gz"
out.unlink(missing_ok=True)
with tarfile.open(out, "w:gz") as tf:
# arcname roots everything under a single directory so extracting does
# not scatter files into the user's current folder.
tf.add(staging, arcname=stem, filter=_normalize)
return out
def _normalize(info: tarfile.TarInfo) -> tarfile.TarInfo:
"""Force sane POSIX metadata regardless of the build machine.
Windows has no execute bit, so ``os.chmod(0o755)`` on the staged launcher
is a no-op there and the shell script arrives non-executable — a Linux user
extracts it and gets "permission denied". Setting the mode on the archive
member instead makes a tarball built on Windows identical to one built on
Linux. Ownership is zeroed for the same reason: whoever built it is not who
should own the extracted files.
"""
info.uid = info.gid = 0
info.uname = info.gname = "root"
if info.isdir():
info.mode = 0o755
elif info.name.endswith(".sh"):
info.mode = 0o755
else:
info.mode = 0o644
return info
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--platform", choices=sorted(PLATFORMS), default=current_platform())
parser.add_argument("--skip-web", action="store_true", help="Reuse an existing web/dist")
args = parser.parse_args()
version = read_version()
print(f"LM-Gambit {version} -> {args.platform}")
if not args.skip_web:
build_web()
staging = DIST / f"_staging-{args.platform}"
stage(args.platform, staging)
out = archive(args.platform, staging, version)
shutil.rmtree(staging, ignore_errors=True)
size_mb = out.stat().st_size / (1024 * 1024)
print(f"\n {out.relative_to(ROOT)} ({size_mb:.1f} MB)")
return 0
if __name__ == "__main__":
raise SystemExit(main())