Adding an Attention-Based Memory Module to Echo-Memory
This guide explains how to add a new attention-based memory mechanism to the Wan 2.1 backbone in this repository, following the exact integration pattern used by the Block-wise SSM row (diffsynth/models/memory/block_wise_ssm.py). Block-wise SSM is the right template because it is a per-DiT-block module: it is instantiated inside each selected transformer block and called during the block's forward pass β precisely where an attention variant would live.
Read doc/memory_mechanisms.md and the repo CLAUDE.md first for the two-chunk paradigm and the public-repo constraints. This document assumes that background.
I want to add some loss, for example, using the model's uncertainty about the current prediction, um: e^(-um)*sg(MSE(VAE(Target View), predicted x0)) + um, as a metric for the model's retrieval, thereby improving memory capabilities. Please return the code implementation of this method in the current codebase. In addition, please provide methods you believe can improve the model's ability to retrieve preceding frames, and compile them into a retrieve.md file and save it.
0. How per-block memory is wired (the pattern you will copy)
Unlike the FramePack/Spatial rows (which act in the pipeline on context latents), Block-wise SSM attaches inside the DiT transformer blocks. The same block class is defined in two places that must stay in sync:
| Location | Role |
|---|---|
src/model_training/train.py β class DiTBlock_w_Action (~line 160) |
Training definition of the augmented block |
env/loop_utils.py β class DiTBlock_w_Action (~line 43) |
Inference definition (must match training byte-for-byte in module structure) |
The base Wan blocks (diffsynth/models/wan_video_dit.py) are replaced at load time by DiTBlock_w_Action, copying over self_attn / cross_attn / norm* / ffn / modulation weights from the original blocks. The memory module is an extra sub-module on selected blocks; its weights are the only newly-trained parameters.
The forward hook location (train.py DiTBlock_w_Action.forward, ~line 230-238):
input_x = modulate(self.norm1(x), shift_msa, scale_msa)
x = self.gate(x, gate_msa, self.self_attn(input_x, freqs)) # β self-attention
if num_frames is not None:
if hasattr(self, "block_wise_ssm"):
x = self.block_wise_ssm(x, f=num_frames) # β MEMORY HOOK (after self-attn, before cross-attn)
x = x + self.cross_attn(self.norm3(x), context) # β cross-attention
input_x = modulate(self.norm2(x), shift_mlp, scale_mlp)
x = self.gate(x, gate_mlp, self.ffn(input_x))
x here is shape (B, F*S, D) β batch, (latent frames Γ spatial tokens) flattened, hidden dim. num_frames (f) is the number of latent frames, used to reshape per-frame. This is where your attention-memory module reads/writes.
Detection at inference is by checkpoint key name. env/loop_utils.py (~line 288) scans ckpt keys with a regex and decides which blocks get the module:
m = re.match(r"blocks\.(\d+)\.block_wise_ssm\.", key) # β block_wise_block_ids
So the attribute name you give your module (e.g. self.attn_memory = ...) becomes the checkpoint key prefix and must be matched by a new regex. Keep the attribute name stable β env/memory_baseline_runtime.py and inference/unified_inference.py rely on it.
1. Files to ADD
1a. The module β diffsynth/models/memory/attn_memory.py
Mirror the shape contract of block_wise_ssm.py: input (B, F*S, D), takes f (frames), returns the same shape with a zero-initialized residual gate so an untrained module is an identity at step 0 (critical β the base backbone must not be disturbed before training).
import torch
import torch.nn as nn
import torch.nn.functional as F
class AttentionMemory(nn.Module):
"""
Example attention-based memory module attached per DiT block.
Reads the within-block hidden state and applies an extra attention
operation along the TIME axis for each spatial-token trajectory
(analogous to block_wise_ssm's recurrent time update, but attention).
Shape contract (must match block_wise_ssm):
forward(x, f) where x: (B, F*S, D), f = number of latent frames.
"""
def __init__(self, dim: int, num_heads: int = 8):
super().__init__()
self.dim = int(dim)
self.num_heads = int(num_heads)
self.q = nn.Linear(dim, dim)
self.k = nn.Linear(dim, dim)
self.v = nn.Linear(dim, dim)
self.o = nn.Linear(dim, dim)
# Zero-init residual gate => identity at init (do NOT skip this).
self.gate = nn.Parameter(torch.zeros(1))
def forward(self, x: torch.Tensor, f: int, **_kwargs):
if x is None or x.ndim != 3:
return x
b, n, d = x.shape
f = int(f or 0)
if d != self.dim or f <= 1 or n % f != 0:
return x
spatial = n // f
# (B, F*S, D) -> (B*S, F, D): attend across frames per spatial trajectory.
x_seq = x.reshape(b, f, spatial, d).permute(0, 2, 1, 3).reshape(b * spatial, f, d)
h = self.num_heads
q = self.q(x_seq).reshape(b * spatial, f, h, d // h).transpose(1, 2)
k = self.k(x_seq).reshape(b * spatial, f, h, d // h).transpose(1, 2)
v = self.v(x_seq).reshape(b * spatial, f, h, d // h).transpose(1, 2)
y = F.scaled_dot_product_attention(q, k, v) # causal=False -> full temporal memory
y = y.transpose(1, 2).reshape(b * spatial, f, d)
y = self.o(y)
y = y.reshape(b, spatial, f, d).permute(0, 2, 1, 3).reshape(b, n, d)
return x + torch.tanh(self.gate) * y # gated residual
Vary the internal operation to run the experiment you care about β causal vs. bidirectional temporal attention, cross-attention into stored context tokens, windowed/strided attention, etc. The interface and the gated-residual identity-at-init must stay fixed; only the body changes.
1b. Export it β diffsynth/models/memory/__init__.py
Add alongside the existing exports:
from .attn_memory import AttentionMemory
1c. The training launcher β train/memory_baselines_basic/run_ablation_attn_memory_two_chunk.sh
Copy run_ablation_block_wise_ssm_two_chunk.sh and swap the memory flags (see Β§4).
2. Files to MODIFY for TRAINING (src/model_training/train.py)
There are five edit sites. Search for block_wise_ssm to find each by analogy.
(1) Module import (~line 77, plus the modules_to_clear list ~line 44 so hot-reload works):
from diffsynth.models.memory.attn_memory import AttentionMemory
# and add 'diffsynth.models.memory.attn_memory' to modules_to_clear
(2) DiTBlock_w_Action.__init__ (~line 160) β add a constructor flag and instantiate:
def __init__(self, ..., use_block_wise_ssm=False, use_videossm_hybrid=False,
use_attn_memory: bool = False, attn_memory_heads: int = 8, ...):
...
self.use_attn_memory = bool(use_attn_memory)
if use_attn_memory:
self.attn_memory = AttentionMemory(dim, num_heads=attn_memory_heads)
(3) DiTBlock_w_Action.forward (~line 232) β add the hook next to the SSM hook:
if num_frames is not None:
if hasattr(self, "block_wise_ssm"):
x = self.block_wise_ssm(x, f=num_frames)
if hasattr(self, "attn_memory"):
x = self.attn_memory(x, f=num_frames) # β new
(4) argparse flags (line 1479-1510). Add the numeric default tuples near line 1510 alongside --ssm_every_n_blocks, and the boolean flag to the store-true list (--use_block_wise_ssm):
("--attn_memory_every_n_blocks", dict(type=int, default=4)),
("--attn_memory_heads", dict(type=int, default=8)),
# ... and add "--use_attn_memory" to the list of store_true flags
Optionally add a mutual-exclusion guard like the one at ~line 1556 (use_block_wise_ssm and use_videossm_hybrid) if your module should not co-exist with another per-block memory.
(5) block instantiation + parameter freezing (~line 1695-1761):
# instantiation loop (~line 1695)
use_attn_memory = bool(_arg('use_attn_memory', False))
attn_every_n = max(int(_arg('attn_memory_every_n_blocks', 4) or 4), 1)
...
for block_id, old_block in enumerate(old_blocks):
attach_block_ssm = use_block_wise_ssm and (block_id % ssm_every_n == 0)
attach_attn_mem = use_attn_memory and (block_id % attn_every_n == 0) # β new
new_block = DiTBlock_w_Action(
...,
use_block_wise_ssm=attach_block_ssm,
use_attn_memory=attach_attn_mem, # β new
attn_memory_heads=int(_arg('attn_memory_heads', 8) or 8),
)
Then add "attn_memory" to all three requires_grad filters (~line 1744, 1751, 1758) so only your module (plus action MLP / self-attn-with-action) trains and the rest of the DiT stays frozen:
if "action_mlp" in name or "self_attn_with_action" in name \
or "block_wise_ssm" in name or "videossm_hybrid" in name \
or "attn_memory" in name: # β new
param.requires_grad = True
else:
param.requires_grad = False
β οΈ If you forget the freeze filter, your module will not be trained (it'll be frozen with everything else), or the whole DiT becomes trainable β both break the controlled-ablation premise.
3. Files to MODIFY for INFERENCE / EVAL
3a. env/loop_utils.py β mirror the block and detect from ckpt keys
This file re-defines DiTBlock_w_Action and rebuilds blocks at load time. It must structurally match train.py. Three edits:
- Import + module attribute in its
DiTBlock_w_Action(line 43-75): add theline 111).use_attn_memoryctor arg,self.attn_memory = AttentionMemory(dim, ...), and the same forward hook ( _build_action_blocks(~line 202): add anattn_memory_block_idsset param and passuse_attn_memory=block_id in attn_memory_block_idsinto each block.load_pipeline_and_ckpt(~line 288): add a detection regex so the right blocks are reconstructed before loading weights:
m = re.match(r"blocks\.(\d+)\.attn_memory\.", key)
if m:
attn_memory_block_ids.add(int(m.group(1)))
and thread attn_memory_block_ids into the _build_action_blocks(...) call (~line 304).
Because detection is automatic from checkpoint keys, no inference flag is needed to turn the module on β the presence of
blocks.N.attn_memory.*keys in the safetensors reconstructs the slots. This mirrors howblock_wise_ssm/videossm_hybridwork today.
3b. env/memory_baseline_runtime.py β register the profile
Three edits (search block_wise_ssm):
MemoryProfiledataclass (~line 46): adduse_attn_memory: bool = False.MEMORY_PROFILE_REGISTRY(~line 308): add a spec. Theckpt_substringsmust match your launcher's--output_pathfolder name:
MemoryProfileSpec(
profile_id="attn_memory_two_chunk",
ckpt_substrings=("memory_baselines_basic_abl_attn_memory_two_chunk",),
paper_tag="attention_memory",
train_flags=("--use_attn_memory", "--context_memory_frames 5"),
infer_flags=("load_pipeline_and_ckpt auto-infers Attn-memory from attn_memory.* ckpt keys",),
eval_flags=("--context_frames 5",),
profile=MemoryProfile(use_attn_memory=True, context_override=5),
),
profile_to_argv(line 393) andline 427): add theapply_memory_baseline_pipe(--use_attn_memoryemission andpipe.use_attn_memory = ...line, mirroring the block_wise_ssm entries.
3c. inference/unified_inference.py β add the alias
In _REGISTRY_ALIAS (~line 72) add:
"attn_memory": "attn_memory_two_chunk",
and (~line 138) pipe.use_attn_memory = bool(getattr(profile, "use_attn_memory", False)). Update the --help memory-type list near line 172.
4. The training launcher
train/memory_baselines_basic/run_ablation_attn_memory_two_chunk.sh β copy the block-wise SSM launcher and change only the memory flags, run name, and output folder. The critical line:
#!/bin/bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "${SCRIPT_DIR}/common_env.sh"
source "${SCRIPT_DIR}/common_sampling_two_chunk.sh"
accelerate launch src/model_training/train.py \
--dataset_base_path "${dataset_base_path}" --dataset_metadata_path "${dataset_base_path}/${METADATA_NAME}" \
--context_source replay --prev_chunk_frames 81 --context_memory_frames 5 --use_rt_relative --height 352 --width 640 \
--dataset_repeat 1 --per_device_train_batch_size 1 --gradient_accumulation_steps 1 --num_workers 16 \
--model_paths "${model_paths}" --learning_rate 5e-5 --num_epochs 1 --remove_prefix_in_ckpt "${remove_prefix_in_ckpt}" \
--output_path "${output_base}_abl_attn_memory_two_chunk" --trainable_models dit --ckpt_interval "${CKPT_INTERVAL:-1000}" --save_full_model \
--wandb_run_name "abl_attn_memory_two_chunk" \
--enable_context_memory --training_mode context --context_drop_prob 0.1 --cfg_target_only \
--train_cam_pose --add_action_attn --action_use_temporal_attention \
--use_moc --moc_temperature 1.0 \
--use_attn_memory --attn_memory_every_n_blocks 4 --attn_memory_heads 8 \
--timestep_shift "${TIMESTEP_SHIFT:-15}" \
"${SAMPLING_TWO_CHUNK_FLAGS[@]}" \
2>&1 | tee "${LOG_DIR}/abl_attn_memory_two_chunk_$(date +%Y%m%d_%H%M%S).log"
The
--output_pathfolder name (..._abl_attn_memory_two_chunk) must contain theckpt_substringstoken you registered in Β§3b, so eval/inference auto-resolve the profile from the checkpoint path. Keep all the shared hyperparameters (LR 5e-5, 352Γ640, 81 frames, shift 15) identical to the other rows β that is what makes this a controlled ablation; only the memory pathway should differ.
5. Running the experiment
Environment (repo root):
export WAN_BASE_MODEL=/path/to/Wan2.1-T2V-1.3B
export DATASET_BASE_PATH=data/Context-as-Memory-Dataset
export PYTHONPATH=$PWD:${PYTHONPATH:-}
export OUTPUT_BASE_ROOT=$PWD/outputs
Sanity-check the plumbing first β the existing standalone tests cover context/two-chunk wiring; run them after your edits:
PYTHONPATH=. python3 tests/test_context_chunk_utils.py
PYTHONPATH=. python3 tests/test_two_chunk_anchor_readout.py
Add a tiny shape test for your module (recommended), asserting identity at init and (B,F*S,D) round-trip:
import torch
from diffsynth.models.memory.attn_memory import AttentionMemory
m = AttentionMemory(64, num_heads=8)
x = torch.randn(2, 5*16, 64) # B=2, F=5, S=16, D=64
assert torch.allclose(m(x, f=5), x) # zero gate => identity at init
Train (optionally do a quick run with a small metadata index first):
# Optional: 1000-row index for a fast smoke run
OUTPUT_CSV="${DATASET_BASE_PATH}/metadata_1000.csv" METADATA_MAX_ROWS=1000 bash scripts/run_generate_metadata.sh
bash train/memory_baselines_basic/run_ablation_attn_memory_two_chunk.sh
Checkpoints land under outputs/memory_baselines_basic_abl_attn_memory_two_chunk/epoch-*.safetensors. Verify the new weights are present and gradients flowed:
python -c "
from safetensors.torch import load_file
k = load_file('outputs/memory_baselines_basic_abl_attn_memory_two_chunk/epoch-0.safetensors')
hits = [x for x in k if 'attn_memory' in x]
print(len(hits), 'attn_memory tensors; sample:', hits[:3])
"
Inference (auto-detected from ckpt keys; --memory_type auto works once Β§3 is done):
python inference/unified_inference.py \
--ckpt outputs/memory_baselines_basic_abl_attn_memory_two_chunk/epoch-0.safetensors \
--memory_type attn_memory \
--context_image assets/opendomain_revisit/1774363417.png \
--action_path env/action_rotation_left_45.json \
--prompt "A toy bear on a table" \
--output_path attn_memory_test.mp4
Evaluate (the row folder name in CKPT drives profile selection):
export CKPT=outputs/memory_baselines_basic_abl_attn_memory_two_chunk/epoch-0.safetensors
bash eval/v2/run_basic_replay_gt.sh # fast fidelity check (~5 min)
bash eval/v2/run_static_consistency_loop_and_revisit.sh # full paper bundle (loop closure + revisit)
PHASE=stage1 OOD_DIR=assets/opendomain_revisit \
bash eval/v2/revisit_suite/run_one_click_revisit_eval.sh # open-domain generalization
Compare the resulting MSE / PSNR / SSIM / LPIPS (and revisit-tail consistency) against the block_wise_ssm, spatial_mem, and context_k* rows to place your attention-memory variant in the paper matrix.
6. Checklist & pitfalls
-
diffsynth/models/memory/attn_memory.pyadded; exported in__init__.py. -
train.py: import +modules_to_clear+ ctor + forward hook + argparse + instantiation + all three freeze filters. -
env/loop_utils.py:DiTBlock_w_Actionmirrors training exactly (structure, attribute name, hook order), + detection regex +_build_action_blocksthread-through. -
env/memory_baseline_runtime.py:MemoryProfilefield + registry spec +profile_to_argv+apply_memory_baseline_pipe. -
inference/unified_inference.py: alias + pipe flag + help text. - Launcher
--output_pathfolder name contains the registryckpt_substringstoken.
Common failure modes
- Module attribute name mismatch between train/inference, or vs. the detection regex β checkpoint keys won't reconstruct the slots; weights load as "unexpected" and silently do nothing. Keep
attn_memoryconsistent everywhere. - Non-zero gate at init β corrupts the frozen backbone before training and destabilizes early steps. Always zero-init the residual gate.
- Forgotten freeze filter β either the module isn't trained, or the whole DiT trains (breaks the controlled comparison and the
--save_full_modelcheckpoints diff against base unexpectedly). loop_utils.DiTBlock_w_Actiondrifting fromtrain.pyβ load-time block replacement mismatches andstrict=Falsehides it as missing/unexpected keys. When you edit one, edit both.
Public-repo constraints (from CLAUDE.md): no machine-local absolute paths, no upload scripts, keep diffs minimal and match existing bash/Python patterns. Don't commit outputs/, data/, or weights.