Skip to content

rdt: long-range visual memory; image memory window and history stride in seconds - #689

Draft
RyanPCo wants to merge 5 commits into
ryanco/norm-stats-per-episodefrom
ryanco/rdt-memory
Draft

RyanPCo wants to merge 5 commits into
ryanco/norm-stats-per-episodefrom
ryanco/rdt-memory

Conversation

@RyanPCo

@RyanPCo RyanPCo commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor

Adds an optional long-range visual memory to RDT, and the loader support it needs. Without trunk.memory and key_map.image_memory, models and keymaps are unchanged.

Loader

  • Human.get_keymap(image_memory=N, image_memory_stride_s=s) adds <front>_mem: N front frames s seconds apart, ending at the current frame, plus a (N,) <front>_mem_mask of the steps inside the episode. Steps before the episode start repeat the oldest real frame and are masked.
  • history_stride_s spaces proprio history in seconds, read in each episode's fps, so a window spans the same time at any fps. It replaces history_stride when set.
  • History windows are now point reads instead of a contiguous span thinned afterwards. On JPEG keys (one frame per chunk) a span would fetch every frame between steps; plan_reads still merges them on numeric keys. Each distinct frame is read and decoded once.

Model

  • RDTMemory encodes each memory frame to one token with a second DINOv3 tower (DINOv3Stem(pool="cls")), fine-tuned at the backbone LR scale, and tags it with a sin-cos code of the frame's age in seconds. fuse_proprio adds a projection of that step's proprio token, which needs the proprio history on the same grid.
  • The N tokens join the DiT's self-attention prefix [t; freq; state; memory; actions] under a key mask, so padded steps are never attended.
  • short_dropout replaces a sample's whole short image stream by a learned null token during training, so the policy cannot lean on the latest frames alone.
  • DINOv3Stem gains pool (patch / cls) and grad_checkpointing. Backprop through the fine-tuned memory tower (320 frames per GPU) OOMed an H100 without it.
  • The backbone LR group now walks every module with backbone_parameters, not just the encoders, so the memory tower lands in it.
  • Model and data config must agree: memory without image_memory in the batch (or the reverse), a frame-count mismatch, or an encoder that gives more than one token per frame raises.

Fixes

  • HPT._stem_history_len called .get on the built nn.ModuleDict, so every proprio-history (K > 1) run crashed at its first batch. The unit tests used a plain dict.
  • The last-step eval MSE handed torchmetrics a strided [:, -1] slice, which it .view(-1)s. On GPU .cpu() made it contiguous; on a CPU run it raised.

The memory ablation train configs (a 2x2 over proprio history and visual memory on the Mecka 20% split) and a cluster launcher are kept out of this PR.

Tests

  • tests/unit/test_image_memory.py: frame spacing in seconds, front padding and masking, the first frame, one-frame windows, one read per distinct frame, no memory by default, norm mode dropping the memory camera.
  • tests/unit/test_rdt.py: masked memory tokens ignored, age tagging, short dropout train-only, loss reaching every trainable parameter (with and without fuse_proprio), the memory tower in the backbone LR group, and the config/batch agreement checks.
  • tests/unit/test_proprio_history.py: stride in seconds following the episode fps, and history_len read from a built ModuleDict.

🤖 Generated with Claude Code

https://claude.ai/code/session_01HjCQBqQCr2igFEMR7yUovw

RyanPCo commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor Author

Warning

This pull request is not mergeable via GitHub because a downstack PR is open. Once all requirements are satisfied, merge this PR as a stack on Graphite.
Learn more

This stack of pull requests is managed by Graphite. Learn more about stacking.

@RyanPCo
RyanPCo changed the base branch from ryanco/fast-dataset-startup to ryanco/norm-stats-per-episode October 12, 2026 20:27
@RyanPCo
RyanPCo force-pushed the ryanco/norm-stats-per-episode branch from e51b3b4 to 3488d12 Compare October 12, 2026 21:50
@RyanPCo
RyanPCo force-pushed the ryanco/norm-stats-per-episode branch from 3488d12 to ece3cd2 Compare October 12, 2026 23:19
@RyanPCo RyanPCo mentioned this pull request Oct 19, 2026
@RyanPCo
RyanPCo force-pushed the ryanco/norm-stats-per-episode branch from ece3cd2 to 025517c Compare October 19, 2026 19:53

