Files
a5d7ad9047 feat(sft) Deepseek v4 flash LoRA (#2216)
* feat(sft): add native MXFP4 routed-expert LoRA kernels

* test(sft): validate DeepSeek V4 MXFP4 routed experts

* feat(sft): AVX2 tier for MXFP4 routed-expert LoRA SFT

MXFP4_SFT_MOE on non-AVX512 builds, same Python contract as the AMX /
AVX512-BF16 kernel from #2185 (KTMoEWrapper(method="MXFP4_SFT")), for
AVX2+FMA+F16C hosts (Zen 2/3 EPYC, older Xeons).

* operators/avx2/sft_mxfp4-moe.hpp: AVX2_SFT_MXFP4_MOE_TP part on top of
  AVX2_MXFP4_MOE_TP (frozen packed E2M1/UE8M0 base, LoRA gate/up/down,
  V4 swiglu clamp, transpose-free packed-weight dX, authoritative LoRA grads).
* operators/moe-sft-tp.hpp: AVX2 result merge; AVX-512 merge loops and the
  block-FP8 staging guarded so the wrapper builds without AVX-512.
* ext_bindings.cpp: bind MXFP4_SFT_MOE -> TP_MOE_SFT<AVX2_SFT_MXFP4_MOE_TP>
  when USE_AMX_AVX_KERNEL is off; MOESFTBindings no longer AMX-only.
* python/sft/backend.py: runtime reports cpu_variant=avx2, kernel avx2-mxfp4-g32.
* test_mxfp4_sft_numerical.py: inference regression uses AVX2MXFP4_MOE when
  AMXFP4_KGroup_MOE is absent.

Validated on 2x EPYC 7452 (AVX2, no AVX-512): test_mxfp4_sft_numerical.py
TP1/TP2 all stages rel-L2 <= 0.4% (limits 3-5%); validate_dsv4_mxfp4_sft_layer.py
on DeepSeek-V4-Flash-0731 layer 3, TP1 and TP2: passed, forward/grad_input/
LoRA grads rel-L2 0.10-0.29%, base weights unchanged.

Note: the build uses -ffast-math, so exp() past the float range is undefined;
the scalar sigmoid tails clamp the argument like the vector path.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Mgsduzz2fx1gSvfHbR8RZ

* fix(sft): clamp MXFP4 scalar-tail sigmoid exponent

* feat(sft): integrate native V4 experts with LoRA training and export

Wrap only routed experts while preserving V4 routing and shared branches. Validate native checkpoint provenance and stream a BF16 non-expert cache without dequantizing routed experts.

Add audited static SGLang export and reproducible training, resume and generation acceptance helpers. CPU retain/GPU recompute GAS1/GAS4 smoke and 265 KT regression tests pass; long-run quality and resume acceptance remain in progress.

* fix(sft): retain async inference buffers until CUDA work completes

* test(sft): isolate V4 adapter effects with matched-kernel controls

Allow baseline and non-expert-only exports to retain a zero-B expert adapter so all ablations execute the same native forward kernel. Add paired held-out generation reporting with explicitly diagnostic lexical metrics.

* test(sft): compare PEFT targets semantically during V4 resume audits

* test(sft): cancel queued V4 inference checks after a failed request

* test(sft): preserve raw V4 responses before finite-output validation

* feat: prepare V4 cache atomically and package static adapter export

* build: preserve accepted CUDA carriers across Python ABIs

* test: retain FP8 loading contracts after V4 wrapper integration

* test: exclude historical concatenated-sample smoke from delivery

* test(sft): audit unpacked batches and single-GPU resume state

* docs(sft): align April V4 recipe with approved unpacked delivery

* test: keep frozen-parameter audit indices exact for large tensors

* test: record the loaded native backend in acceptance evidence

* test: timestamp training audit events for startup measurements

* docs: pin V4 serving dependencies and account for reserved token slots

* [refactor](sft): keep V4 follow-up focused on runtime integration

---------

Co-authored-by: 05yuki <ceu.azul.samu@gmail.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-30 21:29:43 +08:00

308 lines
11 KiB
Python

#!/usr/bin/env python3
# SPDX-License-Identifier: Apache-2.0
"""Pure-Python numerical contract for native MXFP4 routed-expert LoRA."""
from __future__ import annotations
import os
import sys
import pytest
import torch
import torch.nn.functional as F
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from ci.ci_register import register_cpu_ci
from mxfp4_sft_reference import (
E2M1_VALUES,
LORA_NAMES,
MXFP4ExpertWeights,
MXFP4Projection,
base_storage_hash,
dequantize_mxfp4,
pack_e2m1_codes,
relative_l2_and_cosine,
run_routed_reference,
transpose_free_mxfp4_dx,
ue8m0_to_bf16,
unpack_e2m1,
v4_swiglu,
)
register_cpu_ci(est_time=20, suite="default")
def _make_projection(
output_features: int,
input_features: int,
generator: torch.Generator,
) -> MXFP4Projection:
codes = torch.randint(
0,
16,
(output_features, input_features),
dtype=torch.int64,
generator=generator,
)
# Keep the synthetic MLP well-scaled while exercising several UE8M0 values.
encoded_scales = torch.randint(
120,
123,
(output_features, input_features // 32),
dtype=torch.uint8,
generator=generator,
)
return MXFP4Projection(
packed=pack_e2m1_codes(codes),
scales=ue8m0_to_bf16(encoded_scales),
)
def _make_problem(*, expert_count=4, hidden=64, intermediate=64, rank=8):
generator = torch.Generator(device="cpu").manual_seed(20260902)
experts = [
MXFP4ExpertWeights(
gate=_make_projection(intermediate, hidden, generator),
up=_make_projection(intermediate, hidden, generator),
down=_make_projection(hidden, intermediate, generator),
)
for _ in range(expert_count)
]
def randn(shape, scale=0.04):
return torch.randn(shape, generator=generator, dtype=torch.float32) * scale
lora = {
"gate_lora_a": randn((expert_count, rank, hidden)),
"gate_lora_b": randn((expert_count, intermediate, rank)),
"up_lora_a": randn((expert_count, rank, hidden)),
"up_lora_b": randn((expert_count, intermediate, rank)),
"down_lora_a": randn((expert_count, rank, intermediate)),
"down_lora_b": randn((expert_count, hidden, rank)),
}
inputs = randn((5, hidden), scale=0.20)
grad_output = randn((5, hidden), scale=0.10)
expert_ids = torch.tensor(
[[0, 1], [1, 2], [2, 0], [0, 2], [1, 0]], dtype=torch.int64
)
route_weights = torch.tensor(
[[0.75, 0.25], [0.60, 0.40], [0.55, 0.45], [0.70, 0.30], [0.65, 0.35]],
dtype=torch.float32,
)
return experts, lora, inputs, expert_ids, route_weights, grad_output
def test_all_e2m1_codepoints_and_nibble_order():
codes = torch.arange(16, dtype=torch.int64).repeat(2).reshape(1, 32)
packed = pack_e2m1_codes(codes)
decoded = unpack_e2m1(packed)
torch.testing.assert_close(decoded[0, :16], E2M1_VALUES, rtol=0, atol=0)
assert not torch.signbit(decoded[0, 0])
assert torch.signbit(decoded[0, 8])
assert int(packed[0, 0]) == 0x10
assert int(packed[0, 7]) == 0xFE
def test_ue8m0_to_bf16_is_an_exact_exponent_bit_mapping():
encoded = torch.tensor([0, 1, 120, 126, 127, 128, 254, 255], dtype=torch.uint8)
converted = ue8m0_to_bf16(encoded)
expected_bits = (encoded.to(torch.int32) << 7).to(torch.int16)
expected_bits[0] = 0x0040
assert torch.equal(converted.view(torch.int16), expected_bits)
# Native FTZ/DAZ can flush this BF16 subnormal during float conversion.
assert int(converted.view(torch.int16)[0]) == 0x0040
assert float(converted[6]) == 2.0**127
assert torch.isinf(converted[7])
@pytest.mark.parametrize("tokens", [1, 3, 4, 17])
def test_transpose_free_dx_matches_dense_with_row_tail_and_strides(tokens):
generator = torch.Generator(device="cpu").manual_seed(1000 + tokens)
n, k = 37, 64
codes = torch.randint(0, 16, (n, k), generator=generator)
packed_contiguous = pack_e2m1_codes(codes)
scale_encoded = torch.randint(
120, 129, (n, k // 32), dtype=torch.uint8, generator=generator
)
# Slice padded storage to exercise non-trivial row strides and a non-tile N tail.
packed_storage = torch.zeros((n, packed_contiguous.shape[1] + 5), dtype=torch.uint8)
packed_storage[:, : packed_contiguous.shape[1]] = packed_contiguous
packed = packed_storage[:, : packed_contiguous.shape[1]]
scale_storage = torch.ones((n, scale_encoded.shape[1] + 3), dtype=torch.bfloat16)
scale_storage[:, : scale_encoded.shape[1]] = ue8m0_to_bf16(scale_encoded)
scales = scale_storage[:, : scale_encoded.shape[1]]
assert not packed.is_contiguous()
assert not scales.is_contiguous()
grad_output = torch.randn((tokens, n), generator=generator)
actual = transpose_free_mxfp4_dx(grad_output, packed, scales)
expected = grad_output @ dequantize_mxfp4(packed, scales)
torch.testing.assert_close(actual, expected, rtol=2.0e-6, atol=2.0e-6)
def test_mxfp4_layout_validation_fails_closed():
packed = torch.zeros((3, 32), dtype=torch.uint8)
scales = torch.ones((3, 2), dtype=torch.bfloat16)
grad_output = torch.ones((2, 3), dtype=torch.float32)
with pytest.raises(ValueError, match="group_size=32"):
transpose_free_mxfp4_dx(grad_output, packed, scales, group_size=16)
with pytest.raises(ValueError, match="scales have shape"):
transpose_free_mxfp4_dx(grad_output, packed, scales[:, :1])
with pytest.raises(ValueError, match="grad_output must have shape"):
transpose_free_mxfp4_dx(torch.ones((2, 4)), packed, scales)
with pytest.raises(ValueError, match="rank-2 uint8"):
dequantize_mxfp4(packed.to(torch.int8), scales)
def test_v4_clamp_backward_matches_pytorch_including_exact_boundaries():
limit = 10.0
gate = torch.tensor([-20.0, 9.0, 10.0, 11.0], requires_grad=True)
up = torch.tensor([-11.0, -10.0, 10.0, 11.0], requires_grad=True)
output = v4_swiglu(gate, up, limit)
grad_gate, grad_up = torch.autograd.grad(output.sum(), (gate, up))
clamped_gate = torch.clamp(gate.detach(), max=limit)
silu = F.silu(clamped_gate)
sigmoid = torch.sigmoid(clamped_gate)
silu_derivative = sigmoid * (1.0 + clamped_gate * (1.0 - sigmoid))
expected_gate = (
torch.clamp(up.detach(), min=-limit, max=limit)
* silu_derivative
* (gate.detach() <= limit)
)
expected_up = silu * ((up.detach() >= -limit) & (up.detach() <= limit))
torch.testing.assert_close(grad_gate, expected_gate)
torch.testing.assert_close(grad_up, expected_up)
assert grad_gate[-1] == 0
assert grad_up[0] == 0 and grad_up[-1] == 0
assert grad_gate[2] != 0 and grad_up[1] != 0 and grad_up[2] != 0
def test_lora_delta_is_applied_before_v4_clamp():
base_gate = torch.tensor([9.75])
lora_delta = torch.tensor([1.0])
up = torch.tensor([2.0])
correct = v4_swiglu(base_gate + lora_delta, up, 10.0)
incorrect_post_clamp = F.silu(torch.clamp(base_gate, max=10.0) + lora_delta) * up
torch.testing.assert_close(correct, F.silu(torch.tensor([10.0])) * up)
assert not torch.allclose(correct, incorrect_post_clamp)
def test_single_expert_forward_dx_droute_and_six_lora_gradients():
generator = torch.Generator(device="cpu").manual_seed(77)
hidden = intermediate = 64
rank = 8
expert = MXFP4ExpertWeights(
gate=_make_projection(intermediate, hidden, generator),
up=_make_projection(intermediate, hidden, generator),
down=_make_projection(hidden, intermediate, generator),
)
def randn(shape, scale):
return torch.randn(shape, generator=generator) * scale
lora = {
"gate_lora_a": randn((1, rank, hidden), 0.04),
"gate_lora_b": randn((1, intermediate, rank), 0.04),
"up_lora_a": randn((1, rank, hidden), 0.04),
"up_lora_b": randn((1, intermediate, rank), 0.04),
"down_lora_a": randn((1, rank, intermediate), 0.04),
"down_lora_b": randn((1, hidden, rank), 0.04),
}
inputs = randn((3, hidden), 0.2)
expert_ids = torch.zeros((3, 1), dtype=torch.int64)
routes = torch.tensor([[0.6], [0.7], [0.8]], dtype=torch.float32)
grad_output = randn((3, hidden), 0.1)
dense = run_routed_reference(
inputs,
expert_ids,
routes,
[expert],
lora,
grad_output,
lora_scaling=2.0,
swiglu_limit=10.0,
transpose_free_backward=False,
)
transpose_free = run_routed_reference(
inputs,
expert_ids,
routes,
[expert],
lora,
grad_output,
lora_scaling=2.0,
swiglu_limit=10.0,
transpose_free_backward=True,
)
for actual, expected in zip(transpose_free[:3], dense[:3]):
torch.testing.assert_close(actual, expected, rtol=2.0e-6, atol=2.0e-6)
assert torch.count_nonzero(actual) > 0
for name in LORA_NAMES:
torch.testing.assert_close(
transpose_free[3][name], dense[3][name], rtol=2.0e-6, atol=2.0e-6
)
assert torch.count_nonzero(transpose_free[3][name]) > 0
def test_routed_topk_forward_and_all_gradients_match_dense_oracle():
experts, lora, inputs, expert_ids, routes, grad_output = _make_problem()
before_hash = base_storage_hash(experts)
dense = run_routed_reference(
inputs,
expert_ids,
routes,
experts,
lora,
grad_output,
lora_scaling=2.0,
swiglu_limit=10.0,
transpose_free_backward=False,
)
transpose_free = run_routed_reference(
inputs,
expert_ids,
routes,
experts,
lora,
grad_output,
lora_scaling=2.0,
swiglu_limit=10.0,
transpose_free_backward=True,
)
after_hash = base_storage_hash(experts)
for name, actual, expected in (
("forward", transpose_free[0], dense[0]),
("dX", transpose_free[1], dense[1]),
("dRoute", transpose_free[2], dense[2]),
):
relative_l2, cosine = relative_l2_and_cosine(actual, expected)
assert relative_l2 <= 2.0e-6, f"{name}: relative L2={relative_l2}"
assert cosine >= 0.999999, f"{name}: cosine={cosine}"
for name in LORA_NAMES:
actual = transpose_free[3][name]
expected = dense[3][name]
relative_l2, cosine = relative_l2_and_cosine(actual, expected)
assert relative_l2 <= 2.0e-6, f"{name}: relative L2={relative_l2}"
assert cosine >= 0.999999, f"{name}: cosine={cosine}"
assert torch.count_nonzero(actual[:3]) > 0, (
f"{name}: active expert gradient is zero"
)
assert torch.count_nonzero(actual[3]) == 0, (
f"{name}: inactive expert gradient is nonzero"
)
assert torch.count_nonzero(transpose_free[2]) == transpose_free[2].numel()
assert before_hash == after_hash
if __name__ == "__main__":
raise SystemExit(pytest.main([__file__, "-q"]))