DEG-5B-E2B-1018 / source /tests /test_lfm_backbone.py
gcoderw's picture
Rename package to deg (DEG_ env vars, deg.json); note upcoming modalities
16d760d verified
Raw History Blame Contribute Delete
16.8 kB
"""The LFM2.5 backbone (``--backbone lfm2.5-1.2b``; SPEC.md §12.8) on a tiny random LFM2
(CPU, exact torch reference kernels) with the real LFM2.5 tokenizer, plus GPU tests on the
real LFM2.5-1.2B-Instruct.
The model-contract tests of ``test_model`` / ``test_hybrid_generate`` / ``test_train_long``
are imported and collected here against this module's LFM fixtures (field independence
is bitwise alone / reordered / in bigger schemas / across requests, chunked prefill equals
the one-shot read, OOM splits are bitwise, hybrid generate decodes with the frozen base);
LFM-specific tests below check the functional stack against HF ``Lfm2ForCausalLM``,
forked fields against independent full forwards, the renderer's tokens, checkpoints and
training gradients.
"""
from __future__ import annotations
import random
import pytest
torch = pytest.importorskip("torch")
pytest.importorskip("transformers")
import test_hybrid_generate as thg # noqa: E402
import test_model as tm # noqa: E402
import test_train_long as ttl # noqa: E402
from test_hybrid_generate import ( # noqa: E402,F401 (collected here with the LFM pair)
test_hybrid_fill_many_matches_fill,
test_hybrid_generate_rows_are_batch_invariant,
test_hybrid_keeps_deg_fields_and_decodes_text_with_the_base,
)
from test_model import ( # noqa: E402,F401 (collected here with the LFM model)
CONTEXT,
_raw_outputs,
_sub,
test_blocked_span_logits_match_one_shot,
test_consume_extends_state_without_holding_two_copies,
test_field_outputs_are_bitwise_independent,
test_fill_many_matches_fill_alone,
test_fill_meta_is_identical_in_split_and_merged_schemas,
test_fork_batch_split_on_oom_gives_the_same_outputs,
test_generate_fields_are_independent_of_each_other,
test_long_prefill_padded_batch_and_prefix_state,
test_long_prefill_streams_in_chunks_without_quadratic_tensors,
test_only_x_generate_fields_use_the_lm_head,
test_per_field_generate_budget,
test_sampled_generation_is_reproducible_with_seed,
test_span_pointer_addresses_context_tokens_only,
)
from test_train_long import test_inference_long_read_unchanged_by_training_path # noqa: E402,F401
from deg import compile_schema, validate_value # noqa: E402
from deg.infer import fill # noqa: E402
from deg.model import LFM2, base_snapshot, load_model, load_tokenizer # noqa: E402
BACKBONE = "lfm2.5-1.2b"
try:
LFM_SNAPSHOT = base_snapshot(BACKBONE)
except FileNotFoundError: # pragma: no cover - needs the local HF cache
LFM_SNAPSHOT = None
def tiny_lfm(seed: int = 0):
from transformers import Lfm2Config, Lfm2ForCausalLM
torch.manual_seed(seed)
cfg = Lfm2Config(
vocab_size=65536, hidden_size=64, intermediate_size=128,
block_auto_adjust_ff_dim=False, num_hidden_layers=4,
layer_types=["conv", "full_attention", "conv", "full_attention"],
num_attention_heads=4, num_key_value_heads=2, conv_L_cache=3, conv_bias=False,
norm_eps=1e-5, tie_embedding=True, max_position_embeddings=4096,
rope_parameters={"rope_type": "default", "rope_theta": 10000.0},
pad_token_id=0, bos_token_id=1, eos_token_id=7)
lm = Lfm2ForCausalLM(cfg).float().eval()
with torch.no_grad(): # random norms so the stack is not near-identity
for name, p in lm.named_parameters():
if "norm" in name:
p.normal_(1.0, 0.3)
return lm
def _tok():
if LFM_SNAPSHOT is None:
pytest.skip("LFM2.5-1.2B-Instruct tokenizer not cached")
return load_tokenizer(backbone=BACKBONE)
def _lfm_model(seed: int = 0, **kw):
m = load_model(None, device="cpu", dtype=torch.float32, lm=tiny_lfm(seed),
tokenizer=_tok(), **kw)
torch.manual_seed(1)
with torch.no_grad():
for p in m.heads.parameters():
p.normal_(0.0, 0.5)
return m
@pytest.fixture(scope="module")
def model():
return _lfm_model()
@pytest.fixture(scope="module")
def pair():
"""(deg-only, hybrid) LFM models: the hybrid's frozen base is another random LFM
with the same (frozen) embeddings, as deg training leaves them."""
def make(generate):
base, deg = tiny_lfm(7), tiny_lfm(0)
with torch.no_grad():
deg.model.embed_tokens.weight.copy_(base.model.embed_tokens.weight)
m = load_model(None, device="cpu", dtype=torch.float32, lm=deg, tokenizer=_tok(),
generate=generate, base_lm=base)
torch.manual_seed(1)
with torch.no_grad():
for p in m.heads.parameters():
p.normal_(0.0, 0.5)
return m
return make("self"), make("base")
# ----------------------------------------------------------------------- identity
def test_lfm_renderer_and_meta(model):
rend = model.renderer
assert model.backbone.fam is LFM2 and model.meta["backbone"] == BACKBONE
assert model.backbone.types == ["rec", "attn", "rec", "attn"]
ctx = rend.context(CONTEXT)
tok = rend.tokenizer
assert ctx.ids[0] == tok.convert_tokens_to_ids("<|startoftext|>") # BOS
assert tok.convert_ids_to_tokens(ctx.ids[1]) == "<|im_start|>"
assert rend.pad_id == tok.convert_tokens_to_ids("<|pad|>")
assert rend.eos_id == tok.convert_tokens_to_ids("<|im_end|>")
assert rend.gen_turn == "<|im_end|>\n<|im_start|>assistant\n" # no <think> block
# every context token maps back to its text
for i, (a, b) in enumerate(ctx.offsets[:20]):
assert tok.decode([ctx.ids[ctx.ctx_start + i]]).strip() == CONTEXT[a:b].strip()
def test_lfm_backbone_matches_hf_and_fork_equals_concatenation(model):
"""The functional stack equals HF Lfm2Model on the concatenated read; continuing a
state (dense and padded rows) equals reading the whole sequence."""
rend = model.renderer
a = list(rend.context(CONTEXT).ids)
b = rend.enc("FIELD /order_id\nKIND: copy\nANSWER:")
with torch.no_grad():
ref = model.backbone.text(torch.tensor([a + b])).last_hidden_state[0]
h1, st = model.backbone(torch.tensor([a]), torch.tensor([len(a)]))
h2, _ = model.backbone(torch.tensor([b]), torch.tensor([len(b)]), st)
pad = torch.full((2, len(a) + 5), rend.pad_id)
pad[0, :len(a)] = torch.tensor(a)
pad[1, :3] = torch.tensor(a[:3])
_, st2 = model.backbone(pad, torch.tensor([len(a), 3]))
fork = torch.full((1, len(b) + 9), rend.pad_id)
fork[0, :len(b)] = torch.tensor(b)
h3, _ = model.backbone(fork, torch.tensor([len(b)]), st2.select(torch.tensor([0])))
torch.testing.assert_close(torch.cat([h1[0], h2[0]]), ref, atol=2e-5, rtol=2e-5)
torch.testing.assert_close(h3[0, :len(b)], h2[0], atol=2e-5, rtol=2e-5)
# conv layers carry no recurrent state, only a 2-token tail of B*x
assert all(r.shape == (1, 0) and t.shape == (1, 2, 64) for r, t in st.lin)
def test_lfm_forked_fields_equal_independent_full_forward(model):
"""Each fork row (prefix read by reference, run in one batch with other fields) equals
an independent HF full forward over context + that field's query; and a row's fork
hidden states are bitwise the same alone and in the batch."""
rend = model.renderer
ctx = list(rend.context(CONTEXT).ids)
queries = [rend.enc(t) for t in (
"FIELD /order_id\nKIND: copy\nANSWER:",
"FIELD /department\nOPTIONS:\n- billing\n- technical\nANSWER:",
"ARRAY /items\nANSWER:",
"FIELD /total\nTITLE: total charged\nKIND: number\nCONSTRAINTS: {}\nANSWER:")]
bb = model.backbone
def forks(rows):
width = max(len(q) for q in rows)
ids = torch.full((len(rows), width), rend.pad_id)
for i, q in enumerate(rows):
ids[i, :len(q)] = torch.tensor(q)
_, st = bb(torch.tensor([ctx]), torch.tensor([len(ctx)]))
fs = bb.fork(st, len(rows), width)
return bb.fork_forward(ids, torch.tensor([len(q) for q in rows]), fs)
with torch.no_grad():
batch = forks(queries)
for i, q in enumerate(queries):
ref = bb.text(torch.tensor([ctx + q])).last_hidden_state[0, len(ctx):]
torch.testing.assert_close(batch[i, :len(q)], ref, atol=2e-5, rtol=2e-5)
alone = forks([q])
assert torch.equal(alone[0, :len(q)], batch[i, :len(q)]), i
def test_lfm_fork_decode_continues_state(model):
"""keep=True fork passes (header, then one token at a time: the generate decode path)
equal the full read of context + header + tokens."""
rend = model.renderer
bb = model.backbone
ctx = list(rend.context(CONTEXT).ids)
head = rend.enc("WRITE /reply\nVALUE:\n")
more = rend.enc(" Refund issued today")
with torch.no_grad():
_, st = bb(torch.tensor([ctx]), torch.tensor([len(ctx)]))
fs = bb.fork(st, 2, len(head) + len(more) + 1)
ids = torch.tensor([head, head])
bb.fork_forward(ids, torch.tensor([len(head)] * 2), fs, keep=True)
outs = []
for t in more:
outs.append(bb.fork_forward(torch.tensor([[t], [t]]), torch.tensor([1, 1]), fs,
keep=True)[:, 0])
ref = bb.text(torch.tensor([ctx + head + more])).last_hidden_state[0, -len(more):]
got = torch.stack(outs, 1)
torch.testing.assert_close(got[0], ref, atol=2e-5, rtol=2e-5)
assert torch.equal(got[0], got[1])
def test_lfm_chunked_checkpointed_trunk_equals_dense(model, monkeypatch):
"""Training trunks beyond DENSE_PREFILL_MAX (checkpointed chunks) equal the dense
checkpointed read in hidden states, state, forks and gradients (test_train_long)."""
emb = model.backbone.text.embed_tokens.weight
model.backbone.grad_ckpt = True
emb.requires_grad_(False)
try:
ttl.test_chunked_checkpointed_trunk_equals_dense(model, monkeypatch)
finally:
model.backbone.grad_ckpt = False
emb.requires_grad_(True)
for p in model.backbone.parameters():
p.grad = None
def test_lfm_training_example_and_losses(model):
from deg.train import deg_example, forward_losses
a = CONTEXT.index("AB-1234")
rec = {"uid": "t1", "schema": _sub(["department", "order_id", "items", "reply"]),
"context": CONTEXT,
"value": {"department": "billing", "order_id": "AB-1234",
"items": [{"name": "chairs", "qty": 2}], "reply": "Refund issued."},
"spans": {"/order_id": [a, a + 7],
"/items/0/name": [CONTEXT.index("chairs"),
CONTEXT.index("chairs") + 6]},
"span_alternatives": {}, "span_probs": {},
"decide_probs": {"/department": [0.8, 0.15, 0.05]}}
ex = deg_example(model, rec, random.Random(0), shuffle_p=0.0)
assert sorted(it.kind for it in ex.items) == [
"anchor", "choice", "extract", "extract", "generate", "score"]
model.backbone.train()
try:
losses, _ = forward_losses(model, [ex], 4096, 16)
finally:
model.backbone.eval()
total = sum(losses[k] for k in ("decide", "extract", "anchor", "lm"))
assert torch.isfinite(total)
total.backward()
layers = model.backbone.text.layers
assert layers[0].conv.in_proj.weight.grad is not None
assert layers[1].self_attn.q_proj.weight.grad is not None
assert layers[1].feed_forward.w1.weight.grad is not None
for p in [*model.heads.parameters(), *model.backbone.parameters()]:
p.grad = None
def test_lfm_checkpoint_roundtrip_keeps_backbone(tmp_path):
model = _lfm_model(5)
with torch.no_grad(): # checkpoints store the backbone in bf16
for p in model.backbone.parameters():
p.copy_(p.bfloat16().float())
model.save(tmp_path / "ck")
meta = __import__("json").loads((tmp_path / "ck" / "deg.json").read_text())
assert meta["backbone"] == BACKBONE
back = load_model(tmp_path / "ck", device="cpu", dtype=torch.float32, lm=tiny_lfm(3),
tokenizer=_tok())
assert back.backbone.fam is LFM2
want = _raw_outputs(model, _sub(["order_id", "department"]))
got = _raw_outputs(back, _sub(["order_id", "department"]))
for k in want:
assert torch.equal(want[k], got[k]), k
with pytest.raises(ValueError):
load_model(tmp_path / "ck", device="cpu", dtype=torch.float32, lm=tiny_lfm(3),
tokenizer=_tok(), backbone="qwen3.5-0.8b")
with pytest.raises(ValueError): # a Qwen base cannot be the LFM model's generator
from deg.model import attach_base
attach_base(back, tm.tiny_lm(0))
def test_lfm_hybrid_base_shares_embeddings(pair):
_, hyb = pair
assert hyb.base is not None and hyb.base.shared_embeddings and hyb.base.fam is LFM2
emb = hyb.backbone.text.embed_tokens.weight
assert hyb.base.text.embed_tokens.weight is emb and hyb.base.lm.lm_head.weight is emb
resp = fill(hyb, CONTEXT, thg.SCHEMA)
validate_value(compile_schema(thg.SCHEMA), resp["value"])
# ---------------------------------------------------------------------------- GPU
def _gpu_free_gib() -> float:
if not torch.cuda.is_available():
return 0.0
return torch.cuda.mem_get_info()[0] / 2**30
@pytest.mark.skipif(LFM_SNAPSHOT is None or _gpu_free_gib() < 4.0,
reason="needs CUDA with ~4 GiB free and the LFM2.5-1.2B snapshot")
def test_gpu_lfm_real_model_bitwise_independence_and_hf_match():
"""Real LFM2.5-1.2B in bf16 on CUDA: the fork path (bi_linear GEMMs, fork_attention,
Triton LFM RMSNorm, elementwise short conv) gives bitwise identical raw outputs alone,
reordered, inside a bigger schema and across requests (fill_many); the hybrid's base
text is identical alone and next to another generate field; the functional trunk
matches HF within bf16 noise."""
m = load_model(None, device="cuda", backbone=BACKBONE, generate="base")
rend = m.renderer
ids = torch.tensor([rend.context(CONTEXT).ids], device="cuda")
with torch.no_grad():
ref = m.backbone.text(ids).last_hidden_state[0].float()
mine, _ = m.backbone(ids, torch.tensor([ids.shape[1]], device="cuda"))
cos = torch.nn.functional.cosine_similarity(mine[0].float(), ref, -1)
assert float(cos.min()) > 0.99, float(cos.min())
flat = [n for n in tm.SCHEMA["properties"] if n not in ("items", "reply")]
merged = _raw_outputs(m, _sub(flat))
reordered = _raw_outputs(m, _sub(list(reversed(flat))))
big = {"type": "object", "properties": {
**{f"f{i}": {"type": ["string", "null"], "description": f"filler {i}"}
for i in range(12)}, **_sub(flat)["properties"]},
"required": _sub(flat)["required"]}
big_out = _raw_outputs(m, big)
for name in flat:
alone = _raw_outputs(m, _sub([name]))[f"/{name}"]
for other in (merged, reordered, big_out):
assert torch.equal(alone, other[f"/{name}"]), name
resp = fill(m, CONTEXT, tm.SCHEMA)
validate_value(compile_schema(tm.SCHEMA), resp["value"])
both = fill(m, CONTEXT, tm._with_note())
assert both["value"]["reply"] == resp["value"]["reply"]
assert both["meta"]["/reply"] == resp["meta"]["/reply"]
from deg.infer import fill_many
other = "Dana again: the desk arrived broken. Order AB-9 total $80."
jobs = [(CONTEXT, tm.SCHEMA, None), (other, tm._with_note(), None),
(other, _sub(["department", "items"]), None)]
for job, got in zip(jobs, fill_many(m, jobs)):
assert got == fill(m, *job)
@pytest.mark.skipif(LFM_SNAPSHOT is None or _gpu_free_gib() < 4.0,
reason="needs CUDA with ~4 GiB free and the LFM2.5-1.2B snapshot")
def test_gpu_lfm_chunked_prefill_matches(monkeypatch):
"""Real LFM2.5-1.2B on CUDA: the chunked prefill (flash-style fork kernel over the K/V
buffer, head_dim 64, 4 query heads per K/V head) matches the one-shot read within bf16
noise for padded rows."""
import deg.model as dm
m = load_model(None, device="cuda", backbone=BACKBONE)
rend = m.renderer
a = rend.context(" ".join([CONTEXT] * 40)).ids
b = rend.context(" ".join([CONTEXT] * 15)).ids
x = torch.full((2, len(a)), rend.pad_id, device="cuda")
x[0], x[1, :len(b)] = torch.tensor(a), torch.tensor(b)
n = torch.tensor([len(a), len(b)], device="cuda")
with torch.no_grad():
h_one, s_one = m.backbone(x, n)
monkeypatch.setattr(dm, "DENSE_PREFILL_MAX", 512)
monkeypatch.setattr(dm, "PREFILL_CHUNK", 512)
h, s = m.backbone(x, n)
for row, L in enumerate((len(a), len(b))):
cos = torch.nn.functional.cosine_similarity(h[row, :L].float(),
h_one[row, :L].float(), -1)
assert float(cos.min()) > 0.99 and float(cos.mean()) > 0.9995, row
assert torch.equal(s.kv_mask, s_one.kv_mask)