已开启
feat: 支持量化结果持久化与恢复第一里程碑 #267
feat: 支持量化结果持久化与恢复第一里程碑 #267
已开启
sunshine750创建于 17 天前
14 个文件变更+4925-16
@@ -28,17 +28,22 @@ class InsertQuantizeModulePass(BaseModuleFusionPass):
28 APIs: match_pattern, do_pass28 APIs: match_pattern, do_pass
29 """29 """
30 30 
31- def __init__(self, quant_config):31+ def __init__(self, quant_config, quant_result_writer=None):
32 """32 """
33 Function: init object of insert quantize op quant pass33 Function: init object of insert quantize op quant pass
34 Parameter:34 Parameter:
35 quant_config: dict, config35 quant_config: dict, config
36+ quant_result_writer: optional QuantResultWriter injected into every
37+ inserted quantize module so it can dump its quant params at its
38+ own finalization point. None means quant results are not
39+ persisted.
36 Return: None40 Return: None
37 """41 """
38 super().__init__()42 super().__init__()
39 self.config = quant_config43 self.config = quant_config
40 self.quant_layers = list(quant_config.keys())44 self.quant_layers = list(quant_config.keys())
41 self.quantize_ops = dict()45 self.quantize_ops = dict()
46+ self.quant_result_writer = quant_result_writer
42 47 
43 def match_pattern(self, module, name):48 def match_pattern(self, module, name):
44 """49 """
@@ -80,6 +85,8 @@ class InsertQuantizeModulePass(BaseModuleFusionPass):
80 85 
81 helper = ModuleHelper(model)86 helper = ModuleHelper(model)
82 helper.replace_module_by_name(model, object_name, new_module)87 helper.replace_module_by_name(model, object_name, new_module)
88+ if self.quant_result_writer is not None:
89+ new_module.attach_quant_result_writer(self.quant_result_writer)
83 LOGGER.logd(90 LOGGER.logd(
84 "Insert quantize op module to '{}' success!".format(object_name),91 "Insert quantize op module to '{}' success!".format(object_name),
85 'InsertQuantizeModulePass',92 'InsertQuantizeModulePass',
@@ -0,0 +1,1401 @@
1+# -*- coding: UTF-8 -*-
2+# ----------------------------------------------------------------------------
3+# Copyright (c) Huawei Technologies Co., Ltd. 2026. All rights reserved.
4+#
5+# Licensed under the Apache License, Version 2.0 (the "License");
6+# you may not use this file except in compliance with the License.
7+# You may obtain a copy of the License at
8+#
9+# http://www.apache.org/licenses/LICENSE-2.0
10+ 
11+# Unless required by applicable law or agreed to in writing, software
12+# distributed under the License is distributed on an "AS IS" BASIS,
13+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+# See the License for the specific language governing permissions and
15+# limitations under the License.
16+# ----------------------------------------------------------------------------
17+"""Quantization result artifact: writer, integrity digests and manifest.
18+ 
19+Implements the fixed quant result wire format.
20+Directory protocol produced by a successful run::
21+ 
22+ quant_result_dir/
23+ |-- quant_manifest.json
24+ |-- model_layers_0_self_attn_q_proj.safetensors
25+ `-- ...
26+ 
27+``quant_manifest.json`` is written last and only after every expected unit has
28+been written and re-verified on disk. A directory without the manifest is not a
29+valid artifact: it is never partially loaded, never resumed and never
30+overwritten. The manifest is the single source of truth for the full config; the
31+safetensors header carries only the minimal cross-check fields, so two copies of
32+the config can never drift.
33+ 
34+Field names, the ``sha256:`` digest prefix and the header key set here are part
35+of the cross-stage contract consumed by ``reload_quant_params()``; do not rename
36+them for style.
37+"""
38+ 
39+import hashlib
40+import json
41+import os
42+import re
43+import stat
44+import uuid
45+ 
46+import copy
47+ 
48+import torch
49+import torch.nn as nn
50+from safetensors import safe_open
51+from safetensors.torch import save_file
52+ 
53+from amct_pytorch.common.utils.log import LOGGER
54+ 
55+MANIFEST_NAME = 'quant_manifest.json'
56+# Frozen manifest schema constants. Keep every wire-format constant together;
57+# readers and writers must derive their behavior from this single block.
58+MANIFEST_FORMAT = 'amct-quant-result-manifest'
59+SCHEMA_VERSION = 1
60+UNIT_SUFFIX = '.safetensors'
61+LOG_TAG = 'QuantResultWriter'
62+RELOAD_LOG_TAG = 'reload_quant_params'
63+DIGEST_PREFIX = 'sha256:'
64+ 
65+_REQUIRED_MANIFEST_FIELDS = (
66+ 'format',
67+ 'schema_version',
68+ 'artifact_id',
69+ 'source_model',
70+ 'units',
71+)
72+_REQUIRED_UNIT_FIELDS = (
73+ 'layer_name',
74+ 'file',
75+ 'quant_module_type',
76+ 'ori_module_type',
77+ 'quant_config',
78+ 'non_tensor_params',
79+ 'tensors',
80+ 'file_size',
81+ 'sha256',
82+ 'entry_digest',
83+)
84+_REQUIRED_SOURCE_MODEL_FIELDS = ('model_type', 'torch_dtype')
85+_REQUIRED_TENSOR_FIELDS = ('key', 'dtype', 'shape')
86+_DIGEST_PATTERN = re.compile(r'sha256:[0-9a-f]{64}')
87+ 
88+# 640 for files, matching amct_log conventions.
89+_FILE_MODE = stat.S_IRUSR | stat.S_IWUSR | stat.S_IRGRP
90+ 
91+_UNSAFE_NAME_CHARS = re.compile(r'[^0-9a-zA-Z_]')
92+_HASH_CHUNK_SIZE = 1024 * 1024
93+# Fields derived from the file itself, therefore excluded from entry_digest.
94+_DERIVED_ENTRY_FIELDS = ('file_size', 'sha256', 'entry_digest')
95+_ADDRESS_PATTERN = re.compile(r'0x[0-9a-fA-F]{6,}')
96+ 
97+ 
98+def is_quant_result_supported(quant_cls):
99+ """Return the class-level artifact capability declaration.
100+ 
101+ The declaration is only an eligibility signal. ``write_unit`` and the
102+ reload validators still verify the actual tensor and metadata payload, so a
103+ class cannot claim a valid artifact merely by setting this flag.
104+ """
105+ if not isinstance(quant_cls, type):
106+ return False
107+ if not bool(getattr(quant_cls, 'supports_quant_result', False)):
108+ return False
109+ quant_param_keys = getattr(quant_cls, 'quant_param_keys', None)
110+ if not callable(quant_param_keys):
111+ return False
112+ try:
113+ return bool(quant_param_keys())
114+ except (TypeError, ValueError):
115+ return False
116+ 
117+ 
118+def canonical_json(data):
119+ """Serialize to the one canonical JSON form used by every digest."""
120+ return json.dumps(
121+ data,
122+ ensure_ascii=False,
123+ sort_keys=True,
124+ separators=(",", ":"),
125+ allow_nan=False,
126+ )
127+ 
128+ 
129+def _prefixed(hexdigest):
130+ """Wire form of a digest: ``sha256:<hex>``."""
131+ return DIGEST_PREFIX + hexdigest
132+ 
133+ 
134+def sha256_of_text(text):
135+ """Prefixed SHA-256 of a str, encoded as UTF-8."""
136+ return _prefixed(hashlib.sha256(text.encode('utf-8')).hexdigest())
137+ 
138+ 
139+def digest_of(data):
140+ """Prefixed SHA-256 over the canonical JSON form of ``data``."""
141+ return sha256_of_text(canonical_json(data))
142+ 
143+ 
144+def sha256_of_file(file_path):
145+ """Prefixed SHA-256 of a file, read in bounded chunks."""
146+ hasher = hashlib.sha256()
147+ with open(file_path, 'rb') as file_handle:
148+ for chunk in iter(lambda: file_handle.read(_HASH_CHUNK_SIZE), b''):
149+ hasher.update(chunk)
150+ return _prefixed(hasher.hexdigest())
151+ 
152+ 
153+def _byte_view(tensor):
154+ """Flat contiguous uint8 view of a CPU tensor, dtype-agnostic.
155+ 
156+ ``Tensor.numpy()`` refuses bfloat16/float8 dtypes, so reinterpret the raw
157+ storage as uint8 first. Flattening before the view keeps non-contiguous and
158+ 0-d inputs working.
159+ """
160+ return tensor.detach().reshape(-1).contiguous().view(torch.uint8)
161+ 
162+ 
163+def update_hash_with_tensor(hasher, tensor, chunk_elems=_HASH_CHUNK_SIZE):
164+ """Fold a tensor's raw bytes into ``hasher`` without materializing a copy."""
165+ byte_view = _byte_view(tensor.cpu())
166+ total = byte_view.numel()
167+ for start in range(0, total, chunk_elems):
168+ hasher.update(byte_view[start : start + chunk_elems].numpy().tobytes())
169+ 
170+ 
171+def dtype_name(dtype):
172+ """Artifact dtype spelling: ``float32``, not ``torch.float32``."""
173+ text = str(dtype)
174+ return text[len('torch.') :] if text.startswith('torch.') else text
175+ 
176+ 
177+def tensor_schema_list(tensors):
178+ """Tensor schema: a key-sorted list of ``{key, dtype, shape}``."""
179+ return [
180+ {
181+ 'key': key,
182+ 'dtype': dtype_name(tensors[key].dtype),
183+ 'shape': list(tensors[key].shape),
184+ }
185+ for key in sorted(tensors)
186+ ]
187+ 
188+ 
189+def to_jsonable(value, path='quant_config'):
190+ """Strictly coerce a value for the manifest, or raise.
191+ 
192+ Only the types the artifact contract admits are converted: str/bool/int/float,
193+ None, ``torch.dtype``, and list/tuple/set/dict of those. Anything else --
194+ a module, a tensor, an arbitrary object -- is rejected rather than
195+ stringified, because a stringified object cannot reconstruct a module on
196+ reload and would silently produce an unusable artifact.
197+ """
198+ if value is None or isinstance(value, (bool, int, str)):
199+ return value
200+ if isinstance(value, float):
201+ if value != value or value in (float('inf'), float('-inf')):
202+ raise ValueError(
203+ "{} is the non-finite float {!r}, which canonical JSON cannot "
204+ "represent.".format(path, value)
205+ )
206+ return value
207+ if isinstance(value, torch.dtype):
208+ return dtype_name(value)
209+ if isinstance(value, dict):
210+ return _strict_mapping(value, path)
211+ if isinstance(value, (set, frozenset)):
212+ items = [to_jsonable(item, '{}[]'.format(path)) for item in value]
213+ return sorted(items, key=canonical_json)
214+ if isinstance(value, (list, tuple)):
215+ return [
216+ to_jsonable(item, '{}[{}]'.format(path, index))
217+ for index, item in enumerate(value)
218+ ]
219+ raise TypeError(
220+ "{} holds {} ({!r}), which cannot be serialized into a quant result "
221+ "manifest. Only scalars, torch.dtype and containers of those are "
222+ "allowed, so that reload can reconstruct the module.".format(
223+ path, type(value).__name__, value
224+ )
225+ )
226+ 
227+ 
228+def _strict_mapping(mapping, path):
229+ """Strictly convert a dict; keys must be str, bool or int."""
230+ result = {}
231+ for key, item in mapping.items():
232+ if not isinstance(key, (str, bool, int)):
233+ raise TypeError(
234+ "{} has a {} key ({!r}); quant result config keys must be "
235+ "str, bool or int.".format(path, type(key).__name__, key)
236+ )
237+ json_key = key if isinstance(key, str) else str(key)
238+ if json_key in result:
239+ raise ValueError(
240+ "{} key {!r} collides with another key after string "
241+ "normalization.".format(path, json_key)
242+ )
243+ result[json_key] = to_jsonable(item, '{}.{}'.format(path, json_key))
244+ return result
245+ 
246+ 
247+def to_jsonable_lossy(value):
248+ """Coerce anything into a stable digestible form, stringifying unknowns.
249+ 
250+ Only for the *fingerprint* of a source model config: a HuggingFace
251+ ``config.to_dict()`` may legitimately carry arbitrary objects, and the
252+ fingerprint only needs to be deterministic and change-detecting, never
253+ reversible. Never use this for ``quant_config`` or ``non_tensor_params`` --
254+ those must survive a reload, so they go through the strict
255+ :func:`to_jsonable`.
256+ """
257+ if value is None or isinstance(value, (bool, int, str)):
258+ return value
259+ if isinstance(value, float):
260+ if value != value or value in (float('inf'), float('-inf')):
261+ return repr(value)
262+ return value
263+ if isinstance(value, torch.dtype):
264+ return dtype_name(value)
265+ if isinstance(value, torch.Tensor):
266+ return {
267+ 'dtype': dtype_name(value.dtype),
268+ 'shape': list(value.shape),
269+ 'sha256': tensor_bytes_sha256(value),
270+ }
271+ if isinstance(value, (bytes, bytearray)):
272+ return bytes(value).hex()
273+ if isinstance(value, dict):
274+ return {
275+ (key if isinstance(key, str) else _stringify_unknown(key)): (
276+ to_jsonable_lossy(item)
277+ )
278+ for key, item in value.items()
279+ }
280+ if isinstance(value, (set, frozenset)):
281+ return sorted((to_jsonable_lossy(item) for item in value), key=canonical_json)
282+ if isinstance(value, (list, tuple)):
283+ return [to_jsonable_lossy(item) for item in value]
284+ return _stringify_unknown(value)
285+ 
286+ 
287+def _stringify_unknown(value):
288+ """Stable string form for a type JSON cannot represent.
289+ 
290+ ``str()`` of many objects embeds the memory address, which would make the
291+ fingerprint differ between runs; fall back to the qualified type name then.
292+ """
293+ text = str(value)
294+ if _ADDRESS_PATTERN.search(text):
295+ value_type = type(value)
296+ return '{}.{}'.format(value_type.__module__, value_type.__qualname__)
297+ return text
298+ 
299+ 
300+def tensor_bytes_sha256(tensor):
301+ """Prefixed SHA-256 over the raw bytes of a tensor."""
302+ hasher = hashlib.sha256()
303+ update_hash_with_tensor(hasher, tensor)
304+ return _prefixed(hasher.hexdigest())
305+ 
306+ 
307+def unit_file_name(layer_name):
308+ """Map a layer name to its unit file name.
309+ 
310+ Every character outside ``[0-9a-zA-Z_]`` is replaced, so the result can
311+ never contain a path separator, ``..`` or any other traversal construct.
312+ Distinct layer names can normalize to the same file name (``a.b`` and
313+ ``a-b`` both give ``a_b``); the writer keeps a reverse map and rejects such
314+ collisions instead of silently overwriting.
315+ """
316+ if not isinstance(layer_name, str) or not layer_name:
317+ raise ValueError(
318+ "layer name for a quant result unit must be a non-empty str, "
319+ "got {!r}.".format(layer_name)
320+ )
321+ sanitized = _UNSAFE_NAME_CHARS.sub('_', layer_name)
322+ if sanitized.strip('_') == '':
323+ raise ValueError(
324+ "layer name '{}' normalizes to an empty file name.".format(layer_name)
325+ )
326+ return sanitized + UNIT_SUFFIX
327+ 
328+ 
329+def resolve_unit_path(result_dir, file_name):
330+ """Absolute path of a unit file, rejecting anything outside result_dir.
331+ 
332+ Also enforced on read, since a manifest is untrusted input: an absolute
333+ path, a ``..`` segment or a nested path must never escape the artifact.
334+ """
335+ if not isinstance(file_name, str) or not file_name:
336+ raise ValueError("quant result unit file name must be a non-empty str.")
337+ if os.path.isabs(file_name) or os.path.dirname(file_name):
338+ raise ValueError(
339+ "quant result unit file '{}' must be a bare relative file name; "
340+ "absolute paths and directory components are rejected.".format(file_name)
341+ )
342+ root = os.path.realpath(result_dir)
343+ candidate = os.path.realpath(os.path.join(root, file_name))
344+ if os.path.dirname(candidate) != root:
345+ raise ValueError(
346+ "quant result unit '{}' resolves outside the result directory '{}'.".format(
347+ file_name, result_dir
348+ )
349+ )
350+ return candidate
351+ 
352+ 
353+def validate_quant_result_dir(quant_result_dir):
354+ """Validate a caller-created directory before writing quant results.
355+ 
356+ Only ``None`` means "do not persist". Everything else must be a usable,
357+ existing directory. Files, missing paths and inaccessible directories are
358+ rejected. Other directory entries may coexist with the artifact; the writer
359+ checks its own manifest and unit paths separately and never overwrites them.
360+ """
361+ if not isinstance(quant_result_dir, str):
362+ raise TypeError(
363+ "quant_result_dir must be a str path or None, got {}.".format(
364+ type(quant_result_dir).__name__
365+ )
366+ )
367+ if quant_result_dir.strip() == '':
368+ raise ValueError(
369+ "quant_result_dir must be a non-empty path; pass None to disable "
370+ "quant result persistence."
371+ )
372+ 
373+ target = os.path.abspath(os.path.expanduser(quant_result_dir))
374+ if not os.path.exists(target):
375+ raise ValueError(
376+ "quant_result_dir '{}' must be an existing directory; create it "
377+ "before calling quantize().".format(target)
378+ )
379+ if not os.path.isdir(target):
380+ raise ValueError(
381+ "quant_result_dir '{}' must be an existing directory, not a file.".format(
382+ target
383+ )
384+ )
385+ if not os.access(target, os.R_OK | os.W_OK | os.X_OK):
386+ raise ValueError(
387+ "quant_result_dir '{}' must be readable, writable, and searchable.".format(
388+ target
389+ )
390+ )
391+ return target
392+ 
393+ 
394+def temp_name_for(file_path):
395+ """Same-directory temp name for an atomic write of ``file_path``."""
396+ return os.path.join(
397+ os.path.dirname(file_path),
398+ '.{}.{}.tmp'.format(os.path.basename(file_path), os.getpid()),
399+ )
400+ 
401+ 
402+def _atomic_write_via(file_path, write_payload):
403+ """Atomically produce file_path: temp file -> fsync -> os.replace.
404+ 
405+ ``write_payload(tmp_path)`` does the actual writing, so a large tensor
406+ payload can stream straight to disk instead of being serialized into a bytes
407+ object first. Temp file and target share a directory, hence a filesystem.
408+ """
409+ tmp_path = temp_name_for(file_path)
410+ try:
411+ write_payload(tmp_path)
412+ _fsync_file(tmp_path)
413+ os.chmod(tmp_path, _FILE_MODE)
414+ os.replace(tmp_path, file_path)
415+ except BaseException:
416+ # Leave no partial file behind, for any failure including interrupts.
417+ if os.path.exists(tmp_path):
418+ os.remove(tmp_path)
419+ raise
420+ _fsync_dir(os.path.dirname(file_path))
421+ 
422+ 
423+def _fsync_file(file_path):
424+ """Flush a just-written file's contents to stable storage."""
425+ file_descriptor = os.open(file_path, os.O_RDONLY)
426+ try:
427+ os.fsync(file_descriptor)
428+ finally:
429+ os.close(file_descriptor)
430+ 
431+ 
432+def _fsync_dir(directory):
433+ """fsync a directory so a rename is durable; best effort by platform."""
434+ try:
435+ dir_fd = os.open(directory, os.O_RDONLY)
436+ except OSError:
437+ return
438+ try:
439+ os.fsync(dir_fd)
440+ except OSError:
441+ pass
442+ finally:
443+ os.close(dir_fd)
444+ 
445+ 
446+def _write_text_atomically(file_path, text):
447+ """Atomically write UTF-8 text (used for the manifest)."""
448+ 
449+ def _write(tmp_path):
450+ flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
451+ file_descriptor = os.open(tmp_path, flags, _FILE_MODE)
452+ with os.fdopen(file_descriptor, 'w', encoding='utf-8') as handle:
453+ handle.write(text)
454+ handle.flush()
455+ os.fsync(handle.fileno())
456+ 
457+ _atomic_write_via(file_path, _write)
458+ 
459+ 
460+def _source_model_type(model):
461+ """Artifact ``source_model.model_type``: HF model_type, else class name."""
462+ config = getattr(model, 'config', None)
463+ model_type = getattr(config, 'model_type', None)
464+ if isinstance(model_type, str) and model_type:
465+ return model_type
466+ return type(model).__name__
467+ 
468+ 
469+def _source_model_dtype(model):
470+ """Return the dtype that quantization treats as the source model dtype.
471+ 
472+ HuggingFace models declare it on ``config.torch_dtype``. Plain PyTorch
473+ modules do not, so use their first parameter or buffer as the equivalent
474+ declaration. The artifact intentionally records this semantic input,
475+ rather than hashing every weight byte or unrelated configuration field.
476+ """
477+ config = getattr(model, 'config', None)
478+ configured = getattr(config, 'torch_dtype', None)
479+ if configured is None:
480+ to_dict = getattr(config, 'to_dict', None)
481+ if callable(to_dict):
482+ config_dict = to_dict()
483+ if isinstance(config_dict, dict):
484+ configured = config_dict.get('torch_dtype')
485+ if isinstance(configured, torch.dtype):
486+ return dtype_name(configured)
487+ if isinstance(configured, str) and configured:
488+ return dtype_name(configured)
489+ for tensor in model.parameters():
490+ return dtype_name(tensor.dtype)
491+ for tensor in model.buffers():
492+ return dtype_name(tensor.dtype)
493+ return 'unknown'
494+ 
495+ 
496+def compute_model_fingerprint(model):
497+ """Build the source-model identity before any quant module is inserted.
498+ 
499+ Only model type and source dtype are compared during reload. Quantization
500+ results are intentionally reusable across checkpoints with the same
501+ quantization identity; layer names, module types and tensor shapes remain
502+ independently validated while staging each replacement module.
503+ """
504+ return {
Y
Yyaoguangxiu10 天前

校验太严格,建议只校验model type / torch_dtype等影响量化的参数

likedislike
sunshine750
7 天前 评论:
505+ 'model_type': _source_model_type(model),
506+ 'torch_dtype': _source_model_dtype(model),
507+ }
508+ 
509+ 
510+def unit_header(artifact_id, layer_name, entry_digest):
511+ """The minimal safetensors header: three ``str -> str`` cross-check fields.
512+ 
513+ The manifest is the only place that declares the artifact format and schema
514+ version. A unit only binds itself to that manifest and its corresponding
515+ entry, so global schema metadata is not duplicated in every file.
516+ """
517+ return {
518+ 'artifact_id': artifact_id,
519+ 'layer_name': layer_name,
520+ 'entry_digest': entry_digest,
521+ }
522+ 
523+ 
524+class QuantResultWriter:
525+ """Collects per-layer quant params and publishes a quant result artifact.
526+ 
527+ Lifecycle: one ``write_unit()`` per expected layer at that layer's own
528+ finalization point, then ``publish_if_complete()``. The manifest appears
529+ only when every expected unit has been written and re-verified, so an
530+ interrupted run leaves an unpublished (invalid) directory rather than a
531+ half-usable one.
532+ """
533+ 
534+ def __init__(
535+ self,
536+ result_dir,
537+ expected_units,
538+ model_fingerprint,
539+ artifact_id=None,
540+ ):
541+ self._result_dir = os.path.abspath(result_dir)
542+ self._expected_units = self._check_expected_units(expected_units)
543+ _validate_source_model_fields(model_fingerprint)
544+ self._model_fingerprint = dict(model_fingerprint)
545+ self._artifact_id = artifact_id or str(uuid.uuid4())
546+ self._entries = {}
547+ self._file_names = {}
548+ self._published = False
549+ 
550+ @staticmethod
551+ def _check_expected_units(expected_units):
552+ """Validate the expected unit set: non-empty, unique, all str."""
553+ units = list(expected_units)
554+ if not units:
555+ raise ValueError(
556+ "no quantizable layer was found for this model and config, so "
557+ "there is no quant result to persist."
558+ )
559+ for unit in units:
560+ if not isinstance(unit, str) or not unit:
561+ raise ValueError(
562+ "expected quant result unit names must be non-empty str, "
563+ "got {!r}.".format(unit)
564+ )
565+ if len(set(units)) != len(units):
566+ raise ValueError("expected quant result unit names contain duplicates.")
567+ return tuple(units)
568+ 
569+ @classmethod
570+ def create(cls, quant_result_dir, expected_units, model_fingerprint):
571+ """Validate inputs and return a writer for a caller-created directory."""
572+ units = cls._check_expected_units(expected_units)
573+ result_dir = validate_quant_result_dir(quant_result_dir)
574+ cls._check_managed_paths_available(result_dir, units)
575+ return cls(result_dir, units, model_fingerprint)
576+ 
577+ @staticmethod
578+ def _check_managed_paths_available(result_dir, expected_units):
579+ """Reject only paths this writer owns; leave unrelated entries alone."""
580+ managed_names = [MANIFEST_NAME]
581+ managed_names.extend(unit_file_name(unit) for unit in expected_units)
582+ existing = sorted(
583+ set(
584+ name
585+ for name in managed_names
586+ if os.path.lexists(os.path.join(result_dir, name))
587+ )
588+ )
589+ if existing:
590+ raise ValueError(
591+ "quant_result_dir '{}' already contains AMCT quant result file(s) "
592+ "{}; refusing to overwrite or resume them.".format(result_dir, existing)
593+ )
594+ 
595+ @property
596+ def result_dir(self):
597+ return self._result_dir
598+ 
599+ @property
600+ def manifest_path(self):
601+ return os.path.join(self._result_dir, MANIFEST_NAME)
602+ 
603+ @property
604+ def pending_units(self):
605+ """Expected units that still have no entry, in declaration order."""
606+ return tuple(unit for unit in self._expected_units if unit not in self._entries)
607+ 
608+ def write_unit(
609+ self,
610+ layer_name,
611+ tensors,
612+ non_tensor_params=None,
613+ quant_config=None,
614+ quant_module_type=None,
615+ ori_module_type=None,
616+ ):
617+ """Persist one layer's quant params as an atomic safetensors unit.
618+ 
619+ Registers the unit in memory only; the manifest is published separately
620+ once every expected unit has landed.
621+ """
622+ self._check_writable(layer_name)
623+ file_name = self._reserve_file_name(layer_name)
624+ tensors = self._check_tensors(layer_name, tensors)
625+ # Required by the schema the reader enforces: writing None here would
626+ # publish an artifact that cannot be reloaded.
627+ _non_empty_str(
628+ quant_module_type,
629+ "quant_module_type of layer '{}'".format(layer_name),
630+ )
631+ _non_empty_str(
632+ ori_module_type, "ori_module_type of layer '{}'".format(layer_name)
633+ )
634+ quant_config = _mapping_or_empty(
635+ quant_config, "quant_config of layer '{}'".format(layer_name)
636+ )
637+ non_tensor_params = _mapping_or_empty(
638+ non_tensor_params, "non_tensor_params of layer '{}'".format(layer_name)
639+ )
640+ entry = {
641+ 'layer_name': layer_name,
642+ 'file': file_name,
643+ 'quant_module_type': quant_module_type,
644+ 'ori_module_type': ori_module_type,
645+ 'quant_config': to_jsonable(
646+ quant_config, "quant_config of layer '{}'".format(layer_name)
647+ ),
648+ 'non_tensor_params': to_jsonable(
649+ non_tensor_params,
650+ "non_tensor_params of layer '{}'".format(layer_name),
651+ ),
652+ 'tensors': tensor_schema_list(tensors),
653+ }
654+ self._check_jsonable(layer_name, entry)
655+ entry['entry_digest'] = digest_of(entry)
656+ 
657+ file_path = resolve_unit_path(self._result_dir, file_name)
658+ if os.path.lexists(file_path):
659+ raise FileExistsError(
660+ "quant result unit '{}' already exists; refusing to overwrite it.".format(
661+ file_path
662+ )
663+ )
664+ header = unit_header(self._artifact_id, layer_name, entry['entry_digest'])
665+ # save_file streams straight to the temp path: a large tensor payload is
666+ # never serialized into an in-memory bytes object first.
667+ _atomic_write_via(
668+ file_path,
669+ lambda tmp_path: save_file(tensors, tmp_path, metadata=header),
670+ )
671+ # Read the unit back before trusting it, then bind size and digest.
672+ self._verify_unit_file(file_path, entry, header)
673+ entry['file_size'] = os.path.getsize(file_path)
674+ entry['sha256'] = sha256_of_file(file_path)
675+ 
676+ self._entries[layer_name] = entry
677+ LOGGER.logd(
678+ "Wrote quant result unit for layer '{}' to '{}'".format(
679+ layer_name, file_name
680+ ),
681+ LOG_TAG,
682+ )
683+ return entry
684+ 
685+ def _check_writable(self, layer_name):
686+ """Reject writes after publish, for unknown layers, or duplicates."""
687+ if self._published:
688+ raise RuntimeError(
689+ "quant result '{}' is already published; it is immutable.".format(
690+ self._result_dir
691+ )
692+ )
693+ if layer_name not in self._expected_units:
694+ raise ValueError(
695+ "layer '{}' is not part of the expected quant result units; "
696+ "refusing to write an unexpected unit.".format(layer_name)
697+ )
698+ if layer_name in self._entries:
699+ raise ValueError(
700+ "quant result unit for layer '{}' was already written; each "
701+ "layer may be dumped only once.".format(layer_name)
702+ )
703+ 
704+ def _reserve_file_name(self, layer_name):
705+ """Reserve the unit file name, rejecting post-sanitization collisions."""
706+ file_name = unit_file_name(layer_name)
707+ owner = self._file_names.get(file_name)
708+ if owner is not None and owner != layer_name:
709+ raise ValueError(
710+ "layers '{}' and '{}' both map to quant result file '{}'; "
711+ "refusing to overwrite.".format(owner, layer_name, file_name)
712+ )
713+ self._file_names[file_name] = layer_name
714+ return file_name
715+ 
716+ @staticmethod
717+ def _check_tensors(layer_name, tensors):
718+ """Validate the tensor payload: str keys, CPU, contiguous, detached."""
719+ if not isinstance(tensors, dict):
720+ raise TypeError(
721+ "quant param tensors of layer '{}' must be a dict, got {}.".format(
722+ layer_name, type(tensors).__name__
723+ )
724+ )
725+ checked = {}
726+ for key, value in tensors.items():
727+ if not isinstance(key, str) or not key:
728+ raise ValueError(
729+ "quant param tensor keys of layer '{}' must be non-empty "
730+ "str, got {!r}.".format(layer_name, key)
731+ )
732+ if not isinstance(value, torch.Tensor):
733+ raise TypeError(
734+ "quant param '{}' of layer '{}' must be a torch.Tensor, "
735+ "got {}.".format(key, layer_name, type(value).__name__)
736+ )
737+ checked[key] = value.detach().cpu().contiguous()
738+ return checked
739+ 
740+ @staticmethod
741+ def _check_jsonable(layer_name, entry):
742+ """Fail early if the entry cannot be canonically serialized."""
743+ try:
744+ canonical_json(entry)
745+ except (TypeError, ValueError) as error:
746+ raise ValueError(
747+ "quant result entry of layer '{}' is not JSON serializable: {}".format(
748+ layer_name, error
749+ )
750+ ) from error
751+ 
752+ @staticmethod
753+ def _verify_unit_file(file_path, entry, header):
754+ """Re-read a just-written unit and check its header and tensor schema."""
755+ with safe_open(file_path, framework='pt') as file_handle:
756+ actual_header = file_handle.metadata() or {}
757+ actual = {key: file_handle.get_tensor(key) for key in file_handle.keys()}
758+ if actual_header != header:
759+ raise RuntimeError(
760+ "quant result unit '{}' header mismatch after write: expected "
761+ "{}, found {}.".format(file_path, header, actual_header)
762+ )
763+ actual_schema = tensor_schema_list(actual)
764+ if actual_schema != entry['tensors']:
765+ raise RuntimeError(
766+ "quant result unit '{}' tensor schema mismatch after write: "
767+ "expected {}, found {}.".format(
768+ file_path, entry['tensors'], actual_schema
769+ )
770+ )
771+ 
772+ def publish_if_complete(self):
773+ """Publish the manifest iff every expected unit has been written.
774+ 
775+ Returns True when the artifact is published (including a previous
776+ publish), False when units are still pending. Called at each candidate
777+ finalization point, so the manifest lands as soon as -- and only when --
778+ the artifact is complete.
779+ """
780+ if self._published:
781+ return True
782+ pending = self.pending_units
783+ if pending:
784+ LOGGER.logd(
785+ "Quant result not published yet, {} unit(s) pending, e.g. '{}'".format(
786+ len(pending), pending[0]
787+ ),
788+ LOG_TAG,
789+ )
790+ return False
791+ self._publish()
792+ return True
793+ 
794+ def _publish(self):
795+ """Verify the whole file set, then atomically write the manifest last."""
796+ if os.path.lexists(self.manifest_path):
797+ raise FileExistsError(
798+ "quant result manifest '{}' already exists; refusing to overwrite "
799+ "or resume it.".format(self.manifest_path)
800+ )
801+ self._verify_result_dir()
802+ manifest = {
803+ 'format': MANIFEST_FORMAT,
804+ 'schema_version': SCHEMA_VERSION,
805+ 'artifact_id': self._artifact_id,
806+ 'source_model': self._model_fingerprint,
807+ 'units': [self._entries[unit] for unit in self._expected_units],
808+ }
809+ _write_text_atomically(self.manifest_path, canonical_json(manifest) + '\n')
810+ self._published = True
811+ LOGGER.logi(
812+ "Published quant result manifest '{}' with {} unit(s).".format(
813+ self.manifest_path, len(manifest['units'])
814+ ),
815+ LOG_TAG,
816+ )
817+ 
818+ def _verify_result_dir(self):
819+ """Check every unit managed by this writer is present and intact."""
820+ expected_files = {entry['file'] for entry in self._entries.values()}
821+ actual_files = set(os.listdir(self._result_dir))
822+ missing = sorted(expected_files - actual_files)
823+ if missing:
824+ raise RuntimeError(
825+ "quant result directory '{}' is missing unit file(s) {}; "
826+ "refusing to publish an incomplete artifact.".format(
827+ self._result_dir, missing
828+ )
829+ )
830+ for entry in self._entries.values():
831+ self._verify_entry(entry)
832+ 
833+ def _verify_entry(self, entry):
834+ """Re-check one entry against the file on disk before publishing."""
835+ file_path = resolve_unit_path(self._result_dir, entry['file'])
836+ actual_size = os.path.getsize(file_path)
837+ if actual_size != entry['file_size']:
838+ raise RuntimeError(
839+ "quant result unit '{}' changed size after write: expected {} "
840+ "bytes, found {}.".format(file_path, entry['file_size'], actual_size)
841+ )
842+ if sha256_of_file(file_path) != entry['sha256']:
843+ raise RuntimeError(
844+ "quant result unit '{}' failed its SHA-256 check before "
845+ "publish.".format(file_path)
846+ )
847+ digest_input = {
848+ key: value
849+ for key, value in entry.items()
850+ if key not in _DERIVED_ENTRY_FIELDS
851+ }
852+ if digest_of(digest_input) != entry['entry_digest']:
853+ raise RuntimeError(
854+ "quant result entry of layer '{}' failed its entry_digest "
855+ "check before publish.".format(entry['layer_name'])
856+ )
857+ self._verify_unit_file(
858+ file_path,
859+ entry,
860+ unit_header(self._artifact_id, entry['layer_name'], entry['entry_digest']),
861+ )
862+ 
863+ 
864+def load_quant_manifest(quant_result_dir):
865+ """Load and validate ``quant_manifest.json`` of a published artifact.
866+ 
867+ A directory without the manifest is not a valid quant result, so its absence
868+ is an error rather than an empty read. Parsed with ``json`` only -- never
869+ pickle -- and every exact schema field, duplicate-key rule, unit uniqueness
870+ rule and safe relative-path rule are enforced before anything is handed back.
871+ """
872+ result_dir = os.path.abspath(os.path.expanduser(quant_result_dir))
873+ manifest_path = os.path.join(result_dir, MANIFEST_NAME)
874+ if not os.path.isfile(manifest_path):
875+ raise FileNotFoundError(
876+ "'{}' is not a valid quant result: {} is missing, so the run that "
877+ "produced it did not complete.".format(quant_result_dir, MANIFEST_NAME)
878+ )
879+ with open(manifest_path, encoding='utf-8') as file_handle:
880+ manifest = json.load(file_handle, object_pairs_hook=_reject_duplicate_keys)
881+ if not isinstance(manifest, dict):
882+ raise ValueError(
883+ "quant manifest '{}' is not a JSON object.".format(manifest_path)
884+ )
885+ 
886+ _check_required(manifest, _REQUIRED_MANIFEST_FIELDS, 'quant manifest')
887+ if manifest['format'] != MANIFEST_FORMAT:
888+ raise ValueError(
889+ "unsupported quant manifest format '{}', expected '{}'.".format(
890+ manifest['format'], MANIFEST_FORMAT
891+ )
892+ )
893+ # bool is an int subclass and 1.0 == 1, so an exact int is demanded rather
894+ # than mere equality: the wire format says this field is an integer.
895+ version = manifest['schema_version']
896+ if (
897+ isinstance(version, bool)
898+ or not isinstance(version, int)
899+ or version != SCHEMA_VERSION
900+ ):
901+ raise ValueError(
902+ 'unsupported quant manifest schema_version {!r}, expected the int '
903+ '{}.'.format(version, SCHEMA_VERSION)
904+ )
905+ _validate_artifact_id(manifest['artifact_id'])
906+ _validate_source_model_fields(manifest['source_model'])
907+ _validate_units(manifest['units'], result_dir)
908+ return manifest
909+ 
910+ 
911+def _non_empty_str(value, what):
912+ """Every identifier in the manifest must be a non-empty string."""
913+ if not isinstance(value, str) or not value:
914+ raise ValueError('{} must be a non-empty string, got {!r}.'.format(what, value))
915+ return value
916+ 
917+ 
918+def _mapping_or_empty(value, what):
919+ """Normalize an optional mapping field, rejecting anything else.
920+ 
921+ ``None`` means "nothing to record" and becomes ``{}``. Deliberately not
922+ ``value or {}``: that also swallows ``[]``, ``''`` and ``False`` into an empty
923+ config, and a non-mapping would otherwise be written into a manifest the
924+ reader is obliged to reject.
925+ """
926+ if value is None:
927+ return {}
928+ if not isinstance(value, dict):
929+ raise TypeError(
930+ '{} must be a mapping or None, got {}.'.format(what, type(value).__name__)
931+ )
932+ return value
933+ 
934+ 
935+def _validate_artifact_id(artifact_id):
936+ """``artifact_id`` is the UUID the unit headers are cross-checked against."""
937+ _non_empty_str(artifact_id, 'quant manifest artifact_id')
938+ try:
939+ uuid.UUID(artifact_id)
940+ except (ValueError, AttributeError, TypeError):
941+ raise ValueError(
942+ 'quant manifest artifact_id {!r} is not a UUID.'.format(artifact_id)
943+ ) from None
944+ 
945+ 
946+def _validate_digest(value, what):
947+ """Digests are always ``sha256:`` followed by 64 lowercase hex chars."""
948+ _non_empty_str(value, what)
949+ if not _DIGEST_PATTERN.fullmatch(value):
950+ raise ValueError(
951+ '{} must look like "{}<64 hex chars>", got {!r}.'.format(
952+ what, DIGEST_PREFIX, value
953+ )
954+ )
955+ 
956+ 
957+def _validate_source_model_fields(source_model):
958+ """``source_model`` is the fingerprint reload matches the input model on."""
959+ _check_required(source_model, _REQUIRED_SOURCE_MODEL_FIELDS, 'source_model')
960+ _non_empty_str(source_model['model_type'], 'source_model.model_type')
961+ _non_empty_str(source_model['torch_dtype'], 'source_model.torch_dtype')
962+ 
963+ 
964+def _reject_duplicate_keys(pairs):
965+ """Reject duplicate JSON object keys instead of silently keeping the last."""
966+ result = {}
967+ for key, value in pairs:
968+ if key in result:
969+ raise ValueError(
970+ "quant manifest contains duplicate JSON key {!r}.".format(key)
971+ )
972+ result[key] = value
973+ return result
974+ 
975+ 
976+def _check_required(mapping, fields, what):
977+ """Require the exact frozen field set for a protocol object."""
978+ if not isinstance(mapping, dict):
979+ raise ValueError("{} must be a JSON object.".format(what))
980+ missing = [field for field in fields if field not in mapping]
981+ if missing:
982+ raise ValueError("{} is missing required field(s) {}.".format(what, missing))
983+ unexpected = sorted(set(mapping) - set(fields))
984+ if unexpected:
985+ raise ValueError("{} contains unknown field(s) {}.".format(what, unexpected))
986+ 
987+ 
988+def _validate_unit_fields(entry):
989+ """Type-check one unit entry against the frozen artifact schema.
990+ 
991+ Every field reload depends on is checked here rather than where it is used, so
992+ a malformed manifest cannot get as far as touching the model.
993+ """
994+ layer_name = entry['layer_name']
995+ for field in ('layer_name', 'file', 'quant_module_type', 'ori_module_type'):
996+ _non_empty_str(entry[field], "quant manifest unit {}".format(field))
997+ for field in ('quant_config', 'non_tensor_params'):
998+ if not isinstance(entry[field], dict):
999+ raise ValueError(
1000+ "quant manifest unit '{}' field {} must be a JSON object, got "
1001+ '{}.'.format(layer_name, field, type(entry[field]).__name__)
1002+ )
1003+ # bool is an int subclass, so it has to be excluded explicitly.
1004+ file_size = entry['file_size']
1005+ if isinstance(file_size, bool) or not isinstance(file_size, int) or file_size < 0:
1006+ raise ValueError(
1007+ "quant manifest unit '{}' file_size must be a non-negative int, got "
1008+ '{!r}.'.format(layer_name, file_size)
1009+ )
1010+ _validate_digest(
1011+ entry['sha256'], "quant manifest unit '{}' sha256".format(layer_name)
1012+ )
1013+ _validate_digest(
1014+ entry['entry_digest'],
1015+ "quant manifest unit '{}' entry_digest".format(layer_name),
1016+ )
1017+ _validate_tensor_schema(entry['tensors'], layer_name)
1018+ 
1019+ 
1020+def _validate_tensor_schema(tensors, layer_name):
1021+ """The tensor schema is a list of ``{key, dtype, shape}``, keys unique."""
1022+ if not isinstance(tensors, list):
1023+ raise ValueError(
1024+ "quant manifest unit '{}' tensors must be a list, got {}.".format(
1025+ layer_name, type(tensors).__name__
1026+ )
1027+ )
1028+ seen = set()
1029+ for item in tensors:
1030+ _check_required(
1031+ item,
1032+ _REQUIRED_TENSOR_FIELDS,
1033+ "quant manifest unit '{}' tensor".format(layer_name),
1034+ )
1035+ key = _non_empty_str(
1036+ item['key'], "quant manifest unit '{}' tensor key".format(layer_name)
1037+ )
1038+ _non_empty_str(
1039+ item['dtype'],
1040+ "quant manifest unit '{}' tensor '{}' dtype".format(layer_name, key),
1041+ )
1042+ shape = item['shape']
1043+ if not isinstance(shape, list) or any(
1044+ isinstance(dim, bool) or not isinstance(dim, int) or dim < 0
1045+ for dim in shape
1046+ ):
1047+ raise ValueError(
1048+ "quant manifest unit '{}' tensor '{}' shape must be a list of "
1049+ 'non-negative ints, got {!r}.'.format(layer_name, key, shape)
1050+ )
1051+ if key in seen:
1052+ raise ValueError(
1053+ "quant manifest unit '{}' lists tensor '{}' more than once.".format(
1054+ layer_name, key
1055+ )
1056+ )
1057+ seen.add(key)
1058+ 
1059+ 
1060+def _validate_units(units, result_dir):
1061+ """Validate the unit list: shape, required fields, uniqueness, safe paths."""
1062+ if not isinstance(units, list) or not units:
1063+ raise ValueError("quant manifest units must be a non-empty list.")
1064+ seen_layers = set()
1065+ seen_files = set()
1066+ for entry in units:
1067+ _check_required(entry, _REQUIRED_UNIT_FIELDS, 'quant manifest unit')
1068+ _validate_unit_fields(entry)
1069+ layer_name = entry['layer_name']
1070+ if layer_name in seen_layers:
1071+ raise ValueError(
1072+ "quant manifest lists layer '{}' more than once.".format(layer_name)
1073+ )
1074+ seen_layers.add(layer_name)
1075+ # Raises on absolute paths, directory components and traversal.
1076+ resolve_unit_path(result_dir, entry['file'])
1077+ if entry['file'] in seen_files:
1078+ raise ValueError(
1079+ "quant manifest lists file '{}' more than once.".format(entry['file'])
1080+ )
1081+ seen_files.add(entry['file'])
1082+ 
1083+ 
1084+def validate_result_directory(quant_result_dir, manifest):
1085+ """Check that the manifest and every unit it manages are present."""
1086+ result_dir = os.path.abspath(os.path.expanduser(quant_result_dir))
1087+ expected = {entry['file'] for entry in manifest['units']} | {MANIFEST_NAME}
1088+ actual = set(os.listdir(result_dir))
1089+ missing = sorted(expected - actual)
1090+ if missing:
1091+ raise ValueError(
1092+ "quant result '{}' is missing file(s) listed in its manifest: {}.".format(
1093+ result_dir, missing
1094+ )
1095+ )
1096+ 
1097+ 
1098+def read_quant_unit(quant_result_dir, layer_name, manifest=None):
1099+ """Read one verified unit back as ``(tensors, non_tensor_params, entry)``.
1100+ 
1101+ Checks file size, SHA-256, header, entry digest and tensor schema before
1102+ handing anything back, so a tampered or truncated artifact fails loudly.
1103+ """
1104+ result_dir = os.path.abspath(os.path.expanduser(quant_result_dir))
1105+ manifest = manifest or load_quant_manifest(result_dir)
1106+ entries = {entry['layer_name']: entry for entry in manifest['units']}
1107+ entry = entries.get(layer_name)
1108+ if entry is None:
1109+ raise KeyError(
1110+ "quant result '{}' has no unit for layer '{}'.".format(
1111+ result_dir, layer_name
1112+ )
1113+ )
1114+ 
1115+ file_path = resolve_unit_path(result_dir, entry['file'])
1116+ if not os.path.isfile(file_path):
1117+ raise ValueError("quant result unit '{}' is missing.".format(file_path))
1118+ if os.path.getsize(file_path) != entry['file_size']:
1119+ raise ValueError("quant result unit '{}' has wrong size.".format(file_path))
1120+ if sha256_of_file(file_path) != entry['sha256']:
1121+ raise ValueError(
1122+ "quant result unit '{}' failed its SHA-256 check.".format(file_path)
1123+ )
1124+ digest_input = {
1125+ key: value for key, value in entry.items() if key not in _DERIVED_ENTRY_FIELDS
1126+ }
1127+ if digest_of(digest_input) != entry['entry_digest']:
1128+ raise ValueError(
1129+ "quant result entry of layer '{}' failed its entry_digest check.".format(
1130+ layer_name
1131+ )
1132+ )
1133+ 
1134+ with safe_open(file_path, framework='pt') as file_handle:
1135+ header = file_handle.metadata() or {}
1136+ tensors = {key: file_handle.get_tensor(key) for key in file_handle.keys()}
1137+ expected_header = unit_header(
1138+ manifest['artifact_id'], layer_name, entry['entry_digest']
1139+ )
1140+ if header != expected_header:
1141+ raise ValueError(
1142+ "quant result unit '{}' header does not cross-check with the "
1143+ "manifest.".format(file_path)
1144+ )
1145+ actual_schema = tensor_schema_list(tensors)
1146+ if actual_schema != entry['tensors']:
1147+ raise ValueError(
1148+ "quant result unit '{}' tensor schema does not match the manifest.".format(
1149+ file_path
1150+ )
1151+ )
1152+ return tensors, dict(entry['non_tensor_params']), entry
1153+ 
1154+ 
1155+def validate_source_model(model, source_model):
1156+ """The float model handed to reload must be the one that was calibrated.
1157+ 
1158+ Compared on model type, structure/config digest and full state_dict digest,
1159+ so a different architecture, a changed dtype or shape, or any altered weight
1160+ is refused before a single module is touched.
1161+ """
1162+ # Shape first: a non-mapping must fail as a validation error, not as an
1163+ # AttributeError from inside the strict conversion.
1164+ _check_required(source_model, _REQUIRED_SOURCE_MODEL_FIELDS, 'source_model')
1165+ _strict_mapping(source_model, 'source_model')
1166+ actual = compute_model_fingerprint(model)
1167+ mismatched = [
1168+ (field, source_model[field], actual[field])
1169+ for field in _REQUIRED_SOURCE_MODEL_FIELDS
1170+ if source_model[field] != actual.get(field)
1171+ ]
1172+ if mismatched:
1173+ details = '; '.join(
1174+ '{}: artifact has {!r} but this model has {!r}'.format(*item)
1175+ for item in mismatched
1176+ )
1177+ raise ValueError(
1178+ 'the model passed in is not the model this quant result was produced '
1179+ 'from ({}). Reload expects the same original float model that was '
1180+ 'calibrated.'.format(details)
1181+ )
1182+ 
1183+ 
1184+def resolve_layer_parent(model, layer_name):
1185+ """Resolve ``layer_name`` to ``(parent_module, attr_name, child_module)``.
1186+ 
1187+ Every failure is a hard error: an empty name, an unreachable path, or a path
1188+ that runs through something that is not a module all mean the artifact does
1189+ not describe this model.
1190+ """
1191+ if not isinstance(layer_name, str) or not layer_name:
1192+ raise ValueError('quant result unit has an empty layer_name.')
1193+ parts = layer_name.split('.')
1194+ if any(not part for part in parts):
1195+ raise ValueError(
1196+ "quant result unit layer_name '{}' has an empty path component.".format(
1197+ layer_name
1198+ )
1199+ )
1200+ parent = model
1201+ for depth, part in enumerate(parts[:-1]):
1202+ child = getattr(parent, part, None)
1203+ if not isinstance(child, nn.Module):
1204+ raise ValueError(
1205+ "cannot reach layer '{}': '{}' is not a submodule of the model "
1206+ 'passed in.'.format(layer_name, '.'.join(parts[: depth + 1]))
1207+ )
1208+ parent = child
1209+ attr_name = parts[-1]
1210+ child = getattr(parent, attr_name, None)
1211+ if not isinstance(child, nn.Module):
1212+ raise ValueError(
1213+ "the model passed in has no submodule '{}', which this quant result "
1214+ 'expects.'.format(layer_name)
1215+ )
1216+ return parent, attr_name, child
1217+ 
1218+ 
1219+def _check_no_nested_units(layer_names):
1220+ """Refuse units whose paths nest, e.g. ``blk`` and ``blk.fc``.
1221+ 
1222+ Replacing the outer module would throw away the inner replacement (or make
1223+ the result depend on ordering), so this is rejected up front.
1224+ """
1225+ ordered = sorted(layer_names)
1226+ for outer in ordered:
1227+ prefix = outer + '.'
1228+ nested = [name for name in ordered if name.startswith(prefix)]
1229+ if nested:
1230+ raise ValueError(
1231+ "quant result cannot be reloaded: layer '{}' contains layer(s) {}, "
1232+ 'so replacing it would discard them.'.format(outer, nested)
1233+ )
1234+ 
1235+ 
1236+def _resolve_quant_module_class(entry):
1237+ """Resolve a unit through the registered algorithm and source module type."""
1238+ # Imported lazily to keep this module free of an import-time dependency on
1239+ # the algorithm registry.
1240+ from amct_pytorch.algorithms import AlgorithmRegistry
1241+ from amct_pytorch.common.config.utils import get_alg_name_from_config
1242+ from amct_pytorch.quantize_op.base_quant_module import BaseQuantizeModule
1243+ 
1244+ layer_name = entry['layer_name']
1245+ quant_config = entry['quant_config']
1246+ _strict_mapping(quant_config, "quant_config of layer '{}'".format(layer_name))
1247+ try:
1248+ algorithms, _ = get_alg_name_from_config(quant_config.get('algorithm'))
1249+ except ValueError as error:
1250+ raise ValueError(
1251+ "quant config of layer '{}' must name exactly one algorithm.".format(
1252+ layer_name
1253+ )
1254+ ) from error
1255+ if len(algorithms) != 1:
1256+ raise ValueError(
1257+ "quant config of layer '{}' must name exactly one algorithm, got {}.".format(
1258+ layer_name, algorithms
1259+ )
1260+ )
1261+ 
1262+ algorithm = algorithms[0]
1263+ registered_sources = AlgorithmRegistry.algo.get(algorithm)
1264+ if registered_sources is None:
1265+ raise ValueError(
1266+ "quant config of layer '{}' names algorithm '{}', which is not "
1267+ 'registered in this installation.'.format(layer_name, algorithm)
1268+ )
1269+ ori_module_type = entry['ori_module_type']
1270+ quant_cls = registered_sources.get(ori_module_type)
1271+ if quant_cls is None:
1272+ raise ValueError(
1273+ "algorithm '{}' has no registered quantize module for source type "
1274+ "'{}', required by layer '{}'.".format(
1275+ algorithm, ori_module_type, layer_name
1276+ )
1277+ )
1278+ if not is_quant_result_supported(quant_cls):
1279+ raise ValueError(
1280+ "algorithm '{}' of layer '{}' does not support quant result reload "
1281+ 'because its registered quantize class does not declare the artifact '
1282+ 'contract.'.format(algorithm, layer_name)
1283+ )
1284+ if not isinstance(quant_cls, type):
1285+ raise ValueError(
1286+ "registered quantize module for algorithm '{}' and source type '{}' "
1287+ 'is not a class.'.format(algorithm, ori_module_type)
1288+ )
1289+ if not issubclass(quant_cls, BaseQuantizeModule):
1290+ raise ValueError(
1291+ "quantize module '{}' of layer '{}' is not a BaseQuantizeModule "
1292+ 'subclass.'.format(quant_cls.__name__, layer_name)
1293+ )
1294+ if entry['quant_module_type'] != quant_cls.__name__:
1295+ raise ValueError(
1296+ "quant_module_type '{}' of layer '{}' does not match the registered "
1297+ "class '{}' selected by algorithm '{}' and source type '{}'.".format(
1298+ entry['quant_module_type'],
1299+ layer_name,
1300+ quant_cls.__name__,
1301+ algorithm,
1302+ ori_module_type,
1303+ )
1304+ )
1305+ return quant_cls
1306+ 
1307+ 
1308+def _check_ori_module_type(entry, ori_module):
1309+ """The layer in this model must still be the type that was calibrated."""
1310+ actual = type(ori_module).__name__
1311+ if actual != entry['ori_module_type']:
1312+ raise ValueError(
1313+ "the model passed in is not the model expected by quant result layer "
1314+ "'{}': it is a {}, but the artifact was produced from a {}.".format(
1315+ entry['layer_name'], actual, entry['ori_module_type']
1316+ )
1317+ )
1318+ 
1319+ 
1320+def _module_device(module):
1321+ """Device of a module's own tensors, defaulting to CPU when it has none."""
1322+ for tensor in list(module.parameters(recurse=True)) + list(module.buffers()):
1323+ return tensor.device
1324+ return torch.device('cpu')
1325+ 
1326+ 
1327+def _build_staged_module(entry, ori_module, tensors, non_tensor_params):
1328+ """Build one replacement module without touching the live model.
1329+ 
1330+ The quantize module is constructed on an isolated ``deepcopy`` of the layer on
1331+ purpose: some modules keep a reference to the original ``Parameter`` (GPTQ
1332+ does), and loading a persisted weight into that would mutate the model before
1333+ the transaction commits.
1334+ """
1335+ staged_source = copy.deepcopy(ori_module)
1336+ quant_cls = _resolve_quant_module_class(entry)
1337+ replacement = quant_cls(staged_source, entry['layer_name'], entry['quant_config'])
1338+ replacement.load_quant_params(tensors, non_tensor_params)
1339+ replacement.to(_module_device(ori_module))
1340+ return replacement
1341+ 
1342+ 
1343+def reload_quant_params(model, quant_result_dir):
1344+ """Rebuild a calibrated model from a published quant result, atomically.
1345+ 
1346+ Takes the same original float model the artifact was produced from and puts it
1347+ back into its post-calibration state. Everything is validated and every
1348+ replacement module is built and loaded before the first ``setattr``, so a bad
1349+ artifact leaves the model exactly as it was -- no partially quantized model.
1350+ 
1351+ Args:
1352+ model: the original float ``nn.Module`` that was calibrated.
1353+ quant_result_dir: directory holding the published quant result.
1354+ Returns:
1355+ The same model instance, modified in place.
1356+ """
1357+ result_dir = os.path.abspath(os.path.expanduser(quant_result_dir))
1358+ # Phase 1: validate the artifact and stage every replacement.
1359+ manifest = load_quant_manifest(result_dir)
1360+ validate_result_directory(result_dir, manifest)
1361+ validate_source_model(model, manifest['source_model'])
1362+ 
1363+ entries = list(manifest['units'])
1364+ _check_no_nested_units([entry['layer_name'] for entry in entries])
1365+ 
1366+ replacement_plan = []
1367+ for entry in entries:
1368+ layer_name = entry['layer_name']
1369+ parent, attr_name, ori_module = resolve_layer_parent(model, layer_name)
1370+ _check_ori_module_type(entry, ori_module)
1371+ tensors, non_tensor_params, _ = read_quant_unit(
1372+ result_dir, layer_name, manifest
1373+ )
1374+ replacement = _build_staged_module(
1375+ entry, ori_module, tensors, non_tensor_params
1376+ )
1377+ replacement_plan.append((parent, attr_name, replacement, ori_module))
1378+ 
1379+ # Phase 2: commit. Nothing here can fail on artifact content any more; an
1380+ # unexpected setattr failure is rolled back so the model stays untouched.
1381+ _commit_replacements(replacement_plan, result_dir, manifest)
1382+ return model
1383+ 
1384+ 
1385+def _commit_replacements(replacement_plan, result_dir, manifest):
1386+ """Install the staged modules, rolling back if any ``setattr`` fails."""
1387+ committed = []
1388+ try:
1389+ for parent, attr_name, replacement, ori_module in replacement_plan:
1390+ setattr(parent, attr_name, replacement)
1391+ committed.append((parent, attr_name, ori_module))
1392+ except Exception:
1393+ for parent, attr_name, ori_module in reversed(committed):
1394+ setattr(parent, attr_name, ori_module)
1395+ raise
1396+ LOGGER.logd(
1397+ "Reloaded {} quant unit(s) of artifact '{}' from '{}'.".format(
1398+ len(replacement_plan), manifest['artifact_id'], result_dir
1399+ ),
1400+ RELOAD_LOG_TAG,
1401+ )
@@ -25,35 +25,161 @@ from amct_pytorch.classic.optimizer import (
25 ReplaceNpuQuantModulePass,25 ReplaceNpuQuantModulePass,
26)26)
27from amct_pytorch.quantize_op.base_quant_module import BaseQuantizeModule27from amct_pytorch.quantize_op.base_quant_module import BaseQuantizeModule
28+from amct_pytorch.classic.quant_result import (
29+ QuantResultWriter,
30+ compute_model_fingerprint,
31+ is_quant_result_supported,
32+ reload_quant_params,
33+ validate_quant_result_dir,
34+)
28from amct_pytorch.common.config import parse_config, set_default_config35from amct_pytorch.common.config import parse_config, set_default_config
36+from amct_pytorch.common.config.utils import get_alg_name_from_config
29from amct_pytorch.common.utils.check_params import check_params37from amct_pytorch.common.utils.check_params import check_params
38+from amct_pytorch.common.utils.log import LOGGER
30 39 
31 40 
32-@check_params(model=nn.Module, config=(dict, type(None)))41+@check_params(
33-def quantize(model, config=None):42+ model=nn.Module, config=(dict, type(None)), quant_result_dir=(str, type(None))
43+)
44+def quantize(model, config=None, quant_result_dir=None):
34 """45 """
35 Function: Modify user's model for quantization46 Function: Modify user's model for quantization
36 Parameter: model: user mode instance of Torch.nn.Module47 Parameter: model: user mode instance of Torch.nn.Module
37 config: simply config dict from user to set48 config: simply config dict from user to set
49+ quant_result_dir: optional path of an existing directory to persist
50+ the quant result artifact into. Unrelated files may coexist,
51+ but AMCT never overwrites its own result files. None (default)
52+ keeps the model in-memory only and writes nothing.
38 Return: None53 Return: None
39 """54 """
40 if config is None:55 if config is None:
41 config = set_default_config()56 config = set_default_config()
42 57 
43 layer_config = parse_config(model, config, AlgorithmRegistry)58 layer_config = parse_config(model, config, AlgorithmRegistry)
59+ if '' in layer_config:
60+ # Module replacement is keyed by child name and cannot replace the root.
61+ raise ValueError(
62+ "the model passed to quantize() is itself a quantizable layer ({}); "
63+ "each quantized layer must be a named submodule. Wrap it in a parent "
64+ 'nn.Module.'.format(type(model).__name__)
65+ )
66+ 
67+ writer = None
68+ if quant_result_dir is not None:
69+ writer = _create_quant_result_writer(model, layer_config, quant_result_dir)
44 70 
45 optimizer = ModelOptimizer()71 optimizer = ModelOptimizer()
46- optimizer.add_pass(InsertQuantizeModulePass(layer_config))72+ optimizer.add_pass(
73+ InsertQuantizeModulePass(layer_config, quant_result_writer=writer)
74+ )
47 optimizer.do_optimizer(model)75 optimizer.do_optimizer(model)
48 76 
77+ if writer is not None:
78+ _finalize_quant_result(model, writer)
49 79 
50-@check_params(model=nn.Module)80+ 
51-def convert(model):81+def _check_quant_result_algorithms(model, layer_config):
82+ """Screen selected registered quant classes before writing any files.
83+ 
84+ Artifact support is declared by the class in ``AlgorithmRegistry.algo``;
85+ there is no second algorithm-name allowlist to drift out of sync. The
86+ writer still validates concrete values when each layer reaches finalization.
87+ """
88+ unsupported = {}
89+ modules = dict(model.named_modules())
90+ for layer_name, cfg in layer_config.items():
91+ algorithm = cfg.get('algorithm') if isinstance(cfg, dict) else None
92+ alg_names, _ = get_alg_name_from_config(algorithm)
93+ for alg_name in alg_names:
94+ module = modules.get(layer_name)
95+ source_type = type(module).__name__ if module is not None else None
96+ quant_cls = AlgorithmRegistry.algo.get(alg_name, {}).get(source_type)
97+ if not is_quant_result_supported(quant_cls):
98+ unsupported.setdefault(alg_name, layer_name)
99+ 
100+ if unsupported:
101+ details = ', '.join(
102+ "'{}' (e.g. layer '{}')".format(name, layer)
103+ for name, layer in sorted(unsupported.items())
104+ )
105+ raise ValueError(
106+ "quant_result_dir is not supported yet for algorithm(s) {}. "
107+ "The selected registered quantize class has no artifact contract; "
108+ "run without quant_result_dir, or choose a class that declares "
109+ "artifact support.".format(details)
110+ )
111+ 
112+ 
113+def _create_quant_result_writer(model, layer_config, quant_result_dir):
114+ """Validate the target dir, fingerprint the source model, build the writer.
115+ 
116+ Ordering matters: the path and the algorithm declarations are checked before
117+ fingerprinting, and the fingerprint is taken before any quant module is
118+ inserted, so it always describes the original model.
119+ """
120+ validate_quant_result_dir(quant_result_dir)
121+ if len(layer_config) == 0:
122+ raise ValueError(
123+ "no quantizable layer was found for this model and config, so "
124+ "there is no quant result to write to '{}'.".format(quant_result_dir)
125+ )
126+ _check_quant_result_algorithms(model, layer_config)
127+ model_fingerprint = compute_model_fingerprint(model)
128+ return QuantResultWriter.create(
129+ quant_result_dir, tuple(layer_config.keys()), model_fingerprint
130+ )
131+ 
132+ 
133+def _finalize_quant_result(model, writer):
134+ """Publish now if the artifact is already complete, else arm a hook.
135+ 
136+ Weight-only algorithms finalize during module insertion, so the manifest can
137+ be published as soon as the optimizer succeeds. Activation calibration only
138+ finalizes during the user's forward passes, so publication is deferred to a
139+ root forward hook that fires after a successful top-level forward; if that
140+ forward raises, the hook never runs and no manifest is written.
141+ """
142+ if writer.publish_if_complete():
143+ return
144+ 
145+ handle_holder = {}
146+ 
147+ def _publish_hook(module, args, output):
148+ if writer.publish_if_complete():
149+ handle = handle_holder.pop('handle', None)
150+ if handle is not None:
151+ # Artifact is immutable once published; stop checking.
152+ handle.remove()
153+ return None
154+ 
155+ handle_holder['handle'] = model.register_forward_hook(_publish_hook)
156+ LOGGER.logd(
157+ "Quant result '{}' has {} pending unit(s); the manifest will be "
158+ "published after calibration forward completes.".format(
159+ writer.result_dir, len(writer.pending_units)
160+ ),
161+ 'quantize',
162+ )
163+ 
164+ 
165+@check_params(model=nn.Module, quant_result_dir=(str, type(None)))
166+def convert(model, quant_result_dir=None):
52 """167 """
53 Function: Convert quantized calibration model to quantized deployment model168 Function: Convert quantized calibration model to quantized deployment model
54- Parameter: model: quantized calibration model instance of Torch.nn.Module169+ Parameter: model: quantized calibration model instance of Torch.nn.Module.
170+ When quant_result_dir is given, pass the original float
171+ model instead: it is restored to its calibrated state from
172+ the quant result first, then converted.
173+ quant_result_dir: str or None, directory of a published quant
174+ result produced by quantize(..., quant_result_dir=...).
175+ None keeps the original in-memory behaviour.
55 Return: None176 Return: None
56 """177 """
178+ if quant_result_dir is not None:
179+ # Restores calibration state only; which deploy module each layer becomes
180+ # is still decided by ReplaceNpuQuantModulePass below.
181+ reload_quant_params(model, quant_result_dir)
182+ 
57 optimizer = ModelOptimizer()183 optimizer = ModelOptimizer()
58 optimizer.add_pass(ReplaceNpuQuantModulePass())184 optimizer.add_pass(ReplaceNpuQuantModulePass())
59 optimizer.do_optimizer(model)185 optimizer.do_optimizer(model)
@@ -38,6 +38,13 @@ class GPTQuant(BaseQuantizeModule):
38 APIs: forward.38 APIs: forward.
39 """39 """
40 40 
41+ # ``weight`` must be persisted, unlike AWQ: GPTQ rewrites it with the
42+ # Hessian-corrected result, which cannot be recomputed from the original
43+ # weight and the scales. Dropping it would silently deploy the un-optimized
44+ # weight after a reload.
45+ _quant_param_keys = ('scale_w', 'offset_w', 'weight')
46+ supports_quant_result = True
47+ 
41 def __init__(self, ori_module, layer_name, quant_config):48 def __init__(self, ori_module, layer_name, quant_config):
42 """49 """
43 Function: init objective.50 Function: init objective.
@@ -97,6 +104,9 @@ class GPTQuant(BaseQuantizeModule):
97 ),104 ),
98 'GPTQuant',105 'GPTQuant',
99 )106 )
107+ # Dumped only after the optimized weight is in place, so the unit
108+ # holds the corrected weight and not the incoming one.
109+ self.maybe_dump_quant_params()
100 if self.cur_batch > self.quant_config.get('batch_num'):110 if self.cur_batch > self.quant_config.get('batch_num'):
101 return self.fake_quant_forward(inputs)111 return self.fake_quant_forward(inputs)
102 return F.linear(inputs, self.weight, self.bias)112 return F.linear(inputs, self.weight, self.bias)
@@ -114,6 +124,29 @@ class GPTQuant(BaseQuantizeModule):
114 self.fake_quant_cache_ready = True124 self.fake_quant_cache_ready = True
115 return F.linear(inputs, self.cached_dq_w, self.bias)125 return F.linear(inputs, self.cached_dq_w, self.bias)
116 126 
127+ def _on_quant_params_loaded(self):
128+ """Reloaded params are final: skip Hessian accumulation and GPTQ.
129+ 
130+ ``cur_batch`` is pushed past ``batch_num`` so ``forward`` goes straight to
131+ fake-quant, and the Hessian state is released because it is only an
132+ accumulator for the optimization that already happened.
133+ """
134+ # The constructor already put the *original* weight here, so absence in
135+ # the unit must be caught explicitly instead of falling back to it.
136+ self.require_loaded_quant_params('weight')
137+ # MXFP4 quantizes per-group at deploy time and legitimately leaves
138+ # scale_w as None (see cal_scale_offset_static), so it is only required
139+ # for the dtypes that actually produce it.
140+ if self.wts_type not in (MXFP4_E2M1,):
141+ self.require_loaded_quant_params('scale_w')
142+ if self.quant_config.get('weights_cfg').get('symmetric') is False:
143+ self.require_loaded_quant_params('offset_w')
144+ self.cur_batch = self.quant_config.get('batch_num') + 1
145+ self.hessian = None
146+ self.nsamples = 0
147+ self.fake_quant_cache_ready = False
148+ self.cached_dq_w = None
149+ 
117 def get_opt_weight_and_quant_factor(self):150 def get_opt_weight_and_quant_factor(self):
118 """151 """
119 Get optimized weights and quantization factors.152 Get optimized weights and quantization factors.
@@ -36,6 +36,13 @@ class LinearAWQuant(BaseQuantizeModule):
36 APIs: forward.36 APIs: forward.
37 """37 """
38 38 
39+ # ``weight`` is deliberately absent: AWQ never modifies its own weight, it
40+ # scales the original module's weight by 1/scale. Both calibration fake-quant
41+ # and deploy recompute that transform from the original weight and ``scale``,
42+ # so persisting it would only duplicate the source model.
43+ _quant_param_keys = ('scale_w', 'offset_w', 'scale')
44+ supports_quant_result = True
45+ 
39 def __init__(self, ori_module, layer_name, quant_config):46 def __init__(self, ori_module, layer_name, quant_config):
40 """47 """
41 Function: init objective.48 Function: init objective.
@@ -56,6 +63,9 @@ class LinearAWQuant(BaseQuantizeModule):
56 if self.quant_config.get('weights_cfg').get("group_size") is not None:63 if self.quant_config.get('weights_cfg').get("group_size") is not None:
57 self.group_size = self.quant_config.get('weights_cfg').get("group_size")64 self.group_size = self.quant_config.get('weights_cfg').get("group_size")
58 self.calc_done = False65 self.calc_done = False
66+ # True only on the reload path, where ori_module.weight was never scaled
67+ # in place and the transform has to be reapplied from weight and scale.
68+ self._scale_reapplied = False
59 enable_quant = quant_config.get("inputs_cfg").get("enable_quant")69 enable_quant = quant_config.get("inputs_cfg").get("enable_quant")
60 if enable_quant is None or enable_quant:70 if enable_quant is None or enable_quant:
61 self.act_granularity = quant_config.get('inputs_cfg').get('strategy')71 self.act_granularity = quant_config.get('inputs_cfg').get('strategy')
@@ -63,7 +73,7 @@ class LinearAWQuant(BaseQuantizeModule):
63 @torch.no_grad()73 @torch.no_grad()
64 def forward(self, inputs):74 def forward(self, inputs):
65 """75 """
66- Function: LinearAWQuant foward funtion.76+ Function: LinearAWQuant forward function.
67 Args:77 Args:
68 inputs: data used for calibration in torch.tensor.78 inputs: data used for calibration in torch.tensor.
69 """79 """
@@ -94,13 +104,36 @@ class LinearAWQuant(BaseQuantizeModule):
94 "Calculate awq quant params of layer '{}' success!".format(self.layer_name),104 "Calculate awq quant params of layer '{}' success!".format(self.layer_name),
95 "LinearAWQuant",105 "LinearAWQuant",
96 )106 )
107+ self.maybe_dump_quant_params()
97 return output108 return output
98 109 
110+ @torch.no_grad()
111+ def scaled_weight(self):
112+ """The search-scaled weight AWQ fake-quant works on.
113+ 
114+ During calibration ``apply_scale`` multiplies ``ori_module.weight`` in
115+ place, so that tensor is authoritative and is used as-is. After a reload
116+ there is no in-place result to read: the source model is untouched, so the
117+ transform is reapplied from ``self.weight`` (the original weight) and
118+ ``self.scale``.
119+ 
120+ The two are close but not bit-identical. ``self.scale`` is ``1/scale_awq``
121+ rounded to the weight precision, and dividing by it does not generally
122+ recover ``weight * scale_awq`` -- on bf16 a few hundred of 2048 elements
123+ differ by one ulp. Calibration therefore never takes the derived path, so
124+ ``quant_result_dir=None`` behaves exactly as before this was added.
125+ """
126+ original = getattr(self, 'ori_module', None)
127+ if original is not None and not self._scale_reapplied:
128+ return original.weight.data
129+ scale = self.scale.to(device=self.weight.device)
130+ return (self.weight.data / scale).to(self.weight.dtype)
131+ 
99 @torch.no_grad()132 @torch.no_grad()
100 def fake_quant_forward(self, inputs):133 def fake_quant_forward(self, inputs):
101 if not getattr(self, 'fake_quant_cache_ready', False):134 if not getattr(self, 'fake_quant_cache_ready', False):
102 self.cached_dq_w = quant_dequant_weight(135 self.cached_dq_w = quant_dequant_weight(
103- self.ori_module.weight.data,136+ self.scaled_weight(),
104 self.wts_type,137 self.wts_type,
105 self.scale_w,138 self.scale_w,
106 self.offset_w,139 self.offset_w,
@@ -109,3 +142,25 @@ class LinearAWQuant(BaseQuantizeModule):
109 self.fake_quant_cache_ready = True142 self.fake_quant_cache_ready = True
110 x = inputs * self.scale.to(device=inputs.device, dtype=inputs.dtype)143 x = inputs * self.scale.to(device=inputs.device, dtype=inputs.dtype)
111 return F.linear(x, self.cached_dq_w, self.bias)144 return F.linear(x, self.cached_dq_w, self.bias)
145+ 
146+ def _on_quant_params_loaded(self):
147+ """Reloaded params are final: never search a scale again.
148+ 
149+ ``scale`` is what makes the module usable, so a unit without it is an
150+ incomplete artifact rather than something to re-calibrate around. The
151+ fake-quant cache is dropped so it is rebuilt from the loaded params.
152+ """
153+ self.require_loaded_quant_params('scale')
154+ # Same per-dtype rule as GPTQ: forward() only computes scale_w/offset_w for
155+ # these types, so only they may be demanded back. Failing here beats
156+ # entering calc_done with scale_w=None and breaking at fake-quant time.
157+ if self.wts_type in (INT4, INT8, FLOAT4_E2M1):
158+ self.require_loaded_quant_params('scale_w')
159+ if self.wts_symmetric is False:
160+ self.require_loaded_quant_params('offset_w')
161+ self.calc_done = True
162+ # ori_module here is an untouched float layer, so the in-place scaled
163+ # weight does not exist and must be derived instead.
164+ self._scale_reapplied = True
165+ self.fake_quant_cache_ready = False
166+ self.cached_dq_w = None
@@ -45,6 +45,19 @@ class MinMaxQuant(BaseQuantizeModule):
45 APIs: forward.45 APIs: forward.
46 """46 """
47 47 
48+ # Quant params this algorithm produces. scale_w/offset_w are final right
49+ # after __init__; scale_d/offset_d only exist once activation calibration
50+ # completes; scale_w1/scale_w2 replace scale_w on the fp8/fp4 path.
51+ _quant_param_keys = (
52+ 'scale_w',
53+ 'offset_w',
54+ 'scale_d',
55+ 'offset_d',
56+ 'scale_w1',
57+ 'scale_w2',
58+ )
59+ supports_quant_result = True
60+ 
48 def __init__(self, ori_module, layer_name, quant_config):61 def __init__(self, ori_module, layer_name, quant_config):
49 """62 """
50 Function: init objective.63 Function: init objective.
@@ -147,8 +160,42 @@ class MinMaxQuant(BaseQuantizeModule):
147 ),160 ),
148 'MinMaxQuant',161 'MinMaxQuant',
149 )162 )
163+ # Activation params are final only here, so this is this layer's
164+ # single dump point on the activation path.
165+ self.maybe_dump_quant_params()
150 return fp_out166 return fp_out
151 167 
168+ def _on_quant_result_writer_attached(self):
169+ """Weight-only MinMax is already final at construction: dump now.
170+ 
171+ With activation quantization enabled, scale_d/offset_d are still
172+ missing, so the dump waits for the batch_num-th calibration forward.
173+ """
174+ if self.weight_compress_only:
175+ self.maybe_dump_quant_params()
176+ 
177+ def _on_quant_params_loaded(self):
178+ """Drop fake-quant caches and mark calibration as already complete."""
179+ if self.scale_w1 is not None or self.scale_w2 is not None:
180+ self.require_loaded_quant_params('scale_w1', 'scale_w2')
181+ else:
182+ self.require_loaded_quant_params('scale_w')
183+ if self.wts_symmetric is False:
184+ self.require_loaded_quant_params('offset_w')
185+ 
186+ if not self.weight_compress_only:
187+ self.require_loaded_quant_params('scale_d')
188+ if self.act_symmetric is False:
189+ self.require_loaded_quant_params('offset_d')
190+ 
191+ self.fake_quant_cache_ready = False
192+ self.cached_dq_w = None
193+ self.cached_bias = None
194+ if self.scale_d is not None and self.batch_num is not None:
195+ # Calibration is done; the next forward must fake-quant instead of
196+ # re-collecting min/max.
197+ self.cur_batch = self.batch_num
198+ 
152 @torch.no_grad()199 @torch.no_grad()
153 def fake_quant_forward(self, inputs):200 def fake_quant_forward(self, inputs):
154 if not getattr(self, 'fake_quant_cache_ready', False):201 if not getattr(self, 'fake_quant_cache_ready', False):
@@ -15,12 +15,37 @@
15# limitations under the License.15# limitations under the License.
16# ----------------------------------------------------------------------------16# ----------------------------------------------------------------------------
17 17 
18+import torch
18import torch.nn as nn19import torch.nn as nn
19 20 
21+from amct_pytorch.common.utils.log import LOGGER
22+ 
23+LOG_TAG = 'BaseQuantizeModule'
24+ 
20 25 
21class BaseQuantizeModule(nn.Module):26class BaseQuantizeModule(nn.Module):
27+ """Base class of calibration modules with quant param export and load.
28+ 
29+ Each subclass declares its own quant param attribute names so the base class
30+ does not hard-code the algorithm-specific names. The generic export, load
31+ and dump plumbing can then be shared by MinMax, AWQ and GPTQ.
32+ """
33+ 
34+ # Attribute names holding this algorithm's quant params. Values may be
35+ # tensors, JSON-serializable scalars, or None (skipped when exporting).
36+ _quant_param_keys = ()
37+ 
38+ # A declaration consumed by the Classic artifact path. It only says that
39+ # the class participates in the artifact contract; the writer still checks
40+ # the concrete values produced at each algorithm's finalization point.
41+ supports_quant_result = False
42+ 
22 def __init__(self, ori_module, layer_name, quant_config):43 def __init__(self, ori_module, layer_name, quant_config):
23 super().__init__()44 super().__init__()
45+ if not isinstance(layer_name, str) or not layer_name:
46+ raise ValueError(
47+ 'layer_name must be a non-empty string, got {!r}.'.format(layer_name)
48+ )
24 self.ori_module_type = None49 self.ori_module_type = None
25 self.act_type = None50 self.act_type = None
26 self.wts_type = None51 self.wts_type = None
@@ -33,5 +58,185 @@ class BaseQuantizeModule(nn.Module):
33 self.scale = None58 self.scale = None
34 self.clip_max = None59 self.clip_max = None
35 60 
61+ self.layer_name = layer_name
62+ self.quant_config = quant_config
63+ self._quant_result_writer = None
64+ self._quant_params_dumped = False
65+ 
36 def forward(self, inputs):66 def forward(self, inputs):
37 pass67 pass
68+ 
69+ @classmethod
70+ def quant_param_keys(cls):
71+ """Validated tuple of declared quant param attribute names."""
72+ keys = tuple(cls._quant_param_keys)
73+ for key in keys:
74+ if not isinstance(key, str) or not key:
75+ raise TypeError(
76+ "quant param names declared by {} must be non-empty strings, "
77+ "got {!r}.".format(cls.__name__, key)
78+ )
79+ if len(set(keys)) != len(keys):
80+ raise ValueError(
81+ '{} declares duplicate quant param names.'.format(cls.__name__)
82+ )
83+ return keys
84+ 
85+ def export_quant_params(self):
86+ """Split declared quant params into ``(tensors, metadata)``.
87+ 
88+ Tensors come back detached, on CPU and contiguous so they are safe to
89+ serialize; non-tensor values go to metadata; ``None`` values are absent
90+ from both, which is how "this algorithm/config never produced it" is
91+ represented.
92+ """
93+ tensors = {}
94+ metadata = {}
95+ for key in self.quant_param_keys():
96+ value = getattr(self, key, None)
97+ if value is None:
98+ continue
99+ if isinstance(value, torch.Tensor):
100+ tensors[key] = value.detach().cpu().contiguous()
101+ else:
102+ metadata[key] = value
103+ return tensors, metadata
104+ 
105+ def load_quant_params(self, tensors, metadata=None):
106+ """Restore quant params previously exported for this module.
107+ 
108+ Rejects undeclared keys and keys present in both payloads. An existing
109+ tensor attribute is copied into in place (preserving its device, dtype
110+ and shape); an attribute currently ``None`` adopts the persisted dtype
111+ and moves to this module's device.
112+ """
113+ tensors = dict(tensors or {})
114+ metadata = dict(metadata or {})
115+ declared = set(self.quant_param_keys())
116+ self._check_loadable_keys(tensors, metadata, declared)
117+ 
118+ device = self._quant_param_device()
119+ for key, value in tensors.items():
120+ self._load_tensor_param(key, value, device)
121+ for key, value in metadata.items():
122+ setattr(self, key, value)
123+ 
124+ # Recorded so a subclass hook can tell "the artifact carried this" from
125+ # "the constructor happened to leave an attribute here", which matters for
126+ # params that always pre-exist, such as GPTQ's weight.
127+ self._loaded_quant_param_keys = frozenset(tensors) | frozenset(metadata)
128+ self._on_quant_params_loaded()
129+ LOGGER.logd(
130+ "Loaded quant params of layer '{}': {} tensor(s), {} scalar(s)".format(
131+ self.layer_name, len(tensors), len(metadata)
132+ ),
133+ LOG_TAG,
134+ )
135+ 
136+ def _check_loadable_keys(self, tensors, metadata, declared):
137+ """Validate the incoming key sets against the declared params."""
138+ unknown = sorted((set(tensors) | set(metadata)) - declared)
139+ if unknown:
140+ raise ValueError(
141+ "quant params {} are not declared by {}; refusing to load them "
142+ "onto layer '{}'.".format(unknown, type(self).__name__, self.layer_name)
143+ )
144+ overlap = sorted(set(tensors) & set(metadata))
145+ if overlap:
146+ raise ValueError(
147+ "quant params {} appear as both tensor and non-tensor values "
148+ "for layer '{}'.".format(overlap, self.layer_name)
149+ )
150+ 
151+ @torch.no_grad()
152+ def _load_tensor_param(self, key, value, device):
153+ """Restore one tensor quant param, in place when one already exists."""
154+ if not isinstance(value, torch.Tensor):
155+ raise TypeError(
156+ "quant param '{}' of layer '{}' must be a torch.Tensor, got {}.".format(
157+ key, self.layer_name, type(value).__name__
158+ )
159+ )
160+ current = getattr(self, key, None)
161+ if not isinstance(current, torch.Tensor):
162+ setattr(self, key, value.detach().to(device))
163+ return
164+ if tuple(current.shape) != tuple(value.shape):
165+ raise ValueError(
166+ "quant param '{}' of layer '{}' has shape {} but the persisted "
167+ "value has shape {}.".format(
168+ key, self.layer_name, tuple(current.shape), tuple(value.shape)
169+ )
170+ )
171+ if current.dtype != value.dtype:
172+ raise ValueError(
173+ "quant param '{}' of layer '{}' has dtype {} but the persisted "
174+ "value has dtype {}.".format(
175+ key, self.layer_name, current.dtype, value.dtype
176+ )
177+ )
178+ current.copy_(value.to(current.device))
179+ 
180+ def _quant_param_device(self):
181+ """Device a restored quant param should live on: follow the weight."""
182+ weight = getattr(self, 'weight', None)
183+ if isinstance(weight, torch.Tensor):
184+ return weight.device
185+ for parameter in self.parameters(recurse=True):
186+ return parameter.device
187+ return torch.device('cpu')
188+ 
189+ def attach_quant_result_writer(self, writer):
190+ """Attach the run's result writer, then run the subclass hook.
191+ 
192+ Injected right after this module replaces the original one, which is
193+ also the point where an algorithm whose params are already final (pure
194+ weight-only) can dump immediately.
195+ """
196+ self._quant_result_writer = writer
197+ self._on_quant_result_writer_attached()
198+ 
199+ def maybe_dump_quant_params(self):
200+ """Dump this layer's quant params once, if a writer is attached.
201+ 
202+ Returns True when this call performed the dump. A failed write leaves
203+ the module undumped so the unit stays pending and no manifest appears.
204+ """
205+ if self._quant_result_writer is None or self._quant_params_dumped:
206+ return False
207+ # Imported lazily: quant_result lives under classic/, which this module
208+ # must not depend on at import time.
209+ from amct_pytorch.classic.quant_result import to_jsonable
210+ 
211+ tensors, non_tensor_params = self.export_quant_params()
212+ self._quant_result_writer.write_unit(
213+ layer_name=self.layer_name,
214+ tensors=tensors,
215+ non_tensor_params=non_tensor_params,
216+ quant_config=to_jsonable(
217+ self.quant_config,
218+ "quant_config of layer '{}'".format(self.layer_name),
219+ ),
220+ quant_module_type=type(self).__name__,
221+ ori_module_type=self.ori_module_type,
222+ )
223+ self._quant_params_dumped = True
224+ return True
225+ 
226+ def require_loaded_quant_params(self, *keys):
227+ """Fail unless the loaded unit carried every one of ``keys``."""
228+ loaded_keys = getattr(self, '_loaded_quant_param_keys', frozenset())
229+ missing = sorted(key for key in keys if key not in loaded_keys)
230+ if missing:
231+ raise ValueError(
232+ "quant result of layer '{}' is missing required quant param(s) {} "
233+ "for {}; refusing to reload an incomplete unit.".format(
234+ self.layer_name, missing, type(self).__name__
235+ )
236+ )
237+ 
238+ def _on_quant_params_loaded(self):
239+ """Hook: subclasses drop caches / restore progress after a load."""
240+ 
241+ def _on_quant_result_writer_attached(self):
242+ """Hook: subclasses may dump right away if their params are final."""
@@ -13,6 +13,10 @@ loguru
13datasets13datasets
14accelerate14accelerate
15compressed_tensors==0.15.0.115compressed_tensors==0.15.0.1
16+# Quant result artifacts (amct_pytorch/classic/quant_result.py) and the deploy
17+# export path read/write safetensors directly, so declare it explicitly rather
18+# than relying on it arriving as a transformers transitive dependency.
19+safetensors>=0.4.3
16scipy20scipy
17torchao==0.12.021torchao==0.12.0
18einops22einops
@@ -710,7 +710,7 @@ class TestConfigParse(unittest.TestCase):
710 try:710 try:
711 parse_config(model, cfg, AlgorithmRegistry)711 parse_config(model, cfg, AlgorithmRegistry)
712 except Exception as e:712 except Exception as e:
713- self.assertIn('Not support algorithm AA, pls regiter it first', str(e))713+ self.assertIn('Not support algorithm AA', str(e))
714 714 
715 def test_customize_algo_cfg(self):715 def test_customize_algo_cfg(self):
716 cfg = {716 cfg = {
@@ -17,19 +17,37 @@
17# ----------------------------------------------------------------------------17# ----------------------------------------------------------------------------
18 18 
19import sys19import sys
20-from unittest.mock import patch20+from unittest.mock import MagicMock, patch
21 21 
22import pytest22import pytest
23+import torch
23from torch import nn24from torch import nn
24 25 
26+from amct_pytorch.classic.optimizer import InsertQuantizeModulePass
27+from amct_pytorch.classic.quant_result import (
28+ MANIFEST_FORMAT,
29+ MANIFEST_NAME,
30+ SCHEMA_VERSION,
31+ compute_model_fingerprint,
32+ is_quant_result_supported,
33+ load_quant_manifest,
34+ read_quant_unit,
35+ validate_result_directory,
36+)
25from amct_pytorch.classic.quantize import (37from amct_pytorch.classic.quantize import (
26 algorithm_register,38 algorithm_register,
27 convert,39 convert,
28 quantize,40 quantize,
29)41)
42+from amct_pytorch.classic.quantize_op.minmax_module import MinMaxQuant
43+from amct_pytorch.classic.quantize_op.linear_awq_module import LinearAWQuant
44+from amct_pytorch.classic.quantize_op.gptq_module import GPTQuant
45+from amct_pytorch.classic.quantize_op.smooth_quant_module import SmoothQuant
46+from amct_pytorch.common.utils.model_util import ModuleHelper
30from amct_pytorch.quantize_op import BaseQuantizeModule47from amct_pytorch.quantize_op import BaseQuantizeModule
31 48 
32QUANTIZE_MODULE = sys.modules["amct_pytorch.classic.quantize"]49QUANTIZE_MODULE = sys.modules["amct_pytorch.classic.quantize"]
50+QUANT_RESULT_MODULE = sys.modules["amct_pytorch.classic.quant_result"]
33 51 
34 52 
35def test_base_quantize_module_keeps_public_import_paths():53def test_base_quantize_module_keeps_public_import_paths():
@@ -46,6 +64,19 @@ def test_classic_quantize_imports_without_ptq_package_layer():
46 assert classic_algorithm_register is algorithm_register64 assert classic_algorithm_register is algorithm_register
47 65 
48 66 
67+def test_artifact_support_is_declared_by_the_registered_quant_class():
68+ assert is_quant_result_supported(MinMaxQuant)
69+ assert is_quant_result_supported(LinearAWQuant)
70+ assert is_quant_result_supported(GPTQuant)
71+ assert not is_quant_result_supported(SmoothQuant)
72+ 
73+ class _DeclaredWithoutParams(BaseQuantizeModule):
74+ supports_quant_result = True
75+ 
76+ # A declaration alone cannot make an unloadable class eligible.
77+ assert not is_quant_result_supported(_DeclaredWithoutParams)
78+ 
79+ 
49class _DummyQuantOp(BaseQuantizeModule):80class _DummyQuantOp(BaseQuantizeModule):
50 pass81 pass
51 82 
@@ -117,7 +148,7 @@ def test_quantize_uses_default_config_when_none():
117 args, _ = mock_parse.call_args148 args, _ = mock_parse.call_args
118 assert args[0] is model149 assert args[0] is model
119 assert args[1] is sentinel_default150 assert args[1] is sentinel_default
120- mock_pass_cls.assert_called_once_with(sentinel_layer)151+ mock_pass_cls.assert_called_once_with(sentinel_layer, quant_result_writer=None)
121 opt.add_pass.assert_called_once_with(mock_pass_cls.return_value)152 opt.add_pass.assert_called_once_with(mock_pass_cls.return_value)
122 opt.do_optimizer.assert_called_once_with(model)153 opt.do_optimizer.assert_called_once_with(model)
123 154 
@@ -149,3 +180,457 @@ def test_quantize_rejects_non_dict_config():
149 model = nn.Linear(4, 4)180 model = nn.Linear(4, 4)
150 with pytest.raises(TypeError):181 with pytest.raises(TypeError):
151 quantize(model, "bad_config")182 quantize(model, "bad_config")
183+ 
184+ 
185+# --------------------------------------------------------------------------
186+# quant_result_dir: quant result artifact persistence.
187+#
188+# The default (None) path keeps the old user-visible behaviour: no directory and
189+# no artifact writes. With a directory, the manifest appears only once every
190+# expected unit has been dumped.
191+# --------------------------------------------------------------------------
192+ 
193+WEIGHT_ONLY_CFG = {
194+ 'batch_num': 1,
195+ 'quant_cfg': {
196+ 'weights': {'type': 'int8', 'symmetric': True, 'strategy': 'channel'}
197+ },
198+ 'algorithm': {'minmax'},
199+}
200+ 
201+ACTIVATION_CFG = {
202+ 'batch_num': 2,
203+ 'quant_cfg': {
204+ 'weights': {'type': 'int8', 'symmetric': True, 'strategy': 'channel'},
205+ 'inputs': {'type': 'int8', 'symmetric': True, 'strategy': 'tensor'},
206+ },
207+ 'algorithm': {'minmax'},
208+}
209+ 
210+ 
211+class _TinyModel(nn.Module):
212+ def __init__(self):
213+ super().__init__()
214+ self.fc1 = nn.Linear(64, 32)
215+ self.fc2 = nn.Linear(32, 16)
216+ 
217+ def forward(self, inputs):
218+ return self.fc2(self.fc1(inputs))
219+ 
220+ 
221+def _tiny_model():
222+ # int8 minmax only accepts bf16/fp16 source weights.
223+ return _TinyModel().to(torch.bfloat16)
224+ 
225+ 
226+def _tiny_inputs(batch=8):
227+ return torch.randn(batch, 64).to(torch.bfloat16)
228+ 
229+ 
230+def test_quantize_without_result_dir_creates_nothing(tmp_path):
231+ model = _tiny_model()
232+ before = sorted(p.name for p in tmp_path.iterdir())
233+ quantize(model, WEIGHT_ONLY_CFG)
234+ assert sorted(p.name for p in tmp_path.iterdir()) == before
235+ assert not hasattr(model.fc1, 'quant_result_writer')
236+ assert not hasattr(model.fc1, 'quant_params_dumped')
237+ 
238+ 
239+def test_quantize_without_result_dir_keeps_pass_signature():
240+ model = nn.Linear(4, 4)
241+ with (
242+ patch.object(QUANTIZE_MODULE, "parse_config", return_value={"layer": "cfg"}),
243+ patch.object(QUANTIZE_MODULE, "ModelOptimizer"),
244+ patch.object(QUANTIZE_MODULE, "InsertQuantizeModulePass") as mock_pass_cls,
245+ patch.object(QUANTIZE_MODULE, "QuantResultWriter") as mock_writer_cls,
246+ ):
247+ quantize(model, {"granularity": "tensor"})
248+ 
249+ mock_pass_cls.assert_called_once_with({"layer": "cfg"}, quant_result_writer=None)
250+ mock_writer_cls.create.assert_not_called()
251+ 
252+ 
253+@pytest.mark.parametrize("bad_dir", [1, 1.5, b"x", object()])
254+def test_quantize_rejects_non_str_result_dir(bad_dir):
255+ with pytest.raises(TypeError):
256+ quantize(_tiny_model(), WEIGHT_ONLY_CFG, quant_result_dir=bad_dir)
257+ 
258+ 
259+def test_quantize_rejects_empty_result_dir():
260+ with pytest.raises(ValueError, match="non-empty path"):
261+ quantize(_tiny_model(), WEIGHT_ONLY_CFG, quant_result_dir="")
262+ 
263+ 
264+def test_quantize_rejects_missing_result_dir(tmp_path):
265+ missing = tmp_path / "missing"
266+ with pytest.raises(ValueError, match="must be an existing directory"):
267+ quantize(_tiny_model(), WEIGHT_ONLY_CFG, quant_result_dir=str(missing))
268+ 
269+ 
270+def test_quantize_preserves_unmanaged_files_in_result_dir(tmp_path):
271+ existing = tmp_path / "with_other_files"
272+ existing.mkdir()
273+ old_file = existing / "old.safetensors"
274+ old_file.write_bytes(b"old")
275+ quantize(_tiny_model(), WEIGHT_ONLY_CFG, quant_result_dir=str(existing))
276+ assert (existing / MANIFEST_NAME).is_file()
277+ assert old_file.read_bytes() == b"old"
278+ 
279+ 
280+def test_quantize_rejects_result_dir_that_is_a_file(tmp_path):
281+ path = tmp_path / "a_file"
282+ path.write_text("x", encoding="utf-8")
283+ with pytest.raises(ValueError, match="not a file"):
284+ quantize(_tiny_model(), WEIGHT_ONLY_CFG, quant_result_dir=str(path))
285+ 
286+ 
287+def test_quantize_rejects_result_dir_when_no_layer_is_quantizable(tmp_path):
288+ out = tmp_path / "out"
289+ out.mkdir()
290+ # float32 weights are not accepted by int8 minmax, so nothing matches.
291+ with pytest.raises(ValueError, match="no quantizable layer"):
292+ quantize(_TinyModel(), WEIGHT_ONLY_CFG, quant_result_dir=str(out))
293+ assert list(out.iterdir()) == []
294+ 
295+ 
296+def test_quantize_rejects_result_dir_when_the_root_is_the_quant_layer(tmp_path):
297+ out = tmp_path / "out"
298+ out.mkdir()
299+ # parse_config names the root layer '', and module replacement is keyed by
300+ # name, so the root is never actually replaced: its unit could never be
301+ # dumped and the manifest would stay unpublished forever.
302+ root_linear = nn.Linear(64, 32).to(torch.bfloat16)
303+ with pytest.raises(ValueError, match="itself a quantizable layer"):
304+ quantize(root_linear, WEIGHT_ONLY_CFG, quant_result_dir=str(out))
305+ assert list(out.iterdir()) == []
306+ 
307+ 
308+def test_quantize_rejects_root_quant_layer_without_result_dir():
309+ root_linear = nn.Linear(64, 32).to(torch.bfloat16)
310+ with pytest.raises(ValueError, match="itself a quantizable layer"):
311+ quantize(root_linear, WEIGHT_ONLY_CFG)
312+ 
313+ 
314+def test_weight_only_publishes_without_any_forward(tmp_path):
315+ out = tmp_path / "wo"
316+ out.mkdir()
317+ model = _tiny_model()
318+ quantize(model, WEIGHT_ONLY_CFG, quant_result_dir=str(out))
319+ 
320+ assert sorted(p.name for p in out.iterdir()) == sorted(
321+ [MANIFEST_NAME, "fc1.safetensors", "fc2.safetensors"]
322+ )
323+ manifest = load_quant_manifest(str(out))
324+ validate_result_directory(str(out), manifest)
325+ assert manifest["format"] == MANIFEST_FORMAT
326+ assert manifest["schema_version"] == SCHEMA_VERSION
327+ assert [unit["layer_name"] for unit in manifest["units"]] == ["fc1", "fc2"]
328+ assert all(u["quant_module_type"] == "MinMaxQuant" for u in manifest["units"])
329+ assert all(u["ori_module_type"] == "Linear" for u in manifest["units"])
330+ # The algorithm is recorded inside the full layer-level quant_config.
331+ assert all(
332+ u["quant_config"]["algorithm"] == {"minmax": {}} for u in manifest["units"]
333+ )
334+ assert all("batch_num" in u["quant_config"] for u in manifest["units"])
335+ 
336+ tensors, _, _ = read_quant_unit(str(out), "fc1")
337+ assert torch.equal(tensors["scale_w"], model.fc1.scale_w.cpu())
338+ 
339+ 
340+def test_activation_publishes_only_after_the_last_calibration_batch(tmp_path):
341+ out = tmp_path / "act"
342+ out.mkdir()
343+ model = _tiny_model()
344+ quantize(model, ACTIVATION_CFG, quant_result_dir=str(out))
345+ manifest_path = out / MANIFEST_NAME
346+ 
347+ assert list(out.iterdir()) == [], "nothing is final until calibration runs"
348+ 
349+ inputs = _tiny_inputs()
350+ model(inputs)
351+ assert not manifest_path.exists(), "batch_num-1 forwards must not publish"
352+ 
353+ model(inputs)
354+ assert manifest_path.exists()
355+ manifest = load_quant_manifest(str(out))
356+ validate_result_directory(str(out), manifest)
357+ assert len(manifest["units"]) == 2
358+ 
359+ tensors, _, _ = read_quant_unit(str(out), "fc2")
360+ assert sorted(tensors) == ["scale_d", "scale_w"]
361+ assert torch.equal(tensors["scale_d"], model.fc2.scale_d.cpu())
362+ 
363+ 
364+def test_publish_hook_is_removed_after_publishing(tmp_path):
365+ out = tmp_path / "hook"
366+ out.mkdir()
367+ model = _tiny_model()
368+ quantize(model, ACTIVATION_CFG, quant_result_dir=str(out))
369+ assert model._forward_hooks, "a publish hook must be armed while incomplete"
370+ 
371+ inputs = _tiny_inputs()
372+ model(inputs)
373+ model(inputs)
374+ assert not model._forward_hooks, "hook must detach once published"
375+ # Further inference must neither fail nor touch the published artifact.
376+ digest_before = (out / MANIFEST_NAME).read_bytes()
377+ model(inputs)
378+ assert (out / MANIFEST_NAME).read_bytes() == digest_before
379+ 
380+ 
381+def test_failed_calibration_forward_leaves_no_manifest(tmp_path):
382+ out = tmp_path / "failed"
383+ out.mkdir()
384+ model = _tiny_model()
385+ quantize(model, ACTIVATION_CFG, quant_result_dir=str(out))
386+ with pytest.raises(RuntimeError):
387+ model(torch.randn(8, 999).to(torch.bfloat16)) # wrong in_features
388+ assert not (out / MANIFEST_NAME).exists()
389+ 
390+ 
391+def test_one_failed_unit_write_leaves_no_manifest(tmp_path):
392+ out = tmp_path / "partial"
393+ out.mkdir()
394+ model = _tiny_model()
395+ real_save = QUANT_RESULT_MODULE.save_file
396+ calls = {"n": 0}
397+ 
398+ def fail_on_second(*args, **kwargs):
399+ calls["n"] += 1
400+ if calls["n"] == 2:
401+ raise OSError("disk full")
402+ return real_save(*args, **kwargs)
403+ 
404+ with patch.object(QUANT_RESULT_MODULE, "save_file", fail_on_second):
405+ with pytest.raises(OSError):
406+ quantize(model, WEIGHT_ONLY_CFG, quant_result_dir=str(out))
407+ 
408+ names = sorted(p.name for p in out.iterdir())
409+ assert MANIFEST_NAME not in names, "an incomplete run must not publish"
410+ assert names == ["fc1.safetensors"], "no temp file may survive the failure"
411+ with pytest.raises(FileNotFoundError, match="not a valid quant result"):
412+ load_quant_manifest(str(out))
413+ 
414+ 
415+def test_quantize_records_source_model_fingerprint(tmp_path):
416+ out = tmp_path / "fp"
417+ out.mkdir()
418+ model = _tiny_model()
419+ expected = compute_model_fingerprint(_tiny_model_like(model))
420+ quantize(model, WEIGHT_ONLY_CFG, quant_result_dir=str(out))
421+ manifest = load_quant_manifest(str(out))
422+ # Fingerprint is taken before MinMaxQuant replaces the Linear modules, so
423+ # it describes the source model, not the rewritten one.
424+ assert manifest["source_model"] == expected
425+ assert manifest["source_model"]["model_type"] == "_TinyModel"
426+ 
427+ 
428+def _tiny_model_like(model):
429+ """A pristine copy of the pre-quantize model, for fingerprint comparison."""
430+ clone = _tiny_model()
431+ clone.load_state_dict(
432+ {
433+ "fc1.weight": model.fc1.weight.detach().clone(),
434+ "fc1.bias": model.fc1.bias.detach().clone(),
435+ "fc2.weight": model.fc2.weight.detach().clone(),
436+ "fc2.bias": model.fc2.bias.detach().clone(),
437+ }
438+ )
439+ return clone
440+ 
441+ 
442+AWQ_CFG = {
443+ 'batch_num': 1,
444+ 'quant_cfg': {
445+ 'weights': {
446+ 'type': 'int4',
447+ 'symmetric': True,
448+ 'strategy': 'channel',
449+ }
450+ },
451+ 'algorithm': {'awq': {'grids_num': 20}},
452+}
453+ 
454+ 
455+SMOOTHQUANT_CFG = {
456+ 'batch_num': 1,
457+ 'quant_cfg': {
458+ 'weights': {'type': 'int8', 'symmetric': True, 'strategy': 'channel'},
459+ 'inputs': {'type': 'int8', 'symmetric': True, 'strategy': 'tensor'},
460+ },
461+ 'algorithm': {'smoothquant': {'smooth_strength': 0.5}},
462+}
463+ 
464+ 
465+class _LinearShapedModel(nn.Module):
466+ def __init__(self):
467+ super().__init__()
468+ self.fc1 = nn.Linear(64, 64, bias=False)
469+ 
470+ def forward(self, inputs):
471+ return self.fc1(inputs)
472+ 
473+ 
474+def test_quantize_rejects_an_algorithm_without_export_support(tmp_path):
475+ """An algorithm that cannot dump must fail before writing any result.
476+ 
477+ Regression guard: every quantize module inherits BaseQuantizeModule, so a
478+ mere isinstance or hasattr check lets an unwired algorithm through.
479+ quantize() then returned normally, the result directory stayed empty, and the
480+ manifest was pending forever -- an API that reports success while producing
481+ nothing loadable. SmoothQuant stands in for "not wired yet" now that AWQ and
482+ GPTQ are.
483+ """
484+ out = tmp_path / "smoothquant"
485+ out.mkdir()
486+ model = _LinearShapedModel().to(torch.bfloat16)
487+ with pytest.raises(ValueError, match="not supported yet for algorithm") as ctx:
488+ quantize(model, SMOOTHQUANT_CFG, quant_result_dir=str(out))
489+ 
490+ assert "_supports_quant_result_export" not in str(ctx.value)
491+ assert list(out.iterdir()) == [], "a rejected algorithm must write nothing"
492+ assert type(model.fc1).__name__ == "Linear", "the model must be left untouched"
493+ 
494+ 
495+def test_the_same_unsupported_algorithm_still_works_without_a_result_dir(tmp_path):
496+ model = _LinearShapedModel().to(torch.bfloat16)
497+ quantize(model, SMOOTHQUANT_CFG)
498+ # The legacy in-memory path is unaffected by the export screening.
499+ assert type(model.fc1).__name__ == "SmoothQuant"
500+ 
501+ 
502+def test_awq_is_now_accepted_for_export(tmp_path):
503+ """AWQ is wired in Week 4: it must publish a real artifact."""
504+ out = tmp_path / "awq_ok"
505+ out.mkdir()
506+ model = _LinearShapedModel().to(torch.bfloat16)
507+ quantize(model, AWQ_CFG, quant_result_dir=str(out))
508+ model(torch.randn(8, 64).to(torch.bfloat16))
509+ assert (out / "quant_manifest.json").is_file()
510+ 
511+ 
512+def test_artifact_is_consumable_by_the_reload_contract(tmp_path):
513+ """Walk the spec's reload validation chain and rebuild from the manifest.
514+ 
515+ Week 4 owns ``reload_quant_params()``; this asserts the Week 3 artifact
516+ already carries everything that chain needs, so the schema cannot drift out
517+ from under it. The rebuild uses only manifest fields -- no knowledge of how
518+ the artifact was produced.
519+ """
520+ out = tmp_path / "consume"
521+ out.mkdir()
522+ model = _tiny_model()
523+ reference = _tiny_model_like(model)
524+ quantize(model, ACTIVATION_CFG, quant_result_dir=str(out))
525+ inputs = _tiny_inputs()
526+ model(inputs)
527+ model(inputs)
528+ expected = model(inputs)
529+ 
530+ # 1. Manifest format, version, required fields, uniqueness, safe paths.
531+ manifest = load_quant_manifest(str(out))
532+ # 2. The directory holds the manifest and every unit it manages.
533+ validate_result_directory(str(out), manifest)
534+ # 3. The source model identity matches the untouched float model.
535+ assert manifest["source_model"] == compute_model_fingerprint(reference)
536+ 
537+ helper = ModuleHelper(reference)
538+ for entry in manifest["units"]:
539+ layer_name = entry["layer_name"]
540+ assert layer_name in helper.named_module_dict
541+ # 4-6. Size, digest, header and tensor schema, all checked by the reader.
542+ tensors, non_tensor_params, _ = read_quant_unit(str(out), layer_name, manifest)
543+ 
544+ # A Week 4 loader resolves the class from quant_module_type; MinMaxQuant
545+ # is the only registered name here, so assert the recorded value and use
546+ # it rather than hardcoding the class at the call site.
547+ assert entry["quant_module_type"] == "MinMaxQuant"
548+ assert entry["ori_module_type"] == "Linear"
549+ ori_module = helper.named_module_dict[layer_name]
550+ rebuilt = MinMaxQuant(ori_module, layer_name, entry["quant_config"])
551+ rebuilt.load_quant_params(tensors, non_tensor_params)
552+ ModuleHelper.replace_module_by_name(reference, layer_name, rebuilt)
553+ 
554+ # The rebuilt model fake-quants identically, without recalibrating.
555+ assert torch.allclose(reference(inputs), expected)
556+ 
557+ 
558+def _npu_available():
559+ """Return whether torch exposes a usable, non-mocked NPU backend."""
560+ if not hasattr(torch, "npu"):
561+ return False
562+ try:
563+ if torch.npu.is_available() is not True:
564+ return False
565+ torch.zeros(1).npu()
566+ except Exception:
567+ return False
568+ return True
569+ 
570+ 
571+def test_npu_available_rejects_mocked_backend():
572+ with patch.object(torch, "npu", MagicMock(), create=True):
573+ assert not _npu_available()
574+ 
575+ 
576+@pytest.mark.npu
577+def test_npu_round_trip_restores_params_to_the_device(tmp_path):
578+ """On real NPU hardware, units persist as CPU tensors and reload to npu.
579+ 
580+ Units are always stored CPU-side (device is not part of the artifact), so
581+ this is the check that ``_quant_param_device()`` puts them back on the
582+ module's device rather than leaving a CPU tensor in an NPU module.
583+ """
584+ if not _npu_available():
585+ pytest.skip("no Ascend NPU available")
586+ 
587+ out = tmp_path / "npu"
588+ out.mkdir()
589+ model = _tiny_model().npu()
590+ reference = _tiny_model_like(model).npu()
591+ quantize(model, ACTIVATION_CFG, quant_result_dir=str(out))
592+ 
593+ inputs = _tiny_inputs().npu()
594+ model(inputs)
595+ model(inputs)
596+ expected = model(inputs)
597+ 
598+ manifest = load_quant_manifest(str(out))
599+ validate_result_directory(str(out), manifest)
600+ helper = ModuleHelper(reference)
601+ for entry in manifest["units"]:
602+ layer_name = entry["layer_name"]
603+ tensors, non_tensor_params, _ = read_quant_unit(str(out), layer_name, manifest)
604+ assert all(t.device.type == "cpu" for t in tensors.values())
605+ rebuilt = MinMaxQuant(
606+ helper.named_module_dict[layer_name], layer_name, entry["quant_config"]
607+ )
608+ rebuilt.load_quant_params(tensors, non_tensor_params)
609+ assert rebuilt.scale_w.device == rebuilt.weight.device
610+ assert rebuilt.scale_d.device == rebuilt.weight.device
611+ ModuleHelper.replace_module_by_name(reference, layer_name, rebuilt)
612+ 
613+ assert torch.equal(reference(inputs), expected)
614+ 
615+ 
616+def test_pass_attaches_the_writer_without_exposing_capability_flags():
617+ class _RecordingQuantOp(nn.Module):
618+ def __init__(self, ori_module, layer_name, quant_config):
619+ super().__init__()
620+ self.ori_module = ori_module
621+ self.attached_writer = None
622+ 
623+ def attach_quant_result_writer(self, writer):
624+ self.attached_writer = writer
625+ 
626+ def forward(self, inputs):
627+ return self.ori_module(inputs)
628+ 
629+ model = _tiny_model()
630+ writer = MagicMock()
631+ quant_pass = InsertQuantizeModulePass(
632+ {"fc1": {"algorithm": {"minmax": {}}}}, quant_result_writer=writer
633+ )
634+ quant_pass.quantize_ops["fc1"] = _RecordingQuantOp
635+ quant_pass.do_pass(model, model.fc1, "fc1")
636+ assert model.fc1.attached_writer is writer
@@ -137,12 +137,37 @@ class _KvDeployTestMixin(unittest.TestCase):
137 self.assertIs(cache.update, original_update)137 self.assertIs(cache.update, original_update)
138 138 
139 139 
140-class TestDeepseekV3AttentionDeploy(_KvDeployTestMixin):140+class _FakeNpuImportMixin(_KvDeployTestMixin):
141+ """Installs the stub only for the import, then puts sys.modules back.
142+ 
143+ The stub has to exist while the deploy module is imported, but it must not
144+ outlive this class: it lacks most of the real torch_npu surface, so leaking it
145+ breaks any later test whose code path reaches torch_npu (GPTQ's
146+ ``torch_npu.npu.is_available()``, for one).
147+ """
148+ 
149+ __test__ = False
150+ 
151+ @classmethod
152+ def _install_stub_for_import(cls):
153+ cls._saved_module = sys.modules.get('torch_npu')
154+ _install_fake_torch_npu()
155+ 
156+ @classmethod
157+ def tearDownClass(cls):
158+ saved = getattr(cls, '_saved_module', None)
159+ if saved is not None:
160+ sys.modules['torch_npu'] = saved
161+ else:
162+ sys.modules.pop('torch_npu', None)
163+ 
164+ 
165+class TestDeepseekV3AttentionDeploy(_FakeNpuImportMixin):
141 __test__ = True166 __test__ = True
142 167 
143 @classmethod168 @classmethod
144 def setUpClass(cls):169 def setUpClass(cls):
145- _install_fake_torch_npu()170+ cls._install_stub_for_import()
146 from amct_pytorch.classic.deploy_op.npu_quantization_deepseekv3_attention import (171 from amct_pytorch.classic.deploy_op.npu_quantization_deepseekv3_attention import (
147 NpuDeepseekV3AttentionQuant,172 NpuDeepseekV3AttentionQuant,
148 )173 )
@@ -150,12 +175,12 @@ class TestDeepseekV3AttentionDeploy(_KvDeployTestMixin):
150 cls.module_cls = NpuDeepseekV3AttentionQuant175 cls.module_cls = NpuDeepseekV3AttentionQuant
151 176 
152 177 
153-class TestLongcatFlashMLADeploy(_KvDeployTestMixin):178+class TestLongcatFlashMLADeploy(_FakeNpuImportMixin):
154 __test__ = True179 __test__ = True
155 180 
156 @classmethod181 @classmethod
157 def setUpClass(cls):182 def setUpClass(cls):
158- _install_fake_torch_npu()183+ cls._install_stub_for_import()
159 from amct_pytorch.classic.deploy_op.npu_quantization_longcat_flashmla import (184 from amct_pytorch.classic.deploy_op.npu_quantization_longcat_flashmla import (
160 NpuLongcatFlashMLA,185 NpuLongcatFlashMLA,
161 )186 )