"""Test script for Engine implementation."""
import os
from types import SimpleNamespace
from typing import Any
from unittest.mock import MagicMock
import pytest
import torch
import torch.distributed as dist
from transformers import AutoTokenizer
from tests.utils import get_model_path
from areal.api import FinetuneSpec, SaveLoadMeta
from areal.api.cli_args import (
FSDPEngineConfig,
MicroBatchSpec,
OptimizerConfig,
TrainEngineConfig,
)
from areal.engine.fsdp_utils.attn_impl import BUILTIN_ATTN_IMPLS
from areal.infra.platforms import current_platform
VOCAB_SIZE = 100
MODEL_PATH = get_model_path(
"/storage/openpsi/models/Qwen__Qwen3-0.6B/", "Qwen/Qwen3-0.6B"
)
class DummyDeviceStats:
def log(self, *_args, **_kwargs) -> None:
pass
class DummyModel:
def __init__(self):
self.use_kernels = False
self.gradient_checkpointing_calls = []
def gradient_checkpointing_enable(self, **kwargs):
self.gradient_checkpointing_calls.append(kwargs)
@pytest.fixture(scope="module")
def mock_input(
batch_size=5,
min_seqlen=10,
max_seqlen=20,
device=current_platform.device_type,
) -> dict[str, Any]:
"""Create mock padded input data (same format for huggingface) for testing.
Returns a dict with input_ids, attention_mask, and position_ids.
"""
pad_token_id = 0
seqlens = torch.randint(
min_seqlen, max_seqlen, (batch_size,), dtype=torch.int, device=device
)
max_seqlen = int(max(seqlens))
input_ids = torch.randint(
0, VOCAB_SIZE, (batch_size, max_seqlen), dtype=torch.long, device=device
)
attn_mask = torch.zeros((batch_size, max_seqlen), dtype=torch.bool, device=device)
attn_mask[
torch.arange(0, max_seqlen, device=device).unsqueeze(0) < seqlens.unsqueeze(1)
] = 1
input_ids.masked_fill_(~attn_mask, pad_token_id)
return dict(
input_ids=input_ids,
attention_mask=attn_mask,
)
def get_engine(engine_type: str, model_path: str):
from areal.engine import FSDPEngine
engine_cls = {"fsdp": FSDPEngine}[engine_type]
engine_config = TrainEngineConfig(
backend="fsdp:d1",
experiment_name=f"test-{engine_type}-engine",
trial_name="test0",
path=model_path,
optimizer=OptimizerConfig(),
)
engine = engine_cls(engine_config)
engine.create_process_group()
ft_spec = FinetuneSpec(total_train_epochs=1, dataset_size=100, train_batch_size=2)
engine.initialize(None, ft_spec)
return engine
def mock_loss_fn(
logprobs: torch.Tensor,
entropy: torch.Tensor,
input_data: dict,
**kwargs,
) -> torch.Tensor:
"""Mock loss function for testing."""
return torch.mean(logprobs)
@pytest.fixture(
params=[
{"type": "fsdp", "construction": "config"},
{"type": "fsdp", "construction": "from_pretrained"},
]
)
def engine(request):
os.environ.update(
{
"WORLD_SIZE": "1",
"RANK": "0",
"LOCAL_RANK": "0",
"MASTER_ADDR": "localhost",
"MASTER_PORT": "7777",
}
)
construction = request.param["construction"]
engine_type = request.param["type"]
if construction == "config":
engine = get_engine(engine_type, MODEL_PATH)
else:
from areal.engine import FSDPEngine
engine = FSDPEngine.from_pretrained(
model=MODEL_PATH,
experiment_name="test_exp",
trial_name="test_trial",
dp_size=1,
tp_size=1,
learning_rate=1e-5,
)
engine.create_process_group()
ft_spec = FinetuneSpec(
total_train_epochs=1, dataset_size=100, train_batch_size=2
)
engine.initialize(None, ft_spec)
print(f"✓ {engine_type.upper()} Engine created successfully")
try:
yield engine
finally:
engine.destroy()
assert not dist.is_initialized()
@torch.no_grad()
def test_forward_microbatch(engine, mock_input):
engine.eval()
engine.config.mb_spec = MicroBatchSpec(n_mbs=2, max_tokens_per_mb=100)
x2 = engine.forward(input_=mock_input)
engine.config.mb_spec = MicroBatchSpec(n_mbs=1, max_tokens_per_mb=100)
x1 = engine.forward(input_=mock_input)
attn_mask = mock_input["attention_mask"]
loss_mask = attn_mask.clone()
loss_mask[:, :-1] = attn_mask[:, :-1] & attn_mask[:, 1:]
loss_mask[:, -1] = False
x1_valid = x1[loss_mask]
x2_valid = x2[loss_mask]
assert torch.allclose(x1_valid, x2_valid, atol=1e-4, rtol=1e-3), (
(x1_valid - x2_valid).abs().max().item()
)
@torch.no_grad()
def test_eval_batch(engine, mock_input):
engine.eval()
engine.config.mb_spec = MicroBatchSpec(n_mbs=2, max_tokens_per_mb=100)
eval_result = engine.eval_batch(
input_=mock_input,
loss_fn=mock_loss_fn,
loss_weight_fn=lambda x: x["cu_seqlens"][-1],
)
assert isinstance(eval_result, torch.Tensor), "Evaluation should return a tensor"
assert eval_result.is_cuda, "Evaluation tensor should be on CUDA device"
assert eval_result is not None, "Evaluation should return a loss value"
print(f"✓ Evaluation successful, loss: {eval_result.item()}")
def test_train_batch(engine, mock_input):
engine.train()
engine.config.mb_spec = MicroBatchSpec(n_mbs=2, max_tokens_per_mb=100)
train_result = engine.train_batch(
input_=mock_input,
loss_fn=mock_loss_fn,
loss_weight_fn=lambda x: x["cu_seqlens"][-1],
)
assert isinstance(train_result, dict), "Training should return a dictionary"
assert train_result["grad_norm"] is not None
assert train_result["lr"] is not None
print("✓ Training successful")
@torch.no_grad()
def test_hf_save_load_weights(tmp_path_factory, engine, mock_input):
tokenizer = AutoTokenizer.from_pretrained(MODEL_PATH)
path = tmp_path_factory.mktemp("hf_engine_test")
save_load_meta = SaveLoadMeta(
path=path,
weight_format="hf",
tokenizer=tokenizer,
with_optim=True,
base_model_path=None,
)
engine.config.mb_spec = MicroBatchSpec(n_mbs=1, max_tokens_per_mb=100)
old = engine.forward(input_=mock_input)
engine.save(save_load_meta)
for name, param in engine.model.named_parameters():
param.zero_()
engine.load(save_load_meta)
new = engine.forward(input_=mock_input)
assert torch.allclose(old, new)
@torch.no_grad()
@pytest.mark.slow
def test_dcp_save_load_weights(tmp_path_factory, engine, mock_input):
tokenizer = AutoTokenizer.from_pretrained(MODEL_PATH)
path = tmp_path_factory.mktemp("dcp_engine_test")
save_load_meta = SaveLoadMeta(
path=path,
weight_format="dcp",
tokenizer=tokenizer,
with_optim=True,
base_model_path=None,
)
engine.config.mb_spec = MicroBatchSpec(n_mbs=1, max_tokens_per_mb=100)
old = engine.forward(input_=mock_input)
engine.save(save_load_meta)
for name, param in engine.model.named_parameters():
param.zero_()
engine.load(save_load_meta)
new = engine.forward(input_=mock_input)
assert torch.allclose(old, new)
@pytest.mark.parametrize(
"attn_impl",
[
*BUILTIN_ATTN_IMPLS,
"kernels-community/flash-attn",
"kernels-community/flash-attn@main:flash_attn_varlen_func",
"flash_attention_2|kernels-community/flash-attn@main:flash_attn_varlen_func",
],
)
def test_train_engine_config_accepts_builtin_and_kernel_attn_impls(attn_impl):
config = TrainEngineConfig(
backend="fsdp:d1",
experiment_name="test-experiment",
trial_name="trial0",
path="test-model",
attn_impl=attn_impl,
)
assert config.attn_impl == attn_impl
@pytest.mark.parametrize(
"attn_impl",
[
"kernels-community",
"kernels-community/flash-attn/extra",
"kernels-community/flash-attn:entry:extra",
],
)
def test_train_engine_config_rejects_invalid_kernel_attn_impl(attn_impl):
with pytest.raises(ValueError, match="attn_impl must be one of"):
TrainEngineConfig(
backend="fsdp:d1",
experiment_name="test-experiment",
trial_name="trial0",
path="test-model",
attn_impl=attn_impl,
)
@pytest.mark.parametrize(
("memory_efficient_load", "expected_loader"),
[(False, "from_pretrained"), (True, "from_config")],
)
def test_create_llm_actor_or_critic_forwards_attn_impl(
monkeypatch, memory_efficient_load, expected_loader
):
import areal.engine.fsdp_engine as fsdp_module
calls = []
class FakeModelFactory:
@staticmethod
def from_config(config, **kwargs):
calls.append(("from_config", config, kwargs))
return DummyModel()
@staticmethod
def from_pretrained(pretrained_model_name_or_path, **kwargs):
calls.append(("from_pretrained", pretrained_model_name_or_path, kwargs))
return DummyModel()
monkeypatch.setattr(
fsdp_module.AutoConfig,
"from_pretrained",
lambda *args, **kwargs: SimpleNamespace(model_type="qwen2"),
)
monkeypatch.setattr(fsdp_module, "is_valid_vision_model", lambda *_args: False)
monkeypatch.setattr(fsdp_module, "AutoModelForCausalLM", FakeModelFactory)
config = TrainEngineConfig(
backend="fsdp:d1",
experiment_name="test-experiment",
trial_name="trial0",
path="test-model",
attn_impl="kernels-community/flash-attn",
optimizer_dtype="bfloat16",
fsdp=FSDPEngineConfig(memory_efficient_load=memory_efficient_load),
)
engine = fsdp_module.FSDPEngine(config)
engine.model_config = SimpleNamespace()
model = engine._create_llm_actor_or_critic()
assert isinstance(model, DummyModel)
assert calls[0][0] == expected_loader
assert calls[0][2]["attn_implementation"] == "kernels-community/flash-attn"
assert calls[0][2]["dtype"] == torch.bfloat16
@pytest.mark.parametrize("memory_efficient_load", [False, True])
def test_create_device_model_applies_use_kernels(monkeypatch, memory_efficient_load):
import areal.engine.fsdp_engine as fsdp_module
monkeypatch.setattr(
fsdp_module.AutoConfig,
"from_pretrained",
lambda *args, **kwargs: SimpleNamespace(model_type="qwen2"),
)
monkeypatch.setattr(fsdp_module, "is_valid_vision_model", lambda *_args: False)
monkeypatch.setattr(fsdp_module, "load_hf_tokenizer", lambda *_args: object())
monkeypatch.setattr(
fsdp_module.current_platform, "set_device", lambda *_args, **_kwargs: None
)
monkeypatch.setattr(fsdp_module.current_platform, "device_type", "cpu")
monkeypatch.setattr(
fsdp_module.FSDPEngine,
"get_device_stats",
lambda self: DummyDeviceStats(),
)
config = TrainEngineConfig(
backend="fsdp:d1",
experiment_name="test-experiment",
trial_name="trial0",
path="test-model",
use_kernels=True,
gradient_checkpointing=True,
fsdp=FSDPEngineConfig(memory_efficient_load=memory_efficient_load),
)
engine = fsdp_module.FSDPEngine(config)
engine.logger = MagicMock()
dummy_model = DummyModel()
monkeypatch.setattr(
engine,
"_create_llm_actor_or_critic",
lambda: dummy_model,
)
monkeypatch.setenv("LOCAL_RANK", "0")
monkeypatch.setattr(dist, "get_rank", lambda group=None: 0)
engine._create_device_model()
assert engine.model is dummy_model
assert engine.model.use_kernels is True
assert engine.model.gradient_checkpointing_calls == [
{"gradient_checkpointing_kwargs": {"use_reentrant": False}}
]
def test_fsdp_engine_config_construction():
"""Test that FSDPEngine.from_pretrained builds a valid config."""
import areal.engine.fsdp_engine as fsdp_module
engine = fsdp_module.FSDPEngine.from_pretrained(
model=MODEL_PATH,
experiment_name="test_exp",
trial_name="test_trial",
dp_size=1,
learning_rate=1e-5,
use_lora=True,
)
config = TrainEngineConfig(
path=MODEL_PATH,
backend="fsdp:d1t1",
optimizer=OptimizerConfig(lr=1e-5),
use_lora=True,
)
engine2 = fsdp_module.FSDPEngine(config=config)
assert engine.config.path == engine2.config.path
assert engine.config.backend == engine2.config.backend
assert engine.config.optimizer.lr == engine2.config.optimizer.lr
assert engine.config.use_lora == engine2.config.use_lora
@pytest.mark.skipif(
not torch.cuda.is_available()
or torch.cuda.device_count() < 4
or int(os.environ.get("WORLD_SIZE", "1")) < 4,
reason="requires 4 GPUs and 4 processes (torchrun --nproc_per_node=4)",
)
def test_fsdp_engine_alloc_mode_construction():
"""
Test that FSDPEngine.from_pretrained builds a valid config.
Run with 4 processes (required for dp_size=2, tp_size=2):
torchrun --nproc_per_node=4 -m pytest tests/test_train_engine.py -v -k test_fsdp_engine_alloc_mode_construction
"""
import areal.engine.fsdp_engine as fsdp_module
engine = fsdp_module.FSDPEngine.from_pretrained(
model=MODEL_PATH,
experiment_name="test_exp",
trial_name="test_trial",
dp_size=2,
tp_size=2,
learning_rate=1e-5,
use_lora=True,
)
engine.create_process_group()
dist.barrier()
assert engine.parallel_helper.dp_size == 2
assert engine.parallel_helper.tp_size == 2
engine.destroy()