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,272 @@
1
+ """Quantization candidate generation, driven by the format catalog + fit math.
2
+
3
+ Each :class:`QuantizationCandidate` pairs a catalog format with *estimated* size /
4
+ VRAM / max-context / quality-risk / speed numbers (from :mod:`zeroquantz.optimization.fit`
5
+ and the format's quality/speed tiers) plus runtime compatibility and how ZeroQuantz
6
+ would execute it (in the main env, an isolated sub-env, or produce-only).
7
+
8
+ Estimates are heuristics, always labelled as such. Whether a format is runnable
9
+ *here* (hardware, execution path) is tracked separately so ZeroQuantz can show the
10
+ full landscape while only *recommending* something it can actually run.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from typing import TYPE_CHECKING
16
+
17
+ from pydantic import BaseModel, Field
18
+
19
+ from zeroquantz.optimization import fit as fitmod
20
+ from zeroquantz.optimization.constraints import OptimizationGoal
21
+ from zeroquantz.quantization import catalog
22
+ from zeroquantz.quantization.catalog import Execution, QuantFormat
23
+ from zeroquantz.quantization.config import QuantizationConfig
24
+ from zeroquantz.runtimes.compatibility import RuntimeCompat
25
+
26
+ if TYPE_CHECKING:
27
+ from zeroquantz.hardware.capabilities import HardwareProfile
28
+ from zeroquantz.models.metadata import ModelProfile
29
+ from zeroquantz.quantization.registry import BackendRegistry
30
+
31
+ # quality_tier (1..5) -> base quality-degradation risk (0..1), before size scaling
32
+ _TIER_RISK = {5: 0.05, 4: 0.15, 3: 0.30, 2: 0.50, 1: 0.75}
33
+ # speed_tier (1..5) -> estimated decode throughput vs bf16 baseline
34
+ _TIER_SPEED = {5: 1.8, 4: 1.5, 3: 1.2, 2: 1.0, 1: 0.85}
35
+
36
+
37
+ def _size_factor(parameter_count: int) -> float:
38
+ p = parameter_count
39
+ if p < 1_000_000_000:
40
+ return 1.6
41
+ if p < 3_000_000_000:
42
+ return 1.3
43
+ if p < 7_000_000_000:
44
+ return 1.15
45
+ if p < 13_000_000_000:
46
+ return 1.0
47
+ if p < 30_000_000_000:
48
+ return 0.92
49
+ if p < 70_000_000_000:
50
+ return 0.85
51
+ return 0.8
52
+
53
+
54
+ def estimate_quality_risk(model: ModelProfile, fmt: QuantFormat) -> float:
55
+ risk = _TIER_RISK.get(fmt.quality_tier, 0.3) * _size_factor(model.parameter_count)
56
+ return round(min(max(risk, 0.02), 0.95), 3)
57
+
58
+
59
+ def _risk_label(risk: float) -> str:
60
+ if risk < 0.10:
61
+ return "very low"
62
+ if risk < 0.25:
63
+ return "low"
64
+ if risk < 0.45:
65
+ return "moderate"
66
+ if risk < 0.70:
67
+ return "high"
68
+ return "very high"
69
+
70
+
71
+ def _runtime_compat(fmt: QuantFormat, runtime: str | None) -> RuntimeCompat:
72
+ if runtime is None:
73
+ return RuntimeCompat.COMPATIBLE
74
+ return RuntimeCompat.COMPATIBLE if runtime.strip().lower() in fmt.runtimes else RuntimeCompat.UNSUPPORTED
75
+
76
+
77
+ class QuantizationCandidate(BaseModel):
78
+ """A candidate format with estimated costs, runtime fit, and execution info."""
79
+
80
+ format_id: str
81
+ label: str
82
+ family: str
83
+ execution: str
84
+ produced_by: str
85
+ bits_per_weight: float
86
+ calibration: str
87
+ runtimes: list[str] = Field(default_factory=list)
88
+
89
+ config: QuantizationConfig
90
+ estimated_size_gb: float
91
+ estimated_vram_gb: float
92
+ estimated_quality_risk: float
93
+ estimated_speedup: float
94
+ max_context: int | None = None
95
+
96
+ runtime_compat: RuntimeCompat = RuntimeCompat.COMPATIBLE
97
+ hardware_status: str = "supported" # supported | limited | unsupported
98
+ backend_available: bool = True
99
+ fits_memory: bool = True
100
+ meets_quality: bool = True
101
+ notes: list[str] = Field(default_factory=list)
102
+
103
+ # ---- conveniences -------------------------------------------------------
104
+
105
+ @property
106
+ def method(self) -> str:
107
+ return self.config.method
108
+
109
+ @property
110
+ def backend(self) -> str:
111
+ return self.config.backend
112
+
113
+ @property
114
+ def weight_bits(self) -> int:
115
+ return self.config.weight_bits
116
+
117
+ @property
118
+ def activation_bits(self) -> int:
119
+ return self.config.activation_bits
120
+
121
+ @property
122
+ def quality_risk_label(self) -> str:
123
+ return _risk_label(self.estimated_quality_risk)
124
+
125
+ @property
126
+ def executable(self) -> bool:
127
+ return self.execution in (Execution.IN_ENV.value, Execution.ISOLATED.value)
128
+
129
+ @property
130
+ def implemented(self) -> bool: # kept for API compatibility with the recommender
131
+ return self.executable
132
+
133
+ @property
134
+ def feasible(self) -> bool:
135
+ """Runnable here and satisfies the goal's hard constraints."""
136
+ return (
137
+ self.executable
138
+ and self.hardware_status != "unsupported"
139
+ and self.fits_memory
140
+ and self.meets_quality
141
+ and self.runtime_compat is not RuntimeCompat.UNSUPPORTED
142
+ )
143
+
144
+
145
+ class CandidateGenerator:
146
+ """Enumerate quantization candidates for a model/hardware/goal from the catalog."""
147
+
148
+ @staticmethod
149
+ def generate(
150
+ model: ModelProfile,
151
+ hardware: HardwareProfile,
152
+ goal: OptimizationGoal | None = None,
153
+ *,
154
+ registry: BackendRegistry | None = None,
155
+ formats: tuple[QuantFormat, ...] | None = None,
156
+ ) -> list[QuantizationCandidate]:
157
+ goal = goal or OptimizationGoal()
158
+ if registry is None:
159
+ from zeroquantz.quantization.registry import default_registry
160
+
161
+ registry = default_registry()
162
+ formats = formats or catalog.ALL_FORMATS
163
+
164
+ candidates: list[QuantizationCandidate] = []
165
+ for fmt in formats:
166
+ report = fitmod.fit(
167
+ model, fmt, budget_gb=goal.max_vram_gb, context_length=goal.context_length
168
+ )
169
+ risk = estimate_quality_risk(model, fmt)
170
+ compat = _runtime_compat(fmt, goal.runtime)
171
+ hw_status = CandidateGenerator._hardware_status(fmt, hardware)
172
+ config = CandidateGenerator._config_for(fmt, model, hardware, goal, registry)
173
+ backend_available = CandidateGenerator._backend_available(fmt, registry)
174
+
175
+ fits_memory = report.fits
176
+ if goal.max_model_size_gb is not None:
177
+ fits_memory = fits_memory and report.weight_gb <= goal.max_model_size_gb
178
+ meets_quality = (
179
+ goal.max_quality_loss is None
180
+ or risk <= max(goal.max_quality_loss * 4.0, goal.max_quality_loss)
181
+ )
182
+
183
+ candidates.append(
184
+ QuantizationCandidate(
185
+ format_id=fmt.id,
186
+ label=fmt.label,
187
+ family=fmt.family,
188
+ execution=fmt.execution.value,
189
+ produced_by=fmt.produced_by,
190
+ bits_per_weight=fmt.bits_per_weight,
191
+ calibration=fmt.calibration,
192
+ runtimes=list(fmt.runtimes),
193
+ config=config,
194
+ estimated_size_gb=report.weight_gb,
195
+ estimated_vram_gb=report.total_gb,
196
+ estimated_quality_risk=risk,
197
+ estimated_speedup=_TIER_SPEED.get(fmt.speed_tier, 1.0),
198
+ max_context=report.max_context,
199
+ runtime_compat=compat,
200
+ hardware_status=hw_status,
201
+ backend_available=backend_available,
202
+ fits_memory=fits_memory,
203
+ meets_quality=meets_quality,
204
+ notes=CandidateGenerator._notes(fmt, hw_status, compat, backend_available),
205
+ )
206
+ )
207
+ return candidates
208
+
209
+ # ---- internals ----------------------------------------------------------
210
+
211
+ @staticmethod
212
+ def _hardware_status(fmt: QuantFormat, hardware: HardwareProfile) -> str:
213
+ if fmt.requires_precision is None:
214
+ return "supported" # CPU-capable (e.g. GGUF)
215
+ return hardware.status(fmt.requires_precision).label.replace("yes", "supported").replace(
216
+ "no", "unsupported"
217
+ )
218
+
219
+ @staticmethod
220
+ def _backend_available(fmt: QuantFormat, registry: BackendRegistry) -> bool:
221
+ if fmt.execution is Execution.IN_ENV and fmt.backend in registry.names():
222
+ return registry.get(fmt.backend).is_available()
223
+ if fmt.execution is Execution.ISOLATED:
224
+ return True # ZeroQuantz can build a sub-env for it on demand
225
+ return False
226
+
227
+ @staticmethod
228
+ def _config_for(
229
+ fmt: QuantFormat,
230
+ model: ModelProfile,
231
+ hardware: HardwareProfile,
232
+ goal: OptimizationGoal,
233
+ registry: BackendRegistry,
234
+ ) -> QuantizationConfig:
235
+ if fmt.backend and fmt.method and fmt.backend in registry.names():
236
+ backend = registry.get(fmt.backend)
237
+ if fmt.method in backend.methods:
238
+ return backend.default_config(fmt.method, model, hardware, goal)
239
+ compute_dtype = "bfloat16" if hardware.supports_bf16 else "float16"
240
+ return QuantizationConfig(
241
+ backend=fmt.family,
242
+ method=fmt.method or fmt.id,
243
+ weight_bits=max(1, min(16, round(fmt.bits_per_weight))),
244
+ activation_bits=fmt.activation_bits,
245
+ group_size=128 if fmt.bits_per_weight < 8 else None,
246
+ compute_dtype=compute_dtype,
247
+ skip_modules=["lm_head"],
248
+ extra={"format_id": fmt.id},
249
+ )
250
+
251
+ @staticmethod
252
+ def _notes(
253
+ fmt: QuantFormat, hw_status: str, compat: RuntimeCompat, backend_available: bool
254
+ ) -> list[str]:
255
+ notes: list[str] = []
256
+ if fmt.execution is Execution.ISOLATED:
257
+ notes.append("runs in an isolated sub-env")
258
+ elif fmt.execution is Execution.EXTERNAL:
259
+ notes.append(f"produced via {fmt.produced_by}")
260
+ elif fmt.execution is Execution.PLANNED:
261
+ notes.append("roadmap")
262
+ if fmt.execution is Execution.IN_ENV and fmt.backend and not backend_available:
263
+ notes.append(f"needs {fmt.backend}")
264
+ if fmt.calibration == "required":
265
+ notes.append("needs calibration data")
266
+ if hw_status == "unsupported":
267
+ notes.append(f"{fmt.requires_precision} not supported on this GPU")
268
+ elif hw_status == "limited":
269
+ notes.append(f"{fmt.requires_precision} limited on this GPU")
270
+ if compat is RuntimeCompat.UNSUPPORTED:
271
+ notes.append("not supported by the target runtime")
272
+ return notes
@@ -0,0 +1,70 @@
1
+ """The :class:`OptimizationGoal` — ZeroQuantz's internal representation of *what
2
+ the user is asking for*.
3
+
4
+ This object is the boundary between intent and planning: natural-language
5
+ requests and slash commands both funnel into mutations of this one type, and the
6
+ recommender/candidate generator read only from it.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from enum import Enum
12
+ from typing import Any
13
+
14
+ from pydantic import BaseModel, Field
15
+
16
+
17
+ class Objective(str, Enum):
18
+ """What to optimize for when strategies trade off against each other."""
19
+
20
+ QUALITY = "quality"
21
+ SPEED = "speed"
22
+ MEMORY = "memory"
23
+ BALANCED = "balanced"
24
+
25
+ def __str__(self) -> str:
26
+ return self.value
27
+
28
+
29
+ class OptimizationGoal(BaseModel):
30
+ """Constraints and preferences that steer quantization strategy selection."""
31
+
32
+ max_vram_gb: float | None = Field(default=None, gt=0)
33
+ max_model_size_gb: float | None = Field(default=None, gt=0)
34
+ runtime: str | None = None
35
+ objective: Objective = Objective.BALANCED
36
+ #: Context length (tokens) to size the KV cache against when checking fit.
37
+ context_length: int = Field(default=4096, gt=0)
38
+ #: Maximum acceptable relative quality loss (e.g. 0.01 == 1%).
39
+ max_quality_loss: float | None = Field(default=None, ge=0)
40
+ #: Optional named GPU to plan *for* (may differ from the local machine).
41
+ target_gpu: str | None = None
42
+
43
+ def with_updates(self, **changes: Any) -> OptimizationGoal:
44
+ """Return a new goal with ``changes`` applied (originals are immutable-ish
45
+ snapshots for undo/history)."""
46
+ return self.model_copy(update={k: v for k, v in changes.items() if v is not None})
47
+
48
+ def is_empty(self) -> bool:
49
+ return (
50
+ self.max_vram_gb is None
51
+ and self.max_model_size_gb is None
52
+ and self.runtime is None
53
+ and self.max_quality_loss is None
54
+ and self.objective is Objective.BALANCED
55
+ )
56
+
57
+ def describe(self) -> list[tuple[str, str]]:
58
+ rows: list[tuple[str, str]] = [("Objective", str(self.objective))]
59
+ if self.max_vram_gb is not None:
60
+ rows.append(("Max VRAM", f"{self.max_vram_gb:g} GB"))
61
+ if self.max_model_size_gb is not None:
62
+ rows.append(("Max model size", f"{self.max_model_size_gb:g} GB"))
63
+ if self.runtime is not None:
64
+ rows.append(("Runtime", self.runtime))
65
+ rows.append(("Context length", f"{self.context_length} tokens"))
66
+ if self.max_quality_loss is not None:
67
+ rows.append(("Max quality loss", f"{self.max_quality_loss * 100:g}%"))
68
+ if self.target_gpu is not None:
69
+ rows.append(("Target GPU", self.target_gpu))
70
+ return rows
@@ -0,0 +1,203 @@
1
+ """``llmfit``-style memory-fit math.
2
+
3
+ Models inference VRAM the way the ``llmfit`` tool and the "can I run this LLM"
4
+ calculators (gpu_poor) do:
5
+
6
+ total = weights + kv_cache + overhead
7
+
8
+ * **weights** — parameter count × effective bytes/weight for the format. For
9
+ whole-model formats (GGUF) the bytes/weight already averages in embeddings; for
10
+ linear-only formats (AWQ/GPTQ/bnb/TorchAO) embeddings stay at compute precision.
11
+ * **kv_cache** — ``2 · n_layers · n_kv_heads · head_dim · ctx · batch · cache_bytes``
12
+ (GQA-aware, batch defaults to 1, as llmfit assumes for decode).
13
+ * **overhead** — a flat 0.5 GB for CUDA/context/buffers (llmfit's constant); no
14
+ separate activation term for single-stream decode.
15
+
16
+ From this we also derive the **maximum context length** that still fits a budget,
17
+ and a rough QLoRA finetuning estimate (gpu_poor's training formula).
18
+
19
+ All sizes are GiB (see :mod:`zeroquantz.utils.units`).
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from typing import TYPE_CHECKING
25
+
26
+ from pydantic import BaseModel
27
+
28
+ from zeroquantz.utils import units
29
+
30
+ if TYPE_CHECKING:
31
+ from zeroquantz.models.metadata import ModelProfile
32
+ from zeroquantz.quantization.catalog import QuantFormat
33
+
34
+ #: Flat CUDA/context/buffer overhead (GiB) — llmfit's constant.
35
+ OVERHEAD_GIB = 0.5
36
+ #: KV-cache element size (fp16) unless a format uses a quantized cache.
37
+ DEFAULT_CACHE_BYTES = 2.0
38
+ #: Bytes/param for the parts a weight-only format keeps at compute precision.
39
+ COMPUTE_BASE_BYTES = 2.0
40
+
41
+
42
+ def weight_bytes(model: ModelProfile, fmt: QuantFormat) -> float:
43
+ """Estimated weight bytes for ``model`` stored in ``fmt``."""
44
+ bpw_bytes = fmt.bytes_per_weight
45
+ if fmt.whole_model:
46
+ return model.parameter_count * bpw_bytes
47
+ quant = model.quantizable_params * bpw_bytes
48
+ non_quant = model.non_quantizable_params * COMPUTE_BASE_BYTES
49
+ return quant + non_quant
50
+
51
+
52
+ def kv_cache_bytes(
53
+ model: ModelProfile,
54
+ context_length: int,
55
+ *,
56
+ batch: int = 1,
57
+ cache_bytes: float = DEFAULT_CACHE_BYTES,
58
+ ) -> float:
59
+ """KV-cache bytes for a context length and batch (GQA-aware, llmfit formula)."""
60
+ layers = model.num_layers
61
+ kv_heads = model.num_key_value_heads or model.num_attention_heads
62
+ head_dim = model.head_dim
63
+ if not (layers and kv_heads and head_dim):
64
+ # llmfit fallback when layer geometry is unknown.
65
+ return 8e-6 * model.parameter_count * context_length
66
+ per_token = 2 * layers * kv_heads * head_dim * cache_bytes
67
+ return per_token * context_length * batch
68
+
69
+
70
+ def kv_bytes_per_token(model: ModelProfile, *, cache_bytes: float = DEFAULT_CACHE_BYTES) -> float:
71
+ layers = model.num_layers
72
+ kv_heads = model.num_key_value_heads or model.num_attention_heads
73
+ head_dim = model.head_dim
74
+ if not (layers and kv_heads and head_dim):
75
+ return 8e-6 * model.parameter_count
76
+ return 2 * layers * kv_heads * head_dim * cache_bytes
77
+
78
+
79
+ class FitReport(BaseModel):
80
+ """Result of a fit check for one model + format + budget + context."""
81
+
82
+ format_id: str
83
+ context_length: int
84
+ batch: int = 1
85
+ weight_gb: float
86
+ kv_cache_gb: float
87
+ overhead_gb: float
88
+ total_gb: float
89
+ budget_gb: float | None = None
90
+ fits: bool = True
91
+ max_context: int | None = None # largest context that fits the budget
92
+
93
+ def breakdown_rows(self) -> list[tuple[str, str]]:
94
+ rows = [
95
+ ("Weights", f"{self.weight_gb:.2f} GB"),
96
+ (f"KV cache @ {self.context_length} tok", f"{self.kv_cache_gb:.2f} GB"),
97
+ ("Overhead", f"{self.overhead_gb:.2f} GB"),
98
+ ("Total", f"{self.total_gb:.2f} GB"),
99
+ ]
100
+ if self.budget_gb is not None:
101
+ rows.append(("Budget", f"{self.budget_gb:g} GB"))
102
+ rows.append(("Fits", "yes" if self.fits else "no"))
103
+ if self.max_context is not None:
104
+ rows.append(("Max context", f"{self.max_context:,} tokens"))
105
+ return rows
106
+
107
+
108
+ def total_vram_gb(
109
+ model: ModelProfile,
110
+ fmt: QuantFormat,
111
+ *,
112
+ context_length: int = 4096,
113
+ batch: int = 1,
114
+ ) -> float:
115
+ weights = weight_bytes(model, fmt)
116
+ kv = kv_cache_bytes(model, context_length, batch=batch)
117
+ return round(units.bytes_to_gb(weights + kv) + OVERHEAD_GIB, 2)
118
+
119
+
120
+ def weight_gb(model: ModelProfile, fmt: QuantFormat) -> float:
121
+ return round(units.bytes_to_gb(weight_bytes(model, fmt)), 2)
122
+
123
+
124
+ def max_context_that_fits(
125
+ model: ModelProfile,
126
+ fmt: QuantFormat,
127
+ budget_gb: float,
128
+ *,
129
+ batch: int = 1,
130
+ ) -> int:
131
+ """Largest context length whose total VRAM still fits ``budget_gb`` (0 if none)."""
132
+ fixed = units.bytes_to_gb(weight_bytes(model, fmt)) + OVERHEAD_GIB
133
+ remaining_gb = budget_gb - fixed
134
+ if remaining_gb <= 0:
135
+ return 0
136
+ per_token_gb = units.bytes_to_gb(kv_bytes_per_token(model)) * batch
137
+ if per_token_gb <= 0:
138
+ return 0
139
+ return int(remaining_gb / per_token_gb)
140
+
141
+
142
+ def fit(
143
+ model: ModelProfile,
144
+ fmt: QuantFormat,
145
+ *,
146
+ budget_gb: float | None = None,
147
+ context_length: int = 4096,
148
+ batch: int = 1,
149
+ ) -> FitReport:
150
+ """Full fit report for a model + format under a budget and context length."""
151
+ w_bytes = weight_bytes(model, fmt)
152
+ kv = kv_cache_bytes(model, context_length, batch=batch)
153
+ weight_g = units.bytes_to_gb(w_bytes)
154
+ kv_g = units.bytes_to_gb(kv)
155
+ total = round(weight_g + kv_g + OVERHEAD_GIB, 2)
156
+ fits = True if budget_gb is None else total <= budget_gb
157
+ max_ctx = (
158
+ max_context_that_fits(model, fmt, budget_gb, batch=batch)
159
+ if budget_gb is not None
160
+ else None
161
+ )
162
+ return FitReport(
163
+ format_id=fmt.id,
164
+ context_length=context_length,
165
+ batch=batch,
166
+ weight_gb=round(weight_g, 2),
167
+ kv_cache_gb=round(kv_g, 2),
168
+ overhead_gb=OVERHEAD_GIB,
169
+ total_gb=total,
170
+ budget_gb=budget_gb,
171
+ fits=fits,
172
+ max_context=max_ctx,
173
+ )
174
+
175
+
176
+ def qlora_finetune_gb(
177
+ model: ModelProfile,
178
+ *,
179
+ context_length: int = 2048,
180
+ batch: int = 1,
181
+ ) -> float:
182
+ """Rough QLoRA (4-bit base) finetuning VRAM estimate (gpu_poor-style).
183
+
184
+ Approximate: 4-bit frozen weights + fp16 KV/activation working set + a small
185
+ LoRA/optimizer allowance + overhead. Clearly an estimate, not a guarantee.
186
+ """
187
+ from zeroquantz.quantization.catalog import get_format
188
+
189
+ nf4 = get_format("nf4")
190
+ base_bytes = weight_bytes(model, nf4) if nf4 else model.parameter_count * 0.53
191
+ # Training keeps activations for backprop: ~2x the decode KV working set here.
192
+ working = 2.0 * kv_cache_bytes(model, context_length, batch=batch)
193
+ activations = (
194
+ (model.hidden_size or 4096)
195
+ * context_length
196
+ * batch
197
+ * (model.num_layers or 32)
198
+ * COMPUTE_BASE_BYTES
199
+ * 0.25 # gradient-checkpointed approximation
200
+ )
201
+ lora_and_opt = units.gb_to_bytes(1.0) # LoRA params + Adam states + grads (small)
202
+ total_bytes = base_bytes + working + activations + lora_and_opt
203
+ return round(units.bytes_to_gb(total_bytes) + OVERHEAD_GIB, 2)
@@ -0,0 +1,66 @@
1
+ """Pareto-frontier computation over quantization candidates.
2
+
3
+ Objectives, by default:
4
+
5
+ * minimize ``estimated_vram_gb`` (memory)
6
+ * minimize ``estimated_quality_risk`` (quality)
7
+ * maximize ``estimated_speedup`` (speed)
8
+
9
+ A candidate is Pareto-optimal if no other candidate is at least as good on every
10
+ objective and strictly better on at least one.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from typing import TYPE_CHECKING
16
+
17
+ if TYPE_CHECKING:
18
+ from zeroquantz.optimization.candidate import QuantizationCandidate
19
+
20
+ # (attribute, direction) where direction is "min" (lower better) or "max".
21
+ Objective = tuple[str, str]
22
+
23
+ DEFAULT_OBJECTIVES: tuple[Objective, ...] = (
24
+ ("estimated_vram_gb", "min"),
25
+ ("estimated_quality_risk", "min"),
26
+ ("estimated_speedup", "max"),
27
+ )
28
+
29
+
30
+ def _no_worse(a_val: float, b_val: float, direction: str) -> bool:
31
+ return a_val <= b_val if direction == "min" else a_val >= b_val
32
+
33
+
34
+ def _strictly_better(a_val: float, b_val: float, direction: str) -> bool:
35
+ return a_val < b_val if direction == "min" else a_val > b_val
36
+
37
+
38
+ def dominates(
39
+ a: QuantizationCandidate,
40
+ b: QuantizationCandidate,
41
+ objectives: tuple[Objective, ...] = DEFAULT_OBJECTIVES,
42
+ ) -> bool:
43
+ """True if ``a`` dominates ``b`` across ``objectives``."""
44
+ no_worse_all = True
45
+ strictly_better_any = False
46
+ for attr, direction in objectives:
47
+ av = float(getattr(a, attr))
48
+ bv = float(getattr(b, attr))
49
+ if not _no_worse(av, bv, direction):
50
+ no_worse_all = False
51
+ break
52
+ if _strictly_better(av, bv, direction):
53
+ strictly_better_any = True
54
+ return no_worse_all and strictly_better_any
55
+
56
+
57
+ def pareto_frontier(
58
+ candidates: list[QuantizationCandidate],
59
+ objectives: tuple[Objective, ...] = DEFAULT_OBJECTIVES,
60
+ ) -> list[QuantizationCandidate]:
61
+ """Return the non-dominated subset of ``candidates`` (input order preserved)."""
62
+ frontier: list[QuantizationCandidate] = []
63
+ for cand in candidates:
64
+ if not any(dominates(other, cand, objectives) for other in candidates if other is not cand):
65
+ frontier.append(cand)
66
+ return frontier