mirror of
https://github.com/kvcache-ai/ktransformers.git
synced 2026-10-28 21:51:46 +08:00
* [feat](kt-kernel): add AVX2 RAWINT4 routed-expert LoRA SFT backend * [fix](kt-kernel): fix RAWINT4 SFT wrapper and clamp gradients Allow the RAWINT4 production wrapper on AVX2 runtimes and reject shared backward pools before construction. Preserve FP32 clamp decisions across BF16 caches, remove unused configuration state, and cover rectangular dimensions, multiblock dX, clamp boundaries, and production wrapper tasks with tighter LoRA gradient checks. * [fix](kt-kernel): plumb swiglu_limit through the RAWINT4 SFT python path The production wrapper hard-zeroed swiglu_limit for every format except MXFP4, and both SFT python gates rejected non-zero values for RAWINT4 even though MOESFTConfig carries the field and the AVX2 kernel implements the clamp in forward plus the gradient mask in backward. RAWINT4_SFT now accepts a finite non-negative swiglu_limit end to end, while the AMX K2 part (which computes plain SiLU) fails fast instead of silently dropping the clamp. The production-wrapper contract test is parametrized over swiglu_limit 0 and 2, a model-config plumb test covers plain and nested text_config shapes, the __main__ entry exercises both limits, and bench_rawint4_sft.py gains --swiglu-limit.
323 lines
12 KiB
Python
323 lines
12 KiB
Python
# SPDX-License-Identifier: Apache-2.0
|
|
"""Small, auditable signed group-32 RAWINT4 LoRA reference used by CPU SFT tests.
|
|
|
|
Production code must never materialize a persistent BF16 copy of the routed
|
|
expert weights. Tests intentionally provide both a dense oracle and a
|
|
group-at-a-time dX implementation so the transpose-free kernel can be checked
|
|
without treating its own output as ground truth.
|
|
|
|
RAWINT4 stores signed int4 codes biased by +8 (nibble 0..15 -> weight
|
|
``(code - 8) * bf16_group_scale``), low nibble first, the layout produced by
|
|
``compressed-tensors-rawint4-g32-v1`` checkpoints.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
from dataclasses import dataclass
|
|
from typing import Dict, Iterable, Mapping
|
|
|
|
import torch
|
|
import torch.nn.functional as F
|
|
|
|
RAWINT4_GROUP_SIZE = 32
|
|
|
|
LORA_NAMES = (
|
|
"gate_lora_a",
|
|
"gate_lora_b",
|
|
"up_lora_a",
|
|
"up_lora_b",
|
|
"down_lora_a",
|
|
"down_lora_b",
|
|
)
|
|
|
|
|
|
def _validate_rawint4_layout(
|
|
packed: torch.Tensor,
|
|
scales: torch.Tensor,
|
|
group_size: int,
|
|
) -> tuple[int, int]:
|
|
if group_size != RAWINT4_GROUP_SIZE:
|
|
raise ValueError(f"RAWINT4 requires group_size={RAWINT4_GROUP_SIZE}, got {group_size}")
|
|
if packed.dtype != torch.uint8 or packed.ndim != 2:
|
|
raise ValueError("packed RAWINT4 weights must be a rank-2 uint8 tensor")
|
|
if scales.dtype != torch.bfloat16 or scales.ndim != 2:
|
|
raise ValueError("RAWINT4 scales must be a rank-2 BF16 tensor")
|
|
n, packed_k = packed.shape
|
|
k = packed_k * 2
|
|
if k % group_size:
|
|
raise ValueError(f"RAWINT4 K={k} must be divisible by group_size={group_size}")
|
|
expected_scales = (n, k // group_size)
|
|
if tuple(scales.shape) != expected_scales:
|
|
raise ValueError(f"RAWINT4 scales have shape {tuple(scales.shape)}, expected {expected_scales}")
|
|
return n, k
|
|
|
|
|
|
def unpack_rawint4(packed: torch.Tensor) -> torch.Tensor:
|
|
"""Decode low-nibble-first biased int4 bytes to signed values in [-8, 7]."""
|
|
|
|
if packed.dtype != torch.uint8:
|
|
raise ValueError("packed RAWINT4 storage must use torch.uint8")
|
|
low = (packed & 0x0F).to(torch.float32)
|
|
high = ((packed >> 4) & 0x0F).to(torch.float32)
|
|
codes = torch.stack((low, high), dim=-1).reshape(*packed.shape[:-1], -1)
|
|
return codes - 8.0
|
|
|
|
|
|
def pack_rawint4_codes(codes: torch.Tensor) -> torch.Tensor:
|
|
"""Pack integer codes in [0, 15], preserving the checkpoint nibble order."""
|
|
|
|
if codes.ndim < 1 or codes.shape[-1] % 2:
|
|
raise ValueError("RAWINT4 code count must be even")
|
|
if codes.numel() and (int(codes.min()) < 0 or int(codes.max()) > 15):
|
|
raise ValueError("RAWINT4 codes must be in [0, 15]")
|
|
codes = codes.to(torch.uint8)
|
|
return (codes[..., 0::2] | (codes[..., 1::2] << 4)).contiguous()
|
|
|
|
|
|
def dequantize_rawint4(
|
|
packed: torch.Tensor,
|
|
scales: torch.Tensor,
|
|
group_size: int = RAWINT4_GROUP_SIZE,
|
|
) -> torch.Tensor:
|
|
"""Dense test oracle for an ``[N, K]`` signed group-32 RAWINT4 matrix."""
|
|
|
|
_, k = _validate_rawint4_layout(packed, scales, group_size)
|
|
decoded = unpack_rawint4(packed).reshape(packed.shape[0], k)
|
|
expanded_scales = scales.float().repeat_interleave(group_size, dim=1)
|
|
return decoded * expanded_scales
|
|
|
|
|
|
def transpose_free_rawint4_dx(
|
|
grad_output: torch.Tensor,
|
|
packed: torch.Tensor,
|
|
scales: torch.Tensor,
|
|
group_size: int = RAWINT4_GROUP_SIZE,
|
|
) -> torch.Tensor:
|
|
"""Reference ``dX = dY @ W`` using only original RAWINT4 rows/groups.
|
|
|
|
At no point is a transposed weight or full dequantized matrix constructed.
|
|
The loop order mirrors the intended C++ kernel: each K slab owns its output,
|
|
then walks original output rows N and decodes exactly one group at a time.
|
|
"""
|
|
|
|
n, k = _validate_rawint4_layout(packed, scales, group_size)
|
|
if grad_output.ndim != 2 or grad_output.shape[1] != n:
|
|
raise ValueError(f"grad_output must have shape [M, {n}], got {tuple(grad_output.shape)}")
|
|
grad_output = grad_output.float()
|
|
grad_input = torch.zeros((grad_output.shape[0], k), dtype=torch.float32, device=grad_output.device)
|
|
packed_per_group = group_size // 2
|
|
for group in range(k // group_size):
|
|
k_begin = group * group_size
|
|
packed_begin = group * packed_per_group
|
|
for row in range(n):
|
|
group_codes = packed[row, packed_begin : packed_begin + packed_per_group]
|
|
low = (group_codes & 0x0F).to(torch.float32)
|
|
high = ((group_codes >> 4) & 0x0F).to(torch.float32)
|
|
codes = torch.stack((low, high), dim=-1).reshape(group_size)
|
|
values = (codes - 8.0) * scales[row, group].float()
|
|
grad_input[:, k_begin : k_begin + group_size].add_(grad_output[:, row : row + 1] * values)
|
|
return grad_input
|
|
|
|
|
|
class FrozenRawInt4Linear(torch.autograd.Function):
|
|
"""Test-only frozen linear whose backward uses the transpose-free oracle."""
|
|
|
|
@staticmethod
|
|
def forward(ctx, inputs, packed, scales):
|
|
ctx.save_for_backward(packed, scales)
|
|
ctx.input_dtype = inputs.dtype
|
|
weight = dequantize_rawint4(packed, scales)
|
|
return inputs.float().matmul(weight.t())
|
|
|
|
@staticmethod
|
|
def backward(ctx, grad_output):
|
|
packed, scales = ctx.saved_tensors
|
|
grad_input = transpose_free_rawint4_dx(grad_output, packed, scales)
|
|
return grad_input.to(ctx.input_dtype), None, None
|
|
|
|
|
|
def rawint4_swiglu(gate_raw: torch.Tensor, up_raw: torch.Tensor, limit: float = 0.0) -> torch.Tensor:
|
|
"""SwiGLU with the optional asymmetric clamp (K2 checkpoints use limit=0)."""
|
|
|
|
gate = gate_raw
|
|
up = up_raw
|
|
if limit > 0:
|
|
# Keep the boundary-inclusive subgradient explicit across PyTorch versions.
|
|
gate = torch.where(gate <= limit, gate, limit)
|
|
up = torch.where(up < -limit, -limit, torch.where(up > limit, limit, up))
|
|
return F.silu(gate) * up
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class RawInt4Projection:
|
|
packed: torch.Tensor
|
|
scales: torch.Tensor
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class RawInt4ExpertWeights:
|
|
gate: RawInt4Projection
|
|
up: RawInt4Projection
|
|
down: RawInt4Projection
|
|
|
|
|
|
def tensor_storage_hash(tensors: Iterable[torch.Tensor]) -> str:
|
|
digest = hashlib.sha256()
|
|
for tensor in tensors:
|
|
cpu_bytes = tensor.detach().cpu().contiguous().view(torch.uint8).numpy().tobytes()
|
|
digest.update(len(cpu_bytes).to_bytes(8, byteorder="little"))
|
|
digest.update(cpu_bytes)
|
|
return digest.hexdigest()
|
|
|
|
|
|
def base_storage_hash(experts: Iterable[RawInt4ExpertWeights]) -> str:
|
|
tensors = []
|
|
for expert in experts:
|
|
for projection in (expert.gate, expert.up, expert.down):
|
|
tensors.extend((projection.packed, projection.scales))
|
|
return tensor_storage_hash(tensors)
|
|
|
|
|
|
def _base_linear(
|
|
inputs: torch.Tensor,
|
|
projection: RawInt4Projection,
|
|
*,
|
|
transpose_free_backward: bool,
|
|
) -> torch.Tensor:
|
|
if transpose_free_backward:
|
|
return FrozenRawInt4Linear.apply(inputs, projection.packed, projection.scales)
|
|
weight = dequantize_rawint4(projection.packed, projection.scales)
|
|
return F.linear(inputs.float(), weight)
|
|
|
|
|
|
def _lora_linear(
|
|
inputs: torch.Tensor,
|
|
lora_a: torch.Tensor,
|
|
lora_b: torch.Tensor,
|
|
scaling: float,
|
|
) -> torch.Tensor:
|
|
return scaling * F.linear(F.linear(inputs.float(), lora_a), lora_b)
|
|
|
|
|
|
def routed_rawint4_lora(
|
|
inputs: torch.Tensor,
|
|
expert_ids: torch.Tensor,
|
|
route_weights: torch.Tensor,
|
|
experts: list[RawInt4ExpertWeights],
|
|
lora: Mapping[str, torch.Tensor],
|
|
*,
|
|
lora_scaling: float,
|
|
swiglu_limit: float,
|
|
transpose_free_backward: bool,
|
|
) -> torch.Tensor:
|
|
"""Unoptimized routed-expert LoRA equation used as a gradient oracle."""
|
|
|
|
if expert_ids.ndim != 2 or route_weights.shape != expert_ids.shape:
|
|
raise ValueError("expert_ids and route_weights must have the same [M, top_k] shape")
|
|
if inputs.ndim != 2 or inputs.shape[0] != expert_ids.shape[0]:
|
|
raise ValueError("inputs and routing tensors disagree on token count")
|
|
missing = set(LORA_NAMES).difference(lora)
|
|
if missing:
|
|
raise ValueError(f"missing LoRA tensors: {sorted(missing)}")
|
|
|
|
token_outputs = []
|
|
for token_idx in range(inputs.shape[0]):
|
|
routed_output = None
|
|
token_input = inputs[token_idx : token_idx + 1]
|
|
for slot in range(expert_ids.shape[1]):
|
|
expert_idx = int(expert_ids[token_idx, slot])
|
|
expert = experts[expert_idx]
|
|
gate_raw = _base_linear(
|
|
token_input,
|
|
expert.gate,
|
|
transpose_free_backward=transpose_free_backward,
|
|
) + _lora_linear(
|
|
token_input,
|
|
lora["gate_lora_a"][expert_idx],
|
|
lora["gate_lora_b"][expert_idx],
|
|
lora_scaling,
|
|
)
|
|
up_raw = _base_linear(
|
|
token_input, expert.up, transpose_free_backward=transpose_free_backward
|
|
) + _lora_linear(
|
|
token_input,
|
|
lora["up_lora_a"][expert_idx],
|
|
lora["up_lora_b"][expert_idx],
|
|
lora_scaling,
|
|
)
|
|
intermediate = rawint4_swiglu(gate_raw, up_raw, swiglu_limit)
|
|
expert_output = _base_linear(
|
|
intermediate,
|
|
expert.down,
|
|
transpose_free_backward=transpose_free_backward,
|
|
) + _lora_linear(
|
|
intermediate,
|
|
lora["down_lora_a"][expert_idx],
|
|
lora["down_lora_b"][expert_idx],
|
|
lora_scaling,
|
|
)
|
|
weighted = expert_output * route_weights[token_idx, slot]
|
|
routed_output = weighted if routed_output is None else routed_output + weighted
|
|
token_outputs.append(routed_output)
|
|
return torch.cat(token_outputs, dim=0)
|
|
|
|
|
|
def run_routed_reference(
|
|
inputs: torch.Tensor,
|
|
expert_ids: torch.Tensor,
|
|
route_weights: torch.Tensor,
|
|
experts: list[RawInt4ExpertWeights],
|
|
lora: Mapping[str, torch.Tensor],
|
|
grad_output: torch.Tensor,
|
|
*,
|
|
lora_scaling: float,
|
|
swiglu_limit: float,
|
|
transpose_free_backward: bool,
|
|
) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor, Dict[str, torch.Tensor]]:
|
|
differentiable_input = inputs.float().detach().requires_grad_(True)
|
|
differentiable_routes = route_weights.float().detach().requires_grad_(True)
|
|
differentiable_lora = {name: tensor.float().detach().requires_grad_(True) for name, tensor in lora.items()}
|
|
output = routed_rawint4_lora(
|
|
differentiable_input,
|
|
expert_ids,
|
|
differentiable_routes,
|
|
experts,
|
|
differentiable_lora,
|
|
lora_scaling=lora_scaling,
|
|
swiglu_limit=swiglu_limit,
|
|
transpose_free_backward=transpose_free_backward,
|
|
)
|
|
targets = (
|
|
differentiable_input,
|
|
differentiable_routes,
|
|
*(differentiable_lora[name] for name in LORA_NAMES),
|
|
)
|
|
gradients = torch.autograd.grad(
|
|
output,
|
|
targets,
|
|
grad_outputs=grad_output.float(),
|
|
retain_graph=False,
|
|
create_graph=False,
|
|
)
|
|
return (
|
|
output.detach(),
|
|
gradients[0].detach(),
|
|
gradients[1].detach(),
|
|
{name: value.detach() for name, value in zip(LORA_NAMES, gradients[2:])},
|
|
)
|
|
|
|
|
|
def relative_l2_and_cosine(
|
|
actual: torch.Tensor,
|
|
expected: torch.Tensor,
|
|
) -> tuple[float, float]:
|
|
actual = actual.float().flatten()
|
|
expected = expected.float().flatten()
|
|
difference = torch.linalg.vector_norm(actual - expected)
|
|
expected_norm = torch.linalg.vector_norm(expected)
|
|
actual_norm = torch.linalg.vector_norm(actual)
|
|
relative_l2 = float(difference / torch.clamp(expected_norm, min=1.0e-12))
|
|
cosine = float(torch.dot(actual, expected) / torch.clamp(actual_norm * expected_norm, min=1.0e-20))
|
|
return relative_l2, cosine
|