Skip to content

[Perf] Add pluggable backend dispatch for quantization lifecycle ops - #773

Open
ishrith-gowda wants to merge 2 commits into
vllm-project:mainfrom
ishrith-gowda:perf/quant-lifecycle-backend-dispatch
Open

[Perf] Add pluggable backend dispatch for quantization lifecycle ops#773
ishrith-gowda wants to merge 2 commits into
vllm-project:mainfrom
ishrith-gowda:perf/quant-lifecycle-backend-dispatch

Conversation

@ishrith-gowda

@ishrith-gowda ishrith-gowda commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Purpose

Towards #766, the "start by replacing the functions with dispatchers" step (design discussed in #766 (comment)).

The elementwise lifecycle leaf ops (_quantize, _dequantize, _quantize_dequantize) run on every weight/activation group during calibration. Profiling a W4A16 GPTQ run of Qwen2.5-0.5B-Instruct (cProfile) shows _quantize_dequantize is called 245,760 times for a 0.5B model (billions at Kimi/DeepSeek scale), spending 11.6s of self time in a chain of small elementwise CUDA kernels:

ncalls   tottime  cumtime  function
245760    0.26    46.10   forward.py:148 fake_quantize
245760   11.64    25.62   forward_helpers.py:175 _quantize_dequantize

This adds a QuantizationBackend registry: the reference PyTorch code moves into an eager backend (registered by default, bit identical to today), the leaf helpers become thin wrappers that route through the active backend, and accelerated backends (torch.compile, hand written triton kernels) can register under a name and are selected per tensor with transparent eager fallback. No behavior change by default; this is the plug in point for the kernels tracked in the rest of #766.

Changes

  • quantization/lifecycle/backend.py (new): QuantizationBackend(RegistryMixin), the default EagerQuantizationBackend (exact original ops), get_quantization_backend() selection with eager fallback, set_quantization_backend() / COMPRESSED_TENSORS_QUANT_BACKEND env.
  • quantization/lifecycle/forward_helpers.py: the three leaf helpers route through the active backend; signatures and call sites unchanged.

Test Plan / Test Result

  • pytest tests/test_quantization/lifecycle/test_backend.py (new): 5 passed. Eager output is bit identical to the reference formula; quantize/dequantize roundtrip; unknown backend raises; a custom registered backend is dispatched to, and is_available=False falls back to eager.
  • pytest tests/test_quantization/lifecycle/ regression: every test that passes on main still passes with the dispatcher in place (2 unrelated failures are pre-existing on main), confirming the eager path is unchanged.

Built with an AI-assisted engineering workflow (Claude) under my direction: I specified the design, reviewed each iteration, and validated the profiling and test results. Co-authored-by trailer on the commit.

@coderabbitai

coderabbitai Bot commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Important

Review skipped

Auto incremental reviews are disabled on this repository.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 8a376c20-c119-498d-81fa-5924f21c919b

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Walkthrough

Introduces a pluggable quantization backend abstraction (QuantizationBackend registry with an EagerQuantizationBackend implementation, plus set_quantization_backend/get_quantization_backend), refactors forward_helpers.py's quantize/dequantize logic to delegate to this backend, and adds tests covering selection, correctness, and fallback behavior.

Changes

Quantization backend abstraction

Layer / File(s) Summary
Backend registry contract and eager implementation
src/compressed_tensors/quantization/lifecycle/backend.py
Adds QuantizationBackend registry base class with is_available, quantize, dequantize, quantize_dequantize; registers EagerQuantizationBackend under "eager"; adds set_quantization_backend and get_quantization_backend with environment-variable default and availability-based fallback.
Forward helpers delegate to backend
src/compressed_tensors/quantization/lifecycle/forward_helpers.py
Removes inline quantize/dequantize math and rewires _quantize_dequantize, _quantize, _dequantize to call the active backend via get_quantization_backend(...); updates imports accordingly.
Backend selection and correctness tests
tests/test_quantization/lifecycle/test_backend.py
Adds fixture resetting backend to eager, reference QDQ helper, and tests for default backend, exact/tolerant equivalence, invalid backend name error, and fallback when a custom backend reports unavailability.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Caller
  participant ForwardHelpers as "_quantize / _dequantize / _quantize_dequantize"
  participant Dispatcher as get_quantization_backend
  participant Backend as Active Backend

  Caller->>ForwardHelpers: quantize/dequantize request (x, scale, zero_point, args)
  ForwardHelpers->>Dispatcher: get_quantization_backend(x, args)
  Dispatcher->>Backend: is_available(x, args)
  alt backend available
    Dispatcher-->>ForwardHelpers: return Backend
  else backend unavailable
    Dispatcher-->>ForwardHelpers: return EagerQuantizationBackend
  end
  ForwardHelpers->>Backend: quantize/dequantize/quantize_dequantize(...)
  Backend-->>ForwardHelpers: result tensor
  ForwardHelpers-->>Caller: result tensor
