File size: 7,011 Bytes
3c241d8
 
 
 
 
 
 
 
 
95200a7
 
3c241d8
 
95200a7
 
 
 
 
 
3c241d8
 
 
 
 
 
 
 
 
 
95200a7
3c241d8
 
 
 
 
95200a7
 
 
 
 
 
3c241d8
 
 
 
 
 
95200a7
 
 
 
 
 
 
 
3c241d8
 
95200a7
 
 
3c241d8
 
 
95200a7
3c241d8
 
 
95200a7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3c241d8
 
 
 
 
 
 
 
95200a7
3c241d8
95200a7
3c241d8
 
 
95200a7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3c241d8
95200a7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3c241d8
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
"""Record a llama.cpp build for `llama_runtime.json`, by downloading and hashing it.

The digest in that file has to be produced by someone who fetched the bytes and
hashed them. Copying a hash out of a release page or an API response would pin
the same origin that served the download, which proves nothing TLS had not
already said -- and this project withholds a *model* on exactly that reasoning.

Usage::

    python scripts/record_llama_pin.py windows-x64            # runs anywhere
    python scripts/record_llama_pin.py windows-x64-cuda       # CUDA, if present
    python scripts/record_llama_pin.py linux-x64 --tag b10742

An accelerated key records more than one archive, because an accelerated build
is not self-contained: llama.cpp ships the CUDA binaries in one zip and the CUDA
runtime libraries they link against in another, and a worker that fetched only
the first gets ``cudart64_12.dll was not found`` instead of a model. Both are
downloaded, both are hashed, and the fragment names both.

Prints a JSON fragment to paste into `llama_runtime.json`, and exits non-zero if
no matching asset was found. It never writes the file itself: adding a pin
should be a diff somebody reads.
"""

from __future__ import annotations

import argparse
import hashlib
import json
import re
import sys
import urllib.request

RELEASES = "https://api.github.com/repos/ggml-org/llama.cpp/releases?per_page=%d"

#: What a platform's asset name looks like, and what must not be in it.
#:
#: The plain keys still exclude every accelerator: they are the build that runs
#: anywhere, and they remain the fallback every machine can reach. The
#: accelerated keys are additions beside them, never replacements, and are only
#: ever installed on a machine that has been checked for the hardware.
PATTERNS = {
    "windows-x64": (("win", "x64"), ("cuda", "hip", "sycl", "arm64", "vulkan")),
    "windows-arm64": (("win", "arm64"), ("cuda", "hip", "sycl")),
    "linux-x64": (("ubuntu", "x64"), ("cuda", "hip", "sycl", "arm64", "vulkan")),
    "macos-arm64": (("macos", "arm64"), ()),
    "macos-x64": (("macos", "x64"), ()),
    "windows-x64-cuda": (("win", "cuda", "x64"), ("arm64", "hip", "sycl")),
    "linux-x64-cuda": (("ubuntu", "cuda", "x64"), ("arm64", "hip", "sycl")),
}

#: The companion archive an accelerated build cannot start without.
COMPANIONS = {
    "windows-x64-cuda": ("cudart", "win", "x64"),
    "linux-x64-cuda": ("cudart", "ubuntu", "x64"),
}

#: ``llama-b10742-bin-win-cuda-12.4-x64.zip`` -> the CUDA version it needs.
_CUDA_IN_NAME = re.compile(r"cuda[-_]?(\d+)\.(\d+)")


def _get(url: str) -> bytes:
    request = urllib.request.Request(url, headers={"User-Agent": "distinct-pin"})
    with urllib.request.urlopen(request, timeout=300) as response:  # noqa: S310
        return response.read()


def _hash(url: str) -> tuple[str, int]:
    """Stream the asset through a hash rather than holding it in memory.

    A CUDA build and its runtime libraries come to several hundred megabytes.
    Reading that into a string to hash it once works until the machine doing
    the recording is the volunteer's laptop, which is the only machine that
    ever does it.
    """

    digest = hashlib.sha256()
    size = 0
    request = urllib.request.Request(url, headers={"User-Agent": "distinct-pin"})
    with urllib.request.urlopen(request, timeout=300) as response:  # noqa: S310
        while True:
            chunk = response.read(1024 * 1024)
            if not chunk:
                break
            digest.update(chunk)
            size += len(chunk)
            if size % (32 * 1024 * 1024) < 1024 * 1024:
                print(f"  ... {size // (1024 * 1024)} MB", file=sys.stderr, flush=True)
    return digest.hexdigest(), size


def _matches(name: str, wanted: tuple[str, ...], refused: tuple[str, ...]) -> bool:
    return (
        name.endswith(".zip")
        and all(part in name for part in wanted)
        and not any(part in name for part in refused)
    )


def _record(asset: dict) -> dict:
    url = asset["browser_download_url"]
    print(f"downloading {asset['name']} ({asset.get('size', 0)} bytes)", file=sys.stderr)
    digest, size = _hash(url)
    return {"asset": asset["name"], "url": url, "sha256": digest, "bytes": size}


def _cuda_version(*names: str) -> int:
    """The CUDA version an asset name declares, in NVML's integer form."""

    for name in names:
        found = _CUDA_IN_NAME.search(name.casefold())
        if found:
            return int(found.group(1)) * 1000 + int(found.group(2)) * 10
    return 0


def main(argv: list[str]) -> int:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("platform", choices=sorted(PATTERNS))
    parser.add_argument("--tag", default="", help="a specific build tag, e.g. b10742")
    parser.add_argument("--scan", type=int, default=15, help="how many releases to look through")
    args = parser.parse_args(argv)

    wanted, refused = PATTERNS[args.platform]
    companion = COMPANIONS.get(args.platform)
    releases = json.loads(_get(RELEASES % args.scan))

    for release in releases:
        if args.tag and release.get("tag_name") != args.tag:
            continue
        assets = release.get("assets", [])
        main_asset = next(
            (item for item in assets if _matches(str(item.get("name", "")).casefold(), wanted, refused)),
            None,
        )
        if main_asset is None:
            continue

        archives = [_record(main_asset)]
        entry: dict = {"tag": release["tag_name"], "archives": archives}

        if companion is not None:
            # A CUDA build without its runtime libraries does not run slowly,
            # it does not start. Refusing to record half a pin is the whole
            # reason this script knows about companions at all.
            wanted_cuda = _cuda_version(main_asset["name"])
            partner = next(
                (
                    item
                    for item in assets
                    if _matches(str(item.get("name", "")).casefold(), companion, ())
                    and (not wanted_cuda or _cuda_version(str(item.get("name", ""))) == wanted_cuda)
                ),
                None,
            )
            if partner is None:
                print(
                    f"{main_asset['name']} was found but its {companion[0]} companion was not, "
                    "so nothing was recorded: half an accelerated pin is a worker that will "
                    "not start.",
                    file=sys.stderr,
                )
                return 1
            archives.append(_record(partner))
            entry["accelerator"] = "cuda"
            entry["requires_cuda"] = wanted_cuda

        print(json.dumps({args.platform: entry}, indent=2))
        return 0

    print(
        f"no asset matching {wanted} found in the last {args.scan} releases",
        file=sys.stderr,
    )
    return 1


if __name__ == "__main__":
    raise SystemExit(main(sys.argv[1:]))