feat: DGR-001 - Lock the safetensors-versus-GGUF performance contract
This commit is contained in:
519
packages/node/meshnet_node/performance_contract.py
Normal file
519
packages/node/meshnet_node/performance_contract.py
Normal file
@@ -0,0 +1,519 @@
|
||||
"""The versioned safetensors-versus-GGUF performance contract.
|
||||
|
||||
The contract is the decision rule the native GGUF track is judged by, written
|
||||
down *before* the numbers arrive and consumed later by the release gate
|
||||
(DGR-014). Its thresholds are ratios against the Transformers/safetensors
|
||||
reference recipe rather than absolute tokens/sec, because the absolute figure is
|
||||
a property of whichever machine ran the benchmark and would have to be re-argued
|
||||
on every host; a ratio is a claim about the runtime.
|
||||
|
||||
Three rules give the contract its teeth:
|
||||
|
||||
* **Thresholds are locked.** ``CONTRACT_SCHEMA_VERSION`` and ``locked_at``
|
||||
travel with the document. Moving a threshold after seeing results is a new
|
||||
contract version and a human decision, not a tweak.
|
||||
* **Only like-for-like comparisons count.** A recipe measured on a different
|
||||
device than the reference is marked non-comparable and is granted no benefit,
|
||||
so a GPU-versus-CPU mismatch can never be laundered into a speed win.
|
||||
* **Quantized recipes never claim numerical equivalence.** Quality is gated on
|
||||
the near-lossless quality lane; the performance-fit lane is judged on speed,
|
||||
memory and fit alone.
|
||||
|
||||
The verdict is one of ``promote``, ``optimize`` or ``stop`` — the three outcomes
|
||||
the release gate is allowed to reach.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import asdict, dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Mapping
|
||||
|
||||
from .recipe_benchmark import Lane
|
||||
|
||||
# Layout of the contract document understood by this reader.
|
||||
CONTRACT_SCHEMA_VERSION = 1
|
||||
|
||||
VERDICT_PROMOTE = "promote"
|
||||
VERDICT_OPTIMIZE = "optimize"
|
||||
VERDICT_STOP = "stop"
|
||||
|
||||
|
||||
class PerformanceContractError(ValueError):
|
||||
"""Raised when a contract is missing, malformed, or of an unsupported version."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ContractThresholds:
|
||||
"""The locked decision thresholds.
|
||||
|
||||
Every value is a ratio of a GGUF recipe's metric to the reference recipe's
|
||||
metric on the same machine, same device, same plan.
|
||||
|
||||
A *meaningful speed benefit* means the GGUF recipe decodes at least 25%
|
||||
faster for a single request without making time-to-first-token materially
|
||||
worse, or sustains at least 25% more aggregate throughput under concurrency.
|
||||
Either route is a real win for the product: one helps a single user, the
|
||||
other helps a loaded node.
|
||||
|
||||
A *meaningful fit benefit* means peak resident memory (RSS plus VRAM) drops
|
||||
by at least 25%. Fit is the product thesis — models larger than one
|
||||
consumer node — so it is measured in resident bytes, not in how small the
|
||||
file on disk is. Artifact size has its own reported threshold because a
|
||||
smaller download is a real but secondary good.
|
||||
|
||||
25% is chosen to sit well clear of ordinary run-to-run variance on a busy
|
||||
developer machine while still being a benefit a user would notice. A 5%
|
||||
edge would not justify owning a native runtime and a patch stack.
|
||||
"""
|
||||
|
||||
min_decode_speedup: float = 1.25
|
||||
max_ttft_ratio: float = 1.25
|
||||
min_aggregate_throughput_speedup: float = 1.25
|
||||
max_resident_memory_ratio: float = 0.75
|
||||
max_artifact_size_ratio: float = 0.60
|
||||
min_quality_exact_match_rate: float = 0.90
|
||||
min_quality_mean_similarity: float = 0.97
|
||||
max_failure_rate: float = 0.0
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PerformanceContract:
|
||||
"""A locked, versioned contract plus the baseline it was locked against."""
|
||||
|
||||
contract_version: int
|
||||
locked_at: str
|
||||
locked_by: str
|
||||
plan_id: str
|
||||
thresholds: ContractThresholds
|
||||
baseline: Mapping[str, Any]
|
||||
stop_condition: str
|
||||
notes: str = ""
|
||||
schema_version: int = CONTRACT_SCHEMA_VERSION
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"schema_version": self.schema_version,
|
||||
"contract_version": self.contract_version,
|
||||
"locked_at": self.locked_at,
|
||||
"locked_by": self.locked_by,
|
||||
"plan_id": self.plan_id,
|
||||
"thresholds": self.thresholds.to_dict(),
|
||||
"baseline": dict(self.baseline),
|
||||
"stop_condition": self.stop_condition,
|
||||
"notes": self.notes,
|
||||
}
|
||||
|
||||
|
||||
STOP_CONDITION = (
|
||||
"Stop the native llama.cpp/GGUF track when, on the same machine and device "
|
||||
"as the Transformers/safetensors reference and under this plan, no "
|
||||
"performance-fit GGUF recipe delivers either a meaningful speed benefit "
|
||||
"(>=25% higher single-request decode tokens/sec without a >25% worse TTFT, "
|
||||
"or >=25% higher aggregate throughput under concurrency) or a meaningful fit "
|
||||
"benefit (>=25% lower peak resident memory), or when the near-lossless "
|
||||
"quality lane fails, which indicates a broken runtime rather than a "
|
||||
"quantization trade-off."
|
||||
)
|
||||
|
||||
|
||||
def _recipe_entries(report: Mapping[str, Any]) -> dict[str, Mapping[str, Any]]:
|
||||
return {entry["recipe"]["id"]: entry for entry in report["recipes"]}
|
||||
|
||||
|
||||
def _cell(entry: Mapping[str, Any], concurrency: int) -> Mapping[str, Any] | None:
|
||||
return entry["concurrency"].get(str(concurrency))
|
||||
|
||||
|
||||
def _resident_bytes(cell: Mapping[str, Any]) -> int:
|
||||
return int(cell["peak_rss_bytes"]) + int(cell["peak_vram_bytes"])
|
||||
|
||||
|
||||
def _ratio(value: float, reference: float) -> float:
|
||||
"""Ratio guarded against a zero reference, which means "not measured"."""
|
||||
if reference <= 0:
|
||||
return 0.0
|
||||
return round(value / reference, 4)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RecipeEvaluation:
|
||||
"""How one GGUF recipe fared against the reference under the contract."""
|
||||
|
||||
recipe_id: str
|
||||
lane: str
|
||||
comparable: bool
|
||||
incomparable_reason: str
|
||||
speed_benefit: bool
|
||||
fit_benefit: bool
|
||||
quality_pass: bool | None
|
||||
failures: int
|
||||
measurements: Mapping[str, Any]
|
||||
reasons: tuple[str, ...]
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
data = asdict(self)
|
||||
data["measurements"] = dict(self.measurements)
|
||||
data["reasons"] = list(self.reasons)
|
||||
return data
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ContractEvaluation:
|
||||
"""The release-gate answer: a verdict plus every reason behind it."""
|
||||
|
||||
contract_version: int
|
||||
plan_id: str
|
||||
verdict: str
|
||||
quality_lane_pass: bool
|
||||
speed_benefit: bool
|
||||
fit_benefit: bool
|
||||
stop_condition_met: bool
|
||||
recipes: tuple[RecipeEvaluation, ...]
|
||||
rationale: tuple[str, ...]
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"contract_version": self.contract_version,
|
||||
"plan_id": self.plan_id,
|
||||
"verdict": self.verdict,
|
||||
"quality_lane_pass": self.quality_lane_pass,
|
||||
"speed_benefit": self.speed_benefit,
|
||||
"fit_benefit": self.fit_benefit,
|
||||
"stop_condition_met": self.stop_condition_met,
|
||||
"recipes": [recipe.to_dict() for recipe in self.recipes],
|
||||
"rationale": list(self.rationale),
|
||||
}
|
||||
|
||||
|
||||
def _evaluate_recipe(
|
||||
entry: Mapping[str, Any],
|
||||
reference: Mapping[str, Any],
|
||||
drift_by_recipe: Mapping[str, Mapping[str, Any]],
|
||||
thresholds: ContractThresholds,
|
||||
concurrency_levels: list[int],
|
||||
) -> RecipeEvaluation:
|
||||
recipe = entry["recipe"]
|
||||
lane = recipe["lane"]
|
||||
reasons: list[str] = []
|
||||
|
||||
if not entry["available"]:
|
||||
return RecipeEvaluation(
|
||||
recipe_id=recipe["id"], lane=lane, comparable=False,
|
||||
incomparable_reason=entry["unavailable_reason"] or "recipe was not measured",
|
||||
speed_benefit=False, fit_benefit=False, quality_pass=None, failures=0,
|
||||
measurements={}, reasons=("recipe unavailable; no benefit granted",),
|
||||
)
|
||||
|
||||
if recipe["device"] != reference["recipe"]["device"]:
|
||||
return RecipeEvaluation(
|
||||
recipe_id=recipe["id"], lane=lane, comparable=False,
|
||||
incomparable_reason=(
|
||||
f"recipe ran on device {recipe['device']!r} but the reference ran on "
|
||||
f"{reference['recipe']['device']!r}; a cross-device ratio is not a runtime result"
|
||||
),
|
||||
speed_benefit=False, fit_benefit=False, quality_pass=None, failures=0,
|
||||
measurements={}, reasons=("cross-device comparison; no benefit granted",),
|
||||
)
|
||||
|
||||
single = _cell(entry, 1)
|
||||
reference_single = _cell(reference, 1)
|
||||
measurements: dict[str, Any] = {}
|
||||
speed_benefit = False
|
||||
|
||||
if single and reference_single:
|
||||
decode_speedup = _ratio(
|
||||
single["decode_tokens_per_sec"], reference_single["decode_tokens_per_sec"]
|
||||
)
|
||||
ttft_ratio = _ratio(single["ttft_p50_ms"], reference_single["ttft_p50_ms"])
|
||||
measurements["decode_speedup"] = decode_speedup
|
||||
measurements["ttft_ratio"] = ttft_ratio
|
||||
single_request_win = (
|
||||
decode_speedup >= thresholds.min_decode_speedup
|
||||
and 0 < ttft_ratio <= thresholds.max_ttft_ratio
|
||||
)
|
||||
if single_request_win:
|
||||
speed_benefit = True
|
||||
reasons.append(
|
||||
f"single-request decode {decode_speedup:.2f}x reference "
|
||||
f"(>= {thresholds.min_decode_speedup:.2f}x) at TTFT ratio {ttft_ratio:.2f}"
|
||||
)
|
||||
else:
|
||||
reasons.append(
|
||||
f"no single-request speed win: decode {decode_speedup:.2f}x, TTFT {ttft_ratio:.2f}x"
|
||||
)
|
||||
|
||||
concurrent = [level for level in concurrency_levels if level > 1]
|
||||
if concurrent:
|
||||
top = max(concurrent)
|
||||
cell, reference_cell = _cell(entry, top), _cell(reference, top)
|
||||
if cell and reference_cell:
|
||||
aggregate_speedup = _ratio(
|
||||
cell["aggregate_decode_tokens_per_sec"],
|
||||
reference_cell["aggregate_decode_tokens_per_sec"],
|
||||
)
|
||||
measurements["aggregate_throughput_speedup"] = aggregate_speedup
|
||||
measurements["aggregate_concurrency"] = top
|
||||
if aggregate_speedup >= thresholds.min_aggregate_throughput_speedup:
|
||||
speed_benefit = True
|
||||
reasons.append(
|
||||
f"aggregate throughput at concurrency {top} is {aggregate_speedup:.2f}x reference "
|
||||
f"(>= {thresholds.min_aggregate_throughput_speedup:.2f}x)"
|
||||
)
|
||||
else:
|
||||
reasons.append(
|
||||
f"no concurrency speed win: aggregate throughput at {top} is "
|
||||
f"{aggregate_speedup:.2f}x reference"
|
||||
)
|
||||
|
||||
fit_benefit = False
|
||||
if single and reference_single:
|
||||
resident_ratio = _ratio(_resident_bytes(single), _resident_bytes(reference_single))
|
||||
artifact_ratio = _ratio(
|
||||
entry["load"]["artifact_bytes"], reference["load"]["artifact_bytes"]
|
||||
)
|
||||
measurements["resident_memory_ratio"] = resident_ratio
|
||||
measurements["artifact_size_ratio"] = artifact_ratio
|
||||
if 0 < resident_ratio <= thresholds.max_resident_memory_ratio:
|
||||
fit_benefit = True
|
||||
reasons.append(
|
||||
f"peak resident memory is {resident_ratio:.2f}x reference "
|
||||
f"(<= {thresholds.max_resident_memory_ratio:.2f}x)"
|
||||
)
|
||||
else:
|
||||
reasons.append(f"no fit win: peak resident memory is {resident_ratio:.2f}x reference")
|
||||
measurements["artifact_size_win"] = (
|
||||
0 < artifact_ratio <= thresholds.max_artifact_size_ratio
|
||||
)
|
||||
|
||||
failures = sum(cell["failures"] for cell in entry["concurrency"].values())
|
||||
requests = sum(cell["requests"] for cell in entry["concurrency"].values())
|
||||
failure_rate = _ratio(failures, requests) if requests else 0.0
|
||||
measurements["failure_rate"] = failure_rate
|
||||
if failure_rate > thresholds.max_failure_rate:
|
||||
reasons.append(f"failure rate {failure_rate:.2%} exceeds the contract limit")
|
||||
speed_benefit = False
|
||||
fit_benefit = False
|
||||
|
||||
# Quality is a claim only the near-lossless lane is allowed to make. A
|
||||
# quantized recipe's drift is recorded elsewhere and deliberately not read
|
||||
# here: Q4 disagreeing with bf16 is the trade-off, not a failure.
|
||||
quality_pass: bool | None = None
|
||||
if lane == Lane.QUALITY.value:
|
||||
drift = drift_by_recipe.get(recipe["id"])
|
||||
if drift is None:
|
||||
quality_pass = False
|
||||
reasons.append("quality-lane recipe has no drift measurement against the reference")
|
||||
else:
|
||||
quality_pass = (
|
||||
drift["exact_match_rate"] >= thresholds.min_quality_exact_match_rate
|
||||
and drift["mean_similarity"] >= thresholds.min_quality_mean_similarity
|
||||
)
|
||||
measurements["exact_match_rate"] = drift["exact_match_rate"]
|
||||
measurements["mean_similarity"] = drift["mean_similarity"]
|
||||
reasons.append(
|
||||
f"quality lane exact-match {drift['exact_match_rate']:.2f} / similarity "
|
||||
f"{drift['mean_similarity']:.3f} versus the reference "
|
||||
f"({'pass' if quality_pass else 'fail'})"
|
||||
)
|
||||
|
||||
return RecipeEvaluation(
|
||||
recipe_id=recipe["id"], lane=lane, comparable=True, incomparable_reason="",
|
||||
speed_benefit=speed_benefit, fit_benefit=fit_benefit, quality_pass=quality_pass,
|
||||
failures=failures, measurements=measurements, reasons=tuple(reasons),
|
||||
)
|
||||
|
||||
|
||||
def evaluate_contract(
|
||||
contract: PerformanceContract,
|
||||
report: Mapping[str, Any],
|
||||
) -> ContractEvaluation:
|
||||
"""Judge a benchmark report against the locked contract.
|
||||
|
||||
Only performance-fit recipes can earn a speed or fit benefit; the quality
|
||||
lane decides only whether the GGUF runtime is numerically sane. A GGUF
|
||||
runtime that fails the quality lane is broken, and no amount of speed
|
||||
redeems it, so the verdict is ``stop`` regardless of the other numbers.
|
||||
"""
|
||||
entries = _recipe_entries(report)
|
||||
reference_id = report["reference_recipe_id"]
|
||||
reference = entries.get(reference_id)
|
||||
if reference is None:
|
||||
raise PerformanceContractError(
|
||||
f"report names reference recipe {reference_id!r}, which it does not contain"
|
||||
)
|
||||
|
||||
drift_by_recipe = {entry["recipe_id"]: entry for entry in report["drift"]}
|
||||
concurrency_levels = list(report["plan"]["concurrency_levels"])
|
||||
|
||||
evaluations = tuple(
|
||||
_evaluate_recipe(entry, reference, drift_by_recipe, contract.thresholds, concurrency_levels)
|
||||
for recipe_id, entry in entries.items()
|
||||
if recipe_id != reference_id
|
||||
)
|
||||
|
||||
quality_lane = [
|
||||
evaluation for evaluation in evaluations
|
||||
if evaluation.lane == Lane.QUALITY.value and evaluation.comparable
|
||||
]
|
||||
quality_lane_pass = bool(quality_lane) and all(
|
||||
evaluation.quality_pass for evaluation in quality_lane
|
||||
)
|
||||
performance_lane = [
|
||||
evaluation for evaluation in evaluations
|
||||
if evaluation.lane == Lane.PERFORMANCE_FIT.value
|
||||
]
|
||||
speed_benefit = any(evaluation.speed_benefit for evaluation in performance_lane)
|
||||
fit_benefit = any(evaluation.fit_benefit for evaluation in performance_lane)
|
||||
|
||||
rationale: list[str] = []
|
||||
if not quality_lane:
|
||||
rationale.append(
|
||||
"no comparable near-lossless GGUF recipe was measured, so the runtime's "
|
||||
"numerical correctness is unproven"
|
||||
)
|
||||
elif not quality_lane_pass:
|
||||
rationale.append(
|
||||
"the near-lossless quality lane failed: the GGUF runtime disagrees with the "
|
||||
"safetensors reference beyond what near-lossless weights can explain"
|
||||
)
|
||||
else:
|
||||
rationale.append("the near-lossless quality lane passed against the safetensors reference")
|
||||
|
||||
rationale.append(
|
||||
"a meaningful speed benefit was measured" if speed_benefit
|
||||
else "no performance-fit recipe delivered a meaningful speed benefit"
|
||||
)
|
||||
rationale.append(
|
||||
"a meaningful fit benefit was measured" if fit_benefit
|
||||
else "no performance-fit recipe delivered a meaningful fit benefit"
|
||||
)
|
||||
|
||||
stop_condition_met = not quality_lane_pass or not (speed_benefit or fit_benefit)
|
||||
if stop_condition_met:
|
||||
verdict = VERDICT_STOP
|
||||
elif speed_benefit and fit_benefit:
|
||||
verdict = VERDICT_PROMOTE
|
||||
else:
|
||||
verdict = VERDICT_OPTIMIZE
|
||||
rationale.append(
|
||||
"exactly one of speed or fit cleared the contract: the benefit is real but partial, "
|
||||
"so the measured bottleneck needs a bounded optimization task before promotion"
|
||||
)
|
||||
|
||||
return ContractEvaluation(
|
||||
contract_version=contract.contract_version,
|
||||
plan_id=contract.plan_id,
|
||||
verdict=verdict,
|
||||
quality_lane_pass=quality_lane_pass,
|
||||
speed_benefit=speed_benefit,
|
||||
fit_benefit=fit_benefit,
|
||||
stop_condition_met=stop_condition_met,
|
||||
recipes=evaluations,
|
||||
rationale=tuple(rationale),
|
||||
)
|
||||
|
||||
|
||||
def parse_contract(data: Any, source: str = "<memory>") -> PerformanceContract:
|
||||
"""Validate an already-decoded contract document."""
|
||||
if not isinstance(data, Mapping):
|
||||
raise PerformanceContractError(f"contract root in {source} must be a JSON object")
|
||||
|
||||
schema_version = data.get("schema_version")
|
||||
if not isinstance(schema_version, int) or isinstance(schema_version, bool):
|
||||
raise PerformanceContractError(f"'schema_version' in {source} must be an integer")
|
||||
if schema_version != CONTRACT_SCHEMA_VERSION:
|
||||
raise PerformanceContractError(
|
||||
f"{source} declares contract schema version {schema_version}, but this node reads "
|
||||
f"version {CONTRACT_SCHEMA_VERSION}; upgrade the node or use a supported contract"
|
||||
)
|
||||
|
||||
for required in ("contract_version", "locked_at", "locked_by", "plan_id", "stop_condition"):
|
||||
if not data.get(required):
|
||||
raise PerformanceContractError(f"{source} is missing {required!r}")
|
||||
|
||||
raw_thresholds = data.get("thresholds")
|
||||
if not isinstance(raw_thresholds, Mapping):
|
||||
raise PerformanceContractError(f"'thresholds' in {source} must be a JSON object")
|
||||
known = {field for field in ContractThresholds().to_dict()}
|
||||
unknown = set(raw_thresholds) - known
|
||||
if unknown:
|
||||
raise PerformanceContractError(
|
||||
f"{source} carries unknown thresholds {sorted(unknown)}; this node enforces {sorted(known)}"
|
||||
)
|
||||
thresholds = ContractThresholds(**{
|
||||
key: float(value) for key, value in raw_thresholds.items()
|
||||
})
|
||||
|
||||
return PerformanceContract(
|
||||
contract_version=int(data["contract_version"]),
|
||||
locked_at=str(data["locked_at"]),
|
||||
locked_by=str(data["locked_by"]),
|
||||
plan_id=str(data["plan_id"]),
|
||||
thresholds=thresholds,
|
||||
baseline=dict(data.get("baseline") or {}),
|
||||
stop_condition=str(data["stop_condition"]),
|
||||
notes=str(data.get("notes", "")),
|
||||
schema_version=schema_version,
|
||||
)
|
||||
|
||||
|
||||
def load_contract(path: Path) -> PerformanceContract:
|
||||
"""Load and validate the contract at ``path``."""
|
||||
try:
|
||||
raw = path.read_text(encoding="utf-8")
|
||||
except OSError as exc:
|
||||
raise PerformanceContractError(
|
||||
f"cannot read performance contract {path}: {exc.strerror or exc}"
|
||||
) from exc
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise PerformanceContractError(
|
||||
f"{path} is not valid JSON: {exc.msg} at line {exc.lineno} column {exc.colno}"
|
||||
) from exc
|
||||
return parse_contract(data, source=str(path))
|
||||
|
||||
|
||||
def baseline_from_report(report: Mapping[str, Any]) -> dict[str, Any]:
|
||||
"""Distil the reference numbers a later gate needs to compare against."""
|
||||
entries = _recipe_entries(report)
|
||||
baseline: dict[str, Any] = {
|
||||
"evidence_class": report["evidence_class"],
|
||||
"model_id": report["plan"]["model_id"],
|
||||
"model_revision": report["plan"]["model_revision"],
|
||||
"reference_recipe_id": report["reference_recipe_id"],
|
||||
"host": report["host"],
|
||||
"recipes": {},
|
||||
}
|
||||
for recipe_id, entry in entries.items():
|
||||
if not entry["available"]:
|
||||
baseline["recipes"][recipe_id] = {"available": False,
|
||||
"reason": entry["unavailable_reason"]}
|
||||
continue
|
||||
baseline["recipes"][recipe_id] = {
|
||||
"available": True,
|
||||
"lane": entry["recipe"]["lane"],
|
||||
"device": entry["recipe"]["device"],
|
||||
"artifact_bytes": entry["load"]["artifact_bytes"],
|
||||
"concurrency": {
|
||||
level: {
|
||||
"ttft_p50_ms": cell["ttft_p50_ms"],
|
||||
"ttft_p95_ms": cell["ttft_p95_ms"],
|
||||
"latency_p50_ms": cell["latency_p50_ms"],
|
||||
"latency_p95_ms": cell["latency_p95_ms"],
|
||||
"prefill_tokens_per_sec": cell["prefill_tokens_per_sec"],
|
||||
"decode_tokens_per_sec": cell["decode_tokens_per_sec"],
|
||||
"aggregate_decode_tokens_per_sec": cell["aggregate_decode_tokens_per_sec"],
|
||||
"peak_rss_bytes": cell["peak_rss_bytes"],
|
||||
"peak_vram_bytes": cell["peak_vram_bytes"],
|
||||
"failures": cell["failures"],
|
||||
}
|
||||
for level, cell in entry["concurrency"].items()
|
||||
},
|
||||
}
|
||||
return baseline
|
||||
648
packages/node/meshnet_node/recipe_benchmark.py
Normal file
648
packages/node/meshnet_node/recipe_benchmark.py
Normal file
@@ -0,0 +1,648 @@
|
||||
"""Controlled safetensors-versus-GGUF recipe benchmark.
|
||||
|
||||
This is a *model recipe* benchmark, unlike
|
||||
:mod:`meshnet_node.route_session_benchmark`, which is a transport harness. It
|
||||
answers one question: on one machine, with one model revision and one fixed
|
||||
workload, what do the Transformers/safetensors recipe and the whole-model
|
||||
llama.cpp/GGUF recipes actually cost in speed, memory, fit, and output drift?
|
||||
|
||||
Two ideas keep the answer honest.
|
||||
|
||||
**Lanes.** A recipe belongs to exactly one :class:`Lane`. The quality lane
|
||||
holds near-lossless recipes (bf16/f16 weights) whose outputs may legitimately be
|
||||
compared for numerical agreement. The performance-fit lane holds quantized
|
||||
recipes (Q8_0, Q4_K_M, ...). Quantized recipes are judged on speed, memory, and
|
||||
artifact size only; their drift is *reported* but never read as evidence that
|
||||
Q4 and bf16 are numerically equivalent, because they are not. The lane is a
|
||||
property of the recipe, so nothing downstream can quietly cross the boundary.
|
||||
|
||||
**Drivers.** The measurement core here is pure and runtime-free: it drives a
|
||||
:class:`RecipeDriver` and computes metrics. Real runtimes live in
|
||||
:mod:`meshnet_node.recipe_drivers` and are imported only on demand, which keeps
|
||||
the default test suite deterministic, GPU-free and model-download-free while the
|
||||
same code path produces the real evidence.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import statistics
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from difflib import SequenceMatcher
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Any, Protocol, Sequence
|
||||
|
||||
# Layout of the report document produced by :func:`build_report`.
|
||||
REPORT_SCHEMA_VERSION = 1
|
||||
|
||||
|
||||
class Lane(str, Enum):
|
||||
"""Why a recipe is being measured at all.
|
||||
|
||||
``QUALITY`` recipes carry near-lossless weights, so comparing their output
|
||||
with the reference recipe is a meaningful correctness signal.
|
||||
``PERFORMANCE_FIT`` recipes carry quantized weights: they exist to be faster
|
||||
or to fit, and their drift is descriptive, never a pass/fail equivalence
|
||||
claim.
|
||||
"""
|
||||
|
||||
QUALITY = "quality"
|
||||
PERFORMANCE_FIT = "performance-fit"
|
||||
|
||||
|
||||
class BenchmarkError(RuntimeError):
|
||||
"""Raised when a benchmark cannot be run as specified."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SamplingPolicy:
|
||||
"""The sampling policy every recipe must be given, identically.
|
||||
|
||||
Greedy by default: sampling noise would otherwise be indistinguishable from
|
||||
quantization drift, and the whole point of the quality lane is to tell those
|
||||
two apart.
|
||||
"""
|
||||
|
||||
temperature: float = 0.0
|
||||
top_p: float = 1.0
|
||||
top_k: int = 1
|
||||
seed: int = 1234
|
||||
max_output_tokens: int = 64
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PromptSpec:
|
||||
"""One fixed prompt, tagged with the context length it is meant to exercise."""
|
||||
|
||||
id: str
|
||||
text: str
|
||||
context_class: str = "short"
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BenchmarkPlan:
|
||||
"""The controlled variables: identical for every recipe in a report.
|
||||
|
||||
A plan is the experiment. If two recipes were measured under different
|
||||
plans, their numbers are not comparable and the report must not pretend they
|
||||
are, so the plan is recorded once at the top of the document rather than
|
||||
per-recipe.
|
||||
"""
|
||||
|
||||
plan_id: str
|
||||
model_id: str
|
||||
model_revision: str
|
||||
prompts: tuple[PromptSpec, ...]
|
||||
sampling: SamplingPolicy = SamplingPolicy()
|
||||
concurrency_levels: tuple[int, ...] = (1, 4)
|
||||
repeats: int = 1
|
||||
warmup_requests: int = 1
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not self.prompts:
|
||||
raise BenchmarkError("a benchmark plan needs at least one prompt")
|
||||
if not self.concurrency_levels or any(level < 1 for level in self.concurrency_levels):
|
||||
raise BenchmarkError("concurrency levels must all be >= 1")
|
||||
if self.repeats < 1:
|
||||
raise BenchmarkError("repeats must be >= 1")
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"plan_id": self.plan_id,
|
||||
"model_id": self.model_id,
|
||||
"model_revision": self.model_revision,
|
||||
"prompts": [prompt.to_dict() for prompt in self.prompts],
|
||||
"sampling": self.sampling.to_dict(),
|
||||
"concurrency_levels": list(self.concurrency_levels),
|
||||
"repeats": self.repeats,
|
||||
"warmup_requests": self.warmup_requests,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RecipeSpec:
|
||||
"""One runtime recipe under test.
|
||||
|
||||
``is_reference`` marks the single recipe every other recipe's output drift is
|
||||
measured against — the current Transformers/safetensors route, which
|
||||
decision Gate 8 keeps as the correctness backend.
|
||||
"""
|
||||
|
||||
id: str
|
||||
runtime: str
|
||||
weight_format: str
|
||||
weight_quantization: str
|
||||
lane: Lane
|
||||
device: str
|
||||
artifact_path: str = ""
|
||||
is_reference: bool = False
|
||||
notes: str = ""
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
data = asdict(self)
|
||||
data["lane"] = self.lane.value
|
||||
return data
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LoadStats:
|
||||
"""What loading the recipe cost, before any token is generated."""
|
||||
|
||||
artifact_bytes: int
|
||||
load_ms: float
|
||||
rss_bytes: int = 0
|
||||
vram_bytes: int = 0
|
||||
backend_detail: str = ""
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GenerationSample:
|
||||
"""One completed generation as reported by a driver.
|
||||
|
||||
``prefill_ms``/``decode_ms`` are the runtime's own split where it exposes one
|
||||
(llama.cpp does); drivers that cannot split honestly report ``prefill_ms`` as
|
||||
the time to the first token. ``queue_wait_ms`` separates time spent waiting
|
||||
for a runtime slot from time spent computing, so a concurrency-4 TTFT is not
|
||||
silently read as a slower prefill.
|
||||
"""
|
||||
|
||||
text: str
|
||||
prompt_tokens: int
|
||||
decode_tokens: int
|
||||
ttft_ms: float
|
||||
prefill_ms: float
|
||||
decode_ms: float
|
||||
total_ms: float
|
||||
queue_wait_ms: float = 0.0
|
||||
|
||||
|
||||
class RecipeDriver(Protocol):
|
||||
"""The seam every runtime implements; the measurement core knows nothing else."""
|
||||
|
||||
def load(self) -> LoadStats:
|
||||
"""Load the artifact and return its cost."""
|
||||
|
||||
def generate(self, prompt: str, sampling: SamplingPolicy) -> GenerationSample:
|
||||
"""Run one complete generation under the given sampling policy."""
|
||||
|
||||
def memory_probe(self) -> tuple[int, int]:
|
||||
"""Return ``(rss_bytes, vram_bytes)`` observed right now."""
|
||||
|
||||
def close(self) -> None:
|
||||
"""Release the runtime."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RequestOutcome:
|
||||
"""One request attempt, successful or not.
|
||||
|
||||
A failure is a first-class result, not an exception that aborts the run: a
|
||||
recipe that cannot sustain concurrency 4 has told us something, and the
|
||||
report must carry it rather than lose it.
|
||||
"""
|
||||
|
||||
recipe_id: str
|
||||
concurrency: int
|
||||
prompt_id: str
|
||||
repeat: int
|
||||
ok: bool
|
||||
latency_ms: float
|
||||
ttft_ms: float = 0.0
|
||||
prefill_ms: float = 0.0
|
||||
decode_ms: float = 0.0
|
||||
queue_wait_ms: float = 0.0
|
||||
prompt_tokens: int = 0
|
||||
decode_tokens: int = 0
|
||||
text: str = ""
|
||||
error: str = ""
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
def _percentile(values: Sequence[float], percentile: float) -> float:
|
||||
"""Nearest-rank percentile; 0.0 for an empty sample."""
|
||||
ordered = sorted(values)
|
||||
if not ordered:
|
||||
return 0.0
|
||||
rank = max(1, -(-len(ordered) * percentile // 100))
|
||||
return round(ordered[int(rank) - 1], 4)
|
||||
|
||||
|
||||
def _mean(values: Sequence[float]) -> float:
|
||||
return round(statistics.fmean(values), 4) if values else 0.0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ConcurrencyMetrics:
|
||||
"""Aggregate metrics for one recipe at one concurrency level."""
|
||||
|
||||
concurrency: int
|
||||
requests: int
|
||||
failures: int
|
||||
wall_ms: float
|
||||
ttft_p50_ms: float
|
||||
ttft_p95_ms: float
|
||||
latency_p50_ms: float
|
||||
latency_p95_ms: float
|
||||
prefill_tokens_per_sec: float
|
||||
decode_tokens_per_sec: float
|
||||
aggregate_decode_tokens_per_sec: float
|
||||
peak_rss_bytes: int
|
||||
peak_vram_bytes: int
|
||||
failure_reasons: tuple[str, ...] = ()
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
data = asdict(self)
|
||||
data["failure_reasons"] = list(self.failure_reasons)
|
||||
return data
|
||||
|
||||
|
||||
def summarize_concurrency(
|
||||
outcomes: Sequence[RequestOutcome],
|
||||
*,
|
||||
concurrency: int,
|
||||
wall_ms: float,
|
||||
peak_rss_bytes: int,
|
||||
peak_vram_bytes: int,
|
||||
) -> ConcurrencyMetrics:
|
||||
"""Aggregate one recipe/concurrency cell.
|
||||
|
||||
Per-request rates are averaged over successful requests; aggregate
|
||||
throughput is total decoded tokens over the wall clock of the whole cell,
|
||||
which is the only figure that credits a runtime for overlapping work.
|
||||
"""
|
||||
ok = [outcome for outcome in outcomes if outcome.ok]
|
||||
failures = [outcome for outcome in outcomes if not outcome.ok]
|
||||
decode_tokens = sum(outcome.decode_tokens for outcome in ok)
|
||||
|
||||
prefill_rates = [
|
||||
outcome.prompt_tokens / (outcome.prefill_ms / 1000)
|
||||
for outcome in ok
|
||||
if outcome.prefill_ms > 0 and outcome.prompt_tokens
|
||||
]
|
||||
decode_rates = [
|
||||
outcome.decode_tokens / (outcome.decode_ms / 1000)
|
||||
for outcome in ok
|
||||
if outcome.decode_ms > 0 and outcome.decode_tokens
|
||||
]
|
||||
return ConcurrencyMetrics(
|
||||
concurrency=concurrency,
|
||||
requests=len(outcomes),
|
||||
failures=len(failures),
|
||||
wall_ms=round(wall_ms, 4),
|
||||
ttft_p50_ms=_percentile([outcome.ttft_ms for outcome in ok], 50),
|
||||
ttft_p95_ms=_percentile([outcome.ttft_ms for outcome in ok], 95),
|
||||
latency_p50_ms=_percentile([outcome.latency_ms for outcome in ok], 50),
|
||||
latency_p95_ms=_percentile([outcome.latency_ms for outcome in ok], 95),
|
||||
prefill_tokens_per_sec=_mean(prefill_rates),
|
||||
decode_tokens_per_sec=_mean(decode_rates),
|
||||
aggregate_decode_tokens_per_sec=round(decode_tokens / max(1e-6, wall_ms / 1000), 4),
|
||||
peak_rss_bytes=peak_rss_bytes,
|
||||
peak_vram_bytes=peak_vram_bytes,
|
||||
failure_reasons=tuple(sorted({outcome.error for outcome in failures if outcome.error})),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class RecipeMeasurement:
|
||||
"""Everything measured for one recipe across every concurrency level."""
|
||||
|
||||
recipe: RecipeSpec
|
||||
load: LoadStats
|
||||
metrics: dict[int, ConcurrencyMetrics] = field(default_factory=dict)
|
||||
outcomes: list[RequestOutcome] = field(default_factory=list)
|
||||
unavailable_reason: str = ""
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
return not self.unavailable_reason
|
||||
|
||||
def outputs_by_prompt(self) -> dict[str, str]:
|
||||
"""First successful output per prompt, at the lowest concurrency measured.
|
||||
|
||||
Drift is a property of the recipe, not of load: concurrency must not
|
||||
change greedy output, so the least-contended sample is the fair one.
|
||||
"""
|
||||
best: dict[str, tuple[int, str]] = {}
|
||||
for outcome in self.outcomes:
|
||||
if not outcome.ok:
|
||||
continue
|
||||
seen = best.get(outcome.prompt_id)
|
||||
if seen is None or outcome.concurrency < seen[0]:
|
||||
best[outcome.prompt_id] = (outcome.concurrency, outcome.text)
|
||||
return {prompt_id: text for prompt_id, (_, text) in best.items()}
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"recipe": self.recipe.to_dict(),
|
||||
"available": self.available,
|
||||
"unavailable_reason": self.unavailable_reason,
|
||||
"load": self.load.to_dict(),
|
||||
"concurrency": {
|
||||
str(level): metrics.to_dict() for level, metrics in sorted(self.metrics.items())
|
||||
},
|
||||
"outcomes": [outcome.to_dict() for outcome in self.outcomes],
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DriftReport:
|
||||
"""Output drift of one recipe against the reference recipe.
|
||||
|
||||
``advisory`` is true for every performance-fit recipe: the number is
|
||||
published, but a Q4 recipe disagreeing with bf16 is expected behaviour, not a
|
||||
defect, and no gate may read it as one.
|
||||
"""
|
||||
|
||||
recipe_id: str
|
||||
lane: Lane
|
||||
reference_id: str
|
||||
compared_prompts: int
|
||||
exact_match_rate: float
|
||||
mean_similarity: float
|
||||
advisory: bool
|
||||
per_prompt: tuple[dict[str, Any], ...] = ()
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"recipe_id": self.recipe_id,
|
||||
"lane": self.lane.value,
|
||||
"reference_id": self.reference_id,
|
||||
"compared_prompts": self.compared_prompts,
|
||||
"exact_match_rate": self.exact_match_rate,
|
||||
"mean_similarity": self.mean_similarity,
|
||||
"advisory": self.advisory,
|
||||
"per_prompt": list(self.per_prompt),
|
||||
}
|
||||
|
||||
|
||||
def _first_divergence(left: str, right: str) -> int:
|
||||
"""Index of the first differing character, or -1 when the strings agree."""
|
||||
for index, (a, b) in enumerate(zip(left, right)):
|
||||
if a != b:
|
||||
return index
|
||||
return -1 if len(left) == len(right) else min(len(left), len(right))
|
||||
|
||||
|
||||
def compute_drift(
|
||||
measurement: RecipeMeasurement,
|
||||
reference: RecipeMeasurement,
|
||||
) -> DriftReport:
|
||||
"""Compare one recipe's greedy outputs with the reference recipe's."""
|
||||
reference_outputs = reference.outputs_by_prompt()
|
||||
outputs = measurement.outputs_by_prompt()
|
||||
shared = sorted(set(outputs) & set(reference_outputs))
|
||||
|
||||
per_prompt: list[dict[str, Any]] = []
|
||||
exact = 0
|
||||
similarities: list[float] = []
|
||||
for prompt_id in shared:
|
||||
got, want = outputs[prompt_id], reference_outputs[prompt_id]
|
||||
matches = got == want
|
||||
exact += matches
|
||||
similarity = round(SequenceMatcher(None, want, got).ratio(), 4)
|
||||
similarities.append(similarity)
|
||||
per_prompt.append({
|
||||
"prompt_id": prompt_id,
|
||||
"exact_match": matches,
|
||||
"similarity": similarity,
|
||||
"first_divergence_char": _first_divergence(want, got),
|
||||
"reference_text": want,
|
||||
"recipe_text": got,
|
||||
})
|
||||
|
||||
return DriftReport(
|
||||
recipe_id=measurement.recipe.id,
|
||||
lane=measurement.recipe.lane,
|
||||
reference_id=reference.recipe.id,
|
||||
compared_prompts=len(shared),
|
||||
exact_match_rate=round(exact / len(shared), 4) if shared else 0.0,
|
||||
mean_similarity=_mean(similarities),
|
||||
advisory=measurement.recipe.lane is Lane.PERFORMANCE_FIT,
|
||||
per_prompt=tuple(per_prompt),
|
||||
)
|
||||
|
||||
|
||||
class _PeakMemory:
|
||||
"""Sample a driver's memory while requests are in flight."""
|
||||
|
||||
def __init__(self, driver: RecipeDriver) -> None:
|
||||
self._driver = driver
|
||||
self.peak_rss = 0
|
||||
self.peak_vram = 0
|
||||
|
||||
def sample(self) -> None:
|
||||
try:
|
||||
rss, vram = self._driver.memory_probe()
|
||||
except Exception: # a probe must never fail a benchmark run
|
||||
return
|
||||
self.peak_rss = max(self.peak_rss, rss)
|
||||
self.peak_vram = max(self.peak_vram, vram)
|
||||
|
||||
|
||||
def _run_request(
|
||||
driver: RecipeDriver,
|
||||
recipe: RecipeSpec,
|
||||
prompt: PromptSpec,
|
||||
sampling: SamplingPolicy,
|
||||
concurrency: int,
|
||||
repeat: int,
|
||||
memory: _PeakMemory,
|
||||
) -> RequestOutcome:
|
||||
started = time.monotonic()
|
||||
try:
|
||||
sample = driver.generate(prompt.text, sampling)
|
||||
except Exception as exc: # a failed request is data, not a crashed benchmark
|
||||
return RequestOutcome(
|
||||
recipe_id=recipe.id,
|
||||
concurrency=concurrency,
|
||||
prompt_id=prompt.id,
|
||||
repeat=repeat,
|
||||
ok=False,
|
||||
latency_ms=round((time.monotonic() - started) * 1000, 4),
|
||||
error=f"{type(exc).__name__}: {exc}",
|
||||
)
|
||||
finally:
|
||||
memory.sample()
|
||||
|
||||
return RequestOutcome(
|
||||
recipe_id=recipe.id,
|
||||
concurrency=concurrency,
|
||||
prompt_id=prompt.id,
|
||||
repeat=repeat,
|
||||
ok=True,
|
||||
latency_ms=round(sample.total_ms, 4),
|
||||
ttft_ms=round(sample.ttft_ms, 4),
|
||||
prefill_ms=round(sample.prefill_ms, 4),
|
||||
decode_ms=round(sample.decode_ms, 4),
|
||||
queue_wait_ms=round(sample.queue_wait_ms, 4),
|
||||
prompt_tokens=sample.prompt_tokens,
|
||||
decode_tokens=sample.decode_tokens,
|
||||
text=sample.text,
|
||||
)
|
||||
|
||||
|
||||
def measure_recipe(
|
||||
driver: RecipeDriver,
|
||||
recipe: RecipeSpec,
|
||||
plan: BenchmarkPlan,
|
||||
) -> RecipeMeasurement:
|
||||
"""Load one recipe and run the whole plan against it.
|
||||
|
||||
The driver is closed exactly once, whatever happens, so a recipe that dies at
|
||||
concurrency 4 still releases its weights before the next recipe loads.
|
||||
"""
|
||||
load = driver.load()
|
||||
memory = _PeakMemory(driver)
|
||||
memory.sample()
|
||||
measurement = RecipeMeasurement(recipe=recipe, load=load)
|
||||
|
||||
try:
|
||||
for _ in range(plan.warmup_requests):
|
||||
try:
|
||||
driver.generate(plan.prompts[0].text, plan.sampling)
|
||||
except Exception: # a failing warmup is reported by the real requests
|
||||
break
|
||||
|
||||
for concurrency in plan.concurrency_levels:
|
||||
requests = [
|
||||
(prompt, repeat)
|
||||
for repeat in range(plan.repeats)
|
||||
for prompt in plan.prompts
|
||||
for _ in range(concurrency)
|
||||
]
|
||||
started = time.monotonic()
|
||||
with ThreadPoolExecutor(max_workers=concurrency) as pool:
|
||||
outcomes = list(pool.map(
|
||||
lambda item: _run_request(
|
||||
driver, recipe, item[0], plan.sampling, concurrency, item[1], memory
|
||||
),
|
||||
requests,
|
||||
))
|
||||
wall_ms = (time.monotonic() - started) * 1000
|
||||
|
||||
measurement.outcomes.extend(outcomes)
|
||||
measurement.metrics[concurrency] = summarize_concurrency(
|
||||
outcomes,
|
||||
concurrency=concurrency,
|
||||
wall_ms=wall_ms,
|
||||
peak_rss_bytes=memory.peak_rss,
|
||||
peak_vram_bytes=memory.peak_vram,
|
||||
)
|
||||
finally:
|
||||
driver.close()
|
||||
|
||||
return measurement
|
||||
|
||||
|
||||
def build_report(
|
||||
plan: BenchmarkPlan,
|
||||
measurements: Sequence[RecipeMeasurement],
|
||||
*,
|
||||
host: dict[str, Any],
|
||||
evidence_class: str,
|
||||
) -> dict:
|
||||
"""Assemble the machine-readable benchmark document.
|
||||
|
||||
``evidence_class`` is one of ``synthetic``, ``local-real`` or
|
||||
``multi-machine-real`` and is never inferred: a report that cannot say how it
|
||||
was produced cannot be trusted by a release gate.
|
||||
"""
|
||||
if evidence_class not in {"synthetic", "local-real", "multi-machine-real"}:
|
||||
raise BenchmarkError(f"unknown evidence class {evidence_class!r}")
|
||||
|
||||
references = [m for m in measurements if m.recipe.is_reference]
|
||||
if len(references) != 1:
|
||||
raise BenchmarkError(
|
||||
f"exactly one reference recipe is required, got {len(references)}"
|
||||
)
|
||||
reference = references[0]
|
||||
if reference.recipe.lane is not Lane.QUALITY:
|
||||
raise BenchmarkError("the reference recipe must sit in the quality lane")
|
||||
|
||||
drift = [
|
||||
compute_drift(measurement, reference).to_dict()
|
||||
for measurement in measurements
|
||||
if measurement is not reference and measurement.available
|
||||
]
|
||||
return {
|
||||
"schema_version": REPORT_SCHEMA_VERSION,
|
||||
"evidence_class": evidence_class,
|
||||
"plan": plan.to_dict(),
|
||||
"host": host,
|
||||
"reference_recipe_id": reference.recipe.id,
|
||||
"recipes": [measurement.to_dict() for measurement in measurements],
|
||||
"drift": drift,
|
||||
}
|
||||
|
||||
|
||||
def format_summary(report: dict) -> str:
|
||||
"""Render the human-readable companion to the JSON artifact."""
|
||||
plan = report["plan"]
|
||||
lines = [
|
||||
f"Recipe benchmark {plan['plan_id']} ({report['evidence_class']})",
|
||||
f"model {plan['model_id']}@{plan['model_revision']}",
|
||||
]
|
||||
for entry in report["recipes"]:
|
||||
recipe = entry["recipe"]
|
||||
if not entry["available"]:
|
||||
lines.append(f"{recipe['id']:38} UNAVAILABLE: {entry['unavailable_reason']}")
|
||||
continue
|
||||
artifact_gb = entry["load"]["artifact_bytes"] / 1e9
|
||||
for level, metrics in entry["concurrency"].items():
|
||||
lines.append(
|
||||
f"{recipe['id']:38} [{recipe['lane']:16}] c={level:>2} "
|
||||
f"ttft p50/p95 {metrics['ttft_p50_ms']:8.1f}/{metrics['ttft_p95_ms']:8.1f} ms; "
|
||||
f"prefill {metrics['prefill_tokens_per_sec']:7.1f} tok/s; "
|
||||
f"decode {metrics['decode_tokens_per_sec']:6.1f} tok/s; "
|
||||
f"aggregate {metrics['aggregate_decode_tokens_per_sec']:7.1f} tok/s; "
|
||||
f"rss {metrics['peak_rss_bytes'] / 1e9:5.2f} GB; "
|
||||
f"vram {metrics['peak_vram_bytes'] / 1e9:5.2f} GB; "
|
||||
f"artifact {artifact_gb:5.2f} GB; failures {metrics['failures']}"
|
||||
)
|
||||
for entry in report["drift"]:
|
||||
tag = "advisory" if entry["advisory"] else "gated"
|
||||
lines.append(
|
||||
f"drift {entry['recipe_id']:32} vs {entry['reference_id']:28} "
|
||||
f"exact {entry['exact_match_rate']:.2f}; similarity {entry['mean_similarity']:.3f} ({tag})"
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Run the controlled safetensors-versus-GGUF recipe benchmark"
|
||||
)
|
||||
parser.add_argument("--config", type=Path, required=True, help="benchmark configuration JSON")
|
||||
parser.add_argument("--json-out", type=Path, help="write the JSON report to this path")
|
||||
parser.add_argument("--summary-out", type=Path, help="write the text summary to this path")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
from .recipe_drivers import run_configured_benchmark # heavy runtimes: import on demand
|
||||
|
||||
report = run_configured_benchmark(json.loads(args.config.read_text(encoding="utf-8")))
|
||||
summary = format_summary(report)
|
||||
if args.json_out:
|
||||
args.json_out.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||
if args.summary_out:
|
||||
args.summary_out.write_text(summary + "\n", encoding="utf-8")
|
||||
print(summary)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover - CLI entry point
|
||||
raise SystemExit(main())
|
||||
473
packages/node/meshnet_node/recipe_drivers.py
Normal file
473
packages/node/meshnet_node/recipe_drivers.py
Normal file
@@ -0,0 +1,473 @@
|
||||
"""Real runtime drivers for the recipe benchmark.
|
||||
|
||||
This module is the only place that imports torch, transformers, or spawns a
|
||||
llama.cpp server, and :mod:`meshnet_node.recipe_benchmark` imports it lazily.
|
||||
That keeps the default test suite deterministic, GPU-free and download-free
|
||||
while the real evidence runs through exactly the same measurement core.
|
||||
|
||||
Fairness is the whole point of a baseline, so both drivers are held to the same
|
||||
rules:
|
||||
|
||||
* They are handed a **pre-formatted prompt string**. Neither applies a chat
|
||||
template, because a template applied twice — or differently — by two runtimes
|
||||
would show up as a speed and drift difference that has nothing to do with the
|
||||
runtime.
|
||||
* They are given the **same CPU thread budget**, so the comparison measures
|
||||
kernels rather than how many cores each runtime felt entitled to take.
|
||||
* They report the runtime's **own prefill/decode split** where it has one, and
|
||||
say so honestly where it does not.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import subprocess
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
from typing import Any, Mapping
|
||||
|
||||
from .recipe_benchmark import (
|
||||
BenchmarkError,
|
||||
BenchmarkPlan,
|
||||
GenerationSample,
|
||||
Lane,
|
||||
LoadStats,
|
||||
PromptSpec,
|
||||
RecipeSpec,
|
||||
SamplingPolicy,
|
||||
build_report,
|
||||
measure_recipe,
|
||||
)
|
||||
|
||||
REAL_INFERENCE_ENV = "MESHNET_ENABLE_REAL_INFERENCE_TESTS"
|
||||
|
||||
|
||||
def real_inference_enabled() -> bool:
|
||||
"""Real runtimes stay off unless the operator opts in explicitly."""
|
||||
return os.environ.get(REAL_INFERENCE_ENV) == "1"
|
||||
|
||||
|
||||
def require_real_inference() -> None:
|
||||
if not real_inference_enabled():
|
||||
raise BenchmarkError(
|
||||
f"real model execution is opt-in: set {REAL_INFERENCE_ENV}=1 to run this benchmark"
|
||||
)
|
||||
|
||||
|
||||
def _process_rss(pid: int | None = None) -> int:
|
||||
"""Resident bytes for a process and its children, or 0 when unobservable."""
|
||||
try:
|
||||
import psutil
|
||||
except ImportError:
|
||||
return 0
|
||||
try:
|
||||
process = psutil.Process(pid) if pid else psutil.Process()
|
||||
total = process.memory_info().rss
|
||||
for child in process.children(recursive=True):
|
||||
try:
|
||||
total += child.memory_info().rss
|
||||
except psutil.Error:
|
||||
continue
|
||||
return int(total)
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
|
||||
def _directory_bytes(path: Path) -> int:
|
||||
if path.is_file():
|
||||
return path.stat().st_size
|
||||
return sum(entry.stat().st_size for entry in path.rglob("*") if entry.is_file())
|
||||
|
||||
|
||||
class TransformersDriver:
|
||||
"""The current Transformers/safetensors recipe: the correctness reference.
|
||||
|
||||
Generation is a hand-written prefill-then-decode loop rather than
|
||||
``model.generate`` because the benchmark needs the two phases separated: one
|
||||
forward over the prompt gives an exact prefill time and TTFT, and the cached
|
||||
single-token steps that follow give an exact decode rate. ``generate`` would
|
||||
hand back one blended number.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
model_path: str,
|
||||
*,
|
||||
device: str = "cpu",
|
||||
dtype: str = "bfloat16",
|
||||
threads: int = 8,
|
||||
) -> None:
|
||||
self.model_path = Path(model_path)
|
||||
self.device = device
|
||||
self.dtype = dtype
|
||||
self.threads = threads
|
||||
self._model: Any = None
|
||||
self._tokenizer: Any = None
|
||||
self._torch: Any = None
|
||||
|
||||
def load(self) -> LoadStats:
|
||||
import torch
|
||||
from transformers import AutoModelForCausalLM, AutoTokenizer
|
||||
|
||||
self._torch = torch
|
||||
torch.set_num_threads(self.threads)
|
||||
torch.manual_seed(0)
|
||||
|
||||
started = time.monotonic()
|
||||
self._tokenizer = AutoTokenizer.from_pretrained(
|
||||
str(self.model_path), local_files_only=True
|
||||
)
|
||||
self._model = AutoModelForCausalLM.from_pretrained(
|
||||
str(self.model_path),
|
||||
dtype=getattr(torch, self.dtype),
|
||||
local_files_only=True,
|
||||
)
|
||||
self._model.to(self.device)
|
||||
self._model.eval()
|
||||
load_ms = (time.monotonic() - started) * 1000
|
||||
|
||||
return LoadStats(
|
||||
artifact_bytes=_directory_bytes(self.model_path),
|
||||
load_ms=round(load_ms, 4),
|
||||
rss_bytes=_process_rss(),
|
||||
vram_bytes=self._vram_bytes(),
|
||||
backend_detail=(
|
||||
f"torch {torch.__version__}; dtype {self.dtype}; "
|
||||
f"device {self.device}; intra-op threads {self.threads}"
|
||||
),
|
||||
)
|
||||
|
||||
def _vram_bytes(self) -> int:
|
||||
torch = self._torch
|
||||
if torch is None or self.device == "cpu":
|
||||
return 0
|
||||
try:
|
||||
if torch.cuda.is_available():
|
||||
return int(torch.cuda.max_memory_allocated())
|
||||
except Exception:
|
||||
return 0
|
||||
return 0
|
||||
|
||||
def generate(self, prompt: str, sampling: SamplingPolicy) -> GenerationSample:
|
||||
if self._model is None:
|
||||
raise BenchmarkError("TransformersDriver.generate called before load()")
|
||||
torch = self._torch
|
||||
|
||||
# add_special_tokens=False: the plan owns the prompt format, and the
|
||||
# llama.cpp recipe is given the identical string.
|
||||
encoded = self._tokenizer(prompt, return_tensors="pt", add_special_tokens=False)
|
||||
input_ids = encoded["input_ids"].to(self.device)
|
||||
prompt_tokens = int(input_ids.shape[-1])
|
||||
eos_ids = {self._tokenizer.eos_token_id} | set(
|
||||
getattr(self._model.generation_config, "eos_token_id", None) or []
|
||||
if isinstance(getattr(self._model.generation_config, "eos_token_id", None), list)
|
||||
else []
|
||||
)
|
||||
eos_ids.discard(None)
|
||||
|
||||
started = time.monotonic()
|
||||
with torch.inference_mode():
|
||||
outputs = self._model(input_ids=input_ids, use_cache=True)
|
||||
past = outputs.past_key_values
|
||||
next_id = self._select(outputs.logits[:, -1, :], sampling)
|
||||
ttft_ms = (time.monotonic() - started) * 1000
|
||||
|
||||
token_ids = [int(next_id.item())]
|
||||
decode_started = time.monotonic()
|
||||
while len(token_ids) < sampling.max_output_tokens and token_ids[-1] not in eos_ids:
|
||||
outputs = self._model(
|
||||
input_ids=next_id.view(1, 1), past_key_values=past, use_cache=True
|
||||
)
|
||||
past = outputs.past_key_values
|
||||
next_id = self._select(outputs.logits[:, -1, :], sampling)
|
||||
token_ids.append(int(next_id.item()))
|
||||
decode_ms = (time.monotonic() - decode_started) * 1000
|
||||
|
||||
total_ms = (time.monotonic() - started) * 1000
|
||||
emitted = [token for token in token_ids if token not in eos_ids]
|
||||
return GenerationSample(
|
||||
text=self._tokenizer.decode(emitted, skip_special_tokens=True),
|
||||
prompt_tokens=prompt_tokens,
|
||||
# The first token is produced by the prefill forward, so the decode
|
||||
# rate must not be credited with it.
|
||||
decode_tokens=max(0, len(token_ids) - 1),
|
||||
ttft_ms=ttft_ms,
|
||||
prefill_ms=ttft_ms,
|
||||
decode_ms=decode_ms,
|
||||
total_ms=total_ms,
|
||||
)
|
||||
|
||||
def _select(self, logits: Any, sampling: SamplingPolicy) -> Any:
|
||||
if sampling.temperature > 0:
|
||||
raise BenchmarkError(
|
||||
"this benchmark is greedy-only: sampling noise is indistinguishable from "
|
||||
"quantization drift, which is precisely what the quality lane must isolate"
|
||||
)
|
||||
return logits.argmax(dim=-1)
|
||||
|
||||
def memory_probe(self) -> tuple[int, int]:
|
||||
return _process_rss(), self._vram_bytes()
|
||||
|
||||
def close(self) -> None:
|
||||
self._model = None
|
||||
self._tokenizer = None
|
||||
if self._torch is not None:
|
||||
import gc
|
||||
|
||||
gc.collect()
|
||||
|
||||
|
||||
def _free_port() -> int:
|
||||
with socket.socket() as probe:
|
||||
probe.bind(("127.0.0.1", 0))
|
||||
return int(probe.getsockname()[1])
|
||||
|
||||
|
||||
class LlamaCppServerDriver:
|
||||
"""The whole-model llama.cpp/GGUF recipe, driven through ``llama-server``.
|
||||
|
||||
``llama-server`` is used rather than an in-process binding because it is the
|
||||
shape llama.cpp is actually deployed in and the only one that offers
|
||||
continuous batching across parallel slots — which is the runtime property
|
||||
this project cares about most. It also reports its own prefill/decode
|
||||
timings per request, so the decode rate is the runtime's own number and not
|
||||
an inference drawn from a client-side stopwatch.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
binary: str,
|
||||
gguf_path: str,
|
||||
*,
|
||||
device: str = "cpu",
|
||||
threads: int = 8,
|
||||
n_parallel: int = 4,
|
||||
context_per_slot: int = 1024,
|
||||
n_gpu_layers: int = 0,
|
||||
startup_timeout_s: float = 120.0,
|
||||
) -> None:
|
||||
self.binary = Path(binary)
|
||||
self.gguf_path = Path(gguf_path)
|
||||
self.device = device
|
||||
self.threads = threads
|
||||
self.n_parallel = n_parallel
|
||||
self.context_per_slot = context_per_slot
|
||||
self.n_gpu_layers = n_gpu_layers
|
||||
self.startup_timeout_s = startup_timeout_s
|
||||
self._process: subprocess.Popen | None = None
|
||||
self._port = 0
|
||||
self._log: Any = None
|
||||
|
||||
@property
|
||||
def _url(self) -> str:
|
||||
return f"http://127.0.0.1:{self._port}"
|
||||
|
||||
def load(self) -> LoadStats:
|
||||
if not self.binary.exists():
|
||||
raise BenchmarkError(f"llama-server binary not found at {self.binary}")
|
||||
if not self.gguf_path.exists():
|
||||
raise BenchmarkError(f"GGUF artifact not found at {self.gguf_path}")
|
||||
|
||||
self._port = _free_port()
|
||||
command = [
|
||||
str(self.binary),
|
||||
"--model", str(self.gguf_path),
|
||||
"--host", "127.0.0.1",
|
||||
"--port", str(self._port),
|
||||
"--threads", str(self.threads),
|
||||
"--parallel", str(self.n_parallel),
|
||||
# Every slot must hold a whole request, so the pool is sized for the
|
||||
# worst case rather than letting llama.cpp silently truncate context.
|
||||
"--ctx-size", str(self.context_per_slot * self.n_parallel),
|
||||
"--n-gpu-layers", str(self.n_gpu_layers),
|
||||
"--no-webui",
|
||||
]
|
||||
started = time.monotonic()
|
||||
self._log = subprocess.PIPE
|
||||
self._process = subprocess.Popen(
|
||||
command, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
|
||||
)
|
||||
self._await_health(started)
|
||||
load_ms = (time.monotonic() - started) * 1000
|
||||
|
||||
return LoadStats(
|
||||
artifact_bytes=self.gguf_path.stat().st_size,
|
||||
load_ms=round(load_ms, 4),
|
||||
rss_bytes=_process_rss(self._process.pid),
|
||||
vram_bytes=0,
|
||||
backend_detail=(
|
||||
f"llama-server; threads {self.threads}; parallel slots {self.n_parallel}; "
|
||||
f"ctx/slot {self.context_per_slot}; gpu layers {self.n_gpu_layers}"
|
||||
),
|
||||
)
|
||||
|
||||
def _await_health(self, started: float) -> None:
|
||||
while time.monotonic() - started < self.startup_timeout_s:
|
||||
if self._process is not None and self._process.poll() is not None:
|
||||
raise BenchmarkError(
|
||||
f"llama-server exited with code {self._process.returncode} during startup"
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(f"{self._url}/health", timeout=2) as response:
|
||||
if response.status == 200:
|
||||
return
|
||||
except (urllib.error.URLError, OSError):
|
||||
time.sleep(0.25)
|
||||
raise BenchmarkError(
|
||||
f"llama-server did not become healthy within {self.startup_timeout_s:.0f}s"
|
||||
)
|
||||
|
||||
def generate(self, prompt: str, sampling: SamplingPolicy) -> GenerationSample:
|
||||
if self._process is None:
|
||||
raise BenchmarkError("LlamaCppServerDriver.generate called before load()")
|
||||
if sampling.temperature > 0:
|
||||
raise BenchmarkError("this benchmark is greedy-only; see TransformersDriver._select")
|
||||
|
||||
body = json.dumps({
|
||||
"prompt": prompt,
|
||||
"n_predict": sampling.max_output_tokens,
|
||||
"temperature": 0.0,
|
||||
"top_k": 1,
|
||||
"top_p": 1.0,
|
||||
"seed": sampling.seed,
|
||||
# Prompt cache reuse across repeats would measure the cache, not the
|
||||
# prefill, and the safetensors recipe has no equivalent.
|
||||
"cache_prompt": False,
|
||||
"stream": True,
|
||||
}).encode()
|
||||
request = urllib.request.Request(
|
||||
f"{self._url}/completion", data=body,
|
||||
headers={"Content-Type": "application/json"}, method="POST",
|
||||
)
|
||||
|
||||
started = time.monotonic()
|
||||
ttft_ms = 0.0
|
||||
chunks: list[str] = []
|
||||
timings: Mapping[str, Any] = {}
|
||||
with urllib.request.urlopen(request, timeout=600) as response:
|
||||
for raw in response:
|
||||
line = raw.decode("utf-8").strip()
|
||||
if not line.startswith("data:"):
|
||||
continue
|
||||
payload = json.loads(line[len("data:"):].strip())
|
||||
content = payload.get("content", "")
|
||||
if content and not ttft_ms:
|
||||
ttft_ms = (time.monotonic() - started) * 1000
|
||||
chunks.append(content)
|
||||
if payload.get("stop"):
|
||||
timings = payload.get("timings") or {}
|
||||
total_ms = (time.monotonic() - started) * 1000
|
||||
|
||||
if not timings:
|
||||
raise BenchmarkError("llama-server returned no timings; cannot report an honest split")
|
||||
|
||||
prefill_ms = float(timings.get("prompt_ms", 0.0))
|
||||
decode_ms = float(timings.get("predicted_ms", 0.0))
|
||||
return GenerationSample(
|
||||
text="".join(chunks),
|
||||
prompt_tokens=int(timings.get("prompt_n", 0)),
|
||||
decode_tokens=int(timings.get("predicted_n", 0)),
|
||||
ttft_ms=ttft_ms or total_ms,
|
||||
prefill_ms=prefill_ms,
|
||||
decode_ms=decode_ms,
|
||||
total_ms=total_ms,
|
||||
# Whatever the wall clock saw but the runtime did not attribute to
|
||||
# compute is time this request spent waiting for a slot.
|
||||
queue_wait_ms=max(0.0, total_ms - prefill_ms - decode_ms),
|
||||
)
|
||||
|
||||
def memory_probe(self) -> tuple[int, int]:
|
||||
if self._process is None:
|
||||
return 0, 0
|
||||
return _process_rss(self._process.pid), 0
|
||||
|
||||
def close(self) -> None:
|
||||
if self._process is None:
|
||||
return
|
||||
self._process.terminate()
|
||||
try:
|
||||
self._process.wait(timeout=20)
|
||||
except subprocess.TimeoutExpired:
|
||||
self._process.kill()
|
||||
self._process.wait(timeout=10)
|
||||
self._process = None
|
||||
|
||||
|
||||
def build_driver(spec: Mapping[str, Any], plan: BenchmarkPlan) -> RecipeDriverBundle:
|
||||
"""Construct the driver named by a recipe's ``driver`` block."""
|
||||
driver_spec = dict(spec["driver"])
|
||||
kind = driver_spec.pop("type")
|
||||
if kind == "transformers":
|
||||
return TransformersDriver(**driver_spec)
|
||||
if kind == "llama-cpp-server":
|
||||
driver_spec.setdefault("n_parallel", max(plan.concurrency_levels))
|
||||
return LlamaCppServerDriver(**driver_spec)
|
||||
raise BenchmarkError(f"unknown driver type {kind!r}")
|
||||
|
||||
|
||||
RecipeDriverBundle = Any # a RecipeDriver; named for readability at the call site
|
||||
|
||||
|
||||
def _plan_from_config(config: Mapping[str, Any]) -> BenchmarkPlan:
|
||||
raw = config["plan"]
|
||||
return BenchmarkPlan(
|
||||
plan_id=raw["plan_id"],
|
||||
model_id=raw["model_id"],
|
||||
model_revision=raw["model_revision"],
|
||||
prompts=tuple(PromptSpec(**prompt) for prompt in raw["prompts"]),
|
||||
sampling=SamplingPolicy(**raw.get("sampling", {})),
|
||||
concurrency_levels=tuple(raw.get("concurrency_levels", (1, 4))),
|
||||
repeats=int(raw.get("repeats", 1)),
|
||||
warmup_requests=int(raw.get("warmup_requests", 1)),
|
||||
)
|
||||
|
||||
|
||||
def _recipe_from_config(spec: Mapping[str, Any]) -> RecipeSpec:
|
||||
return RecipeSpec(
|
||||
id=spec["id"],
|
||||
runtime=spec["runtime"],
|
||||
weight_format=spec["weight_format"],
|
||||
weight_quantization=spec["weight_quantization"],
|
||||
lane=Lane(spec["lane"]),
|
||||
device=spec["device"],
|
||||
artifact_path=spec.get("artifact_path", ""),
|
||||
is_reference=bool(spec.get("is_reference", False)),
|
||||
notes=spec.get("notes", ""),
|
||||
)
|
||||
|
||||
|
||||
def run_configured_benchmark(config: Mapping[str, Any]) -> dict:
|
||||
"""Run every recipe in ``config`` against one shared plan and return the report.
|
||||
|
||||
A recipe whose runtime cannot start is recorded as unavailable with the real
|
||||
reason rather than dropped: a report that silently omits the recipe that
|
||||
crashed would read as a clean result.
|
||||
"""
|
||||
require_real_inference()
|
||||
plan = _plan_from_config(config)
|
||||
|
||||
from .recipe_benchmark import RecipeMeasurement # local import keeps the seam obvious
|
||||
|
||||
measurements = []
|
||||
for spec in config["recipes"]:
|
||||
recipe = _recipe_from_config(spec)
|
||||
try:
|
||||
driver = build_driver(spec, plan)
|
||||
measurements.append(measure_recipe(driver, recipe, plan))
|
||||
except Exception as exc:
|
||||
measurements.append(RecipeMeasurement(
|
||||
recipe=recipe,
|
||||
load=LoadStats(artifact_bytes=0, load_ms=0.0),
|
||||
unavailable_reason=f"{type(exc).__name__}: {exc}",
|
||||
))
|
||||
|
||||
return build_report(
|
||||
plan,
|
||||
measurements,
|
||||
host=dict(config.get("host", {})),
|
||||
evidence_class=config.get("evidence_class", "local-real"),
|
||||
)
|
||||
Reference in New Issue
Block a user