epic-quant / epic_quant /packed.py
toxzak's picture
Add 1.58/3/4/16-bit sweep, packed weights, real SDPA, COMPARISON.md
7e372c7
Raw
History Blame Contribute Delete
7.45 kB
"""
Packed 2-bit ternary, 3-bit symmetric int, and 4-bit weight formats.
Reference implementation that reports real byte counts.
Packing layout:
- 2-bit: 4 values packed into 1 byte. Each value is 2 bits representing
one of {-1, 0, +1, "n/a"} -> we use {0, 1, 2, 3} = {-1, 0, +1, unused}.
- 3-bit: 3 values packed into 1 byte (1 bit unused). Symmetric int in
range [-4, 3] (8 centroids). 2.67 bits/value effective, but the
unused bit is real overhead — see packed_size_bytes.
- 4-bit: 2 values packed into 1 byte. Each value is int4 in [-8, 7].
- "fp16": no quant, weights stored as BF16 (the engine's working
dtype). 2 bytes per value. Used as a "no quant" baseline.
scales: per-row fp16 scale, one per output row. Stored separately
from the packed weights.
Byte accounting per row of an [out, in] Linear:
- 2-bit packed: out * ceil(in/4) bytes + out * 2 bytes (scales)
- 3-bit packed: out * ceil(in/3) bytes + out * 2 bytes (scales)
- 4-bit packed: out * ceil(in/2) bytes + out * 2 bytes (scales)
- FP16 (BF16): out * in * 2 bytes (no scales needed)
"""
from __future__ import annotations
from typing import Tuple
import torch
def quantize_packed(w: torch.Tensor, bits: int) -> Tuple[torch.Tensor, torch.Tensor]:
"""Quantize a 2D weight matrix and return (packed_bytes, per_row_scales).
`packed_bytes` is a uint8 tensor of shape [out, ceil(in/pack)].
`scales` is fp16 [out].
"""
assert w.is_floating_point() and w.dim() == 2
out, in_dim = w.shape
w32 = w.to(torch.float32)
if bits == 2:
# Ternary {-1, 0, +1}
max_abs = w32.abs().amax(dim=1, keepdim=True).clamp(min=1e-8)
scale = max_abs.squeeze(1) # [out]
w_scaled = w32 / max_abs
q = torch.zeros_like(w_scaled, dtype=torch.int8)
q[w_scaled > 0.33] = 1
q[w_scaled < -0.33] = -1
q01 = (q + 1).to(torch.int64) # {-1,0,1} -> {0,1,2}
pad = (4 - in_dim % 4) % 4
if pad:
q01 = torch.cat([q01, torch.zeros(out, pad, dtype=torch.int64)], dim=1)
in_packed = q01.shape[1] // 4
packed = torch.zeros(out, in_packed, dtype=torch.uint8)
for j in range(4):
packed |= (q01[:, j::4].to(torch.uint8) << (2 * j))
return packed, scale.to(torch.float16)
if bits == 3:
# Symmetric int3 in [-4, 3] (8 centroids).
# 3 bits/value, but 3 values don't fit in a byte (3*3=9). Standard
# packing: 2 values per byte, 2 bits wasted. So in_dim values
# need ceil(in_dim * 3 / 8) bytes = ceil(in_dim / 2) * 3 / 4 ...
# actually 2 values per byte at 3 bits each = 6 bits used, 2 wasted
# per byte. Same as ceil(in_dim * 3 / 8) = ceil(in_dim * 0.375).
max_abs = w32.abs().amax(dim=1, keepdim=True).clamp(min=1e-8)
scale = (max_abs / 4.0).squeeze(1)
w_scaled = (w32 / scale.unsqueeze(1)).round().clamp(-4, 3)
q01 = (w_scaled + 4).to(torch.int64) # [-4,3] -> [0,7]
# Pad in_dim to even count (so 2 values/byte works cleanly)
pad = (2 - in_dim % 2) % 2
if pad:
q01 = torch.cat([q01, torch.zeros(out, pad, dtype=torch.int64)], dim=1)
# Reshape [out, in_padded] -> [out, in_padded/2, 2] and pack each
# pair into a byte: low nibble = vals[0], high nibble shifted = vals[1].
in_padded = q01.shape[1]
pairs = q01.reshape(out, in_padded // 2, 2)
v0 = pairs[:, :, 0].to(torch.uint8) & 0x7
v1 = pairs[:, :, 1].to(torch.uint8) & 0x7
packed = v0 | (v1 << 3)
return packed, scale.to(torch.float16)
if bits == 4:
max_abs = w32.abs().amax(dim=1, keepdim=True).clamp(min=1e-8)
scale = (max_abs / 7.0).squeeze(1)
w_scaled = (w32 / scale.unsqueeze(1)).round().clamp(-8, 7)
q01 = (w_scaled + 8).to(torch.int64)
pad = (2 - in_dim % 2) % 2
if pad:
q01 = torch.cat([q01, torch.zeros(out, pad, dtype=torch.int64)], dim=1)
in_packed = q01.shape[1] // 2
packed = torch.zeros(out, in_packed, dtype=torch.uint8)
packed |= (q01[:, 0::2].to(torch.uint8) & 0xF)
packed |= (q01[:, 1::2].to(torch.uint8) & 0xF) << 4
return packed, scale.to(torch.float16)
if bits == 16:
# FP16 path: no quant. We return the BF16 bytes of the weight
# (cast to BF16 for engine consistency) as a "packed" buffer of
# 1 byte per byte, plus a scale of 1s (so the dequant path is
# uniform across bits=2/3/4/16).
scale = torch.ones(out, dtype=torch.float16)
# Pack BF16 weight bytes (2 per value).
w_bf16 = w.to(torch.bfloat16).contiguous()
packed = w_bf16.view(torch.uint8).reshape(out, in_dim * 2).contiguous()
return packed, scale
raise NotImplementedError(f"bits={bits}")
def dequantize_packed(packed: torch.Tensor, scales: torch.Tensor,
out_dim: int, in_dim: int, bits: int) -> torch.Tensor:
"""Inverse of quantize_packed. Returns BF16 weight."""
if bits == 2:
out, in_packed = packed.shape
q01 = torch.zeros(out, in_packed * 4, dtype=torch.int64)
for j in range(4):
q01[:, j::4] = ((packed >> (2 * j)) & 0x3).to(torch.int64)
q01 = q01[:, :in_dim]
q = (q01 - 1).to(torch.float32)
return (q * scales.to(torch.float32).unsqueeze(1)).to(torch.bfloat16)
if bits == 3:
out, in_packed = packed.shape # in_packed = ceil(in_dim/2)
# Unpack: each byte has v0 in low 3 bits, v1 in next 3 bits
v0 = (packed & 0x7).to(torch.int64)
v1 = ((packed >> 3) & 0x7).to(torch.int64)
# interleave: [out, in_packed*2] = [v0[0], v1[0], v0[1], v1[1], ...]
q01 = torch.stack([v0, v1], dim=-1).reshape(out, in_packed * 2)
q01 = q01[:, :in_dim]
q = (q01 - 4).to(torch.float32) # back to [-4, 3]
return (q * scales.to(torch.float32).unsqueeze(1)).to(torch.bfloat16)
if bits == 4:
out, in_packed = packed.shape
q01 = torch.zeros(out, in_packed * 2, dtype=torch.int64)
q01[:, 0::2] = (packed & 0xF).to(torch.int64)
q01[:, 1::2] = ((packed >> 4) & 0xF).to(torch.int64)
q01 = q01[:, :in_dim]
q = (q01 - 8).to(torch.float32)
return (q * scales.to(torch.float32).unsqueeze(1)).to(torch.bfloat16)
if bits == 16:
# Treat the 2-bytes-per-value buffer as BF16.
flat = packed.reshape(out_dim, in_dim * 2)
t = flat.view(torch.uint8).reshape(out_dim, in_dim, 2).view(torch.bfloat16).reshape(out_dim, in_dim).clone()
return t
raise NotImplementedError(f"bits={bits}")
def packed_size_bytes(out_dim: int, in_dim: int, bits: int) -> int:
"""Real on-disk / on-RAM byte count for a packed weight tensor (no scales)."""
if bits == 2:
return out_dim * ((in_dim + 3) // 4)
if bits == 3:
# 2 values per byte (3+3 bits, 2 bits wasted). 2.67 bits/value effective.
return out_dim * ((in_dim + 1) // 2)
if bits == 4:
return out_dim * ((in_dim + 1) // 2)
if bits == 16:
return out_dim * in_dim * 2
raise NotImplementedError
def total_packed_size_bytes(out_dim: int, in_dim: int, bits: int) -> int:
"""packed weights + per-row fp16 scales (no scales needed at 16)."""
if bits == 16:
return packed_size_bytes(out_dim, in_dim, bits)
return packed_size_bytes(out_dim, in_dim, bits) + out_dim * 2