zeroquantz 0.1.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.
Files changed (105) hide show
  1. zeroquantz/__init__.py +14 -0
  2. zeroquantz/__main__.py +8 -0
  3. zeroquantz/agent/__init__.py +16 -0
  4. zeroquantz/agent/dispatcher.py +520 -0
  5. zeroquantz/agent/intents.py +46 -0
  6. zeroquantz/agent/parser.py +255 -0
  7. zeroquantz/benchmark/__init__.py +7 -0
  8. zeroquantz/benchmark/latency.py +66 -0
  9. zeroquantz/benchmark/memory.py +41 -0
  10. zeroquantz/benchmark/quality.py +38 -0
  11. zeroquantz/benchmark/runner.py +151 -0
  12. zeroquantz/cli/__init__.py +7 -0
  13. zeroquantz/cli/app.py +98 -0
  14. zeroquantz/cli/commands.py +459 -0
  15. zeroquantz/cli/interactive.py +56 -0
  16. zeroquantz/core/__init__.py +7 -0
  17. zeroquantz/core/artifacts.py +179 -0
  18. zeroquantz/core/context.py +127 -0
  19. zeroquantz/core/events.py +30 -0
  20. zeroquantz/core/exceptions.py +105 -0
  21. zeroquantz/core/session.py +202 -0
  22. zeroquantz/core/subenv.py +202 -0
  23. zeroquantz/deploy/__init__.py +25 -0
  24. zeroquantz/deploy/assets.py +161 -0
  25. zeroquantz/deploy/launcher.py +80 -0
  26. zeroquantz/deploy/runtime_env.py +66 -0
  27. zeroquantz/deploy/targets.py +154 -0
  28. zeroquantz/export/__init__.py +8 -0
  29. zeroquantz/export/exporter.py +68 -0
  30. zeroquantz/export/report.py +203 -0
  31. zeroquantz/hardware/__init__.py +15 -0
  32. zeroquantz/hardware/capabilities.py +152 -0
  33. zeroquantz/hardware/detector.py +200 -0
  34. zeroquantz/hardware/gpu.py +31 -0
  35. zeroquantz/models/__init__.py +8 -0
  36. zeroquantz/models/architecture.py +168 -0
  37. zeroquantz/models/downloader.py +161 -0
  38. zeroquantz/models/hf_auth.py +105 -0
  39. zeroquantz/models/inspector.py +249 -0
  40. zeroquantz/models/metadata.py +108 -0
  41. zeroquantz/models/search.py +71 -0
  42. zeroquantz/optimization/__init__.py +22 -0
  43. zeroquantz/optimization/candidate.py +272 -0
  44. zeroquantz/optimization/constraints.py +70 -0
  45. zeroquantz/optimization/fit.py +203 -0
  46. zeroquantz/optimization/pareto.py +66 -0
  47. zeroquantz/optimization/planner.py +297 -0
  48. zeroquantz/optimization/recommender.py +149 -0
  49. zeroquantz/profiling/__init__.py +18 -0
  50. zeroquantz/profiling/calibration.py +74 -0
  51. zeroquantz/profiling/sensitivity.py +234 -0
  52. zeroquantz/quantization/__init__.py +17 -0
  53. zeroquantz/quantization/backends/__init__.py +8 -0
  54. zeroquantz/quantization/backends/bitsandbytes.py +210 -0
  55. zeroquantz/quantization/backends/torchao.py +198 -0
  56. zeroquantz/quantization/base.py +136 -0
  57. zeroquantz/quantization/catalog.py +321 -0
  58. zeroquantz/quantization/config.py +106 -0
  59. zeroquantz/quantization/gguf_pipeline.py +210 -0
  60. zeroquantz/quantization/isolated.py +248 -0
  61. zeroquantz/quantization/memory.py +133 -0
  62. zeroquantz/quantization/native.py +91 -0
  63. zeroquantz/quantization/registry.py +101 -0
  64. zeroquantz/render.py +341 -0
  65. zeroquantz/runtimes/__init__.py +18 -0
  66. zeroquantz/runtimes/base.py +64 -0
  67. zeroquantz/runtimes/compatibility.py +91 -0
  68. zeroquantz/runtimes/registry.py +70 -0
  69. zeroquantz/runtimes/transformers.py +53 -0
  70. zeroquantz/runtimes/vllm.py +83 -0
  71. zeroquantz/tui/__init__.py +13 -0
  72. zeroquantz/tui/app.py +77 -0
  73. zeroquantz/tui/banner.py +47 -0
  74. zeroquantz/tui/screens/__init__.py +25 -0
  75. zeroquantz/tui/screens/confirm.py +41 -0
  76. zeroquantz/tui/screens/execute.py +194 -0
  77. zeroquantz/tui/screens/model_select.py +206 -0
  78. zeroquantz/tui/screens/plan.py +177 -0
  79. zeroquantz/tui/screens/quantize_select.py +272 -0
  80. zeroquantz/tui/screens/settings.py +219 -0
  81. zeroquantz/tui/screens/token.py +94 -0
  82. zeroquantz/tui/screens/welcome.py +128 -0
  83. zeroquantz/tui/screens/workspace.py +175 -0
  84. zeroquantz/tui/styles/app.tcss +424 -0
  85. zeroquantz/tui/widgets/__init__.py +9 -0
  86. zeroquantz/tui/widgets/chip.py +36 -0
  87. zeroquantz/tui/widgets/sidebar.py +107 -0
  88. zeroquantz/tui/widgets/status_bar.py +43 -0
  89. zeroquantz/utils/__init__.py +8 -0
  90. zeroquantz/utils/config.py +46 -0
  91. zeroquantz/utils/env.py +78 -0
  92. zeroquantz/utils/logging.py +73 -0
  93. zeroquantz/utils/metrics.py +98 -0
  94. zeroquantz/utils/paths.py +57 -0
  95. zeroquantz/utils/units.py +134 -0
  96. zeroquantz/verification/__init__.py +17 -0
  97. zeroquantz/verification/logits.py +55 -0
  98. zeroquantz/verification/report.py +186 -0
  99. zeroquantz/verification/weights.py +44 -0
  100. zeroquantz/version.py +8 -0
  101. zeroquantz-0.1.0.dist-info/METADATA +72 -0
  102. zeroquantz-0.1.0.dist-info/RECORD +105 -0
  103. zeroquantz-0.1.0.dist-info/WHEEL +4 -0
  104. zeroquantz-0.1.0.dist-info/entry_points.txt +2 -0
  105. zeroquantz-0.1.0.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,255 @@
