Skip to content

Commit 93efad0

Browse files
committed
Make sLSTM checkpoints portable across backends (#127)
The sLSTM cell stores its recurrent kernel and bias in a backend-specific internal layout (sLSTMCell_vanilla vs sLSTMCell_cuda use different shapes and orderings). state_dict serialized those internal parameters directly, so a checkpoint saved with one backend could not be loaded into a cell using the other: the recurrent kernel raised a size mismatch and the bias loaded silently with a scrambled gate/head ordering -- even though the cell already exposes a single canonical (external) layout via its ext2int/int2ext conversions and documents cross-backend conversion as an intended workflow. Register a state_dict hook that stores the recurrent kernel and bias in the backend-agnostic external layout, and a load-state-dict pre-hook that converts an external-layout checkpoint back into the current backend's internal layout. External tensors are identified by rank (recurrent kernel 4D, bias 3D), so legacy checkpoints saved in the internal layout still load into a cell of their original backend. Adds regression tests covering cross-backend load (both directions), same-backend round-trips, and legacy internal-layout checkpoints.
1 parent f539ba8 commit 93efad0

2 files changed

Lines changed: 138 additions & 0 deletions

File tree

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
"""Regression tests for sLSTM cross-backend checkpoint loading (issue #127).
2+
3+
The sLSTM cell stores its recurrent kernel and bias in a backend-specific
4+
internal layout. ``state_dict`` used to serialize those internal parameters
5+
directly, so a checkpoint saved with one backend could not be loaded into a
6+
cell using another backend. These tests run on CPU via ``skip_backend_init``.
7+
"""
8+
9+
import pytest
10+
import torch
11+
12+
from xlstm.blocks.slstm.cell import (
13+
sLSTMCell_cuda,
14+
sLSTMCell_vanilla,
15+
sLSTMCellConfig,
16+
)
17+
18+
19+
def _external_weights(cell):
20+
"""Return the backend-agnostic (external) recurrent kernel and bias."""
21+
return (
22+
cell._recurrent_kernel_int2ext(cell._recurrent_kernel_),
23+
cell._bias_int2ext(cell._bias_),
24+
)
25+
26+
27+
def _randomize(cell):
28+
with torch.no_grad():
29+
cell._recurrent_kernel_.copy_(torch.randn_like(cell._recurrent_kernel_))
30+
cell._bias_.copy_(torch.randn_like(cell._bias_))
31+
32+
33+
@pytest.mark.parametrize(
34+
"src_cls,dst_cls",
35+
[
36+
(sLSTMCell_cuda, sLSTMCell_vanilla),
37+
(sLSTMCell_vanilla, sLSTMCell_cuda),
38+
],
39+
)
40+
def test_state_dict_loads_across_backends(src_cls, dst_cls):
41+
"""A checkpoint saved with one backend loads into the other without error
42+
and preserves the (canonical) weights."""
43+
config = sLSTMCellConfig(hidden_size=16, num_heads=4)
44+
src = src_cls(config, skip_backend_init=True)
45+
_randomize(src)
46+
dst = dst_cls(config, skip_backend_init=True)
47+
48+
missing, unexpected = dst.load_state_dict(src.state_dict(), strict=True)
49+
assert missing == [] and unexpected == []
50+
51+
src_kernel, src_bias = _external_weights(src)
52+
dst_kernel, dst_bias = _external_weights(dst)
53+
assert torch.allclose(dst_kernel, src_kernel)
54+
assert torch.allclose(dst_bias, src_bias)
55+
56+
57+
@pytest.mark.parametrize("cls", [sLSTMCell_vanilla, sLSTMCell_cuda])
58+
def test_state_dict_same_backend_round_trip(cls):
59+
"""Saving and reloading within the same backend keeps the internal weights
60+
bit-for-bit identical."""
61+
config = sLSTMCellConfig(hidden_size=16, num_heads=4)
62+
src = cls(config, skip_backend_init=True)
63+
_randomize(src)
64+
dst = cls(config, skip_backend_init=True)
65+
66+
dst.load_state_dict(src.state_dict())
67+
assert torch.allclose(dst._recurrent_kernel_, src._recurrent_kernel_)
68+
assert torch.allclose(dst._bias_, src._bias_)
69+
70+
71+
@pytest.mark.parametrize("cls", [sLSTMCell_vanilla, sLSTMCell_cuda])
72+
def test_state_dict_loads_legacy_internal_checkpoint(cls):
73+
"""A legacy checkpoint stored in the internal layout still loads into a cell
74+
of its original backend (backward compatibility)."""
75+
config = sLSTMCellConfig(hidden_size=16, num_heads=4)
76+
src = cls(config, skip_backend_init=True)
77+
_randomize(src)
78+
legacy = {
79+
"_recurrent_kernel_": src._recurrent_kernel_.detach().clone(),
80+
"_bias_": src._bias_.detach().clone(),
81+
}
82+
83+
dst = cls(config, skip_backend_init=True)
84+
dst.load_state_dict(legacy)
85+
assert torch.allclose(dst._recurrent_kernel_, src._recurrent_kernel_)
86+
assert torch.allclose(dst._bias_, src._bias_)

xlstm/blocks/slstm/cell.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -262,11 +262,63 @@ def __init__(self, config: sLSTMCellConfig):
262262

263263
self.reset_parameters()
264264

265+
# Persist the backend-agnostic (external/canonical) layout in `state_dict` so
266+
# checkpoints can be moved between backends (e.g. "cuda" <-> "vanilla"). The
267+
# stored `_recurrent_kernel_`/`_bias_` parameters are kept in a backend-specific
268+
# internal layout; without these hooks a checkpoint saved with one backend fails
269+
# to load into another -- the recurrent kernel raises a size mismatch and the
270+
# bias loads silently with a scrambled gate/head ordering. See issue #127.
271+
self._register_state_dict_hook(sLSTMCellBase._state_dict_to_external_hook)
272+
self._register_load_state_dict_pre_hook(
273+
sLSTMCellBase._load_state_dict_to_internal_pre_hook, with_module=True
274+
)
275+
265276
if self.config.hidden_size % self.config.num_heads != 0:
266277
raise ValueError(
267278
f"Hidden Size {self.config.hidden_size} must be divisible by head num {self.config.num_heads}"
268279
)
269280

281+
@staticmethod
282+
def _state_dict_to_external_hook(module, state_dict, prefix, local_metadata):
283+
"""Store the recurrent kernel and bias in the backend-agnostic external layout."""
284+
recurrent_kernel_key = prefix + "_recurrent_kernel_"
285+
bias_key = prefix + "_bias_"
286+
if recurrent_kernel_key in state_dict:
287+
state_dict[recurrent_kernel_key] = module._recurrent_kernel_int2ext(
288+
state_dict[recurrent_kernel_key]
289+
)
290+
if bias_key in state_dict:
291+
state_dict[bias_key] = module._bias_int2ext(state_dict[bias_key])
292+
return state_dict
293+
294+
@staticmethod
295+
def _load_state_dict_to_internal_pre_hook(
296+
module,
297+
state_dict,
298+
prefix,
299+
local_metadata,
300+
strict,
301+
missing_keys,
302+
unexpected_keys,
303+
error_msgs,
304+
):
305+
"""Convert an external-layout checkpoint to this backend's internal layout.
306+
307+
External tensors are identified by their rank (recurrent kernel: 4D, bias: 3D).
308+
Legacy checkpoints saved in the internal layout (recurrent kernel: 3D, bias: 1D)
309+
are left untouched so they still load into a cell of their original backend.
310+
"""
311+
recurrent_kernel_key = prefix + "_recurrent_kernel_"
312+
bias_key = prefix + "_bias_"
313+
recurrent_kernel = state_dict.get(recurrent_kernel_key)
314+
if recurrent_kernel is not None and recurrent_kernel.ndim == 4:
315+
state_dict[recurrent_kernel_key] = module._recurrent_kernel_ext2int(
316+
recurrent_kernel
317+
)
318+
bias = state_dict.get(bias_key)
319+
if bias is not None and bias.ndim == 3:
320+
state_dict[bias_key] = module._bias_ext2int(bias)
321+
270322
def __repr__(self):
271323
return (
272324
f"{self.__class__.__name__}(function={self.config.function}, "

0 commit comments

Comments
 (0)