MichaelMintIcecream commited on
Commit
346bcb7
·
verified ·
1 Parent(s): f5a56d6

RTC: wire real-time chunking into /act

Browse files
Files changed (1) hide show
  1. rtc.py +237 -0
rtc.py ADDED
@@ -0,0 +1,237 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Real-Time Chunking (RTC) for MolmoAct2 — inference-time, no retraining.
2
+
3
+ RTC (arXiv 2506.07339) removes the discontinuity you get when a freshly generated
4
+ action chunk replaces the one currently executing. It treats the overlap as an
5
+ INPAINTING problem during flow sampling: the actions that will inevitably execute
6
+ while we're computing are pinned to the previous chunk, a middle band is softly
7
+ guided toward it, and the tail beyond the old chunk is generated freely.
8
+
9
+ idx < d "frozen" weight 1 (executes during the inference delay)
10
+ d <= idx < s "guided" weight 1->0 (EXP schedule)
11
+ idx >= s "free" weight 0
12
+
13
+ Guidance is NOT an overwrite. Following the paper (and LeRobot's reference
14
+ implementation) it is a pseudoinverse-guidance (PiGDM) correction injected into the
15
+ velocity field at EVERY denoising step, via a vector-Jacobian product:
16
+
17
+ x1 = x_t + (1 - tau) * v # predicted clean sample
18
+ err = (prev_chunk - x1) * W # weighted target error
19
+ corr = VJP(x1, x_t, err) # d x1/d x_t ^T @ err
20
+ v_rtc = v + w(tau) * corr
21
+ w(tau) = min(beta, ((1-tau)^2 + tau^2) / (tau * (1-tau)))
22
+
23
+ SIGN NOTE (the highest-risk part of this port): LeRobot integrates time 1->0 with a
24
+ velocity pointing toward NOISE and writes `v - w*corr`. MolmoAct2 integrates 0->1
25
+ with a velocity pointing toward DATA (`trajectory = trajectory + dt * velocity`), so
26
+ the correction ADDS here. Both end up moving the trajectory by +corr; only the
27
+ velocity convention differs. `selftest_guidance_direction()` asserts this
28
+ empirically rather than trusting the derivation.
29
+
30
+ COSTS (measured/reported, worth knowing before enabling):
31
+ * needs autograd -> the model's @torch.no_grad() must be lifted, and the
32
+ CUDA-graph fast path must be disabled (you cannot backprop a captured graph).
33
+ * ~20% extra latency on top of that. We are already NETWORK-bound, so RTC is
34
+ only worth enabling once the round-trip is short.
35
+ * requires d <= s <= H - d. With H=30, s=10: d <= 10. At 10fps that's ~1.0s of
36
+ tolerable delay; at 15fps only ~0.66s.
37
+ """
38
+
39
+ import math
40
+ from typing import Optional
41
+
42
+ import torch
43
+
44
+
45
+ def prefix_weights(
46
+ delay: int, execution_horizon: int, total: int, schedule: str = "exp"
47
+ ) -> torch.Tensor:
48
+ """Per-timestep guidance weights (port of LeRobot's get_prefix_weights).
49
+
50
+ `delay` (d) actions are pinned at 1.0; weights decay to 0 by `execution_horizon`
51
+ (s); everything at/after s is free (0.0)."""
52
+ start = min(delay, execution_horizon)
53
+ end = execution_horizon
54
+ if schedule == "zeros":
55
+ w = torch.zeros(total)
56
+ w[:start] = 1.0
57
+ return w
58
+ if schedule == "ones":
59
+ w = torch.ones(total)
60
+ w[end:] = 0.0
61
+ return w
62
+
63
+ # linear ramp over the guided band, exclusive of the 1.0 and 0.0 endpoints
64
+ skip = max(total - end, 0)
65
+ steps = total - skip - start
66
+ lin = torch.linspace(1, 0, steps + 2)[1:-1] if (end > start and steps > 0) else torch.tensor([])
67
+ if schedule == "exp":
68
+ # decay harder than linear: w * expm1(w) / (e - 1)
69
+ lin = lin * torch.expm1(lin).div(math.e - 1)
70
+ if total - end > 0:
71
+ lin = torch.cat([lin, torch.zeros(total - end)])
72
+ if min(start, total) > 0:
73
+ lin = torch.cat([torch.ones(min(start, total)), lin])
74
+ return lin
75
+
76
+
77
+ def guidance_weight(tau: float, max_weight: float) -> float:
78
+ """w(tau) = min(beta, ((1-tau)^2 + tau^2) / (tau*(1-tau))), clamped at both ends."""
79
+ one_minus = 1.0 - tau
80
+ if tau <= 0.0 or one_minus <= 0.0:
81
+ return float(max_weight)
82
+ w = ((one_minus ** 2) + (tau ** 2)) / (tau * one_minus)
83
+ return float(min(w, max_weight))
84
+
85
+
86
+ def feasible(delay: int, horizon: int, execution_horizon: int) -> bool:
87
+ """RTC needs d <= s <= H - d. Past that the frozen prefix and the free tail
88
+ overlap and the guidance is not well defined — better to skip RTC for that
89
+ request than to emit a silently wrong chunk."""
90
+ return 0 <= delay <= execution_horizon <= horizon - delay
91
+
92
+
93
+ def pick_execution_horizon(delay: int, horizon: int) -> Optional[int]:
94
+ """Smallest feasible s that leaves the guided band some room, or None if the
95
+ delay is too large for this horizon (d > H/2)."""
96
+ s = min(max(delay + 4, delay), horizon - delay)
97
+ return s if feasible(delay, horizon, s) else None
98
+
99
+
100
+ class RTCState:
101
+ """Per-session guidance target, kept in the model's NORMALIZED action space.
102
+
103
+ We cache the previous chunk as the raw flow output rather than asking the client
104
+ to send actions back: the flow operates on normalized actions, so a robot-scale
105
+ chunk from the client would have to be re-normalized (and our client also applies
106
+ a joint calibration). Caching the model's own output sidesteps both."""
107
+
108
+ def __init__(self) -> None:
109
+ self.prev: Optional[torch.Tensor] = None # (B, H, A) normalized
110
+ self.enabled = False
111
+ self.consumed = 0 # actions executed since `prev` was produced (= alignment shift)
112
+ self.delay = 0 # d: actions that will execute during THIS inference
113
+ self.execution_horizon = 10
114
+ self.max_guidance_weight = 10.0
115
+ self.schedule = "exp"
116
+ self.applied = 0 # count of guided steps, for observability
117
+
118
+ def target(self, like: torch.Tensor) -> Optional[torch.Tensor]:
119
+ """Previous chunk aligned to the new chunk's timeline: drop the `consumed`
120
+ actions that already executed, then zero-pad to the new chunk's shape."""
121
+ if not self.enabled or self.prev is None:
122
+ return None
123
+ left = self.prev[:, self.consumed:, :]
124
+ if left.shape[1] == 0:
125
+ return None
126
+ out = torch.zeros_like(like)
127
+ n = min(left.shape[1], out.shape[1])
128
+ a = min(left.shape[2], out.shape[2])
129
+ out[:, :n, :a] = left[:, :n, :a].to(out.device, out.dtype)
130
+ return out
131
+
132
+
133
+ def install_rtc(model, state: RTCState):
134
+ """Monkeypatch the model's Euler flow loop to apply RTC guidance.
135
+
136
+ Returns the original bound method so the caller can restore it. The patched loop
137
+ is a no-op (bit-identical to upstream) whenever `state` has no target, so leaving
138
+ it installed costs nothing when RTC is off."""
139
+ original = model._run_action_flow_loop
140
+
141
+ def guided_loop(inputs, steps: int) -> torch.Tensor:
142
+ trajectory = inputs.trajectory
143
+ target = state.target(trajectory)
144
+ if target is None:
145
+ # No usable prefix (first call of a session, or the previous chunk is
146
+ # fully consumed). Run upstream verbatim — but still CAPTURE the output,
147
+ # or the next call has nothing to guide toward.
148
+ out = original(inputs, steps)
149
+ state.prev = out.detach()
150
+ return out
151
+
152
+ action_expert = model._require_action_expert()
153
+ dt = 1.0 / steps
154
+ pad = inputs.action_dim_is_pad
155
+ mask_enabled = model.config.mask_action_dim_padding
156
+ W = prefix_weights(state.delay, state.execution_horizon,
157
+ trajectory.shape[1], state.schedule)
158
+ W = W.to(trajectory.device, trajectory.dtype).view(1, -1, 1)
159
+
160
+ for idx in range(steps):
161
+ tau = idx / steps # 0 = noise, 1 = data (MolmoAct2 integrates forward)
162
+ x_t = trajectory.detach().requires_grad_(True)
163
+ with torch.enable_grad():
164
+ velocity = action_expert.forward_with_context(
165
+ x_t,
166
+ inputs.modulations[idx].conditioning,
167
+ context=inputs.context,
168
+ modulation=inputs.modulations[idx],
169
+ )
170
+ velocity = model._mask_action_dim_tensor(
171
+ velocity, action_dim_is_pad=pad, enabled=mask_enabled
172
+ )
173
+ x1 = x_t + (1.0 - tau) * velocity # predicted clean sample
174
+ err = ((target - x1) * W).detach() # weighted pull toward prev
175
+ corr = torch.autograd.grad(x1, x_t, err, retain_graph=False)[0]
176
+
177
+ w = guidance_weight(tau, state.max_guidance_weight)
178
+ # + (not -): our velocity points toward DATA -- see SIGN NOTE above.
179
+ velocity = (velocity + w * corr).detach()
180
+ trajectory = model._mask_action_dim_tensor(
181
+ trajectory.detach() + dt * velocity, action_dim_is_pad=pad, enabled=mask_enabled
182
+ )
183
+ state.applied += 1
184
+ state.prev = trajectory.detach()
185
+ return trajectory
186
+
187
+ model._run_action_flow_loop = guided_loop
188
+ return original
189
+
190
+
191
+ # --------------------------------------------------------------------------- tests
192
+ def selftest_guidance_direction() -> dict:
193
+ """Assert the SIGN empirically on a toy linear flow, with no model involved.
194
+
195
+ Toy: velocity = (goal - x). Euler-integrating it drives x -> goal. With RTC
196
+ guidance toward `prev`, the FROZEN prefix must end up closer to `prev` than the
197
+ unguided run does, and the free tail must be left alone."""
198
+ H, A, steps = 30, 4, 10
199
+ goal = torch.zeros(1, H, A)
200
+ prev = torch.ones(1, H, A) * 5.0
201
+ W = prefix_weights(4, 10, H, "exp").view(1, -1, 1)
202
+
203
+ def run(guided: bool) -> torch.Tensor:
204
+ x = torch.full((1, H, A), -5.0)
205
+ for idx in range(steps):
206
+ tau = idx / steps
207
+ xt = x.detach().requires_grad_(True)
208
+ with torch.enable_grad():
209
+ v = goal - xt
210
+ x1 = xt + (1.0 - tau) * v
211
+ if guided:
212
+ err = ((prev - x1) * W).detach()
213
+ corr = torch.autograd.grad(x1, xt, err, retain_graph=False)[0]
214
+ v = v + guidance_weight(tau, 10.0) * corr
215
+ x = (x.detach() + (1.0 / steps) * v.detach())
216
+ return x
217
+
218
+ plain, rtc = run(False), run(True)
219
+ d_plain = (plain[0, 0] - prev[0, 0]).abs().mean().item()
220
+ d_rtc = (rtc[0, 0] - prev[0, 0]).abs().mean().item()
221
+ tail_shift = (rtc[0, -1] - plain[0, -1]).abs().mean().item()
222
+ return {
223
+ "prefix_dist_unguided": round(d_plain, 4),
224
+ "prefix_dist_rtc": round(d_rtc, 4),
225
+ "prefix_pulled_toward_prev": d_rtc < d_plain,
226
+ "free_tail_unchanged": tail_shift < 1e-5,
227
+ }
228
+
229
+
230
+ if __name__ == "__main__":
231
+ w = prefix_weights(4, 10, 30, "exp")
232
+ print("weights[:12]:", [round(float(x), 3) for x in w[:12]])
233
+ print("frozen prefix all 1.0 :", bool((w[:4] == 1.0).all()))
234
+ print("free tail all 0.0 :", bool((w[10:] == 0.0).all()))
235
+ print("monotonic in guided band:", bool((w[4:10].diff() <= 0).all()))
236
+ print("w(tau) mid/edges :", [round(guidance_weight(t, 10.0), 3) for t in (0.0, 0.1, 0.5, 0.9, 1.0)])
237
+ print("direction selftest :", selftest_guidance_direction())