axquant 1.3.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.
- axquant/__init__.py +5 -0
- axquant/__main__.py +3 -0
- axquant/activation_cache.py +512 -0
- axquant/analyzer.py +141 -0
- axquant/architectures/__init__.py +4 -0
- axquant/architectures/dense_family.py +419 -0
- axquant/architectures/nemotron3.py +156 -0
- axquant/architectures/qwen36.py +141 -0
- axquant/architectures/registry.py +105 -0
- axquant/architectures/types.py +19 -0
- axquant/artifact_paths.py +67 -0
- axquant/awq.py +271 -0
- axquant/benchmark.py +1532 -0
- axquant/benchmark_evidence.py +347 -0
- axquant/calibration.py +92 -0
- axquant/calibration_dataset.py +107 -0
- axquant/campaign.py +955 -0
- axquant/capture.py +818 -0
- axquant/capture_binding.py +150 -0
- axquant/certification/__init__.py +18 -0
- axquant/certification/common.py +226 -0
- axquant/certification/dispatch.py +75 -0
- axquant/certification/flagship.py +852 -0
- axquant/certification/packaging.py +123 -0
- axquant/certification/policy.py +26 -0
- axquant/certification/qwen3_next_direct.py +1832 -0
- axquant/certification/registry.py +127 -0
- axquant/claims.py +311 -0
- axquant/cli/__init__.py +2349 -0
- axquant/cli/_parser.py +1354 -0
- axquant/coding_sandbox.py +992 -0
- axquant/coding_suite.py +919 -0
- axquant/compatibility.py +380 -0
- axquant/converter.py +1436 -0
- axquant/data/__init__.py +0 -0
- axquant/data/convert_ladders.yaml +85 -0
- axquant/data/messages.yaml +18 -0
- axquant/data/profiles.yaml +118 -0
- axquant/data/quantizer_defaults.yaml +13 -0
- axquant/data/reference_calibration.jsonl +160 -0
- axquant/data/role_preferences.yaml +26 -0
- axquant/dataset_overlap.py +194 -0
- axquant/deferred.py +68 -0
- axquant/direct_quality.py +185 -0
- axquant/dwq.py +43 -0
- axquant/errors.py +50 -0
- axquant/experimental_bits.py +93 -0
- axquant/feasibility.py +583 -0
- axquant/gptq.py +347 -0
- axquant/hardware_registry.py +842 -0
- axquant/head_to_head.py +228 -0
- axquant/identity.py +155 -0
- axquant/inspector.py +654 -0
- axquant/kernel_latency.py +214 -0
- axquant/kv_exec.py +139 -0
- axquant/kv_probe.py +417 -0
- axquant/kv_quality.py +60 -0
- axquant/ladders.py +180 -0
- axquant/lifecycle.py +102 -0
- axquant/logging.py +22 -0
- axquant/manual.py +344 -0
- axquant/model_card.py +808 -0
- axquant/module_paths.py +199 -0
- axquant/mtp_sidecar.py +841 -0
- axquant/multimodal_backend.py +181 -0
- axquant/naming.py +106 -0
- axquant/numeric.py +39 -0
- axquant/package_data.py +52 -0
- axquant/pareto.py +79 -0
- axquant/planner.py +920 -0
- axquant/predicate.py +247 -0
- axquant/probe.py +1282 -0
- axquant/probe_capacity.py +261 -0
- axquant/profiles.py +55 -0
- axquant/publisher.py +672 -0
- axquant/quality.py +462 -0
- axquant/quantize.py +297 -0
- axquant/quantizers.py +786 -0
- axquant/recipes.py +234 -0
- axquant/recovery.py +392 -0
- axquant/refinement.py +1354 -0
- axquant/refinement_runner.py +611 -0
- axquant/release_audit.py +2109 -0
- axquant/release_exceptions.py +244 -0
- axquant/release_validation.py +151 -0
- axquant/reporting.py +1018 -0
- axquant/reproduction.py +285 -0
- axquant/revisions.py +11 -0
- axquant/role_policy.py +163 -0
- axquant/runtime.py +803 -0
- axquant/schema/__init__.py +535 -0
- axquant/schema/_base.py +27 -0
- axquant/schema/artifacts.py +2054 -0
- axquant/schema/campaign.py +647 -0
- axquant/schema/certification.py +844 -0
- axquant/schema/claims.py +116 -0
- axquant/schema/coding_suite.py +184 -0
- axquant/schema/enums.py +167 -0
- axquant/schema/flagship.py +55 -0
- axquant/schema/flagship_audit.py +276 -0
- axquant/schema/inventory.py +104 -0
- axquant/schema/kernel_latency.py +117 -0
- axquant/schema/lifecycle.py +189 -0
- axquant/schema/planning.py +747 -0
- axquant/schema/sensitivity.py +303 -0
- axquant/scoreboard.py +525 -0
- axquant/serde.py +156 -0
- axquant/simple_convert.py +305 -0
- axquant/source_prep.py +491 -0
- axquant/suites.py +329 -0
- axquant/support_policy.py +252 -0
- axquant/unified_sensitivity.py +192 -0
- axquant/validator.py +696 -0
- axquant/versioning.py +70 -0
- axquant-1.3.0.dist-info/METADATA +1049 -0
- axquant-1.3.0.dist-info/RECORD +119 -0
- axquant-1.3.0.dist-info/WHEEL +4 -0
- axquant-1.3.0.dist-info/entry_points.txt +2 -0
- axquant-1.3.0.dist-info/licenses/LICENSE +21 -0
axquant/__init__.py
ADDED
axquant/__main__.py
ADDED
|
@@ -0,0 +1,512 @@
|
|
|
1
|
+
"""Tokenized calibration and activation cache.
|
|
2
|
+
|
|
3
|
+
Manages the calibration cache directory structure with content-addressed
|
|
4
|
+
shards, checksum verification, and atomic completion markers. Changed
|
|
5
|
+
inputs always create a new cache directory rather than overwriting.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import contextlib
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import random
|
|
14
|
+
import tempfile
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
import structlog
|
|
19
|
+
|
|
20
|
+
from axquant.errors import ArtifactError, BackendUnavailableError, CacheError
|
|
21
|
+
from axquant.schema import (
|
|
22
|
+
ModelIdentity,
|
|
23
|
+
ProfileName,
|
|
24
|
+
TokenizedCacheManifest,
|
|
25
|
+
)
|
|
26
|
+
from axquant.serde import file_sha256, load_model, stable_sha256, write_data
|
|
27
|
+
from axquant.versioning import collect_versions
|
|
28
|
+
|
|
29
|
+
log = structlog.get_logger()
|
|
30
|
+
|
|
31
|
+
_SHARD_PREFIX = "shard-"
|
|
32
|
+
_SHARD_SUFFIX = ".npz"
|
|
33
|
+
_COMPLETION_MARKER = "completion.json"
|
|
34
|
+
_COMPLETION_SCHEMA = "axquant.tokenized-cache-completion.v1"
|
|
35
|
+
_MANIFEST_NAME = "tokenized_cache_manifest.json"
|
|
36
|
+
_BACKEND_VERSION = "axquant-tokenizer-v1"
|
|
37
|
+
_SAMPLES_PER_SHARD = 100
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def compute_cache_key(
|
|
41
|
+
*,
|
|
42
|
+
model: ModelIdentity,
|
|
43
|
+
dataset_sha256: str,
|
|
44
|
+
profile: ProfileName,
|
|
45
|
+
sequence_length: int,
|
|
46
|
+
random_seed: int,
|
|
47
|
+
tokenizer_revision: str | None = None,
|
|
48
|
+
config_digest: str | None = None,
|
|
49
|
+
backend_version: str = _BACKEND_VERSION,
|
|
50
|
+
mlx_version: str | None = None,
|
|
51
|
+
mlx_lm_version: str | None = None,
|
|
52
|
+
capture_points: tuple[str, ...] = ("output", "hidden"),
|
|
53
|
+
separation_attested: bool = False,
|
|
54
|
+
domains: tuple[str, ...] = (),
|
|
55
|
+
) -> str:
|
|
56
|
+
"""Compute a deterministic cache key from all identity-defining fields.
|
|
57
|
+
|
|
58
|
+
The cache key includes: source revision, config digest, tokenizer
|
|
59
|
+
revision, dataset digest, profile, sequence length, seed, backend
|
|
60
|
+
version, MLX/MLX-LM versions, and capture-point definition.
|
|
61
|
+
"""
|
|
62
|
+
identity = {
|
|
63
|
+
"model_id": model.model_id,
|
|
64
|
+
"revision": model.revision,
|
|
65
|
+
"config_digest": config_digest,
|
|
66
|
+
"tokenizer_revision": tokenizer_revision,
|
|
67
|
+
"dataset_sha256": dataset_sha256,
|
|
68
|
+
"profile": profile.value,
|
|
69
|
+
"sequence_length": sequence_length,
|
|
70
|
+
"random_seed": random_seed,
|
|
71
|
+
"backend_version": backend_version,
|
|
72
|
+
"mlx_version": mlx_version,
|
|
73
|
+
"mlx_lm_version": mlx_lm_version,
|
|
74
|
+
"capture_points": list(capture_points),
|
|
75
|
+
"calibration_evaluation_separation_attested": separation_attested,
|
|
76
|
+
"domains": list(domains),
|
|
77
|
+
}
|
|
78
|
+
return stable_sha256(identity)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _shard_path(directory: Path, index: int) -> Path:
|
|
82
|
+
return directory / f"{_SHARD_PREFIX}{index:04d}{_SHARD_SUFFIX}"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def verify_cache_integrity(cache_dir: Path, manifest: TokenizedCacheManifest) -> list[str]:
|
|
86
|
+
"""Verify all shards in the cache directory against the manifest.
|
|
87
|
+
|
|
88
|
+
Returns a list of issues (empty means all checks passed).
|
|
89
|
+
"""
|
|
90
|
+
issues: list[str] = []
|
|
91
|
+
tokenized_dir = cache_dir / "tokenized"
|
|
92
|
+
if not tokenized_dir.is_dir():
|
|
93
|
+
issues.append("tokenized directory missing")
|
|
94
|
+
return issues
|
|
95
|
+
|
|
96
|
+
expected_shards = {
|
|
97
|
+
_shard_path(tokenized_dir, index).name for index in range(manifest.shard_count)
|
|
98
|
+
}
|
|
99
|
+
recorded_shards = set(manifest.shard_sha256)
|
|
100
|
+
for name in sorted(expected_shards - recorded_shards):
|
|
101
|
+
issues.append(f"missing checksum binding: {name}")
|
|
102
|
+
for name in sorted(recorded_shards - expected_shards):
|
|
103
|
+
issues.append(f"unexpected checksum binding: {name}")
|
|
104
|
+
|
|
105
|
+
for i in range(manifest.shard_count):
|
|
106
|
+
shard = _shard_path(tokenized_dir, i)
|
|
107
|
+
if not shard.is_file():
|
|
108
|
+
issues.append(f"missing shard: {shard.name}")
|
|
109
|
+
elif shard.stat().st_size == 0:
|
|
110
|
+
issues.append(f"empty shard: {shard.name}")
|
|
111
|
+
else:
|
|
112
|
+
expected_sha256 = manifest.shard_sha256.get(shard.name)
|
|
113
|
+
if expected_sha256 is not None and file_sha256(shard) != expected_sha256:
|
|
114
|
+
issues.append(f"checksum mismatch: {shard.name}")
|
|
115
|
+
continue
|
|
116
|
+
try:
|
|
117
|
+
import numpy as np
|
|
118
|
+
|
|
119
|
+
with np.load(shard, allow_pickle=False) as data:
|
|
120
|
+
required = {"input_ids", "attention_mask", "sample_indices"}
|
|
121
|
+
missing = required - set(data.files)
|
|
122
|
+
if missing:
|
|
123
|
+
issues.append(
|
|
124
|
+
f"invalid shard {shard.name}: missing arrays {sorted(missing)}"
|
|
125
|
+
)
|
|
126
|
+
continue
|
|
127
|
+
input_ids = data["input_ids"]
|
|
128
|
+
attention_mask = data["attention_mask"]
|
|
129
|
+
sample_indices = data["sample_indices"]
|
|
130
|
+
if input_ids.ndim != 2 or input_ids.shape != attention_mask.shape:
|
|
131
|
+
issues.append(f"invalid shard {shard.name}: token array shape mismatch")
|
|
132
|
+
if sample_indices.ndim != 1 or len(sample_indices) != len(input_ids):
|
|
133
|
+
issues.append(f"invalid shard {shard.name}: sample index shape mismatch")
|
|
134
|
+
except (OSError, ValueError) as exc:
|
|
135
|
+
issues.append(f"invalid shard {shard.name}: {exc}")
|
|
136
|
+
|
|
137
|
+
# Check for extra shards
|
|
138
|
+
existing_shards = sorted(tokenized_dir.glob(f"{_SHARD_PREFIX}*{_SHARD_SUFFIX}"))
|
|
139
|
+
if len(existing_shards) > manifest.shard_count:
|
|
140
|
+
issues.append(
|
|
141
|
+
f"found {len(existing_shards)} shards but manifest declares {manifest.shard_count}"
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
return issues
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _read_dataset(path: Path) -> list[dict[str, Any]]:
|
|
148
|
+
samples: list[dict[str, Any]] = []
|
|
149
|
+
try:
|
|
150
|
+
with path.open(encoding="utf-8") as source:
|
|
151
|
+
for line_number, line in enumerate(source, 1):
|
|
152
|
+
if not line.strip():
|
|
153
|
+
continue
|
|
154
|
+
value = json.loads(line)
|
|
155
|
+
if not isinstance(value, dict):
|
|
156
|
+
raise CacheError(f"{path}:{line_number} must contain a JSON object")
|
|
157
|
+
samples.append(value)
|
|
158
|
+
except json.JSONDecodeError as exc:
|
|
159
|
+
raise CacheError(f"invalid JSONL in {path}:{exc.lineno}: {exc.msg}") from exc
|
|
160
|
+
except OSError as exc:
|
|
161
|
+
raise CacheError(f"cannot read calibration dataset: {exc}") from exc
|
|
162
|
+
if not samples:
|
|
163
|
+
raise CacheError("calibration dataset contains no samples")
|
|
164
|
+
return samples
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _sample_text(sample: dict[str, Any], tokenizer: Any) -> str:
|
|
168
|
+
messages = sample.get("messages")
|
|
169
|
+
if isinstance(messages, list) and messages:
|
|
170
|
+
apply_template = getattr(tokenizer, "apply_chat_template", None)
|
|
171
|
+
if callable(apply_template):
|
|
172
|
+
rendered = apply_template(messages, tokenize=False, add_generation_prompt=False)
|
|
173
|
+
if isinstance(rendered, str) and rendered:
|
|
174
|
+
return rendered
|
|
175
|
+
for key in ("text", "prompt", "content", "instruction"):
|
|
176
|
+
value = sample.get(key)
|
|
177
|
+
if isinstance(value, str) and value:
|
|
178
|
+
response = sample.get("response") or sample.get("output")
|
|
179
|
+
return f"{value}\n{response}" if isinstance(response, str) and response else value
|
|
180
|
+
raise CacheError(
|
|
181
|
+
"calibration samples require non-empty text, prompt, content, instruction, or messages"
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _load_tokenizer(model: ModelIdentity, tokenizer_revision: str | None) -> Any:
|
|
186
|
+
try:
|
|
187
|
+
from transformers import AutoTokenizer
|
|
188
|
+
except ImportError:
|
|
189
|
+
raise BackendUnavailableError(
|
|
190
|
+
"tokenization requires transformers; install axquant[mlx]"
|
|
191
|
+
) from None
|
|
192
|
+
source = model.local_path or model.model_id
|
|
193
|
+
kwargs: dict[str, Any] = {"trust_remote_code": False}
|
|
194
|
+
if model.local_path is not None:
|
|
195
|
+
kwargs["local_files_only"] = True
|
|
196
|
+
elif tokenizer_revision or model.revision:
|
|
197
|
+
kwargs["revision"] = tokenizer_revision or model.revision
|
|
198
|
+
try:
|
|
199
|
+
return AutoTokenizer.from_pretrained(source, **kwargs)
|
|
200
|
+
except (OSError, ValueError) as exc:
|
|
201
|
+
raise CacheError(f"cannot load tokenizer for {source}: {exc}") from exc
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _tokenizer_sha256(tokenizer: Any) -> str:
|
|
205
|
+
try:
|
|
206
|
+
vocabulary = tokenizer.get_vocab()
|
|
207
|
+
except (AttributeError, TypeError):
|
|
208
|
+
vocabulary = {}
|
|
209
|
+
special_tokens_map = getattr(tokenizer, "special_tokens_map", {}) or {}
|
|
210
|
+
special_tokens = {str(key): str(value) for key, value in special_tokens_map.items()}
|
|
211
|
+
return stable_sha256(
|
|
212
|
+
{
|
|
213
|
+
"class": type(tokenizer).__name__,
|
|
214
|
+
"vocabulary": vocabulary,
|
|
215
|
+
"special_tokens": special_tokens,
|
|
216
|
+
}
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _write_npz_atomic(path: Path, *, compressed: bool = False, **arrays: Any) -> None:
|
|
221
|
+
import numpy as np
|
|
222
|
+
|
|
223
|
+
descriptor, temporary_name = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
|
|
224
|
+
try:
|
|
225
|
+
with os.fdopen(descriptor, "wb") as destination:
|
|
226
|
+
if compressed:
|
|
227
|
+
np.savez_compressed(destination, **arrays)
|
|
228
|
+
else:
|
|
229
|
+
np.savez(destination, **arrays)
|
|
230
|
+
destination.flush()
|
|
231
|
+
os.fsync(destination.fileno())
|
|
232
|
+
os.replace(temporary_name, path)
|
|
233
|
+
except Exception:
|
|
234
|
+
with contextlib.suppress(FileNotFoundError):
|
|
235
|
+
os.unlink(temporary_name)
|
|
236
|
+
raise
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def is_cache_complete(
|
|
240
|
+
cache_dir: Path,
|
|
241
|
+
manifest: TokenizedCacheManifest | None = None,
|
|
242
|
+
) -> bool:
|
|
243
|
+
"""Verify that the completion marker binds the finished cache manifest.
|
|
244
|
+
|
|
245
|
+
Legacy markers did not carry a schema or manifest digest. They remain
|
|
246
|
+
readable only when every field they did record matches the manifest; new
|
|
247
|
+
markers additionally bind the complete semantic manifest identity.
|
|
248
|
+
"""
|
|
249
|
+
|
|
250
|
+
bound_manifest = manifest or load_cache_manifest(cache_dir)
|
|
251
|
+
if bound_manifest is None or not bound_manifest.complete:
|
|
252
|
+
return False
|
|
253
|
+
marker_path = cache_dir / _COMPLETION_MARKER
|
|
254
|
+
if not marker_path.is_file():
|
|
255
|
+
return False
|
|
256
|
+
try:
|
|
257
|
+
marker = json.loads(marker_path.read_text(encoding="utf-8"))
|
|
258
|
+
except (OSError, json.JSONDecodeError):
|
|
259
|
+
return False
|
|
260
|
+
if not isinstance(marker, dict) or marker.get("complete") is not True:
|
|
261
|
+
return False
|
|
262
|
+
expected_fields: dict[str, Any] = {
|
|
263
|
+
"cache_key_sha256": bound_manifest.cache_key_sha256,
|
|
264
|
+
"shard_count": bound_manifest.shard_count,
|
|
265
|
+
"total_tokens": bound_manifest.total_tokens,
|
|
266
|
+
}
|
|
267
|
+
if any(marker.get(name) != expected for name, expected in expected_fields.items()):
|
|
268
|
+
return False
|
|
269
|
+
schema_version = marker.get("schema_version")
|
|
270
|
+
if schema_version is None:
|
|
271
|
+
return True
|
|
272
|
+
if schema_version != _COMPLETION_SCHEMA:
|
|
273
|
+
return False
|
|
274
|
+
return marker.get("manifest_sha256") == stable_sha256(bound_manifest)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def load_cache_manifest(cache_dir: Path) -> TokenizedCacheManifest | None:
|
|
278
|
+
"""Load the tokenized cache manifest if it exists."""
|
|
279
|
+
manifest_path = cache_dir / _MANIFEST_NAME
|
|
280
|
+
if not manifest_path.is_file():
|
|
281
|
+
return None
|
|
282
|
+
try:
|
|
283
|
+
return load_model(manifest_path, TokenizedCacheManifest)
|
|
284
|
+
except (ArtifactError, ValueError):
|
|
285
|
+
return None
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def tokenize_calibration(
|
|
289
|
+
*,
|
|
290
|
+
model: ModelIdentity,
|
|
291
|
+
dataset_path: str | Path,
|
|
292
|
+
output_dir: str | Path,
|
|
293
|
+
profile: ProfileName,
|
|
294
|
+
sequence_length: int,
|
|
295
|
+
random_seed: int,
|
|
296
|
+
tokenizer_revision: str | None = None,
|
|
297
|
+
config_digest: str | None = None,
|
|
298
|
+
tokenizer: Any | None = None,
|
|
299
|
+
calibration_manifest_sha256: str | None = None,
|
|
300
|
+
separation_attested: bool = False,
|
|
301
|
+
domains: list[str] | None = None,
|
|
302
|
+
) -> TokenizedCacheManifest:
|
|
303
|
+
"""Tokenize a calibration dataset and write the cache structure.
|
|
304
|
+
|
|
305
|
+
This function requires MLX-LM for tokenization. If MLX-LM is not
|
|
306
|
+
available, it raises BackendUnavailableError.
|
|
307
|
+
|
|
308
|
+
Cache structure:
|
|
309
|
+
calibration-cache/
|
|
310
|
+
tokenized_cache_manifest.json
|
|
311
|
+
tokenized/
|
|
312
|
+
shard-0000.npz ... shard-NNNN.npz
|
|
313
|
+
completion.json
|
|
314
|
+
"""
|
|
315
|
+
dataset = Path(dataset_path).expanduser().resolve()
|
|
316
|
+
if not dataset.is_file():
|
|
317
|
+
raise CacheError(f"calibration dataset does not exist: {dataset}")
|
|
318
|
+
|
|
319
|
+
dataset_sha = file_sha256(dataset)
|
|
320
|
+
samples_data = _read_dataset(dataset)
|
|
321
|
+
recorded_domains = [
|
|
322
|
+
domain.strip()
|
|
323
|
+
for sample in samples_data
|
|
324
|
+
if isinstance((domain := sample.get("domain")), str) and domain.strip()
|
|
325
|
+
]
|
|
326
|
+
observed_domains = sorted(set(recorded_domains))
|
|
327
|
+
requested_domains = sorted(set(domains or observed_domains))
|
|
328
|
+
if observed_domains and not set(requested_domains).issubset(observed_domains):
|
|
329
|
+
missing = sorted(set(requested_domains) - set(observed_domains))
|
|
330
|
+
raise CacheError(f"declared calibration domains have no matching samples: {missing}")
|
|
331
|
+
domain_provenance = (
|
|
332
|
+
"sample-records"
|
|
333
|
+
if len(recorded_domains) == len(samples_data) and observed_domains
|
|
334
|
+
else "declared"
|
|
335
|
+
)
|
|
336
|
+
versions = collect_versions()
|
|
337
|
+
cache_dir = Path(output_dir).expanduser().resolve()
|
|
338
|
+
existing_manifest = load_cache_manifest(cache_dir)
|
|
339
|
+
if (
|
|
340
|
+
existing_manifest is not None
|
|
341
|
+
and existing_manifest.calibration_manifest_sha256 != calibration_manifest_sha256
|
|
342
|
+
):
|
|
343
|
+
raise CacheError(
|
|
344
|
+
"calibration cache already exists with a different calibration manifest binding "
|
|
345
|
+
f"at {cache_dir}; use a new output directory"
|
|
346
|
+
)
|
|
347
|
+
if existing_manifest is not None and (
|
|
348
|
+
existing_manifest.dataset_sha256 != dataset_sha
|
|
349
|
+
or existing_manifest.model != model
|
|
350
|
+
or existing_manifest.profile != profile
|
|
351
|
+
or existing_manifest.sequence_length != sequence_length
|
|
352
|
+
):
|
|
353
|
+
raise CacheError(
|
|
354
|
+
f"calibration cache already exists with different inputs at {cache_dir}; "
|
|
355
|
+
"use a new output directory"
|
|
356
|
+
)
|
|
357
|
+
tokenizer_instance = tokenizer or _load_tokenizer(model, tokenizer_revision)
|
|
358
|
+
tokenizer_sha = _tokenizer_sha256(tokenizer_instance)
|
|
359
|
+
|
|
360
|
+
cache_key = compute_cache_key(
|
|
361
|
+
model=model,
|
|
362
|
+
dataset_sha256=dataset_sha,
|
|
363
|
+
profile=profile,
|
|
364
|
+
sequence_length=sequence_length,
|
|
365
|
+
random_seed=random_seed,
|
|
366
|
+
tokenizer_revision=tokenizer_revision or model.revision,
|
|
367
|
+
config_digest=config_digest or tokenizer_sha,
|
|
368
|
+
mlx_version=versions.mlx,
|
|
369
|
+
mlx_lm_version=versions.mlx_lm,
|
|
370
|
+
separation_attested=separation_attested,
|
|
371
|
+
domains=tuple(requested_domains),
|
|
372
|
+
)
|
|
373
|
+
|
|
374
|
+
# Check for existing cache with same key
|
|
375
|
+
if existing_manifest is not None:
|
|
376
|
+
if existing_manifest.cache_key_sha256 == cache_key:
|
|
377
|
+
if is_cache_complete(cache_dir, existing_manifest):
|
|
378
|
+
issues = verify_cache_integrity(cache_dir, existing_manifest)
|
|
379
|
+
if issues:
|
|
380
|
+
raise CacheError(f"completed calibration cache failed verification: {issues}")
|
|
381
|
+
log.info("calibration_cache_reused", path=str(cache_dir))
|
|
382
|
+
return existing_manifest
|
|
383
|
+
# Incomplete cache with same key - verify and resume
|
|
384
|
+
issues = verify_cache_integrity(cache_dir, existing_manifest)
|
|
385
|
+
if not issues:
|
|
386
|
+
final_manifest = existing_manifest.model_copy(update={"complete": True})
|
|
387
|
+
write_data(cache_dir / _MANIFEST_NAME, final_manifest)
|
|
388
|
+
_write_completion_marker(cache_dir, final_manifest)
|
|
389
|
+
return final_manifest
|
|
390
|
+
raise CacheError(f"existing cache is incomplete and cannot be resumed: {issues}")
|
|
391
|
+
# Different inputs - refuse to overwrite
|
|
392
|
+
raise CacheError(
|
|
393
|
+
f"calibration cache already exists with different inputs at {cache_dir}; "
|
|
394
|
+
"use a new output directory"
|
|
395
|
+
)
|
|
396
|
+
|
|
397
|
+
# Create cache structure
|
|
398
|
+
tokenized_dir = cache_dir / "tokenized"
|
|
399
|
+
tokenized_dir.mkdir(parents=True, exist_ok=True)
|
|
400
|
+
|
|
401
|
+
try:
|
|
402
|
+
import numpy as np
|
|
403
|
+
except ImportError:
|
|
404
|
+
raise BackendUnavailableError(
|
|
405
|
+
"tokenization requires numpy; install with: pip install numpy"
|
|
406
|
+
) from None
|
|
407
|
+
|
|
408
|
+
order = list(range(len(samples_data)))
|
|
409
|
+
random.Random(random_seed).shuffle(order)
|
|
410
|
+
encoded: list[tuple[int, list[int]]] = []
|
|
411
|
+
for sample_index in order:
|
|
412
|
+
text = _sample_text(samples_data[sample_index], tokenizer_instance)
|
|
413
|
+
token_ids = tokenizer_instance.encode(
|
|
414
|
+
text,
|
|
415
|
+
add_special_tokens=True,
|
|
416
|
+
truncation=True,
|
|
417
|
+
max_length=sequence_length,
|
|
418
|
+
)
|
|
419
|
+
if not isinstance(token_ids, list):
|
|
420
|
+
token_ids = list(token_ids)
|
|
421
|
+
normalized_ids = [int(token) for token in token_ids[:sequence_length]]
|
|
422
|
+
if not normalized_ids:
|
|
423
|
+
fallback_token = getattr(tokenizer_instance, "eos_token_id", None)
|
|
424
|
+
normalized_ids = [int(fallback_token) if fallback_token is not None else 0]
|
|
425
|
+
encoded.append((sample_index, normalized_ids))
|
|
426
|
+
|
|
427
|
+
shard_count = max(1, (len(encoded) + _SAMPLES_PER_SHARD - 1) // _SAMPLES_PER_SHARD)
|
|
428
|
+
total_tokens = sum(len(tokens) for _, tokens in encoded)
|
|
429
|
+
shard_sha256: dict[str, str] = {}
|
|
430
|
+
pad_token = getattr(tokenizer_instance, "pad_token_id", None)
|
|
431
|
+
if pad_token is None:
|
|
432
|
+
pad_token = getattr(tokenizer_instance, "eos_token_id", None)
|
|
433
|
+
pad_token_id = int(pad_token) if pad_token is not None else 0
|
|
434
|
+
|
|
435
|
+
for shard_index in range(shard_count):
|
|
436
|
+
shard_samples = encoded[
|
|
437
|
+
shard_index * _SAMPLES_PER_SHARD : (shard_index + 1) * _SAMPLES_PER_SHARD
|
|
438
|
+
]
|
|
439
|
+
width = max(len(tokens) for _, tokens in shard_samples)
|
|
440
|
+
input_ids = np.full((len(shard_samples), width), pad_token_id, dtype=np.int32)
|
|
441
|
+
attention_mask = np.zeros((len(shard_samples), width), dtype=np.uint8)
|
|
442
|
+
sample_indices = np.empty((len(shard_samples),), dtype=np.int64)
|
|
443
|
+
for row, (sample_index, token_ids) in enumerate(shard_samples):
|
|
444
|
+
input_ids[row, : len(token_ids)] = token_ids
|
|
445
|
+
attention_mask[row, : len(token_ids)] = 1
|
|
446
|
+
sample_indices[row] = sample_index
|
|
447
|
+
shard_file = _shard_path(tokenized_dir, shard_index)
|
|
448
|
+
_write_npz_atomic(
|
|
449
|
+
shard_file,
|
|
450
|
+
compressed=True,
|
|
451
|
+
input_ids=input_ids,
|
|
452
|
+
attention_mask=attention_mask,
|
|
453
|
+
sample_indices=sample_indices,
|
|
454
|
+
)
|
|
455
|
+
shard_sha256[shard_file.name] = file_sha256(shard_file)
|
|
456
|
+
|
|
457
|
+
manifest = TokenizedCacheManifest(
|
|
458
|
+
cache_key_sha256=cache_key,
|
|
459
|
+
model=model,
|
|
460
|
+
dataset_sha256=dataset_sha,
|
|
461
|
+
profile=profile,
|
|
462
|
+
domains=requested_domains,
|
|
463
|
+
domain_provenance=domain_provenance,
|
|
464
|
+
sequence_length=sequence_length,
|
|
465
|
+
samples=len(samples_data),
|
|
466
|
+
shard_count=shard_count,
|
|
467
|
+
total_tokens=total_tokens,
|
|
468
|
+
tokenizer_revision=tokenizer_revision or model.revision,
|
|
469
|
+
tokenizer_sha256=tokenizer_sha,
|
|
470
|
+
sample_order_sha256=stable_sha256(order),
|
|
471
|
+
calibration_manifest_sha256=calibration_manifest_sha256,
|
|
472
|
+
calibration_evaluation_separation_attested=separation_attested,
|
|
473
|
+
backend_version=_BACKEND_VERSION,
|
|
474
|
+
shard_sha256=shard_sha256,
|
|
475
|
+
software_versions=versions,
|
|
476
|
+
complete=False,
|
|
477
|
+
)
|
|
478
|
+
|
|
479
|
+
# Write manifest
|
|
480
|
+
write_data(cache_dir / _MANIFEST_NAME, manifest)
|
|
481
|
+
|
|
482
|
+
# Verify all shards before writing completion marker
|
|
483
|
+
issues = verify_cache_integrity(cache_dir, manifest)
|
|
484
|
+
if issues:
|
|
485
|
+
raise CacheError(f"cache verification failed after writing: {issues}")
|
|
486
|
+
|
|
487
|
+
# Write atomic completion marker
|
|
488
|
+
final_manifest = manifest.model_copy(update={"complete": True})
|
|
489
|
+
write_data(cache_dir / _MANIFEST_NAME, final_manifest)
|
|
490
|
+
_write_completion_marker(cache_dir, final_manifest)
|
|
491
|
+
|
|
492
|
+
log.info(
|
|
493
|
+
"calibration_cache_created",
|
|
494
|
+
path=str(cache_dir),
|
|
495
|
+
shards=shard_count,
|
|
496
|
+
samples=len(samples_data),
|
|
497
|
+
total_tokens=total_tokens,
|
|
498
|
+
)
|
|
499
|
+
return final_manifest
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def _write_completion_marker(cache_dir: Path, manifest: TokenizedCacheManifest) -> None:
|
|
503
|
+
"""Write the atomic completion marker."""
|
|
504
|
+
marker_data = {
|
|
505
|
+
"schema_version": _COMPLETION_SCHEMA,
|
|
506
|
+
"complete": True,
|
|
507
|
+
"cache_key_sha256": manifest.cache_key_sha256,
|
|
508
|
+
"manifest_sha256": stable_sha256(manifest),
|
|
509
|
+
"shard_count": manifest.shard_count,
|
|
510
|
+
"total_tokens": manifest.total_tokens,
|
|
511
|
+
}
|
|
512
|
+
write_data(cache_dir / _COMPLETION_MARKER, marker_data)
|
axquant/analyzer.py
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pydantic import ValidationError
|
|
4
|
+
|
|
5
|
+
from axquant.errors import PlanningError
|
|
6
|
+
from axquant.schema import (
|
|
7
|
+
AX_ENGINE_EXECUTABLE_BITS,
|
|
8
|
+
AX_ENGINE_EXECUTABLE_GROUP_SIZES,
|
|
9
|
+
CandidateMeasurement,
|
|
10
|
+
EvidenceKind,
|
|
11
|
+
Inventory,
|
|
12
|
+
MetricVector,
|
|
13
|
+
ProfileName,
|
|
14
|
+
QuantMethod,
|
|
15
|
+
SensitivityReport,
|
|
16
|
+
TensorRole,
|
|
17
|
+
TensorSensitivity,
|
|
18
|
+
)
|
|
19
|
+
from axquant.serde import stable_sha256
|
|
20
|
+
|
|
21
|
+
_ROLE_SENSITIVITY = {
|
|
22
|
+
TensorRole.EMBEDDING: 0.90,
|
|
23
|
+
TensorRole.ATTENTION: 0.72,
|
|
24
|
+
TensorRole.MLP: 0.45,
|
|
25
|
+
TensorRole.NORM: 1.20,
|
|
26
|
+
TensorRole.LM_HEAD: 1.25,
|
|
27
|
+
TensorRole.ROUTER: 1.10,
|
|
28
|
+
TensorRole.EXPERT: 0.38,
|
|
29
|
+
TensorRole.MTP_PROJECTION: 1.20,
|
|
30
|
+
TensorRole.MTP_BLOCK: 1.10,
|
|
31
|
+
TensorRole.MTP_OUTPUT: 1.35,
|
|
32
|
+
TensorRole.VISION: 0.90,
|
|
33
|
+
TensorRole.AUDIO: 0.90,
|
|
34
|
+
TensorRole.OTHER: 0.60,
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _prior_metrics(
|
|
39
|
+
role: TensorRole,
|
|
40
|
+
bits: int,
|
|
41
|
+
*,
|
|
42
|
+
group_size: int | None = 64,
|
|
43
|
+
) -> MetricVector:
|
|
44
|
+
if bits == 16:
|
|
45
|
+
return MetricVector()
|
|
46
|
+
# Smaller groups weakly reduce prior noise (AXQ-028 development heuristic only).
|
|
47
|
+
group_factor = 1.0 if group_size is None else (float(group_size) / 64.0) ** 0.5
|
|
48
|
+
noise = (2.0 ** (4 - bits)) * group_factor
|
|
49
|
+
sensitivity = _ROLE_SENSITIVITY[role]
|
|
50
|
+
mtp_factor = 1.4 if role.is_mtp else 0.08
|
|
51
|
+
long_context_factor = 1.25 if role == TensorRole.ATTENTION else 0.30
|
|
52
|
+
return MetricVector(
|
|
53
|
+
output_kl=sensitivity * noise,
|
|
54
|
+
hidden_state_error=sensitivity * 0.80 * noise,
|
|
55
|
+
cosine_distance=sensitivity * 0.45 * noise,
|
|
56
|
+
token_disagreement=sensitivity * 0.55 * noise,
|
|
57
|
+
task_loss_delta=sensitivity * 0.65 * noise,
|
|
58
|
+
mtp_acceptance_loss=sensitivity * mtp_factor * noise,
|
|
59
|
+
long_context_loss=sensitivity * long_context_factor * noise,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def architecture_prior_report(
|
|
64
|
+
inventory: Inventory,
|
|
65
|
+
*,
|
|
66
|
+
profile: ProfileName,
|
|
67
|
+
candidate_bits: tuple[int, ...] = (4, 6, 8, 16),
|
|
68
|
+
group_size: int = 64,
|
|
69
|
+
candidate_group_sizes: tuple[int, ...] = (),
|
|
70
|
+
) -> SensitivityReport:
|
|
71
|
+
"""Build architecture-prior sensitivity with optional multi-group candidates (AXQ-028)."""
|
|
72
|
+
try:
|
|
73
|
+
inventory = Inventory.model_validate(inventory.model_dump(mode="python"))
|
|
74
|
+
except ValidationError as exc:
|
|
75
|
+
raise PlanningError(f"invalid inventory for architecture analysis: {exc}") from exc
|
|
76
|
+
if not inventory.tensors:
|
|
77
|
+
raise PlanningError("architecture analysis requires a non-empty tensor inventory")
|
|
78
|
+
if not candidate_bits:
|
|
79
|
+
raise PlanningError("architecture analysis requires at least one candidate bit-width")
|
|
80
|
+
if any(type(bits) is not int for bits in candidate_bits):
|
|
81
|
+
raise PlanningError("architecture candidate bit-widths must be integers")
|
|
82
|
+
normalized_bits = tuple(sorted(set(candidate_bits)))
|
|
83
|
+
unsupported_bits = set(normalized_bits) - AX_ENGINE_EXECUTABLE_BITS
|
|
84
|
+
if unsupported_bits:
|
|
85
|
+
raise PlanningError(
|
|
86
|
+
f"AX Engine does not support candidate bit-widths {sorted(unsupported_bits)}"
|
|
87
|
+
)
|
|
88
|
+
raw_groups = candidate_group_sizes or (group_size,)
|
|
89
|
+
if not raw_groups or any(type(size) is not int for size in raw_groups):
|
|
90
|
+
raise PlanningError("architecture candidate group sizes must be non-empty integers")
|
|
91
|
+
effective_groups = tuple(sorted(set(raw_groups)))
|
|
92
|
+
unsupported_groups = set(effective_groups) - AX_ENGINE_EXECUTABLE_GROUP_SIZES
|
|
93
|
+
if unsupported_groups:
|
|
94
|
+
raise PlanningError(
|
|
95
|
+
f"AX Engine does not support candidate group sizes {sorted(unsupported_groups)}"
|
|
96
|
+
)
|
|
97
|
+
entries: list[TensorSensitivity] = []
|
|
98
|
+
for tensor in inventory.tensors:
|
|
99
|
+
bits_for_tensor = normalized_bits if tensor.quantizable else (16,)
|
|
100
|
+
candidates: list[CandidateMeasurement] = []
|
|
101
|
+
for bits in bits_for_tensor:
|
|
102
|
+
if bits == 16:
|
|
103
|
+
candidates.append(
|
|
104
|
+
CandidateMeasurement(
|
|
105
|
+
bits=16,
|
|
106
|
+
method=QuantMethod.BF16,
|
|
107
|
+
group_size=None,
|
|
108
|
+
metrics=_prior_metrics(tensor.role, 16),
|
|
109
|
+
note="architecture prior; not a measured quality result",
|
|
110
|
+
)
|
|
111
|
+
)
|
|
112
|
+
continue
|
|
113
|
+
for size in effective_groups:
|
|
114
|
+
candidates.append(
|
|
115
|
+
CandidateMeasurement(
|
|
116
|
+
bits=bits,
|
|
117
|
+
method=QuantMethod.AFFINE,
|
|
118
|
+
group_size=size,
|
|
119
|
+
metrics=_prior_metrics(tensor.role, bits, group_size=size),
|
|
120
|
+
note="architecture prior; not a measured quality result",
|
|
121
|
+
)
|
|
122
|
+
)
|
|
123
|
+
entries.append(TensorSensitivity(tensor=tensor, candidates=candidates))
|
|
124
|
+
warnings = [
|
|
125
|
+
"This report contains architecture priors, not calibration measurements.",
|
|
126
|
+
"Conversion planning requires --allow-unmeasured for this report.",
|
|
127
|
+
]
|
|
128
|
+
if len(effective_groups) > 1:
|
|
129
|
+
warnings.append(
|
|
130
|
+
"Multi-group architecture priors weakly prefer smaller group sizes; "
|
|
131
|
+
"this is development evidence only (AXQ-028)."
|
|
132
|
+
)
|
|
133
|
+
return SensitivityReport(
|
|
134
|
+
model=inventory.model,
|
|
135
|
+
architecture_profile=inventory.architecture_profile,
|
|
136
|
+
profile=profile,
|
|
137
|
+
evidence_kind=EvidenceKind.ARCHITECTURE_PRIOR,
|
|
138
|
+
inventory_sha256=stable_sha256(inventory.model_dump(mode="json", exclude={"created_at"})),
|
|
139
|
+
entries=entries,
|
|
140
|
+
warnings=warnings,
|
|
141
|
+
)
|