test: refactor tests and fix inference edge cases

- Convert protocol and MoE test classes to plain functions
- Add real server/engine integration and generate_async tests
- Isolate test model per test and use pytest tmp_path
- Reset FastAPI engine state after inference tests
- Fix generate_async StopIteration handling on Python 3.12
- Fix HF adapter MoE dense/shared and Gemma qk_norm mapping
- Correct dev dependency httpx2 to httpx
This commit is contained in:
2026-08-21 22:59:51 +08:00
parent 7d27f3e078
commit dcc96de12a
11 changed files with 580 additions and 384 deletions
+2 -3
View File
@@ -156,9 +156,8 @@ class InferenceEngine:
async def _agen(): async def _agen():
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
while True: while True:
try: token = await loop.run_in_executor(None, next, sync_gen, None)
token = await loop.run_in_executor(None, next, sync_gen) if token is None:
except StopIteration:
break break
yield token yield token
+31 -1
View File
@@ -44,6 +44,8 @@ HF_MODEL_TYPES = frozenset(
_EMBED = re.compile(r"^model\.embed_tokens\.weight$") _EMBED = re.compile(r"^model\.embed_tokens\.weight$")
_ATTN = re.compile(r"^model\.layers\.(\d+)\.self_attn\.(q|k|v|o)_proj\.(weight|bias)$") _ATTN = re.compile(r"^model\.layers\.(\d+)\.self_attn\.(q|k|v|o)_proj\.(weight|bias)$")
_Q_NORM = re.compile(r"^model\.layers\.(\d+)\.self_attn\.q_norm\.weight$")
_K_NORM = re.compile(r"^model\.layers\.(\d+)\.self_attn\.k_norm\.weight$")
_INPUT_NORM = re.compile(r"^model\.layers\.(\d+)\.input_layernorm\.weight$") _INPUT_NORM = re.compile(r"^model\.layers\.(\d+)\.input_layernorm\.weight$")
_POST_NORM = re.compile(r"^model\.layers\.(\d+)\.post_attention_layernorm\.weight$") _POST_NORM = re.compile(r"^model\.layers\.(\d+)\.post_attention_layernorm\.weight$")
_FINAL_NORM = re.compile(r"^model\.norm\.weight$") _FINAL_NORM = re.compile(r"^model\.norm\.weight$")
@@ -56,7 +58,7 @@ _MOE_EXPERTS = re.compile(
r"^model\.layers\.(\d+)\.mlp\.experts\.(\d+)\.(gate|up|down)_proj\.(weight|bias)$" r"^model\.layers\.(\d+)\.mlp\.experts\.(\d+)\.(gate|up|down)_proj\.(weight|bias)$"
) )
_MOE_SHARED = re.compile( _MOE_SHARED = re.compile(
r"^model\.layers\.(\d+)\.mlp\.shared_experts\.(\d+)\." r"^model\.layers\.(\d+)\.mlp\.shared_expert(?:s)?\.(\d+)\."
r"(gate|up|down)_proj\.(weight|bias)$" r"(gate|up|down)_proj\.(weight|bias)$"
) )
@@ -74,6 +76,17 @@ def looks_like_hf_state_dict(state_dict: Mapping[str, Any]) -> bool:
) )
def _is_dense_mlp_layer(config: BaseConfig, layer_id: int) -> bool:
"""Return whether a layer uses dense MLP instead of routed experts."""
if getattr(config, "ffn_type", "mlp") != "moe":
return True
mlp_only = getattr(config, "mlp_only_layers", None) or []
if layer_id in mlp_only:
return True
step = getattr(config, "decoder_sparse_step", 1) or 1
return step > 1 and (layer_id + 1) % step != 0
def adapt_config(raw: Dict[str, Any]) -> Dict[str, Any]: def adapt_config(raw: Dict[str, Any]) -> Dict[str, Any]:
"""Translate *raw* for AstrAI if it looks like an HF model config.""" """Translate *raw* for AstrAI if it looks like an HF model config."""
if raw.get("model_type") in HF_MODEL_TYPES: if raw.get("model_type") in HF_MODEL_TYPES:
@@ -112,6 +125,8 @@ def convert_hf_config(raw: Dict[str, Any]) -> Dict[str, Any]:
"topk_method", "topk_method",
"norm_topk_prob", "norm_topk_prob",
"moe_aux_loss_coef", "moe_aux_loss_coef",
"decoder_sparse_step",
"mlp_only_layers",
"neftune_alpha", "neftune_alpha",
): ):
if key in raw: if key in raw:
@@ -119,6 +134,13 @@ def convert_hf_config(raw: Dict[str, Any]) -> Dict[str, Any]:
if "qk_norm" in raw and "use_qk_norm" not in cfg: if "qk_norm" in raw and "use_qk_norm" not in cfg:
cfg["use_qk_norm"] = raw["qk_norm"] cfg["use_qk_norm"] = raw["qk_norm"]
if (
raw.get("model_type") in ("gemma", "gemma2")
and "use_qk_norm" not in cfg
and "qk_norm" not in raw
):
# Gemma/Gemma2 always apply RMSNorm to Q and K before attention.
cfg["use_qk_norm"] = True
n_heads = raw.get("num_attention_heads") n_heads = raw.get("num_attention_heads")
if cfg.get("num_key_value_heads") is None and n_heads is not None: if cfg.get("num_key_value_heads") is None and n_heads is not None:
@@ -205,6 +227,10 @@ def convert_hf_weights(
f"layers.{m.group(1)}.mlp.shared_experts.{m.group(2)}." f"layers.{m.group(1)}.mlp.shared_experts.{m.group(2)}."
f"{m.group(3)}.{m.group(4)}" f"{m.group(3)}.{m.group(4)}"
) )
if new_key is None:
m = _DENSE_MLP.match(key)
if m and _is_dense_mlp_layer(config, int(m.group(1))):
new_key = f"layers.{m.group(1)}.mlp.{m.group(2)}.{m.group(3)}"
else: else:
m = _DENSE_MLP.match(key) m = _DENSE_MLP.match(key)
if m: if m:
@@ -216,6 +242,10 @@ def convert_hf_weights(
new_key = ( new_key = (
f"layers.{m.group(1)}.attention.{m.group(2)}_proj.{m.group(3)}" f"layers.{m.group(1)}.attention.{m.group(2)}_proj.{m.group(3)}"
) )
elif (m := _Q_NORM.match(key)) is not None:
new_key = f"layers.{m.group(1)}.attention.q_norm.weight"
elif (m := _K_NORM.match(key)) is not None:
new_key = f"layers.{m.group(1)}.attention.k_norm.weight"
elif (m := _INPUT_NORM.match(key)) is not None: elif (m := _INPUT_NORM.match(key)) is not None:
new_key = f"layers.{m.group(1)}.input_norm.weight" new_key = f"layers.{m.group(1)}.input_norm.weight"
elif (m := _POST_NORM.match(key)) is not None: elif (m := _POST_NORM.match(key)) is not None:
+1 -1
View File
@@ -31,7 +31,7 @@ classifiers = [
urls = { Homepage = "https://github.com/ViperEkura/AstrAI" } urls = { Homepage = "https://github.com/ViperEkura/AstrAI" }
[project.optional-dependencies] [project.optional-dependencies]
dev = ["pytest==9.0.2", "ruff", "httpx2"] dev = ["pytest==9.0.2", "ruff", "httpx"]
flash = ["flash-attn>=2.6"] flash = ["flash-attn>=2.6"]
[tool.setuptools.packages.find] [tool.setuptools.packages.find]
+5 -9
View File
@@ -1,7 +1,5 @@
import json import json
import os import os
import shutil
import tempfile
import pytest import pytest
import torch import torch
@@ -44,20 +42,18 @@ def test_tokenizer():
return create_test_tokenizer() return create_test_tokenizer()
@pytest.fixture(scope="session") @pytest.fixture
def test_model(device): def test_model(device):
"""Session-scoped small AutoRegressiveLM model, created once.""" """Function-scoped small AutoRegressiveLM model, isolated per test."""
config = make_tiny_config() config = make_tiny_config()
model = AutoRegressiveLM(config).to(device=device) model = AutoRegressiveLM(config).to(device=device)
return {"model": model, "device": device, "config": config} return {"model": model, "device": device, "config": config}
@pytest.fixture @pytest.fixture
def temp_dir(): def temp_dir(tmp_path):
"""Function-scoped temporary directory, cleaned up after each test.""" """Function-scoped temporary directory, cleaned up by pytest."""
d = tempfile.mkdtemp() return str(tmp_path)
yield d
shutil.rmtree(d, ignore_errors=True)
@pytest.fixture @pytest.fixture
+7
View File
@@ -8,6 +8,13 @@ from fastapi.testclient import TestClient
from astrai.inference import get_app from astrai.inference import get_app
@pytest.fixture(autouse=True)
def _cleanup_app_engine():
"""Reset the lazy FastAPI singleton engine after each inference test."""
yield
get_app().state.engine = None
@pytest.fixture @pytest.fixture
def client(): def client():
"""Provide a test client for the FastAPI app.""" """Provide a test client for the FastAPI app."""
+30
View File
@@ -1,5 +1,6 @@
"""Unit tests for GenerateResult accumulator and InferenceEngine.generate().""" """Unit tests for GenerateResult accumulator and InferenceEngine.generate()."""
import asyncio
import threading import threading
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
@@ -156,6 +157,35 @@ def test_engine_generate_streaming_yields_tokens():
assert tokens == ["t1", "t2"] assert tokens == ["t1", "t2"]
def test_engine_generate_async_yields_tokens_until_stop():
mock_model, mock_tokenizer = _make_engine_mocks(decode="tok")
callbacks_saved = []
def capture_cb(prompt, **kw):
callbacks_saved.append(kw.get("stream_callback"))
with patch("astrai.inference.engine.InferenceScheduler") as MockSched:
instance = MockSched.return_value
instance.add_task.side_effect = capture_cb
instance.remove_task.return_value = []
eng = InferenceEngine(mock_model, mock_tokenizer, max_batch_size=1)
agen = eng.generate_async("hello")
async def collect():
out = []
async for token in agen:
out.append(token)
return out
cb = callbacks_saved[0]
cb("t1")
cb("t2")
cb(STOP)
assert asyncio.run(collect()) == ["t1", "t2"]
def test_engine_generate_non_streaming_batch(): def test_engine_generate_non_streaming_batch():
mock_model, mock_tokenizer = _make_engine_mocks(decode="r") mock_model, mock_tokenizer = _make_engine_mocks(decode="r")
+97 -75
View File
@@ -3,8 +3,6 @@
import json import json
from unittest.mock import MagicMock from unittest.mock import MagicMock
import pytest
from astrai.inference.network.anthropic import AnthropicResponseBuilder from astrai.inference.network.anthropic import AnthropicResponseBuilder
from astrai.inference.network.openai import OpenAIResponseBuilder from astrai.inference.network.openai import OpenAIResponseBuilder
from astrai.inference.network.protocol import GenContext, StopChecker, StopInfo from astrai.inference.network.protocol import GenContext, StopChecker, StopInfo
@@ -34,48 +32,7 @@ def _sse_payloads(events):
return payloads return payloads
class TestStopChecker: def _make_openai_builder():
def test_check_finds_match(self):
sc = StopChecker(["stop", "end"])
assert sc.check("hello stop world") == "stop"
def test_check_returns_none_when_no_match(self):
sc = StopChecker(["stop"])
assert sc.check("hello world") is None
def test_check_empty_sequences(self):
sc = StopChecker([])
assert sc.check("hello") is None
class TestGenContext:
def test_defaults(self):
ctx = GenContext(resp_id="a", created=1, model="m", prompt_tokens=10)
assert ctx.completion_tokens == 0
def test_fields_mutable(self):
ctx = GenContext(resp_id="a", created=1, model="m", prompt_tokens=10)
ctx.completion_tokens = 42
assert ctx.completion_tokens == 42
class TestStopInfo:
def test_defaults(self):
s = StopInfo()
assert s.matched is None
assert s.body == ""
assert s.yielded == ""
def test_with_values(self):
s = StopInfo(matched="stop", body="hello stop", yielded="hello ")
assert s.matched == "stop"
assert s.body == "hello stop"
assert s.yielded == "hello "
class TestOpenAIResponseBuilder:
@pytest.fixture
def builder(self):
builder = OpenAIResponseBuilder() builder = OpenAIResponseBuilder()
req = MagicMock() req = MagicMock()
req.messages = [MagicMock(role="user", content="Hello")] req.messages = [MagicMock(role="user", content="Hello")]
@@ -86,7 +43,61 @@ class TestOpenAIResponseBuilder:
builder.prepare(req, engine) builder.prepare(req, engine)
return builder return builder
def test_prepare_returns_prompt_ctx_stops(self, builder):
def _make_anthropic_builder():
builder = AnthropicResponseBuilder()
req = MagicMock()
req.messages = [MagicMock(role="user", content="Hello")]
req.model = "claude"
req.system = None
engine = MagicMock()
engine.tokenizer.apply_chat_template.return_value = "Hello"
builder.prepare(req, engine)
return builder
def test_check_finds_match():
sc = StopChecker(["stop", "end"])
assert sc.check("hello stop world") == "stop"
def test_check_returns_none_when_no_match():
sc = StopChecker(["stop"])
assert sc.check("hello world") is None
def test_check_empty_sequences():
sc = StopChecker([])
assert sc.check("hello") is None
def test_gen_context_defaults():
ctx = GenContext(resp_id="a", created=1, model="m", prompt_tokens=10)
assert ctx.completion_tokens == 0
def test_gen_context_fields_mutable():
ctx = GenContext(resp_id="a", created=1, model="m", prompt_tokens=10)
ctx.completion_tokens = 42
assert ctx.completion_tokens == 42
def test_stop_info_defaults():
s = StopInfo()
assert s.matched is None
assert s.body == ""
assert s.yielded == ""
def test_stop_info_with_values():
s = StopInfo(matched="stop", body="hello stop", yielded="hello ")
assert s.matched == "stop"
assert s.body == "hello stop"
assert s.yielded == "hello "
def test_openai_prepare_returns_prompt_ctx_stops():
builder = _make_openai_builder()
req = MagicMock() req = MagicMock()
req.messages = [MagicMock(role="user", content="Hi")] req.messages = [MagicMock(role="user", content="Hi")]
req.stop = ["END"] req.stop = ["END"]
@@ -99,7 +110,9 @@ class TestOpenAIResponseBuilder:
assert ctx.prompt_tokens == 0 assert ctx.prompt_tokens == 0
assert stops == ["END"] assert stops == ["END"]
def test_prepare_no_stop_returns_empty_list(self, builder):
def test_openai_prepare_no_stop_returns_empty_list():
builder = _make_openai_builder()
req = MagicMock() req = MagicMock()
req.messages = [] req.messages = []
req.stop = None req.stop = None
@@ -109,7 +122,9 @@ class TestOpenAIResponseBuilder:
_, _, stops = builder.prepare(req, engine) _, _, stops = builder.prepare(req, engine)
assert stops == [] assert stops == []
def test_format_stream_start(self, builder):
def test_openai_format_stream_start():
builder = _make_openai_builder()
ctx = _make_ctx() ctx = _make_ctx()
events = builder.format_stream_start(ctx) events = builder.format_stream_start(ctx)
payloads = _sse_payloads(events) payloads = _sse_payloads(events)
@@ -119,13 +134,17 @@ class TestOpenAIResponseBuilder:
assert p["choices"][0]["delta"]["role"] == "assistant" assert p["choices"][0]["delta"]["role"] == "assistant"
assert p["choices"][0]["finish_reason"] is None assert p["choices"][0]["finish_reason"] is None
def test_format_chunk(self, builder):
def test_openai_format_chunk():
builder = _make_openai_builder()
events = builder.format_chunk("hello", body="hello") events = builder.format_chunk("hello", body="hello")
payload = json.loads(events[0].split("data: ", 1)[1]) payload = json.loads(events[0].split("data: ", 1)[1])
assert payload["choices"][0]["delta"]["content"] == "hello" assert payload["choices"][0]["delta"]["content"] == "hello"
assert payload["choices"][0]["finish_reason"] is None assert payload["choices"][0]["finish_reason"] is None
def test_format_stream_end(self, builder):
def test_openai_format_stream_end():
builder = _make_openai_builder()
ctx = _make_ctx(completion_tokens=5) ctx = _make_ctx(completion_tokens=5)
stop = StopInfo(matched="stop") stop = StopInfo(matched="stop")
events = builder.format_stream_end(ctx, stop) events = builder.format_stream_end(ctx, stop)
@@ -136,7 +155,9 @@ class TestOpenAIResponseBuilder:
assert usage["completion_tokens"] == 5 assert usage["completion_tokens"] == 5
assert usage["total_tokens"] == 15 assert usage["total_tokens"] == 15
def test_format_response(self, builder):
def test_openai_format_response():
builder = _make_openai_builder()
ctx = _make_ctx() ctx = _make_ctx()
stop = StopInfo() stop = StopInfo()
resp = builder.format_response(ctx, "hello", stop) resp = builder.format_response(ctx, "hello", stop)
@@ -145,20 +166,8 @@ class TestOpenAIResponseBuilder:
assert resp["usage"]["prompt_tokens"] == 10 assert resp["usage"]["prompt_tokens"] == 10
class TestAnthropicResponseBuilder: def test_anthropic_prepare_messages():
@pytest.fixture builder = _make_anthropic_builder()
def builder(self):
builder = AnthropicResponseBuilder()
req = MagicMock()
req.messages = [MagicMock(role="user", content="Hello")]
req.model = "claude"
engine = MagicMock()
engine.tokenizer.apply_chat_template.return_value = "Hello"
req.system = None
builder.prepare(req, engine)
return builder
def test_prepare_messages(self, builder):
req = MagicMock() req = MagicMock()
req.messages = [MagicMock(role="user", content="Hi")] req.messages = [MagicMock(role="user", content="Hi")]
req.model = "claude" req.model = "claude"
@@ -170,7 +179,9 @@ class TestAnthropicResponseBuilder:
assert prompt == "Hi" assert prompt == "Hi"
assert stops == [] assert stops == []
def test_prepare_with_stop_sequences(self, builder):
def test_anthropic_prepare_with_stop_sequences():
builder = _make_anthropic_builder()
req = MagicMock() req = MagicMock()
req.messages = [] req.messages = []
req.model = "x" req.model = "x"
@@ -181,7 +192,9 @@ class TestAnthropicResponseBuilder:
_, _, stops = builder.prepare(req, engine) _, _, stops = builder.prepare(req, engine)
assert stops == ["stop", "end"] assert stops == ["stop", "end"]
def test_format_stream_start(self, builder):
def test_anthropic_format_stream_start():
builder = _make_anthropic_builder()
ctx = _make_ctx(prompt_tokens=3) ctx = _make_ctx(prompt_tokens=3)
events = builder.format_stream_start(ctx) events = builder.format_stream_start(ctx)
payloads = _sse_payloads(events) payloads = _sse_payloads(events)
@@ -190,23 +203,28 @@ class TestAnthropicResponseBuilder:
assert payloads[0]["message"]["usage"]["input_tokens"] == 3 assert payloads[0]["message"]["usage"]["input_tokens"] == 3
assert payloads[1]["type"] == "content_block_start" assert payloads[1]["type"] == "content_block_start"
def test_format_chunk(self, builder):
def test_anthropic_format_chunk():
builder = _make_anthropic_builder()
events = builder.format_chunk("tok", body="tok") events = builder.format_chunk("tok", body="tok")
payload = json.loads(events[0].split("data: ", 1)[1]) payload = json.loads(events[0].split("data: ", 1)[1])
assert payload["type"] == "content_block_delta" assert payload["type"] == "content_block_delta"
assert payload["delta"]["text"] == "tok" assert payload["delta"]["text"] == "tok"
def test_format_stream_end_no_stop(self, builder):
def test_anthropic_format_stream_end_no_stop():
builder = _make_anthropic_builder()
ctx = _make_ctx(completion_tokens=3) ctx = _make_ctx(completion_tokens=3)
stop = StopInfo() stop = StopInfo()
events = builder.format_stream_end(ctx, stop) events = builder.format_stream_end(ctx, stop)
payloads = _sse_payloads(events) payloads = _sse_payloads(events)
# content_block_stop, message_delta, message_stop
types = [p["type"] for p in payloads] types = [p["type"] for p in payloads]
assert types == ["content_block_stop", "message_delta", "message_stop"] assert types == ["content_block_stop", "message_delta", "message_stop"]
assert payloads[1]["delta"]["stop_reason"] == "end_turn" assert payloads[1]["delta"]["stop_reason"] == "end_turn"
def test_format_stream_end_with_stop_trims_and_emits_remaining(self, builder):
def test_anthropic_format_stream_end_with_stop_trims_and_emits_remaining():
builder = _make_anthropic_builder()
ctx = _make_ctx(completion_tokens=7) ctx = _make_ctx(completion_tokens=7)
stop = StopInfo( stop = StopInfo(
matched="END", matched="END",
@@ -215,7 +233,6 @@ class TestAnthropicResponseBuilder:
) )
events = builder.format_stream_end(ctx, stop) events = builder.format_stream_end(ctx, stop)
payloads = _sse_payloads(events) payloads = _sse_payloads(events)
# unyielded delta, content_block_stop, message_delta, message_stop
types = [p["type"] for p in payloads] types = [p["type"] for p in payloads]
assert types == [ assert types == [
"content_block_delta", "content_block_delta",
@@ -227,7 +244,9 @@ class TestAnthropicResponseBuilder:
assert payloads[2]["delta"]["stop_reason"] == "stop_sequence" assert payloads[2]["delta"]["stop_reason"] == "stop_sequence"
assert payloads[2]["delta"]["stop_sequence"] == "END" assert payloads[2]["delta"]["stop_sequence"] == "END"
def test_format_stream_end_stop_trimmed_already_yielded(self, builder):
def test_anthropic_format_stream_end_stop_trimmed_already_yielded():
builder = _make_anthropic_builder()
ctx = _make_ctx() ctx = _make_ctx()
stop = StopInfo( stop = StopInfo(
matched="END", matched="END",
@@ -236,11 +255,12 @@ class TestAnthropicResponseBuilder:
) )
events = builder.format_stream_end(ctx, stop) events = builder.format_stream_end(ctx, stop)
payloads = _sse_payloads(events) payloads = _sse_payloads(events)
# No unyielded delta (everything already sent)
types = [p["type"] for p in payloads] types = [p["type"] for p in payloads]
assert types == ["content_block_stop", "message_delta", "message_stop"] assert types == ["content_block_stop", "message_delta", "message_stop"]
def test_format_response_with_stop_trims_content(self, builder):
def test_anthropic_format_response_with_stop_trims_content():
builder = _make_anthropic_builder()
ctx = _make_ctx() ctx = _make_ctx()
stop = StopInfo(matched="STOP", body="text STOP extra", yielded="text ") stop = StopInfo(matched="STOP", body="text STOP extra", yielded="text ")
resp = builder.format_response(ctx, "text STOP extra", stop) resp = builder.format_response(ctx, "text STOP extra", stop)
@@ -248,7 +268,9 @@ class TestAnthropicResponseBuilder:
assert resp["stop_reason"] == "stop_sequence" assert resp["stop_reason"] == "stop_sequence"
assert resp["stop_sequence"] == "STOP" assert resp["stop_sequence"] == "STOP"
def test_format_response_no_stop(self, builder):
def test_anthropic_format_response_no_stop():
builder = _make_anthropic_builder()
ctx = _make_ctx() ctx = _make_ctx()
stop = StopInfo() stop = StopInfo()
resp = builder.format_response(ctx, "full text", stop) resp = builder.format_response(ctx, "full text", stop)
+44
View File
@@ -1,8 +1,15 @@
"""Unit tests for the inference HTTP server.""" """Unit tests for the inference HTTP server."""
from pathlib import Path
import pytest import pytest
import torch
from astrai.inference import get_app from astrai.inference import get_app
from astrai.inference.network.app import _create_engine
from astrai.model.transformer import AutoRegressiveLM
from astrai.serialization import save_model
from tests.helpers import CHAT_TEMPLATE, build_test_tokenizer, make_tiny_config
def test_health_no_model(client): def test_health_no_model(client):
@@ -212,5 +219,42 @@ def test_chat_completions_stop_sequence_stream(client, loaded_model):
) )
def test_chat_completions_real_engine(tmp_path, client):
"""POST /v1/chat/completions with a real tiny model and tokenizer."""
cfg = make_tiny_config(vocab_size=256)
model = AutoRegressiveLM(cfg).eval()
save_model(cfg.to_dict(), model.state_dict(), str(tmp_path))
tokenizer = build_test_tokenizer(vocab_size=256, chat_template=CHAT_TEMPLATE)
tokenizer.save_pretrained(str(tmp_path))
engine = _create_engine(
Path(tmp_path),
device="cpu",
dtype=torch.float32,
max_batch_size=1,
max_seq_len=64,
)
try:
get_app().state.engine = engine
response = client.post(
"/v1/chat/completions",
json={
"messages": [{"role": "user", "content": "Hello"}],
"max_tokens": 4,
"temperature": 0.0,
"stream": False,
},
)
assert response.status_code == 200
data = response.json()
content = data["choices"][0]["message"]["content"]
assert isinstance(content, str)
assert data["usage"]["completion_tokens"] > 0
finally:
engine.shutdown()
get_app().state.engine = None
if __name__ == "__main__": if __name__ == "__main__":
pytest.main([__file__, "-v"]) pytest.main([__file__, "-v"])
+65 -57
View File
@@ -54,6 +54,20 @@ def _make_batch(config, batch_size=2, seq_len=8, with_extra=False):
return batch return batch
def _make_seq_moe_fixture(device):
config = _make_tiny_moe_config()
model = _make_model(config).to(device)
model.train()
return config, model
def _make_sft_moe_fixture(device):
config = _make_tiny_moe_config()
model = _make_model(config).to(device)
model.train()
return config, model
def test_model_forward_contract_uses_dense_training_and_packed_inference(): def test_model_forward_contract_uses_dense_training_and_packed_inference():
from astrai.inference.cache import PagePool, TaskCacheManager from astrai.inference.cache import PagePool, TaskCacheManager
from astrai.inference.workspace import InferenceWorkspace from astrai.inference.workspace import InferenceWorkspace
@@ -185,35 +199,28 @@ def test_moe_metrics_flow_through_wrapped_model(device):
assert "router_entropy" in strategy._moe_metrics assert "router_entropy" in strategy._moe_metrics
class TestSEQStrategyMoE: def test_seq_compute_loss_returns_scalar(device):
"""Endtoend tests for SEQStrategy with MoE aux loss."""
@pytest.fixture(autouse=True)
def setup(self, device):
self.device = device
self.config = _make_tiny_moe_config()
self.model = _make_model(self.config).to(device)
self.model.train()
def test_compute_loss_returns_scalar(self):
"""compute_loss should return a scalar tensor.""" """compute_loss should return a scalar tensor."""
config, model = _make_seq_moe_fixture(device)
strategy = SEQStrategy( strategy = SEQStrategy(
self.model, model,
self.device, device,
moe_aux_loss_coef=0.01, moe_aux_loss_coef=0.01,
) )
loss = strategy.compute_loss(_make_batch(self.config)) loss = strategy.compute_loss(_make_batch(config))
assert loss.ndim == 0 assert loss.ndim == 0
assert loss.requires_grad assert loss.requires_grad
def test_compute_loss_output_has_metrics(self):
def test_seq_compute_loss_output_has_metrics(device):
"""compute_loss_output dict with moe_aux_loss_coef > 0 includes MoE metrics.""" """compute_loss_output dict with moe_aux_loss_coef > 0 includes MoE metrics."""
config, model = _make_seq_moe_fixture(device)
strategy = SEQStrategy( strategy = SEQStrategy(
self.model, model,
self.device, device,
moe_aux_loss_coef=0.01, moe_aux_loss_coef=0.01,
) )
output = strategy.compute_loss_output(_make_batch(self.config)) output = strategy.compute_loss_output(_make_batch(config))
assert "loss" in output assert "loss" in output
assert "metrics" in output assert "metrics" in output
@@ -226,14 +233,16 @@ class TestSEQStrategyMoE:
assert key in metrics, f"Missing metric: {key}" assert key in metrics, f"Missing metric: {key}"
assert isinstance(metrics[key], float) assert isinstance(metrics[key], float)
def test_moe_metrics_populated_after_forward(self):
def test_seq_moe_metrics_populated_after_forward(device):
"""strategy._moe_metrics populated after compute_loss_output.""" """strategy._moe_metrics populated after compute_loss_output."""
config, model = _make_seq_moe_fixture(device)
strategy = SEQStrategy( strategy = SEQStrategy(
self.model, model,
self.device, device,
moe_aux_loss_coef=0.01, moe_aux_loss_coef=0.01,
) )
strategy.compute_loss_output(_make_batch(self.config)) strategy.compute_loss_output(_make_batch(config))
moe_metrics = strategy._moe_metrics moe_metrics = strategy._moe_metrics
assert moe_metrics, "_moe_metrics should not be empty for MoE model" assert moe_metrics, "_moe_metrics should not be empty for MoE model"
@@ -247,14 +256,16 @@ class TestSEQStrategyMoE:
assert key in moe_metrics, f"Missing _moe_metrics key: {key}" assert key in moe_metrics, f"Missing _moe_metrics key: {key}"
assert isinstance(moe_metrics[key], float) assert isinstance(moe_metrics[key], float)
def test_zero_coef_zeroes_weighted_aux(self):
def test_seq_zero_coef_zeroes_weighted_aux(device):
"""moe_aux_loss_coef=0 → weighted_aux_loss is zero, task_loss == loss.""" """moe_aux_loss_coef=0 → weighted_aux_loss is zero, task_loss == loss."""
config, model = _make_seq_moe_fixture(device)
strategy = SEQStrategy( strategy = SEQStrategy(
self.model, model,
self.device, device,
moe_aux_loss_coef=0.0, moe_aux_loss_coef=0.0,
) )
output = strategy.compute_loss_output(_make_batch(self.config)) output = strategy.compute_loss_output(_make_batch(config))
metrics = output["metrics"] metrics = output["metrics"]
# task_loss and loss should be equal (aux weighted by zero) # task_loss and loss should be equal (aux weighted by zero)
@@ -269,40 +280,44 @@ class TestSEQStrategyMoE:
assert strategy._moe_metrics assert strategy._moe_metrics
assert "router_entropy" in strategy._moe_metrics assert "router_entropy" in strategy._moe_metrics
def test_aux_loss_added_to_total_loss(self):
def test_seq_aux_loss_added_to_total_loss(device):
"""Total loss > task_loss when moe_aux_loss_coef > 0.""" """Total loss > task_loss when moe_aux_loss_coef > 0."""
config, model = _make_seq_moe_fixture(device)
strategy = SEQStrategy( strategy = SEQStrategy(
self.model, model,
self.device, device,
moe_aux_loss_coef=0.01, moe_aux_loss_coef=0.01,
) )
output = strategy.compute_loss_output(_make_batch(self.config)) output = strategy.compute_loss_output(_make_batch(config))
assert output["metrics"]["loss"] > output["metrics"]["task_loss"] + 1e-12 assert output["metrics"]["loss"] > output["metrics"]["task_loss"] + 1e-12
def test_factory_creates_strategy_with_coef(self):
def test_seq_factory_creates_strategy_with_coef(device):
"""StrategyFactory.create passes moe_aux_loss_coef to strategy.""" """StrategyFactory.create passes moe_aux_loss_coef to strategy."""
_, model = _make_seq_moe_fixture(device)
strategy = StrategyFactory.create( strategy = StrategyFactory.create(
"seq", "seq",
model=self.model, model=model,
device=self.device, device=device,
moe_aux_loss_coef=0.02, moe_aux_loss_coef=0.02,
) )
assert strategy.moe_aux_loss_coef == 0.02 assert strategy.moe_aux_loss_coef == 0.02
def test_no_aux_loss_for_mlp_model(self):
"""Pure MLP model: model outputs no aux_loss → no MoE metrics."""
from astrai.config.model_config import AutoRegressiveLMConfig
def test_seq_no_aux_loss_for_mlp_model(device):
"""Pure MLP model: model outputs no aux_loss → no MoE metrics."""
config, _ = _make_seq_moe_fixture(device)
mlp_config = AutoRegressiveLMConfig(**{**TINY_CONFIG, "ffn_type": "mlp"}) mlp_config = AutoRegressiveLMConfig(**{**TINY_CONFIG, "ffn_type": "mlp"})
mlp_model = AutoRegressiveLM(mlp_config).to(self.device) mlp_model = AutoRegressiveLM(mlp_config).to(device)
mlp_model.train() mlp_model.train()
strategy = SEQStrategy( strategy = SEQStrategy(
mlp_model, mlp_model,
self.device, device,
moe_aux_loss_coef=0.01, moe_aux_loss_coef=0.01,
) )
output = strategy.compute_loss_output(_make_batch(self.config)) output = strategy.compute_loss_output(_make_batch(config))
metrics = output["metrics"] metrics = output["metrics"]
assert "moe_aux_loss" not in metrics assert "moe_aux_loss" not in metrics
@@ -311,24 +326,15 @@ class TestSEQStrategyMoE:
assert strategy._moe_metrics == {} assert strategy._moe_metrics == {}
class TestSFTStrategyMoE: def test_sft_compute_loss_output_with_aux_loss(device):
"""Endtoend tests for SFTStrategy with MoE aux loss."""
@pytest.fixture(autouse=True)
def setup(self, device):
self.device = device
self.config = _make_tiny_moe_config()
self.model = _make_model(self.config).to(device)
self.model.train()
def test_compute_loss_output_with_aux_loss(self):
"""SFTStrategy produces MoE metrics when coef > 0.""" """SFTStrategy produces MoE metrics when coef > 0."""
config, model = _make_sft_moe_fixture(device)
strategy = SFTStrategy( strategy = SFTStrategy(
self.model, model,
self.device, device,
moe_aux_loss_coef=0.01, moe_aux_loss_coef=0.01,
) )
output = strategy.compute_loss_output(_make_batch(self.config, with_extra=True)) output = strategy.compute_loss_output(_make_batch(config, with_extra=True))
metrics = output["metrics"] metrics = output["metrics"]
assert "moe_aux_loss" in metrics assert "moe_aux_loss" in metrics
@@ -339,14 +345,16 @@ class TestSFTStrategyMoE:
assert "router_entropy" in moe_metrics assert "router_entropy" in moe_metrics
assert "dead_expert_fraction" in moe_metrics assert "dead_expert_fraction" in moe_metrics
def test_sft_zero_coef_zeroes_weighted_aux(self):
def test_sft_zero_coef_zeroes_weighted_aux(device):
"""SFTStrategy with zero coef: weighted aux is zero, loss == task_loss.""" """SFTStrategy with zero coef: weighted aux is zero, loss == task_loss."""
config, model = _make_sft_moe_fixture(device)
strategy = SFTStrategy( strategy = SFTStrategy(
self.model, model,
self.device, device,
moe_aux_loss_coef=0.0, moe_aux_loss_coef=0.0,
) )
output = strategy.compute_loss_output(_make_batch(self.config, with_extra=True)) output = strategy.compute_loss_output(_make_batch(config, with_extra=True))
metrics = output["metrics"] metrics = output["metrics"]
assert metrics["loss"] == pytest.approx(metrics["task_loss"], abs=1e-6) assert metrics["loss"] == pytest.approx(metrics["task_loss"], abs=1e-6)
+62
View File
@@ -183,6 +183,68 @@ def test_convert_hf_weights_rejects_mla():
convert_hf_weights(sd, cfg) convert_hf_weights(sd, cfg)
def test_convert_hf_config_qwen2_moe_preserves_sparse_fields():
raw = {
**LLAMA_RAW,
"model_type": "qwen2_moe",
"num_local_experts": 2,
"num_experts_per_tok": 1,
"n_shared_experts": 1,
"decoder_sparse_step": 2,
"mlp_only_layers": [0],
}
cfg = ConfigFactory.load(convert_hf_config(raw))
assert cfg.decoder_sparse_step == 2
assert cfg.mlp_only_layers == [0]
def test_convert_hf_config_gemma_enables_qk_norm():
raw = {**LLAMA_RAW, "model_type": "gemma"}
cfg = ConfigFactory.load(convert_hf_config(raw))
assert cfg.use_qk_norm is True
def test_convert_hf_weights_moe_with_dense_layers_roundtrip():
cfg = make_tiny_config(
ffn_type="moe",
n_routed_experts=2,
n_shared_experts=1,
n_activated_experts=1,
moe_intermediate_size=16,
shared_expert_intermediate_size=16,
mlp_only_layers=[0],
decoder_sparse_step=1,
)
model = AutoRegressiveLM(cfg)
converted = convert_hf_weights(to_hf_keys(model.state_dict()), cfg)
assert_state_dicts_equal(converted, model.state_dict())
def test_convert_hf_weights_qwen2_moe_singular_shared_expert_roundtrip():
cfg = make_tiny_config(
ffn_type="moe",
n_routed_experts=2,
n_shared_experts=1,
n_activated_experts=1,
moe_intermediate_size=16,
shared_expert_intermediate_size=16,
)
model = AutoRegressiveLM(cfg)
hf_sd = to_hf_keys(model.state_dict())
hf_sd = {
k.replace("shared_experts.", "shared_expert.", 1): v for k, v in hf_sd.items()
}
converted = convert_hf_weights(hf_sd, cfg)
assert_state_dicts_equal(converted, model.state_dict())
def test_convert_hf_weights_gemma_qk_norm_roundtrip():
cfg = make_tiny_config(use_qk_norm=True)
model = AutoRegressiveLM(cfg)
converted = convert_hf_weights(to_hf_keys(model.state_dict()), cfg)
assert_state_dicts_equal(converted, model.state_dict())
def test_from_pretrained_hf_directory(tmp_path): def test_from_pretrained_hf_directory(tmp_path):
cfg = make_tiny_config() cfg = make_tiny_config()
model = AutoRegressiveLM(cfg).eval() model = AutoRegressiveLM(cfg).eval()
+2 -4
View File
@@ -31,13 +31,11 @@ def test_gradient_checkpointing_empty_modules_noop(test_model):
model = test_model["model"] model = test_model["model"]
callback = GradientCheckpointingCallback() callback = GradientCheckpointingCallback()
originals = [layer.forward for layer in model.layers]
for layer in model.layers: for layer in model.layers:
callback._enable(layer) callback._enable(layer)
for layer, orig in zip(model.layers, originals): for layer in model.layers:
assert layer.forward is orig assert not hasattr(layer, "_original_forward")
def test_gradient_checkpointing_forward_unchanged(test_model): def test_gradient_checkpointing_forward_unchanged(test_model):