Fix some errors for specific quants. Enable saving quantizations.

This commit is contained in:
Jaret Burkett committed 2026-07-14 07:25:09 -06:00
1 parent abba6b5845
commit cf0db39ede
3 files changed
+422 -37

No files matched your search

+1 -1
View File
@@ -48,7 +48,7 @@ VRAM_TOKENS = 4096
QTYPES = [ QTYPES = [
"bf16", "qfloat8", "float8", "orbit4", "orbitvq4", "convrot8", "convrot4", "bf16", "qfloat8", "float8", "orbit4", "orbitvq4", "convrot8", "convrot4",
"convrotint7", "convrotint6", "convrotint5", "convrotint4", "convrotint3", "convrotint7", "convrotint6", "convrotint5", "convrotint4", "convrotint3",
"convrotint2", "convrotbitnet", "convrotint2", "convrotbitnet", "convrotcomfyw4a4",
] ]
STACK_KEY = f"{VRAM_BLOCKS}-block stack" STACK_KEY = f"{VRAM_BLOCKS}-block stack"
+277 -35
View File
@@ -51,7 +51,7 @@ import torch.nn.functional as F
from toolkit.print import print_acc from toolkit.print import print_acc
from toolkit.util.ostris_quant import OstrisQuantizer 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) f"convrotint{b}" for b in range(2, 9)
) )
@@ -63,6 +63,8 @@ def get_convrot_quantizer(qtype: str):
return ConvRotInt8Quantizer(rot_size=256) return ConvRotInt8Quantizer(rot_size=256)
if qtype == "convrotbitnet": if qtype == "convrotbitnet":
return ConvRotBitNetQuantizer(rot_size=256) return ConvRotBitNetQuantizer(rot_size=256)
if qtype == "convrotcomfyw4a4":
return ConvRotComfyW4A4Quantizer()
if qtype.startswith("convrotint"): if qtype.startswith("convrotint"):
bits = int(qtype[len("convrotint"):]) bits = int(qtype[len("convrotint"):])
if 2 <= bits <= 8: if 2 <= bits <= 8:
@@ -334,7 +336,8 @@ def _get_kernel():
def _launch_nvfp4_kernel(x, packed, scales, pts, blocked_scales: bool): def _launch_nvfp4_kernel(x, packed, scales, pts, blocked_scales: bool):
rows, K = x.shape rows, K = x.shape
n_col_tiles = -(-(K // BLOCK) // 4) 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)) grid = (rows, -(-K // BLOCK_K))
_get_kernel()[grid]( _get_kernel()[grid](
x, packed, scales, pts, x, packed, scales, pts,
@@ -617,6 +620,28 @@ class ConvRotQuantizer(OstrisQuantizer):
def _pts(module) -> torch.Tensor: def _pts(module) -> torch.Tensor:
return module.cr_pts.view(torch.float32).reshape(()) 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: def _dequantize_rotated(self, module, dtype: torch.dtype) -> torch.Tensor:
return dequantize_nvfp4( return dequantize_nvfp4(
module.cr_qdata, module.cr_qdata,
@@ -631,6 +656,27 @@ class ConvRotQuantizer(OstrisQuantizer):
w = self._dequantize_rotated(module, torch.float32) w = self._dequantize_rotated(module, torch.float32)
return rotate(w, module.cr_rot_size) # self-inverse 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: def requantize_(self, module, fp_weight: torch.Tensor) -> None:
w = fp_weight.to(device=module.cr_qdata.device, dtype=torch.float32) w = fp_weight.to(device=module.cr_qdata.device, dtype=torch.float32)
w_rot = rotate(w, module.cr_rot_size) w_rot = rotate(w, module.cr_rot_size)
@@ -704,12 +750,13 @@ class ConvRotQuantizer(OstrisQuantizer):
# ---------------- convrot8: W8A8 int8 backend ---------------- # ---------------- convrot8: W8A8 int8 backend ----------------
def quantize_int8_rows(x: torch.Tensor): def quantize_int8_rows(x: torch.Tensor, qmax: int = 127):
"""Symmetric per-row int8 quantization. Returns (int8 (rows, K), fp32 scales (rows,)).""" """Symmetric per-row integer quantization to [-qmax, qmax] (int8 storage).
Returns (int8 (rows, K), fp32 scales (rows,))."""
xf = x.float() 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)) 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 return q, scales
@@ -725,7 +772,9 @@ def _get_int8_kernels():
from triton.language.extra import libdevice from triton.language.extra import libdevice
@triton.jit @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) row = tl.program_id(0)
base = row * K base = row * K
acc = tl.zeros((BLOCK_K,), tl.float32) 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) v = tl.load(x_ptr + base + offs, mask=offs < K, other=0.0).to(tl.float32)
acc = tl.maximum(acc, tl.abs(v)) acc = tl.maximum(acc, tl.abs(v))
amax = tl.max(acc, axis=0) 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): for k0 in range(0, K, BLOCK_K):
offs = k0 + tl.arange(0, BLOCK_K) offs = k0 + tl.arange(0, BLOCK_K)
mask = offs < K mask = offs < K
v = tl.load(x_ptr + base + offs, mask=mask, other=0.0).to(tl.float32) 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 # rint = round-half-to-even, matching torch.round in the reference path
q = libdevice.rint(v / scale) 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(q_ptr + base + offs, q.to(tl.int8), mask=mask)
tl.store(s_ptr + row, scale) 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 # 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. # 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=()) @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, K = x.shape
rows_pad = -(-rows // 32) * 32 rows_pad = -(-rows // 32) * 32
x = x.contiguous() x = x.contiguous()
@@ -786,12 +835,16 @@ def _int8_act_quant_op(x: torch.Tensor) -> list[torch.Tensor]:
q[rows:].zero_() q[rows:].zero_()
scales[rows:].fill_(1.0) scales[rows:].fill_(1.0)
kernel, _ = _get_int8_kernels() 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] return [q, scales]
@_int8_act_quant_op.register_fake @_int8_act_quant_op.register_fake
def _int8_act_quant_fake(x): def _int8_act_quant_fake(x, qmax):
rows, K = x.shape rows, K = x.shape
rows_pad = -(-rows // 32) * 32 rows_pad = -(-rows // 32) * 32
return [ 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)) 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 """Triton path of quantize_int8_rows: one extra read of x instead of the
multi-kernel torch chain. Falls back to the torch ops.""" multi-kernel torch chain. Falls back to the torch ops."""
if not (_triton_available() and x.is_cuda): if not (_triton_available() and x.is_cuda):
return quantize_int8_rows(x) return quantize_int8_rows(x, qmax)
q, scales = _int8_act_quant_op(x) q, scales = _int8_act_quant_op(x, qmax)
rows = x.shape[0] rows = x.shape[0]
return q[:rows], scales[:rows] 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.""" """Act quant with rows padded to a multiple of 32 for torch._int_mm."""
if _triton_available() and x.is_cuda: 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 return q, scales
q, scales = quantize_int8_rows(x) q, scales = quantize_int8_rows(x, qmax)
rows = q.shape[0] rows = q.shape[0]
rows_pad = -(-rows // 32) * 32 rows_pad = -(-rows // 32) * 32
if rows_pad != rows: if rows_pad != rows:
@@ -867,10 +920,11 @@ def _int8_linear_ste_op(
qdata: torch.Tensor, qdata: torch.Tensor,
w_scales_u8: torch.Tensor, w_scales_u8: torch.Tensor,
bias: Optional[torch.Tensor], bias: Optional[torch.Tensor],
act_qmax: int,
out_dtype: str, out_dtype: str,
) -> torch.Tensor: ) -> torch.Tensor:
m = x2d.shape[0] 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()) i32 = torch._int_mm(aq, qdata.t())
return _int8_epilogue( return _int8_epilogue(
i32[:m], a_s[:m], w_scales_u8.view(torch.float32), bias, 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 @_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( return torch.empty(
x2d.shape[0], qdata.shape[0], device=x2d.device, dtype=getattr(torch, out_dtype) x2d.shape[0], qdata.shape[0], device=x2d.device, dtype=getattr(torch, out_dtype)
) )
def _int8_linear_ste_setup(ctx, inputs, output): 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) 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 qdata, w_scales_u8 = ctx.saved_tensors
w_scales = w_scales_u8.view(torch.float32).to(grad.dtype) w_scales = w_scales_u8.view(torch.float32).to(grad.dtype)
w = qdata.to(grad.dtype) * w_scales.unsqueeze(1) 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( _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, per-output-channel symmetric int8 with torch._int_mm. One instance per qtype,
shareable across modules.""" 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): def __init__(self, rot_size: int = 256):
self.rot_size = rot_size 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 """Hardware STE linear for the training path. For int8 the saved qdata is
the resident buffer itself, so autograd holds only a free reference.""" the resident buffer itself, so autograd holds only a free reference."""
return _int8_linear_ste_op( 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: def _dequantize_rotated(self, module, dtype: torch.dtype) -> torch.Tensor:
w = self._qdata(module).float() * self._scales(module).unsqueeze(1) w = self._qdata(module).float() * self._scales(module).unsqueeze(1)
return w.to(dtype) return w.to(dtype)
@@ -1024,6 +1090,15 @@ class ConvRotInt8Quantizer(OstrisQuantizer):
module.cr8_qdata = q module.cr8_qdata = q
module.cr8_scales = scales.view(torch.uint8) 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: def forward(self, module, x: torch.Tensor) -> torch.Tensor:
rot = self._rot(module) rot = self._rot(module)
in_f, out_f = module.in_features, module.out_features 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 # no int8 hardware: straight-through fake-quant + bf16 matmul
x2d = rotate(x, rot).reshape(-1, in_f) x2d = rotate(x, rot).reshape(-1, in_f)
with torch.no_grad(): 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) x_dq = (aq.float() * a_s.unsqueeze(1)).to(x.dtype)
w = self._dequantize_rotated(module, x.dtype) w = self._dequantize_rotated(module, x.dtype)
x_ste = x2d + (x_dq - x2d).detach() x_ste = x2d + (x_dq - x2d).detach()
@@ -1057,7 +1132,9 @@ class ConvRotInt8Quantizer(OstrisQuantizer):
if _int8_gemm_supported(x.device): if _int8_gemm_supported(x.device):
# row padding for _int_mm happens inside the act-quant op (compile # row padding for _int_mm happens inside the act-quant op (compile
# safety); slice the mm output back to m rows (a contiguous prefix) # 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()) i32 = torch._int_mm(aq, self._qdata(module).t())
out = _int8_epilogue(i32[:m], a_s[:m], self._scales(module), module.bias, x.dtype) out = _int8_epilogue(i32[:m], a_s[:m], self._scales(module), module.bias, x.dtype)
return out.reshape(*x.shape[:-1], out_f) return out.reshape(*x.shape[:-1], out_f)
@@ -1413,12 +1490,13 @@ def _intn_linear_ste_op(
w_scales_u8: torch.Tensor, w_scales_u8: torch.Tensor,
bias: Optional[torch.Tensor], bias: Optional[torch.Tensor],
bits: int, bits: int,
act_qmax: int,
out_dtype: str, out_dtype: str,
) -> torch.Tensor: ) -> torch.Tensor:
m, K = x2d.shape m, K = x2d.shape
rows = w_scales_u8.view(torch.float32).numel() rows = w_scales_u8.view(torch.float32).numel()
qdata = _unpack_intn_grouped_impl(packed, gratio, bits, rows, K) 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()) i32 = torch._int_mm(aq, qdata.t())
return _int8_epilogue( return _int8_epilogue(
i32[:m], a_s[:m], w_scales_u8.view(torch.float32), bias, 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 @_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( return torch.empty(
x2d.shape[0], w_scales_u8.view(torch.float32).numel(), x2d.shape[0], w_scales_u8.view(torch.float32).numel(),
device=x2d.device, dtype=getattr(torch, out_dtype), 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): 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.bits = bits
ctx.cols = x2d.shape[1] ctx.cols = x2d.shape[1]
ctx.save_for_backward(packed, gratio, w_scales_u8) 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 packed, gratio, ctx.bits, w_scales.numel(), ctx.cols
) )
w = qdata.to(grad.dtype) * w_scales.to(grad.dtype).unsqueeze(1) 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( _intn_linear_ste_op.register_autograd(
@@ -1495,12 +1573,13 @@ def _bitnet_linear_ste_op(
gratio: torch.Tensor, gratio: torch.Tensor,
w_scales_u8: torch.Tensor, w_scales_u8: torch.Tensor,
bias: Optional[torch.Tensor], bias: Optional[torch.Tensor],
act_qmax: int,
out_dtype: str, out_dtype: str,
) -> torch.Tensor: ) -> torch.Tensor:
m, K = x2d.shape m, K = x2d.shape
rows = w_scales_u8.view(torch.float32).numel() rows = w_scales_u8.view(torch.float32).numel()
qdata = _unpack_bitnet_impl(packed, gratio, rows, K) 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()) i32 = torch._int_mm(aq, qdata.t())
return _int8_epilogue( return _int8_epilogue(
i32[:m], a_s[:m], w_scales_u8.view(torch.float32), bias, 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 @_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( return torch.empty(
x2d.shape[0], w_scales_u8.view(torch.float32).numel(), x2d.shape[0], w_scales_u8.view(torch.float32).numel(),
device=x2d.device, dtype=getattr(torch, out_dtype), 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): 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.cols = x2d.shape[1]
ctx.save_for_backward(packed, gratio, w_scales_u8) ctx.save_for_backward(packed, gratio, w_scales_u8)
@@ -1529,7 +1608,7 @@ def _bitnet_linear_ste_backward(ctx, grad):
# fake tensors # fake tensors
qdata = _bitnet_unpack_op(packed, gratio, w_scales.numel(), ctx.cols) qdata = _bitnet_unpack_op(packed, gratio, w_scales.numel(), ctx.cols)
w = qdata.to(grad.dtype) * w_scales.to(grad.dtype).unsqueeze(1) 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( _bitnet_linear_ste_op.register_autograd(
@@ -1608,7 +1687,7 @@ class ConvRotIntNQuantizer(ConvRotInt8Quantizer):
# in the backward # in the backward
return _intn_linear_ste_op( return _intn_linear_ste_op(
x2d, module.crn_qdata, gratio.view(torch.float32), 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: def requantize_(self, module, fp_weight: torch.Tensor) -> None:
@@ -1621,6 +1700,46 @@ class ConvRotIntNQuantizer(ConvRotInt8Quantizer):
if gratio is not None: if gratio is not None:
module.crn_gratio = gratio.view(torch.uint8) 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): class ConvRotBitNetQuantizer(ConvRotIntNQuantizer):
"""BitNet-b1.58 style W1.58A8: convrotint2's exact ternary codes and """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) gratio, rscales = _intn_group_ratio_and_rscales(gscales, self.bits)
return pack_ternary_rows(q), rscales, gratio.contiguous() 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: def _qdata(self, module) -> torch.Tensor:
return _bitnet_unpack_op( return _bitnet_unpack_op(
module.crn_qdata, module.crn_gratio.view(torch.float32), 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: def _linear_ste(self, module, x2d: torch.Tensor, out_dtype: str) -> torch.Tensor:
return _bitnet_linear_ste_op( return _bitnet_linear_ste_op(
x2d, module.crn_qdata, module.crn_gratio.view(torch.float32), 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)
+144 -1
View File
@@ -29,7 +29,7 @@ class OstrisQuantizer:
""" """
# the qtype string this instance was resolved from (stamped by # 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 qtype: Optional[str] = None
def can_quantize(self, module: torch.nn.Linear) -> bool: def can_quantize(self, module: torch.nn.Linear) -> bool:
@@ -60,6 +60,9 @@ class OstrisQuantizer:
return F.linear(x, w, module.bias) return F.linear(x, w, module.bias)
_wrong_device_warned = False
class OstrisLinear(torch.nn.Linear): class OstrisLinear(torch.nn.Linear):
"""A linear layer whose weight is quantized by an OstrisQuantizer backend. """A linear layer whose weight is quantized by an OstrisQuantizer backend.
@@ -87,6 +90,24 @@ class OstrisLinear(torch.nn.Linear):
return w return w
def forward(self, x: torch.Tensor) -> torch.Tensor: 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) return self.ostris_quantizer.forward(self, x)
@torch.no_grad() @torch.no_grad()
@@ -117,10 +138,132 @@ def get_ostris_quantizer(qtype: str) -> Optional[OstrisQuantizer]:
elif qtype in CONVROT_QTYPES: elif qtype in CONVROT_QTYPES:
quantizer = get_convrot_quantizer(qtype) quantizer = get_convrot_quantizer(qtype)
if quantizer is not None: if quantizer is not None:
# quantized saves read this back to restore the backend on load
quantizer.qtype = qtype quantizer.qtype = qtype
return quantizer 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() @torch.no_grad()
def convert_linear_to_ostris( def convert_linear_to_ostris(
module: torch.nn.Linear, quantizer: OstrisQuantizer module: torch.nn.Linear, quantizer: OstrisQuantizer