quantcost 0.2.0__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.
- edgellm/__init__.py +7 -0
- edgellm/base.py +44 -0
- edgellm/benchmark.py +226 -0
- edgellm/card.py +234 -0
- edgellm/cli.py +370 -0
- edgellm/cli_bench.py +195 -0
- edgellm/config.py +120 -0
- edgellm/data/SOURCE.md +17 -0
- edgellm/data/eval_wikitext2.txt +205 -0
- edgellm/eval_lite.py +128 -0
- edgellm/export.py +65 -0
- edgellm/hub.py +125 -0
- edgellm/leaderboard.py +210 -0
- edgellm/models.py +91 -0
- edgellm/ort_lite.py +236 -0
- edgellm/quantize.py +134 -0
- edgellm/render.py +164 -0
- edgellm/report.py +53 -0
- edgellm/runners.py +170 -0
- edgellm/submit.py +225 -0
- edgellm/sweep.py +309 -0
- edgellm/validate.py +274 -0
- quantcost-0.2.0.dist-info/METADATA +265 -0
- quantcost-0.2.0.dist-info/RECORD +27 -0
- quantcost-0.2.0.dist-info/WHEEL +4 -0
- quantcost-0.2.0.dist-info/entry_points.txt +3 -0
- quantcost-0.2.0.dist-info/licenses/LICENSE +21 -0
edgellm/quantize.py
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""Quantization: PyTorch INT8 and ONNX Runtime INT8 / INT4.
|
|
2
|
+
|
|
3
|
+
Plain-English summary of what happens here (the README expands on this):
|
|
4
|
+
|
|
5
|
+
* **INT8 (8-bit)** stores each weight as an 8-bit integer plus a floating-point
|
|
6
|
+
*scale*, cutting size ~4x vs FP32. **Dynamic** quantization keeps activations
|
|
7
|
+
in float and quantizes weights only; per-token activation scales are computed
|
|
8
|
+
on the fly at inference (no calibration data needed) — the robust default for
|
|
9
|
+
transformers.
|
|
10
|
+
* **INT4 (4-bit)** packs weights into 4 bits using *block-wise* quantization:
|
|
11
|
+
each contiguous block of ``block_size`` weights shares one scale (and, when
|
|
12
|
+
asymmetric, a zero-point). Smaller blocks = better accuracy, more overhead.
|
|
13
|
+
|
|
14
|
+
On CUDA hardware, GPTQ/AWQ give higher-quality INT4; they are unavailable on
|
|
15
|
+
this Apple-Silicon machine (both require CUDA), so the INT4 path here uses ONNX
|
|
16
|
+
Runtime's block-wise weight-only quantizer, which runs on CPU. See
|
|
17
|
+
:meth:`Quantizer.int4_availability` for the honest state of each backend.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import logging
|
|
23
|
+
import shutil
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
from edgellm.config import QuantizeConfig
|
|
27
|
+
|
|
28
|
+
logger = logging.getLogger(__name__)
|
|
29
|
+
|
|
30
|
+
# Non-weight files copied alongside a quantized model.onnx so an ORT runner can
|
|
31
|
+
# load the directory standalone (tokenizer, config, chat template, ...).
|
|
32
|
+
_SIDE_CAR_GLOBS = (
|
|
33
|
+
"*.json",
|
|
34
|
+
"*.txt",
|
|
35
|
+
"*.jinja",
|
|
36
|
+
"*.model",
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _copy_sidecars(src_dir: Path, dst_dir: Path) -> None:
|
|
41
|
+
dst_dir.mkdir(parents=True, exist_ok=True)
|
|
42
|
+
for pattern in _SIDE_CAR_GLOBS:
|
|
43
|
+
for f in src_dir.glob(pattern):
|
|
44
|
+
if f.is_file():
|
|
45
|
+
shutil.copy2(f, dst_dir / f.name)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class Quantizer:
|
|
49
|
+
"""Produce quantized model artifacts from an exported FP32 ONNX directory."""
|
|
50
|
+
|
|
51
|
+
def __init__(self, config: QuantizeConfig) -> None:
|
|
52
|
+
self.config = config
|
|
53
|
+
|
|
54
|
+
# --- ONNX Runtime INT8 (dynamic, weight-only) ---
|
|
55
|
+
def ort_dynamic_int8(self, fp32_dir: Path, out_dir: Path) -> Path:
|
|
56
|
+
"""Dynamically quantize ONNX weights to INT8. Returns the output dir."""
|
|
57
|
+
from onnxruntime.quantization import QuantType, quantize_dynamic
|
|
58
|
+
|
|
59
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
60
|
+
model_out = out_dir / "model.onnx"
|
|
61
|
+
logger.info("ORT dynamic INT8: %s -> %s", fp32_dir, model_out)
|
|
62
|
+
quantize_dynamic(
|
|
63
|
+
model_input=str(fp32_dir / "model.onnx"),
|
|
64
|
+
model_output=str(model_out),
|
|
65
|
+
weight_type=QuantType.QInt8,
|
|
66
|
+
per_channel=self.config.int8.per_channel,
|
|
67
|
+
)
|
|
68
|
+
_copy_sidecars(fp32_dir, out_dir)
|
|
69
|
+
return out_dir
|
|
70
|
+
|
|
71
|
+
# --- ONNX Runtime INT4 (block-wise, weight-only) ---
|
|
72
|
+
def ort_int4(self, fp32_dir: Path, out_dir: Path) -> Path:
|
|
73
|
+
"""Block-wise weight-only INT4 quantization (native ORT quantizer).
|
|
74
|
+
|
|
75
|
+
The model is passed *by path* so ONNX Runtime streams the >2GB external
|
|
76
|
+
data instead of requiring it all in a single in-memory ModelProto.
|
|
77
|
+
"""
|
|
78
|
+
from onnxruntime.quantization.matmul_nbits_quantizer import MatMulNBitsQuantizer
|
|
79
|
+
|
|
80
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
81
|
+
model_out = out_dir / "model.onnx"
|
|
82
|
+
logger.info("ORT INT4 (block_size=%d): %s", self.config.int4.group_size, model_out)
|
|
83
|
+
|
|
84
|
+
quantizer = MatMulNBitsQuantizer(
|
|
85
|
+
str(fp32_dir / "model.onnx"),
|
|
86
|
+
block_size=self.config.int4.group_size,
|
|
87
|
+
is_symmetric=True,
|
|
88
|
+
)
|
|
89
|
+
quantizer.process()
|
|
90
|
+
quantizer.model.save_model_to_file(str(model_out), use_external_data_format=False)
|
|
91
|
+
_copy_sidecars(fp32_dir, out_dir)
|
|
92
|
+
return out_dir
|
|
93
|
+
|
|
94
|
+
# --- PyTorch INT8 (dynamic) ---
|
|
95
|
+
@staticmethod
|
|
96
|
+
def pytorch_dynamic_int8(model):
|
|
97
|
+
"""Dynamically quantize ``nn.Linear`` layers of a torch model to INT8 (CPU).
|
|
98
|
+
|
|
99
|
+
Selects the ``qnnpack`` engine, which is the INT8 kernel backend available
|
|
100
|
+
on ARM (Apple Silicon); x86 would use ``fbgemm``.
|
|
101
|
+
"""
|
|
102
|
+
import torch
|
|
103
|
+
from torch.ao.quantization import quantize_dynamic
|
|
104
|
+
|
|
105
|
+
engines = torch.backends.quantized.supported_engines
|
|
106
|
+
if "qnnpack" in engines:
|
|
107
|
+
torch.backends.quantized.engine = "qnnpack"
|
|
108
|
+
elif "fbgemm" in engines:
|
|
109
|
+
torch.backends.quantized.engine = "fbgemm"
|
|
110
|
+
|
|
111
|
+
model = model.to("cpu").eval()
|
|
112
|
+
return quantize_dynamic(model, {torch.nn.Linear}, dtype=torch.qint8)
|
|
113
|
+
|
|
114
|
+
@staticmethod
|
|
115
|
+
def int4_availability() -> dict[str, str]:
|
|
116
|
+
"""Report which INT4 backends can actually run on this machine."""
|
|
117
|
+
import importlib.util
|
|
118
|
+
|
|
119
|
+
import torch
|
|
120
|
+
|
|
121
|
+
cuda = torch.cuda.is_available()
|
|
122
|
+
return {
|
|
123
|
+
"gptq (auto-gptq)": (
|
|
124
|
+
"available"
|
|
125
|
+
if (cuda and importlib.util.find_spec("auto_gptq"))
|
|
126
|
+
else "unavailable (needs CUDA)"
|
|
127
|
+
),
|
|
128
|
+
"awq (autoawq)": (
|
|
129
|
+
"available"
|
|
130
|
+
if (cuda and importlib.util.find_spec("awq"))
|
|
131
|
+
else "unavailable (needs CUDA)"
|
|
132
|
+
),
|
|
133
|
+
"onnxruntime block-wise INT4": "available (CPU)",
|
|
134
|
+
}
|
edgellm/render.py
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""Turn a result card into the thing a person actually wanted: a tradeoff table.
|
|
2
|
+
|
|
3
|
+
Raw per-precision numbers are not the answer to "should I quantize this?". The
|
|
4
|
+
answer is *relative*: how much faster, how much smaller, how much worse. So every
|
|
5
|
+
view here is anchored to a baseline precision and reports deltas, with the
|
|
6
|
+
absolute numbers kept alongside so nothing is hidden.
|
|
7
|
+
|
|
8
|
+
The verdict line exists because the honest answer is frequently "don't" — INT4 on
|
|
9
|
+
a CPU without native 4-bit kernels routinely dequantizes to fp32 inside the GEMM
|
|
10
|
+
and comes out slower than the baseline it was supposed to beat. A tool that
|
|
11
|
+
prints numbers but will not say that is making the reader do the work.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from edgellm.card import PrecisionRow, ResultCard
|
|
17
|
+
|
|
18
|
+
BASELINE = "fp32"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _fmt(value: float | None, spec: str = ".1f", dash: str = "—") -> str:
|
|
22
|
+
return dash if value is None else format(value, spec)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _signed(value: float, spec: str = ".1f") -> str:
|
|
26
|
+
"""Format with an explicit sign, so a quality *improvement* reads as negative."""
|
|
27
|
+
return format(value, f"+{spec}")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def tradeoff_table(card: ResultCard, *, baseline: str = BASELINE) -> str:
|
|
31
|
+
"""A Markdown table of absolute numbers plus deltas against the baseline."""
|
|
32
|
+
by_precision = {r.precision: r for r in card.rows}
|
|
33
|
+
base = by_precision.get(baseline)
|
|
34
|
+
|
|
35
|
+
header = (
|
|
36
|
+
"| Precision | Size (MB) | Throughput (tok/s) | Speedup | "
|
|
37
|
+
"Peak RAM (MB) | Perplexity | PPL change |\n"
|
|
38
|
+
"| --- | --- | --- | --- | --- | --- | --- |\n"
|
|
39
|
+
)
|
|
40
|
+
lines = []
|
|
41
|
+
for row in card.rows:
|
|
42
|
+
speedup = "baseline" if row.precision == baseline else "—"
|
|
43
|
+
ppl_delta = "baseline" if row.precision == baseline else "—"
|
|
44
|
+
|
|
45
|
+
if base and row.precision != baseline:
|
|
46
|
+
if base.tokens_per_second > 0:
|
|
47
|
+
ratio = row.tokens_per_second / base.tokens_per_second
|
|
48
|
+
speedup = f"{ratio:.2f}x"
|
|
49
|
+
if base.perplexity and row.perplexity:
|
|
50
|
+
pct = (row.perplexity / base.perplexity - 1) * 100
|
|
51
|
+
ppl_delta = f"{_signed(pct)}%"
|
|
52
|
+
|
|
53
|
+
lines.append(
|
|
54
|
+
f"| {row.precision} | {_fmt(row.size_mb, '.0f')} | "
|
|
55
|
+
f"{_fmt(row.tokens_per_second, '.2f')} | {speedup} | "
|
|
56
|
+
f"{_fmt(row.peak_ram_mb, '.0f')} | {_fmt(row.perplexity, '.2f')} | {ppl_delta} |"
|
|
57
|
+
)
|
|
58
|
+
return header + "\n".join(lines) + "\n"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def unstable_rows(card: ResultCard, *, ratio: float = 0.15) -> list[str]:
|
|
62
|
+
"""Precisions whose run-to-run spread is too wide for the number to be trusted."""
|
|
63
|
+
return [
|
|
64
|
+
row.precision
|
|
65
|
+
for row in card.rows
|
|
66
|
+
if row.latency_s_mean > 0 and row.latency_s_std / row.latency_s_mean > ratio
|
|
67
|
+
]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def verdict(card: ResultCard, *, baseline: str = BASELINE) -> list[str]:
|
|
71
|
+
"""Plain-language findings. Says 'not worth it' when the numbers say so."""
|
|
72
|
+
by_precision = {r.precision: r for r in card.rows}
|
|
73
|
+
base = by_precision.get(baseline)
|
|
74
|
+
if base is None:
|
|
75
|
+
return [f"No {baseline} baseline was measured, so nothing can be compared against it."]
|
|
76
|
+
|
|
77
|
+
findings: list[str] = []
|
|
78
|
+
candidates = [r for r in card.rows if r.precision != baseline]
|
|
79
|
+
|
|
80
|
+
def shrink(row: PrecisionRow) -> float:
|
|
81
|
+
return base.size_mb / row.size_mb if row.size_mb else 0.0
|
|
82
|
+
|
|
83
|
+
for row in candidates:
|
|
84
|
+
ratio = row.tokens_per_second / base.tokens_per_second if base.tokens_per_second else 0.0
|
|
85
|
+
size_x = shrink(row)
|
|
86
|
+
bits = [f"{size_x:.1f}x smaller"] if size_x > 1.05 else []
|
|
87
|
+
|
|
88
|
+
if ratio >= 1.15:
|
|
89
|
+
bits.append(f"{ratio:.2f}x faster")
|
|
90
|
+
elif ratio <= 0.9:
|
|
91
|
+
bits.append(f"**{1 / ratio:.2f}x slower**")
|
|
92
|
+
else:
|
|
93
|
+
bits.append("about the same speed")
|
|
94
|
+
|
|
95
|
+
if base.perplexity and row.perplexity:
|
|
96
|
+
pct = (row.perplexity / base.perplexity - 1) * 100
|
|
97
|
+
if pct > 10:
|
|
98
|
+
bits.append(f"and {pct:.0f}% worse perplexity")
|
|
99
|
+
elif pct > 2:
|
|
100
|
+
bits.append(f"for {pct:.0f}% worse perplexity")
|
|
101
|
+
else:
|
|
102
|
+
bits.append("at essentially no quality cost")
|
|
103
|
+
|
|
104
|
+
findings.append(f"- **{row.precision}**: " + ", ".join(bits) + ".")
|
|
105
|
+
|
|
106
|
+
base_tps = base.tokens_per_second
|
|
107
|
+
faster = [r for r in candidates if base_tps and r.tokens_per_second > base_tps * 1.15]
|
|
108
|
+
slower = [r for r in candidates if base_tps and r.tokens_per_second < base_tps * 0.9]
|
|
109
|
+
|
|
110
|
+
if slower:
|
|
111
|
+
names = ", ".join(r.precision for r in slower)
|
|
112
|
+
verb = "is" if len(slower) == 1 else "are"
|
|
113
|
+
findings.append(
|
|
114
|
+
f"- On this machine **{names} {verb} slower than {baseline}** despite being smaller. "
|
|
115
|
+
"This is a property of *autoregressive decode*, not of quantized arithmetic: "
|
|
116
|
+
"ONNX Runtime unpacks the weights back to float inside each step, and a single "
|
|
117
|
+
"token cannot amortise unpacking a whole weight matrix. With a long prompt to "
|
|
118
|
+
"amortise over, the same artifacts often come out faster than the baseline. "
|
|
119
|
+
"What that means in practice: quantize here to fit in memory, not to speed up "
|
|
120
|
+
"generation."
|
|
121
|
+
)
|
|
122
|
+
if faster:
|
|
123
|
+
best = max(faster, key=lambda r: r.tokens_per_second)
|
|
124
|
+
findings.append(
|
|
125
|
+
f"- Best throughput on this machine is **{best.precision}** at "
|
|
126
|
+
f"{best.tokens_per_second:.1f} tok/s."
|
|
127
|
+
)
|
|
128
|
+
return findings
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def console_report(card: ResultCard, errors: dict[str, str] | None = None) -> str:
|
|
132
|
+
"""The full human-facing report printed after a run."""
|
|
133
|
+
machine = card.machine
|
|
134
|
+
measured = card.rows[0].measured_runs if card.rows else 0
|
|
135
|
+
out = [
|
|
136
|
+
"",
|
|
137
|
+
f" model {card.model_id} @ {card.revision}",
|
|
138
|
+
f" machine {machine.cpu} ({machine.arch}), {machine.physical_cores} physical cores, "
|
|
139
|
+
f"{machine.ram_gb:.0f} GB RAM",
|
|
140
|
+
f" software {machine.os} {machine.os_release} · Python {machine.python} · "
|
|
141
|
+
f"onnxruntime {machine.onnxruntime} · {machine.provider}",
|
|
142
|
+
f" settings {machine.intra_op_threads} threads ({machine.thread_policy}) · "
|
|
143
|
+
f"{card.gen_tokens} generated tokens · "
|
|
144
|
+
f"{card.warmup_runs} warmup + {measured} measured runs"
|
|
145
|
+
+ (f" · {card.eval_windows} perplexity windows" if card.eval_windows else ""),
|
|
146
|
+
"",
|
|
147
|
+
tradeoff_table(card),
|
|
148
|
+
]
|
|
149
|
+
findings = verdict(card)
|
|
150
|
+
if findings:
|
|
151
|
+
out += ["What this means on this machine:", ""] + findings + [""]
|
|
152
|
+
|
|
153
|
+
if unstable := unstable_rows(card):
|
|
154
|
+
out += [
|
|
155
|
+
"Measurement warning:",
|
|
156
|
+
"",
|
|
157
|
+
f"- {', '.join(unstable)} varied by more than 15% between runs, so these "
|
|
158
|
+
"numbers are noisy. Close other applications and re-run before submitting — "
|
|
159
|
+
"a busy machine makes the comparison between precisions meaningless.",
|
|
160
|
+
"",
|
|
161
|
+
]
|
|
162
|
+
if errors:
|
|
163
|
+
out += ["Skipped:"] + [f"- {p}: {why}" for p, why in errors.items()] + [""]
|
|
164
|
+
return "\n".join(out)
|
edgellm/report.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Turn the benchmark JSON into charts (and, in Phase 6, the full report)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _label(row: dict) -> str:
|
|
10
|
+
return f"{row['backend']}\n{row['precision']}/{row['device']}"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def render_charts(results_json: Path, out_path: Path) -> Path:
|
|
14
|
+
"""Render size / throughput / perplexity bar charts to a single PNG."""
|
|
15
|
+
import matplotlib
|
|
16
|
+
|
|
17
|
+
matplotlib.use("Agg") # headless: no display needed
|
|
18
|
+
import matplotlib.pyplot as plt
|
|
19
|
+
|
|
20
|
+
rows = json.loads(results_json.read_text())
|
|
21
|
+
labels = [_label(r) for r in rows]
|
|
22
|
+
|
|
23
|
+
panels = [
|
|
24
|
+
("On-disk size (MB)", [r.get("size_mb") for r in rows], "#4C72B0"),
|
|
25
|
+
("Throughput (tok/s)", [r.get("tokens_per_second") for r in rows], "#55A868"),
|
|
26
|
+
("Perplexity (lower=better)", [r.get("perplexity") for r in rows], "#C44E52"),
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
fig, axes = plt.subplots(1, 3, figsize=(15, 5))
|
|
30
|
+
for ax, (title, values, color) in zip(axes, panels, strict=False):
|
|
31
|
+
xs = list(range(len(labels)))
|
|
32
|
+
vals = [v if v is not None else 0 for v in values]
|
|
33
|
+
bars = ax.bar(xs, vals, color=color)
|
|
34
|
+
ax.set_title(title)
|
|
35
|
+
ax.set_xticks(xs)
|
|
36
|
+
ax.set_xticklabels(labels, fontsize=8)
|
|
37
|
+
for bar, original in zip(bars, values, strict=False):
|
|
38
|
+
text = "n/a" if original is None else f"{original:g}"
|
|
39
|
+
ax.text(
|
|
40
|
+
bar.get_x() + bar.get_width() / 2,
|
|
41
|
+
bar.get_height(),
|
|
42
|
+
text,
|
|
43
|
+
ha="center",
|
|
44
|
+
va="bottom",
|
|
45
|
+
fontsize=8,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
fig.suptitle("EdgeLLM benchmark (real runs)", fontsize=13)
|
|
49
|
+
fig.tight_layout()
|
|
50
|
+
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
51
|
+
fig.savefig(out_path, dpi=120)
|
|
52
|
+
plt.close(fig)
|
|
53
|
+
return out_path
|
edgellm/runners.py
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""Inference backends.
|
|
2
|
+
|
|
3
|
+
Every backend implements the same :class:`InferenceRunner` interface so the
|
|
4
|
+
benchmark harness can treat PyTorch, ONNX Runtime (CPU/GPU) and the Qualcomm QNN
|
|
5
|
+
NPU path identically. Phase 1 ships the PyTorch backend; later phases add the
|
|
6
|
+
ONNX Runtime and QNN runners.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import time
|
|
12
|
+
|
|
13
|
+
import torch
|
|
14
|
+
|
|
15
|
+
from edgellm.base import GenerationResult, InferenceRunner
|
|
16
|
+
from edgellm.config import GenerationConfig
|
|
17
|
+
from edgellm.models import LoadedModel
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"GenerationResult",
|
|
21
|
+
"InferenceRunner",
|
|
22
|
+
"PyTorchRunner",
|
|
23
|
+
"ORTRunner",
|
|
24
|
+
"QNNRunner",
|
|
25
|
+
"encode_prompt",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def encode_prompt(tokenizer, prompt: str) -> dict[str, torch.Tensor]:
|
|
30
|
+
"""Turn a prompt into model inputs (CPU tensors, not yet placed on a device).
|
|
31
|
+
|
|
32
|
+
Uses the chat template for instruct models; falls back to plain tokenization
|
|
33
|
+
for base models. Shared by every backend so all runners see identical inputs.
|
|
34
|
+
"""
|
|
35
|
+
if tokenizer.chat_template:
|
|
36
|
+
messages = [{"role": "user", "content": prompt}]
|
|
37
|
+
return tokenizer.apply_chat_template(
|
|
38
|
+
messages,
|
|
39
|
+
add_generation_prompt=True,
|
|
40
|
+
return_tensors="pt",
|
|
41
|
+
return_dict=True,
|
|
42
|
+
)
|
|
43
|
+
return dict(tokenizer(prompt, return_tensors="pt"))
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class PyTorchRunner(InferenceRunner):
|
|
47
|
+
"""Eager PyTorch generation — the correctness/quality reference backend."""
|
|
48
|
+
|
|
49
|
+
name = "pytorch"
|
|
50
|
+
|
|
51
|
+
def __init__(self, loaded: LoadedModel) -> None:
|
|
52
|
+
self.loaded = loaded
|
|
53
|
+
|
|
54
|
+
def _build_inputs(self, prompt: str) -> dict[str, torch.Tensor]:
|
|
55
|
+
"""Encode ``prompt`` and place the tensors on the model's device."""
|
|
56
|
+
inputs = encode_prompt(self.loaded.tokenizer, prompt)
|
|
57
|
+
return {k: v.to(self.loaded.device) for k, v in inputs.items()}
|
|
58
|
+
|
|
59
|
+
@torch.inference_mode()
|
|
60
|
+
def generate(self, prompt: str, generation: GenerationConfig) -> GenerationResult:
|
|
61
|
+
torch.manual_seed(generation.seed)
|
|
62
|
+
tokenizer = self.loaded.tokenizer
|
|
63
|
+
inputs = self._build_inputs(prompt)
|
|
64
|
+
prompt_tokens = int(inputs["input_ids"].shape[-1])
|
|
65
|
+
|
|
66
|
+
start = time.perf_counter()
|
|
67
|
+
output_ids = self.loaded.model.generate(
|
|
68
|
+
**inputs,
|
|
69
|
+
max_new_tokens=generation.max_new_tokens,
|
|
70
|
+
min_new_tokens=generation.min_new_tokens,
|
|
71
|
+
do_sample=generation.do_sample,
|
|
72
|
+
temperature=generation.temperature,
|
|
73
|
+
top_p=generation.top_p,
|
|
74
|
+
pad_token_id=tokenizer.pad_token_id,
|
|
75
|
+
)
|
|
76
|
+
if self.loaded.device == "mps":
|
|
77
|
+
torch.mps.synchronize()
|
|
78
|
+
elif self.loaded.device == "cuda":
|
|
79
|
+
torch.cuda.synchronize()
|
|
80
|
+
latency_s = time.perf_counter() - start
|
|
81
|
+
|
|
82
|
+
new_ids = output_ids[0, prompt_tokens:]
|
|
83
|
+
generated_tokens = int(new_ids.shape[-1])
|
|
84
|
+
text = tokenizer.decode(new_ids, skip_special_tokens=True)
|
|
85
|
+
tokens_per_second = generated_tokens / latency_s if latency_s > 0 else 0.0
|
|
86
|
+
|
|
87
|
+
return GenerationResult(
|
|
88
|
+
backend=self.name,
|
|
89
|
+
prompt=prompt,
|
|
90
|
+
text=text,
|
|
91
|
+
prompt_tokens=prompt_tokens,
|
|
92
|
+
generated_tokens=generated_tokens,
|
|
93
|
+
latency_s=latency_s,
|
|
94
|
+
tokens_per_second=tokens_per_second,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class ORTRunner(InferenceRunner):
|
|
99
|
+
"""ONNX Runtime backend (loads an exported/quantized ONNX model directory).
|
|
100
|
+
|
|
101
|
+
Used for the FP32 ONNX baseline (Phase 2) and every quantized ONNX model
|
|
102
|
+
(Phase 3). The execution provider selects the hardware: ``CPUExecutionProvider``
|
|
103
|
+
for CPU, ``CoreMLExecutionProvider`` on Apple, ``QNNExecutionProvider`` for the
|
|
104
|
+
Qualcomm NPU (Phase 5).
|
|
105
|
+
"""
|
|
106
|
+
|
|
107
|
+
def __init__(
|
|
108
|
+
self,
|
|
109
|
+
model_dir: str,
|
|
110
|
+
tokenizer,
|
|
111
|
+
provider: str = "CPUExecutionProvider",
|
|
112
|
+
name: str = "ort-cpu",
|
|
113
|
+
) -> None:
|
|
114
|
+
import onnxruntime as ort
|
|
115
|
+
from optimum.onnxruntime import ORTModelForCausalLM
|
|
116
|
+
|
|
117
|
+
available = ort.get_available_providers()
|
|
118
|
+
if provider not in available:
|
|
119
|
+
raise RuntimeError(
|
|
120
|
+
f"Execution provider '{provider}' is not available in this ONNX "
|
|
121
|
+
f"Runtime build (have: {available}). For the Qualcomm NPU, install "
|
|
122
|
+
"onnxruntime-qnn on a Snapdragon host, or use aihub/run_on_snapdragon.py."
|
|
123
|
+
)
|
|
124
|
+
self.name = name
|
|
125
|
+
self.tokenizer = tokenizer
|
|
126
|
+
self.model = ORTModelForCausalLM.from_pretrained(model_dir, provider=provider)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class QNNRunner(ORTRunner):
|
|
130
|
+
"""ONNX Runtime QNN Execution Provider runner (Qualcomm NPU).
|
|
131
|
+
|
|
132
|
+
Requires an ``onnxruntime-qnn`` build on a Snapdragon host; on other platforms
|
|
133
|
+
construction raises a clear error. For laptop-side profiling of real Snapdragon
|
|
134
|
+
hardware without a device in hand, use ``aihub/run_on_snapdragon.py`` instead.
|
|
135
|
+
"""
|
|
136
|
+
|
|
137
|
+
def __init__(self, model_dir: str, tokenizer, name: str = "ort-qnn") -> None:
|
|
138
|
+
super().__init__(model_dir, tokenizer, provider="QNNExecutionProvider", name=name)
|
|
139
|
+
|
|
140
|
+
def generate(self, prompt: str, generation: GenerationConfig) -> GenerationResult:
|
|
141
|
+
torch.manual_seed(generation.seed)
|
|
142
|
+
inputs = encode_prompt(self.tokenizer, prompt)
|
|
143
|
+
prompt_tokens = int(inputs["input_ids"].shape[-1])
|
|
144
|
+
|
|
145
|
+
start = time.perf_counter()
|
|
146
|
+
output_ids = self.model.generate(
|
|
147
|
+
**inputs,
|
|
148
|
+
max_new_tokens=generation.max_new_tokens,
|
|
149
|
+
min_new_tokens=generation.min_new_tokens,
|
|
150
|
+
do_sample=generation.do_sample,
|
|
151
|
+
temperature=generation.temperature,
|
|
152
|
+
top_p=generation.top_p,
|
|
153
|
+
pad_token_id=self.tokenizer.pad_token_id,
|
|
154
|
+
)
|
|
155
|
+
latency_s = time.perf_counter() - start
|
|
156
|
+
|
|
157
|
+
new_ids = output_ids[0, prompt_tokens:]
|
|
158
|
+
generated_tokens = int(new_ids.shape[-1])
|
|
159
|
+
text = self.tokenizer.decode(new_ids, skip_special_tokens=True)
|
|
160
|
+
tokens_per_second = generated_tokens / latency_s if latency_s > 0 else 0.0
|
|
161
|
+
|
|
162
|
+
return GenerationResult(
|
|
163
|
+
backend=self.name,
|
|
164
|
+
prompt=prompt,
|
|
165
|
+
text=text,
|
|
166
|
+
prompt_tokens=prompt_tokens,
|
|
167
|
+
generated_tokens=generated_tokens,
|
|
168
|
+
latency_s=latency_s,
|
|
169
|
+
tokens_per_second=tokens_per_second,
|
|
170
|
+
)
|