Skip to content

Commit da41b49

Browse files
authored
Merge pull request #20 from FujitsuResearch/develop/v1-1-1
Develop/v1-1-1
2 parents 6526c0f + 002e217 commit da41b49

47 files changed

Lines changed: 2034 additions & 379 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ models
1111
.cursor/rules/test-workflow.mdc
1212
.cursor/rules/gitlab-integration.mdc
1313
.cursor/rules/slurm-submit.mdc
14+
.cursor/rules/run-tests-examples.mdc
1415
.hydra/
1516
*.out
1617
*.err

CHANGELOG.md

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,44 @@
11
# Change log
22

3+
## [v1.1.1] 2026-05-21
4+
5+
### New Feature: Quantization progress logging
6+
7+
- Added `QuantizationProgressTracker` (`onecomp/utils/quantization_progress.py`) that emits a single `[progress]` INFO line per completed step with done/total, percentage, elapsed time, and a linear ETA estimate; supports an optional `thread_safe=True` mode for multi-GPU quantization
8+
- 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
9+
- 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)
10+
11+
### Bug fixes: QEP + JointQ validation
12+
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) (`quantizer/_quantizer.py`, `quantizer/jointq/_jointq.py`, `quantizer/autobit/_autobit.py`, `runner.py`).
14+
15+
### Bug fixes: VLM save / load
16+
17+
- `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`).
18+
- 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`).
19+
- `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`).
20+
- `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`).
21+
- `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`).
22+
- Added regression tests `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).
23+
- Loosened `test_save_load_pipeline_tinyllama.py` and `test_save_load_pipeline_qwen3.py` save/load round-trip threshold from absolute `1e-3` to relative `1%` of the per-tensor logits magnitude (`tests/onecomp/pre_process/test_save_load_pipeline_*.py`). The original absolute bound was below fp16's representable precision once accumulated through the 22-28 decoder layers of TinyLlama / Qwen3, causing the `gptq + save_dequantized` cases to fail on aarch64 + Blackwell (GB200) where cuBLAS picks slightly different reduction kernels than reference x86_64 / Hopper hosts. The save/load equivalence intent is preserved via the relative comparison, which is robust to platform-specific fp16 rounding noise.
24+
- Set `gpu_memory_utilization=0.78` explicitly when constructing `LLM(...)` in `example/vllm_inference/example_autobit_vllm_inference.py` and `example/vllm_inference/example_gptq_vllm_inference.py`. The vLLM default `0.92` cgroup-OOMs on UMA hosts (e.g. DGX Spark / GB200, 121.7 GiB UMA) because vLLM's startup memory check fails: the residual quantizer process leaves only ~106 GiB free, which is below `0.92 * 121.7 = 111.96 GiB`. `0.78` matches the value already used in `tests/vllm_plugins/gptq/test_mixed_gptq_e2e.py` and is documented in the workspace `slurm-submit.mdc` rule.
25+
26+
### Logging / observability tweaks
27+
28+
- `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`).
29+
- `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`).
30+
- `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`).
31+
32+
### Tests
33+
34+
- 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).
35+
- 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()`.
36+
- 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.
37+
38+
### New Contributors
39+
40+
- [@sotanengel](https://github.com/sotanengel) made their first contribution in [#13](https://github.com/FujitsuResearch/OneCompression/pull/13)
41+
342
## [v1.1.0] 2026-04-16
443

544
### Gemma 3 / Gemma 4 & VLM Support

example/.gitignore

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
buf*.py
22
run_*.sh
3-
tinyllama_gptq4_*/
3+
tinyllama_gptq4*/
44
TinyLlama*/
5+
quantized_model_saveload/
6+
rotated_model_*

example/vllm_inference/example_autobit_vllm_inference.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,12 +38,17 @@ def main():
3838
gc.collect()
3939
torch.cuda.empty_cache()
4040

41-
# Step 2: Load the quantized model with vLLM and generate text
41+
# Step 2: Load the quantized model with vLLM and generate text.
42+
# gpu_memory_utilization=0.78 leaves headroom for the residual
43+
# quantizer process (~16 GiB) on a UMA 121.7 GiB device (e.g. DGX
44+
# Spark / GB200). The vLLM default 0.92 cgroup-OOMs on shared-memory
45+
# GPUs.
4246
llm = LLM(
4347
model=save_dir,
4448
max_model_len=512,
4549
dtype="float16",
4650
enforce_eager=True,
51+
gpu_memory_utilization=0.78,
4752
)
4853

4954
prompts = [

example/vllm_inference/example_gptq_vllm_inference.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,12 +67,17 @@ def main():
6767
gc.collect()
6868
torch.cuda.empty_cache()
6969

70-
# Step 3: Load the quantized model with vLLM
70+
# Step 3: Load the quantized model with vLLM.
71+
# gpu_memory_utilization=0.78 leaves headroom for the residual
72+
# quantizer process (~16 GiB) on a UMA 121.7 GiB device (e.g. DGX
73+
# Spark / GB200). The vLLM default 0.92 cgroup-OOMs on shared-memory
74+
# GPUs.
7175
llm = LLM(
7276
model=save_dir,
7377
max_model_len=512,
7478
dtype="float16",
7579
enforce_eager=True,
80+
gpu_memory_utilization=0.78,
7681
)
7782

7883
# Step 4: Generate text

onecomp/__version__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,4 @@
66
77
"""
88

9-
__version__ = "1.1.0"
9+
__version__ = "1.1.1"

onecomp/analyzer/quantization_error.py

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -107,15 +107,21 @@ def _plot_layer_type(ax, layer_type, label_points, colors):
107107
cy = min(y, ylim * 0.92)
108108
text = f"{k} ({x:.1f}, {y:.2f})"
109109
ax.annotate(
110-
text, (cx, cy),
111-
fontsize=6, color=color, fontstyle="italic",
110+
text,
111+
(cx, cy),
112+
fontsize=6,
113+
color=color,
114+
fontstyle="italic",
112115
bbox=dict(boxstyle="round,pad=0.2", fc="white", ec=color, alpha=0.8),
113116
)
114117
else:
115118
ax.annotate(
116-
str(k), (x, y),
117-
textcoords="offset points", xytext=(5, 3),
118-
fontsize=7, color=color,
119+
str(k),
120+
(x, y),
121+
textcoords="offset points",
122+
xytext=(5, 3),
123+
fontsize=7,
124+
color=color,
119125
)
120126

121127
ax.set_xlim(0, xlim)

onecomp/lpcd/_gradient_solver.py

Lines changed: 17 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
Copyright 2025-2026 Fujitsu Ltd.
33
44
"""
5+
56
from abc import ABC, abstractmethod
67

78
import torch
@@ -19,7 +20,7 @@ class PenaltyFn(ABC):
1920
@abstractmethod
2021
def __call__(self, module_q: LpcdMetric, module_f: LpcdMetric) -> torch.Tensor:
2122
pass
22-
23+
2324

2425
def gradient_solver(
2526
lpcd_config: LPCDConfig,
@@ -35,19 +36,21 @@ def gradient_solver(
3536
device = lpcd_config.device
3637
batch_size = 16
3738

38-
assert lpcd_config.gd_batch_size % batch_size == 0, \
39-
f"gd_batch_size should be divisible by batch_size, " + \
40-
f"but got {lpcd_config.gd_batch_size} and {batch_size}"
41-
42-
assert target_modules[0].weight.data.dtype in [torch.float32, torch.bfloat16], \
43-
"The model must be loaded in float32 or bfloat16 " + \
44-
"for gradient-based LPCD refinement due to numerical stability."
45-
39+
assert lpcd_config.gd_batch_size % batch_size == 0, (
40+
f"gd_batch_size should be divisible by batch_size, "
41+
+ f"but got {lpcd_config.gd_batch_size} and {batch_size}"
42+
)
43+
44+
assert target_modules[0].weight.data.dtype in [torch.float32, torch.bfloat16], (
45+
"The model must be loaded in float32 or bfloat16 "
46+
+ "for gradient-based LPCD refinement due to numerical stability."
47+
)
48+
4649
# backup and configure grad state
4750
grad_state_q = [p.requires_grad for p in metric_q.parameters()]
4851
grad_state_f = [p.requires_grad for p in metric_f.parameters()]
4952

50-
# set target modules as trainablethe
53+
# set target modules as trainablethe
5154
metric_q.requires_grad_(False)
5255
metric_f.requires_grad_(False)
5356
for module in target_modules:
@@ -60,13 +63,11 @@ def gradient_solver(
6063

6164
optimizer = optim.Adam(metric_q.parameters(), lr=lpcd_config.gd_base_lr)
6265
scheduler = get_cosine_schedule_with_warmup(
63-
optimizer,
64-
num_warmup_steps=int(total_iters * 0.1),
65-
num_training_steps=total_iters
66+
optimizer, num_warmup_steps=int(total_iters * 0.1), num_training_steps=total_iters
6667
)
6768

6869
# Perform gradient descent to relax the weights
69-
for epoch in range(1, lpcd_config.gd_steps+1):
70+
for epoch in range(1, lpcd_config.gd_steps + 1):
7071

7172
chunked_inps = zip(
7273
inps_q.split(batch_size),
@@ -75,7 +76,7 @@ def gradient_solver(
7576

7677
# TODO: shuffle the inputs for better convergence
7778
for iter, (inp_q, inp_f) in enumerate(chunked_inps):
78-
79+
7980
with torch.no_grad():
8081
out_f = metric_f(inp_f.to(device), **kwargs)
8182

@@ -95,4 +96,4 @@ def gradient_solver(
9596
for p, requires_grad in zip(metric_q.parameters(), grad_state_q):
9697
p.requires_grad_(requires_grad)
9798
for p, requires_grad in zip(metric_f.parameters(), grad_state_f):
98-
p.requires_grad_(requires_grad)
99+
p.requires_grad_(requires_grad)

onecomp/lpcd/_lpcd_config.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,4 +54,3 @@ class LPCDConfig:
5454
gd_batch_size: int = 16
5555
gd_base_lr: float = 1e-4
5656
device: str = "cuda:0"
57-

onecomp/lpcd/_lpcd_runner.py

Lines changed: 16 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ def run_quantize_with_lpcd(
4545
lpcd_config: LPCDConfig,
4646
calibration_config: CalibrationConfig,
4747
):
48-
""" Run quantization with LPCD.
48+
"""Run quantization with LPCD.
4949
5050
Args:
5151
model_config (ModelConfig): Model configuration
@@ -55,8 +55,9 @@ def run_quantize_with_lpcd(
5555
calibration_config (CalibrationConfig): Calibration configuration
5656
"""
5757

58-
assert not (qep_config is not None and qep_config.general), \
59-
"qep_config.general must be False when qep is enabled."
58+
assert not (
59+
qep_config is not None and qep_config.general
60+
), "qep_config.general must be False when qep is enabled."
6061

6162
# TODO: Parameterize when necessary
6263
batch_size = 16
@@ -69,7 +70,7 @@ def run_quantize_with_lpcd(
6970

7071
model_inputs = prepare_calibration_dataset(
7172
tokenizer=tokenizer,
72-
device=torch.device('cpu'),
73+
device=torch.device("cpu"),
7374
calibration_config=calibration_config,
7475
model=model,
7576
logger=logger,
@@ -99,7 +100,7 @@ def run_quantize_with_lpcd(
99100
block_f = copy.deepcopy(block_q)
100101

101102
groups_q = make_grouped_module(block_q, inps_q, kwargs, device)
102-
103+
103104
# Build name→module map for block_f, then align groups_f to
104105
# groups_q by module name. Using make_grouped_module on
105106
# block_f independently can produce a different group ordering
@@ -112,12 +113,17 @@ def run_quantize_with_lpcd(
112113
name: mod for name, mod in block_q.named_modules() if isinstance(mod, nn.Linear)
113114
}
114115
groups_f = [
115-
[name_to_module_f[next(n for n, m2 in name_to_module_q.items() if m2 is m)] for m in gq]
116+
[
117+
name_to_module_f[next(n for n, m2 in name_to_module_q.items() if m2 is m)]
118+
for m in gq
119+
]
116120
for gq in groups_q
117121
]
118122

119123
lpcd_metrics = make_lpcd_metrics(lpcd_config, block_q, block_f)
120-
lpcd_modules_q = [module for metric, _ in lpcd_metrics.metrics for _, module in metric.named_targets()]
124+
lpcd_modules_q = [
125+
module for metric, _ in lpcd_metrics.metrics for _, module in metric.named_targets()
126+
]
121127

122128
# 3. For each group of layers, perform the following sequentially
123129
for group_q, group_f in zip(groups_q, groups_f):
@@ -149,13 +155,10 @@ def run_quantize_with_lpcd(
149155
# Skip layers not registered for quantization
150156
if module not in quantizer.module_to_name:
151157
skipped_name = module_q_to_name.get(module, "<unknown>")
152-
logger.info(
153-
"Skipping layer (not in quantization targets): %s", skipped_name
154-
)
158+
logger.info("Skipping layer (not in quantization targets): %s", skipped_name)
155159
continue
156160
name = quantizer.module_to_name[module]
157161

158-
159162
# Fall back to standard quantization if the module is not LPCD targets
160163
if not module in lpcd_modules_q:
161164

@@ -190,10 +193,7 @@ def run_quantize_with_lpcd(
190193
# Update the weights of the target layer
191194
dtype = module.weight.data.dtype
192195
module.weight.data = (
193-
quantizer.results[name]
194-
.compute_dequantized_weight()
195-
.to(device)
196-
.to(dtype)
196+
quantizer.results[name].compute_dequantized_weight().to(device).to(dtype)
197197
)
198198

199199
lpcd_metrics.mark_as_ready(module)
@@ -218,20 +218,11 @@ def run_quantize_with_lpcd(
218218
inps_f = forward_input(inps_f, block_f, kwargs, batch_size, device)
219219

220220
# DEBUG:Compute MSE between quantized and full-precision outputs
221-
mse = compute_mse(
222-
block_q,
223-
block_f,
224-
inps_q,
225-
inps_f,
226-
batch_size,
227-
device,
228-
kwargs
229-
)
221+
mse = compute_mse(block_q, block_f, inps_q, inps_f, batch_size, device, kwargs)
230222
logger.info(f"[INFO] Layer {block_idx + 1} MSE: {mse:.6e}")
231223

232224
# free memory
233225
block_q.cpu()
234226
torch.cuda.empty_cache()
235227

236228
quantizer.execute_post_processing()
237-

0 commit comments

Comments
 (0)