279 lines
12 KiB
Python
279 lines
12 KiB
Python
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import json
|
||
|
|
import math
|
||
|
|
import os
|
||
|
|
import tempfile
|
||
|
|
import unittest
|
||
|
|
from pathlib import Path
|
||
|
|
from unittest import mock
|
||
|
|
|
||
|
|
import torch
|
||
|
|
from transformers import Qwen3Config, Qwen3ForCausalLM
|
||
|
|
|
||
|
|
import modeling_aha_qwen3 as aha_module
|
||
|
|
from modeling_aha_qwen3 import (
|
||
|
|
AHAQwen3Config,
|
||
|
|
AHAQwen3ForCausalLM,
|
||
|
|
aha_router_output_size,
|
||
|
|
)
|
||
|
|
from router_training_utils import RowWiseAdamW, configure_gate_only
|
||
|
|
|
||
|
|
|
||
|
|
def base_config() -> Qwen3Config:
|
||
|
|
config = Qwen3Config(
|
||
|
|
vocab_size=97,
|
||
|
|
hidden_size=32,
|
||
|
|
intermediate_size=64,
|
||
|
|
num_hidden_layers=2,
|
||
|
|
num_attention_heads=4,
|
||
|
|
num_key_value_heads=2,
|
||
|
|
head_dim=8,
|
||
|
|
max_position_embeddings=64,
|
||
|
|
attention_dropout=0.0,
|
||
|
|
attention_bias=True,
|
||
|
|
tie_word_embeddings=False,
|
||
|
|
)
|
||
|
|
config._attn_implementation = "eager"
|
||
|
|
return config
|
||
|
|
|
||
|
|
|
||
|
|
def aha_config(granularity: str, *, force_gate_value=None) -> AHAQwen3Config:
|
||
|
|
payload = base_config().to_dict()
|
||
|
|
payload.update(
|
||
|
|
aha_window_size=2,
|
||
|
|
aha_local_kind="sliding_window",
|
||
|
|
aha_mode="dynamic",
|
||
|
|
aha_router_granularity=granularity,
|
||
|
|
aha_force_gate_value=force_gate_value,
|
||
|
|
aha_reg_weight=1.0,
|
||
|
|
aha_ce_weight=0.0,
|
||
|
|
model_type="aha_qwen3",
|
||
|
|
)
|
||
|
|
config = AHAQwen3Config(**payload)
|
||
|
|
config._attn_implementation = "eager"
|
||
|
|
return config
|
||
|
|
|
||
|
|
|
||
|
|
def copy_base_weights(base: Qwen3ForCausalLM, target: AHAQwen3ForCausalLM) -> None:
|
||
|
|
source = base.state_dict()
|
||
|
|
destination = target.state_dict()
|
||
|
|
q_rows = target.config.num_attention_heads * target.config.head_dim
|
||
|
|
with torch.no_grad():
|
||
|
|
for name, value in source.items():
|
||
|
|
if name not in destination:
|
||
|
|
continue
|
||
|
|
if destination[name].shape == value.shape:
|
||
|
|
destination[name].copy_(value)
|
||
|
|
elif name.endswith("self_attn.q_proj.weight"):
|
||
|
|
destination[name][:q_rows].copy_(value)
|
||
|
|
destination[name][q_rows:].zero_()
|
||
|
|
elif name.endswith("self_attn.q_proj.bias"):
|
||
|
|
destination[name][:q_rows].copy_(value)
|
||
|
|
destination[name][q_rows:].zero_()
|
||
|
|
else:
|
||
|
|
raise AssertionError(f"unexpected shape mismatch for {name}")
|
||
|
|
target.load_state_dict(destination)
|
||
|
|
|
||
|
|
|
||
|
|
def aligned_models():
|
||
|
|
torch.manual_seed(7)
|
||
|
|
base = Qwen3ForCausalLM(base_config()).eval()
|
||
|
|
models = {}
|
||
|
|
for granularity in ("token", "token_kv_head"):
|
||
|
|
model = AHAQwen3ForCausalLM(
|
||
|
|
aha_config(granularity, force_gate_value=1.0)
|
||
|
|
).eval()
|
||
|
|
copy_base_weights(base, model)
|
||
|
|
models[granularity] = model
|
||
|
|
return base, models
|
||
|
|
|
||
|
|
|
||
|
|
class RouterGranularityTest(unittest.TestCase):
|
||
|
|
def test_auto_class_checkpoint_embeds_modeling_source(self):
|
||
|
|
AHAQwen3Config.register_for_auto_class()
|
||
|
|
AHAQwen3ForCausalLM.register_for_auto_class("AutoModelForCausalLM")
|
||
|
|
with tempfile.TemporaryDirectory() as tmp:
|
||
|
|
AHAQwen3ForCausalLM(aha_config("token")).save_pretrained(
|
||
|
|
tmp, safe_serialization=True
|
||
|
|
)
|
||
|
|
self.assertTrue((Path(tmp) / "modeling_aha_qwen3.py").exists())
|
||
|
|
|
||
|
|
def test_projection_and_effective_gate_shapes(self):
|
||
|
|
_, models = aligned_models()
|
||
|
|
input_ids = torch.tensor([[1, 2, 3, 4, 5]])
|
||
|
|
expected_q_rows = 4 * 8
|
||
|
|
for granularity, native_rows in (("token", 1), ("token_kv_head", 2)):
|
||
|
|
with self.subTest(granularity=granularity):
|
||
|
|
model = models[granularity]
|
||
|
|
attention = model.model.layers[0].self_attn
|
||
|
|
self.assertEqual(attention.q_proj.out_features, expected_q_rows + native_rows)
|
||
|
|
self.assertEqual(attention.aha_router_outputs, native_rows)
|
||
|
|
output = model.model(input_ids=input_ids, use_cache=False)
|
||
|
|
self.assertEqual(len(output.all_gate_soft), 2)
|
||
|
|
self.assertEqual(output.all_gate_soft[0].shape, (1, 5, 2))
|
||
|
|
self.assertEqual(output.all_gate_hard[0].shape, (1, 5, 2))
|
||
|
|
|
||
|
|
def test_force_open_matches_full_attention_logits(self):
|
||
|
|
base, models = aligned_models()
|
||
|
|
input_ids = torch.tensor([[1, 4, 2, 8, 3, 9]])
|
||
|
|
with torch.no_grad():
|
||
|
|
expected = base(input_ids=input_ids, use_cache=False).logits
|
||
|
|
for granularity, model in models.items():
|
||
|
|
with self.subTest(granularity=granularity):
|
||
|
|
actual = model(input_ids=input_ids, use_cache=False).logits
|
||
|
|
torch.testing.assert_close(actual, expected, atol=1e-6, rtol=1e-5)
|
||
|
|
|
||
|
|
def test_force_closed_uses_identical_local_branch(self):
|
||
|
|
_, models = aligned_models()
|
||
|
|
input_ids = torch.tensor([[1, 4, 2, 8, 3, 9]])
|
||
|
|
for model in models.values():
|
||
|
|
model.config.aha_force_gate_value = 0.0
|
||
|
|
with torch.no_grad():
|
||
|
|
token_logits = models["token"](input_ids=input_ids, use_cache=False).logits
|
||
|
|
head_logits = models["token_kv_head"](
|
||
|
|
input_ids=input_ids, use_cache=False
|
||
|
|
).logits
|
||
|
|
torch.testing.assert_close(token_logits, head_logits, atol=1e-6, rtol=1e-5)
|
||
|
|
|
||
|
|
def test_regularizer_uses_effective_kv_head_denominator(self):
|
||
|
|
_, models = aligned_models()
|
||
|
|
probability = 0.73
|
||
|
|
bias = math.log(probability / (1.0 - probability))
|
||
|
|
input_ids = torch.tensor([[1, 4, 2, 8, 3, 9]])
|
||
|
|
outputs = {}
|
||
|
|
for granularity, model in models.items():
|
||
|
|
model.train()
|
||
|
|
model.config.aha_force_gate_value = None
|
||
|
|
q_rows = model.config.num_attention_heads * model.config.head_dim
|
||
|
|
with torch.no_grad():
|
||
|
|
for layer in model.model.layers:
|
||
|
|
layer.self_attn.q_proj.weight[q_rows:].zero_()
|
||
|
|
layer.self_attn.q_proj.bias[q_rows:].fill_(bias)
|
||
|
|
outputs[granularity] = model(
|
||
|
|
input_ids=input_ids, labels=input_ids.clone(), use_cache=False
|
||
|
|
)
|
||
|
|
torch.testing.assert_close(
|
||
|
|
outputs["token"].gate_soft_mean,
|
||
|
|
outputs["token_kv_head"].gate_soft_mean,
|
||
|
|
)
|
||
|
|
torch.testing.assert_close(
|
||
|
|
outputs["token"].gate_aux_loss,
|
||
|
|
outputs["token_kv_head"].gate_aux_loss,
|
||
|
|
)
|
||
|
|
self.assertAlmostEqual(outputs["token"].gate_soft_mean.item(), probability, places=6)
|
||
|
|
|
||
|
|
def test_gate_only_updates_only_appended_rows(self):
|
||
|
|
for granularity, expected_rows in (("token", 1), ("token_kv_head", 2)):
|
||
|
|
with self.subTest(granularity=granularity):
|
||
|
|
model = AHAQwen3ForCausalLM(aha_config(granularity))
|
||
|
|
setup = configure_gate_only(model)
|
||
|
|
self.assertEqual(setup.gate_rows, expected_rows)
|
||
|
|
q_proj = model.model.layers[0].self_attn.q_proj
|
||
|
|
before = q_proj.weight.detach().clone()
|
||
|
|
q_proj.weight.sum().backward()
|
||
|
|
self.assertEqual(
|
||
|
|
torch.count_nonzero(q_proj.weight.grad[: setup.q_rows]).item(), 0
|
||
|
|
)
|
||
|
|
self.assertGreater(
|
||
|
|
torch.count_nonzero(q_proj.weight.grad[setup.q_rows :]).item(), 0
|
||
|
|
)
|
||
|
|
torch.optim.SGD(setup.parameters, lr=0.1).step()
|
||
|
|
torch.testing.assert_close(
|
||
|
|
q_proj.weight[: setup.q_rows], before[: setup.q_rows]
|
||
|
|
)
|
||
|
|
self.assertFalse(
|
||
|
|
torch.equal(q_proj.weight[setup.q_rows :], before[setup.q_rows :])
|
||
|
|
)
|
||
|
|
|
||
|
|
def test_rowwise_adamw_applies_real_ten_x_lr_ratio(self):
|
||
|
|
parameter = torch.nn.Parameter(torch.zeros(3, 1))
|
||
|
|
optimizer = RowWiseAdamW(
|
||
|
|
[{"params": [parameter], "lr": 0.1}],
|
||
|
|
row_scales=[(parameter, 2, 0.1)],
|
||
|
|
weight_decay=0.0,
|
||
|
|
betas=(0.9, 0.999),
|
||
|
|
)
|
||
|
|
parameter.grad = torch.ones_like(parameter)
|
||
|
|
optimizer.step()
|
||
|
|
backbone_update = parameter[:2].abs().mean().item()
|
||
|
|
gate_update = parameter[2:].abs().mean().item()
|
||
|
|
self.assertAlmostEqual(gate_update / backbone_update, 10.0, places=5)
|
||
|
|
|
||
|
|
def test_one_step_smoke_is_finite_and_reloadable(self):
|
||
|
|
input_ids = torch.tensor([[1, 4, 2, 8, 3, 9]])
|
||
|
|
for granularity in ("token", "token_kv_head"):
|
||
|
|
with self.subTest(granularity=granularity), tempfile.TemporaryDirectory() as tmp:
|
||
|
|
model = AHAQwen3ForCausalLM(aha_config(granularity)).train()
|
||
|
|
setup = configure_gate_only(model)
|
||
|
|
optimizer = torch.optim.AdamW(setup.parameters, lr=3e-5)
|
||
|
|
output = model(
|
||
|
|
input_ids=input_ids, labels=input_ids.clone(), use_cache=False
|
||
|
|
)
|
||
|
|
self.assertTrue(torch.isfinite(output.loss).item())
|
||
|
|
output.loss.backward()
|
||
|
|
self.assertTrue(
|
||
|
|
all(
|
||
|
|
parameter.grad is None
|
||
|
|
or torch.isfinite(parameter.grad).all().item()
|
||
|
|
for parameter in setup.parameters
|
||
|
|
)
|
||
|
|
)
|
||
|
|
optimizer.step()
|
||
|
|
model.save_pretrained(tmp, safe_serialization=True)
|
||
|
|
reloaded = AHAQwen3ForCausalLM.from_pretrained_aha(
|
||
|
|
tmp, torch_dtype=torch.float32, attn_implementation="eager"
|
||
|
|
)
|
||
|
|
self.assertEqual(
|
||
|
|
reloaded.config.aha_router_granularity, granularity
|
||
|
|
)
|
||
|
|
|
||
|
|
def test_legacy_default_and_save_load_roundtrip(self):
|
||
|
|
legacy = AHAQwen3Config(**base_config().to_dict())
|
||
|
|
self.assertEqual(legacy.aha_router_granularity, "token_kv_head")
|
||
|
|
self.assertEqual(aha_router_output_size(legacy), legacy.num_key_value_heads)
|
||
|
|
|
||
|
|
with tempfile.TemporaryDirectory() as tmp:
|
||
|
|
model = AHAQwen3ForCausalLM(aha_config("token"))
|
||
|
|
model.save_pretrained(tmp, safe_serialization=True)
|
||
|
|
loaded = AHAQwen3ForCausalLM.from_pretrained_aha(
|
||
|
|
tmp, torch_dtype=torch.float32, attn_implementation="eager"
|
||
|
|
)
|
||
|
|
self.assertEqual(loaded.config.aha_router_granularity, "token")
|
||
|
|
self.assertEqual(loaded.model.layers[0].self_attn.aha_router_outputs, 1)
|
||
|
|
|
||
|
|
def test_sparsity_tracker_records_native_and_effective_counts(self):
|
||
|
|
with tempfile.TemporaryDirectory() as tmp:
|
||
|
|
output = os.path.join(tmp, "sparsity.json")
|
||
|
|
with mock.patch.dict(os.environ, {"AHA_SPARSITY_STATS_PATH": output}):
|
||
|
|
tracker = aha_module._AHAInferenceSparsityTracker()
|
||
|
|
gate_hard = torch.tensor([[[1.0, 1.0], [0.0, 0.0]]])
|
||
|
|
gate_soft = torch.tensor([[[0.8, 0.8], [0.2, 0.2]]])
|
||
|
|
tracker.update(
|
||
|
|
gate_hard,
|
||
|
|
gate_soft,
|
||
|
|
layer_idx=0,
|
||
|
|
phase="prefill",
|
||
|
|
router_granularity="token",
|
||
|
|
native_router_width=1,
|
||
|
|
)
|
||
|
|
tracker.update(
|
||
|
|
gate_hard[:, :1],
|
||
|
|
gate_soft[:, :1],
|
||
|
|
layer_idx=0,
|
||
|
|
phase="decode",
|
||
|
|
router_granularity="token",
|
||
|
|
native_router_width=1,
|
||
|
|
)
|
||
|
|
tracker.write_stats()
|
||
|
|
payload = json.loads(Path(output).read_text())
|
||
|
|
self.assertEqual(payload["router_granularity"], "token")
|
||
|
|
self.assertEqual(payload["native_router_decisions"], 3)
|
||
|
|
self.assertEqual(payload["effective_router_decisions"], 6)
|
||
|
|
self.assertAlmostEqual(payload["sparsity"], 1 / 3)
|
||
|
|
self.assertEqual(payload["by_phase"]["decode"]["total_decisions"], 2)
|
||
|
|
|
||
|
|
|
||
|
|
if __name__ == "__main__":
|
||
|
|
unittest.main()
|