Skip to content
16 changes: 13 additions & 3 deletions modelopt/torch/quantization/ggml/gptq.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@

import torch

from ..extensions import get_cuda_ext_ggml
from ..utils.calib_utils import GPTQHelper, register_gptq_helper
from .common import floored_second_moments, pin_packed_weight
from .registry import GGML_FORMAT_REGISTRY
Expand Down Expand Up @@ -78,11 +79,20 @@ class GGMLGPTQHelper(GPTQHelper):
those exact codes rather than encoding the GPTQ'd weight again, which would not return them.
"""

def update_weights(self, block_size, perc_damp, weight=None):
def updates_on_any_device(self):
"""Whether the CUDA encoders are available.

The update reads only the weight, its Hessian and the format's codec, but the PyTorch
fallback's scratch grows with ``block_chunk_size``, past the room the update is checked for.
"""
return get_cuda_ext_ggml() is not None

def update_weights(self, block_size, perc_damp, weight=None, device=None):
"""Run the GPTQ update, then pin the chosen payload to the weight quantizer."""
super().update_weights(block_size, perc_damp, weight)
super().update_weights(block_size, perc_damp, weight, device)
quantizer = self.weight_quantizer
pin_packed_weight(quantizer, quantizer.num_bits, self._packed)
weight = self.module.weight if weight is None else weight
pin_packed_weight(quantizer, quantizer.num_bits, self._packed.to(weight.device))
self._packed = None

def _blockwise_update(self, block_size):
Expand Down
45 changes: 17 additions & 28 deletions modelopt/torch/quantization/model_calib.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@
import warnings
from collections import Counter, defaultdict
from collections.abc import Callable, Mapping, Sequence
from concurrent.futures import ThreadPoolExecutor
from contextlib import nullcontext
from functools import partial
from typing import Any, TypeAlias
Expand Down Expand Up @@ -79,7 +78,9 @@
_IMATRIX_ENCODER_REGISTRY,
GPTQHelper,
bfloat16_hessian_gemm,
run_on_each_gpu,
track_calib_forward_memory,
update_expert_weights,
)

__all__ = [
Expand Down Expand Up @@ -2648,15 +2649,17 @@ def _expert_hook(_input_quantizer, _args, output):
handle.update_weights(block_size, perc_damp)
handle.free()
for name, module in fused_experts:
updated = unreached = 0
with enable_weight_access_and_writeback(module, model, names):
for weight, quantizer in module.iter_weights_for_calibration():
handle = expert_handles.pop(id(quantizer), None)
if handle is not None:
unreached += handle.n_samples == 0
handle.update_weights(block_size, perc_damp, weight)
handle.free()
updated += 1
work = [
(expert_handles.pop(id(quantizer)), weight)
for weight, quantizer in module.iter_weights_for_calibration()
if id(quantizer) in expert_handles
]
updated = len(work)
unreached = sum(handle.n_samples == 0 for handle, _ in work)
update_expert_weights(work, block_size, perc_damp)

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.

Bot comment.

[P2] Drop the work-list tensor references before leaving this materialization window. work contains slices of both full fused projections and survives until the next list assignment. Accelerate’s weight_access_and_writeback_context() writes back and replaces the parameters with meta tensors, but these slices keep the old GPU storage alive. The next module’s window opens before work is replaced, so non-layerwise GPTQ on consecutive offloaded expert modules holds the previous module’s gate/up and down weights while onloading the next. The old loop retained only its final projection slice; the list adds the entire gate/up storage to that overlap. Keep the updated count separately and clear/delete work inside the window after the updates. This also follows AGENTS.md’s rule not to retain onloaded weights past their layer; add an offload memory-lifetime regression.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 88de90b. gptq() counts the updates, then deletes work inside the window. That also releases the last down_proj slice the old loop variable kept alive.

The new CPU test test_gptq_drops_each_fused_experts_weights_before_the_next_window keeps weakrefs to every updated expert slice and checks they are dead when the next module's window opens. It fails without the del.

# Its weight slices would keep this module's onloaded weights alive past the window.
del work
if updated:
print_rank_0(
f"[{name}] GPTQ updated {updated} expert weights, {unreached} of them never "
Expand Down Expand Up @@ -2794,25 +2797,11 @@ def _encode_on_each_gpu(by_device, encode):
if device not in gpus:
for item in items:
encode(*item)
if not gpus:
return
# The first encode runs here, so lazy setup that is not thread-safe, such as loading a format's
# CUDA extension, happens once before the workers start.
first, *by_device[gpus[0]] = by_device[gpus[0]]
encode(*first)
inference = torch.is_inference_mode_enabled()

def worker(device):
# The device, grad mode and inference mode are per thread: take the caller's.
torch.cuda.set_device(device)
with torch.inference_mode() if inference else torch.no_grad():
for item in by_device[device]:
encode(*item)
torch.cuda.synchronize(device)

with ThreadPoolExecutor(len(gpus)) as pool:
for future in [pool.submit(worker, gpu) for gpu in gpus]:
future.result()
if gpus:
pending = {device: iter(by_device[device]) for device in gpus}
run_on_each_gpu(
gpus, lambda device: next(pending[device], None), lambda item, _: encode(*item)
)


def _run_weight_scale_calibration(model, forward_loop, scale_algorithm):
Expand Down
121 changes: 112 additions & 9 deletions modelopt/torch/quantization/utils/calib_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@
"""GPTQ helper and Hessian utilities for calibration."""

import functools
import queue
from concurrent.futures import ThreadPoolExecutor
from contextlib import contextmanager

import torch
Expand Down Expand Up @@ -106,13 +108,103 @@ def _hessian_offload_device(nbytes: int) -> torch.device:
rescale of the whole matrix. Only GPUs this process already holds memory on are candidates,
and only outside distributed runs, so no other job's or rank's GPU is touched.
"""
if not (dist.is_available() and dist.is_initialized() and dist.get_world_size() > 1):
for gpu in sorted(get_process_gpus(), key=get_available_gpu_memory, reverse=True):
if _gpu_has_room(gpu, nbytes):
return torch.device("cuda", gpu)
for gpu in sorted(_own_gpus(), key=get_available_gpu_memory, reverse=True):
if _gpu_has_room(gpu, nbytes):
return torch.device("cuda", gpu)
return torch.device("cpu")


