Skip to content

Commit 2c7b82b

Browse files
committed
Merge branch 'lab/issue-64-takane-vision' into 'export/v1-1-1'
See merge request onecomp/onecomp-lab!69
2 parents 7eaa488 + be81c87 commit 2c7b82b

6 files changed

Lines changed: 1049 additions & 17 deletions

File tree

CHANGELOG.md

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,29 @@
88
- Added `report_progress: bool = True` flag to `Runner.__init__` (`onecomp/runner.py`) and to the underlying entry points `run_chunked_quantization` (`onecomp/runner_methods/chunked_quantization.py`), `run_multi_gpu_quantization` / `run_quantization_phase` (`onecomp/runner_methods/multi_gpu_quantization.py`), `run_quantize_with_qep` (`onecomp/qep/_quantize_with_qep.py`), and `run_quantize_with_qep_arch` (`onecomp/qep/_quantize_with_qep_arch.py`) so long quantization runs (calibration, chunked, multi-GPU, QEP) report progress by default; pass `report_progress=False` for quiet runs
99
- Demoted some INFO-level per-layer / per-chunk logs to DEBUG to avoid duplication with the new `[progress]` line (still available via `logging.basicConfig(level=logging.DEBUG)` for deep debugging)
1010

11-
### Bug Fixes
11+
### Bug fixes: VLM save / load
1212

13-
- Raise a clear error when ``Runner`` is configured with ``qep=True`` and a quantizer that does not support QEP (currently `JointQ`). Previously the run failed deep inside `quantize_with_qep` / `adjust_weight` with a confusing low-level error. `Runner.check()` now reports e.g. "Quantizer 'JointQ' (or one of its candidate quantizers) does not support QEP (Quantization Error Propagation). Set qep=False, or use a QEP-compatible quantizer (e.g., GPTQ, DBF, AutoBitQuantizer with QEP-compatible candidates)." Implementation: added `flag_qep_supported` (default `True`) on `Quantizer`, set to `False` on `JointQ`, and propagated via `AutoBitQuantizer._sync_flags` (only `True` when *all* candidate quantizers support QEP).
13+
- `Runner.save_quantized_model()` now copies all auxiliary `*.json` and `*.jinja` files (e.g. `preprocessor_config.json`, `processor_config.json`, `special_tokens_map.json`, `chat_template.jinja`) from the original model directory to the save directory, so the quantized model is fully self-contained for VLM / multimodal inference. Weight tensors (`*.safetensors`, `*.bin`, `*.pt`, `*.pth`), weight index files, `config.json` and `generation_config.json` are skipped, and any file already written by `model.save_pretrained` / `tokenizer.save_pretrained` is preserved (`runner.py`).
14+
- Source-model directory resolution (incl. `huggingface_hub.snapshot_download` fallback for Hub IDs) was extracted into a private helper `Runner._resolve_source_model_dir()` (`runner.py`).
15+
- `load_quantized_model()` now re-establishes the `lm_head` <-> `embed_tokens` weight tie for models with `tie_word_embeddings=True`. `load_state_dict(..., assign=True)` would otherwise leave `lm_head.weight` as the freshly initialised tensor (typically `float16`) while `embed_tokens.weight` got replaced with the checkpoint tensor (typically `bfloat16`), causing `RuntimeError: expected mat1 and mat2 to have the same dtype` at the final `lm_head` matmul during generation. The re-tie is gated on `lm_head` still being an `nn.Linear` so it does not interfere when `lm_head` itself was quantized (`quantized_model_loader.py`).
16+
- `load_quantized_model()` now reads `torch_dtype` from `config.json` when no explicit `torch_dtype` is passed by the caller, so the empty model is built in the same dtype as the saved checkpoint. Previously it always defaulted to `torch.float16`, which left non-quantized VLM submodules (e.g. `multi_modal_projector` in Cohere2Vision) at fp16 whenever `load_state_dict(..., assign=True)` could not find their key in the state_dict (`quantized_model_loader.py`).
17+
- `load_quantized_model()` now casts any leftover `float16` parameters and buffers of non-quantized modules to `model.config.torch_dtype` after the `lm_head` re-tie step. Quantized layers (`GPTQLinear`, `DoubleBinaryLinear`) and `float32` params (e.g. fp32 LayerNorm in mixed-precision models) are deliberately untouched. This generalises the existing `lm_head` re-tie to any non-quantized module and fixes the dtype mismatch reported in issue 64-3 (`RuntimeError: ... c10::Half != c10::BFloat16` on VLM image features) (`quantized_model_loader.py`).
18+
19+
### Bug fixes: QEP + JointQ validation
20+
21+
- Raise a clear error when `Runner` is configured with `qep=True` and a quantizer that does not support QEP (currently `JointQ`). Previously the run failed deep inside `quantize_with_qep` / `adjust_weight` with a confusing low-level error. `Runner.check()` now reports e.g. "Quantizer 'JointQ' (or one of its candidate quantizers) does not support QEP (Quantization Error Propagation). Set qep=False, or use a QEP-compatible quantizer (e.g., GPTQ, DBF, AutoBitQuantizer with QEP-compatible candidates)." Implementation: added `flag_qep_supported` (default `True`) on `Quantizer`, set to `False` on `JointQ`, and propagated via `AutoBitQuantizer._sync_flags` (only `True` when *all* candidate quantizers support QEP) (`quantizer/_quantizer.py`, `quantizer/jointq/_jointq.py`, `quantizer/autobit/_autobit.py`, `runner.py`).
22+
23+
### Logging / observability tweaks
24+
25+
- `Runner._copy_auxiliary_files()` now emits a matter-of-fact `INFO`-level log when an auxiliary file from the original model directory is *not* copied because the destination already contains a file of the same name (typically because `tokenizer.save_pretrained` wrote it just before, or a previous `save_quantized_model` call did). The new line is symmetrical to the existing `Copied %s to save directory` entry so the auxiliary-copy step can be audited end-to-end (`runner.py`).
26+
- `QuantizedModelLoader._cast_fp16_to_target_dtype()` now returns the list of fully-qualified parameter / buffer names whose dtype was actually converted instead of a plain count. The post-load `INFO` log in `load_quantized_model()` includes those names so it is obvious which non-quantized submodules were normalised by the safety-net cast (e.g. `multi_modal_projector.linear_*` in Cohere2Vision). Existing tests are updated accordingly and a new test pins the buffer-name reporting (`quantized_model_loader.py`, `tests/onecomp/runner/test_load_excluded_module_dtype.py`, `tests/onecomp/runner/test_save_quantized_aux_files.py`).
27+
- `QuantizedModelLoader.load_quantized_model()` now detects `tie_word_embeddings=True` even when the flag is nested in a sub-config (e.g. `model.config.text_config.tie_word_embeddings` in Llama 3.2-Vision and other torchtune-derived VLMs) by walking one level of sub-configs. Previously the flag was only read from the top-level `model.config`, so VLMs that placed it in `text_config` skipped the post-load re-tie; with HF deduplicating `lm_head.weight` for tied checkpoints, that left `lm_head.weight` at the empty-model random initial values rather than re-pointing to `embed_tokens.weight` (`quantized_model_loader.py`).
1428