@RyanPCo RyanPCo left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated adversarial review (read-only; findings were checked by a second skeptical pass before posting): 2 low, 1 nit.

)

@property
def n_tokens(self) -> int:

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

low (dead-code): Nothing reads RDTMemory.n_tokens, and each head's n_memory_tokens has to be set by hand to match trunk.memory.frames. Either derive the heads' n_memory_tokens from it (a hydra interpolation, or set it while building the heads) or delete the property.

# A history window, (K, H, W, 3): decode each
# distinct frame once (front padding repeats one).
window = windows[k][1]
unique = {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

low (efficiency): This comprehension still decodes every entry. Front-padding duplicates are decoded and then overwritten, so the comment's "decode each distinct frame once" is not true. Dedupe before decoding: by_idx = dict(zip(map(int, window), data[k])), then unique = {i: simplejpeg.decode_jpeg(b, colorspace="RGB") for i, b in by_idx.items()}.

# distinct frame once (front padding repeats one).
window = windows[k][1]
unique = {
int(i): simplejpeg.decode_jpeg(b, colorspace="RGB")

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit (efficiency): The comment says each distinct frame is decoded once, but the dict comprehension calls simplejpeg.decode_jpeg for every window entry and keeps only the last result per index. Front-padded repeats are decoded K times. Check int(i) not in unique before decoding.

RyanPCo and others added 5 commits October 30, 2026 18:51
…ide in seconds

Human.get_keymap(image_memory=N, image_memory_stride_s=s) adds <front>_mem:
N front frames s seconds apart, the newest s seconds back, plus a (N,)
<front>_mem_mask of the steps inside the episode. history_stride_s spaces
proprio history in seconds, so windows span the same time at any fps.

History windows are now point reads instead of a contiguous span thinned
afterwards: on JPEG keys (one frame per chunk) a span would fetch every
frame between steps. plan_reads still merges them on numeric keys.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UL4UaiqSDNbhpatNwdvJbe
…-step eval MSE

_stem_history_len called .get on the policy's stems, an nn.ModuleDict once
built, so every proprio-history (K > 1) run crashed at its first batch. The
unit tests used a plain dict.

The last-step MSE handed torchmetrics a strided [:, -1] slice, which it
.view(-1)s; on GPU .cpu() copied it contiguous, on a CPU run it raised.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UL4UaiqSDNbhpatNwdvJbe
RDTMemory: N past frames of one camera through a frozen DINOv3 tower (only its
proj trains), a Perceiver resampler shared across frames pooling each to M
latents, a sin-cos code of each frame's age in seconds, and optionally that
step's proprio token (fuse_proprio). The N x M tokens join the DiT's
self-attention prefix [t; freq; state; memory; actions] under a key mask, so
steps before the episode start and dropped memories (trunk.memory.dropout)
are never attended. Without trunk.memory the model is unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01HjCQBqQCr2igFEMR7yUovw
Follows the dual-encoder design of diffusion-policy-experiments:
- the memory tower is a second DINOv3, fine-tuned at the backbone LR scale
  (the param-group walk now covers every module with backbone_parameters),
  pooling each frame to its CLS token; the Perceiver resampler is gone
- the window ends at the current frame (t-9 s .. t)
- short_dropout (0.3 in the memory arms) replaces a sample's short image
  stream by a learned null token, replacing whole-memory dropout

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UL4UaiqSDNbhpatNwdvJbe
Backprop through 320 frames / GPU OOMed an H100; with the tower's blocks
recomputed the both arm trains at 1.19 s / step on 8xH100.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UL4UaiqSDNbhpatNwdvJbe
@RyanPCo

RyanPCo commented Oct 30, 2026

Copy link
Copy Markdown
Contributor Author

Review findings: status after the fixup pass. The fixes are squashed into this PR's commits, and the whole stack was force-pushed.

Fixed

  • RDTMemory.n_tokens unused; n_memory_tokens set by hand: RDTModel.finalize_modules sets each head's n_memory_tokens from long_memory.n_tokens via the new RDTDenoiser.set_n_memory_tokens; test helper no longer passes it; test added
  • Decode-once comprehension still decodes every frame: bytes deduped by index before decoding; test counts decode calls on a front-padded window
  • JPEG window decode runs on every entry: duplicate of the decode-once item above; same fix

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant