Skip to content

Commit b64ff9d

Browse files
feat: add prefix cache for KV cache reuse during inference
Add caching infrastructure to avoid redundant computation of prefix embeddings (images + language) when observation hasn't changed between consecutive inference calls. This can provide significant speedup in continuous robot control scenarios. Changes: - Add prefix_cache.py with PrefixCacheKey and PrefixCache classes - Add sample_actions_with_cache() method to Pi0 model - Add enable_prefix_cache parameter to Policy and create_trained_policy - Add clear_cache() and reset() methods to Policy Usage: policy = create_trained_policy(config, checkpoint, enable_prefix_cache=True) result = policy.infer(obs) # Auto-caches prefix policy.reset() # Clear cache for new episode
1 parent 650c5b0 commit b64ff9d

5 files changed

Lines changed: 526 additions & 1 deletion

File tree

src/openpi/models/pi0.py

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
from openpi.models import model as _model
1111
from openpi.models import pi0_config
12+
from openpi.models import prefix_cache as _prefix_cache
1213
import openpi.models.gemma as _gemma
1314
import openpi.models.siglip as _siglip
1415
from openpi.shared import array_typing as at
@@ -277,3 +278,86 @@ def cond(carry):
277278

278279
x_0, _ = jax.lax.while_loop(cond, step, (noise, 1.0))
279280
return x_0
281+
282+
def sample_actions_with_cache(
283+
self,
284+
rng: at.KeyArrayLike,
285+
observation: _model.Observation,
286+
*,
287+
num_steps: int | at.Int[at.Array, ""] = 10,
288+
noise: at.Float[at.Array, "b ah ad"] | None = None,
289+
prefix_cache: _prefix_cache.PrefixCache | None = None,
290+
) -> tuple[_model.Actions, _prefix_cache.PrefixCache]:
291+
"""Sample actions with optional prefix cache reuse.
292+
293+
This method extends sample_actions() to support caching of the prefix
294+
(image + language) embeddings and KV cache. When the observation hasn't
295+
changed between calls, this can provide significant speedup by avoiding
296+
redundant computation.
297+
298+
Args:
299+
rng: Random key for noise generation.
300+
observation: The observation containing images, state, and prompt.
301+
num_steps: Number of denoising steps.
302+
noise: Optional pre-generated noise.
303+
prefix_cache: Optional cached prefix from a previous call.
304+
305+
Returns:
306+
A tuple of (actions, prefix_cache) where prefix_cache can be passed
307+
to subsequent calls for reuse.
308+
"""
309+
observation = _model.preprocess_observation(None, observation, train=False)
310+
dt = -1.0 / num_steps
311+
batch_size = observation.state.shape[0]
312+
if noise is None:
313+
noise = jax.random.normal(rng, (batch_size, self.action_horizon, self.action_dim))
314+
315+
if prefix_cache is not None and prefix_cache.is_valid_for(observation):
316+
prefix_tokens = prefix_cache.prefix_tokens
317+
prefix_mask = prefix_cache.prefix_mask
318+
kv_cache = prefix_cache.kv_cache
319+
logger.debug("Prefix cache hit, reusing cached KV cache")
320+
else:
321+
prefix_tokens, prefix_mask, prefix_ar_mask = self.embed_prefix(observation)
322+
prefix_attn_mask = make_attn_mask(prefix_mask, prefix_ar_mask)
323+
positions = jnp.cumsum(prefix_mask, axis=1) - 1
324+
_, kv_cache = self.PaliGemma.llm([prefix_tokens, None], mask=prefix_attn_mask, positions=positions)
325+
326+
cache_key = _prefix_cache.PrefixCacheKey.from_observation(observation)
327+
prefix_cache = _prefix_cache.PrefixCache(
328+
key=cache_key,
329+
prefix_tokens=prefix_tokens,
330+
prefix_mask=prefix_mask,
331+
prefix_ar_mask=prefix_ar_mask,
332+
kv_cache=kv_cache,
333+
)
334+
logger.debug("Prefix cache miss, computed new KV cache")
335+
336+
def step(carry):
337+
x_t, time = carry
338+
suffix_tokens, suffix_mask, suffix_ar_mask, adarms_cond = self.embed_suffix(
339+
observation, x_t, jnp.broadcast_to(time, batch_size)
340+
)
341+
suffix_attn_mask = make_attn_mask(suffix_mask, suffix_ar_mask)
342+
prefix_attn_mask_step = einops.repeat(prefix_mask, "b p -> b s p", s=suffix_tokens.shape[1])
343+
full_attn_mask = jnp.concatenate([prefix_attn_mask_step, suffix_attn_mask], axis=-1)
344+
positions = jnp.sum(prefix_mask, axis=-1)[:, None] + jnp.cumsum(suffix_mask, axis=-1) - 1
345+
346+
(prefix_out, suffix_out), _ = self.PaliGemma.llm(
347+
[None, suffix_tokens],
348+
mask=full_attn_mask,
349+
positions=positions,
350+
kv_cache=kv_cache,
351+
adarms_cond=[None, adarms_cond],
352+
)
353+
assert prefix_out is None
354+
v_t = self.action_out_proj(suffix_out[:, -self.action_horizon :])
355+
356+
return x_t + dt * v_t, time + dt
357+
358+
def cond(carry):
359+
x_t, time = carry
360+
return time >= -dt / 2
361+
362+
x_0, _ = jax.lax.while_loop(cond, step, (noise, 1.0))
363+
return x_0, prefix_cache

src/openpi/models/prefix_cache.py

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
"""Prefix cache for KV cache reuse during continuous inference.
2+
3+
This module provides caching infrastructure to avoid redundant computation
4+
of prefix embeddings (images + language) when the observation hasn't changed
5+
between consecutive inference calls.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
import dataclasses
11+
import hashlib
12+
from typing import TYPE_CHECKING
13+
14+
if TYPE_CHECKING:
15+
import jax
16+
17+
from openpi.models import model as _model
18+
19+
import numpy as np
20+
21+
22+
def fast_array_hash(arr: np.ndarray | jax.Array, sample_size: int = 1000) -> str:
23+
"""Compute a fast hash of an array using sampling for large arrays.
24+
25+
For small arrays, computes the hash of the entire content plus metadata.
26+
For large arrays, samples uniformly distributed points to balance
27+
speed and collision resistance.
28+
29+
Args:
30+
arr: The array to hash.
31+
sample_size: Number of samples for large arrays.
32+
33+
Returns:
34+
MD5 hex digest of the array content.
35+
"""
36+
arr = np.asarray(arr)
37+
flat = arr.ravel()
38+
39+
if flat.size <= sample_size:
40+
data = flat.tobytes() + str(arr.shape).encode() + str(arr.dtype).encode()
41+
else:
42+
indices = np.linspace(0, flat.size - 1, sample_size, dtype=np.int64)
43+
sampled = flat[indices]
44+
data = sampled.tobytes() + str(arr.shape).encode() + str(arr.dtype).encode()
45+
46+
return hashlib.md5(data).hexdigest()
47+
48+
49+
@dataclasses.dataclass(frozen=True)
50+
class PrefixCacheKey:
51+
"""Cache key based on image and prompt content hashes.
52+
53+
This is used to determine whether the cached prefix can be reused.
54+
The key is immutable and hashable.
55+
"""
56+
57+
image_hashes: tuple[tuple[str, str], ...]
58+
prompt_hash: str | None
59+
batch_size: int
60+
61+
@classmethod
62+
def from_observation(cls, observation: _model.Observation) -> PrefixCacheKey:
63+
"""Compute cache key from an Observation.
64+
65+
Args:
66+
observation: The observation to compute the key from.
67+
68+
Returns:
69+
A PrefixCacheKey that uniquely identifies this observation's prefix.
70+
"""
71+
image_hashes = []
72+
for name in sorted(observation.images.keys()):
73+
image = observation.images[name]
74+
h = fast_array_hash(image)
75+
image_hashes.append((name, h))
76+
77+
prompt_hash = None
78+
if observation.tokenized_prompt is not None:
79+
prompt_hash = fast_array_hash(observation.tokenized_prompt)
80+
81+
batch_size = int(np.asarray(observation.state).shape[0])
82+
83+
return cls(
84+
image_hashes=tuple(image_hashes),
85+
prompt_hash=prompt_hash,
86+
batch_size=batch_size,
87+
)
88+
89+
90+
@dataclasses.dataclass
91+
class PrefixCache:
92+
"""Stores computed prefix results for reuse.
93+
94+
Contains the cache key and all intermediate results needed to skip
95+
prefix computation on subsequent inference calls.
96+
"""
97+
98+
key: PrefixCacheKey
99+
100+
prefix_tokens: jax.Array
101+
prefix_mask: jax.Array
102+
prefix_ar_mask: jax.Array
103+
kv_cache: tuple[jax.Array, jax.Array]
104+
105+
def is_valid_for(self, observation: _model.Observation) -> bool:
106+
"""Check if this cache is valid for the given observation.
107+
108+
Args:
109+
observation: The observation to check against.
110+
111+
Returns:
112+
True if the cache can be reused, False otherwise.
113+
"""
114+
new_key = PrefixCacheKey.from_observation(observation)
115+
return self.key == new_key
116+
117+
def get_prefix_len(self) -> int:
118+
"""Get the length of the cached prefix sequence."""
119+
return int(self.prefix_tokens.shape[1])

0 commit comments

Comments
 (0)