Skip to content

[quantization] Support auto_percdamp for Qwen GPTQ Quantizer - #1074

Open
Torrero wants to merge 6 commits into
Samsung:mainfrom
Torrero:qwen_auto_percdamp
Open

Torrero wants to merge 6 commits into
Samsung:mainfrom
Torrero:qwen_auto_percdamp

Conversation

@Torrero

@Torrero Torrero commented Oct 21, 2026

Copy link
Copy Markdown
Contributor

What

Adds an opt-in automatic per-layer damping policy (percdamp_mode="auto") to the Qwen3-VL GPTQ quantizer. Instead of using a single global percdamp value for every layer, auto mode derives a per-layer starting damping factor from the Qwen3-VL stage type, calibration volume, effective token/patch observations, and Hessian diagonal spread — and can retry unstable Cholesky factorizations with escalated damping instead of aborting the whole run.

Default behavior is unchanged: percdamp_mode="static" preserves the existing GPTQ behavior bit-for-bit.

Motivation

Qwen3-VL mixes heterogeneous stages (vision patch embed, vision blocks, mergers/deepstack mergers, text layers, LM head) that are calibrated with very different numbers of batches and effective observations. A single global damping factor is a poor fit: vision stages replaying fewer calibration batches tend to produce ill-conditioned Hessians, and a Cholesky failure on a single layer previously aborted the entire quantization run. Auto damping normalizes regularization across stages and makes runs resilient via bounded damping escalation.

Usage:

Option Default Description
percdamp_mode "static" "static" uses percdamp unchanged; "auto" enables the per-layer policy.
auto_percdamp_min / auto_percdamp_max 0.002 / 0.08 Clamp bounds for the auto-resolved starting damping.
auto_percdamp_retry_max 0.5 Retry ceiling for damping escalation, independent of auto_percdamp_max — layers already clamped to auto_percdamp_max (e.g. vision patch embed) can still be rescued by retries.
auto_percdamp_ref_batches 0 (auto) Reference calibration batch count. 0 uses the actual number of batches replayed in the run, so the batch factor only raises damping for stages with reduced coverage.
auto_percdamp_ref_observations 0 (auto) Reference token/patch observation count. 0 derives it from the calibration config as sum(datasets n_samples) * seq_len; falls back to 128 * 2048 when no spec is known.
# recipe YAML
pipeline:
- name: gptq
  percdamp_mode: auto        # opt-in; "static" keeps legacy behavior
  auto_percdamp_min: 0.002
  auto_percdamp_max: 0.08
  auto_percdamp_retry_max: 0.5

Co-Authored-By: Cline

TICO-DCO-1.0-Signed-off-by: Evgenii Maltsev e.maltsev@samsung.com

This commit support auto_percdamp for Qwen GPTQ Quantizer

Co-Authored-By: Cline

TICO-DCO-1.0-Signed-off-by:  Evgenii Maltsev <e.maltsev@samsung.com>
@Torrero

Torrero commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor Author

Qwen3-vl-4B evaluation with using auto_percdamp.

Commit: 658a7704a
The same configuration with/without auto_percdamp:

Config ID PPL mmlu vqav2 COCO (CIDEr/Bleu_4) hellaswag MMMU_Pro (vision) Llava-Bench Rel. videomme (32 frames)
FP32 9.46 0.735 0.895 0.335 / 0.079 0.642 0.303 104.7 65
Qwen3_W4A16
head:8bit
Spinquant
GPTQ_V2_alpha_0.2_mse_for_gptq
Calib_samples:
vqav2=128 split=train
wikitext2=128 split=train
mlu=96 split=auxiliary_train
filtered textvqa=60 split=train
hessian_dtype=fp64 inp_dtype=fp64
static percdamp=0.01
9.89 0.706 0.887 0.281 / 0.087 0.618 0.287 106.6 62.5
% degradation 4.5% 2.9% 0.8% 16% / 0% 2.4% 1.6% 0% 2.5%
Qwen3_W4A16
head:8bit
Spinquant
GPTQ_V2_alpha_0.2_mse_for_gptq
Calib_samples:
vqav2=128 split=train
wikitext2=128 split=train
mlu=96 split=auxiliary_train
filtered textvqa=60 split=train
hessian_dtype=fp64 inp_dtype=fp64
auto_percdamp
9.87 0.707 0.893 0.299 /0.081 0.600 0.297 104.02 64.5
% degradation 4.3% 2.8% 0.2% 10.7%/0% 4.2% 0.6% 0.6% 0.5%
Logs:

log1_no_auto_percdamp.zip

log2_auto_percdamp.zip.zip

Run command:

run_qwen_1.sh

Additional configuration:

Config ID PPL mmlu vqav2 COCO (CIDEr/Bleu_4) hellaswag MMMU_Pro (vision) Llava-Bench Rel. videomme (32 frames)
FP32 9.46 0.735 0.895 0.335 / 0.079 0.642 0.303 104.7 65
Qwen3_W4A16
head:8bit
Spinquant
GPTQ_V2_alpha_0.2_mse_for_gptq
Calib_samples:
vqav2=128 split=train
wikitext2=128 split=train
mlu=64 split=auxiliary_train
filtered textvqa=64 split=train
hessian_dtype=fp64 inp_dtype=fp64
auto_percdamp
9.85 0.707 0.894 0.342/0.090 0.612 0.294 105.5 64
% degradation 4.1 2.8% 0.1% 0% /0% 3% 1% 0% 1%
Logs:

Log3.zip

Run command:

run_qwen_3.sh

@mhs4670go

Copy link
Copy Markdown
Contributor

Thanks for adding opt-in automatic per-layer damping and bounded Cholesky retries. The overall direction looks reasonable, especially given the different calibration characteristics of Qwen3-VL's vision and language stages.

However, I found three issues that I think should be addressed before merging.

1. Memory regression in the default static mode

File: tico/quantization/algorithm/qwen3_vl_gptq/gptq.py
Function: _factorize_hessian_inverse()

The new implementation retains several full-size Hessian matrices simultaneously:

h_try = h.clone()
h_try[diag, diag] += cur_percdamp * diag_mean
chol = torch.linalg.cholesky(h_try)
hinv = torch.cholesky_inverse(chol)
hinv = torch.linalg.cholesky(hinv, upper=True)

Previously, intermediate results were assigned back to the same variable, allowing earlier allocations to be released sooner.

In a CPU tensor-lifetime comparison, the maximum number of simultaneously retained N × N tensors increased from 2 to 5. For an 8192 × 8192 FP64 Hessian, this represents approximately 1.5 GiB of additional tensor memory, excluding temporary workspaces.

More importantly, this new factorization path is also used when percdamp_mode="static". Therefore, users who do not enable the new feature may still experience OOM regressions.

Suggestion:

  • Preserve the previous memory behavior in static mode.
  • In auto mode, retain the original Hessian for retries but release intermediate tensors as early as possible.
  • Add regression coverage for memory usage in the default path.

2. Failed retry exceptions retain Hessian tensors until garbage collection

File: tico/quantization/algorithm/qwen3_vl_gptq/gptq.py
Function: _factorize_hessian_inverse()

The retry loop stores the exception:

except RuntimeError as exc:
    last_error = exc

When a factorization fails and a subsequent retry succeeds, last_error still holds the traceback, which references local tensors and can create a reference cycle.

I reproduced this with an initially indefinite Hessian that succeeds after increasing damping. Even after fasterquant() returns and gptq.free() is called, the Hessian-related tensors remain alive until cyclic garbage collection runs.

This can increase memory pressure during sequential layer quantization.

Suggestion: Avoid retaining the exception traceback after a successful retry, or explicitly clear the retained exception before returning. A regression test should verify that tensors from failed attempts are released without requiring gc.collect().

3. Explicit auto_percdamp_ref_observations=0 does not behave as documented

File: tico/quantization/recipes/stages/gptq.py
Function: run()

The configuration documents 0 as an automatic value that derives the reference observation count from the calibration configuration.