1529
### Tests
1630

17-
- Added `tests/onecomp/test_runner_check.py` covering the new `qep=True` validation path: JointQ + qep=True raises a clear `ValueError`, while JointQ + qep=False and GPTQ + qep=True both pass `Runner.check()`.
31+
- Added regression tests for the save/load fixes above: `tests/onecomp/runner/test_save_quantized_aux_files.py` (auxiliary-file copy whitelist), `tests/onecomp/runner/test_load_tied_embeddings.py` (tied-embedding dtype round-trip), and `tests/onecomp/runner/test_load_excluded_module_dtype.py` (non-quantized module dtype handling, including config-based empty-model dtype default, fp16 safety-net cast, fp32 preservation, and quantized-layer skip).
32+
- Added `tests/onecomp/test_runner_check.py` for the new `qep=True` validation path: JointQ + qep=True raises a clear `ValueError`, while JointQ + qep=False and GPTQ + qep=True both pass `Runner.check()`.
33+
- Added `tests/onecomp/runner/test_load_tied_embeddings.py::test_should_retie_word_embeddings_*` unit tests covering top-level, nested-text-config, all-False and unrelated-sub-attribute shapes.
1834

1935
### New Contributors
2036

onecomp/quantized_model_loader.py

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,53 @@ def load_quantized_model(
8686
# Load all weights (quantized + non-quantized) in one go
8787
model.load_state_dict(state_dict, strict=False, assign=True)
8888

89+
# ``assign=True`` swaps Parameter objects in place, which breaks the
90+
# weight sharing established by ``from_config`` for models with
91+
# ``tie_word_embeddings=True``. Concretely, ``embed_tokens.weight``
92+
# gets replaced by the bf16 tensor from the checkpoint while
93+
# ``lm_head.weight`` keeps its original (often fp16) tensor, leading
94+
# to a dtype mismatch at the final ``F.linear`` call during
95+
# generation. Re-tie when (a) the config tree still asks for it
96+
# (multi-config VLMs such as Llama 3.2-Vision place the flag in
97+
# ``text_config`` rather than at the top level, so we walk the
98+
# nested configs) and (b) ``lm_head`` is still a plain
99+
# ``nn.Linear`` -- if it has been replaced by a quantized layer
100+
# (e.g. ``GPTQLinear``) it has no ``weight`` attribute to retie
101+
# and tying would be meaningless.
102+
if cls._should_retie_word_embeddings(model.config):
103+
lm_head = getattr(model, "lm_head", None)
104+
if isinstance(lm_head, torch.nn.Linear):
105+
model.tie_weights()
106+
logger.info("Re-tied lm_head to embed_tokens after assign-load")
107+
108+
# Safety net: ``load_state_dict(..., assign=True)`` only replaces
109+
# parameters whose key in the checkpoint exactly matches the model's
110+
# ``named_parameters`` path. For some VLMs (e.g. Cohere2Vision's
111+
# ``multi_modal_projector``) the path prefix differs between the
112+
# checkpoint and the model class produced by ``from_config``, so a
113+
# subset of params silently keeps the empty-model dtype. Together
114+
# with the config-based dtype default in
115+
# ``_build_empty_model_from_config`` this normalises any remaining
116+
# fp16 tensors of non-quantized modules to ``target_dtype``. fp32
117+
# params (e.g. fp32 LayerNorm in mixed-precision models) are left
118+
# untouched, and quantized layers are skipped so that GPTQ scales
119+
# and similar fp16 metadata are preserved.
120+
target_dtype = (
121+
torch_dtype
122+
if torch_dtype is not None
123+
else cls._resolve_dtype_from_config(config_dict)
124+
)
125+
if target_dtype is None:
126+
target_dtype = torch.float16
127+
converted = cls._cast_fp16_to_target_dtype(model, target_dtype)
128+
if converted:
129+
logger.info(
130+
"Cast %d non-quantized fp16 tensor(s) to %s: %s",
131+
len(converted),
132+
target_dtype,
133+
converted,
134+
)
135+
89136
# Register Hadamard hooks for rotation-preprocessed models
90137
if quant_config.get("rotated", False):
91138
from .pre_process.rotation_utils import register_online_hadamard_hooks
@@ -220,7 +267,119 @@ def _load_config_and_quant_config(save_directory: str) -> Tuple[Dict, Dict]:
220267
return config_dict, quant_config
221268

222269
@staticmethod
270+
def _cast_fp16_to_target_dtype(
271+
model: torch.nn.Module, target_dtype: torch.dtype
272+
) -> List[str]:
273+
"""Cast fp16 params/buffers of non-quantized modules to ``target_dtype``.
274+
275+
Quantized layers (``GPTQLinear``, ``DoubleBinaryLinear``) are
276+
skipped so their fp16 metadata (e.g. GPTQ ``scales``) is preserved.
277+
Only fp16 tensors are cast: fp32 params (e.g. fp32 LayerNorm in
278+
mixed-precision models) and other dtypes are left untouched.
279+
280+
Args:
281+
model: The model whose parameters/buffers should be normalised.
282+
target_dtype: Destination dtype. When equal to
283+
``torch.float16`` this is a no-op.
284+
285+
Returns:
286+
Fully-qualified names of every parameter / buffer whose
287+
dtype was actually converted (e.g. ``"model.layers.0.mlp.
288+
down_proj.weight"``). An empty list means nothing needed
289+
casting (or ``target_dtype == torch.float16``). The list
290+
form makes it easy for tests and operators to inspect
291+
which submodules were touched by the safety net.
292+
"""
293+
converted: List[str] = []
294+
if target_dtype == torch.float16:
295+
return converted
296+
skip_types = (GPTQLinear, DoubleBinaryLinear)
297+
for mod_name, mod in model.named_modules():
298+
if isinstance(mod, skip_types):
299+
continue
300+
for p_name, p in mod.named_parameters(recurse=False):
301+
if p.dtype == torch.float16:
302+
p.data = p.data.to(target_dtype)
303+
full_name = f"{mod_name}.{p_name}" if mod_name else p_name
304+
converted.append(full_name)
305+
for b_name, b in mod.named_buffers(recurse=False):
306+
if b.dtype == torch.float16:
307+
b.data = b.data.to(target_dtype)
308+
full_name = f"{mod_name}.{b_name}" if mod_name else b_name
309+
converted.append(full_name)
310+
return converted
311+
312+
@classmethod
313+
def _should_retie_word_embeddings(cls, config: Any) -> bool:
314+
"""Return True if any nesting level of ``config`` requests weight tying.
315+
316+
Single-config language models (e.g. Llama, Qwen) expose
317+
``tie_word_embeddings`` directly on ``model.config``. Multi-
318+
config VLMs vary: ``gemma-4`` puts the flag at the top level
319+
but ``llama3.2-vlm-torchtune`` and other torchtune-derived
320+
checkpoints place it inside ``text_config`` only, so the naive
321+
``getattr(model.config, "tie_word_embeddings", False)`` would
322+
miss the tying request and skip the re-tie that
323+
``load_state_dict(..., assign=True)`` necessitates.
324+
325+
We walk the config tree shallowly: any direct sub-attribute
326+
that itself exposes ``tie_word_embeddings`` is inspected. The
327+
check is intentionally non-recursive past one level because
328+
HuggingFace nests language sub-configs at most one level deep
329+
in practice (``text_config``, ``language_config`` etc.) and a
330+
deeper recursion would risk being confused by unrelated
331+
sub-objects.
332+
333+
Args:
334+
config: A ``transformers.PretrainedConfig``-like object
335+
(anything supporting ``getattr``).
336+
337+
Returns:
338+
``True`` if ``tie_word_embeddings`` is truthy at the top
339+
level or on any direct sub-attribute that itself looks
340+
like a config (i.e. carries a ``tie_word_embeddings``
341+
attribute). ``False`` otherwise.
342+
"""
343+
if getattr(config, "tie_word_embeddings", False):
344+
return True
345+
try:
346+
sub_items = vars(config).items()
347+
except TypeError:
348+
return False
349+
for _, value in sub_items:
350+
# Duck-type check: only descend into things that themselves
351+
# carry the flag, so we don't accidentally walk unrelated
352+
# auxiliary objects (e.g. tokenizer caches) that happen to
353+
# be stored on the config.
354+
if hasattr(value, "tie_word_embeddings") and getattr(
355+
value, "tie_word_embeddings", False
356+
):
357+
return True
358+
return False
359+
360+
@staticmethod
361+
def _resolve_dtype_from_config(
362+
config_dict: Dict,
363+
) -> Optional[torch.dtype]:
364+
"""Read ``torch_dtype`` / ``dtype`` from a config dict.
365+
366+
Accepts both the JSON-serialised string form (e.g. ``"bfloat16"``)
367+
and a real ``torch.dtype`` value. Returns ``None`` when the field
368+
is missing, ``"auto"``, or otherwise unresolvable.
369+
"""
370+
for key in ("torch_dtype", "dtype"):
371+
val = config_dict.get(key)
372+
if isinstance(val, torch.dtype):
373+
return val
374+
if isinstance(val, str) and val and val != "auto":
375+
resolved = getattr(torch, val, None)
376+
if isinstance(resolved, torch.dtype):
377+
return resolved
378+
return None
379+
380+
@classmethod
223381
def _build_empty_model_from_config(
382+
cls,
224383
config_dict: Dict,
225384
torch_dtype: Optional[torch.dtype] = None,
226385
) -> torch.nn.Module:
@@ -238,6 +397,13 @@ def _build_empty_model_from_config(
238397
f"Cannot build config: model_type={model_type!r} not in CONFIG_MAPPING."
239398
)
240399

400+
# Default to the dtype recorded in config.json so the empty model
401+
# starts in the same dtype as the saved checkpoint. This avoids
402+
# leaving non-quantized submodules at the hard-coded fp16 default
403+
# if ``load_state_dict(..., assign=True)`` cannot find their key
404+
# in the state_dict (e.g. tied or path-shifted VLM submodules).
405+
if torch_dtype is None:
406+
torch_dtype = cls._resolve_dtype_from_config(clean_config)
241407
dtype = torch_dtype if torch_dtype is not None else torch.float16
242408
config_cls = CONFIG_MAPPING[model_type]
243409
model_config = config_cls.from_dict(clean_config)

0 commit comments

Comments
 (0)