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.
- zeroquantz/__init__.py +14 -0
- zeroquantz/__main__.py +8 -0
- zeroquantz/agent/__init__.py +16 -0
- zeroquantz/agent/dispatcher.py +520 -0
- zeroquantz/agent/intents.py +46 -0
- zeroquantz/agent/parser.py +255 -0
- zeroquantz/benchmark/__init__.py +7 -0
- zeroquantz/benchmark/latency.py +66 -0
- zeroquantz/benchmark/memory.py +41 -0
- zeroquantz/benchmark/quality.py +38 -0
- zeroquantz/benchmark/runner.py +151 -0
- zeroquantz/cli/__init__.py +7 -0
- zeroquantz/cli/app.py +98 -0
- zeroquantz/cli/commands.py +459 -0
- zeroquantz/cli/interactive.py +56 -0
- zeroquantz/core/__init__.py +7 -0
- zeroquantz/core/artifacts.py +179 -0
- zeroquantz/core/context.py +127 -0
- zeroquantz/core/events.py +30 -0
- zeroquantz/core/exceptions.py +105 -0
- zeroquantz/core/session.py +202 -0
- zeroquantz/core/subenv.py +202 -0
- zeroquantz/deploy/__init__.py +25 -0
- zeroquantz/deploy/assets.py +161 -0
- zeroquantz/deploy/launcher.py +80 -0
- zeroquantz/deploy/runtime_env.py +66 -0
- zeroquantz/deploy/targets.py +154 -0
- zeroquantz/export/__init__.py +8 -0
- zeroquantz/export/exporter.py +68 -0
- zeroquantz/export/report.py +203 -0
- zeroquantz/hardware/__init__.py +15 -0
- zeroquantz/hardware/capabilities.py +152 -0
- zeroquantz/hardware/detector.py +200 -0
- zeroquantz/hardware/gpu.py +31 -0
- zeroquantz/models/__init__.py +8 -0
- zeroquantz/models/architecture.py +168 -0
- zeroquantz/models/downloader.py +161 -0
- zeroquantz/models/hf_auth.py +105 -0
- zeroquantz/models/inspector.py +249 -0
- zeroquantz/models/metadata.py +108 -0
- zeroquantz/models/search.py +71 -0
- zeroquantz/optimization/__init__.py +22 -0
- zeroquantz/optimization/candidate.py +272 -0
- zeroquantz/optimization/constraints.py +70 -0
- zeroquantz/optimization/fit.py +203 -0
- zeroquantz/optimization/pareto.py +66 -0
- zeroquantz/optimization/planner.py +297 -0
- zeroquantz/optimization/recommender.py +149 -0
- zeroquantz/profiling/__init__.py +18 -0
- zeroquantz/profiling/calibration.py +74 -0
- zeroquantz/profiling/sensitivity.py +234 -0
- zeroquantz/quantization/__init__.py +17 -0
- zeroquantz/quantization/backends/__init__.py +8 -0
- zeroquantz/quantization/backends/bitsandbytes.py +210 -0
- zeroquantz/quantization/backends/torchao.py +198 -0
- zeroquantz/quantization/base.py +136 -0
- zeroquantz/quantization/catalog.py +321 -0
- zeroquantz/quantization/config.py +106 -0
- zeroquantz/quantization/gguf_pipeline.py +210 -0
- zeroquantz/quantization/isolated.py +248 -0
- zeroquantz/quantization/memory.py +133 -0
- zeroquantz/quantization/native.py +91 -0
- zeroquantz/quantization/registry.py +101 -0
- zeroquantz/render.py +341 -0
- zeroquantz/runtimes/__init__.py +18 -0
- zeroquantz/runtimes/base.py +64 -0
- zeroquantz/runtimes/compatibility.py +91 -0
- zeroquantz/runtimes/registry.py +70 -0
- zeroquantz/runtimes/transformers.py +53 -0
- zeroquantz/runtimes/vllm.py +83 -0
- zeroquantz/tui/__init__.py +13 -0
- zeroquantz/tui/app.py +77 -0
- zeroquantz/tui/banner.py +47 -0
- zeroquantz/tui/screens/__init__.py +25 -0
- zeroquantz/tui/screens/confirm.py +41 -0
- zeroquantz/tui/screens/execute.py +194 -0
- zeroquantz/tui/screens/model_select.py +206 -0
- zeroquantz/tui/screens/plan.py +177 -0
- zeroquantz/tui/screens/quantize_select.py +272 -0
- zeroquantz/tui/screens/settings.py +219 -0
- zeroquantz/tui/screens/token.py +94 -0
- zeroquantz/tui/screens/welcome.py +128 -0
- zeroquantz/tui/screens/workspace.py +175 -0
- zeroquantz/tui/styles/app.tcss +424 -0
- zeroquantz/tui/widgets/__init__.py +9 -0
- zeroquantz/tui/widgets/chip.py +36 -0
- zeroquantz/tui/widgets/sidebar.py +107 -0
- zeroquantz/tui/widgets/status_bar.py +43 -0
- zeroquantz/utils/__init__.py +8 -0
- zeroquantz/utils/config.py +46 -0
- zeroquantz/utils/env.py +78 -0
- zeroquantz/utils/logging.py +73 -0
- zeroquantz/utils/metrics.py +98 -0
- zeroquantz/utils/paths.py +57 -0
- zeroquantz/utils/units.py +134 -0
- zeroquantz/verification/__init__.py +17 -0
- zeroquantz/verification/logits.py +55 -0
- zeroquantz/verification/report.py +186 -0
- zeroquantz/verification/weights.py +44 -0
- zeroquantz/version.py +8 -0
- zeroquantz-0.1.0.dist-info/METADATA +72 -0
- zeroquantz-0.1.0.dist-info/RECORD +105 -0
- zeroquantz-0.1.0.dist-info/WHEEL +4 -0
- zeroquantz-0.1.0.dist-info/entry_points.txt +2 -0
- 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
|