def _own_gpus() -> list[int]:
"""GPUs this process already holds memory on; none in a multi-rank run, where they may be another rank's."""
if dist.is_available() and dist.is_initialized() and dist.get_world_size() > 1:
return []
return get_process_gpus()


def _update_room(helper, weight, block_size) -> int:
"""Bytes a GPU needs to run ``helper``'s update of ``weight``, as measured with the CUDA codecs.

The Hessian, the inverse and their temporaries; the float32 copy of the weight with a
dtype-conversion temporary (a projection much wider than its input makes the weight the larger
part); the block's errors and up to four temporaries of a group no wider than the block, each
as large as the weight when the input is no wider than a block; and the codec's tables and
scratch, about 35 MiB on a GPU's first update.
"""
columns = weight.shape[-1]
rows = weight.numel() // columns
block = min(block_size, columns)
return 2**26 + 4 * (8 * helper.hessian.numel() + rows * (2 * columns + 5 * block))


def update_expert_weights(work, block_size, perc_damp):

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.

Suggestion (non-blocking): this repeats the scaffolding of _encode_on_each_gpu in model_calib.py (imatrix): the first item runs in the caller's thread for lazy setup, inference mode is propagated to the workers, then one thread per GPU and a sync. Only the scheduling differs (shared queue here, per-GPU lists there). Could a small shared helper, say a queue-based run_on_gpus(items, fn, gpus), serve both, so the thread rules stay in one place?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Done in e3123f9. run_on_each_gpu(gpus, next_item, run) in calib_utils.py now holds the thread rules: the first item runs in the caller's thread for lazy setup, then one thread per GPU under the caller's grad and inference mode, then a sync. Both callers use it. _encode_on_each_gpu gives each GPU its own list, and update_expert_weights gives all GPUs one shared queue. next_item(device) covers both kinds of scheduling, so the helper takes no queue of its own. The diff is about neutral (+45/-44).

Both callers' 2-GPU tests pass on 2x RTX PRO 6000, with and without inference mode: test_imatrix_encodes_each_gpus_weights_at_once (including its check that the two GPUs' encodes overlap) and test_expert_updates_on_another_gpu_match_one_gpu. So do the PR's GPU test files (117) and the GPTQ/imatrix/fused-experts unit tests (250).