However, the current logic only performs this calculation when the key is absent:

if "auto_percdamp_ref_observations" not in payload:

This means that explicitly specifying:

auto_percdamp_ref_observations: 0

skips the calibration-based calculation and causes the quantizer to use the fixed fallback of 128 * 2048.

For example, with two datasets containing 128 samples each and seq_len=2048, omitting the option produces a reference of 524288, while explicitly setting it to 0 produces 262144.

I also verified that this difference changes the resolved damping value.

Suggestion: Treat both an omitted value and an explicit 0 as automatic, while preserving positive user-provided overrides. Please add a recipe-level regression test covering these cases.

Additional suggestions

Retry ceiling vs. retry count

The fixed max_retries=5 can terminate the retry loop before reaching auto_percdamp_retry_max.

For example, starting from percdamp=0.002 with a retry factor of 2.5, the five attempts only reach 0.078125, even when the configured ceiling is 0.5.

This limit may be intentional, but the current error message suggests increasing the retry ceiling even when the actual limiting factor is the attempt count.

It would be helpful to distinguish these termination conditions or ensure that the configured ceiling is attempted before giving up.

Evaluation results

The reported benchmark results show improvements in several metrics, but also regressions in others, including HellaSwag and LLaVA-Bench.

Keeping this policy opt-in is appropriate. I would avoid describing it as a general accuracy improvement without additional controlled experiments.

@Torrero

Torrero commented Oct 23, 2026

Copy link
Copy Markdown
Contributor Author

@mhs4670go, thank you for reviewing this PR! I've fixed your comments:

  • Memory regression in Hessian factorization — the transferred path held 5 live N×N tensors; the static path was inlined in fasterquant() with legacy in-place damping and a del-based chain (peak 5→2, full legacy parity), while the auto-only _factorize_hessian_inverse() retains the original for retries but frees per-attempt temporaries early (peak 5→3), pinned by data_ptr/weakref peak-profiler regression tests.

  • Failed retry exceptions retaining Hessian tensors until cyclic GC — last_error = None was added before the successful return hinv to break the exc → traceback → frame → locals → exc reference cycle, so the original Hessian and failed-attempt clones are released by refcounting alone, verified by a regression test running with gc.disable().

  • Explicit auto_percdamp_ref_observations: 0 not behaving as documented — the recipe stage now treats 0 (or an omitted key) as "auto" via if not payload.get(...), deriving sum(datasets n_samples) * seq_len from the calibration config while preserving positive pins and the 128×2048 fallback, covered by a recipe-level regression test with four subTest cases.

  • Retry ceiling unreachable within the attempt budget — the retry loop was restructured so the configured auto_percdamp_retry_max is always attempted before giving up (one final ceiling attempt when max_retries is exhausted first, at most max_retries + 1 attempts), with the error message now stating that the final attempt used the configured ceiling.

@Torrero
Torrero marked this pull request as ready for review October 23, 2026 13:02
@Torrero
Torrero requested a review from mhs4670go October 23, 2026 13:02
@mhs4670go

Copy link
Copy Markdown
Contributor

Thanks for addressing the previous review comments. Clearing last_error before a successful return fixes the post-return retention issue. There is still a related memory-lifetime issue between attempts, however.

Remaining issue

When this operation fails:

hinv = torch.linalg.cholesky(hinv, upper=True) # _factorize_hessian_inverse()

the assignment does not complete, so hinv still references the inverse matrix from the failed attempt. The next iteration allocates a new h_try and chol while that old matrix is still alive.

There is an additional reference path when _validate_hinv() raises: last_error = exc retains the traceback, including the validation frame and its hinv argument. Clearing only the retry loop's tensor variables would not address that reference.

Consequently, the retry path can retain an extra full-size matrix precisely when it is trying to recover from a numerical failure. For an 8192 × 8192 FP64 matrix, one unnecessary allocation is 512 MiB, excluding workspaces. This can turn an otherwise recoverable numerical failure into an OOM in a memory-constrained run; I am not claiming that a full-model GPU OOM was reproduced.

