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/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
+ )