Skip to content

[quantization] _qparams_locked mechanism for AffineObserverBase - #866

Merged
mhs4670go merged 2 commits into
Samsung:mainfrom
Torrero:_qparams_locked_introduction
Aug 7, 2026
Merged

[quantization] _qparams_locked mechanism for AffineObserverBase#866
mhs4670go merged 2 commits into
Samsung:mainfrom
Torrero:_qparams_locked_introduction

Conversation

@Torrero

@Torrero Torrero commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

This PR introduces _qparams_locked mechanism for AffineObserverBase

Implemented the _qparams_locked mechanism in affine_base.py with a YAML-controlled gate that defaults to OFF (preserving original behavior). When enabled via lock_gptq_qparams: true in the PTQ stage config, GPTQ-injected scales and zero-points are preserved through convert() instead of being silently replaced by PTQ min/max statistics.

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

This commit introduces _qparams_locked mechanism for AffineObserverBase

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

mhs4670go commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Could you explain when this feature is needed? Because when inject_gptq_qparams is called, the observers are already being locked.

@Torrero

Torrero commented Aug 6, 2026

Copy link
Copy Markdown
Contributor Author

Could you explain when this feature is needed? Because when inject_gptq_qparams is called, the observers are already being locked.

Yes, but after inject_gptq_qparams in convert function we call compute_qparams which recalcs injected params.

@mhs4670go

Copy link
Copy Markdown
Contributor

Ah, I got it. Good catch.

I agree with the issue raised here. In the current implementation, lock=True only stops further statistic collection by setting enabled=False; it does not prevent compute_qparams() from recomputing scale and zero-point from the previously collected min/max values. As a result, externally injected GPTQ qparams can still be overwritten during convert() / freeze_qparams().

A separate _qparams_locked state makes the semantics explicit. But, I would recommend making the qparam lock an observer-local state rather than introducing a module-level global flag or a YAML option. The behavior should be fully defined by load_qparams(..., lock=True) itself.

class AffineObserverBase(ObserverBase):
    def __init__(self, ...):
        super().__init__(...)
        ...
        self._qparams_locked = False
        self.reset()

    def reset(self) -> None:
        ...

        self._cached_scale = self._cached_scale.new_empty((0,))
        self._cached_zp = self._cached_zp.new_empty((0,), dtype=torch.int)
        self._qparams_locked = False

    def load_qparams(
        self,
        scale: torch.Tensor,
        zero_point: torch.Tensor,
        *,
        lock: bool = True,
    ) -> None:
        """Load externally computed quantization parameters.

        When ``lock`` is enabled, subsequent calls to ``compute_qparams()``
        preserve the loaded values instead of recomputing them from collected
        statistics.
        """
        self._cached_scale = scale.detach().clone()
        self._cached_zp = zero_point.detach().to(dtype=torch.int).clone()
        self._qparams_locked = bool(lock)

        if lock:
            self.enabled = False

    def compute_qparams(self) -> tuple[torch.Tensor, torch.Tensor]:
        """Return locked qparams or compute them from collected statistics."""
        if self._qparams_locked:
            if not self.has_qparams:
                raise RuntimeError(
                    "The observer is locked but does not contain valid qparams."
                )
            return self._cached_scale, self._cached_zp

        # Existing min/max-based qparam computation.
        ...

With this behavior:

  • load_qparams(..., lock=True) preserves externally injected GPTQ qparams.
  • load_qparams(..., lock=False) allows later recomputation from statistics.
  • No process-wide global state or additional recipe/YAML option is required.

I would also add tests covering both the observer-level semantics and the full GPTQ conversion flow.

def test_locked_qparams_survive_compute_qparams():
    observer = create_test_observer()

    observer.collect(
        torch.tensor(
            [
                [-2.0, 1.0],
                [-1.0, 3.0],
            ]
        )
    )

    injected_scale = torch.tensor([0.1, 0.2])
    injected_zero_point = torch.tensor([7, 8], dtype=torch.int)

    observer.load_qparams(
        injected_scale,
        injected_zero_point,
        lock=True,
    )

    scale, zero_point = observer.compute_qparams()

    torch.testing.assert_close(scale, injected_scale)
    torch.testing.assert_close(zero_point, injected_zero_point)