Suggested implementation

Could we isolate each attempt in its own helper and retain only diagnostic text, rather than the exception object, in the outer retry loop?

Both changes matter: the helper gives each attempt its own temporary variables, and dropping the exception prevents its traceback from retaining that helper frame. Python clears the except ... as exc binding when the handler exits, so the failed attempt can be released before the next allocation. See the Python exception-handler reference.

The following is a suggested replacement for the auto-mode helper. Add _factorize_hessian_inverse_once() and replace _factorize_hessian_inverse() inside GPTQ. Keep _damping_precheck(), _validate_hinv(), and the existing inlined static path in fasterquant() unchanged. The existing Optional import is sufficient.

Suggested code
    def _factorize_hessian_inverse_once(
        self,
        h: torch.Tensor,
        diag: torch.Tensor,
        diag_mean: torch.Tensor,
        percdamp: float,
    ) -> torch.Tensor:
        """Run one attempt; keep its temporary tensors in this frame only."""
        h_try = h.clone()
        h_try[diag, diag] += percdamp * diag_mean
        chol = torch.linalg.cholesky(h_try)
        del h_try
        hinv = torch.cholesky_inverse(chol)
        del chol
        hinv = torch.linalg.cholesky(hinv, upper=True)
        self._validate_hinv(hinv, percdamp)
        return hinv

    def _factorize_hessian_inverse(
        self,
        h: torch.Tensor,
        percdamp: float,
        percdamp_max: Optional[float] = None,
        retry_factor: float = 2.5,
        max_retries: int = 5,
    ) -> torch.Tensor:
        """Retry auto-mode factorization without retaining failed attempts."""
        if percdamp_max is None:
            raise ValueError(
                "percdamp_max is required for the retry path; the static "
                "single-attempt path is inlined in fasterquant."
            )
        if percdamp_max < percdamp:
            raise ValueError(
                "percdamp_max must be >= percdamp. "
                f"got {percdamp_max} < {percdamp}"
            )

        diag, diag_mean = self._damping_precheck(h, percdamp)
        cur_percdamp = float(percdamp)
        ceiling = float(percdamp_max)
        attempts = 0
        last_error_text = ""

        while True:
            attempts += 1
            try:
                hinv = self._factorize_hessian_inverse_once(
                    h, diag, diag_mean, cur_percdamp
                )
            except torch.OutOfMemoryError:
                # Damping cannot fix an OOM. Preserve the original exception.
                raise
            except RuntimeError as exc:
                # Keep diagnostic text only, not the exception or traceback.
                # Python clears `exc` when this handler exits, allowing the
                # failed helper frame and its temporaries to be released
                # before the next attempt allocates anything.
                last_error_text = f"{type(exc).__name__}: {exc}"
            else:
                self.percdamp_used = cur_percdamp
                return hinv

            # Keep the existing escalation policy and final ceiling attempt.
            if cur_percdamp >= ceiling:
                break
            if attempts >= max_retries:
                cur_percdamp = ceiling
            else:
                cur_percdamp = min(ceiling, cur_percdamp * retry_factor)

        raise RuntimeError(
            "GPTQ Hessian factorization failed or became numerically unstable "
            f"after {attempts} attempt(s). "
            f"start_percdamp={percdamp}, last_percdamp={cur_percdamp}, "
            f"retry_ceiling={percdamp_max}. "
            "The final attempt used the configured retry ceiling. "
            "Increase percdamp or raise the retry ceiling "
            "(auto_percdamp_retry_max). "
            f"Last error: {last_error_text}"
        ) from None

This keeps the current damping schedule, the final attempt at the configured ceiling, percdamp_used, and immediate propagation of torch.OutOfMemoryError.

Diagnostic trade-off: the final numerical-failure error includes the last exception's type and message, but deliberately does not chain the original exception. Retaining that exception would retain its traceback and defeat the cleanup. OOM exceptions are still re-raised unchanged. Neither gc.collect() nor torch.cuda.empty_cache() is needed to compensate for these references.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants