Files
2026-07-13 12:34:46 +08:00

242 lines
8.7 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""cider.nn — CiderLinear: unified INT8 kernel, zero double storage.
Two internal paths (transparent to caller):
- per_channel (gs=0): perchannel_linear (prefill GEMM + decode MV, both per-channel)
- per_group (gs∈{64,128,256}): pergroup_linear (prefill GEMM + decode MV, per-group)
Both paths: only one copy of int8 weights in memory.
Conversion: from_float() does symmetric requant.
Usage:
from cider import convert_model
convert_model(model)
# Done. Both prefill and decode use INT8 kernels.
"""
import mlx.core as mx
import mlx.nn as nn
import numpy as np
from . import ops
# ── Backward compat stubs (no-op) ──────────────────────────────
def set_mode(mode: str):
"""No-op. Kept for backward compatibility."""
pass
def get_mode() -> str:
"""Always returns 'auto'."""
return "auto"
# ── Per-group symmetric quantization helper ────────────────────
def _symmetric_quantize_pergroup(w_fp: np.ndarray, group_size: int):
"""Quantize [N, K] float weights to per-group symmetric INT8.
Args:
w_fp: [N, K] float32 numpy array
group_size: elements per group (K must be divisible)
Returns:
w_int8: [N, K] int8 numpy
scale_w: [N, num_groups] float32 numpy
"""
N, K = w_fp.shape
assert K % group_size == 0, f"K={K} not divisible by group_size={group_size}"
num_groups = K // group_size
w_reshaped = w_fp.reshape(N, num_groups, group_size)
group_max = np.max(np.abs(w_reshaped), axis=2) # [N, num_groups]
scale = group_max / 127.0
scale = np.where(scale == 0, 1.0, scale) # avoid div by zero
w_int8 = np.clip(
np.round(w_reshaped / scale[:, :, np.newaxis]),
-128, 127
).astype(np.int8).reshape(N, K)
return w_int8, scale.astype(np.float32)
# ── CiderLinear ─────────────────────────────────────────────────
class CiderLinear(nn.Module):
"""Unified INT8 Linear: both prefill and decode use custom kernels.
Internal dispatch (transparent to caller):
per_channel (gs=0): ops.perchannel_linear — per-channel INT8 pipeline
per_group (gs∈{64,128,256}): ops.pergroup_linear — per-group with group scales
Both paths auto-dispatch GEMM (M>1) vs MV (M==1) internally.
No double weight storage. Only int8 weights + scales in memory.
"""
def __init__(
self,
w_int8: mx.array, # [N, K] int8
scale_w: mx.array, # per-channel: [N], per-group: [num_groups, N] (row-major contiguous)
group_size: int, # 0 = per-channel, 64/128/256 = per-group
in_features: int,
out_features: int,
bias: mx.array = None, # [N] float16, default zeros
):
super().__init__()
self.w_int8 = w_int8
# Per-group: physically transpose scale_w from [N, num_groups] to [num_groups, N]
# for coalesced SIMD access in Metal kernels.
# scale_w for per-group must already be [num_groups, N] physically contiguous.
# This is ensured by from_float() and convert.py at construction time (numpy transpose).
self.scale_w = scale_w
self.group_size = group_size
self._in_features = in_features
self._out_features = out_features
self.bias = bias if bias is not None else mx.zeros((out_features,), dtype=mx.float16)
if group_size == 0:
self._mode = "per_channel"
elif group_size in (64, 128, 256):
self._mode = "per_group"
else:
raise ValueError(f"Unsupported group_size={group_size}. Use 0 (per-channel) or 64/128/256.")
def __call__(self, x: mx.array) -> mx.array:
orig_shape = x.shape
x_2d = x.reshape(-1, self._in_features)
if self._mode == "per_channel":
y = ops.perchannel_linear(x_2d, self.w_int8, self.scale_w, self.bias)
else:
y = ops.pergroup_linear(x_2d, self.w_int8, self.scale_w, self.group_size, self.bias)
y = y.reshape(*orig_shape[:-1], self._out_features)
return y
@staticmethod
def from_float(layer: nn.Module, target_group_size: int = None, clip_percentile: float = None) -> "CiderLinear":
"""Create from nn.Linear or nn.QuantizedLinear.
For QuantizedLinear (8-bit, gs∈{64,128,256}): dequant → symmetric requant per-group.
For QuantizedLinear (non-8-bit or unsupported gs): dequant → per-channel requant.
For Linear: per-channel requant.
Args:
layer: Source nn.Linear or nn.QuantizedLinear
target_group_size: Override group_size for conversion.
"""
if isinstance(layer, nn.QuantizedLinear):
bits = layer.bits
gs = layer.group_size
out_f = layer.scales.shape[0]
in_f = layer.scales.shape[1] * gs
lin_bias = getattr(layer, "bias", None)
# Determine target group_size
if target_group_size is not None:
tgs = target_group_size
elif bits == 8 and gs in (64, 128, 256):
tgs = gs
else:
tgs = 0 # per-channel
# Dequant
w_fp = mx.dequantize(
layer.weight, layer.scales,
getattr(layer, "biases", None),
gs, bits,
)
w_np = np.array(w_fp.astype(mx.float32))
if tgs == 0:
# Per-channel symmetric requant
w_int8_np, scale_np = ops.quantize_weight_int8(w_np, clip_percentile=clip_percentile)
return CiderLinear(
w_int8=mx.array(w_int8_np),
scale_w=mx.array(scale_np),
group_size=0,
in_features=in_f,
out_features=out_f,
bias=lin_bias,
)
else:
# Per-group symmetric requant
w_int8_np, scale_np = _symmetric_quantize_pergroup(w_np, tgs)
return CiderLinear(
w_int8=mx.array(w_int8_np),
scale_w=mx.array(scale_np.T.copy()), # [N,ng] -> [ng,N] contiguous
group_size=tgs,
in_features=in_f,
out_features=out_f,
bias=lin_bias,
)
elif hasattr(layer, "weight"):
# FP Linear → per-channel
out_f, in_f = layer.weight.shape
lin_bias = getattr(layer, "bias", None)
tgs = target_group_size or 0
w_np = np.array(layer.weight.astype(mx.float32))
if tgs == 0:
w_int8_np, scale_np = ops.quantize_weight_int8(w_np, clip_percentile=clip_percentile)
else:
w_int8_np, scale_np = _symmetric_quantize_pergroup(w_np, tgs)
return CiderLinear(
w_int8=mx.array(w_int8_np),
scale_w=mx.array(scale_np.T.copy() if tgs > 0 else scale_np), # [N,ng] -> [ng,N]
group_size=tgs,
in_features=in_f,
out_features=out_f,
bias=lin_bias,
)
else:
raise TypeError(f"Unsupported layer: {type(layer)}")
@property
def input_dims(self) -> int:
return self._in_features
@property
def output_dims(self) -> int:
return self._out_features
def __repr__(self):
return (
f"CiderLinear(in={self._in_features}, out={self._out_features}, "
f"mode={self._mode}, gs={self.group_size})"
)
# Backward compatibility alias
W8A8Linear = CiderLinear
# ── W4A8Linear ──────────────────────────────────────────────────
class W4A8Linear(nn.Module):
"""Packed INT4 weight × INT8 activation linear layer."""
def __init__(self, packed_weight: mx.array, scale: mx.array, K: int):
super().__init__()
self.packed_weight = packed_weight
self.scale = scale
self._K = K
def __call__(self, x: mx.array) -> mx.array:
return ops.w4a8_linear(x, self.packed_weight, self.scale)
@staticmethod
def from_weights(w: np.ndarray, zero_point: int = 8) -> "W4A8Linear":
"""Create from FP16/FP32 numpy weight [K, N]."""
K = w.shape[0]
packed, scale = ops.pack_weight_int4(w, zero_point)
return W4A8Linear(mx.array(packed), mx.array(scale), K)
@property
def input_dims(self) -> int:
return self._K
@property
def output_dims(self) -> int:
return self.packed_weight.shape[1]