1
+ """Parse user text (slash commands *or* natural language) into an :class:`Intent`.
2
+
3
+ Natural-language understanding here is deliberately rule-based (no LLM): the core
4
+ must work fully offline. The important, well-tested piece is :func:`parse_goal`,
5
+ which turns phrases like "fit this under 8GB and target vLLM, quality matters"
6
+ into structured :class:`OptimizationGoal` updates.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+
13
+ from zeroquantz.agent.intents import Intent, IntentKind
14
+
15
+ # ---- slash command table ---------------------------------------------------
16
+
17
+ _SLASH: dict[str, IntentKind] = {
18
+ "model": IntentKind.LOAD_MODEL,
19
+ "load": IntentKind.LOAD_MODEL, # /load MODEL (ambiguous with sessions; see parse)
20
+ "inspect": IntentKind.INSPECT,
21
+ "hardware": IntentKind.HARDWARE,
22
+ "hw": IntentKind.HARDWARE,
23
+ "goal": IntentKind.SET_GOAL,
24
+ "recommend": IntentKind.RECOMMEND,
25
+ "rec": IntentKind.RECOMMEND,
26
+ "profile": IntentKind.PROFILE,
27
+ "plan": IntentKind.PLAN,
28
+ "quantize": IntentKind.QUANTIZE,
29
+ "benchmark": IntentKind.BENCHMARK,
30
+ "bench": IntentKind.BENCHMARK,
31
+ "compare": IntentKind.COMPARE,
32
+ "verify": IntentKind.VERIFY,
33
+ "export": IntentKind.EXPORT,
34
+ "history": IntentKind.HISTORY,
35
+ "undo": IntentKind.UNDO,
36
+ "checkout": IntentKind.CHECKOUT,
37
+ "sessions": IntentKind.SESSIONS,
38
+ "save": IntentKind.SAVE,
39
+ "help": IntentKind.HELP,
40
+ "exit": IntentKind.EXIT,
41
+ "quit": IntentKind.EXIT,
42
+ }
43
+
44
+ # ---- natural-language command verbs (ordered; first match wins) ------------
45
+
46
+ _NL_VERBS: tuple[tuple[IntentKind, tuple[str, ...]], ...] = (
47
+ (IntentKind.RECOMMEND, ("recommend", "suggest", "what should i", "which quant", "advise", "best strategy", "best way")),
48
+ (IntentKind.HARDWARE, ("my gpu", "my hardware", "what gpu", "hardware", "my card", "my vram")),
49
+ (IntentKind.PLAN, ("mixed precision", "precision plan", "make a plan", "build a plan", "plan it")),
50
+ (IntentKind.PROFILE, ("sensitivity", "profile the", "which layers", "sensitive layers")),
51
+ (IntentKind.BENCHMARK, ("benchmark", "measure speed", "how fast", "throughput test")),
52
+ (IntentKind.VERIFY, ("verify", "validate quality", "check quality", "how lossy")),
53
+ (IntentKind.COMPARE, ("compare", "side by side", "side-by-side")),
54
+ (IntentKind.QUANTIZE, ("quantize", "compress", "shrink the model", "make it smaller")),
55
+ (IntentKind.EXPORT, ("export", "write the report", "save the model")),
56
+ (IntentKind.INSPECT, ("inspect", "tell me about", "details about", "info on", "describe")),
57
+ (IntentKind.HISTORY, ("show history", "history")),
58
+ (IntentKind.UNDO, ("undo", "go back")),
59
+ (IntentKind.SESSIONS, ("list sessions", "my sessions")),
60
+ )
61
+
62
+ _LOAD_VERBS = ("load", "open", "use", "optimize", "model", "quantize", "compress")
63
+
64
+ _MODEL_ID_RE = re.compile(r"\b([A-Za-z0-9][\w.\-]*/[\w.\-]+)\b")
65
+ _GB_RE = re.compile(r"(\d+(?:\.\d+)?)\s*(gib|gb|g)\b")
66
+ _PCT_RE = re.compile(r"(\d+(?:\.\d+)?)\s*%")
67
+
68
+ _KNOWN_RUNTIMES = ("vllm", "transformers", "sglang", "tensorrt-llm", "tensorrt", "llama.cpp", "ollama", "tgi")
69
+
70
+ _OBJECTIVE_KEYWORDS: dict[str, tuple[str, ...]] = {
71
+ "quality": ("quality", "accuracy", "accurate", "preserve", "lossless", "faithful", "precise", "minimal loss", "without losing", "keep quality"),
72
+ "speed": ("fastest", "faster", "speed", "throughput", "low latency", "latency", "quick", "tokens/sec", "tok/s", "high throughput"),
73
+ "memory": ("smallest", "as small as possible", "minimize memory", "tiniest", "most compact", "smallest footprint", "least vram"),
74
+ "balanced": ("balanced", "good balance", "trade off", "tradeoff"),
75
+ }
76
+ _OBJECTIVE_PRECEDENCE = ("quality", "speed", "memory", "balanced")
77
+
78
+
79
+ def parse_goal(text: str) -> dict:
80
+ """Extract structured :class:`OptimizationGoal` updates from free text.
81
+
82
+ Returns only the keys it actually detected, so callers can merge with
83
+ ``goal.with_updates(**parse_goal(text))``.
84
+ """
85
+ t = text.lower()
86
+ out: dict = {}
87
+
88
+ m = _GB_RE.search(t)
89
+ if m:
90
+ val = float(m.group(1))
91
+ preceding = t[max(0, m.start() - 28) : m.start()]
92
+ if any(w in preceding for w in ("size", "disk", "file", "weights")):
93
+ out["max_model_size_gb"] = val
94
+ else:
95
+ out["max_vram_gb"] = val
96
+
97
+ for rt in _KNOWN_RUNTIMES:
98
+ if rt in t:
99
+ out["runtime"] = rt
100
+ break
101
+
102
+ pct = _PCT_RE.search(t)
103
+ if pct and any(w in t for w in ("loss", "degrad", "accuracy", "quality", "within", "perplexity", "drop")):
104
+ out["max_quality_loss"] = round(float(pct.group(1)) / 100.0, 4)
105
+
106
+ objective = _detect_objective(t)
107
+ if objective:
108
+ out["objective"] = objective
109
+
110
+ return out
111
+
112
+
113
+ def _detect_objective(t: str) -> str | None:
114
+ scores = {
115
+ obj: sum(1 for kw in kws if kw in t) for obj, kws in _OBJECTIVE_KEYWORDS.items()
116
+ }
117
+ best = max(scores.values())
118
+ if best == 0:
119
+ return None
120
+ for obj in _OBJECTIVE_PRECEDENCE: # deterministic tie-break
121
+ if scores[obj] == best:
122
+ return obj
123
+ return None
124
+
125
+
126
+ def _detect_model_id(text: str) -> str | None:
127
+ m = _MODEL_ID_RE.search(text)
128
+ if m:
129
+ return m.group(1)
130
+ # local path heuristic
131
+ for token in text.split():
132
+ if token.startswith(("./", "~/", "../")) or "\\" in token:
133
+ return token
134
+ return None
135
+
136
+
137
+ class IntentParser:
138
+ """Turn a raw input line into an :class:`Intent`."""
139
+
140
+ @staticmethod
141
+ def parse(text: str) -> Intent:
142
+ raw = text.strip()
143
+ if not raw:
144
+ return Intent(IntentKind.UNKNOWN, raw=raw)
145
+
146
+ if raw.startswith("/"):
147
+ return IntentParser._parse_slash(raw)
148
+ return IntentParser._parse_natural(raw)
149
+
150
+ # ---- slash --------------------------------------------------------------
151
+
152
+ @staticmethod
153
+ def _parse_slash(raw: str) -> Intent:
154
+ parts = raw[1:].split()
155
+ if not parts:
156
+ return Intent(IntentKind.UNKNOWN, raw=raw)
157
+ cmd = parts[0].lower()
158
+ rest = parts[1:]
159
+ rest_str = " ".join(rest)
160
+ kind = _SLASH.get(cmd)
161
+ if kind is None:
162
+ return Intent(IntentKind.UNKNOWN, args={"command": cmd}, raw=raw)
163
+
164
+ # /load is a session load when it names a saved session, but /model is the
165
+ # canonical way to load a model; treat bare "/load X" as model load only if
166
+ # X looks like a model id/path.
167
+ if cmd == "load":
168
+ if rest and _looks_like_model(rest_str):
169
+ return Intent(IntentKind.LOAD_MODEL, {"model_id": rest_str}, raw)
170
+ return Intent(IntentKind.LOAD_SESSION, {"name": rest_str}, raw)
171
+
172
+ if kind is IntentKind.LOAD_MODEL:
173
+ return Intent(kind, {"model_id": rest_str}, raw)
174
+ if kind is IntentKind.SET_GOAL:
175
+ return Intent(kind, {"goal_updates": parse_goal(rest_str)}, raw)
176
+ if kind is IntentKind.CHECKOUT:
177
+ return Intent(kind, {"index": _safe_int(rest_str)}, raw)
178
+ if kind is IntentKind.SAVE:
179
+ return Intent(kind, {"name": rest_str or None}, raw)
180
+ if kind is IntentKind.EXPORT:
181
+ return Intent(kind, {"path": rest_str or None}, raw)
182
+ if kind is IntentKind.QUANTIZE:
183
+ return Intent(kind, {"method": rest[0] if rest else None}, raw)
184
+ if kind is IntentKind.VERIFY:
185
+ args = {}
186
+ if len(rest) >= 1:
187
+ args["base"] = rest[0]
188
+ if len(rest) >= 2:
189
+ args["quant"] = rest[1]
190
+ return Intent(kind, args, raw)
191
+ if kind in (IntentKind.BENCHMARK, IntentKind.INSPECT):
192
+ return Intent(kind, {"target": rest_str or None}, raw)
193
+ return Intent(kind, raw=raw)
194
+
195
+ # ---- natural language ---------------------------------------------------
196
+
197
+ @staticmethod
198
+ def _parse_natural(raw: str) -> Intent:
199
+ t = raw.lower()
200
+
201
+ if t in ("exit", "quit", "q", ":q"):
202
+ return Intent(IntentKind.EXIT, raw=raw)
203
+ if t in ("help", "?", "commands"):
204
+ return Intent(IntentKind.HELP, raw=raw)
205
+
206
+ goal_updates = parse_goal(raw)
207
+ model_id = _detect_model_id(raw)
208
+ verb = IntentParser._detect_verb(t)
209
+
210
+ # explicit load ("load X", "use X", or a bare model id)
211
+ has_load_verb = any(re.search(rf"\b{v}\b", t) for v in _LOAD_VERBS)
212
+ if verb is None and model_id and (has_load_verb or _is_bare_id(raw)):
213
+ return Intent(IntentKind.LOAD_MODEL, {"model_id": model_id}, raw)
214
+
215
+ if verb is None:
216
+ if goal_updates:
217
+ return Intent(IntentKind.SET_GOAL, {"goal_updates": goal_updates}, raw)
218
+ return Intent(IntentKind.UNKNOWN, raw=raw)
219
+
220
+ args: dict = {}
221
+ if goal_updates:
222
+ args["goal_updates"] = goal_updates
223
+ if model_id and verb in (
224
+ IntentKind.INSPECT,
225
+ IntentKind.QUANTIZE,
226
+ IntentKind.BENCHMARK,
227
+ IntentKind.VERIFY,
228
+ IntentKind.RECOMMEND,
229
+ ):
230
+ args["target"] = model_id
231
+ return Intent(verb, args, raw)
232
+
233
+ @staticmethod
234
+ def _detect_verb(t: str) -> IntentKind | None:
235
+ for kind, keywords in _NL_VERBS:
236
+ if any(kw in t for kw in keywords):
237
+ return kind
238
+ return None
239
+
240
+
241
+ def _looks_like_model(text: str) -> bool:
242
+ return bool(_MODEL_ID_RE.search(text)) or text.startswith((".", "~", "/")) or "\\" in text
243
+
244
+
245
+ def _is_bare_id(text: str) -> bool:
246
+ """A message that is essentially just a model id (optionally 'load <id>')."""
247
+ tokens = text.split()
248
+ return len(tokens) <= 2 and _MODEL_ID_RE.search(text) is not None
249
+
250
+
251
+ def _safe_int(text: str) -> int | None:
252
+ try:
253
+ return int(text.strip())
254
+ except (ValueError, AttributeError):
255
+ return None
@@ -0,0 +1,7 @@
1
+ """Benchmarking: measured VRAM, latency, throughput, size, and quality."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from zeroquantz.benchmark.runner import BenchmarkResult, BenchmarkRunner
6
+
7
+ __all__ = ["BenchmarkResult", "BenchmarkRunner"]
@@ -0,0 +1,66 @@
1
+ """Latency and throughput measurement (torch/transformers, imported lazily)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from dataclasses import dataclass
7
+ from typing import Any
8
+
9
+
10
+ @dataclass
11
+ class LatencyStats:
12
+ latency_ms_mean: float
13
+ latency_ms_p50: float
14
+ tokens_per_sec: float
15
+ generated_tokens: int
16
+ runs: int
17
+
18
+
19
+ def measure_generation_latency(
20
+ model: Any,
21
+ tokenizer: Any,
22
+ prompt: str,
23
+ *,
24
+ max_new_tokens: int = 64,
25
+ warmup: int = 2,
26
+ runs: int = 5,
27
+ ) -> LatencyStats:
28
+ """Time ``model.generate`` over ``runs`` iterations (after ``warmup``)."""
29
+ import torch
30
+
31
+ device = next(model.parameters()).device
32
+ inputs = tokenizer(prompt, return_tensors="pt").to(device)
33
+
34
+ def _one() -> tuple[float, int]:
35
+ torch.cuda.synchronize() if torch.cuda.is_available() else None
36
+ start = time.perf_counter()
37
+ with torch.no_grad():
38
+ out = model.generate(
39
+ **inputs, max_new_tokens=max_new_tokens, do_sample=False
40
+ )
41
+ torch.cuda.synchronize() if torch.cuda.is_available() else None
42
+ elapsed = time.perf_counter() - start
43
+ new_tokens = int(out.shape[-1] - inputs["input_ids"].shape[-1])
44
+ return elapsed, new_tokens
45
+
46
+ for _ in range(max(warmup, 0)):
47
+ _one()
48
+
49
+ times: list[float] = []
50
+ total_tokens = 0
51
+ for _ in range(max(runs, 1)):
52
+ elapsed, new_tokens = _one()
53
+ times.append(elapsed)
54
+ total_tokens += new_tokens
55
+
56
+ times.sort()
57
+ mean = sum(times) / len(times)
58
+ p50 = times[len(times) // 2]
59
+ tps = (total_tokens / sum(times)) if sum(times) > 0 else 0.0
60
+ return LatencyStats(
61
+ latency_ms_mean=round(mean * 1000, 2),
62
+ latency_ms_p50=round(p50 * 1000, 2),
63
+ tokens_per_sec=round(tps, 2),
64
+ generated_tokens=total_tokens,
65
+ runs=len(times),
66
+ )
@@ -0,0 +1,41 @@
1
+ """Measured VRAM helpers (torch-backed, imported lazily)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from zeroquantz.utils import units
6
+
7
+
8
+ def reset_peak_memory() -> None:
9
+ try:
10
+ import torch
11
+
12
+ if torch.cuda.is_available():
13
+ torch.cuda.reset_peak_memory_stats()
14
+ torch.cuda.empty_cache()
15
+ except Exception: # pragma: no cover - torch/CUDA optional
16
+ pass
17
+
18
+
19
+ def peak_vram_gb() -> float | None:
20
+ """Peak allocated VRAM since the last reset, in GiB, or None if unavailable."""
21
+ try:
22
+ import torch
23
+
24
+ if torch.cuda.is_available():
25
+ return round(units.bytes_to_gb(torch.cuda.max_memory_allocated()), 3)
26
+ except Exception: # pragma: no cover
27
+ pass
28
+ return None
29
+
30
+
31
+ def module_size_gb(module) -> float | None: # noqa: ANN001
32
+ """On-device size of a live module's parameters + buffers, in GiB."""
33
+ try:
34
+ total = 0
35
+ for p in module.parameters():
36
+ total += p.numel() * p.element_size()
37
+ for b in module.buffers():
38
+ total += b.numel() * b.element_size()
39
+ return round(units.bytes_to_gb(total), 3)
40
+ except Exception: # pragma: no cover
41
+ return None
@@ -0,0 +1,38 @@
1
+ """Quality measurement: perplexity over calibration/eval text (lazy torch)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+
8
+ def measure_perplexity(
9
+ model: Any,
10
+ tokenizer: Any,
11
+ texts: list[str],
12
+ *,
13
+ max_length: int = 512,
14
+ ) -> float:
15
+ """Token-level perplexity of ``model`` over ``texts`` (teacher forcing)."""
16
+ import torch
17
+
18
+ device = next(model.parameters()).device
19
+ total_nll = 0.0
20
+ total_tokens = 0
21
+ with torch.no_grad():
22
+ for text in texts:
23
+ enc = tokenizer(
24
+ text, return_tensors="pt", truncation=True, max_length=max_length
25
+ ).to(device)
26
+ input_ids = enc["input_ids"]
27
+ if input_ids.shape[-1] < 2:
28
+ continue
29
+ out = model(**enc, labels=input_ids)
30
+ # HF returns mean NLL over tokens; weight by token count.
31
+ n_tokens = int(input_ids.shape[-1] - 1)
32
+ total_nll += float(out.loss) * n_tokens
33
+ total_tokens += n_tokens
34
+ if total_tokens == 0:
35
+ return float("nan")
36
+ import math
37
+
38
+ return float(math.exp(total_nll / total_tokens))
@@ -0,0 +1,151 @@
1
+ """The common benchmark runner.
2
+
3
+ Loads a model (base or quantized directory), measures peak VRAM, load time,
4
+ generation latency, throughput, on-device size, and optional perplexity. Every
5
+ result records the hardware + software environment it was produced on. All
6
+ numbers here are *measured* (``measured=True``) — distinct from the planner's
7
+ estimates.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import time
13
+ from typing import TYPE_CHECKING
14
+
15
+ from pydantic import BaseModel, Field
16
+
17
+ from zeroquantz.benchmark import latency as latency_mod
18
+ from zeroquantz.benchmark import memory as memory_mod
19
+ from zeroquantz.benchmark import quality as quality_mod
20
+ from zeroquantz.core.exceptions import DependencyError, ZeroQuantzError
21
+ from zeroquantz.utils import units
22
+ from zeroquantz.utils.env import capture_environment
23
+ from zeroquantz.utils.logging import get_logger
24
+
25
+ if TYPE_CHECKING:
26
+ from collections.abc import Callable
27
+
28
+ from zeroquantz.hardware.capabilities import HardwareProfile
29
+
30
+ log = get_logger(__name__)
31
+
32
+
33
+ class BenchmarkResult(BaseModel):
34
+ model_ref: str
35
+ measured: bool = True
36
+ peak_vram_gb: float | None = None
37
+ load_time_s: float | None = None
38
+ model_size_gb: float | None = None
39
+ latency_ms_mean: float | None = None
40
+ latency_ms_p50: float | None = None
41
+ tokens_per_sec: float | None = None
42
+ perplexity: float | None = None
43
+ num_prompts: int = 0
44
+ warmup: int = 0
45
+ runs: int = 0
46
+ max_new_tokens: int = 0
47
+ environment: dict = Field(default_factory=dict)
48
+ notes: list[str] = Field(default_factory=list)
49
+
50
+ def summary_rows(self) -> list[tuple[str, str]]:
51
+ def fmt(v: float | None, unit: str = "") -> str:
52
+ return f"{v:g}{unit}" if v is not None else "n/a"
53
+
54
+ return [
55
+ ("Peak VRAM", fmt(self.peak_vram_gb, " GB")),
56
+ ("Load time", fmt(self.load_time_s, " s")),
57
+ ("Model size", fmt(self.model_size_gb, " GB")),
58
+ ("Latency (mean)", fmt(self.latency_ms_mean, " ms")),
59
+ ("Throughput", fmt(self.tokens_per_sec, " tok/s")),
60
+ ("Perplexity", fmt(self.perplexity)),
61
+ ]
62
+
63
+
64
+ class BenchmarkRunner:
65
+ """Load and benchmark a model end-to-end."""
66
+
67
+ @staticmethod
68
+ def run(
69
+ model_ref: str,
70
+ *,
71
+ prompts: list[str] | None = None,
72
+ eval_texts: list[str] | None = None,
73
+ warmup: int = 2,
74
+ runs: int = 5,
75
+ max_new_tokens: int = 64,
76
+ measure_quality: bool = False,
77
+ hardware: HardwareProfile | None = None,
78
+ progress: Callable[[str, float], None] | None = None,
79
+ ) -> BenchmarkResult:
80
+ try:
81
+ import torch
82
+ from transformers import AutoModelForCausalLM, AutoTokenizer
83
+ except ImportError as exc:
84
+ raise DependencyError.for_extra(
85
+ "transformers", "torch", purpose="benchmark a model"
86
+ ) from exc
87
+
88
+ prompts = prompts or ["Explain the theory of relativity in simple terms."]
89
+
90
+ if progress:
91
+ progress("Loading model", 0.05)
92
+ memory_mod.reset_peak_memory()
93
+ load_start = time.perf_counter()
94
+ try:
95
+ tokenizer = AutoTokenizer.from_pretrained(model_ref)
96
+ model = AutoModelForCausalLM.from_pretrained(model_ref, device_map="auto")
97
+ model.eval()
98
+ except Exception as exc: # pragma: no cover - requires weights
99
+ raise ZeroQuantzError(
100
+ f"Could not load '{model_ref}' for benchmarking.",
101
+ detail=str(exc),
102
+ suggestions=["zeroquantz inspect " + model_ref, "check available VRAM"],
103
+ ) from exc
104
+ load_time = time.perf_counter() - load_start
105
+
106
+ if tokenizer.pad_token is None and tokenizer.eos_token is not None:
107
+ tokenizer.pad_token = tokenizer.eos_token
108
+
109
+ result = BenchmarkResult(
110
+ model_ref=model_ref,
111
+ num_prompts=len(prompts),
112
+ warmup=warmup,
113
+ runs=runs,
114
+ max_new_tokens=max_new_tokens,
115
+ load_time_s=round(load_time, 2),
116
+ model_size_gb=memory_mod.module_size_gb(model),
117
+ environment=capture_environment(hardware),
118
+ )
119
+
120
+ if progress:
121
+ progress("Measuring latency", 0.4)
122
+ try:
123
+ stats = latency_mod.measure_generation_latency(
124
+ model, tokenizer, prompts[0],
125
+ max_new_tokens=max_new_tokens, warmup=warmup, runs=runs,
126
+ )
127
+ result.latency_ms_mean = stats.latency_ms_mean
128
+ result.latency_ms_p50 = stats.latency_ms_p50
129
+ result.tokens_per_sec = stats.tokens_per_sec
130
+ except Exception as exc: # pragma: no cover
131
+ result.notes.append(f"latency measurement failed: {exc}")
132
+
133
+ if measure_quality:
134
+ if progress:
135
+ progress("Measuring perplexity", 0.7)
136
+ try:
137
+ result.perplexity = round(
138
+ quality_mod.measure_perplexity(model, tokenizer, eval_texts or prompts), 4
139
+ )
140
+ except Exception as exc: # pragma: no cover
141
+ result.notes.append(f"perplexity measurement failed: {exc}")
142
+
143
+ result.peak_vram_gb = memory_mod.peak_vram_gb()
144
+ if progress:
145
+ progress("Benchmark complete", 1.0)
146
+ del model
147
+ try:
148
+ torch.cuda.empty_cache()
149
+ except Exception:
150
+ pass
151
+ return result
@@ -0,0 +1,7 @@
1
+ """Typer command-line interface and text REPL."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from zeroquantz.cli.app import app, main
6
+
7
+ __all__ = ["app", "main"]
zeroquantz/cli/app.py ADDED
@@ -0,0 +1,98 @@
1
+ """The ``zeroquantz`` Typer application.
2
+
3
+ Running ``zeroquantz`` with no subcommand launches the interactive TUI (falling
4
+ back to a text REPL when stdout is not a TTY). Subcommands provide the scriptable,
5
+ non-interactive surface.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import sys
12
+
13
+ import typer
14
+
15
+ from zeroquantz import __version__
16
+ from zeroquantz.utils.logging import configure_logging
17
+
18
+ app = typer.Typer(
19
+ add_completion=False,
20
+ no_args_is_help=False,
21
+ rich_markup_mode="rich",
22
+ help="Interactive model optimization and quantization from your terminal.",
23
+ )
24
+
25
+
26
+ def _version_callback(value: bool) -> None:
27
+ if value:
28
+ typer.echo(f"zeroquantz {__version__}")
29
+ raise typer.Exit()
30
+
31
+
32
+ @app.callback(invoke_without_command=True)
33
+ def _main(
34
+ ctx: typer.Context,
35
+ version: bool = typer.Option(
36
+ False, "--version", callback=_version_callback, is_eager=True, help="Show version and exit."
37
+ ),
38
+ verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose (INFO) console logging."),
39
+ debug: bool = typer.Option(False, "--debug", help="Debug (DEBUG) console logging."),
40
+ session: str | None = typer.Option(None, "--session", help="Use a named session."),
41
+ continue_last: bool = typer.Option(
42
+ False, "--continue", help="Resume the most recent session."
43
+ ),
44
+ no_tui: bool = typer.Option(False, "--no-tui", help="Use the plain text REPL instead of the TUI."),
45
+ ) -> None:
46
+ configure_logging("debug" if debug else ("verbose" if verbose else "quiet"))
47
+ if ctx.invoked_subcommand is not None:
48
+ return
49
+ _launch_interactive(session=session, continue_last=continue_last, no_tui=no_tui)
50
+
51
+
52
+ def _launch_interactive(*, session: str | None, continue_last: bool, no_tui: bool) -> None:
53
+ use_tui = (not no_tui) and sys.stdout.isatty() and sys.stdin.isatty()
54
+ if use_tui:
55
+ try:
56
+ from zeroquantz.tui.app import run_tui
57
+
58
+ run_tui(session_name=session, continue_last=continue_last)
59
+ return
60
+ except Exception as exc: # pragma: no cover - environment dependent
61
+ typer.echo(f"(TUI unavailable: {exc}; falling back to text mode)\n")
62
+ from zeroquantz.cli.interactive import run_repl
63
+
64
+ run_repl(session_name=session, continue_last=continue_last)
65
+
66
+
67
+ # Register subcommands.
68
+ from zeroquantz.cli import commands as _commands # noqa: E402
69
+
70
+ _commands.register(app)
71
+
72
+
73
+ def _setup_stdio() -> None:
74
+ """Make Unicode output reliable (Windows consoles default to cp1252)."""
75
+ os.environ.setdefault("HF_HUB_DISABLE_SYMLINKS_WARNING", "1")
76
+ os.environ.setdefault("TOKENIZERS_PARALLELISM", "false")
77
+ # Opt into the Rust hf_transfer download accelerator when it's installed
78
+ # (safe no-op otherwise; never sets the flag without the package present).
79
+ from zeroquantz.utils.env import enable_fast_downloads
80
+
81
+ enable_fast_downloads()
82
+ import contextlib
83
+
84
+ for stream in (sys.stdout, sys.stderr):
85
+ reconfigure = getattr(stream, "reconfigure", None)
86
+ if reconfigure is not None:
87
+ with contextlib.suppress(Exception): # pragma: no cover
88
+ reconfigure(encoding="utf-8")
89
+
90
+
91
+ def main() -> None:
92
+ """Console-script entry point."""
93
+ _setup_stdio()
94
+ app()
95
+
96
+
97
+ if __name__ == "__main__":
98
+ main()