"""Run the GPTQ updates of ``(helper, weight)`` pairs, spread over this process's GPUs.

Expert updates are independent, and in a layerwise run of a model sharded over several GPUs
only the current layer's GPU works. Helpers whose update can run on any device run in one
worker thread per GPU this process uses that has room for an update, each writing its result

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.

Bot comment.

[P2] The CUDA-only restriction fixes the fallback scratch issue, but this bound still omits GPTQ's block/group buffers. For a supported (65536, 256) IQ2_XXS expert with block_size=256, this returns 194 MiB. In ggml/gptq.py:gptq_group_update, the float32 working weight, errs, decoded qdq, and triangular-solve result err are simultaneously live and each occupies 64 MiB: at least 256 MiB before Hessian/inverse, payloads, or codec scratch. Thus another process-owned GPU with 200 MiB available qualifies and can OOM, whereas the sequential update could succeed on the adequately provisioned weight GPU. This is reachable with only gate/up configured for GGML and down disabled, so another projection does not necessarily raise the maximum room. Could admission account for block_size and the format's group-sized buffers, or could gptq_group_update bound these temporaries first? Extend test_an_update_that_may_move_fits_its_room with this CUDA-encoder shape; no exact GPU-filling fixture is needed.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 3d90e69. _update_room now takes block_size and adds the block's errors plus up to four temporaries of a group no wider than the block: the decoded group, the residual, and the solve result and its copy. That is 2**26 + 4 * (8 * in^2 + out * (2 * in + 5 * min(block_size, in))).

Measured on 2x RTX PRO 6000 (peak allocated on the worker GPU, CUDA encoder):

Format, expert weight, block_size Peak Old room New room
IQ2_XXS 65536 x 256, 256 325 MiB 194 514
IQ1_S 65536 x 256, 256 324 MiB 194 514
IQ2_S 65536 x 256, 256 326 MiB 194 514
IQ2_XXS 16384 x 2048, 2048 361 MiB 448 1088
IQ2_XXS 28672 x 4096, 1024 879 MiB 1472 2032

Across 17 format, shape and block-size combinations (IQ1_S, IQ1_M, IQ2_XXS, IQ2_XS, IQ2_S, Q8_0; block sizes 256 to 2048), the peak is 29-63% of the new room.

test_an_update_that_may_move_fits_its_room now includes IQ2_XXS 65536 x 256 at block size 256. It fails without the block term (325 vs 194 MiB). The PR's GPU test files pass (117) on 2x RTX PRO 6000.

back to its weight; the others, or all of them when fewer than two GPUs qualify, run in turn on
their weight's device.
"""
movable = [(h, w) for h, w in work if w.is_cuda and h.updates_on_any_device()]
# A worker holds one expert at a time.
room = max((_update_room(h, w, block_size) for h, w in movable), default=0)
gpus = [torch.device("cuda", g) for g in _own_gpus() if get_available_gpu_memory(g) > room]
if len(gpus) < 2 or len(movable) < 2:
movable = []

def update(item, device):
helper, weight = item
helper.update_weights(block_size, perc_damp, weight, device=device)
helper.free()

moved = {id(helper) for helper, _ in movable}
for item in work:
if id(item[0]) not in moved:
update(item, None)
if not movable:
return

pending: queue.SimpleQueue = queue.SimpleQueue()
for item in movable:
pending.put(item)

def next_item(device):
try:
return pending.get_nowait()
except queue.Empty:
return None

run_on_each_gpu(gpus, next_item, update)
for device in {weight.device for _, weight in movable}:
torch.cuda.synchronize(device)


def run_on_each_gpu(gpus, next_item, run):
"""Call ``run(item, device)`` on the items ``next_item(device)`` hands out, one thread per GPU.

``next_item`` returns the next item for that GPU, or ``None`` when there is none left; each GPU's
items can be its own or come from a queue all of them share. The first item runs in the caller's
thread with ``device=None``, on the device its data is on, so lazy setup that is not thread-safe,
such as loading a format's CUDA extension, happens once before the workers start.
"""
first = next_item(gpus[0])
if first is not None:
run(first, None)
inference = torch.is_inference_mode_enabled()

def worker(device):
# The device, grad mode and inference mode are per thread: take the caller's.
torch.cuda.set_device(device)
with torch.inference_mode() if inference else torch.no_grad():
while (item := next_item(device)) is not None:
run(item, device)
torch.cuda.synchronize(device)

