mirror of
https://github.com/kvcache-ai/ktransformers.git
synced 2026-10-18 14:43:29 +08:00
a5d7ad9047
* 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>
323 lines
12 KiB
Python
323 lines
12 KiB
Python
"""Native V4 selective-loading contracts; no model download is required."""
|
|
|
|
import json
|
|
import importlib.util
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
import torch
|
|
from safetensors.torch import load_file, save_file
|
|
|
|
from kt_kernel.sft.artifacts import KTArtifactError, _sha256_file
|
|
from kt_kernel.sft.deepseek_v4 import (
|
|
canonical_non_expert_key,
|
|
dequantize_fp8_blocks,
|
|
prepare_non_expert_cache,
|
|
resolve_native_load_plan,
|
|
)
|
|
|
|
|
|
def test_fp8_decode_partial_blocks():
|
|
weight = torch.full((129, 130), 2.0).to(torch.float8_e4m3fn)
|
|
scale = torch.tensor([[0.5, 1.0], [2.0, 4.0]]).to(torch.float8_e8m0fnu)
|
|
result = dequantize_fp8_blocks(weight, scale)
|
|
assert result.dtype == torch.bfloat16
|
|
assert result[0, 0] == 1 and result[0, 129] == 2
|
|
assert result[128, 0] == 4 and result[128, 129] == 8
|
|
with pytest.raises(KTArtifactError, match="shape"):
|
|
dequantize_fp8_blocks(weight, scale[:1])
|
|
|
|
|
|
def test_native_key_mapping():
|
|
assert canonical_non_expert_key("layers.2.attn.indexer.compressor.wkv.weight") == (
|
|
"model.layers.2.self_attn.compressor.indexer.kv_proj.weight"
|
|
)
|
|
assert (
|
|
canonical_non_expert_key("layers.2.hc_attn_scale")
|
|
== "model.layers.2.attn_hc.scale"
|
|
)
|
|
assert canonical_non_expert_key("norm.weight") == "model.norm.weight"
|
|
with pytest.raises(KTArtifactError):
|
|
canonical_non_expert_key("mtp.0.norm.weight")
|
|
|
|
|
|
@pytest.fixture
|
|
def native_cache(tmp_path):
|
|
source, cache = tmp_path / "source", tmp_path / "cache"
|
|
source.mkdir()
|
|
config = {
|
|
"model_type": "deepseek_v4",
|
|
"num_hidden_layers": 1,
|
|
"n_routed_experts": 1,
|
|
"hidden_size": 32,
|
|
"moe_intermediate_size": 32,
|
|
"quantization_config": {
|
|
"quant_method": "fp8",
|
|
"fmt": "e4m3",
|
|
"weight_block_size": [128, 128],
|
|
"scale_fmt": "ue8m0",
|
|
},
|
|
}
|
|
(source / "config.json").write_text(json.dumps(config))
|
|
tensors = {
|
|
"layers.0.attn.wq_a.weight": torch.ones(32, 32).to(torch.float8_e4m3fn),
|
|
"layers.0.attn.wq_a.scale": torch.tensor([[0.5]]).to(torch.float8_e8m0fnu),
|
|
"layers.0.hc_attn_scale": torch.ones(3),
|
|
"layers.0.ffn.gate.tid2eid": torch.zeros(8, 1, dtype=torch.int64),
|
|
"mtp.0.norm.weight": torch.ones(32, dtype=torch.bfloat16),
|
|
}
|
|
for proj in ("w1", "w2", "w3"):
|
|
tensors[f"layers.0.ffn.experts.0.{proj}.weight"] = torch.zeros(
|
|
32, 16, dtype=torch.int8
|
|
)
|
|
tensors[f"layers.0.ffn.experts.0.{proj}.scale"] = torch.ones(32, 1).to(
|
|
torch.float8_e8m0fnu
|
|
)
|
|
save_file(tensors, source / "source.safetensors")
|
|
(source / "model.safetensors.index.json").write_text(
|
|
json.dumps({"weight_map": {key: "source.safetensors" for key in tensors}})
|
|
)
|
|
prepare_non_expert_cache(str(source), str(cache))
|
|
return source, cache
|
|
|
|
|
|
def test_cache_roundtrip_and_tampering(native_cache):
|
|
source, cache = native_cache
|
|
cfg = SimpleNamespace(
|
|
kt_weight_path=str(source), kt_non_expert_weight_path=str(cache)
|
|
)
|
|
plan = resolve_native_load_plan(cfg, str(source))
|
|
assert len(plan.weight_keys) == 3
|
|
data = load_file(plan.checkpoint_files[0])
|
|
assert data["model.layers.0.attn_hc.scale"].dtype == torch.float32
|
|
assert data["model.layers.0.mlp.gate.tid2eid"].dtype == torch.int64
|
|
assert torch.all(data["model.layers.0.self_attn.q_a_proj.weight"] == 0.5)
|
|
with open(plan.checkpoint_files[0], "r+b") as handle:
|
|
handle.seek(-1, 2)
|
|
handle.write(b"\x00")
|
|
with pytest.raises(KTArtifactError, match="hash mismatch"):
|
|
resolve_native_load_plan(cfg, str(source))
|
|
|
|
|
|
def test_first_load_prepares_cache_and_reuses_without_conversion(native_cache, tmp_path, monkeypatch):
|
|
import kt_kernel.sft.deepseek_v4 as v4
|
|
|
|
source, _ = native_cache
|
|
cache = tmp_path / "automatic"
|
|
cfg = SimpleNamespace(kt_weight_path=str(source), kt_non_expert_weight_path=str(cache))
|
|
first = resolve_native_load_plan(cfg, str(source))
|
|
before = {p.name: p.stat().st_mtime_ns for p in cache.iterdir()}
|
|
|
|
def unexpected_conversion(*args):
|
|
pytest.fail("warm load must not regenerate the cache")
|
|
|
|
monkeypatch.setattr(v4, "_convert_non_expert_cache", unexpected_conversion)
|
|
second = resolve_native_load_plan(cfg, str(source))
|
|
assert first.manifest == second.manifest
|
|
assert before == {p.name: p.stat().st_mtime_ns for p in cache.iterdir()}
|
|
|
|
|
|
def test_concurrent_preparation_has_one_writer(native_cache, tmp_path, monkeypatch):
|
|
from concurrent.futures import ThreadPoolExecutor
|
|
import kt_kernel.sft.deepseek_v4 as v4
|
|
|
|
source, _ = native_cache
|
|
cache = tmp_path / "concurrent"
|
|
convert = v4._convert_non_expert_cache
|
|
calls = []
|
|
|
|
def counted(source, staging):
|
|
calls.append(staging)
|
|
assert not cache.exists(), "unvalidated weights must not be published"
|
|
return convert(source, staging)
|
|
|
|
monkeypatch.setattr(v4, "_convert_non_expert_cache", counted)
|
|
with ThreadPoolExecutor(max_workers=2) as executor:
|
|
jobs = [executor.submit(prepare_non_expert_cache, str(source), str(cache)) for _ in range(2)]
|
|
manifests = [job.result(timeout=30) for job in jobs]
|
|
assert len(calls) == 1
|
|
assert manifests[0] == manifests[1]
|
|
|
|
|
|
def test_interrupted_owned_preparation_is_retried(native_cache, tmp_path, monkeypatch):
|
|
import kt_kernel.sft.deepseek_v4 as v4
|
|
|
|
source, _ = native_cache
|
|
cache = tmp_path / "retry"
|
|
convert = v4._convert_non_expert_cache
|
|
|
|
def interrupted(source, staging):
|
|
(staging / "incomplete.safetensors").write_bytes(b"partial")
|
|
raise KeyboardInterrupt("simulated interrupted converter")
|
|
|
|
monkeypatch.setattr(v4, "_convert_non_expert_cache", interrupted)
|
|
with pytest.raises(KeyboardInterrupt):
|
|
prepare_non_expert_cache(str(source), str(cache))
|
|
assert not cache.exists()
|
|
monkeypatch.setattr(v4, "_convert_non_expert_cache", convert)
|
|
assert prepare_non_expert_cache(str(source), str(cache))["status"] == "ready"
|
|
assert not (cache / "incomplete.safetensors").exists()
|
|
assert not (tmp_path / ".retry.kt-v4-cache.partial").exists()
|
|
|
|
|
|
def test_cache_failure_does_not_publish_or_delete_unrelated_data(native_cache, tmp_path, monkeypatch):
|
|
import kt_kernel.sft.deepseek_v4 as v4
|
|
|
|
source, _ = native_cache
|
|
cache = tmp_path / "full-disk"
|
|
monkeypatch.setattr(v4.shutil, "disk_usage", lambda _: SimpleNamespace(free=0))
|
|
with pytest.raises(KTArtifactError, match="Insufficient space"):
|
|
prepare_non_expert_cache(str(source), str(cache))
|
|
assert not cache.exists()
|
|
unrelated = tmp_path / "unrelated"
|
|
unrelated.mkdir()
|
|
note = unrelated / "user.txt"
|
|
note.write_text("keep me")
|
|
with pytest.raises(KTArtifactError):
|
|
prepare_non_expert_cache(str(source), str(unrelated))
|
|
assert note.read_text() == "keep me"
|
|
|
|
|
|
def test_changed_source_and_symlink_cache_are_rejected(native_cache, tmp_path):
|
|
source, cache = native_cache
|
|
link = tmp_path / "linked-cache"
|
|
link.symlink_to(cache, target_is_directory=True)
|
|
with pytest.raises(KTArtifactError, match="real directory"):
|
|
prepare_non_expert_cache(str(source), str(link))
|
|
config_path = source / "config.json"
|
|
config = json.loads(config_path.read_text())
|
|
config["initializer_range"] = 0.01
|
|
config_path.write_text(json.dumps(config))
|
|
with pytest.raises(KTArtifactError, match="changed after cache"):
|
|
prepare_non_expert_cache(str(source), str(cache))
|
|
|
|
|
|
def test_config_loading_allows_an_unprepared_cache(native_cache, tmp_path):
|
|
from kt_kernel.sft.artifacts import should_disable_kt_source_quantizer
|
|
|
|
cfg = SimpleNamespace(kt_expert_weight_format="mxfp4", kt_non_expert_weight_path=str(tmp_path / "future"))
|
|
assert should_disable_kt_source_quantizer(cfg, SimpleNamespace(model_type="deepseek_v4"))
|
|
assert not (tmp_path / "future").exists()
|
|
|
|
|
|
@pytest.fixture
|
|
def adapter_export(native_cache, tmp_path, monkeypatch):
|
|
source, cache = native_cache
|
|
from kt_kernel.sft import export_dsv4_sglang_adapter as module
|
|
adapter = tmp_path / "adapter"
|
|
adapter.mkdir()
|
|
(adapter / "adapter_config.json").write_text(json.dumps({"r": 8, "lora_alpha": 16, "lora_dropout": 0.0}))
|
|
prefix = "base_model.model.model.layers.0.self_attn.q_a_proj.lora_"
|
|
save_file(
|
|
{
|
|
prefix + "A.weight": torch.full((8, 32), 0.25),
|
|
prefix + "B.weight": torch.full((32, 8), 0.125),
|
|
},
|
|
adapter / "adapter_model.safetensors",
|
|
)
|
|
fused = {}
|
|
for proj in ("gate", "up", "down"):
|
|
fused[f"layers.0.experts.{proj}_lora_a"] = torch.ones(1, 8, 32)
|
|
fused[f"layers.0.experts.{proj}_lora_b"] = torch.ones(1, 32, 8)
|
|
save_file(fused, adapter / "fused_expert_lora.safetensors")
|
|
manifest = {
|
|
"status": "ready",
|
|
"expert_weight_format": "mxfp4",
|
|
"lora": {"rank": 8, "alpha": 16},
|
|
"base": {
|
|
"fingerprint": module.inspect_native_checkpoint(source)["fingerprint"]
|
|
},
|
|
}
|
|
|
|
def seal():
|
|
manifest["artifacts"] = {
|
|
p.name: {"size": p.stat().st_size, "sha256": _sha256_file(p)}
|
|
for p in adapter.iterdir()
|
|
if p.name != "kt_adapter_manifest.json"
|
|
}
|
|
(adapter / "kt_adapter_manifest.json").write_text(json.dumps(manifest))
|
|
|
|
seal()
|
|
return module, source, cache, adapter, tmp_path / "export", seal
|
|
|
|
|
|
@pytest.mark.parametrize("component", ["all", "base", "experts", "nonexperts"])
|
|
def test_static_export_components(adapter_export, component):
|
|
module, source, cache, adapter, output, _ = adapter_export
|
|
report = module.export(
|
|
str(source), str(cache), str(adapter), str(output), component
|
|
)
|
|
tensors = load_file(output / "model" / "non-experts-000.safetensors")
|
|
merged = component in {"all", "nonexperts"}
|
|
assert torch.all(tensors["layers.0.attn.wq_a.weight"] == (1.0 if merged else 0.5))
|
|
assert report["standard_pairs_consumed"] == (
|
|
["model.layers.0.self_attn.q_a_proj"] if merged else []
|
|
)
|
|
assert report["expert_exported_tensor_count"] == (
|
|
6 if component in {"all", "experts"} else 0
|
|
)
|
|
assert "quantization_config" not in json.loads(
|
|
(output / "model" / "config.json").read_text()
|
|
)
|
|
assert all(".experts." not in key for key in tensors)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"damage", ["missing_pair", "wrong_layer", "bad_shape", "nan", "unsealed"]
|
|
)
|
|
def test_static_export_rejects_damaged_adapters(adapter_export, damage):
|
|
module, source, cache, adapter, output, seal = adapter_export
|
|
path = adapter / "fused_expert_lora.safetensors"
|
|
tensors = load_file(path)
|
|
key = next(iter(tensors))
|
|
if damage in {"missing_pair", "unsealed"}:
|
|
tensors.pop(key)
|
|
elif damage == "wrong_layer":
|
|
tensors = {
|
|
key.replace("layers.0", "layers.1"): value for key, value in tensors.items()
|
|
}
|
|
elif damage == "bad_shape":
|
|
tensors[key] = tensors[key][..., :1].contiguous()
|
|
else:
|
|
tensors[key].flatten()[0] = float("nan")
|
|
save_file(tensors, path)
|
|
if damage != "unsealed":
|
|
seal()
|
|
with pytest.raises(KTArtifactError):
|
|
module.export(str(source), str(cache), str(adapter), str(output))
|
|
assert not (output / "deployment_manifest.json").exists()
|
|
|
|
|
|
def test_static_export_rejects_unknown_standard_module(adapter_export):
|
|
module, source, cache, adapter, output, seal = adapter_export
|
|
path = adapter / "adapter_model.safetensors"
|
|
tensors = {
|
|
key.replace("q_a_proj", "o_a_proj"): value
|
|
for key, value in load_file(path).items()
|
|
}
|
|
save_file(tensors, path)
|
|
seal()
|
|
with pytest.raises(KTArtifactError, match="unsupported non-expert"):
|
|
module.export(str(source), str(cache), str(adapter), str(output))
|
|
|
|
|
|
def test_static_baseline_can_match_the_expert_forward_kernel(adapter_export):
|
|
module, source, cache, adapter, output, _ = adapter_export
|
|
report = module.export(
|
|
str(source),
|
|
str(cache),
|
|
str(adapter),
|
|
str(output),
|
|
component="base",
|
|
match_expert_kernel=True,
|
|
)
|
|
assert report["zero_expert_adapter_control"] and not report["expert_effect_enabled"]
|
|
assert (
|
|
report["expert_exported_tensor_count"] == 6
|
|
and report["standard_pairs_consumed"] == []
|
|
)
|
|
tensors = load_file(output / "experts" / "adapter_model.safetensors")
|
|
for name, tensor in tensors.items():
|
|
assert torch.all(tensor == (0 if ".lora_B." in name else 1))
|