Fix some errors for specific quants. Enable saving quantizations.

This commit is contained in:
Jaret Burkett
2026-07-14 07:25:09 -06:00
parent abba6b5845
commit cf0db39ede
3 changed files with 422 additions and 37 deletions

View File

@@ -48,7 +48,7 @@ VRAM_TOKENS = 4096
QTYPES = [
"bf16", "qfloat8", "float8", "orbit4", "orbitvq4", "convrot8", "convrot4",
"convrotint7", "convrotint6", "convrotint5", "convrotint4", "convrotint3",
"convrotint2", "convrotbitnet",
"convrotint2", "convrotbitnet", "convrotcomfyw4a4",
]
STACK_KEY = f"{VRAM_BLOCKS}-block stack"

View File

@@ -51,7 +51,7 @@ import torch.nn.functional as F
from toolkit.print import print_acc
from toolkit.util.ostris_quant import OstrisQuantizer
CONVROT_QTYPES = ("convrot4", "convrot8", "convrotbitnet") + tuple(
CONVROT_QTYPES = ("convrot4", "convrot8", "convrotbitnet", "convrotcomfyw4a4") + tuple(
f"convrotint{b}" for b in range(2, 9)
)
@@ -63,6 +63,8 @@ def get_convrot_quantizer(qtype: str):
return ConvRotInt8Quantizer(rot_size=256)
if qtype == "convrotbitnet":
return ConvRotBitNetQuantizer(rot_size=256)
if qtype == "convrotcomfyw4a4":
return ConvRotComfyW4A4Quantizer()
if qtype.startswith("convrotint"):
bits = int(qtype[len("convrotint"):])
if 2 <= bits <= 8:
@@ -334,7 +336,8 @@ def _get_kernel():
def _launch_nvfp4_kernel(x, packed, scales, pts, blocked_scales: bool):
rows, K = x.shape
n_col_tiles = -(-(K // BLOCK) // 4)
BLOCK_K = 2048 if K >= 2048 else K
# triton block shapes must be powers of 2; loads/stores are masked on offs < K
BLOCK_K = min(2048, 1 << (K - 1).bit_length())
grid = (rows, -(-K // BLOCK_K))
_get_kernel()[grid](
x, packed, scales, pts,
@@ -617,6 +620,28 @@ class ConvRotQuantizer(OstrisQuantizer):
def _pts(module) -> torch.Tensor:
return module.cr_pts.view(torch.float32).reshape(())
def _rot(self, module) -> int:
return module.cr_rot_size
def fake_quant_rotated_weight(self, module, w_rot: torch.Tensor) -> torch.Tensor:
"""dequant(quant(w_rot)) on the deployed e2m1 grid with the module's STORED
e4m3 block scales (no scale re-optimization) — the value half of the QAT
straight-through estimator. Returns float32."""
rows, K = w_rot.shape
s = module.cr_scales.view(torch.float8_e4m3fn).float() * self._pts(module)
denom = s.unsqueeze(-1)
safe = torch.where(denom > 0, denom, torch.ones_like(denom))
z = (w_rot.float().view(rows, K // BLOCK, BLOCK) / safe).clamp(-F4_MAX, F4_MAX)
edges = _cached(
_edges_cache, str(w_rot.device),
lambda: torch.tensor(_E2M1_EDGES, device=w_rot.device),
)
vals = torch.tensor(
[0.0, 0.5, 1.0, 1.5, 2.0, 3.0, 4.0, 6.0], device=w_rot.device
)
recon = vals[torch.bucketize(z.abs(), edges)] * torch.sign(z) * safe
return recon.view(rows, K)
def _dequantize_rotated(self, module, dtype: torch.dtype) -> torch.Tensor:
return dequantize_nvfp4(
module.cr_qdata,
@@ -631,6 +656,27 @@ class ConvRotQuantizer(OstrisQuantizer):
w = self._dequantize_rotated(module, torch.float32)
return rotate(w, module.cr_rot_size) # self-inverse
@torch.no_grad()
def requantize_codes_(self, module, fp_weight: torch.Tensor) -> None:
"""Re-quantize only the codes on the module's STORED scales — the grid a
QAT run trains against. Re-optimizing the scales here would re-grid every
weight and destroy the code adjustments training made."""
w = fp_weight.to(device=module.cr_qdata.device, dtype=torch.float32)
w_rot = rotate(w, module.cr_rot_size)
rows, K = w_rot.shape
s = module.cr_scales.view(torch.float8_e4m3fn).float() * self._pts(module)
denom = s.unsqueeze(-1)
safe = torch.where(denom > 0, denom, torch.ones_like(denom))
z = (w_rot.view(rows, K // BLOCK, BLOCK) / safe).clamp(-F4_MAX, F4_MAX)
edges = _cached(
_edges_cache, str(w_rot.device),
lambda: torch.tensor(_E2M1_EDGES, device=w_rot.device),
)
z = z.reshape(rows, K)
mag = torch.bucketize(z.abs(), edges).to(torch.uint8)
codes = mag | ((z < 0).to(torch.uint8) << 3)
module.cr_qdata = ((codes[:, 1::2] << 4) | codes[:, ::2]).contiguous()
def requantize_(self, module, fp_weight: torch.Tensor) -> None:
w = fp_weight.to(device=module.cr_qdata.device, dtype=torch.float32)
w_rot = rotate(w, module.cr_rot_size)
@@ -704,12 +750,13 @@ class ConvRotQuantizer(OstrisQuantizer):
# ---------------- convrot8: W8A8 int8 backend ----------------
def quantize_int8_rows(x: torch.Tensor):
"""Symmetric per-row int8 quantization. Returns (int8 (rows, K), fp32 scales (rows,))."""
def quantize_int8_rows(x: torch.Tensor, qmax: int = 127):
"""Symmetric per-row integer quantization to [-qmax, qmax] (int8 storage).
Returns (int8 (rows, K), fp32 scales (rows,))."""
xf = x.float()
scales = xf.abs().amax(dim=1) / 127.0
scales = xf.abs().amax(dim=1) / qmax
scales = torch.where(scales > 0, scales, torch.ones_like(scales))
q = torch.round(xf / scales.unsqueeze(1)).clamp_(-127, 127).to(torch.int8)
q = torch.round(xf / scales.unsqueeze(1)).clamp_(-qmax, qmax).to(torch.int8)
return q, scales
@@ -725,7 +772,9 @@ def _get_int8_kernels():
from triton.language.extra import libdevice
@triton.jit
def int8_act_quant_kernel(x_ptr, q_ptr, s_ptr, K, BLOCK_K: tl.constexpr):
def int8_act_quant_kernel(
x_ptr, q_ptr, s_ptr, K, QMAX: tl.constexpr, BLOCK_K: tl.constexpr
):
row = tl.program_id(0)
base = row * K
acc = tl.zeros((BLOCK_K,), tl.float32)
@@ -734,14 +783,14 @@ def _get_int8_kernels():
v = tl.load(x_ptr + base + offs, mask=offs < K, other=0.0).to(tl.float32)
acc = tl.maximum(acc, tl.abs(v))
amax = tl.max(acc, axis=0)
scale = tl.where(amax > 0, amax / 127.0, 1.0)
scale = tl.where(amax > 0, amax / QMAX, 1.0)
for k0 in range(0, K, BLOCK_K):
offs = k0 + tl.arange(0, BLOCK_K)
mask = offs < K
v = tl.load(x_ptr + base + offs, mask=mask, other=0.0).to(tl.float32)
# rint = round-half-to-even, matching torch.round in the reference path
q = libdevice.rint(v / scale)
q = tl.minimum(tl.maximum(q, -127.0), 127.0)
q = tl.minimum(tl.maximum(q, -1.0 * QMAX), 1.0 * QMAX)
tl.store(q_ptr + base + offs, q.to(tl.int8), mask=mask)
tl.store(s_ptr + row, scale)
@@ -776,7 +825,7 @@ def _get_int8_kernels():
# nodes with known output shapes (see _nvfp4_act_quant_op). rows are padded to a
# multiple of 32 inside the op for torch._int_mm; callers slice the mm output.
@torch.library.custom_op("ostris::convrot_int8_act_quant", mutates_args=())
def _int8_act_quant_op(x: torch.Tensor) -> list[torch.Tensor]:
def _int8_act_quant_op(x: torch.Tensor, qmax: int) -> list[torch.Tensor]:
rows, K = x.shape
rows_pad = -(-rows // 32) * 32
x = x.contiguous()
@@ -786,12 +835,16 @@ def _int8_act_quant_op(x: torch.Tensor) -> list[torch.Tensor]:
q[rows:].zero_()
scales[rows:].fill_(1.0)
kernel, _ = _get_int8_kernels()
kernel[(rows,)](x, q, scales, K, BLOCK_K=2048 if K >= 2048 else K, num_warps=8)
# triton block shapes must be powers of 2; loads/stores are masked on offs < K
block_k = min(2048, 1 << (K - 1).bit_length())
kernel[(rows,)](
x, q, scales, K, QMAX=qmax, BLOCK_K=block_k, num_warps=8
)
return [q, scales]
@_int8_act_quant_op.register_fake
def _int8_act_quant_fake(x):
def _int8_act_quant_fake(x, qmax):
rows, K = x.shape
rows_pad = -(-rows // 32) * 32
return [
@@ -832,22 +885,22 @@ def _int8_epilogue_fake(i32, a_scales, w_scales, bias, out_dtype):
return torch.empty(m, n, device=i32.device, dtype=getattr(torch, out_dtype))
def quantize_int8_rows_fused(x: torch.Tensor):
def quantize_int8_rows_fused(x: torch.Tensor, qmax: int = 127):
"""Triton path of quantize_int8_rows: one extra read of x instead of the
multi-kernel torch chain. Falls back to the torch ops."""
if not (_triton_available() and x.is_cuda):
return quantize_int8_rows(x)
q, scales = _int8_act_quant_op(x)
return quantize_int8_rows(x, qmax)
q, scales = _int8_act_quant_op(x, qmax)
rows = x.shape[0]
return q[:rows], scales[:rows]
def _int8_act_quant_padded(x: torch.Tensor):
def _int8_act_quant_padded(x: torch.Tensor, qmax: int = 127):
"""Act quant with rows padded to a multiple of 32 for torch._int_mm."""
if _triton_available() and x.is_cuda:
q, scales = _int8_act_quant_op(x)
q, scales = _int8_act_quant_op(x, qmax)
return q, scales
q, scales = quantize_int8_rows(x)
q, scales = quantize_int8_rows(x, qmax)
rows = q.shape[0]
rows_pad = -(-rows // 32) * 32
if rows_pad != rows:
@@ -867,10 +920,11 @@ def _int8_linear_ste_op(
qdata: torch.Tensor,
w_scales_u8: torch.Tensor,
bias: Optional[torch.Tensor],
act_qmax: int,
out_dtype: str,
) -> torch.Tensor:
m = x2d.shape[0]
aq, a_s = _int8_act_quant_padded(x2d)
aq, a_s = _int8_act_quant_padded(x2d, act_qmax)
i32 = torch._int_mm(aq, qdata.t())
return _int8_epilogue(
i32[:m], a_s[:m], w_scales_u8.view(torch.float32), bias,
@@ -879,14 +933,14 @@ def _int8_linear_ste_op(
@_int8_linear_ste_op.register_fake
def _int8_linear_ste_fake(x2d, qdata, w_scales_u8, bias, out_dtype):
def _int8_linear_ste_fake(x2d, qdata, w_scales_u8, bias, act_qmax, out_dtype):
return torch.empty(
x2d.shape[0], qdata.shape[0], device=x2d.device, dtype=getattr(torch, out_dtype)
)
def _int8_linear_ste_setup(ctx, inputs, output):
x2d, qdata, w_scales_u8, bias, out_dtype = inputs
x2d, qdata, w_scales_u8, bias, act_qmax, out_dtype = inputs
ctx.save_for_backward(qdata, w_scales_u8)
@@ -894,7 +948,7 @@ def _int8_linear_ste_backward(ctx, grad):
qdata, w_scales_u8 = ctx.saved_tensors
w_scales = w_scales_u8.view(torch.float32).to(grad.dtype)
w = qdata.to(grad.dtype) * w_scales.unsqueeze(1)
return grad @ w, None, None, None, None
return grad @ w, None, None, None, None, None
_int8_linear_ste_op.register_autograd(
@@ -960,6 +1014,10 @@ class ConvRotInt8Quantizer(OstrisQuantizer):
per-output-channel symmetric int8 with torch._int_mm. One instance per qtype,
shareable across modules."""
# activation quantization range (per-token symmetric [-act_qmax, act_qmax]);
# the comfy w4a4 subclass narrows this to 7
act_qmax = 127
def __init__(self, rot_size: int = 256):
self.rot_size = rot_size
@@ -1006,9 +1064,17 @@ class ConvRotInt8Quantizer(OstrisQuantizer):
"""Hardware STE linear for the training path. For int8 the saved qdata is
the resident buffer itself, so autograd holds only a free reference."""
return _int8_linear_ste_op(
x2d, self._qdata(module), self._scales_u8(module), module.bias, out_dtype
x2d, self._qdata(module), self._scales_u8(module), module.bias,
self.act_qmax, out_dtype,
)
def fake_quant_rotated_weight(self, module, w_rot: torch.Tensor) -> torch.Tensor:
"""dequant(quant(w_rot)) on the deployed int8 grid with the module's STORED
per-row scales — the value half of the QAT straight-through estimator.
Returns float32."""
s = self._scales(module).unsqueeze(1)
return torch.round(w_rot.float() / s).clamp_(-127, 127) * s
def _dequantize_rotated(self, module, dtype: torch.dtype) -> torch.Tensor:
w = self._qdata(module).float() * self._scales(module).unsqueeze(1)
return w.to(dtype)
@@ -1024,6 +1090,15 @@ class ConvRotInt8Quantizer(OstrisQuantizer):
module.cr8_qdata = q
module.cr8_scales = scales.view(torch.uint8)
@torch.no_grad()
def requantize_codes_(self, module, fp_weight: torch.Tensor) -> None:
"""Re-quantize only the codes on the module's STORED scales — the grid a
QAT run trains against (see ConvRotQuantizer.requantize_codes_)."""
w = fp_weight.to(device=module.cr8_qdata.device, dtype=torch.float32)
w_rot = rotate(w, self._rot(module))
s = self._scales(module).unsqueeze(1)
module.cr8_qdata = torch.round(w_rot / s).clamp_(-127, 127).to(torch.int8)
def forward(self, module, x: torch.Tensor) -> torch.Tensor:
rot = self._rot(module)
in_f, out_f = module.in_features, module.out_features
@@ -1047,7 +1122,7 @@ class ConvRotInt8Quantizer(OstrisQuantizer):
# no int8 hardware: straight-through fake-quant + bf16 matmul
x2d = rotate(x, rot).reshape(-1, in_f)
with torch.no_grad():
aq, a_s = quantize_int8_rows(x2d.detach())
aq, a_s = quantize_int8_rows(x2d.detach(), self.act_qmax)
x_dq = (aq.float() * a_s.unsqueeze(1)).to(x.dtype)
w = self._dequantize_rotated(module, x.dtype)
x_ste = x2d + (x_dq - x2d).detach()
@@ -1057,7 +1132,9 @@ class ConvRotInt8Quantizer(OstrisQuantizer):
if _int8_gemm_supported(x.device):
# row padding for _int_mm happens inside the act-quant op (compile
# safety); slice the mm output back to m rows (a contiguous prefix)
aq, a_s = _int8_act_quant_padded(rotate(x, rot).reshape(-1, in_f))
aq, a_s = _int8_act_quant_padded(
rotate(x, rot).reshape(-1, in_f), self.act_qmax
)
i32 = torch._int_mm(aq, self._qdata(module).t())
out = _int8_epilogue(i32[:m], a_s[:m], self._scales(module), module.bias, x.dtype)
return out.reshape(*x.shape[:-1], out_f)
@@ -1413,12 +1490,13 @@ def _intn_linear_ste_op(
w_scales_u8: torch.Tensor,
bias: Optional[torch.Tensor],
bits: int,
act_qmax: int,
out_dtype: str,
) -> torch.Tensor:
m, K = x2d.shape
rows = w_scales_u8.view(torch.float32).numel()
qdata = _unpack_intn_grouped_impl(packed, gratio, bits, rows, K)
aq, a_s = _int8_act_quant_padded(x2d)
aq, a_s = _int8_act_quant_padded(x2d, act_qmax)
i32 = torch._int_mm(aq, qdata.t())
return _int8_epilogue(
i32[:m], a_s[:m], w_scales_u8.view(torch.float32), bias,
@@ -1427,7 +1505,7 @@ def _intn_linear_ste_op(
@_intn_linear_ste_op.register_fake
def _intn_linear_ste_fake(x2d, packed, gratio, w_scales_u8, bias, bits, out_dtype):
def _intn_linear_ste_fake(x2d, packed, gratio, w_scales_u8, bias, bits, act_qmax, out_dtype):
return torch.empty(
x2d.shape[0], w_scales_u8.view(torch.float32).numel(),
device=x2d.device, dtype=getattr(torch, out_dtype),
@@ -1435,7 +1513,7 @@ def _intn_linear_ste_fake(x2d, packed, gratio, w_scales_u8, bias, bits, out_dtyp
def _intn_linear_ste_setup(ctx, inputs, output):
x2d, packed, gratio, w_scales_u8, bias, bits, out_dtype = inputs
x2d, packed, gratio, w_scales_u8, bias, bits, act_qmax, out_dtype = inputs
ctx.bits = bits
ctx.cols = x2d.shape[1]
ctx.save_for_backward(packed, gratio, w_scales_u8)
@@ -1450,7 +1528,7 @@ def _intn_linear_ste_backward(ctx, grad):
packed, gratio, ctx.bits, w_scales.numel(), ctx.cols
)
w = qdata.to(grad.dtype) * w_scales.to(grad.dtype).unsqueeze(1)
return grad @ w, None, None, None, None, None, None
return grad @ w, None, None, None, None, None, None, None
_intn_linear_ste_op.register_autograd(
@@ -1495,12 +1573,13 @@ def _bitnet_linear_ste_op(
gratio: torch.Tensor,
w_scales_u8: torch.Tensor,
bias: Optional[torch.Tensor],
act_qmax: int,
out_dtype: str,
) -> torch.Tensor:
m, K = x2d.shape
rows = w_scales_u8.view(torch.float32).numel()
qdata = _unpack_bitnet_impl(packed, gratio, rows, K)
aq, a_s = _int8_act_quant_padded(x2d)
aq, a_s = _int8_act_quant_padded(x2d, act_qmax)
i32 = torch._int_mm(aq, qdata.t())
return _int8_epilogue(
i32[:m], a_s[:m], w_scales_u8.view(torch.float32), bias,
@@ -1509,7 +1588,7 @@ def _bitnet_linear_ste_op(
@_bitnet_linear_ste_op.register_fake
def _bitnet_linear_ste_fake(x2d, packed, gratio, w_scales_u8, bias, out_dtype):
def _bitnet_linear_ste_fake(x2d, packed, gratio, w_scales_u8, bias, act_qmax, out_dtype):
return torch.empty(
x2d.shape[0], w_scales_u8.view(torch.float32).numel(),
device=x2d.device, dtype=getattr(torch, out_dtype),
@@ -1517,7 +1596,7 @@ def _bitnet_linear_ste_fake(x2d, packed, gratio, w_scales_u8, bias, out_dtype):
def _bitnet_linear_ste_setup(ctx, inputs, output):
x2d, packed, gratio, w_scales_u8, bias, out_dtype = inputs
x2d, packed, gratio, w_scales_u8, bias, act_qmax, out_dtype = inputs
ctx.cols = x2d.shape[1]
ctx.save_for_backward(packed, gratio, w_scales_u8)
@@ -1529,7 +1608,7 @@ def _bitnet_linear_ste_backward(ctx, grad):
# fake tensors
qdata = _bitnet_unpack_op(packed, gratio, w_scales.numel(), ctx.cols)
w = qdata.to(grad.dtype) * w_scales.to(grad.dtype).unsqueeze(1)
return grad @ w, None, None, None, None, None
return grad @ w, None, None, None, None, None, None
_bitnet_linear_ste_op.register_autograd(
@@ -1608,7 +1687,7 @@ class ConvRotIntNQuantizer(ConvRotInt8Quantizer):
# in the backward
return _intn_linear_ste_op(
x2d, module.crn_qdata, gratio.view(torch.float32),
module.crn_scales, module.bias, module.crn_bits, out_dtype,
module.crn_scales, module.bias, module.crn_bits, self.act_qmax, out_dtype,
)
def requantize_(self, module, fp_weight: torch.Tensor) -> None:
@@ -1621,6 +1700,46 @@ class ConvRotIntNQuantizer(ConvRotInt8Quantizer):
if gratio is not None:
module.crn_gratio = gratio.view(torch.uint8)
def _codes_on_stored_scales(self, module, w_rot: torch.Tensor) -> torch.Tensor:
"""n-bit codes of a rotated weight on the module's stored scales (fp32)."""
qmax = (1 << (module.crn_bits - 1)) - 1
rs = self._scales(module).unsqueeze(1)
wf = w_rot.float()
gratio = getattr(module, "crn_gratio", None)
if gratio is None:
return torch.round(wf / rs).clamp_(-qmax, qmax)
gratio = gratio.view(torch.float32)
group = module.in_features // gratio.shape[1]
gs = gratio.repeat_interleave(group, dim=1) * rs
safe = torch.where(gs > 0, gs, torch.ones_like(gs))
return torch.round(wf / safe).clamp_(-qmax, qmax)
def fake_quant_rotated_weight(self, module, w_rot: torch.Tensor) -> torch.Tensor:
"""Like the int8 version but on the n-bit grid: quantize with the stored
group scales, then re-express on the row int8 grid exactly like the
deployed unpack does. Returns float32."""
rs = self._scales(module).unsqueeze(1)
codes = self._codes_on_stored_scales(module, w_rot)
gratio = getattr(module, "crn_gratio", None)
if gratio is None:
return codes * rs
gratio = gratio.view(torch.float32)
group = module.in_features // gratio.shape[1]
ratio = gratio.repeat_interleave(group, dim=1)
code8 = torch.round(codes * ratio).clamp_(-127, 127)
return code8 * rs
def _pack(self, q: torch.Tensor) -> torch.Tensor:
return pack_intn_rows(q, self.bits)
@torch.no_grad()
def requantize_codes_(self, module, fp_weight: torch.Tensor) -> None:
"""Re-quantize only the codes on the module's STORED scales — the grid a
QAT run trains against (see ConvRotQuantizer.requantize_codes_)."""
w = fp_weight.to(device=module.crn_qdata.device, dtype=torch.float32)
codes = self._codes_on_stored_scales(module, rotate(w, self._rot(module)))
module.crn_qdata = self._pack(codes.to(torch.int8))
class ConvRotBitNetQuantizer(ConvRotIntNQuantizer):
"""BitNet-b1.58 style W1.58A8: convrotint2's exact ternary codes and
@@ -1636,6 +1755,9 @@ class ConvRotBitNetQuantizer(ConvRotIntNQuantizer):
gratio, rscales = _intn_group_ratio_and_rscales(gscales, self.bits)
return pack_ternary_rows(q), rscales, gratio.contiguous()
def _pack(self, q: torch.Tensor) -> torch.Tensor:
return pack_ternary_rows(q)
def _qdata(self, module) -> torch.Tensor:
return _bitnet_unpack_op(
module.crn_qdata, module.crn_gratio.view(torch.float32),
@@ -1645,6 +1767,126 @@ class ConvRotBitNetQuantizer(ConvRotIntNQuantizer):
def _linear_ste(self, module, x2d: torch.Tensor, out_dtype: str) -> torch.Tensor:
return _bitnet_linear_ste_op(
x2d, module.crn_qdata, module.crn_gratio.view(torch.float32),
module.crn_scales, module.bias, out_dtype,
module.crn_scales, module.bias, self.act_qmax, out_dtype,
)
# ---------------- convrotcomfyw4a4: ComfyUI convrot_w4a4 compatible --------------
#
# Matches comfy_kitchen's TensorCoreConvRotW4A4Layout numerics exactly so a
# trained model exports to a checkpoint ComfyUI loads and runs natively:
# - regular-Hadamard rotation, group size fixed at 256 (same R4 kronecker
# matrix as the other backends; comfy rejects other sizes)
# - weights: symmetric per-row int4, scale = row absmax / 7, RTN clamp [-7, 7]
# - activations: per-token int4 the same way (act_qmax = 7)
# Storage here stays our bitpacked layout (bits=4); export_comfy_convrot_w4a4
# repacks to comfy's nibble pairs and writes weight/weight_scale/comfy_quant
# keys. Integer GEMM results are identical (int4 values through _int_mm
# accumulate exactly like their int4 MMA).
class ConvRotComfyW4A4Quantizer(ConvRotIntNQuantizer):
"""ComfyUI-compatible ConvRot W4A4 (see block comment above)."""
act_qmax = 7
COMFY_GROUPSIZE = 256
def __init__(self, rot_size: int = 256):
super().__init__(bits=4, rot_size=self.COMFY_GROUPSIZE)
def _rot_for(self, d: int) -> int:
return self.COMFY_GROUPSIZE
def can_quantize(self, module: torch.nn.Linear) -> bool:
d = module.in_features
if d % self.COMFY_GROUPSIZE != 0 or module.out_features % 8 != 0:
if d not in _skip_warned:
_skip_warned.add(d)
print_acc(
f"ConvRot(comfy): skipping linears with in_features={d} "
f"(comfy convrot_w4a4 needs in divisible by {self.COMFY_GROUPSIZE}, "
f"out by 8)"
)
return False
return True
def _quantize_rotated(self, w_rot: torch.Tensor, in_features: int):
# their format only fixes the DECODE (w = code * row_scale), not how the
# codes/scales are chosen — so use MSE-optimal per-row scales (sweep +
# LS refit with one group spanning the whole row) instead of their
# amax/7. group scales can't be expressed: folding 4-bit group grids
# onto the 4-bit row grid has no headroom (same inversion as the 8-bit
# fold), so per-row optimal is the ceiling of the format
q, gscales = _optimal_group_quantize(w_rot, self.bits, w_rot.shape[1])
return pack_intn_rows(q, self.bits), gscales.reshape(-1), None
def export_comfy_convrot_w4a4(module, prefix: str) -> dict:
"""State-dict entries for one convrotcomfyw4a4-quantized OstrisLinear in
ComfyUI's checkpoint format: packed int4 weight (low nibble = even column),
fp32 per-row weight_scale, and the comfy_quant JSON marker."""
import json
q = module.ostris_quantizer
if not isinstance(q, ConvRotComfyW4A4Quantizer):
raise ValueError(
f"export_comfy_convrot_w4a4 needs a convrotcomfyw4a4 module, got "
f"qtype '{getattr(q, 'qtype', None)}'"
)
codes = _unpack_intn_impl(
module.crn_qdata, module.crn_bits, module.out_features, module.in_features
)
lo = codes[:, 0::2].to(torch.int32) & 0x0F
hi = codes[:, 1::2].to(torch.int32) & 0x0F
packed = (lo | (hi << 4)).to(torch.int8)
conf = {
"format": "convrot_w4a4",
"convrot_groupsize": module.crn_rot_size,
}
entries = {
f"{prefix}weight": packed.contiguous(),
f"{prefix}weight_scale": q._scales(module).contiguous(),
f"{prefix}comfy_quant": torch.tensor(
list(json.dumps(conf).encode("utf-8")), dtype=torch.uint8
),
}
if module.bias is not None:
entries[f"{prefix}bias"] = module.bias.detach()
return entries
# ---------------- QAT: train the quantized weights themselves ----------------
def convrot_qat_forward(module, x: torch.Tensor) -> torch.Tensor:
"""Quantization-aware training forward for an OstrisLinear with a convrot
backend and a trainable full-precision `qat_master` parameter attached.
The weight enters the matmul through a straight-through fake-quant on the
DEPLOYED grid using the module's stored scales (refresh them by calling
module.requantize_(master) periodically), so the forward numerics track what
the saved quantized model will compute while gradients flow to the master.
Activations are fake-quanted like the no-hardware training path, also with a
straight-through estimate, so gradients pass through to the input when it
carries grad (end-to-end training; layer-local distillation feeds detached
inputs and the act STE is then value-identical)."""
q = module.ostris_quantizer
rot = q._rot(module)
in_f, out_f = module.in_features, module.out_features
w_rot = rotate(module.qat_master.to(x.dtype), rot)
with torch.no_grad():
w_fq = q.fake_quant_rotated_weight(module, w_rot.detach()).to(x.dtype)
w_ste = w_rot + (w_fq - w_rot).detach()
x2d = rotate(x, rot).reshape(-1, in_f)
with torch.no_grad():
if isinstance(q, ConvRotQuantizer):
pts1 = torch.ones((), device=x.device, dtype=torch.float32)
aq, a_scales, _ = quantize_nvfp4(x2d.detach(), pts=pts1)
x_dq = dequantize_nvfp4(aq, a_scales, pts1, x2d.shape[0], in_f, x.dtype)
else:
aq, a_scales = quantize_int8_rows(x2d.detach(), q.act_qmax)
x_dq = (aq.float() * a_scales.unsqueeze(1)).to(x.dtype)
x_ste = x2d + (x_dq - x2d).detach()
out = F.linear(x_ste, w_ste, module.bias)
return out.reshape(*x.shape[:-1], out_f)

View File

@@ -29,7 +29,7 @@ class OstrisQuantizer:
"""
# the qtype string this instance was resolved from (stamped by
# get_ostris_quantizer); pre-quantized saves need it to restore the backend
# get_ostris_quantizer); quantized saves need it to restore the backend
qtype: Optional[str] = None
def can_quantize(self, module: torch.nn.Linear) -> bool:
@@ -60,6 +60,9 @@ class OstrisQuantizer:
return F.linear(x, w, module.bias)
_wrong_device_warned = False
class OstrisLinear(torch.nn.Linear):
"""A linear layer whose weight is quantized by an OstrisQuantizer backend.
@@ -87,6 +90,24 @@ class OstrisLinear(torch.nn.Linear):
return w
def forward(self, x: torch.Tensor) -> torch.Tensor:
if x.is_cuda and not hasattr(self, "_layer_memory_manager"):
# a module left behind on the wrong device (usually cpu after a
# low_vram load) would run its dequant/matmul on cpu threads and
# silently hammer the cpu. move it to the input's device once.
# memory-managed modules are excluded: they keep cpu buffers by
# design and stage them to the gpu per forward.
buf = next((b for b in self._buffers.values() if b is not None), None)
if buf is not None and buf.device != x.device:
global _wrong_device_warned
if not _wrong_device_warned:
_wrong_device_warned = True
from toolkit.print import print_acc
print_acc(
f"OstrisLinear: quantized weights found on {buf.device} while the "
f"input is on {x.device}; moving them to {x.device}. This usually "
f"means something left the model behind after a low_vram load."
)
self.to(x.device)
return self.ostris_quantizer.forward(self, x)
@torch.no_grad()
@@ -117,10 +138,132 @@ def get_ostris_quantizer(qtype: str) -> Optional[OstrisQuantizer]:
elif qtype in CONVROT_QTYPES:
quantizer = get_convrot_quantizer(qtype)
if quantizer is not None:
# quantized saves read this back to restore the backend on load
quantizer.qtype = qtype
return quantizer
# metadata key shared with toolkit/models/classes/_mixin.py save_quantized
QUANT_LAYERS_METADATA_KEY = "aitk_quantization"
@torch.no_grad()
def save_quantized_layers(
modules: Dict[str, "OstrisLinear"],
file_path: str,
metadata: Optional[Dict[str, str]] = None,
extra_tensors: Optional[Dict[str, torch.Tensor]] = None,
) -> None:
"""Save a set of quantized linears (keyed by their submodule path in the
target model) as a single safetensors file: backend buffers + bias per
module, with the qtype/attrs needed to restore them recorded in the file
metadata. Same layout as OstrisModelMixin.save_quantized, but partial —
apply with load_quantized_layers."""
import json
from safetensors.torch import save_file
quant_map = {}
state_dict = {}
for name, module in modules.items():
if module.ostris_quantizer.qtype is None:
raise ValueError(
f"Cannot save quantized module '{name}': its quantizer has no "
"qtype recorded (was it created through get_ostris_quantizer?)."
)
entry = {
"qtype": module.ostris_quantizer.qtype,
"dtype": str(module.ostris_orig_dtype).replace("torch.", ""),
"buffers": [],
"attrs": {},
}
if module.bias is not None:
state_dict[f"{name}.bias"] = module.bias
for buf_name, buf in module._buffers.items():
if buf is None:
continue
state_dict[f"{name}.{buf_name}"] = buf
entry["buffers"].append(buf_name)
for attr, value in vars(module).items():
if attr.startswith("_") or attr in (
"training", "in_features", "out_features",
"ostris_quantizer", "ostris_orig_dtype",
):
continue
if isinstance(value, (bool, int, float, str)):
entry["attrs"][attr] = value
quant_map[name] = entry
if extra_tensors:
# training-state extras (e.g. QAT master weights); load_quantized_layers
# ignores them, so deployment loads are unaffected
state_dict.update(extra_tensors)
state_dict = {
k: v.detach().to("cpu", copy=True).contiguous() for k, v in state_dict.items()
}
meta = dict(metadata or {})
meta[QUANT_LAYERS_METADATA_KEY] = json.dumps(
{"modules": quant_map, "layers_only": True}
)
save_file(state_dict, file_path, metadata=meta)
@torch.no_grad()
def load_quantized_layers(root: torch.nn.Module, file_path: str) -> int:
"""Apply a file written by save_quantized_layers onto a model: restores the
backend buffers (and bias) of each recorded module. Target modules may
already be OstrisLinear (model quantized on load — buffers are replaced) or
still plain nn.Linear (converted in place, no quantization math needed).
Returns the number of modules restored."""
import json
from safetensors import safe_open
from safetensors.torch import load_file
with safe_open(file_path, framework="pt", device="cpu") as f:
meta = f.metadata() or {}
if QUANT_LAYERS_METADATA_KEY not in meta:
raise ValueError(f"{file_path} has no quantized-layer metadata")
quant_map = json.loads(meta[QUANT_LAYERS_METADATA_KEY])["modules"]
state_dict = load_file(file_path)
for name, entry in quant_map.items():
module = root.get_submodule(name)
quantizer = get_ostris_quantizer(entry["qtype"])
if quantizer is None:
raise ValueError(f"Unknown qtype '{entry['qtype']}' in {file_path}")
# figure out the device the module currently lives on
ref = next(
(t for t in module._parameters.values() if t is not None),
next((t for t in module._buffers.values() if t is not None), None),
)
device = ref.device if ref is not None else torch.device("cpu")
if isinstance(module, OstrisLinear):
# replacing an existing quantized state (possibly another backend):
# its buffers are exclusively backend state, drop them all
module._buffers.clear()
else:
if "weight" in module._parameters:
del module._parameters["weight"]
module.ostris_orig_dtype = getattr(torch, entry["dtype"])
module.__class__ = OstrisLinear
for buf_name in entry["buffers"]:
module.register_buffer(
buf_name,
state_dict.pop(f"{name}.{buf_name}").to(device),
persistent=False,
)
for attr, value in entry.get("attrs", {}).items():
setattr(module, attr, value)
module.ostris_quantizer = quantizer
bias_key = f"{name}.bias"
if bias_key in state_dict and module.bias is not None:
module.bias.data.copy_(
state_dict.pop(bias_key).to(device, module.bias.dtype)
)
if module.bias is not None:
module.bias.requires_grad_(False)
return len(quant_map)
@torch.no_grad()
def convert_linear_to_ostris(
module: torch.nn.Linear, quantizer: OstrisQuantizer