feat: add packaging scripts, build process, and GPU acceleration documentation
This commit is contained in:
@@ -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.
|
||||
@@ -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
|
||||
@@ -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" "$@"
|
||||
@@ -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())
|
||||
Reference in New Issue
Block a user