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,200 @@
|
|
|
1
|
+
"""Detect the local hardware and turn it into a :class:`HardwareProfile`.
|
|
2
|
+
|
|
3
|
+
Uses PyTorch's CUDA APIs as the primary source and NVML (``pynvml`` /
|
|
4
|
+
``nvidia-ml-py``) for richer telemetry (free memory, driver version). Every probe
|
|
5
|
+
is wrapped so that a missing library or an unavailable subsystem degrades
|
|
6
|
+
gracefully to a CPU profile instead of raising.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from zeroquantz.hardware.capabilities import HardwareProfile, derive_precision_support
|
|
12
|
+
from zeroquantz.hardware.gpu import GpuInfo
|
|
13
|
+
from zeroquantz.utils import units
|
|
14
|
+
from zeroquantz.utils.logging import get_logger
|
|
15
|
+
|
|
16
|
+
log = get_logger(__name__)
|
|
17
|
+
|
|
18
|
+
# Presets used only by :meth:`HardwareDetector.simulate`, so that recommendations
|
|
19
|
+
# can be produced for a target card you are not currently running on.
|
|
20
|
+
_GPU_PRESETS: dict[str, tuple[float, tuple[int, int]]] = {
|
|
21
|
+
# name substring -> (vram GiB, compute capability)
|
|
22
|
+
"rtx 4090": (24.0, (8, 9)),
|
|
23
|
+
"rtx 4080": (16.0, (8, 9)),
|
|
24
|
+
"rtx 4070": (12.0, (8, 9)),
|
|
25
|
+
"rtx 4060": (8.0, (8, 9)),
|
|
26
|
+
"rtx 3090": (24.0, (8, 6)),
|
|
27
|
+
"rtx 3080": (10.0, (8, 6)),
|
|
28
|
+
"a100": (80.0, (8, 0)),
|
|
29
|
+
"h100": (80.0, (9, 0)),
|
|
30
|
+
"l40s": (48.0, (8, 9)),
|
|
31
|
+
"l4": (24.0, (8, 9)),
|
|
32
|
+
"t4": (16.0, (7, 5)),
|
|
33
|
+
"v100": (32.0, (7, 0)),
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class HardwareDetector:
|
|
38
|
+
"""Probe the machine for GPUs, VRAM, compute capability, and CUDA versions."""
|
|
39
|
+
|
|
40
|
+
@staticmethod
|
|
41
|
+
def detect() -> HardwareProfile:
|
|
42
|
+
gpus: list[GpuInfo] = []
|
|
43
|
+
cuda_available = False
|
|
44
|
+
cuda_version: str | None = None
|
|
45
|
+
torch_version: str | None = None
|
|
46
|
+
driver_version: str | None = None
|
|
47
|
+
notes: list[str] = []
|
|
48
|
+
|
|
49
|
+
# ---- PyTorch probe --------------------------------------------------
|
|
50
|
+
try:
|
|
51
|
+
import torch
|
|
52
|
+
|
|
53
|
+
torch_version = torch.__version__
|
|
54
|
+
cuda_version = getattr(torch.version, "cuda", None)
|
|
55
|
+
cuda_available = bool(torch.cuda.is_available())
|
|
56
|
+
if cuda_available:
|
|
57
|
+
for i in range(torch.cuda.device_count()):
|
|
58
|
+
props = torch.cuda.get_device_properties(i)
|
|
59
|
+
try:
|
|
60
|
+
free_b, _total_b = torch.cuda.mem_get_info(i)
|
|
61
|
+
available_gb = units.bytes_to_gb(free_b)
|
|
62
|
+
except Exception: # pragma: no cover - driver dependent
|
|
63
|
+
available_gb = None
|
|
64
|
+
gpus.append(
|
|
65
|
+
GpuInfo(
|
|
66
|
+
index=i,
|
|
67
|
+
name=props.name,
|
|
68
|
+
total_vram_gb=round(units.bytes_to_gb(props.total_memory), 2),
|
|
69
|
+
available_vram_gb=(
|
|
70
|
+
round(available_gb, 2) if available_gb is not None else None
|
|
71
|
+
),
|
|
72
|
+
compute_capability=(props.major, props.minor),
|
|
73
|
+
multiprocessor_count=getattr(props, "multi_processor_count", None),
|
|
74
|
+
)
|
|
75
|
+
)
|
|
76
|
+
except ImportError:
|
|
77
|
+
notes.append("PyTorch is not installed; GPU detection is limited.")
|
|
78
|
+
except Exception as exc: # pragma: no cover - defensive
|
|
79
|
+
log.warning("torch hardware probe failed: %s", exc)
|
|
80
|
+
notes.append(f"PyTorch hardware probe failed: {exc}")
|
|
81
|
+
|
|
82
|
+
# ---- NVML probe (driver version, free memory, CPU-only fallback) ----
|
|
83
|
+
nvml_gpus = HardwareDetector._probe_nvml()
|
|
84
|
+
if nvml_gpus is not None:
|
|
85
|
+
driver_version = nvml_gpus.get("driver")
|
|
86
|
+
if not gpus and nvml_gpus.get("gpus"):
|
|
87
|
+
# torch unavailable but NVML present -> still report the devices.
|
|
88
|
+
gpus = nvml_gpus["gpus"]
|
|
89
|
+
cuda_available = bool(gpus)
|
|
90
|
+
elif gpus and nvml_gpus.get("gpus"):
|
|
91
|
+
# Enrich torch data with NVML free memory where torch could not.
|
|
92
|
+
for gpu, nv in zip(gpus, nvml_gpus["gpus"], strict=False):
|
|
93
|
+
if gpu.available_vram_gb is None and nv.available_vram_gb is not None:
|
|
94
|
+
gpu.available_vram_gb = nv.available_vram_gb
|
|
95
|
+
|
|
96
|
+
# ---- CPU / RAM ------------------------------------------------------
|
|
97
|
+
cpu_cores, system_ram_gb = HardwareDetector._probe_cpu()
|
|
98
|
+
|
|
99
|
+
primary_cc = gpus[0].compute_capability if gpus else None
|
|
100
|
+
precision = derive_precision_support(primary_cc, cuda_available=cuda_available)
|
|
101
|
+
|
|
102
|
+
if not gpus:
|
|
103
|
+
notes.append("No CUDA GPU detected — running in CPU/planning-only mode.")
|
|
104
|
+
|
|
105
|
+
return HardwareProfile(
|
|
106
|
+
gpus=gpus,
|
|
107
|
+
cuda_available=cuda_available,
|
|
108
|
+
cuda_version=cuda_version,
|
|
109
|
+
driver_version=driver_version,
|
|
110
|
+
torch_version=torch_version,
|
|
111
|
+
cpu_cores=cpu_cores,
|
|
112
|
+
system_ram_gb=system_ram_gb,
|
|
113
|
+
precision_support=precision,
|
|
114
|
+
notes=notes,
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
@staticmethod
|
|
118
|
+
def _probe_nvml() -> dict | None:
|
|
119
|
+
try:
|
|
120
|
+
import pynvml # provided by nvidia-ml-py or the legacy pynvml package
|
|
121
|
+
except ImportError:
|
|
122
|
+
return None
|
|
123
|
+
try:
|
|
124
|
+
pynvml.nvmlInit()
|
|
125
|
+
except Exception: # pragma: no cover - no driver
|
|
126
|
+
return None
|
|
127
|
+
try:
|
|
128
|
+
driver = _as_str(pynvml.nvmlSystemGetDriverVersion())
|
|
129
|
+
gpus: list[GpuInfo] = []
|
|
130
|
+
for i in range(pynvml.nvmlDeviceGetCount()):
|
|
131
|
+
handle = pynvml.nvmlDeviceGetHandleByIndex(i)
|
|
132
|
+
mem = pynvml.nvmlDeviceGetMemoryInfo(handle)
|
|
133
|
+
try:
|
|
134
|
+
major, minor = pynvml.nvmlDeviceGetCudaComputeCapability(handle)
|
|
135
|
+
cc: tuple[int, int] | None = (major, minor)
|
|
136
|
+
except Exception:
|
|
137
|
+
cc = None
|
|
138
|
+
gpus.append(
|
|
139
|
+
GpuInfo(
|
|
140
|
+
index=i,
|
|
141
|
+
name=_as_str(pynvml.nvmlDeviceGetName(handle)),
|
|
142
|
+
total_vram_gb=round(units.bytes_to_gb(mem.total), 2),
|
|
143
|
+
available_vram_gb=round(units.bytes_to_gb(mem.free), 2),
|
|
144
|
+
compute_capability=cc,
|
|
145
|
+
)
|
|
146
|
+
)
|
|
147
|
+
return {"driver": driver, "gpus": gpus}
|
|
148
|
+
except Exception as exc: # pragma: no cover - defensive
|
|
149
|
+
log.debug("NVML probe failed: %s", exc)
|
|
150
|
+
return None
|
|
151
|
+
finally:
|
|
152
|
+
try:
|
|
153
|
+
pynvml.nvmlShutdown()
|
|
154
|
+
except Exception:
|
|
155
|
+
pass
|
|
156
|
+
|
|
157
|
+
@staticmethod
|
|
158
|
+
def _probe_cpu() -> tuple[int | None, float | None]:
|
|
159
|
+
cores: int | None = None
|
|
160
|
+
ram_gb: float | None = None
|
|
161
|
+
try:
|
|
162
|
+
import os
|
|
163
|
+
|
|
164
|
+
cores = os.cpu_count()
|
|
165
|
+
except Exception:
|
|
166
|
+
pass
|
|
167
|
+
try:
|
|
168
|
+
import psutil
|
|
169
|
+
|
|
170
|
+
ram_gb = round(units.bytes_to_gb(psutil.virtual_memory().total), 1)
|
|
171
|
+
except Exception:
|
|
172
|
+
pass
|
|
173
|
+
return cores, ram_gb
|
|
174
|
+
|
|
175
|
+
@staticmethod
|
|
176
|
+
def simulate(gpu_name: str) -> HardwareProfile:
|
|
177
|
+
"""Build a profile for a *named* GPU without needing that hardware present.
|
|
178
|
+
|
|
179
|
+
Powers ``recommend --gpu <name>``: plan for the card you will deploy on.
|
|
180
|
+
Raises :class:`ValueError` if the name is not in the preset table.
|
|
181
|
+
"""
|
|
182
|
+
key = gpu_name.strip().lower()
|
|
183
|
+
match = next((preset for name, preset in _GPU_PRESETS.items() if name in key), None)
|
|
184
|
+
if match is None:
|
|
185
|
+
known = ", ".join(sorted(_GPU_PRESETS))
|
|
186
|
+
raise ValueError(f"unknown GPU preset {gpu_name!r}; known presets: {known}")
|
|
187
|
+
vram, cc = match
|
|
188
|
+
gpu = GpuInfo(index=0, name=gpu_name, total_vram_gb=vram, compute_capability=cc)
|
|
189
|
+
return HardwareProfile(
|
|
190
|
+
gpus=[gpu],
|
|
191
|
+
cuda_available=True,
|
|
192
|
+
precision_support=derive_precision_support(cc, cuda_available=True),
|
|
193
|
+
notes=[f"Simulated hardware profile for '{gpu_name}'."],
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _as_str(value: object) -> str:
|
|
198
|
+
if isinstance(value, bytes):
|
|
199
|
+
return value.decode("utf-8", errors="replace")
|
|
200
|
+
return str(value)
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Per-GPU information model."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, Field
|
|
6
|
+
|
|
7
|
+
from zeroquantz.utils import units
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class GpuInfo(BaseModel):
|
|
11
|
+
"""A single detected CUDA device."""
|
|
12
|
+
|
|
13
|
+
index: int
|
|
14
|
+
name: str
|
|
15
|
+
total_vram_gb: float = Field(ge=0)
|
|
16
|
+
available_vram_gb: float | None = Field(default=None, ge=0)
|
|
17
|
+
compute_capability: tuple[int, int] | None = None
|
|
18
|
+
multiprocessor_count: int | None = None
|
|
19
|
+
uuid: str | None = None
|
|
20
|
+
|
|
21
|
+
@property
|
|
22
|
+
def sm(self) -> str | None:
|
|
23
|
+
"""Compute capability rendered like ``"SM89"``."""
|
|
24
|
+
if self.compute_capability is None:
|
|
25
|
+
return None
|
|
26
|
+
major, minor = self.compute_capability
|
|
27
|
+
return f"SM{major}{minor}"
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def total_vram_bytes(self) -> float:
|
|
31
|
+
return units.gb_to_bytes(self.total_vram_gb)
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""Read a Hugging Face ``config.json`` into normalized dimensions, and estimate a
|
|
2
|
+
parameter count from those dimensions when exact tensor headers are unavailable.
|
|
3
|
+
|
|
4
|
+
The estimate targets modern decoder-only transformers (LLaMA/Qwen/Mistral-style
|
|
5
|
+
gated MLP with grouped-query attention), which is v0.1's scope. It is clearly
|
|
6
|
+
labelled as an *estimate* everywhere it surfaces.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
# Families that use a 3-matrix gated MLP (gate/up/down). Anything else is assumed
|
|
14
|
+
# to use a 2-matrix MLP. Only affects the *estimate* path; the safetensors path is
|
|
15
|
+
# exact regardless.
|
|
16
|
+
_GATED_MLP_FAMILIES = {
|
|
17
|
+
"llama",
|
|
18
|
+
"mistral",
|
|
19
|
+
"mixtral",
|
|
20
|
+
"qwen2",
|
|
21
|
+
"qwen3",
|
|
22
|
+
"qwen2_moe",
|
|
23
|
+
"gemma",
|
|
24
|
+
"gemma2",
|
|
25
|
+
"gemma3",
|
|
26
|
+
"phi3",
|
|
27
|
+
"phi",
|
|
28
|
+
"stablelm",
|
|
29
|
+
"starcoder2",
|
|
30
|
+
"cohere",
|
|
31
|
+
"olmo",
|
|
32
|
+
"granite",
|
|
33
|
+
"internlm2",
|
|
34
|
+
"yi",
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
_NON_GATED_MLP_FAMILIES = {"gpt2", "gpt_neox", "gptj", "opt", "bloom", "falcon", "mpt"}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _first(config: dict, *keys: str, default: Any = None) -> Any:
|
|
41
|
+
for key in keys:
|
|
42
|
+
if key in config and config[key] is not None:
|
|
43
|
+
return config[key]
|
|
44
|
+
return default
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def extract_dimensions(config: dict) -> dict[str, Any]:
|
|
48
|
+
"""Pull normalized architecture dimensions out of a raw HF config dict."""
|
|
49
|
+
architectures = config.get("architectures") or []
|
|
50
|
+
architecture = architectures[0] if architectures else None
|
|
51
|
+
|
|
52
|
+
num_heads = _first(config, "num_attention_heads", "n_head", "num_heads")
|
|
53
|
+
hidden = _first(config, "hidden_size", "n_embd", "d_model", "hidden_dim")
|
|
54
|
+
head_dim = config.get("head_dim")
|
|
55
|
+
if head_dim is None and hidden and num_heads:
|
|
56
|
+
head_dim = hidden // num_heads
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
"architecture": architecture,
|
|
60
|
+
"model_type": config.get("model_type"),
|
|
61
|
+
"num_layers": _first(config, "num_hidden_layers", "n_layer", "num_layers"),
|
|
62
|
+
"hidden_size": hidden,
|
|
63
|
+
"intermediate_size": _first(
|
|
64
|
+
config, "intermediate_size", "n_inner", "ffn_dim", "d_ff"
|
|
65
|
+
),
|
|
66
|
+
"num_attention_heads": num_heads,
|
|
67
|
+
"num_key_value_heads": _first(config, "num_key_value_heads", default=num_heads),
|
|
68
|
+
"head_dim": head_dim,
|
|
69
|
+
"vocab_size": config.get("vocab_size"),
|
|
70
|
+
"max_position_embeddings": _first(
|
|
71
|
+
config, "max_position_embeddings", "n_positions", "max_seq_len", "seq_length"
|
|
72
|
+
),
|
|
73
|
+
# HF's PretrainedConfig default is True; large models usually override to False.
|
|
74
|
+
"tie_word_embeddings": bool(config.get("tie_word_embeddings", True)),
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def uses_gated_mlp(model_type: str | None) -> bool:
|
|
79
|
+
"""Whether a model family uses a 3-matrix gated MLP (gate/up/down)."""
|
|
80
|
+
if model_type is None:
|
|
81
|
+
return True # modern default
|
|
82
|
+
mt = model_type.lower()
|
|
83
|
+
if mt in _NON_GATED_MLP_FAMILIES:
|
|
84
|
+
return False
|
|
85
|
+
if mt in _GATED_MLP_FAMILIES:
|
|
86
|
+
return True
|
|
87
|
+
return True
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
# Backwards-compatible private alias.
|
|
91
|
+
_uses_gated_mlp = uses_gated_mlp
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def estimate_parameter_count(config: dict) -> int | None:
|
|
95
|
+
"""Estimate total parameters from config dimensions, or ``None`` if the config
|
|
96
|
+
lacks the dimensions needed."""
|
|
97
|
+
dims = extract_dimensions(config)
|
|
98
|
+
layers = dims["num_layers"]
|
|
99
|
+
hidden = dims["hidden_size"]
|
|
100
|
+
inter = dims["intermediate_size"]
|
|
101
|
+
heads = dims["num_attention_heads"]
|
|
102
|
+
kv_heads = dims["num_key_value_heads"] or heads
|
|
103
|
+
head_dim = dims["head_dim"]
|
|
104
|
+
vocab = dims["vocab_size"]
|
|
105
|
+
|
|
106
|
+
if not all((layers, hidden, heads, vocab)):
|
|
107
|
+
return None
|
|
108
|
+
if head_dim is None:
|
|
109
|
+
head_dim = hidden // heads
|
|
110
|
+
if inter is None:
|
|
111
|
+
inter = 4 * hidden # common fallback ratio
|
|
112
|
+
|
|
113
|
+
q_proj = hidden * (heads * head_dim)
|
|
114
|
+
o_proj = (heads * head_dim) * hidden
|
|
115
|
+
kv_proj = hidden * (kv_heads * head_dim)
|
|
116
|
+
attn = q_proj + o_proj + 2 * kv_proj
|
|
117
|
+
|
|
118
|
+
if _uses_gated_mlp(dims["model_type"]):
|
|
119
|
+
mlp = 3 * hidden * inter
|
|
120
|
+
else:
|
|
121
|
+
mlp = 2 * hidden * inter
|
|
122
|
+
|
|
123
|
+
norms = 2 * hidden # two RMS/LayerNorms per block
|
|
124
|
+
per_layer = attn + mlp + norms
|
|
125
|
+
|
|
126
|
+
embed = vocab * hidden
|
|
127
|
+
lm_head = 0 if dims["tie_word_embeddings"] else vocab * hidden
|
|
128
|
+
final_norm = hidden
|
|
129
|
+
|
|
130
|
+
return int(layers * per_layer + embed + lm_head + final_norm)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
# safetensors dtype codes -> canonical names
|
|
134
|
+
_ST_DTYPES = {
|
|
135
|
+
"F64": "float64",
|
|
136
|
+
"F32": "float32",
|
|
137
|
+
"F16": "float16",
|
|
138
|
+
"BF16": "bfloat16",
|
|
139
|
+
"I64": "int64",
|
|
140
|
+
"I32": "int32",
|
|
141
|
+
"I16": "int16",
|
|
142
|
+
"I8": "int8",
|
|
143
|
+
"U8": "uint8",
|
|
144
|
+
"F8_E4M3": "fp8",
|
|
145
|
+
"F8_E5M2": "fp8",
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
# dtypes that represent actual model weights (not integer buffers / rotary caches)
|
|
149
|
+
_WEIGHT_DTYPES = {"float32", "float16", "bfloat16", "float64", "fp8"}
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def normalize_st_dtype(code: str) -> str:
|
|
153
|
+
return _ST_DTYPES.get(str(code).upper(), str(code).lower())
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def dominant_weight_dtype(param_count_by_dtype: dict[str, int]) -> str:
|
|
157
|
+
"""Given ``{dtype_code: n_params}`` pick the dtype that best represents the
|
|
158
|
+
model's weights (largest float bucket, ignoring integer buffers)."""
|
|
159
|
+
normalized: dict[str, int] = {}
|
|
160
|
+
for code, count in param_count_by_dtype.items():
|
|
161
|
+
name = normalize_st_dtype(code)
|
|
162
|
+
normalized[name] = normalized.get(name, 0) + int(count)
|
|
163
|
+
|
|
164
|
+
weight_only = {d: c for d, c in normalized.items() if d in _WEIGHT_DTYPES}
|
|
165
|
+
pool = weight_only or normalized
|
|
166
|
+
if not pool:
|
|
167
|
+
return "float16"
|
|
168
|
+
return max(pool.items(), key=lambda kv: kv[1])[0]
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""Download a model's weights from the Hub with real-time byte progress.
|
|
2
|
+
|
|
3
|
+
Files are fetched one at a time (so a download can be cancelled between files and
|
|
4
|
+
overall byte progress is exact), hooking each file's tqdm bar to report byte
|
|
5
|
+
deltas. The essential files are selected — safetensors weights (or .bin when
|
|
6
|
+
there are no safetensors) plus config/tokenizer — skipping other formats
|
|
7
|
+
(.gguf/.onnx/.h5/…) and repo cruft.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import threading
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import TYPE_CHECKING
|
|
16
|
+
|
|
17
|
+
from zeroquantz.core.exceptions import ZeroQuantzError
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from collections.abc import Callable
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class DownloadCancelled(ZeroQuantzError):
|
|
24
|
+
"""Raised when a download is cancelled by the user."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class DownloadFile:
|
|
29
|
+
name: str
|
|
30
|
+
size: int
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
_KEEP_SUFFIXES = (".safetensors", ".json", ".model", ".txt", ".jinja", ".tiktoken")
|
|
34
|
+
_WEIGHT_FALLBACK = (".bin", ".pth")
|
|
35
|
+
_SKIP_SUFFIXES = (".gguf", ".onnx", ".h5", ".msgpack", ".pt", ".ckpt", ".gitattributes", ".md")
|
|
36
|
+
# Alternative-format subdirectories that hold duplicate configs/tokenizers/weights
|
|
37
|
+
# we never need for a transformers/vLLM load — skip them so we don't download, e.g.,
|
|
38
|
+
# an `onnx/` copy of the tokenizer alongside the real one.
|
|
39
|
+
_SKIP_DIRS = frozenset({"onnx", "coreml", "tf", "flax", "tflite", "openvino"})
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def plan_download(model_id: str, *, revision: str | None = None, token: str | None = None) -> tuple[list[DownloadFile], int]:
|
|
43
|
+
"""Return the essential files to download and their total size in bytes."""
|
|
44
|
+
from huggingface_hub import HfApi
|
|
45
|
+
|
|
46
|
+
api = HfApi(token=token)
|
|
47
|
+
try:
|
|
48
|
+
info = api.model_info(model_id, revision=revision, files_metadata=True)
|
|
49
|
+
except Exception as exc:
|
|
50
|
+
raise ZeroQuantzError(
|
|
51
|
+
f"Could not read the file list for '{model_id}'.", detail=str(exc)
|
|
52
|
+
) from exc
|
|
53
|
+
|
|
54
|
+
siblings = info.siblings or []
|
|
55
|
+
names = [s.rfilename for s in siblings]
|
|
56
|
+
has_safetensors = any(n.endswith(".safetensors") for n in names)
|
|
57
|
+
|
|
58
|
+
files: list[DownloadFile] = []
|
|
59
|
+
for sib in siblings:
|
|
60
|
+
name = sib.rfilename
|
|
61
|
+
low = name.lower()
|
|
62
|
+
if low.endswith(_SKIP_SUFFIXES):
|
|
63
|
+
continue
|
|
64
|
+
if "/" in low and low.split("/", 1)[0] in _SKIP_DIRS:
|
|
65
|
+
continue
|
|
66
|
+
keep = (
|
|
67
|
+
low.endswith(_KEEP_SUFFIXES)
|
|
68
|
+
or "tokenizer" in low
|
|
69
|
+
or (low.endswith(_WEIGHT_FALLBACK) and not has_safetensors)
|
|
70
|
+
)
|
|
71
|
+
if keep:
|
|
72
|
+
files.append(DownloadFile(name=name, size=int(sib.size or 0)))
|
|
73
|
+
total = sum(f.size for f in files)
|
|
74
|
+
return files, total
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _make_tqdm(on_delta: Callable[[int], None]):
|
|
78
|
+
from tqdm.auto import tqdm as _base
|
|
79
|
+
|
|
80
|
+
class _ProgressTqdm(_base):
|
|
81
|
+
def __init__(self, *args, **kwargs) -> None:
|
|
82
|
+
# Silence the child bar (would corrupt the TUI); we report via on_delta.
|
|
83
|
+
kwargs["disable"] = True
|
|
84
|
+
super().__init__(*args, **kwargs)
|
|
85
|
+
|
|
86
|
+
def update(self, n: float | None = 1) -> bool | None:
|
|
87
|
+
# hf_hub only uses this tqdm_class for a file's byte download, so every
|
|
88
|
+
# positive delta is bytes. (We must NOT gate on ``self.unit``: tqdm's
|
|
89
|
+
# __init__ returns early when ``disable=True`` and never assigns it.)
|
|
90
|
+
try:
|
|
91
|
+
if n:
|
|
92
|
+
on_delta(int(n))
|
|
93
|
+
except Exception:
|
|
94
|
+
pass
|
|
95
|
+
return super().update(n)
|
|
96
|
+
|
|
97
|
+
return _ProgressTqdm
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def download_model(
|
|
101
|
+
model_id: str,
|
|
102
|
+
files: list[DownloadFile],
|
|
103
|
+
*,
|
|
104
|
+
revision: str | None = None,
|
|
105
|
+
token: str | None = None,
|
|
106
|
+
cancel: threading.Event | None = None,
|
|
107
|
+
on_progress: Callable[[int], None] | None = None,
|
|
108
|
+
) -> str:
|
|
109
|
+
"""Download ``files`` for ``model_id``; return the local snapshot directory.
|
|
110
|
+
|
|
111
|
+
Progress is reported two ways so it is always correct and always reaches
|
|
112
|
+
``total``: intra-file byte deltas (from each file's tqdm) give a smooth bar,
|
|
113
|
+
and a snap to the exact byte boundary after every completed file guarantees
|
|
114
|
+
the reported total lands on 100% even when deltas are unavailable — e.g. tiny
|
|
115
|
+
files with no progress bar, or the ``hf_transfer`` accelerator, which doesn't
|
|
116
|
+
drive our tqdm.
|
|
117
|
+
"""
|
|
118
|
+
from huggingface_hub import hf_hub_download, snapshot_download
|
|
119
|
+
|
|
120
|
+
total = sum(f.size for f in files)
|
|
121
|
+
completed = 0 # bytes from fully-finished files
|
|
122
|
+
|
|
123
|
+
def report(done: int) -> None:
|
|
124
|
+
if on_progress:
|
|
125
|
+
on_progress(min(done, total))
|
|
126
|
+
|
|
127
|
+
for f in files:
|
|
128
|
+
if cancel is not None and cancel.is_set():
|
|
129
|
+
raise DownloadCancelled("Download cancelled.")
|
|
130
|
+
file_seen = 0
|
|
131
|
+
|
|
132
|
+
# ``_base``/``_size`` are bound at closure creation (per file); ``completed``
|
|
133
|
+
# is not mutated during this file's download, so the pre-file base is correct.
|
|
134
|
+
def bump(delta: int, _base: int = completed, _size: int = f.size) -> None:
|
|
135
|
+
nonlocal file_seen
|
|
136
|
+
file_seen = min(file_seen + delta, _size)
|
|
137
|
+
report(_base + file_seen)
|
|
138
|
+
|
|
139
|
+
try:
|
|
140
|
+
hf_hub_download(
|
|
141
|
+
model_id, f.name, revision=revision, token=token,
|
|
142
|
+
tqdm_class=_make_tqdm(bump),
|
|
143
|
+
)
|
|
144
|
+
except Exception as exc:
|
|
145
|
+
raise ZeroQuantzError(
|
|
146
|
+
f"Failed to download '{f.name}' from '{model_id}'.", detail=str(exc)
|
|
147
|
+
) from exc
|
|
148
|
+
completed += f.size
|
|
149
|
+
report(completed) # snap to the file boundary (covers any missed deltas)
|
|
150
|
+
|
|
151
|
+
report(total) # guarantee the bar reaches 100%
|
|
152
|
+
|
|
153
|
+
# Resolve the snapshot root from the now-cached files (no re-download).
|
|
154
|
+
try:
|
|
155
|
+
root = snapshot_download(
|
|
156
|
+
model_id, revision=revision, token=token,
|
|
157
|
+
allow_patterns=[f.name for f in files], local_files_only=True,
|
|
158
|
+
)
|
|
159
|
+
except Exception:
|
|
160
|
+
root = str(Path.home() / ".cache" / "huggingface")
|
|
161
|
+
return root
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Hugging Face token management.
|
|
2
|
+
|
|
3
|
+
Validates and persists a token using the standard huggingface_hub token store, so
|
|
4
|
+
every subsequent Hub call (search, inspect, download) authenticates automatically
|
|
5
|
+
without threading a token through the code. The token itself is never logged or
|
|
6
|
+
displayed — only a masked form or the resolved username.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import contextlib
|
|
12
|
+
import io
|
|
13
|
+
import os
|
|
14
|
+
|
|
15
|
+
from zeroquantz.core.exceptions import ZeroQuantzError
|
|
16
|
+
from zeroquantz.utils.logging import get_logger
|
|
17
|
+
|
|
18
|
+
log = get_logger(__name__)
|
|
19
|
+
|
|
20
|
+
_ENV_VARS = ("HF_TOKEN", "HUGGING_FACE_HUB_TOKEN", "HUGGINGFACE_HUB_TOKEN")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def current_token() -> str | None:
|
|
24
|
+
"""The effective token (env var or saved file), or None. No network."""
|
|
25
|
+
try:
|
|
26
|
+
from huggingface_hub import get_token
|
|
27
|
+
|
|
28
|
+
return get_token()
|
|
29
|
+
except Exception:
|
|
30
|
+
for var in _ENV_VARS:
|
|
31
|
+
if os.environ.get(var):
|
|
32
|
+
return os.environ[var]
|
|
33
|
+
return None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def masked(token: str | None) -> str | None:
|
|
37
|
+
if not token:
|
|
38
|
+
return None
|
|
39
|
+
t = token.strip()
|
|
40
|
+
return f"{t[:3]}…{t[-4:]}" if len(t) > 10 else "set"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def whoami_name(token: str | None = None) -> str | None:
|
|
44
|
+
"""Resolve the username for a token (network). Raises if invalid/unreachable."""
|
|
45
|
+
from huggingface_hub import whoami
|
|
46
|
+
|
|
47
|
+
info = whoami(token=token) if token else whoami()
|
|
48
|
+
if isinstance(info, dict):
|
|
49
|
+
return info.get("name") or info.get("fullname")
|
|
50
|
+
return None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def save_token(token: str) -> str | None:
|
|
54
|
+
"""Validate ``token`` (via whoami) and persist it. Returns the username.
|
|
55
|
+
|
|
56
|
+
Raises :class:`ZeroQuantzError` with an actionable message if the token is
|
|
57
|
+
invalid or the Hub is unreachable.
|
|
58
|
+
"""
|
|
59
|
+
token = token.strip()
|
|
60
|
+
if not token:
|
|
61
|
+
raise ZeroQuantzError("No token entered.")
|
|
62
|
+
|
|
63
|
+
try:
|
|
64
|
+
name = whoami_name(token)
|
|
65
|
+
except Exception as exc:
|
|
66
|
+
raise ZeroQuantzError(
|
|
67
|
+
"That Hugging Face token was rejected.",
|
|
68
|
+
detail=str(exc),
|
|
69
|
+
suggestions=["create a token at https://huggingface.co/settings/tokens"],
|
|
70
|
+
) from exc
|
|
71
|
+
|
|
72
|
+
# Effective immediately for this process...
|
|
73
|
+
os.environ["HF_TOKEN"] = token
|
|
74
|
+
# ...and persisted for future sessions (quietly — no stray prints in the TUI).
|
|
75
|
+
with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()):
|
|
76
|
+
try:
|
|
77
|
+
from huggingface_hub import HfFolder
|
|
78
|
+
|
|
79
|
+
HfFolder.save_token(token)
|
|
80
|
+
except Exception:
|
|
81
|
+
try:
|
|
82
|
+
from huggingface_hub import login
|
|
83
|
+
|
|
84
|
+
login(token=token, add_to_git_credential=False)
|
|
85
|
+
except Exception as exc: # pragma: no cover - persistence best-effort
|
|
86
|
+
log.warning("token set for this session but not persisted: %s", exc)
|
|
87
|
+
return name
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def clear_token() -> None:
|
|
91
|
+
"""Remove the token from the environment and the saved store."""
|
|
92
|
+
for var in _ENV_VARS:
|
|
93
|
+
os.environ.pop(var, None)
|
|
94
|
+
with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()):
|
|
95
|
+
try:
|
|
96
|
+
from huggingface_hub import logout
|
|
97
|
+
|
|
98
|
+
logout()
|
|
99
|
+
except Exception: # pragma: no cover
|
|
100
|
+
try:
|
|
101
|
+
from huggingface_hub import HfFolder
|
|
102
|
+
|
|
103
|
+
HfFolder.delete_token()
|
|
104
|
+
except Exception:
|
|
105
|
+
pass
|