Files
Strata/packaging/package_engine.py
Niko1221 28ce98bf16 Merge f-dsv41-rel (0be62ccad) into v0.2.3.1: DeepSeek V4.1-Flash on top of the released 0.2.3 line
f-dsv41-rel was based on the 0.2.2-era tree (merge-base 1f727f4b5), so the merge brings it onto 7ddc17a38 without dropping 0.2.3 / 0.2.3.1.
Conflict resolutions (0.2.3.1's behaviour kept everywhere that is not DeepSeek):
- packaging/package_engine.py: build() keeps 0.2.3.1's check_reader / allow_stale_vision / allow_no_reader and adds require_dsv41; both the
  picture-reader check and the strata-dsv41 presence check run; the CLI call passes both sets of arguments.
- packaging/ENGINE-COMPONENT.md: 0.2.3.1's cuda13 / cuda12 rows (vision built per variant), with strata-dsv41.exe added to the cuda13 contents.
- serve/server.py: the parser is self.parser_cls (OutputParser, or the DeepSeek DSML parser) and keeps 0.2.3.1's declared_only argument.
- strata_cli/launch/cmd.py: the --plan output prints 0.2.3.1's drafting line and then the DeepSeek placement lines.
- strata_cli/launch/runner.py: plan() returns 0.2.3.1's dict with "drafting", plus "placement" for multi-file (DeepSeek) models;
  drafting_plan_line() kept; dsv41_engine() and placement() added.
2026-10-23 23:27:29 +02:00

364 lines
21 KiB
Python

#!/usr/bin/env python3
"""Make a local, unpublished engine package for the component folder (strata_control/engine_component.py).
python packaging/package_engine.py --src <folder with strata[.exe] and its libraries> --backend cuda \
--version 0.1.45 --out <components>/engine --arch sm_86 --arch compute_89 \
--min-driver nvidia=580.65 [--build-id <id>] [--platform linux-x86_64] [--tree] [--include GLOB ...]
python packaging/package_engine.py --verify <components>/engine/<package folder> # full hash check, exit 0 / 1
The result is one folder `<out>/<version>-<platform>-<backend>/` holding the engine binary, the libraries it loads,
BUILD.json when the source has one, and `engine-component.json` (schema 2: every file's SHA-256 and size, the arch
list, the oldest driver, the engine build id, and an empty `signature` for S6). It is the thing Control resolves,
checks and starts; it is not signed, not published, and nothing here uploads it. Signing is stream S6
(`engine_component.register_signature_verifier`); `--sign-with` is deliberately absent until then.
Files taken from --src, by default: the entrypoint, every `*.dll` / `*.so` / `*.so.*` / `*.dylib` / `*.metallib`
beside it, `BUILD.json`, `LICENSE*`, the picture reader `strata-vision[.exe]` when the build has one (without it the
engine answers text only: setup.py Control mode keeps the person's picture choice only when it is there) and the DeepSeek-V4.1
engine `strata-dsv41[.exe]` (setup.py dsv41_exe() looks for it beside the engine; `--require-dsv41` refuses a package without it). `--include GLOB` (repeatable, relative to --src, `**` allowed) adds more, for
example `strata-vision.exe`, `strata-device.exe` or `rocm/**`. `--tree` takes every regular file under --src (the HIP
package ships a `rocm/` tree of kernels). Symlinks are never followed or copied.
"""
from __future__ import annotations
import argparse
import fnmatch
import hashlib
import json
import os
import re
import shutil
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
sys.path.insert(0, str(Path(__file__).resolve().parent))
import binary_deps # noqa: E402
from strata_control import engine_component as ec # noqa: E402
from strata_control.components import BACKEND_MATRIX, host_platform # noqa: E402
DSV41_EXE = "strata-dsv41"
def sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as stream:
for block in iter(lambda: stream.read(1 << 20), b""):
digest.update(block)
return digest.hexdigest()
def wanted(path: Path) -> bool:
name = path.name.lower()
return (name.endswith((".dll", ".so", ".dylib", ".metallib")) or ".so." in name or name == "build.json"
or name.startswith("license") or name in ("strata-vision", "strata-vision.exe", "strata-vision-hip", "strata-vision-hip.exe",
DSV41_EXE, DSV41_EXE + ".exe")) # the picture readers (CPU, AMD GPU) and the DeepSeek-V4.1 engine beside the engine
READER_NAMES = ("strata-vision", "strata-vision.exe")
def _arch_numbers(value) -> set[int]:
"""The CUDA architectures in a BUILD.json `archs` / `vision_archs` value ("60;61;70", [60, "sm_61", "70-real"], ...), as numbers."""
if isinstance(value, str):
value = re.split(r"[;,\s]+", value)
found: set[int] = set()
for item in value or []:
m = re.fullmatch(r"(?:sm_|compute_)?(\d+)(?:a|f)?(?:-real|-virtual)?", str(item).strip().lower())
if m:
found.add(int(m.group(1)))
return found
def check_picture_reader(src: Path, backend: str, target: str, archs: tuple[str, ...], *, variant: str = "",
allow_stale: bool = False, allow_none: bool = False) -> list[str]:
"""The picture reader of a package must run on every card and Mac the package does (tools tests: I1, I2, #3669, #3697).
* CUDA package with a reader: BUILD.json (beside the engine) must list `vision_archs`, and every `sm_NN` the package carries
code for must be there. A reader reused from an older build (the Windows zips' strata-vision.exe, unchanged since 0.1.44, had no
sm_60) stops with "device kernel image is invalid" on a Pascal card while the engine runs. Refused, unless allow_stale.
* macOS package without a reader: refused (a Mac with this package cannot read a picture), unless allow_none.
* CUDA package without a reader: allowed (text-only), reported in the returned notes so the build log says it.
Returns notes to print."""
reader = next((n for n in READER_NAMES if (src / n).is_file()), None)
notes: list[str] = []
if target.startswith("macos"):
if reader is None and not allow_none:
raise SystemExit("refusing to package the macOS engine without its picture reader: a Mac with this package could not read "
"a picture (#3697). Build it with packaging/build_vision_mac.sh and copy strata-vision beside strata "
"(or give --no-picture-reader to ship a text-only package on purpose).")
if reader is None:
notes.append("no picture reader in this macOS package: pictures are off for it")
return notes
if backend != "cuda":
return notes
if reader is None:
notes.append("no picture reader in this CUDA package: the engine answers text only (the app says pictures need the reader)")
return notes
needed = {n for n in _arch_numbers([a for a in archs if str(a).lower().startswith("sm_")])}
have: set[int] = set()
build_json = src / "BUILD.json"
try:
have = _arch_numbers(json.loads(build_json.read_text(encoding="utf-8-sig")).get("vision_archs"))
except (OSError, ValueError, AttributeError):
pass
missing = sorted(needed - have)
if missing and not allow_stale:
what = ("BUILD.json has no vision_archs: the reader's build is not recorded" if not have
else "the reader has no code for " + ", ".join(f"sm_{n}" for n in missing))
raise SystemExit(f"refusing to package the {variant or backend} engine with this picture reader: {what}. A reader reused from an "
"older build stops with 'device kernel image is invalid' on a card it was not built for (#3669). Build it for the "
"package's architectures (packaging/build_vision_windows.bat, packaging/build_cuda_linux.sh) and record them as "
"vision_archs in BUILD.json (or give --allow-stale-vision).")
if missing:
notes.append("picture reader NOT built for " + ", ".join(f"sm_{n}" for n in missing) + " (allowed by --allow-stale-vision)")
return notes
def stamp_vision_archs(stage: Path, archs: str) -> None:
"""Record the architectures the picture reader in `stage` was built for as BUILD.json `vision_archs` (the build recipes for Windows
call this after building strata-vision.exe; check_picture_reader compares it with the package's archs)."""
path = stage / "BUILD.json"
try:
data = json.loads(path.read_text(encoding="utf-8-sig")) if path.is_file() else {}
except (OSError, ValueError):
data = {}
data["vision_archs"] = ";".join(str(n) for n in sorted(_arch_numbers(archs)))
path.write_text(json.dumps(data, indent=1) + chr(10), encoding="utf-8")
def git_build_id(repo: Path = ROOT) -> str:
"""The source commit of the engine build, `-dirty` when the tree had changes. `unknown` outside a checkout."""
try:
head = subprocess.run(["git", "-C", str(repo), "rev-parse", "--short=12", "HEAD"], capture_output=True,
text=True, timeout=20).stdout.strip()
dirty = subprocess.run(["git", "-C", str(repo), "status", "--porcelain", "--untracked-files=no"],
capture_output=True, text=True, timeout=60).stdout.strip()
except (OSError, subprocess.SubprocessError):
return "unknown"
return (head + ("-dirty" if dirty else "")) if head else "unknown"
def select(src: Path, entrypoint: str, *, tree: bool, include: list[str]) -> list[str]:
"""Relative POSIX paths to package, entrypoint first, the rest sorted."""
chosen: set[str] = set()
for path in sorted(src.rglob("*") if (tree or include) else src.iterdir()):
if path.is_symlink() or not path.is_file():
continue
rel = path.relative_to(src).as_posix()
if rel == ec.MANIFEST:
continue
top_level = "/" not in rel
if tree or (top_level and wanted(path)) or any(fnmatch.fnmatch(rel, pattern) for pattern in include):
chosen.add(rel)
for path in sorted((src / "tuning").glob("*.txt")) if (src / "tuning").is_dir() else []:
if path.is_file() and not path.is_symlink(): # the hipBLASLt tuning tables of an AMD package (strata_control/hipblaslt_tuning.py)
chosen.add(path.relative_to(src).as_posix())
chosen.discard(entrypoint)
return [entrypoint] + sorted(chosen)
def build(src: Path, backend: str, version: str, target: str, out: Path, *, entry: str | None = None,
archs: tuple[str, ...] = (), min_driver: dict | None = None, build_id: str | None = None,
min_runtime: str | None = None, notes: str = "", tree: bool = False, include: list[str] | None = None,
variant: str = "", search: list[Path] | None = None, allow_libs: list[str] | None = None,
check_reader: bool = False, allow_stale_vision: bool = False, allow_no_reader: bool = False,
require_dsv41: bool = False) -> Path:
if backend not in BACKEND_MATRIX:
raise SystemExit(f"unknown backend {backend!r}")
if backend != "cpu" and not archs:
raise SystemExit(f"a {backend} package needs at least one --arch (sm_86, gfx1201, ...)")
if backend == "cuda" and variant == "cuda12" and not any(a in ("sm_60", "sm_61", "sm_70") for a in archs):
# #3503: the cuda12 package is what the feed and docs/DOCKER.md offer to GTX 10 / P100 / V100; one built for
# sm_75 and up starts a restart loop on those cards ("this PC's GPU is sm_60")
raise SystemExit("refusing to package the cuda12 engine without sm_60, sm_61 or sm_70: it is the Pascal and Volta build "
"(CUDA 12.9, -DSTRATA_EXPERIMENTAL_SM60=ON, --arch sm_60 --arch sm_61 --arch sm_70 ...; see packaging/build_cuda_linux.sh)")
if backend == "hip":
from strata_control import hipblaslt_tuning
problems = hipblaslt_tuning.check_stage(src, archs)
if problems:
raise SystemExit("refusing to package the AMD engine:\n " + "\n ".join(problems))
entrypoint = entry or ("strata.exe" if target.startswith("windows") else "strata")
if not (src / entrypoint).is_file():
raise SystemExit(f"{src / entrypoint} does not exist")
if check_reader: # the release recipes and the CLI ask for it; the library call keeps its old behaviour
for note in check_picture_reader(src, backend, target, archs, variant=variant, allow_stale=allow_stale_vision,
allow_none=allow_no_reader):
print("note: " + note)
if require_dsv41 and not any((src / n).is_file() for n in (DSV41_EXE, DSV41_EXE + ".exe")):
raise SystemExit(f"refusing to package: {src} has no {DSV41_EXE}[.exe] (--require-dsv41); build it with -DSTRATA_BUILD_DSV41=ON "
"(--target strata-dsv41) and copy it beside the engine")
folder = out / (f"{version}-{target}-{backend}" + (f"-{variant}" if variant else ""))
if folder.exists():
raise SystemExit(f"{folder} already exists; remove it first (packages are immutable)")
staging = out / f".{folder.name}.partial"
if staging.exists():
shutil.rmtree(staging)
staging.mkdir(parents=True)
try:
files = []
for rel in select(src, entrypoint, tree=tree, include=include or []):
destination = staging / rel
destination.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(src / rel, destination)
files.append({"path": rel, "sha256": sha256(destination), "bytes": destination.stat().st_size})
files += _bundle_missing_libraries(staging, target, [f["path"] for f in files], search or [], allow_libs or [])
manifest = {"schema_version": ec.SCHEMA_VERSION, "id": ec.COMPONENT_ID, "name": ec.COMPONENT_ID,
"version": version, "platform": target, "backend": backend, "archs": list(archs),
"min_driver": dict(min_driver or {}), "build_id": build_id or git_build_id(),
"entrypoint": entrypoint, "files": files, "signature": None}
if variant:
manifest["variant"] = variant
if min_runtime:
manifest["min_runtime"] = min_runtime
if notes:
manifest["notes"] = notes
(staging / ec.MANIFEST).write_text(json.dumps(manifest, indent=1) + "\n", encoding="utf-8")
if os.name != "nt":
for exe_name in (entrypoint, DSV41_EXE, "strata-vision", "strata-vision-hip"): # the programs setup starts: executable in the tar
if (staging / exe_name).is_file():
(staging / exe_name).chmod(0o755)
ec.verify(ec.read_manifest(staging), full=True)
staging.rename(folder)
except BaseException:
shutil.rmtree(staging, ignore_errors=True)
raise
return folder
def _default_search(target: str) -> list[Path]:
"""Where a Windows build finds the redistributable DLLs its engine imports: the CUDA/HIP toolkits and PATH."""
if not target.startswith("windows"):
return []
found: list[Path] = []
for var in ("CUDA_PATH", "HIP_PATH"):
root = os.environ.get(var)
if root:
found += [Path(root) / "bin" / "x64", Path(root) / "bin"]
found += [Path(x) for x in os.environ.get("PATH", "").split(os.pathsep) if x]
return [d for d in found if d.is_dir()]
def _bundle_missing_libraries(staging: Path, target: str, rels: list[str], search: list[Path], allow: list[str]) -> list[dict]:
"""Every library the package's binaries import must be in the package, or belong to the OS or the GPU driver
(binary_deps.py). Windows builds copy a missing one from the search folders (the CUDA toolkit's bin/x64, PATH): the packager
runs on a PC that has the toolkit, the person's PC does not. Anything still missing refuses the package."""
added: list[dict] = []
roots = search or _default_search(target)
for _ in range(8): # a copied DLL can import more DLLs
missing = [(f, n) for f, n in binary_deps.uncovered(staging) if n.lower() not in {a.lower() for a in allow}]
if not missing:
return added
progressed = False
for _file, name in sorted(set(missing), key=lambda x: x[1].lower()):
if not target.startswith("windows"):
continue
hit = next((d / name for d in roots if (d / name).is_file()), None)
if hit is None:
hit = next((c for d in roots for c in d.glob("*") if c.name.lower() == name.lower() and c.is_file()), None)
if hit is None or (staging / hit.name).exists():
continue
shutil.copy2(hit, staging / hit.name)
added.append({"path": hit.name, "sha256": sha256(staging / hit.name), "bytes": (staging / hit.name).stat().st_size})
progressed = True
print(f"bundled {hit.name} (imported by the engine; from {hit.parent})")
if not progressed:
break
left = [(f, n) for f, n in binary_deps.uncovered(staging) if n.lower() not in {a.lower() for a in allow}]
if left:
lines = "\n".join(f" {f} needs {n}" for f, n in sorted(set(left)))
raise SystemExit("refusing to package: these libraries are neither bundled nor part of the OS or the GPU driver, so a clean PC could not "
f"start the engine (0xC0000135 / 'library not found'):\n{lines}\nGive --search <folder holding them> or fix the build.")
return added
def package_sha256(folder: Path) -> str:
"""One digest for the whole package: SHA-256 of the manifest file (it holds every file's hash)."""
return sha256(folder / ec.MANIFEST)
def main(argv: list[str] | None = None) -> int:
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--verify", type=Path, metavar="FOLDER", help="hash-check an existing package and exit")
ap.add_argument("--src", type=Path)
ap.add_argument("--backend", choices=sorted(BACKEND_MATRIX))
ap.add_argument("--version")
ap.add_argument("--platform", default=None, help="default: this machine")
ap.add_argument("--out", type=Path, help="the component folder's engine/ directory")
ap.add_argument("--entrypoint", default=None)
ap.add_argument("--arch", "--cuda-arch", dest="arch", action="append", default=[],
help="sm_86 (code for that card), compute_89 (PTX), gfx1201, ...; repeat for several")
ap.add_argument("--min-driver", action="append", default=[], metavar="VENDOR=VERSION",
help="the oldest driver, e.g. nvidia=580.65; repeat for several vendors")
ap.add_argument("--variant", default="", help="tag when one backend has several builds (cuda13, cuda12); goes in the folder name")
ap.add_argument("--build-id", default=None, help="default: the source commit of this checkout")
ap.add_argument("--min-runtime", default=None)
ap.add_argument("--notes", default="")
ap.add_argument("--tree", action="store_true", help="every regular file under --src, recursively")
ap.add_argument("--search", action="append", default=[], type=Path, metavar="DIR",
help="folder to copy a missing imported DLL from (default on Windows: CUDA_PATH/bin/x64, HIP_PATH/bin, PATH)")
ap.add_argument("--allow-lib", action="append", default=[], metavar="NAME",
help="an imported library that really is provided by the target system (rare; named so the check passes)")
ap.add_argument("--require-dsv41", action="store_true",
help="refuse the package when --src has no strata-dsv41[.exe] (the DeepSeek-V4.1 engine that setup.py looks for beside the engine)")
ap.add_argument("--include", action="append", default=[], metavar="GLOB", help="more files, relative to --src")
ap.add_argument("--stamp-vision", type=Path, metavar="STAGE", help="write --vision-archs into STAGE/BUILD.json and exit")
ap.add_argument("--vision-archs", default="", help="with --stamp-vision: the architectures the reader was built for, e.g. '60;61;70;75;80;86;89'")
ap.add_argument("--allow-stale-vision", action="store_true",
help="package a CUDA picture reader whose BUILD.json vision_archs do not cover the package's archs (it will fail on those cards)")
ap.add_argument("--no-picture-reader", action="store_true", help="ship a macOS package without strata-vision on purpose (pictures stay off)")
a = ap.parse_args(argv)
if a.verify:
try:
component = ec.read_manifest(a.verify)
ec.verify(component, full=True)
except ec.ComponentProblem as exc:
print(f"{a.verify}: REFUSED: {exc}")
return 1
if a.require_dsv41 and not any(f[0] in (DSV41_EXE, DSV41_EXE + ".exe") for f in component.files):
print(f"{a.verify}: REFUSED: no {DSV41_EXE}[.exe] in the manifest (--require-dsv41)")
return 1
bad = binary_deps.uncovered(a.verify)
if bad:
for rel, name in bad:
print(f"{a.verify}: REFUSED: {rel} needs {name}, which is neither bundled nor an OS / GPU driver library")
return 1
print(f"{a.verify}: ok ({len(component.files)} files, manifest sha256 {package_sha256(a.verify)})")
return 0
if a.stamp_vision:
if not _arch_numbers(a.vision_archs):
ap.error("--stamp-vision needs --vision-archs, for example '60;61;70;75;80;86;89'")
stamp_vision_archs(a.stamp_vision, a.vision_archs)
print(f"{a.stamp_vision / 'BUILD.json'}: vision_archs {a.vision_archs}")
return 0
for needed in ("src", "backend", "version", "out"):
if getattr(a, needed) is None:
ap.error(f"--{needed} is required")
drivers = {}
for item in a.min_driver:
vendor, _, version = item.partition("=")
if not vendor or not version:
ap.error(f"--min-driver {item!r} is not VENDOR=VERSION")
drivers[vendor] = version
a.out.mkdir(parents=True, exist_ok=True)
folder = build(a.src, a.backend, a.version, a.platform or host_platform(), a.out, entry=a.entrypoint,
archs=tuple(a.arch), min_driver=drivers, build_id=a.build_id, min_runtime=a.min_runtime,
notes=a.notes, tree=a.tree, include=a.include, variant=a.variant,
search=a.search, allow_libs=a.allow_lib, check_reader=True, allow_stale_vision=a.allow_stale_vision,
allow_no_reader=a.no_picture_reader, require_dsv41=a.require_dsv41)
entries = [p for p in folder.rglob("*") if p.is_file()]
total = sum(p.stat().st_size for p in entries)
print(f"{folder} ({total / (1 << 20):.1f} MiB, {len(entries)} files, manifest sha256 {package_sha256(folder)})")
return 0
if __name__ == "__main__":
raise SystemExit(main())