The unlocked case should verify that loaded values may be replaced by values computed from the collected statistics:

def test_unlocked_qparams_can_be_recomputed():
    observer = create_test_observer()

    observer.collect(
        torch.tensor(
            [
                [-2.0, 1.0],
                [-1.0, 3.0],
            ]
        )
    )

    injected_scale = torch.tensor([123.0, 456.0])
    injected_zero_point = torch.tensor([7, 8], dtype=torch.int)

    observer.load_qparams(
        injected_scale,
        injected_zero_point,
        lock=False,
    )

    scale, zero_point = observer.compute_qparams()

    assert not torch.equal(scale, injected_scale)
    assert not torch.equal(zero_point, injected_zero_point)

It would also be useful to verify that reset() removes the lock:

def test_reset_clears_qparam_lock():
    observer = create_test_observer()

    observer.load_qparams(
        torch.tensor([0.1]),
        torch.tensor([0], dtype=torch.int),
        lock=True,
    )

    observer.reset()

    assert not observer._qparams_locked
    assert not observer.has_qparams

@Torrero

Torrero commented Aug 6, 2026

Copy link
Copy Markdown
Contributor Author

Actually,
Now, I think current GPTQ->PTQ flow (without _qparams_locked ) is correct.
In common case in GPTQ stage we preliminary compute qparams (scales, zp) for target tensor and can adjust min/max range using MSE or SMSE. After that the quantization is executed with the hessian-based error compensation, which corrects the tensor and min/max range accordingly.
In PTQ stage (in current implementation) new qparams are recalculated using actual min/max range of the target tensor and this range can diverge from preliminary computed in GTPQ (actual range can be less or equal then preliminary computed in GTPQ).
So I think my PR is redundant, or together with _qparams_locked and inject_gptq_qparams we need to compute actual qparams in the final stage of GPTQ.

@mhs4670go

Copy link
Copy Markdown
Contributor

@Torrero

I believe GPTQ should own the final weight qparams in the current GPTQ → PTQ
pipeline.

In fasterquant(), the quantizer scale and zero-point are finalized before the sequential quantization loop. Each weight column is then quantized using those exact qparams.

The Hessian-based error compensation modifies only the remaining not-yet-quantized weights. It does not recompute the quantization grid after the final Q is produced. The resulting Q is assigned directly to layer.weight.

Therefore, quantizer.scale and quantizer.zero are not merely preliminary min/max estimates. They are the actual quantization parameters used to generate the final GPTQ weight.

It is possible for the observed min/max range of the final Q to be narrower than the full range represented by those qparams. This only means that the final tensor does not use every available integer code. It does not mean that the original GPTQ qparams are no longer valid.

Recomputing qparams from the observed min/max of the final GPTQ weight creates a different quantization grid. During PTQ fake quantization, the already quantized GPTQ weight is then quantized again using that new grid. This second quantization is not part of the Hessian-aware GPTQ optimization and may alter the weight values and increase the reconstruction error.

I suggest keeping _qparams_locked as observer-local state and defining
load_qparams(..., lock=True) to guarantee that later
compute_qparams() calls return the injected values.

@Torrero

Torrero commented Aug 7, 2026

Copy link
Copy Markdown
Contributor Author

@mhs4670go

I agree with you, changed my PR.

This only means that the final tensor does not use every available integer code.

That's what I wanted to highlight.

Recomputing qparams from the observed min/max of the final GPTQ weight creates a different quantization grid. During PTQ fake quantization, the already quantized GPTQ weight is then quantized again using that new grid. This second quantization is not part of the Hessian-aware GPTQ optimization and may alter the weight values and increase the reconstruction error.

Maybe we can keep this technique by introducing argument to YAML file. This technique can produce a more precise quantization grid, which may have advantages in some cases. We can conduct some tests.

@mhs4670go mhs4670go left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM. Thank you!

@mhs4670go
mhs4670go merged commit 8da8a14 into Samsung:main Aug 7, 2026
7 checks passed
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