with ThreadPoolExecutor(len(gpus)) as pool:
for future in [pool.submit(worker, device) for device in gpus]:
future.result()


def update_hessian(input, hessian, n_samples, input_dtype=torch.float32):
"""Update hessian matrix with new input samples using incremental formula.

Expand Down Expand Up @@ -359,20 +451,31 @@ def free(self):
self.weight = None
self.h_inv = None

def update_weights(self, block_size, perc_damp, weight=None):
def updates_on_any_device(self) -> bool:
"""Whether :meth:`update_weights` may run on a device other than its weight's.

True only for helpers whose update reads no quantizer state, which lives on the weight's
device, and fits the room :func:`update_expert_weights` checks for.
"""
return False

def update_weights(self, block_size, perc_damp, weight=None, device=None):
"""Run GPTQ blockwise weight update on this helper's weight.

Populates ``self.weight`` and ``self.h_inv``, runs the blockwise update, logs MSE, and
overwrites ``weight`` (default ``module.weight``) in place, so an expert's slice of a
fused 3-D parameter is written back into that parameter.
fused 3-D parameter is written back into that parameter. The update runs on ``device``,
by default the weight's; only a helper whose :meth:`updates_on_any_device` is true may
pass another.
"""
weight = self.module.weight if weight is None else weight
hessian = self.hessian.to(weight.device)
self.weight = weight.data.float().clone()
device = weight.device if device is None else device
hessian = self.hessian.to(device)
self.weight = weight.data.to(device, torch.float32, copy=True)
self._prepare_hessian_inverse(hessian, perc_damp)
self._blockwise_update(block_size)
if self.log_mse:
self._print_mse_error(hessian, weight)
self._print_mse_error(hessian, weight.data.to(device))
weight.data.copy_(self.weight.reshape(weight.shape))

# ------------------------------------------------------------------
Expand Down
18 changes: 8 additions & 10 deletions tests/_test_utils/torch/quantization/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -174,15 +174,13 @@ def get_input(self, batch_size=2):
class ToyFusedExperts(nn.Module):
"""HF transformers 5 fused-experts layout: one ``F.linear`` pair per routed expert."""

def __init__(self):
def __init__(self, hidden=TOY_MOE_HIDDEN, intermediate=TOY_MOE_INTERMEDIATE):
super().__init__()
self.num_experts = TOY_MOE_EXPERTS
self.gate_up_proj = nn.Parameter(
torch.randn(TOY_MOE_EXPERTS, 2 * TOY_MOE_INTERMEDIATE, TOY_MOE_HIDDEN) * 0.02
)
self.down_proj = nn.Parameter(
torch.randn(TOY_MOE_EXPERTS, TOY_MOE_HIDDEN, TOY_MOE_INTERMEDIATE) * 0.02
torch.randn(TOY_MOE_EXPERTS, 2 * intermediate, hidden) * 0.02
)
self.down_proj = nn.Parameter(torch.randn(TOY_MOE_EXPERTS, hidden, intermediate) * 0.02)

def forward(self, hidden_states, expert_of_token):
output = torch.zeros_like(hidden_states)
Expand All @@ -198,20 +196,20 @@ def forward(self, hidden_states, expert_of_token):
class ToyMoEBlock(nn.Module):
"""Routes token ``t`` to expert ``t % 3``, so the last expert receives no token."""

def __init__(self):
def __init__(self, hidden=TOY_MOE_HIDDEN, intermediate=TOY_MOE_INTERMEDIATE):
super().__init__()
self.experts = ToyFusedExperts()
self.shared_expert = nn.Linear(TOY_MOE_HIDDEN, TOY_MOE_HIDDEN, bias=False)
self.experts = ToyFusedExperts(hidden, intermediate)
self.shared_expert = nn.Linear(hidden, hidden, bias=False)

def forward(self, hidden_states):
routed = self.experts(hidden_states, torch.arange(len(hidden_states)) % 3)
return routed + self.shared_expert(hidden_states)


class ToyMoE(nn.Module):
def __init__(self):
def __init__(self, hidden=TOY_MOE_HIDDEN, intermediate=TOY_MOE_INTERMEDIATE):
super().__init__()
self.mlp = ToyMoEBlock()
self.mlp = ToyMoEBlock(hidden, intermediate)

def forward(self, hidden_states):
return self.mlp(hidden_states)
Loading
Loading