crit-eval 0.1.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- crit_eval/__init__.py +104 -0
- crit_eval/__main__.py +10 -0
- crit_eval/ab.py +94 -0
- crit_eval/ceiling.py +259 -0
- crit_eval/chat.py +462 -0
- crit_eval/cli.py +347 -0
- crit_eval/context.py +300 -0
- crit_eval/deadline.py +43 -0
- crit_eval/estimate.py +258 -0
- crit_eval/examples/mock_adapter.py +154 -0
- crit_eval/fixed.py +143 -0
- crit_eval/gguf_deepseek4.py +122 -0
- crit_eval/gguf_layout.py +308 -0
- crit_eval/gguf_qwen4exp.py +118 -0
- crit_eval/headroom.py +869 -0
- crit_eval/host.py +213 -0
- crit_eval/hostload.py +53 -0
- crit_eval/indexers.py +155 -0
- crit_eval/kernels.py +422 -0
- crit_eval/logprob.py +29 -0
- crit_eval/metrics.py +428 -0
- crit_eval/outcomes.py +291 -0
- crit_eval/packages.py +150 -0
- crit_eval/pin.py +82 -0
- crit_eval/prepare.py +505 -0
- crit_eval/probe/device_probe.cu +550 -0
- crit_eval/probe/kernel_trace.cpp +92 -0
- crit_eval/probe/logprob_compare.cu +61 -0
- crit_eval/probe/logprob_compare.h +23 -0
- crit_eval/probe.py +186 -0
- crit_eval/protocol.json +199 -0
- crit_eval/reference.py +1898 -0
- crit_eval/report.py +175 -0
- crit_eval/runner.py +1488 -0
- crit_eval/schemes.py +507 -0
- crit_eval/status.py +1019 -0
- crit_eval/suite/agent/README.md.txt +158 -0
- crit_eval/suite/agent/cli.py.txt +86 -0
- crit_eval/suite/agent/headroom.py.txt +117 -0
- crit_eval/suite/agent/metrics.py.txt +106 -0
- crit_eval/suite/agent/prepare.py.txt +135 -0
- crit_eval/suite/agent/reference.py.txt +171 -0
- crit_eval/suite/agent/report.py.txt +87 -0
- crit_eval/suite/agent/runner.py.txt +180 -0
- crit_eval/suite/agent/tools.json +6 -0
- crit_eval/suite/followup.json +12 -0
- crit_eval/suite/heldout.json +26 -0
- crit_eval/suite/long.json +40 -0
- crit_eval/suite/long_document.txt +503 -0
- crit_eval/suite/quality.json +48 -0
- crit_eval/suite/speed.json +69 -0
- crit_eval/tensors.py +52 -0
- crit_eval-0.1.1.dist-info/METADATA +186 -0
- crit_eval-0.1.1.dist-info/RECORD +58 -0
- crit_eval-0.1.1.dist-info/WHEEL +5 -0
- crit_eval-0.1.1.dist-info/entry_points.txt +2 -0
- crit_eval-0.1.1.dist-info/licenses/LICENSE +208 -0
- crit_eval-0.1.1.dist-info/top_level.txt +1 -0
crit_eval/__init__.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""crit eval: one benchmark and quality suite that every engine runs the same way."""
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
# The package holds its suite, protocol, probes and examples; a checkout of crit-eval is its parent.
|
|
8
|
+
EVAL_DIR = Path(__file__).resolve().parent
|
|
9
|
+
SUITE_DIR = EVAL_DIR / "suite"
|
|
10
|
+
__version__ = "0.1.1"
|
|
11
|
+
CONTRACT = "eval-v1"
|
|
12
|
+
PROTOCOL_ID = re.compile(r"crit-eval-v(\d+)\.(\d+)\.(\d+)")
|
|
13
|
+
# The ids before crit-eval had its own repository (crit-eval-v4, crit-eval-v4.1, ... crit-eval-v8): major and minor.
|
|
14
|
+
EARLIER_ID = re.compile(r"crit-eval-v([1-9]\d*)(?:\.(\d+))?")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def protocol_version(identifier):
|
|
18
|
+
"""crit-eval-v0.1.0 -> (0, 1, 0). An earlier id sorts below every such version: crit-eval-v4.1 -> (-1, 4, 1)."""
|
|
19
|
+
text = str(identifier)
|
|
20
|
+
match = PROTOCOL_ID.fullmatch(text)
|
|
21
|
+
if match:
|
|
22
|
+
return tuple(int(part) for part in match.groups())
|
|
23
|
+
match = EARLIER_ID.fullmatch(text)
|
|
24
|
+
if match:
|
|
25
|
+
return -1, int(match[1]), int(match[2] or 0)
|
|
26
|
+
raise ValueError(f"not a crit-eval protocol id: {identifier}")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def protocol_series(identifier):
|
|
30
|
+
"""What two protocols share when their scores compare and data prepared under one runs under the other: the minor
|
|
31
|
+
version before 1.0 (crit-eval-v0.1.x), the major from 1.0 on; an earlier id's major (docs/README.md)."""
|
|
32
|
+
major, minor, _ = protocol_version(identifier)
|
|
33
|
+
return (major, minor) if major <= 0 else (major,)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# The context a user wants engines built for, unless settings.toml's context_tokens says otherwise; the model's
|
|
37
|
+
# positions and the device's memory may allow less.
|
|
38
|
+
DEFAULT_CONTEXT_TOKENS = 262_144
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def checkouts():
|
|
42
|
+
"""The checkouts crit-eval runs from: crit-eval's (when the package is not installed into site-packages) and the
|
|
43
|
+
crit
|
|
44
|
+
checkout of the crit_core it imports, which holds the machine's settings.toml."""
|
|
45
|
+
import crit_core
|
|
46
|
+
found = [EVAL_DIR.parent, Path(crit_core.__file__).resolve().parents[3]]
|
|
47
|
+
return [root for i, root in enumerate(found) if root not in found[:i] and (root / ".git").exists()]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def machine_settings():
|
|
51
|
+
"""Memory thresholds from settings.toml (vram_headroom_gib, context_reserve_gib), the wanted context
|
|
52
|
+
(context_tokens) and the reference package source (eval_packages, crit_eval.packages): crit-eval's checkout's,
|
|
53
|
+
else the crit checkout's (checkouts); without one crit-eval uses the protocol's defaults and
|
|
54
|
+
DEFAULT_CONTEXT_TOKENS."""
|
|
55
|
+
path = next((root / "settings.toml" for root in checkouts() if (root / "settings.toml").is_file()), None)
|
|
56
|
+
if path is None:
|
|
57
|
+
return {}
|
|
58
|
+
import tomllib
|
|
59
|
+
value = tomllib.loads(path.read_text(encoding="utf-8"))
|
|
60
|
+
found = {key: float(value[key]) for key in ("vram_headroom_gib", "context_reserve_gib")
|
|
61
|
+
if isinstance(value.get(key), (int, float)) and not isinstance(value.get(key), bool)}
|
|
62
|
+
if type(value.get("context_tokens")) is int and value["context_tokens"] > 0:
|
|
63
|
+
found["context_tokens"] = value["context_tokens"]
|
|
64
|
+
if isinstance(value.get("eval_packages"), str) and value["eval_packages"]:
|
|
65
|
+
found["eval_packages"] = value["eval_packages"]
|
|
66
|
+
return found
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def requested_context():
|
|
70
|
+
"""The context the user wants: settings.toml's context_tokens, else DEFAULT_CONTEXT_TOKENS."""
|
|
71
|
+
return machine_settings().get("context_tokens", DEFAULT_CONTEXT_TOKENS)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def portable_path(path):
|
|
75
|
+
"""A path as results store it, naming no user: relative to a checkout crit-eval runs from (posix) inside it, `~/...` inside
|
|
76
|
+
the home directory, the bare file name where another user directory (/home/NAME, C:/Users/NAME, WSL's
|
|
77
|
+
/mnt/c/Users/NAME) holds it, else the absolute path (toolkits under /usr/local or Program Files). None stays
|
|
78
|
+
None."""
|
|
79
|
+
if path is None:
|
|
80
|
+
return None
|
|
81
|
+
path = Path(os.path.abspath(path))
|
|
82
|
+
for root, prefix in [(root, "") for root in checkouts()] + [(Path.home(), "~/")]:
|
|
83
|
+
try:
|
|
84
|
+
return prefix + path.relative_to(root).as_posix()
|
|
85
|
+
except ValueError:
|
|
86
|
+
pass
|
|
87
|
+
if any(part.lower() in ("users", "home") for part in path.parts[:-2]):
|
|
88
|
+
return path.name
|
|
89
|
+
return path.as_posix()
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def load_json(path):
|
|
93
|
+
with open(path, encoding="utf-8") as handle:
|
|
94
|
+
return json.load(handle)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def write_json(path, value):
|
|
98
|
+
path = Path(path)
|
|
99
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
100
|
+
temporary = path.with_name(path.name + ".tmp")
|
|
101
|
+
with open(temporary, "w", encoding="utf-8") as handle:
|
|
102
|
+
json.dump(value, handle, indent=1)
|
|
103
|
+
handle.write("\n")
|
|
104
|
+
os.replace(temporary, path)
|
crit_eval/__main__.py
ADDED
crit_eval/ab.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Paired speed runs: `crit eval ab --adapter A --config CA --config-b CB --model M --data D --out FILE`.
|
|
2
|
+
|
|
3
|
+
A shared device drifts between sessions (other processes, clocks, temperature) by more than the
|
|
4
|
+
run-to-run spread inside one session, so two results measured apart cannot confirm a small change.
|
|
5
|
+
`ab` measures both engines in one session: each round runs the speed suite for both, in alternating
|
|
6
|
+
order (A B, then B A), loading and closing each engine in turn. It reports every round's scores and the
|
|
7
|
+
paired ratio B / A per round; the median and spread of those ratios are facts about the pair, measured
|
|
8
|
+
under the same conditions.
|
|
9
|
+
"""
|
|
10
|
+
import datetime
|
|
11
|
+
import time
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from . import EVAL_DIR, load_json, metrics, write_json
|
|
15
|
+
from .pin import load_continuations
|
|
16
|
+
from .prepare import PREPARED, check_prepared
|
|
17
|
+
from .runner import load_adapter, open_engine, run_speed
|
|
18
|
+
|
|
19
|
+
KPIS = ("decode_score", "prefill_score", "ttft_score_ms")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def run_ab(adapter_a, config_a, adapter_b, config_b, model, data, out, rounds=2, warmups=None, repetitions=None,
|
|
23
|
+
log=print, context_tokens=None):
|
|
24
|
+
protocol = load_json(EVAL_DIR / "protocol.json")
|
|
25
|
+
prepared = load_json(Path(data) / PREPARED)
|
|
26
|
+
check_prepared(prepared, protocol, data)
|
|
27
|
+
if rounds < 1:
|
|
28
|
+
raise SystemExit("--rounds must be at least 1")
|
|
29
|
+
continuations = load_continuations(data, prepared)
|
|
30
|
+
warmups = protocol["speed"]["warmup_runs"] if warmups is None else warmups
|
|
31
|
+
repetitions = protocol["speed"]["repetitions"] if repetitions is None else repetitions
|
|
32
|
+
engines = {"a": (load_adapter(adapter_a), dict(config_a or {})), "b": (load_adapter(adapter_b), dict(config_b or {}))}
|
|
33
|
+
result = {"started_at": datetime.datetime.now(datetime.timezone.utc).isoformat(),
|
|
34
|
+
"model_sha256": prepared["model"]["sha256"], "suite_sha256": prepared["suite_sha256"],
|
|
35
|
+
"warmup_runs": warmups, "repetitions": repetitions, "context_tokens": context_tokens, "rounds": [],
|
|
36
|
+
"engines": {}}
|
|
37
|
+
for r in range(rounds):
|
|
38
|
+
order = ("a", "b") if r % 2 == 0 else ("b", "a")
|
|
39
|
+
row = {"order": list(order)}
|
|
40
|
+
for key in order:
|
|
41
|
+
module, config = engines[key]
|
|
42
|
+
started = time.perf_counter()
|
|
43
|
+
engine, info = open_engine(module, model, config, speculative=True, context_tokens=context_tokens)
|
|
44
|
+
try:
|
|
45
|
+
speed = run_speed(engine, info, prepared, protocol, warmups, repetitions,
|
|
46
|
+
lambda message, k=key: log(f"round {r + 1} {k}: {message}"), continuations)
|
|
47
|
+
finally:
|
|
48
|
+
engine.close()
|
|
49
|
+
result["engines"].setdefault(key, {"name": info["name"], "configuration": info["configuration"]})
|
|
50
|
+
row[key] = {kpi: speed.get(kpi) for kpi in KPIS} | {"complete": speed["complete"],
|
|
51
|
+
"decode_text": speed["decode_text"],
|
|
52
|
+
"seconds": time.perf_counter() - started}
|
|
53
|
+
result["rounds"].append(row)
|
|
54
|
+
result["paired"] = paired(result["rounds"])
|
|
55
|
+
result["ended_at"] = datetime.datetime.now(datetime.timezone.utc).isoformat()
|
|
56
|
+
write_json(out, result)
|
|
57
|
+
return result
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def paired(rounds):
|
|
61
|
+
"""Per KPI: the B / A ratio in each round, their median and spread (max - min). For time to first
|
|
62
|
+
token the ratio is A / B, so a ratio above 1 means B is faster on every KPI."""
|
|
63
|
+
out = {}
|
|
64
|
+
for kpi in KPIS:
|
|
65
|
+
ratios = []
|
|
66
|
+
for row in rounds:
|
|
67
|
+
a, b = row["a"].get(kpi), row["b"].get(kpi)
|
|
68
|
+
if a and b:
|
|
69
|
+
ratios.append(a / b if kpi == "ttft_score_ms" else b / a)
|
|
70
|
+
out[kpi] = {"ratios": ratios, "median": metrics.median(ratios),
|
|
71
|
+
"spread": (max(ratios) - min(ratios)) if len(ratios) > 1 else None}
|
|
72
|
+
return out
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def render(result):
|
|
76
|
+
names = {k: v["name"] for k, v in result["engines"].items()}
|
|
77
|
+
lines = [f"A = {names.get('a')}, B = {names.get('b')}; {len(result['rounds'])} rounds, order alternating"]
|
|
78
|
+
for i, row in enumerate(result["rounds"], 1):
|
|
79
|
+
cells = []
|
|
80
|
+
for key in ("a", "b"):
|
|
81
|
+
r = row[key]
|
|
82
|
+
cells.append(f"{key.upper()}: decode {_fmt(r['decode_score'], '.1f')}, prefill {_fmt(r['prefill_score'], ',.0f')}, "
|
|
83
|
+
f"ttft {_fmt(r['ttft_score_ms'], ',.0f')} ms")
|
|
84
|
+
lines.append(f"round {i} ({' '.join(k.upper() for k in row['order'])}): " + "; ".join(cells))
|
|
85
|
+
for kpi, label in (("decode_score", "decode"), ("prefill_score", "prefill"), ("ttft_score_ms", "ttft (speed)")):
|
|
86
|
+
p = result["paired"][kpi]
|
|
87
|
+
if p["median"] is not None:
|
|
88
|
+
lines.append(f"B vs A {label}: {p['median'] - 1:+.1%} median over rounds"
|
|
89
|
+
+ (f", spread {p['spread']:.1%}" if p["spread"] is not None else ""))
|
|
90
|
+
return "\n".join(lines)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _fmt(value, spec):
|
|
94
|
+
return "–" if value is None else format(value, spec)
|
crit_eval/ceiling.py
ADDED
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
"""The model's arithmetic per token, for the prefill floor, its weight bytes, for the memory
|
|
2
|
+
floor of a short prompt, and its KV bytes per token, for the decode floor at long context. Counted once from the weight headers and config when the
|
|
3
|
+
suite is prepared, so every engine evaluated against one data directory gets the same ceiling.
|
|
4
|
+
|
|
5
|
+
Prefill is compute bound. Its floor for a prompt of N tokens is
|
|
6
|
+
|
|
7
|
+
matmul N x sum over weight formats of FLOPs per token / that format's GEMM rate
|
|
8
|
+
attention N(N+1)/2 query-key pairs x FLOPs per pair / the BF16 rate
|
|
9
|
+
recurrent N x linear-attention FLOPs per token / the BF16 rate (chunked form, approximate)
|
|
10
|
+
|
|
11
|
+
A projection weight of R x K elements costs 2RK FLOPs per token at the rate of the format it is stored in
|
|
12
|
+
(crit_eval.schemes): BF16/F16/F32 (bf16), FP8 (fp8), NVFP4 (nvfp4), weight-only integer codes (AWQ, GPTQ,
|
|
13
|
+
MLX, bitsandbytes NF4, compressed-tensors pack-quantized: bf16, as engines widen them for the GEMM) and 8-bit
|
|
14
|
+
integer weights with per-row scales (bitsandbytes LLM.int8, compressed-tensors int-quantized: fp8, the int8 rate).
|
|
15
|
+
A GGUF's ggml block types
|
|
16
|
+
count at the fp8 rate (engines multiply their integer codes on 8-bit tensor cores, which run int8 and FP8 at
|
|
17
|
+
the same rate), its MXFP4 and NVFP4 at the nvfp4 rate and its F32/F16/BF16 at bf16. Embedding tables
|
|
18
|
+
(gathered), vision towers, norms, convolutions and the output head (one row of logits per
|
|
19
|
+
prompt) are left out. Engines may compute in other formats; the floor uses the stored ones, and
|
|
20
|
+
prefill_floor_ms prices the quantized matrices with other prompt activations on request (ACTIVATIONS:
|
|
21
|
+
BF16, FP16 accumulation, FP8), as crit_eval.headroom.activation_ceilings reports them.
|
|
22
|
+
|
|
23
|
+
Time to first token cannot beat either floor: the prompt's arithmetic, or reading the weights
|
|
24
|
+
once (language-model tensors and the output head, not embeddings, vision or draft layers)
|
|
25
|
+
plus writing the prompt's KV, at the device bandwidth.
|
|
26
|
+
"""
|
|
27
|
+
import math
|
|
28
|
+
import os
|
|
29
|
+
|
|
30
|
+
from crit_core.model import open_model, parse_model_config
|
|
31
|
+
from crit_core.model.gguf import TYPE_BY_NAME, TensorInfo as GgufTensor
|
|
32
|
+
|
|
33
|
+
from .schemes import PRIMARY, quantization_of, refuse_unread, stored
|
|
34
|
+
from .tensors import tensor_role
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _source(model):
|
|
38
|
+
# A model path that does not exist reads as a model without files, as a folder without them does.
|
|
39
|
+
return open_model(model) if os.path.lexists(model) else None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def model_config(model):
|
|
43
|
+
"""The model's config.json read with Core's rules (crit_core.model.ModelSource.config): the folder's own, or
|
|
44
|
+
the one a GGUF's metadata derives; an empty config when the model has none. Rules read the text section
|
|
45
|
+
(`text_config` over the root)."""
|
|
46
|
+
source = _source(model)
|
|
47
|
+
return source.config() if source else parse_model_config({})
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def weight_files(model, drafts=False):
|
|
51
|
+
"""The weight files of the model (a folder or a weight file, crit_core.model.open_model) with the draft blocks
|
|
52
|
+
their tensors' roles need: (files, draft_blocks). Without `drafts`, draft-layer files are left out; with it,
|
|
53
|
+
those beside the checkpoint join it. ([], no blocks) for a folder without weights."""
|
|
54
|
+
source = _source(model)
|
|
55
|
+
return (list(source.weight_files(drafts)), source.draft_blocks) if source else ([], frozenset())
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def weight_shards(model, drafts=False):
|
|
59
|
+
"""The safetensors files of the model, validated by crit_core.model (weight_files of a safetensors model);
|
|
60
|
+
[] for any other model."""
|
|
61
|
+
source = _source(model)
|
|
62
|
+
return list(source.weight_files(drafts)) if source and source.format == "safetensors" else []
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _read(role):
|
|
66
|
+
# A pass of the language model reads its layers, experts and head; embedding rows are gathered.
|
|
67
|
+
return role.kind in ("layer", "expert", "head") and not role.draft
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def weight_bytes(tensors, file_name="", draft_blocks=()):
|
|
71
|
+
"""Bytes of the tensors one forward pass reads (crit_eval.tensors: layers, experts and the output head, not
|
|
72
|
+
embeddings, other modalities or draft layers). tensors: {name: crit_core.model.TensorInfo} of one file."""
|
|
73
|
+
return sum(t.nbytes for name, t in tensors.items() if _read(tensor_role(name, file_name, draft_blocks)))
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _ggml_format(dtype):
|
|
77
|
+
"""A GGUF tensor's GEMM rate key (see the module docstring); None for the integer types."""
|
|
78
|
+
if dtype in ("F32", "F16", "BF16"):
|
|
79
|
+
return "bf16"
|
|
80
|
+
if dtype in ("MXFP4", "NVFP4"):
|
|
81
|
+
return "nvfp4"
|
|
82
|
+
return "fp8" if TYPE_BY_NAME[dtype].quantized else None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def matmul_flops(tensors, file_name="", draft_blocks=(), quantization=None):
|
|
86
|
+
"""FLOPs per token by stored weight format, and how many matrices each format has, over one file's
|
|
87
|
+
{name: TensorInfo}. The output head is left out (one row of logits per prompt). A 3-D expert tensor counts as
|
|
88
|
+
one matrix per expert, as the per-expert matrices of a checkpoint that stores them apart do. quantization: the
|
|
89
|
+
config's quantization_config (or MLX's quantization), which names the scheme of packed integer weights
|
|
90
|
+
(crit_eval.schemes)."""
|
|
91
|
+
flops, matrices, seen = {}, {}, set()
|
|
92
|
+
for name, tensor in tensors.items():
|
|
93
|
+
role = tensor_role(name, file_name, draft_blocks)
|
|
94
|
+
stacked = len(tensor.shape) == 3 and role.kind == "expert"
|
|
95
|
+
if not _read(role) or role.kind == "head":
|
|
96
|
+
continue
|
|
97
|
+
count = tensor.shape[0] if stacked else 1
|
|
98
|
+
if isinstance(tensor, GgufTensor):
|
|
99
|
+
if "conv" in name or (len(tensor.shape) != 2 and not stacked): # GGUF squeezes conv kernels to 2-D
|
|
100
|
+
continue
|
|
101
|
+
fmt = _ggml_format(tensor.dtype) if name.endswith(".weight") else None
|
|
102
|
+
elements = math.prod(tensor.shape)
|
|
103
|
+
else:
|
|
104
|
+
base = next((name[:-len(suffix)] for suffix in PRIMARY if name.endswith(suffix)), None)
|
|
105
|
+
found = None if base is None or base in seen else stored(tensors, base, quantization)
|
|
106
|
+
# Plain and FP8 weights of other ranks are norms, biases and convolution kernels, not matrices.
|
|
107
|
+
if found is None or (found[0] in ("plain", "fp8") and len(tensor.shape) != 2 and not stacked):
|
|
108
|
+
continue
|
|
109
|
+
seen.add(base)
|
|
110
|
+
_, fmt, elements = found
|
|
111
|
+
if fmt:
|
|
112
|
+
flops[fmt] = flops.get(fmt, 0) + 2 * elements
|
|
113
|
+
matrices[fmt] = matrices.get(fmt, 0) + count
|
|
114
|
+
return flops, matrices
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def model_ceiling(model):
|
|
118
|
+
"""Per-token arithmetic and KV bytes of the model, or None for a model without weight files."""
|
|
119
|
+
shards, drafts = weight_files(model)
|
|
120
|
+
if not shards:
|
|
121
|
+
return None
|
|
122
|
+
config = model_config(model)
|
|
123
|
+
quantization = quantization_of(config)
|
|
124
|
+
flops, matrices, read = {}, {}, 0
|
|
125
|
+
for shard in shards:
|
|
126
|
+
tensors = {tensor.name: tensor for tensor in shard.tensors}
|
|
127
|
+
refuse_unread(quantization, tensors)
|
|
128
|
+
read += weight_bytes(tensors, shard.path.name, drafts)
|
|
129
|
+
f, m = matmul_flops(tensors, shard.path.name, drafts, quantization)
|
|
130
|
+
for key in f:
|
|
131
|
+
flops[key] = flops.get(key, 0) + f[key]
|
|
132
|
+
matrices[key] = matrices.get(key, 0) + m[key]
|
|
133
|
+
shape = config.attention_shape()
|
|
134
|
+
out = {"matmul_flops_per_token": flops, "matrices": matrices, "weight_bytes": read,
|
|
135
|
+
"attention_layers": None, "attention_flops_per_pair": None, "kv_bytes_per_token": None,
|
|
136
|
+
"recurrent_flops_per_token": 0}
|
|
137
|
+
if shape:
|
|
138
|
+
full, heads, head_dim, kv_heads = shape
|
|
139
|
+
# Scores (QK^T) and weighted values (PV): 2 x 2 x heads x head_dim FLOPs per query-key pair.
|
|
140
|
+
out.update(attention_layers=full, attention_flops_per_pair=4 * heads * head_dim * full,
|
|
141
|
+
kv_bytes_per_token=2 * kv_heads * head_dim * 2 * full) # K and V, BF16
|
|
142
|
+
linear = config.layer_types.count("linear_attention")
|
|
143
|
+
attention = config.linear
|
|
144
|
+
hv, dk, dv = ((attention.num_value_heads, attention.key_head_dim, attention.value_head_dim) if attention
|
|
145
|
+
else (None, None, None))
|
|
146
|
+
if linear and hv and dk and dv:
|
|
147
|
+
chunk = 64 # chunked gated delta rule: state read/update plus intra-chunk scores and values
|
|
148
|
+
out["recurrent_flops_per_token"] = linear * hv * (4 * dk * dv + 2 * chunk * (dk + dv))
|
|
149
|
+
return out
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
RATE_SOURCES = (("tflops", "cuBLASLt GEMM"), ("mma_tflops", "raw MMA"))
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def best_rates(device):
|
|
156
|
+
"""The best achievable TFLOPS per format of a `crit eval probe` result, and what achieved each: {key: tflops} and
|
|
157
|
+
{key: "cuBLASLt GEMM" or "raw MMA"}. cuBLASLt's GEMMs (bf16, fp8, nvfp4) are not every device's best kernels
|
|
158
|
+
(an RTX 5090's BF16 GEMM reaches 185 TFLOPS where its BF16 tensor cores run 252), so each format takes the larger
|
|
159
|
+
of its GEMM rate and its raw tensor-core rate (bf16, fp16, fp16_acc, fp8, nvfp4, fp4_fp8). A probe from before
|
|
160
|
+
the raw rates has GEMM rates alone."""
|
|
161
|
+
rates, sources = {}, {}
|
|
162
|
+
for key, source in RATE_SOURCES:
|
|
163
|
+
for name, value in ((device or {}).get(key) or {}).items():
|
|
164
|
+
if value and value > rates.get(name, 0):
|
|
165
|
+
rates[name], sources[name] = value, source
|
|
166
|
+
return rates, sources
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
# The activation formats a quantized matrix's prompt GEMM can take, and the rates (crit_eval.probe keys) each runs at
|
|
170
|
+
# for each stored weight format, the best available one counting: BF16 activations on BF16 tensor cores with FP32
|
|
171
|
+
# accumulation, FP16 activations accumulating in FP16, FP8 activations (FP4 weights times FP8 through kind::f8f6f4, or
|
|
172
|
+
# widened to FP8), and the stored format itself (W8A8, W4A4). Weights stored in BF16 stay at the BF16 rate.
|
|
173
|
+
ACTIVATIONS = {"bf16": {"fp8": ("bf16",), "nvfp4": ("bf16",)},
|
|
174
|
+
"fp16_acc": {"fp8": ("fp16_acc",), "nvfp4": ("fp16_acc",)},
|
|
175
|
+
"fp8": {"fp8": ("fp8",), "nvfp4": ("fp4_fp8", "fp8")},
|
|
176
|
+
"stored": {"fp8": ("fp8",), "nvfp4": ("nvfp4",)}}
|
|
177
|
+
QUANTIZED = ("fp8", "nvfp4")
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def quantized_formats(ceiling):
|
|
181
|
+
"""The quantized stored formats whose matrices the model's prompt GEMMs multiply (fp8, nvfp4)."""
|
|
182
|
+
return [f for f in QUANTIZED if ((ceiling or {}).get("matmul_flops_per_token") or {}).get(f)]
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def activation_shares(activations, formats):
|
|
186
|
+
"""{stored format: {activation: share}} from an engine's prompt_activations: one activation for every quantized
|
|
187
|
+
format ("fp16_acc"), one per format ({"nvfp4": "fp16_acc", "fp8": "fp8"}) or shares of a format's FLOPs
|
|
188
|
+
({"nvfp4": {"fp16_acc": 0.93, "bf16": 0.07}}); None when it names no format of `formats` or an activation that is
|
|
189
|
+
not one of ACTIVATIONS. A format it leaves out keeps its stored activations."""
|
|
190
|
+
if isinstance(activations, str):
|
|
191
|
+
activations = {f: activations for f in formats}
|
|
192
|
+
if not isinstance(activations, dict):
|
|
193
|
+
return None
|
|
194
|
+
shares = {}
|
|
195
|
+
for f in formats:
|
|
196
|
+
value = activations.get(f, "stored")
|
|
197
|
+
value = {value: 1.0} if isinstance(value, str) else value
|
|
198
|
+
if (not isinstance(value, dict) or not value or any(k not in ACTIVATIONS for k in value)
|
|
199
|
+
or any(not isinstance(v, (int, float)) or v < 0 for v in value.values())):
|
|
200
|
+
return None
|
|
201
|
+
total = sum(value.values())
|
|
202
|
+
if total <= 0:
|
|
203
|
+
return None
|
|
204
|
+
shares[f] = {k: v / total for k, v in value.items()}
|
|
205
|
+
return shares if any(f in activations for f in formats) else None
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def matmul_rate(tflops, stored, activation=None):
|
|
209
|
+
"""(TFLOPS, rate key) a stored format's matrices multiply at with the given activation (ACTIVATIONS; None: the
|
|
210
|
+
stored format's own), the best of the activation's rates; (None, the keys tried) when none was probed."""
|
|
211
|
+
keys = ACTIVATIONS[activation].get(stored, (stored,)) if activation else (stored,)
|
|
212
|
+
found = [(tflops[key], key) for key in keys if (tflops or {}).get(key)]
|
|
213
|
+
return max(found) if found else (None, keys)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def prefill_floor_ms(ceiling, tflops, prompt_tokens, activations=None):
|
|
217
|
+
"""Floor of one prefill in ms with its parts (matmul_by_format: each stored format's matmul ms), or (None,
|
|
218
|
+
missing rates). activations: {stored format: {activation: share}} (activation_shares) for the quantized
|
|
219
|
+
formats' prompt GEMMs; without it each format multiplies at its own rate."""
|
|
220
|
+
if not ceiling or not tflops:
|
|
221
|
+
return None, ["device GEMM rates (--device)"] if not tflops else ["model arithmetic (re-run prepare)"]
|
|
222
|
+
missing, by_format, n = [], {}, prompt_tokens
|
|
223
|
+
for f, flops in ceiling["matmul_flops_per_token"].items():
|
|
224
|
+
ms = 0.0
|
|
225
|
+
for activation, share in ((activations or {}).get(f) or {None: 1.0}).items():
|
|
226
|
+
rate, key = matmul_rate(tflops, f, activation)
|
|
227
|
+
if rate is None:
|
|
228
|
+
missing += [k for k in key if k not in missing]
|
|
229
|
+
continue
|
|
230
|
+
ms += share * n * flops / (rate * 1e12) * 1000
|
|
231
|
+
by_format[f] = ms
|
|
232
|
+
if not tflops.get("bf16"):
|
|
233
|
+
missing.append("bf16")
|
|
234
|
+
if missing:
|
|
235
|
+
return None, [f"{f} GEMM rate" for f in sorted(set(missing))]
|
|
236
|
+
matmul = sum(by_format.values())
|
|
237
|
+
pairs = n * (n + 1) / 2
|
|
238
|
+
# Attention at the faster of the BF16 and FP8 rates: engines run Q K^T and P V on FP8 tensor cores within
|
|
239
|
+
# the quality budget, so a BF16-only floor is one they beat.
|
|
240
|
+
attention_rate = max(tflops["bf16"], tflops.get("fp8") or 0)
|
|
241
|
+
attention = pairs * (ceiling.get("attention_flops_per_pair") or 0) / (attention_rate * 1e12) * 1000
|
|
242
|
+
recurrent = n * (ceiling.get("recurrent_flops_per_token") or 0) / (tflops["bf16"] * 1e12) * 1000
|
|
243
|
+
return {"matmul_ms": matmul, "attention_ms": attention, "recurrent_ms": recurrent,
|
|
244
|
+
"total_ms": matmul + attention + recurrent, "matmul_by_format": by_format}, []
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def ttft_floor_ms(ceiling, tflops, bandwidth_gbs, prompt_tokens, bytes_per_pass=None, activations=None):
|
|
248
|
+
"""Floor of the time to first token: the larger of the prompt's compute floor (with the quantized formats'
|
|
249
|
+
activations, prefill_floor_ms) and one read of the weights (bytes_per_pass when the adapter declares it) plus the
|
|
250
|
+
prompt's KV writes."""
|
|
251
|
+
compute, missing = prefill_floor_ms(ceiling, tflops, prompt_tokens, activations)
|
|
252
|
+
weights = bytes_per_pass or (ceiling or {}).get("weight_bytes")
|
|
253
|
+
if not weights or not bandwidth_gbs:
|
|
254
|
+
missing = missing + ["device bandwidth (--device)" if not bandwidth_gbs else "model weight bytes (re-run prepare)"]
|
|
255
|
+
if missing:
|
|
256
|
+
return None, missing
|
|
257
|
+
memory = (weights + (ceiling.get("kv_bytes_per_token") or 0) * prompt_tokens) / (bandwidth_gbs * 1e9) * 1000
|
|
258
|
+
return {"compute_ms": compute["total_ms"], "memory_ms": memory, "total_ms": max(compute["total_ms"], memory),
|
|
259
|
+
"bound": "compute" if compute["total_ms"] >= memory else "memory"}, []
|