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,206 @@
|
|
|
1
|
+
"""Wizard entry: search for a model and inspect it (metadata only).
|
|
2
|
+
|
|
3
|
+
As the user types, the Hub is searched live (debounced) and results are shown
|
|
4
|
+
ranked by popularity. Selecting one (↑↓ + Enter, or a mouse click) inspects it
|
|
5
|
+
and advances straight to the quantization step — the model's details are shown
|
|
6
|
+
there, so this screen stays a clean search view.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from rich.text import Text
|
|
12
|
+
from textual import on, work
|
|
13
|
+
from textual.app import ComposeResult
|
|
14
|
+
from textual.binding import Binding
|
|
15
|
+
from textual.containers import Vertical
|
|
16
|
+
from textual.screen import Screen
|
|
17
|
+
from textual.widgets import Footer, Input, OptionList, Static
|
|
18
|
+
from textual.widgets.option_list import Option
|
|
19
|
+
|
|
20
|
+
from zeroquantz.agent.intents import Intent, IntentKind
|
|
21
|
+
from zeroquantz.models.search import ModelHit, search_models
|
|
22
|
+
|
|
23
|
+
_SUGGESTIONS: tuple[tuple[str, str], ...] = (
|
|
24
|
+
("Qwen/Qwen3-8B", "8.2B · dense · GQA"),
|
|
25
|
+
("Qwen/Qwen2.5-0.5B-Instruct", "0.5B · tiny, fast to inspect"),
|
|
26
|
+
("meta-llama/Llama-3.1-8B-Instruct", "8B · gated repo"),
|
|
27
|
+
("mistralai/Mistral-7B-Instruct-v0.3", "7B · popular baseline"),
|
|
28
|
+
("microsoft/Phi-3.5-mini-instruct", "3.8B · small, strong"),
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
_DEBOUNCE_SECONDS = 0.35
|
|
32
|
+
_MIN_QUERY = 2
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class ModelSelectScreen(Screen):
|
|
36
|
+
BINDINGS = [Binding("escape", "back", "Back")]
|
|
37
|
+
|
|
38
|
+
def compose(self) -> ComposeResult:
|
|
39
|
+
with Vertical(id="model-page"):
|
|
40
|
+
yield Static(
|
|
41
|
+
"Search a model to optimize · ↑↓ to move · Enter or click to load",
|
|
42
|
+
classes="page-subtitle",
|
|
43
|
+
)
|
|
44
|
+
yield Input(placeholder="search models… e.g. qwen3, llama, phi", id="model-input")
|
|
45
|
+
yield Static("", id="search-status", classes="page-hint")
|
|
46
|
+
yield OptionList(id="model-suggestions")
|
|
47
|
+
yield Footer()
|
|
48
|
+
|
|
49
|
+
def on_mount(self) -> None:
|
|
50
|
+
self._debounce = None
|
|
51
|
+
self._pending_query = ""
|
|
52
|
+
self._suppress_search = False
|
|
53
|
+
self._loading = False
|
|
54
|
+
self._show_defaults()
|
|
55
|
+
self.query_one("#model-input", Input).focus()
|
|
56
|
+
|
|
57
|
+
def on_screen_resume(self) -> None:
|
|
58
|
+
# Returning from the quantization step: clear the transient "inspecting…"
|
|
59
|
+
# status and restore focus for continued searching.
|
|
60
|
+
self._loading = False
|
|
61
|
+
count = self.query_one("#model-suggestions", OptionList).option_count
|
|
62
|
+
self.query_one("#search-status", Static).update(
|
|
63
|
+
Text(f"{count} models" if count else "", style="#6e7681")
|
|
64
|
+
)
|
|
65
|
+
self.query_one("#model-input", Input).focus()
|
|
66
|
+
|
|
67
|
+
# ---- live search --------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
@on(Input.Changed, "#model-input")
|
|
70
|
+
def _changed(self, event: Input.Changed) -> None:
|
|
71
|
+
if self._suppress_search:
|
|
72
|
+
self._suppress_search = False
|
|
73
|
+
return
|
|
74
|
+
self._pending_query = event.value.strip()
|
|
75
|
+
if self._debounce is not None:
|
|
76
|
+
self._debounce.stop()
|
|
77
|
+
self._debounce = self.set_timer(_DEBOUNCE_SECONDS, self._fire_search)
|
|
78
|
+
|
|
79
|
+
def _fire_search(self) -> None:
|
|
80
|
+
query = self._pending_query
|
|
81
|
+
if len(query) < _MIN_QUERY:
|
|
82
|
+
self._show_defaults()
|
|
83
|
+
return
|
|
84
|
+
self.query_one("#search-status", Static).update(Text(f"searching “{query}” …", style="#d29922"))
|
|
85
|
+
self._search(query)
|
|
86
|
+
|
|
87
|
+
@work(thread=True, exclusive=True, group="model-search")
|
|
88
|
+
def _search(self, query: str) -> None:
|
|
89
|
+
try:
|
|
90
|
+
hits: list[ModelHit] | None = search_models(query, limit=20)
|
|
91
|
+
except Exception:
|
|
92
|
+
hits = None
|
|
93
|
+
self.app.call_from_thread(self._populate, query, hits)
|
|
94
|
+
|
|
95
|
+
def _populate(self, query: str, hits: list[ModelHit] | None) -> None:
|
|
96
|
+
if self.query_one("#model-input", Input).value.strip() != query:
|
|
97
|
+
return
|
|
98
|
+
options = self.query_one("#model-suggestions", OptionList)
|
|
99
|
+
status = self.query_one("#search-status", Static)
|
|
100
|
+
if hits is None:
|
|
101
|
+
status.update(Text("offline — showing common models", style="#d29922"))
|
|
102
|
+
self._show_defaults()
|
|
103
|
+
return
|
|
104
|
+
if not hits:
|
|
105
|
+
status.update(Text("no matching models", style="#6e7681"))
|
|
106
|
+
options.clear_options()
|
|
107
|
+
return
|
|
108
|
+
status.update(Text(f"{len(hits)} models — most downloaded first", style="#6e7681"))
|
|
109
|
+
options.clear_options()
|
|
110
|
+
for hit in hits:
|
|
111
|
+
text = Text()
|
|
112
|
+
text.append("● ", style="#3fb950")
|
|
113
|
+
text.append(hit.id, style="bold #58a6ff")
|
|
114
|
+
meta = hit.meta_line()
|
|
115
|
+
if meta:
|
|
116
|
+
text.append(f" {meta}", style="#6e7681")
|
|
117
|
+
options.add_option(Option(text, id=hit.id))
|
|
118
|
+
|
|
119
|
+
def _show_defaults(self) -> None:
|
|
120
|
+
options = self.query_one("#model-suggestions", OptionList)
|
|
121
|
+
options.clear_options()
|
|
122
|
+
for model_id, note in _SUGGESTIONS:
|
|
123
|
+
text = Text()
|
|
124
|
+
text.append("● ", style="#3fb950")
|
|
125
|
+
text.append(model_id, style="bold #58a6ff")
|
|
126
|
+
text.append(f" {note}", style="#6e7681")
|
|
127
|
+
options.add_option(Option(text, id=model_id))
|
|
128
|
+
|
|
129
|
+
# ---- navigation & selection --------------------------------------------
|
|
130
|
+
|
|
131
|
+
def on_key(self, event) -> None: # noqa: ANN001
|
|
132
|
+
"""Drive the results list with ↑/↓ while the search box keeps focus."""
|
|
133
|
+
if self._loading:
|
|
134
|
+
return
|
|
135
|
+
if not self.query_one("#model-input", Input).has_focus:
|
|
136
|
+
return
|
|
137
|
+
if event.key == "down":
|
|
138
|
+
self._move_highlight(1)
|
|
139
|
+
event.stop()
|
|
140
|
+
event.prevent_default()
|
|
141
|
+
elif event.key == "up":
|
|
142
|
+
self._move_highlight(-1)
|
|
143
|
+
event.stop()
|
|
144
|
+
event.prevent_default()
|
|
145
|
+
|
|
146
|
+
def _move_highlight(self, delta: int) -> None:
|
|
147
|
+
options = self.query_one("#model-suggestions", OptionList)
|
|
148
|
+
count = options.option_count
|
|
149
|
+
if count == 0:
|
|
150
|
+
return
|
|
151
|
+
current = options.highlighted
|
|
152
|
+
nxt = 0 if current is None else max(0, min(count - 1, current + delta))
|
|
153
|
+
options.highlighted = nxt
|
|
154
|
+
try:
|
|
155
|
+
options.scroll_to_highlight()
|
|
156
|
+
except Exception:
|
|
157
|
+
pass
|
|
158
|
+
|
|
159
|
+
@on(Input.Submitted, "#model-input")
|
|
160
|
+
def _submit(self, event: Input.Submitted) -> None:
|
|
161
|
+
options = self.query_one("#model-suggestions", OptionList)
|
|
162
|
+
model_id: str | None = None
|
|
163
|
+
if options.highlighted is not None and options.option_count:
|
|
164
|
+
model_id = str(options.get_option_at_index(options.highlighted).id)
|
|
165
|
+
if not model_id:
|
|
166
|
+
model_id = event.value.strip()
|
|
167
|
+
if model_id:
|
|
168
|
+
self._load(model_id)
|
|
169
|
+
|
|
170
|
+
@on(OptionList.OptionSelected, "#model-suggestions")
|
|
171
|
+
def _pick(self, event: OptionList.OptionSelected) -> None:
|
|
172
|
+
model_id = str(event.option.id)
|
|
173
|
+
self._suppress_search = True
|
|
174
|
+
self.query_one("#model-input", Input).value = model_id
|
|
175
|
+
if self._debounce is not None:
|
|
176
|
+
self._debounce.stop()
|
|
177
|
+
self._load(model_id)
|
|
178
|
+
|
|
179
|
+
# ---- loading ------------------------------------------------------------
|
|
180
|
+
|
|
181
|
+
def _load(self, model_id: str) -> None:
|
|
182
|
+
if self._loading:
|
|
183
|
+
return
|
|
184
|
+
self._loading = True
|
|
185
|
+
self.query_one("#search-status", Static).update(
|
|
186
|
+
Text(f"inspecting {model_id} … (metadata only, no weights downloaded)", style="#d29922")
|
|
187
|
+
)
|
|
188
|
+
self._inspect(model_id)
|
|
189
|
+
|
|
190
|
+
@work(thread=True, exclusive=True, group="model-inspect")
|
|
191
|
+
def _inspect(self, model_id: str) -> None:
|
|
192
|
+
result = self.app.dispatcher.dispatch(
|
|
193
|
+
Intent(IntentKind.LOAD_MODEL, {"model_id": model_id}), self.app.ctx
|
|
194
|
+
)
|
|
195
|
+
self.app.call_from_thread(self._after_inspect, result)
|
|
196
|
+
|
|
197
|
+
def _after_inspect(self, result) -> None: # noqa: ANN001
|
|
198
|
+
self._loading = False
|
|
199
|
+
if result.success:
|
|
200
|
+
self.app.push_quantize_select() # decide the plan first; download happens in Execute
|
|
201
|
+
else:
|
|
202
|
+
message = (result.message or "could not load model").splitlines()[0]
|
|
203
|
+
self.query_one("#search-status", Static).update(Text(f"✗ {message}", style="#f85149"))
|
|
204
|
+
|
|
205
|
+
def action_back(self) -> None:
|
|
206
|
+
self.app.pop_screen()
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""Plan screen: review the full decision (model + quantization + deploy target)
|
|
2
|
+
before executing. Choosing everything up front, then Execute runs
|
|
3
|
+
download → quantize → deploy.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from rich.console import Group
|
|
9
|
+
from rich.panel import Panel
|
|
10
|
+
from rich.text import Text
|
|
11
|
+
from textual import on
|
|
12
|
+
from textual.app import ComposeResult
|
|
13
|
+
from textual.binding import Binding
|
|
14
|
+
from textual.containers import Horizontal, Vertical, VerticalScroll
|
|
15
|
+
from textual.screen import Screen
|
|
16
|
+
from textual.widgets import Footer, Static
|
|
17
|
+
|
|
18
|
+
from zeroquantz.deploy import deploy_targets_for, get_target
|
|
19
|
+
from zeroquantz.optimization.planner import MixedPrecisionPlanner
|
|
20
|
+
from zeroquantz.quantization.catalog import get_format
|
|
21
|
+
from zeroquantz.render import render_plan
|
|
22
|
+
from zeroquantz.tui.widgets import Chip
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class PlanScreen(Screen):
|
|
26
|
+
BINDINGS = [
|
|
27
|
+
Binding("escape", "back", "Back"),
|
|
28
|
+
Binding("enter", "execute", "Execute"),
|
|
29
|
+
Binding("left", "target_prev", "deploy target", priority=True),
|
|
30
|
+
Binding("right", "target_next", "deploy target", priority=True, show=False),
|
|
31
|
+
Binding("r", "save_assets", "Save plan"),
|
|
32
|
+
Binding("h", "home", "Home"),
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
def compose(self) -> ComposeResult:
|
|
36
|
+
with Vertical(id="plan-page"):
|
|
37
|
+
with Horizontal(classes="ctl-row"):
|
|
38
|
+
yield Static("Deploy to", classes="ctl-label")
|
|
39
|
+
with Horizontal(id="deploy-chips", classes="ctl-row"):
|
|
40
|
+
yield Static("", id="deploy-chip-holder")
|
|
41
|
+
with VerticalScroll(id="plan-scroll"):
|
|
42
|
+
yield Static("", id="plan-body")
|
|
43
|
+
yield Static("", id="plan-status", classes="page-hint")
|
|
44
|
+
yield Footer()
|
|
45
|
+
|
|
46
|
+
def on_mount(self) -> None:
|
|
47
|
+
candidate = self.app.selected_candidate
|
|
48
|
+
fmt = get_format(candidate.format_id) if candidate else None
|
|
49
|
+
targets = deploy_targets_for(fmt) if fmt else []
|
|
50
|
+
self._targets = targets
|
|
51
|
+
# default deploy target: the goal runtime if compatible, else the first.
|
|
52
|
+
goal_rt = self.app.ctx.session.goal.runtime
|
|
53
|
+
default = next((t for t in targets if t.name == goal_rt), targets[0] if targets else None)
|
|
54
|
+
self.app.deploy_target = default.name if default else None
|
|
55
|
+
holder = self.query_one("#deploy-chips", Horizontal)
|
|
56
|
+
holder.remove_children()
|
|
57
|
+
for t in targets:
|
|
58
|
+
holder.mount(Chip(t.label, group="deploy", value=t.name,
|
|
59
|
+
selected=t.name == self.app.deploy_target))
|
|
60
|
+
self._refresh_body()
|
|
61
|
+
|
|
62
|
+
# ---- deploy target selection -------------------------------------------
|
|
63
|
+
|
|
64
|
+
@on(Chip.Selected)
|
|
65
|
+
def _chip(self, event: Chip.Selected) -> None:
|
|
66
|
+
if event.group == "deploy":
|
|
67
|
+
self.app.deploy_target = event.value
|
|
68
|
+
for chip in self.query(Chip):
|
|
69
|
+
chip.set_selected(chip.chip_value == event.value)
|
|
70
|
+
self._refresh_body()
|
|
71
|
+
|
|
72
|
+
def action_target_next(self) -> None:
|
|
73
|
+
self._cycle_target(1)
|
|
74
|
+
|
|
75
|
+
def action_target_prev(self) -> None:
|
|
76
|
+
self._cycle_target(-1)
|
|
77
|
+
|
|
78
|
+
def _cycle_target(self, direction: int) -> None:
|
|
79
|
+
if not self._targets:
|
|
80
|
+
return
|
|
81
|
+
names = [t.name for t in self._targets]
|
|
82
|
+
idx = names.index(self.app.deploy_target) if self.app.deploy_target in names else 0
|
|
83
|
+
self.app.deploy_target = names[(idx + direction) % len(names)]
|
|
84
|
+
for chip in self.query(Chip):
|
|
85
|
+
chip.set_selected(chip.chip_value == self.app.deploy_target)
|
|
86
|
+
self._refresh_body()
|
|
87
|
+
|
|
88
|
+
# ---- body ---------------------------------------------------------------
|
|
89
|
+
|
|
90
|
+
def _refresh_body(self) -> None:
|
|
91
|
+
candidate = self.app.selected_candidate
|
|
92
|
+
ctx = self.app.ctx
|
|
93
|
+
model = ctx.session.model_profile
|
|
94
|
+
if candidate is None or model is None:
|
|
95
|
+
self.query_one("#plan-body", Static).update(Text("Nothing selected.", style="#d29922"))
|
|
96
|
+
return
|
|
97
|
+
fmt = get_format(candidate.format_id)
|
|
98
|
+
target = get_target(self.app.deploy_target) if self.app.deploy_target else None
|
|
99
|
+
|
|
100
|
+
fit_ok = candidate.fits_memory
|
|
101
|
+
header = Text()
|
|
102
|
+
header.append(f"{model.model_id} · {candidate.label}\n", style="bold #58a6ff")
|
|
103
|
+
header.append(
|
|
104
|
+
f"{candidate.estimated_size_gb:.1f} GB · {candidate.estimated_vram_gb:.1f} GB VRAM · "
|
|
105
|
+
f"quality {candidate.quality_risk_label} · {candidate.estimated_speedup:.1f}x · "
|
|
106
|
+
f"{candidate.calibration} calibration",
|
|
107
|
+
style="#8b949e",
|
|
108
|
+
)
|
|
109
|
+
header.append(" ")
|
|
110
|
+
header.append("✓ fits" if fit_ok else "✗ over budget", style="#3fb950" if fit_ok else "#f85149")
|
|
111
|
+
parts = [Panel(header, title="[b]Quantization[/b]",
|
|
112
|
+
border_style="#3fb950" if fit_ok else "#d29922")]
|
|
113
|
+
|
|
114
|
+
if target is not None:
|
|
115
|
+
compatible = target.name in fmt.runtimes
|
|
116
|
+
dep = Text()
|
|
117
|
+
dep.append(f"{target.label}", style="bold #58a6ff")
|
|
118
|
+
dep.append(" ")
|
|
119
|
+
dep.append("✓ compatible" if compatible else "✗ not supported for this format",
|
|
120
|
+
style="#3fb950" if compatible else "#f85149")
|
|
121
|
+
dep.append(f"\n{target.note}\n\n", style="#8b949e")
|
|
122
|
+
dep.append("Serve command\n", style="#8b949e")
|
|
123
|
+
dep.append(f" {target.serve_command(_deploy_path(ctx, model), fmt)}", style="#3fb950")
|
|
124
|
+
parts.append(Panel(dep, title="[b]Deploy target[/b]",
|
|
125
|
+
border_style="#3fb950" if compatible else "#f85149"))
|
|
126
|
+
|
|
127
|
+
if candidate.method == "mixed":
|
|
128
|
+
plan = MixedPrecisionPlanner.plan(model, ctx.hardware, ctx.session.goal, ctx.sensitivity)
|
|
129
|
+
ctx.session.plan = plan
|
|
130
|
+
parts.append(render_plan(plan))
|
|
131
|
+
|
|
132
|
+
self.query_one("#plan-body", Static).update(Group(*parts))
|
|
133
|
+
self.query_one("#plan-status", Static).update(
|
|
134
|
+
Text("Enter: execute (download → quantize → deploy) · r: save plan & assets only",
|
|
135
|
+
style="#6e7681")
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
# ---- actions ------------------------------------------------------------
|
|
139
|
+
|
|
140
|
+
def action_execute(self) -> None:
|
|
141
|
+
self.app.push_execute()
|
|
142
|
+
|
|
143
|
+
def action_save_assets(self) -> None:
|
|
144
|
+
from pathlib import Path
|
|
145
|
+
|
|
146
|
+
from zeroquantz.deploy import generate_assets
|
|
147
|
+
from zeroquantz.export.exporter import Exporter
|
|
148
|
+
from zeroquantz.export.report import build_report
|
|
149
|
+
|
|
150
|
+
ctx = self.app.ctx
|
|
151
|
+
candidate = self.app.selected_candidate
|
|
152
|
+
model = ctx.session.model_profile
|
|
153
|
+
fmt = get_format(candidate.format_id)
|
|
154
|
+
target = get_target(self.app.deploy_target)
|
|
155
|
+
name = (ctx.session.model_id or "model").split("/")[-1]
|
|
156
|
+
out = Path.cwd() / f"zeroquantz-{name}-{candidate.method}"
|
|
157
|
+
status = self.query_one("#plan-status", Static)
|
|
158
|
+
try:
|
|
159
|
+
report = build_report(model, config=candidate.config, hardware=ctx.hardware,
|
|
160
|
+
goal=ctx.session.goal,
|
|
161
|
+
plan=ctx.session.plan if candidate.method == "mixed" else None)
|
|
162
|
+
Exporter.write_report(report, out)
|
|
163
|
+
bundle = generate_assets(_deploy_path(ctx, model), fmt, target, out / "deploy")
|
|
164
|
+
status.update(Text(f"✓ Plan + deploy assets written to {out}", style="#3fb950"))
|
|
165
|
+
except Exception as exc: # pragma: no cover
|
|
166
|
+
status.update(Text(f"✗ {exc}", style="#f85149"))
|
|
167
|
+
|
|
168
|
+
def action_back(self) -> None:
|
|
169
|
+
self.app.pop_screen()
|
|
170
|
+
|
|
171
|
+
def action_home(self) -> None:
|
|
172
|
+
self.app.go_home()
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _deploy_path(ctx, model) -> str: # noqa: ANN001
|
|
176
|
+
"""Path to reference in deploy assets: local quantized dir, else the model id."""
|
|
177
|
+
return ctx.session.model_local_path or ctx.session.model_id or model.model_id
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
"""Wizard step 2: pick a quantization strategy from the full catalog.
|
|
2
|
+
|
|
3
|
+
Constraints (budget, context, runtime, objective, filter) are inline **segmented
|
|
4
|
+
chips** above the table — every option is visible; click one (or focus it and
|
|
5
|
+
press Enter) to change it. The fit column, max-context, and ranking update live.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from rich.text import Text
|
|
11
|
+
from textual import on
|
|
12
|
+
from textual.app import ComposeResult
|
|
13
|
+
from textual.binding import Binding
|
|
14
|
+
from textual.containers import Horizontal, Vertical
|
|
15
|
+
from textual.screen import Screen
|
|
16
|
+
from textual.widgets import DataTable, Footer, Static
|
|
17
|
+
|
|
18
|
+
from zeroquantz.optimization.candidate import CandidateGenerator
|
|
19
|
+
from zeroquantz.optimization.constraints import Objective
|
|
20
|
+
from zeroquantz.optimization.recommender import Recommender
|
|
21
|
+
from zeroquantz.tui.widgets import Chip
|
|
22
|
+
from zeroquantz.utils import units
|
|
23
|
+
|
|
24
|
+
_EXEC_LABEL = {"in_env": "in-env", "isolated": "sub-env", "external": "produce", "planned": "roadmap"}
|
|
25
|
+
_OBJECTIVES = (("Balanced", "balanced"), ("Quality", "quality"), ("Speed", "speed"), ("Memory", "memory"))
|
|
26
|
+
_RUNTIMES = (
|
|
27
|
+
("any", "any"), ("vLLM", "vllm"), ("Transformers", "transformers"),
|
|
28
|
+
("llama.cpp", "llamacpp"), ("ExLlamaV2", "exllamav2"),
|
|
29
|
+
)
|
|
30
|
+
_CONTEXTS = (1024, 2048, 4096, 8192, 16384, 32768, 65536, 131072)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class QuantizeSelectScreen(Screen):
|
|
34
|
+
BINDINGS = [
|
|
35
|
+
Binding("escape", "back", "Back"),
|
|
36
|
+
Binding("enter", "select", "Select"),
|
|
37
|
+
Binding("left", "obj_prev", "objective", priority=True),
|
|
38
|
+
Binding("right", "obj_next", "objective", priority=True, show=False),
|
|
39
|
+
Binding("shift+left", "rt_prev", "runtime", priority=True),
|
|
40
|
+
Binding("shift+right", "rt_next", "runtime", priority=True, show=False),
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
def __init__(self) -> None:
|
|
44
|
+
super().__init__()
|
|
45
|
+
self._ranked: list = []
|
|
46
|
+
|
|
47
|
+
def compose(self) -> ComposeResult:
|
|
48
|
+
with Vertical(id="quant-page"):
|
|
49
|
+
yield Static("", id="quant-model")
|
|
50
|
+
with Horizontal(classes="ctl-row"):
|
|
51
|
+
yield Static("Budget", classes="ctl-label")
|
|
52
|
+
yield Chip("–", group="budget", value="-")
|
|
53
|
+
yield Static("", id="budget-value", classes="ctl-value")
|
|
54
|
+
yield Chip("+", group="budget", value="+")
|
|
55
|
+
yield Static("Context", classes="ctl-label")
|
|
56
|
+
yield Chip("–", group="context", value="-")
|
|
57
|
+
yield Static("", id="context-value", classes="ctl-value")
|
|
58
|
+
yield Chip("+", group="context", value="+")
|
|
59
|
+
with Horizontal(classes="ctl-row"):
|
|
60
|
+
yield Static("Objective", classes="ctl-label")
|
|
61
|
+
for label, value in _OBJECTIVES:
|
|
62
|
+
yield Chip(label, group="objective", value=value)
|
|
63
|
+
yield Static("Show", classes="ctl-label")
|
|
64
|
+
yield Chip("all", group="show", value="all")
|
|
65
|
+
yield Chip("fits", group="show", value="fits")
|
|
66
|
+
with Horizontal(classes="ctl-row"):
|
|
67
|
+
yield Static("Runtime", classes="ctl-label")
|
|
68
|
+
for label, value in _RUNTIMES:
|
|
69
|
+
yield Chip(label, group="runtime", value=value)
|
|
70
|
+
yield DataTable(id="quant-table", cursor_type="row", zebra_stripes=True)
|
|
71
|
+
yield Footer()
|
|
72
|
+
|
|
73
|
+
def on_mount(self) -> None:
|
|
74
|
+
table = self.query_one("#quant-table", DataTable)
|
|
75
|
+
table.add_columns(
|
|
76
|
+
"Strategy", "bpw", "Size", "VRAM", "Fits", "Max ctx", "Quality", "Speed", "Run", "Exec"
|
|
77
|
+
)
|
|
78
|
+
goal = self.app.ctx.session.goal
|
|
79
|
+
if goal.max_vram_gb is None and self.app.ctx.hardware.total_vram_gb:
|
|
80
|
+
self.app.ctx.session.goal = goal.with_updates(
|
|
81
|
+
max_vram_gb=round(self.app.ctx.hardware.total_vram_gb)
|
|
82
|
+
)
|
|
83
|
+
self._refresh()
|
|
84
|
+
self._sync_chips()
|
|
85
|
+
table.focus()
|
|
86
|
+
|
|
87
|
+
# ---- chip interactions --------------------------------------------------
|
|
88
|
+
|
|
89
|
+
@on(Chip.Selected)
|
|
90
|
+
def _chip(self, event: Chip.Selected) -> None:
|
|
91
|
+
goal = self.app.ctx.session.goal
|
|
92
|
+
g, v = event.group, event.value
|
|
93
|
+
if g == "budget":
|
|
94
|
+
cur = goal.max_vram_gb or 8
|
|
95
|
+
self.app.ctx.session.goal = goal.with_updates(max_vram_gb=max(1.0, min(256.0, cur + (1 if v == "+" else -1))))
|
|
96
|
+
elif g == "context":
|
|
97
|
+
idx = min(range(len(_CONTEXTS)), key=lambda i: abs(_CONTEXTS[i] - goal.context_length))
|
|
98
|
+
idx = max(0, min(len(_CONTEXTS) - 1, idx + (1 if v == "+" else -1)))
|
|
99
|
+
self.app.ctx.session.goal = goal.with_updates(context_length=_CONTEXTS[idx])
|
|
100
|
+
elif g == "objective":
|
|
101
|
+
self.app.ctx.session.goal = goal.model_copy(update={"objective": Objective(v)})
|
|
102
|
+
elif g == "runtime":
|
|
103
|
+
self.app.ctx.session.goal = goal.model_copy(update={"runtime": None if v == "any" else v})
|
|
104
|
+
elif g == "show":
|
|
105
|
+
self.app.quant_fits_only = v == "fits"
|
|
106
|
+
self._refresh()
|
|
107
|
+
self._sync_chips()
|
|
108
|
+
|
|
109
|
+
def action_obj_next(self) -> None:
|
|
110
|
+
self._cycle_objective(1)
|
|
111
|
+
|
|
112
|
+
def action_obj_prev(self) -> None:
|
|
113
|
+
self._cycle_objective(-1)
|
|
114
|
+
|
|
115
|
+
def action_rt_next(self) -> None:
|
|
116
|
+
self._cycle_runtime(1)
|
|
117
|
+
|
|
118
|
+
def action_rt_prev(self) -> None:
|
|
119
|
+
self._cycle_runtime(-1)
|
|
120
|
+
|
|
121
|
+
def _cycle_objective(self, direction: int) -> None:
|
|
122
|
+
goal = self.app.ctx.session.goal
|
|
123
|
+
values = [v for _, v in _OBJECTIVES]
|
|
124
|
+
idx = values.index(str(goal.objective)) if str(goal.objective) in values else 0
|
|
125
|
+
new = values[(idx + direction) % len(values)]
|
|
126
|
+
self.app.ctx.session.goal = goal.model_copy(update={"objective": Objective(new)})
|
|
127
|
+
self._refresh()
|
|
128
|
+
self._sync_chips()
|
|
129
|
+
|
|
130
|
+
def _cycle_runtime(self, direction: int) -> None:
|
|
131
|
+
goal = self.app.ctx.session.goal
|
|
132
|
+
values = [v for _, v in _RUNTIMES]
|
|
133
|
+
current = goal.runtime or "any"
|
|
134
|
+
idx = values.index(current) if current in values else 0
|
|
135
|
+
new = values[(idx + direction) % len(values)]
|
|
136
|
+
self.app.ctx.session.goal = goal.model_copy(update={"runtime": None if new == "any" else new})
|
|
137
|
+
self._refresh()
|
|
138
|
+
self._sync_chips()
|
|
139
|
+
|
|
140
|
+
def _sync_chips(self) -> None:
|
|
141
|
+
goal = self.app.ctx.session.goal
|
|
142
|
+
fits_only = getattr(self.app, "quant_fits_only", False)
|
|
143
|
+
for chip in self.query(Chip):
|
|
144
|
+
if chip.chip_group == "objective":
|
|
145
|
+
chip.set_selected(chip.chip_value == str(goal.objective))
|
|
146
|
+
elif chip.chip_group == "runtime":
|
|
147
|
+
chip.set_selected(chip.chip_value == (goal.runtime or "any"))
|
|
148
|
+
elif chip.chip_group == "show":
|
|
149
|
+
chip.set_selected(chip.chip_value == ("fits" if fits_only else "all"))
|
|
150
|
+
self.query_one("#budget-value", Static).update(
|
|
151
|
+
Text(f"{goal.max_vram_gb:g} GB" if goal.max_vram_gb else "unset", style="bold #3fb950")
|
|
152
|
+
)
|
|
153
|
+
self.query_one("#context-value", Static).update(
|
|
154
|
+
Text(f"{goal.context_length}", style="bold #58a6ff")
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
# ---- table --------------------------------------------------------------
|
|
158
|
+
|
|
159
|
+
def _refresh(self) -> None:
|
|
160
|
+
ctx = self.app.ctx
|
|
161
|
+
model = ctx.session.model_profile
|
|
162
|
+
if model is None:
|
|
163
|
+
self.query_one("#quant-model", Static).update(
|
|
164
|
+
Text("No model loaded — go back and select one.", style="#d29922")
|
|
165
|
+
)
|
|
166
|
+
return
|
|
167
|
+
goal = ctx.session.goal
|
|
168
|
+
cands = CandidateGenerator.generate(model, ctx.hardware, goal, registry=ctx.backends)
|
|
169
|
+
self._ranked = Recommender.rank(cands, goal)
|
|
170
|
+
best = Recommender.recommend(cands, goal)
|
|
171
|
+
self._best_id = best.candidate.format_id if best else None
|
|
172
|
+
self.query_one("#quant-model", Static).update(self._model_text(model))
|
|
173
|
+
|
|
174
|
+
fits_only = getattr(self.app, "quant_fits_only", False)
|
|
175
|
+
# "fits" = fits the budget AND is usable on the selected runtime — not
|
|
176
|
+
# "auto-runnable" (GGUF/BitNet are produce-only yet still valid choices).
|
|
177
|
+
rows = [
|
|
178
|
+
r for r in self._ranked
|
|
179
|
+
if not fits_only
|
|
180
|
+
or (r.candidate.fits_memory and r.candidate.runtime_compat.rank >= 1)
|
|
181
|
+
]
|
|
182
|
+
self._rows = rows
|
|
183
|
+
|
|
184
|
+
table = self.query_one("#quant-table", DataTable)
|
|
185
|
+
table.clear()
|
|
186
|
+
best_row = 0
|
|
187
|
+
for i, rec in enumerate(rows):
|
|
188
|
+
c = rec.candidate
|
|
189
|
+
recommended = c.format_id == self._best_id
|
|
190
|
+
if recommended:
|
|
191
|
+
best_row = i
|
|
192
|
+
strategy = Text(("★ " if recommended else " ") + c.label,
|
|
193
|
+
style="bold white" if recommended else "white")
|
|
194
|
+
fits = Text("✓", style="#3fb950") if c.fits_memory else Text("✗", style="#f85149")
|
|
195
|
+
vram = Text(f"{c.estimated_vram_gb:.1f}", style="white" if c.fits_memory else "#f85149")
|
|
196
|
+
table.add_row(
|
|
197
|
+
strategy, f"{c.bits_per_weight:.2f}", f"{c.estimated_size_gb:.1f}", vram, fits,
|
|
198
|
+
_fmt_ctx(c.max_context), Text(c.quality_risk_label, style=_risk_style(c.quality_risk_label)),
|
|
199
|
+
f"{c.estimated_speedup:.1f}x", _run_cell(c),
|
|
200
|
+
Text(_EXEC_LABEL.get(c.execution, c.execution), style=_exec_style(c.execution)),
|
|
201
|
+
)
|
|
202
|
+
if rows:
|
|
203
|
+
table.move_cursor(row=min(best_row, len(rows) - 1))
|
|
204
|
+
|
|
205
|
+
def _model_text(self, model) -> Text: # noqa: ANN001
|
|
206
|
+
text = Text()
|
|
207
|
+
text.append(model.model_id, style="bold #58a6ff")
|
|
208
|
+
if model.architecture:
|
|
209
|
+
text.append(f" {model.architecture}", style="#8b949e")
|
|
210
|
+
text.append("\n")
|
|
211
|
+
parts = [f"{units.humanize_params(model.parameter_count)} params", model.dtype.upper()]
|
|
212
|
+
if model.num_layers:
|
|
213
|
+
parts.append(f"{model.num_layers} layers")
|
|
214
|
+
if model.hidden_size:
|
|
215
|
+
parts.append(f"hidden {model.hidden_size}")
|
|
216
|
+
if model.num_attention_heads:
|
|
217
|
+
heads = f"{model.num_attention_heads} heads"
|
|
218
|
+
if model.num_key_value_heads and model.num_key_value_heads != model.num_attention_heads:
|
|
219
|
+
heads += f" (KV {model.num_key_value_heads})"
|
|
220
|
+
parts.append(heads)
|
|
221
|
+
parts.append(f"~{model.estimated_weight_size_gb:.1f} GB original")
|
|
222
|
+
text.append(" · ".join(parts), style="#8b949e")
|
|
223
|
+
return text
|
|
224
|
+
|
|
225
|
+
# ---- selection ----------------------------------------------------------
|
|
226
|
+
|
|
227
|
+
def action_select(self) -> None:
|
|
228
|
+
self._select(self.query_one("#quant-table", DataTable).cursor_row)
|
|
229
|
+
|
|
230
|
+
@on(DataTable.RowSelected, "#quant-table")
|
|
231
|
+
def _row_selected(self, event: DataTable.RowSelected) -> None:
|
|
232
|
+
self._select(event.cursor_row)
|
|
233
|
+
|
|
234
|
+
def _select(self, row: int) -> None:
|
|
235
|
+
rows = getattr(self, "_rows", [])
|
|
236
|
+
if row is None or row < 0 or row >= len(rows):
|
|
237
|
+
return
|
|
238
|
+
candidate = rows[row].candidate
|
|
239
|
+
self.app.ctx.session.selected_method = candidate.method
|
|
240
|
+
self.app.ctx.session.selected_format_id = candidate.format_id
|
|
241
|
+
self.app.push_plan(candidate)
|
|
242
|
+
|
|
243
|
+
def action_back(self) -> None:
|
|
244
|
+
self.app.pop_screen()
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _fmt_ctx(n: int | None) -> str:
|
|
248
|
+
if not n:
|
|
249
|
+
return "—"
|
|
250
|
+
return f"{n / 1000:.1f}k" if n >= 1000 else str(n)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _risk_style(label: str) -> str:
|
|
254
|
+
return {
|
|
255
|
+
"very low": "#3fb950", "low": "#3fb950", "moderate": "#d29922",
|
|
256
|
+
"high": "#f85149", "very high": "bold #f85149",
|
|
257
|
+
}.get(label, "white")
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _exec_style(execution: str) -> str:
|
|
261
|
+
return {"in_env": "#3fb950", "isolated": "#58a6ff", "external": "#d29922", "planned": "#6e7681"}.get(
|
|
262
|
+
execution, "white"
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _run_cell(candidate) -> Text: # noqa: ANN001
|
|
267
|
+
rank = candidate.runtime_compat.rank
|
|
268
|
+
if rank >= 3:
|
|
269
|
+
return Text("✓", style="#3fb950")
|
|
270
|
+
if rank >= 1:
|
|
271
|
+
return Text("~", style="#d29922")
|
|
272
|
+
return Text("✗", style="#f85149")
|