distinct / distinct_agent /offload.py
User1342's picture
Measure the GPU offload instead of estimating it, and keep the development scratch out of the repository
9f85661
Raw
History Blame
6.32 kB
"""Remember how many layers this machine should actually put on its GPU.
WHY THIS IS MEASURED AND NOT CALCULATED.
Two estimates were tried and both were wrong on the same laptop, in opposite
directions. Offloading everything was wrong because Windows does not refuse an
oversubscribed allocation -- it backs the excess with system memory and every
token then crawls across the PCIe bus, which took a benchmark that had been
completing to twenty timeouts. Sizing the offload to free VRAM was wrong too,
more subtly: nine of thirty-two layers fitted, started cleanly, reported itself
healthy, and was still slower than the CPU, because a split model pays a
round-trip per token and a quarter of the layers does not earn it back.
The variables are the card, how much of it the desktop is already using, the
width of the link, the model, the context length and the CPU on the other side
of the split. Nobody can hold that in a formula. But it takes about a minute to
*try* three settings and see which is fastest, and the answer is stable for as
long as that machine and that model stay the same. So it is measured once,
written down, and read thereafter.
Zero is always one of the settings tried, so the worst outcome of measuring is
the CPU-only behaviour it replaced -- and unlike an estimate, it is impossible
for this to leave a machine running slower than that.
"""
from __future__ import annotations
import json
import os
import tempfile
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Mapping, Optional
#: Where the answers live. Beside the installed runtime, which is already
#: per-machine and already ignored by version control.
CACHE_NAME = "offload-cache.json"
#: An answer older than this is re-measured. Drivers change, cards get
#: replaced, and a figure nobody can explain is worse than a minute of
#: measuring.
MAX_AGE_SECONDS = 90 * 24 * 3600
#: How much faster than the CPU an offload has to be before it is worth the
#: split. Below this the two are the same speed within noise, and the CPU has
#: the advantage of leaving the card free for whatever else the volunteer is
#: doing with their computer.
WORTH_IT_MARGIN = 1.08
@dataclass(frozen=True)
class Measurement:
"""What one setting achieved, in tokens per second."""
layers: int
tokens_per_second: float
note: str = ""
def cache_path(root: Optional[Path] = None) -> Path:
from .runtime import runtime_directory # noqa: PLC0415 - avoids a cycle
return runtime_directory(root) / CACHE_NAME
def machine_key(
*, model_id: str, gpu: str, vram_bytes: int, build: str, context: int
) -> str:
"""Everything that would change the answer, in one string.
Deliberately includes the context length: the key/value cache is per
token, so the same model at 4096 and at 32768 are different problems.
"""
return f"{model_id}|{gpu}|{vram_bytes}|{build}|{context}"
def _read(path: Path) -> dict:
try:
loaded = json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError):
return {}
return loaded if isinstance(loaded, dict) else {}
def remembered(key: str, root: Optional[Path] = None) -> Optional[int]:
"""The layer count measured for this machine and model, if it is still fresh."""
entry = _read(cache_path(root)).get(key)
if not isinstance(entry, Mapping):
return None
try:
layers = int(entry["layers"])
measured_at = float(entry.get("measured_at", 0))
except (KeyError, TypeError, ValueError):
return None
if layers < 0 or time.time() - measured_at > MAX_AGE_SECONDS:
return None
return layers
def remember(
key: str,
layers: int,
measurements: tuple[Measurement, ...] = (),
root: Optional[Path] = None,
) -> None:
"""Write the answer down, with the measurements that produced it.
The measurements are kept because the number on its own is unfalsifiable.
A volunteer who wonders why their card is idle can read the row that says
the GPU was tried and was slower.
"""
path = cache_path(root)
store = _read(path)
store[key] = {
"layers": int(layers),
"measured_at": time.time(),
"measurements": [
{
"layers": item.layers,
"tokens_per_second": round(item.tokens_per_second, 3),
"note": item.note,
}
for item in measurements
],
}
try:
path.parent.mkdir(parents=True, exist_ok=True)
# Written whole and moved into place: a half-written cache read by the
# next start-up would be a permanent, silent fallback to the CPU.
handle, temporary = tempfile.mkstemp(dir=str(path.parent), suffix=".tmp")
with os.fdopen(handle, "w", encoding="utf-8") as out:
json.dump(store, out, indent=2)
os.replace(temporary, path)
except OSError:
# A machine that cannot write the cache measures again next time. That
# is a cost, not a failure, and not worth ending a run over.
return
def choose(measurements: tuple[Measurement, ...]) -> int:
"""The layer count worth using, given what each one achieved.
The CPU wins ties and near-ties. An offload has to be meaningfully faster
to justify taking a volunteer's graphics card for the duration of a run,
and a figure inside the noise is not a reason to.
"""
usable = [item for item in measurements if item.tokens_per_second > 0]
if not usable:
return 0
baseline = next((item.tokens_per_second for item in usable if item.layers == 0), 0.0)
best = max(usable, key=lambda item: item.tokens_per_second)
if best.layers == 0:
return 0
if baseline > 0 and best.tokens_per_second < baseline * WORTH_IT_MARGIN:
return 0
return best.layers
def candidates(plan: tuple[int, ...]) -> tuple[int, ...]:
"""Which settings to actually time, from the ladder that would fit.
Three, not the whole ladder: each one costs a model load, and the shape of
the curve -- flat, rising, or falling -- is legible from the top of what
fits, half of it, and none of it.
"""
top = plan[0] if plan else 0
return tuple(sorted({top, top // 2, 0}, reverse=True)) if top > 0 else (0,)