Loading

Related issues: None specified.

Related PRs: None specified.

Suggested labels: enhancement, refactor

Suggested reviewers: None specified.

🐰 A backend hopped in, plug-and-play,
Quantize, dequantize — now dispatched away,
Eager by default, custom if you dare,
Tests confirm the fallback's always there,
A tidy warren, refactored with care.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly summarizes the main change: adding pluggable backend dispatch for quantization lifecycle ops.
Description check ✅ Passed The description is detailed and directly matches the changeset and testing described in the pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@mergify

mergify Bot commented Jul 7, 2026

Copy link
Copy Markdown

The quality checks have failed. Please run make style and make quality under
the root directory to adddress the lint failures. You will need to install the
dev optional install to get the required linting packages.

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 3

🧹 Nitpick comments (2)
tests/test_quantization/lifecycle/test_backend.py (2)

84-114: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Exercise custom dispatch for all backend leaf ops.

The custom backend test only proves _quantize_dequantize dispatch. Add sentinel quantize and dequantize methods too, so regressions in the other changed helper paths are caught.

As per path instructions, tests/**/*.py should be comprehensive for quantization scenarios.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_quantization/lifecycle/test_backend.py` around lines 84 - 114, The
custom backend test only covers _quantize_dequantize, so it can miss regressions
in the other backend leaf helpers. Extend SentinelBackend in
test_custom_backend_dispatch_and_fallback to also define sentinel quantize and
dequantize methods, then add assertions that the backend-specific paths for
those helpers dispatch to the sentinels and fall back to the eager/reference
behavior when availability is toggled off. Use the existing
QuantizationBackend.register, set_quantization_backend, and _reference_qdq-style
patterns to keep the test aligned with the other helper paths.

Source: Path instructions


32-44: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Cover global_scale in the reference and roundtrip tests.

The dispatch paths forward global_scale, but every assertion currently exercises only None, so a regression in that branch would pass.

Proposed test expansion
-def _reference_qdq(x, scale, zero_point, q_min, q_max, args):
+def _reference_qdq(x, scale, zero_point, q_min, q_max, args, global_scale=None):
     from compressed_tensors.quantization.quant_args import round_to_quantized_type_args
 
+    if global_scale is not None:
+        scale = scale / global_scale
     scaled = x / scale
@@
-def test_eager_qdq_matches_reference():
+@pytest.mark.parametrize("global_scale", [None, torch.tensor(2.0)])
+def test_eager_qdq_matches_reference(global_scale):
@@
-    got = _quantize_dequantize(x, scale, zp, q_min, q_max, args)
-    ref = _reference_qdq(x, scale, zp, q_min, q_max, args)
+    got = _quantize_dequantize(
+        x, scale, zp, q_min, q_max, args, global_scale=global_scale
+    )
+    ref = _reference_qdq(
+        x, scale, zp, q_min, q_max, args, global_scale=global_scale
+    )
     assert torch.equal(got, ref)
@@
-def test_quantize_then_dequantize_roundtrip():
+@pytest.mark.parametrize("global_scale", [None, torch.tensor(2.0)])
+def test_quantize_then_dequantize_roundtrip(global_scale):
@@
-    q = _quantize(x, scale, zp, q_min, q_max, args)
-    dq = _dequantize(q, scale, zp)
+    q = _quantize(x, scale, zp, q_min, q_max, args, global_scale=global_scale)
+    dq = _dequantize(q, scale, zp, global_scale=global_scale)
@@
-    assert torch.allclose(dq, _quantize_dequantize(x, scale, zp, q_min, q_max, args))
+    assert torch.allclose(
+        dq,
+        _quantize_dequantize(
+            x, scale, zp, q_min, q_max, args, global_scale=global_scale
+        ),
+    )

As per path instructions, tests/**/*.py should cover edge cases for compression and quantization scenarios.

Also applies to: 52-76

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_quantization/lifecycle/test_backend.py` around lines 32 - 44,
Expand the quantization lifecycle tests to explicitly exercise the global_scale
branch in both the reference helper _reference_qdq and the roundtrip assertions,
since the current cases only cover None and can miss regressions in dispatch
paths that forward global_scale. Add coverage in test_backend.py around the
existing reference/roundtrip test helpers so they validate behavior when
global_scale is provided, keeping the checks aligned with the existing
quantization flow and symbols like _reference_qdq and the related roundtrip test
cases.

