Files
yyj e80614fc2d [feat](kt-sft): Activation reuse & Int8 kernel refactor & native block-FP8 LoRA SFT (#2141)
* feat(sft): support distributed activation reuse policies

* feat(sft): add frozen-base INT8 LoRA training

* fix(sft): make INT8 expert LoRA rank-zero authoritative

* fix(sft): preserve DeepSeek router LoRA routing

* feat(sft): enable persistent INT8 LoRA training

* perf(sft): accelerate INT8 VNNI with oneDNN BRGEMM

* perf(int8): fuse oneDNN compensation into backward repack

* [feat]: support BF16 expert LoRA training

* [fix]: honor forwarded activation policy in SFT workers

* feat(sft): add native block-FP8 routed expert LoRA

* feat(sft): expose explicit expert placeholder ownership

* fix(sft): publish fused adapter artifacts atomically

* feat(sft): own artifact and adapter lifecycle contracts

* fix(sft): harden artifact and rank-local contracts

* fix(sft): auto-adapt owner before adapter restore

* style(sft): keep lifecycle comments concise

* fix(sft): require fused adapter manifests

* test(sft): use spawn for distributed workers

* fix(sft): preserve runtime checkpoint metadata

* fix(sft): validate wrapped runtime configuration

* fix(sft): preserve expert format provenance

* fix(sft): own routed experts during device dispatch

* test(sft): lock explicit quantization conflict

* fix(cpu): make shared memory buffers lifetime-safe

* release: prepare v0.7.0
2026-08-17 16:25:14 +08:00

519 lines
19 KiB
Python

# SPDX-License-Identifier: Apache-2.0
import json
from types import SimpleNamespace
import pytest
import torch
import torch.nn as nn
import torch.multiprocessing as mp
from safetensors.torch import save_file
from kt_kernel.sft import get_kt_expert_placeholders
from kt_kernel.sft.arch import MOEArchConfig
from kt_kernel.sft.checkpoint import (
FULL_WEIGHT_INDEX_NAME,
load_full_weight_checkpoint,
load_full_weight_layer,
resolve_full_weight_checkpoint,
)
from kt_kernel.sft.config import KTConfig
from kt_kernel.sft.layer import KTMoELayerWrapper
from kt_kernel.sft.lora import load_kt_moe_from_adapter, save_kt_moe_to_adapter
from kt_kernel.sft.wrapper import wrap_moe_layers_with_kt_wrapper
from kt_kernel.sft.weights import _clear_original_expert_weights, extract_moe_weights
class _FakeFullBackend:
def __init__(self, *, expert_num=2, intermediate_size=3, hidden_size=4, fill=0.0):
self.gate_proj_buf = nn.Parameter(
torch.full((expert_num, intermediate_size, hidden_size), fill, dtype=torch.bfloat16)
)
self.up_proj_buf = nn.Parameter(
torch.full((expert_num, intermediate_size, hidden_size), fill + 1, dtype=torch.bfloat16)
)
self.down_proj_buf = nn.Parameter(
torch.full((expert_num, hidden_size, intermediate_size), fill + 2, dtype=torch.bfloat16)
)
self._base_weights_dirty = False
self._kt_full_checkpoint_load_failed = False
def _fake_wrapper(layer_idx, *, fill=0.0, full=True, backend=True):
moe_config = MOEArchConfig(
moe_layer_attr="mlp",
router_attr="gate",
experts_attr="experts",
weight_names=("gate_proj", "up_proj", "down_proj"),
expert_num=2,
intermediate_size=3,
num_experts_per_tok=1,
)
return SimpleNamespace(
layer_idx=layer_idx,
hidden_size=4,
moe_config=moe_config,
_full_weight_grad=full,
wrapper=_FakeFullBackend(fill=fill) if backend else None,
lora_experts=None,
_fused_expert_lora_params=None,
)
def _params(wrapper):
backend = wrapper.wrapper
return (backend.gate_proj_buf, backend.up_proj_buf, backend.down_proj_buf)
def test_full_checkpoint_round_trip_preserves_parameter_grad_and_optimizer_identity(tmp_path):
wrappers = [_fake_wrapper(1, fill=10), _fake_wrapper(3, fill=20)]
model = SimpleNamespace(_kt_wrappers=wrappers)
all_params = [param for wrapper in wrappers for param in _params(wrapper)]
optimizer = torch.optim.SGD(all_params, lr=0.1, momentum=0.9)
for param in all_params:
param.grad = torch.ones_like(param)
optimizer.step()
saved_values = [param.detach().clone() for param in all_params]
parameter_ids = [id(param) for param in all_params]
parameter_ptrs = [param.data_ptr() for param in all_params]
grad_ptrs = [param.grad.data_ptr() for param in all_params]
optimizer_state_ids = [id(optimizer.state[param]) for param in all_params]
save_kt_moe_to_adapter(model, str(tmp_path))
assert resolve_full_weight_checkpoint(tmp_path) == str(tmp_path)
assert (tmp_path / FULL_WEIGHT_INDEX_NAME).is_file()
with torch.no_grad():
for param in all_params:
param.fill_(-7)
load_kt_moe_from_adapter(model, str(tmp_path))
for param, expected in zip(all_params, saved_values):
torch.testing.assert_close(param, expected)
assert [id(param) for param in all_params] == parameter_ids
assert [param.data_ptr() for param in all_params] == parameter_ptrs
assert [param.grad.data_ptr() for param in all_params] == grad_ptrs
assert [id(optimizer.state[param]) for param in all_params] == optimizer_state_ids
assert all(wrapper.wrapper._base_weights_dirty for wrapper in wrappers)
assert all(not wrapper.wrapper._kt_full_checkpoint_load_failed for wrapper in wrappers)
def test_full_checkpoint_is_published_atomically_by_manifest(tmp_path, monkeypatch):
wrappers = [_fake_wrapper(0, fill=1), _fake_wrapper(1, fill=2)]
model = SimpleNamespace(_kt_wrappers=wrappers)
save_kt_moe_to_adapter(model, str(tmp_path))
old_index = json.loads((tmp_path / FULL_WEIGHT_INDEX_NAME).read_text())
old_values = [param.detach().clone() for wrapper in wrappers for param in _params(wrapper)]
with torch.no_grad():
for param in [param for wrapper in wrappers for param in _params(wrapper)]:
param.add_(100)
import safetensors.torch
real_save_file = safetensors.torch.save_file
call_count = 0
def fail_second_shard(*args, **kwargs):
nonlocal call_count
call_count += 1
if call_count == 2:
raise OSError("synthetic shard write failure")
return real_save_file(*args, **kwargs)
monkeypatch.setattr(safetensors.torch, "save_file", fail_second_shard)
with pytest.raises(OSError, match="synthetic shard write failure"):
save_kt_moe_to_adapter(model, str(tmp_path))
assert json.loads((tmp_path / FULL_WEIGHT_INDEX_NAME).read_text()) == old_index
load_kt_moe_from_adapter(model, str(tmp_path))
for param, expected in zip(
[param for wrapper in wrappers for param in _params(wrapper)],
old_values,
):
torch.testing.assert_close(param, expected)
def test_full_checkpoint_layer_loader_validates_and_returns_logical_tensors(tmp_path):
wrapper = _fake_wrapper(7, fill=4)
model = SimpleNamespace(_kt_wrappers=[wrapper])
save_kt_moe_to_adapter(model, str(tmp_path))
expected_shapes = {
"gate_proj": (2, 3, 4),
"up_proj": (2, 3, 4),
"down_proj": (2, 4, 3),
}
loaded = load_full_weight_layer(
str(tmp_path),
layer_idx=7,
expected_shapes=expected_shapes,
)
for tensor, expected in zip(loaded, _params(wrapper)):
torch.testing.assert_close(tensor, expected)
assert tensor.is_contiguous()
bad_shapes = dict(expected_shapes)
bad_shapes["gate_proj"] = (2, 4, 4)
with pytest.raises(RuntimeError, match="index shapes mismatch"):
load_full_weight_layer(str(tmp_path), layer_idx=7, expected_shapes=bad_shapes)
def test_full_checkpoint_rejects_missing_layer_without_mutating_parameters(tmp_path):
wrappers = [_fake_wrapper(0, fill=3), _fake_wrapper(1, fill=5)]
model = SimpleNamespace(_kt_wrappers=wrappers)
save_kt_moe_to_adapter(model, str(tmp_path))
before = [param.detach().clone() for wrapper in wrappers for param in _params(wrapper)]
index_path = tmp_path / FULL_WEIGHT_INDEX_NAME
index = json.loads(index_path.read_text())
del index["layers"]["1"]
index_path.write_text(json.dumps(index))
with pytest.raises(RuntimeError, match="layer set mismatch"):
load_kt_moe_from_adapter(model, str(tmp_path))
for param, expected in zip(
[param for wrapper in wrappers for param in _params(wrapper)],
before,
):
torch.testing.assert_close(param, expected)
def test_full_checkpoint_rejects_corrupt_shard_size(tmp_path):
wrapper = _fake_wrapper(0)
model = SimpleNamespace(_kt_wrappers=[wrapper])
save_kt_moe_to_adapter(model, str(tmp_path))
index = json.loads((tmp_path / FULL_WEIGHT_INDEX_NAME).read_text())
shard = tmp_path / index["layers"]["0"]["file"]
shard.write_bytes(shard.read_bytes()[:-1])
with pytest.raises(RuntimeError, match="shard size mismatch"):
load_kt_moe_from_adapter(model, str(tmp_path))
def test_lora_only_model_does_not_create_full_checkpoint(tmp_path):
wrapper = _fake_wrapper(0, full=False, backend=False)
model = SimpleNamespace(_kt_wrappers=[wrapper])
save_kt_moe_to_adapter(model, str(tmp_path))
assert not (tmp_path / FULL_WEIGHT_INDEX_NAME).exists()
def test_full_model_refuses_to_resume_without_full_checkpoint(tmp_path):
model = SimpleNamespace(_kt_wrappers=[_fake_wrapper(0)])
with pytest.raises(RuntimeError, match=FULL_WEIGHT_INDEX_NAME):
load_kt_moe_from_adapter(model, str(tmp_path))
class _FakeKTBackend:
def __init__(self, **kwargs):
self.num_experts = kwargs["num_experts"]
self.moe_intermediate_size = kwargs["moe_intermediate_size"]
self.hidden_size = kwargs["hidden_size"]
self._full_weight_grad = kwargs["full_weight_grad"]
self._uses_authoritative_optimizer_grads = False
self.gate_proj = None
self.up_proj = None
self.down_proj = None
def load_weights_from_tensors(self, gate_proj, up_proj, down_proj, physical_to_logical_map_cpu):
torch.testing.assert_close(
physical_to_logical_map_cpu,
torch.arange(self.num_experts, dtype=torch.int64),
)
self.gate_proj = gate_proj
self.up_proj = up_proj
self.down_proj = down_proj
def init_full_weight_grad_buffers(self, gate_proj, up_proj, down_proj):
self.gate_proj_buf = nn.Parameter(gate_proj.clone())
self.up_proj_buf = nn.Parameter(up_proj.clone())
self.down_proj_buf = nn.Parameter(down_proj.clone())
self.grad_gate_proj_buf = torch.empty_like(self.gate_proj_buf)
self.grad_up_proj_buf = torch.empty_like(self.up_proj_buf)
self.grad_down_proj_buf = torch.empty_like(self.down_proj_buf)
class _TinyLayer(nn.Module):
def __init__(self):
super().__init__()
self.mlp = _OriginalMoE()
class _TinyModel(nn.Module):
def __init__(self):
super().__init__()
self.config = SimpleNamespace(
architectures=["Qwen3MoeForCausalLM"],
hidden_size=4,
num_experts=2,
moe_intermediate_size=3,
num_experts_per_tok=1,
max_position_embeddings=16,
)
self.model = nn.Module()
self.model.layers = nn.ModuleList([_TinyLayer()])
def test_wrapping_uses_full_checkpoint_as_initial_authoritative_source(tmp_path, monkeypatch):
saved_wrapper = _fake_wrapper(0, fill=6)
save_kt_moe_to_adapter(SimpleNamespace(_kt_wrappers=[saved_wrapper]), str(tmp_path))
expected = [param.detach().clone() for param in _params(saved_wrapper)]
import kt_kernel.sft.wrapper as wrapper_module
monkeypatch.setattr(wrapper_module, "KT_KERNEL_AVAILABLE", True)
monkeypatch.setattr(wrapper_module, "KTMoEWrapper", _FakeKTBackend)
model = _TinyModel()
with torch.no_grad():
for param in model.model.layers[0].mlp.parameters():
param.fill_(99)
config = KTConfig(
kt_backend="AMXBF16",
kt_expert_checkpoint_path=str(tmp_path),
kt_skip_expert_loading=True,
kt_train_mode="full",
kt_full_weight_grad=True,
kt_lora_rank=0,
kt_model_max_length=16,
)
wrappers = wrap_moe_layers_with_kt_wrapper(model, config)
assert len(wrappers) == 1
for actual, saved in zip(_params(wrappers[0]), expected):
torch.testing.assert_close(actual, saved)
def _distributed_load_worker(rank, checkpoint_path, init_file, result_queue):
import torch.distributed as dist
dist.init_process_group(
"gloo",
init_method=f"file://{init_file}",
rank=rank,
world_size=2,
)
try:
wrapper = _fake_wrapper(0, fill=-9, backend=rank == 0)
load_full_weight_checkpoint([wrapper], checkpoint_path)
result_queue.put(
(
rank,
"ok",
float(wrapper.wrapper.gate_proj_buf.detach().float().sum()) if rank == 0 else None,
)
)
except Exception as exc:
result_queue.put((rank, "error", str(exc)))
finally:
dist.destroy_process_group()
@pytest.mark.skipif(not torch.distributed.is_available(), reason="torch.distributed is unavailable")
def test_distributed_full_checkpoint_load_is_rank0_owned_and_error_synchronized(tmp_path):
wrapper = _fake_wrapper(0, fill=6)
save_kt_moe_to_adapter(SimpleNamespace(_kt_wrappers=[wrapper]), str(tmp_path))
expected_sum = float(wrapper.wrapper.gate_proj_buf.detach().float().sum())
context = mp.get_context("spawn")
result_queue = context.Queue()
init_file = tmp_path / "dist-success"
processes = [
context.Process(
target=_distributed_load_worker,
args=(rank, str(tmp_path), str(init_file), result_queue),
)
for rank in range(2)
]
for process in processes:
process.start()
results = [result_queue.get(timeout=20) for _ in processes]
for process in processes:
process.join(timeout=20)
assert process.exitcode == 0
assert sorted((rank, status) for rank, status, _ in results) == [(0, "ok"), (1, "ok")]
assert next(value for rank, _, value in results if rank == 0) == expected_sum
index_path = tmp_path / FULL_WEIGHT_INDEX_NAME
index = json.loads(index_path.read_text())
index["layers"] = {}
index_path.write_text(json.dumps(index))
failure_queue = context.Queue()
failure_init_file = tmp_path / "dist-failure"
failure_processes = [
context.Process(
target=_distributed_load_worker,
args=(rank, str(tmp_path), str(failure_init_file), failure_queue),
)
for rank in range(2)
]
for process in failure_processes:
process.start()
failures = [failure_queue.get(timeout=20) for _ in failure_processes]
for process in failure_processes:
process.join(timeout=20)
assert process.exitcode == 0
assert sorted((rank, status) for rank, status, _ in failures) == [(0, "error"), (1, "error")]
assert len({message for _, _, message in failures}) == 1
assert "layer set mismatch" in failures[0][2]
class _Expert(nn.Module):
def __init__(self):
super().__init__()
self.gate_proj = nn.Linear(4, 3, bias=False)
self.up_proj = nn.Linear(4, 3, bias=False)
self.down_proj = nn.Linear(3, 4, bias=False)
class _OriginalMoE(nn.Module):
def __init__(self):
super().__init__()
self.gate = nn.Linear(4, 2, bias=False)
self.experts = nn.ModuleList([_Expert(), _Expert()])
def test_extract_fused_experts_rejects_unmarked_tiny_storage_placeholder():
fused_experts = nn.Module()
tiny_storage = torch.empty(1, dtype=torch.bfloat16).untyped_storage()
gate_up = torch.empty(0, dtype=torch.bfloat16).set_(
tiny_storage,
storage_offset=0,
size=(2, 6, 4),
stride=(0, 0, 0),
)
fused_experts.gate_up_proj = nn.Parameter(gate_up, requires_grad=False)
fused_experts.down_proj = nn.Parameter(
torch.empty((2, 4, 3), dtype=torch.bfloat16),
requires_grad=False,
)
moe_module = nn.Module()
moe_module.experts = fused_experts
moe_config = MOEArchConfig(
moe_layer_attr="mlp",
router_attr="gate",
experts_attr="experts",
weight_names=("gate_proj", "up_proj", "down_proj"),
expert_num=2,
intermediate_size=3,
num_experts_per_tok=1,
)
with pytest.raises(RuntimeError, match="zero-storage placeholder"):
extract_moe_weights(moe_module, moe_config)
def _layer_with_expert_placeholders():
moe_config = MOEArchConfig(
moe_layer_attr="mlp",
router_attr="gate",
experts_attr="experts",
weight_names=("gate_proj", "up_proj", "down_proj"),
expert_num=2,
intermediate_size=3,
num_experts_per_tok=1,
)
original_moe = _OriginalMoE()
layer = KTMoELayerWrapper(
original_moe=original_moe,
wrapper=None,
lora_params=None,
moe_config=moe_config,
hidden_size=4,
layer_idx=0,
)
_clear_original_expert_weights(original_moe, moe_config, empty_placeholders=True)
return layer
def test_public_placeholder_helper_returns_exact_identities_and_fqns():
layer = _layer_with_expert_placeholders()
model = nn.Module()
model.layers = nn.ModuleList([layer])
alias = layer.experts[0].gate_proj.weight
model.register_parameter("placeholder_alias", alias)
unrelated = nn.Parameter(torch.empty(0), requires_grad=False)
unrelated._kt_zero_storage = True
model.register_parameter("unrelated_marked_parameter", unrelated)
placeholders = get_kt_expert_placeholders(model)
expected_fqns = {
f"layers.0.experts.{expert_idx}.{projection}.weight"
for expert_idx in range(2)
for projection in ("gate_proj", "up_proj", "down_proj")
}
expected_fqns.add("placeholder_alias")
assert set(placeholders) == expected_fqns
assert placeholders["placeholder_alias"] is alias
assert placeholders["layers.0.experts.0.gate_proj.weight"] is alias
assert all(getattr(parameter, "_kt_zero_storage", False) for parameter in placeholders.values())
assert "unrelated_marked_parameter" not in placeholders
def test_public_placeholder_helper_rejects_conflicting_fqn_identities():
layer = _layer_with_expert_placeholders()
model = nn.Module()
model.layer = layer
first = layer.experts[0].gate_proj.weight
second = layer.experts[0].up_proj.weight
model.named_parameters = lambda remove_duplicate=False: iter((("duplicate", first), ("duplicate", second)))
with pytest.raises(RuntimeError, match="conflicting Parameter identities"):
get_kt_expert_placeholders(model)
def test_state_dict_hook_removes_only_marked_expert_placeholders(tmp_path):
moe_config = MOEArchConfig(
moe_layer_attr="mlp",
router_attr="gate",
experts_attr="experts",
weight_names=("gate_proj", "up_proj", "down_proj"),
expert_num=2,
intermediate_size=3,
num_experts_per_tok=1,
)
layer = KTMoELayerWrapper(
original_moe=_OriginalMoE(),
wrapper=None,
lora_params=None,
moe_config=moe_config,
hidden_size=4,
layer_idx=0,
full_weight_grad=True,
)
tiny_storage = torch.UntypedStorage(1, device="cpu")
fake_tensor = torch.tensor([], dtype=torch.float32, device="cpu").set_(
tiny_storage,
storage_offset=0,
size=(3, 4),
stride=(0, 0),
)
placeholder = nn.Parameter(fake_tensor, requires_grad=False)
placeholder._kt_zero_storage = True
placeholder_storage_nbytes = placeholder.untyped_storage().nbytes()
layer.experts[0].gate_proj._parameters["weight"] = placeholder
state_dict = layer.state_dict()
assert "experts.0.gate_proj.weight" not in state_dict
assert "experts.0.up_proj.weight" in state_dict
assert "gate.weight" in state_dict
save_file(state_dict, str(tmp_path / "model.safetensors"))
load_result = layer.load_state_dict(state_dict, strict=True, assign=True)
assert load_result.missing_keys == []
assert load_result.unexpected_keys == []
loaded_placeholder = layer.experts[0].gate_proj.weight
assert loaded_placeholder is placeholder
assert loaded_placeholder._kt_zero_storage is True
assert loaded_placeholder.requires_grad is False
assert loaded_placeholder.stride() == (0, 0)
assert loaded_placeholder.untyped_storage().nbytes() == placeholder_storage_nbytes
assert placeholder_storage_nbytes == placeholder.element_size()
assert "experts.0.gate_proj.weight" not in layer.state_dict()