Source: Path instructions

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/compressed_tensors/quantization/lifecycle/backend.py`:
- Around line 50-53: The backend availability probe in `Backend.is_available`
currently assumes `args` is always a `QuantizationArgs`, but `_dequantize` may
call it without one, causing custom backends to fail before eager fallback.
Update the `is_available` contract and all backend implementations/call sites to
allow `args=None`, and make the availability checks in the quantization
lifecycle code handle a missing args value safely without raising.
- Around line 22-34: Apply the configured import/export sorting in the backend
module so Ruff passes: separate the first-party imports from the
standard/library import and reorder them according to the project’s import
grouping rules. Also sort the entries in the module’s __all__ list in a
consistent order, keeping the exported symbols in the expected alphabetical
sequence. Use the existing import block and __all__ definition in the
QuantizationBackend module as the targets for the fix.

In `@tests/test_quantization/lifecycle/test_backend.py`:
- Around line 79-81: The test for set_quantization_backend is asserting too
broadly with pytest.raises(Exception) even though
QuantizationBackend.get_value_from_registry() raises KeyError for unknown names.
Update test_set_unknown_backend_raises to expect KeyError specifically so the
test matches the actual registry validation behavior.

---

Nitpick comments:
In `@tests/test_quantization/lifecycle/test_backend.py`:
- Around line 84-114: The custom backend test only covers _quantize_dequantize,
so it can miss regressions in the other backend leaf helpers. Extend
SentinelBackend in test_custom_backend_dispatch_and_fallback to also define
sentinel quantize and dequantize methods, then add assertions that the
backend-specific paths for those helpers dispatch to the sentinels and fall back
to the eager/reference behavior when availability is toggled off. Use the
existing QuantizationBackend.register, set_quantization_backend, and
_reference_qdq-style patterns to keep the test aligned with the other helper
paths.
- Around line 32-44: Expand the quantization lifecycle tests to explicitly
exercise the global_scale branch in both the reference helper _reference_qdq and
the roundtrip assertions, since the current cases only cover None and can miss
regressions in dispatch paths that forward global_scale. Add coverage in
test_backend.py around the existing reference/roundtrip test helpers so they
validate behavior when global_scale is provided, keeping the checks aligned with
the existing quantization flow and symbols like _reference_qdq and the related
roundtrip test cases.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 57071d61-bb37-42d8-a37f-aa1f74546c16

📥 Commits

Reviewing files that changed from the base of the PR and between a4ee36a and e526fe4.

📒 Files selected for processing (3)
  • src/compressed_tensors/quantization/lifecycle/backend.py
  • src/compressed_tensors/quantization/lifecycle/forward_helpers.py
  • tests/test_quantization/lifecycle/test_backend.py
🔗 Linked repositories identified

CodeRabbit considers these linked repositories for cross-repo context during reviews:

  • vllm-project/llm-compressor (manual)

Comment thread src/compressed_tensors/quantization/lifecycle/backend.py
Comment thread src/compressed_tensors/quantization/lifecycle/backend.py Outdated
Comment thread tests/test_quantization/lifecycle/test_backend.py
The elementwise lifecycle leaf ops (_quantize, _dequantize,
_quantize_dequantize) run on every weight/activation group during
calibration. Profiling a W4A16 GPTQ run of Qwen2.5-0.5B shows
_quantize_dequantize is called 245,760 times for a 0.5B model (billions
at Kimi/DeepSeek scale), spending 11.6s of self time in a chain of small
elementwise CUDA kernels.

Add a QuantizationBackend registry (RegistryMixin) so these ops can
dispatch to different backends. The reference PyTorch code moves into an
eager backend (registered by default, bit identical to before); the leaf
helpers become thin wrappers that route through the active backend;
accelerated backends (torch.compile, triton kernels) register under a
name and are selected per tensor with transparent eager fallback.
Behavior is unchanged by default; this is the plug in point for the
kernels tracked in the rest of the issue.

Towards vllm-project#766

Co-authored-by: Claude <noreply@anthropic.com>
Signed-off-by: ishrith-gowda <ishrithgowda@berkeley.edu>
@ishrith-gowda
ishrith-gowda force-pushed the perf/quant-lifecycle-backend-dispatch branch from e526fe4 to 1b9d926 Compare July 8, 2026 13:54
@mergify mergify Bot removed the quality-failed label Jul 8, 2026

@brian-dellabetta brian-dellabetta left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Thanks for preparing this, I understand the design. a couple questions.

# clamp and round
quantized_value = round_to_quantized_type_args(
tensor=scaled, args=args, min=q_min, max=q_max
return get_quantization_backend(x, args).quantize(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

since these are called potentially billions of times, can the quant backend resolution occur elsewhere a single time?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good call. Done in 8e3282c: the backend class is now resolved once and cached in a module global (set_quantization_backend updates the cache, first call resolves the env-configured name lazily). The hot path is now one global read plus an identity check, and the per-tensor is_available probe only runs when a non-eager backend is active. With eager active (the default) there is no registry access per call at all.

]

_DEFAULT_BACKEND = "eager"
_ACTIVE_BACKEND = os.environ.get("COMPRESSED_TENSORS_QUANT_BACKEND", _DEFAULT_BACKEND)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

in practice, will the most suitable backend be inferred based on version / device available? Would we ever want quant backend to be different for different modules in the LLM?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The per-tensor is_available hook already gives device-level adaptivity: a CUDA-only backend runs for GPU modules while CPU modules transparently fall back to eager, so mixed-device models do the right thing without per-module config. For auto-inference, my thinking is a follow-up "auto" mode that walks registered backends in priority order and picks the first whose is_available passes (version and device checks live in each backend). Explicit per-module backend selection could ride on QuantizationScheme later if a concrete kernel needs it, but I would hold off until the triton kernels land and we see whether they want different dispatch per scheme. Happy to adjust if you have a preference.

…able

Per review feedback:
- Resolve the active backend once and cache the class; the hot path now
  costs one global read instead of a registry lookup per call. The
  per-tensor is_available fallback only runs for non-eager backends.
- is_available accepts args=None (dequantize carries no QuantizationArgs).
- Narrow unknown-backend test assertion to KeyError.

Co-authored-by: Claude <noreply@anthropic.com>
Signed-off-by: ishrith-gowda <ishrithgowda@berkeley.edu>
@mergify

mergify Bot commented Jul 30, 2026

Copy link
Copy Markdown

This pull request has merge conflicts that must be resolved before it can be
merged. Please rebase the PR, @ishrith-gowda.

https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/syncing-a-fork

@mergify mergify Bot added the needs-rebase label Jul 30, 2026
@kylesayrs

kylesayrs commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator

Hi @ishrith-gowda!

Let me know what you think of #804. I think this interface should be a little more generalizable to backending functions beyond just quantization

@ishrith-gowda

Copy link
Copy Markdown
Contributor Author

This looks better than what I had. Generalizing to any function with register(name, req, priority) plus a use(name) fallback is the right call, the priority ordering is cleaner than the single active backend I used, and putting the eager path in the decorated body means call sites stay untouched. The CT_ENFORCE_EAGER escape hatch is a good addition.

One thing worth checking before this goes on the QDQ leaf: wrapper walks the backend list and calls req(*args, **kwargs) on every invocation. For the elementwise ops that is 245K calls on a 0.5B model, so the requirement checks land in exactly the path we are trying to speed up. In #773 I cached the resolved backend for that reason. Options would be caching per name and invalidating on register, or keeping use on coarser grained functions and letting the leaf ops stay direct. The nvfp4 and fp4_utils call sites in this PR are coarse enough that it likely does not show up there, so this is mostly a question of how far down you plan to push it.

Happy to close #773 in favor of this and port over the eager path equivalence tests and the QDQ benchmark I used for the profiling numbers, if that is useful. The merge conflict on mine is moot in that case.

@kylesayrs

Copy link
Copy Markdown
Collaborator

One thing worth checking before this goes on the QDQ leaf: wrapper walks the backend list and calls req(*args, **kwargs) on every invocation. For the elementwise ops that is 245K calls on a 0.5B model, so the requirement checks land in exactly the path we are trying to speed up

@ishrith-gowda I think your _ACTIVE_BACKEND_CLS is a good idea, but it might require a little more engineering effort. The only complication I can think of is that hot-pathing essentially overrides the concept of priority backends. We can remove the "priority" backends concept in favor of hotpathing if we

  1. demonstrate a speedup (can be a very isolated benchmark, doesn't need to be e2e)
  2. add a solid regression test that removing priority field is fine and that backend requirements are non-overlapping

Would you be interested in opening a PR rebased on #804 to add this hot-path caching?

@mergify

mergify Bot commented Aug 17, 2026

Copy link
Copy Markdown

Merge Protections

🔴 1 of 1 protections blocking · waiting on 👀 reviews

Protection Waiting on
🔴 Require one maintainer review 👀 reviews

🔴 Require one maintainer review

Waiting for any of

  • approved-reviews-by=HDCharles
  • approved-reviews-by=brian-dellabetta
  • approved-reviews-by=dsikka
  • approved-reviews-by=kylesayrs
This rule is failing.

All PRs must have at least one approving review from a maintainer before merging.

  • any of:
    • approved-reviews-by=HDCharles
    • approved-reviews-by=brian-dellabetta
    • approved-reviews-by=dsikka
    • approved-reviews-by=kylesayrs
  • #changes-requested-reviews-by = 0

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants