Skip to content

Add KVPress support to KerasHub - #2941

Draft
Ahmed0830 wants to merge 6 commits into
keras-team:masterfrom
Ahmed0830:gpt2-kv-cache-compression
Draft

Add KVPress support to KerasHub#2941
Ahmed0830 wants to merge 6 commits into
keras-team:masterfrom
Ahmed0830:gpt2-kv-cache-compression

Conversation

@Ahmed0830

@Ahmed0830 Ahmed0830 commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Description of the change

Adds keras_hub.press, KVPress support for KerasHub — a lightweight port of the eviction ideas behind [NVIDIA's KVPress (https://github.com/NVIDIA/kvpress), raised in #2075. During generate(), a KVCachePress scores every cached token and evicts the lowest-scoring ones right after each transformer layer's KV cache is produced, before the next layer runs. Compressing per layer, rather than once over the fully assembled, all-layers cache, bounds the transient memory overlap between a layer's old and compressed cache to a single layer instead of paying that cost across the whole stack.

CausalLM.compile() gets a new press argument that any subclass can opt into. This PR wires it up for GPT2CausalLM and LlamaCausalLM.

Example Usage

gpt2_lm = keras_hub.models.GPT2CausalLM.from_preset("gpt2_base_en")
gpt2_lm.compile(press=keras_hub.press.KnormPress(compression_ratio=0.5))
gpt2_lm.generate("Keras is a", max_length=64)

Available presses

Press Strategy Notes
KnormPress Evicts the tokens with the largest key-vector L2 norm, per layer/head Follows the KVPress finding that low key-norm tokens tend to matter more
RandomPress Evicts a random subset, per layer/head Baseline for comparing the other presses
StreamingLLMPress Keeps the first n_sink "sink" tokens plus the most recent tokens Based on StreamingLLM; content-independent, so identical across layers/heads

Current limitation

Compression during generate() requires the PyTorch backend. Sizing the compressed buffer correctly depends on the real (non-padding) prompt length, which is only known at runtime, but the buffer shape must be static at trace time under jax.jit/XLA-compiled tf.functions. Rather than silently fall back to the padded length (which would let the decode loop overwrite the very tokens that were just retained), compress() raises NotImplementedError on JAX/TensorFlow whenever a padding_mask is supplied. compression_ratio=0.0 stays a true no-op on every backend.

Testing

Area Coverage
KVCachePress base + built-in presses shape, eviction correctness, padding/reserve-slot math, get_config/from_config and keras.saving round-trips
GPT2CausalLM / LlamaCausalLM position/cache-index decoupling, cache-shrink + offset math, a corruption regression test, mixed-length batches, compression_ratio=0.0 matching an uncompressed baseline exactly

25 new tests total. Ran under KERAS_BACKEND=torch; all pass.

Colab Reference

This notebook adapts NVIDIA's speed_and_memory benchmark notebook. It confirms peak memory decreases monotonically as the compression ratio increases, matching what the reference kvpress benchmark shows for its own models.

Reference

Checklist

  • I have added all the necessary unit tests for my change.
  • I have verified that my change does not break existing code and works with all backends (TensorFlow, JAX, and PyTorch).
  • My PR is based on the latest changes of the main branch (if unsure, rebase the code).
  • I have followed the Keras Hub Model contribution guidelines in making these changes.
  • I have followed the Keras Hub API design guidelines in making these changes.
  • I have signed the Contributor License Agreement.

Introduces keras_hub.press with a KVCachePress base class plus
KnormPress, RandomPress, and StreamingLLMPress implementations for
evicting less-important tokens from a KV cache to bound memory usage
during generation.
CausalLM.compile() takes a new `press` argument, and a
`_compress_layer_cache()` helper lets subclasses compress each layer's
KV cache right after it is seeded during prefill, one layer at a time,
rather than only after the whole cache has been assembled.
GPT2CausalLM.call_with_cache() now runs each transformer layer's
freshly-seeded cache through CausalLM._compress_layer_cache() during
prefill, so a `press` passed to compile() actually shrinks the prompt
cache before generation starts.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code Review

This pull request introduces KV cache compression to KerasHub, adding a base KVCachePress class and concrete strategies (KnormPress, RandomPress, and StreamingLLMPress). It integrates this compression mechanism into CausalLM tasks, specifically updating GPT2CausalLM and LlamaCausalLM to decouple physical cache indices from logical position indices for correct position and rotary embeddings. The review feedback focuses on enhancing the robustness of the implementation under JAX/JIT compilation and tracing, specifically by using ops.shape for dynamic dimensions, avoiding symbolic batch sizes in ops.reshape, and optimizing memory usage on accelerators by casting indices to int32 instead of int64.

Comment on lines +212 to +217
reserve_shape = list(compressed.shape)
reserve_shape[3] = reserve
compressed = ops.concatenate(
[compressed, ops.zeros(reserve_shape, dtype=compressed.dtype)],
axis=3,
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

Using list(compressed.shape) to construct reserve_shape is fragile because the batch dimension can be dynamic (i.e., None) during tracing or compilation. Passing a shape containing None to ops.zeros will raise a ValueError. Instead, construct the shape dynamically using ops.shape(compressed) to ensure robustness across all backends.

Suggested change
reserve_shape = list(compressed.shape)
reserve_shape[3] = reserve
compressed = ops.concatenate(
[compressed, ops.zeros(reserve_shape, dtype=compressed.dtype)],
axis=3,
)
compressed_shape = ops.shape(compressed)
reserve_shape = (
compressed_shape[0],
compressed_shape[1],
compressed_shape[2],
reserve,
compressed_shape[4],
compressed_shape[5],
)
compressed = ops.concatenate(
[compressed, ops.zeros(reserve_shape, dtype=compressed.dtype)],
axis=3,
)

Comment on lines +239 to +241
batch_size, num_layers = cache.shape[0], cache.shape[1]
num_heads = cache.shape[4]
keep_len = keep_indices.shape[-1]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

Accessing dimensions directly via cache.shape is fragile when the batch dimension is dynamic (i.e., None) during tracing or compilation. Use ops.shape(cache) to dynamically retrieve the shape, ensuring robustness across all backends.

Suggested change
batch_size, num_layers = cache.shape[0], cache.shape[1]
num_heads = cache.shape[4]
keep_len = keep_indices.shape[-1]
cache_shape = ops.shape(cache)
batch_size = cache_shape[0]
num_layers = cache_shape[1]
num_heads = cache_shape[4]
keep_len = ops.shape(keep_indices)[-1]

Comment on lines +260 to +263
indices = ops.cast(indices, "int64")
indices = ops.broadcast_to(
indices, (batch_size, num_layers, 2, keep_len, num_heads, 1)
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

Casting indices to int64 is less efficient and consumes more memory on accelerators. Casting to int32 is sufficient for sequence length indices and reduces memory overhead.

Suggested change
indices = ops.cast(indices, "int64")
indices = ops.broadcast_to(
indices, (batch_size, num_layers, 2, keep_len, num_heads, 1)
)
indices = ops.cast(indices, "int32")
indices = ops.broadcast_to(
indices, (batch_size, num_layers, 2, keep_len, num_heads, 1)
)

Comment on lines +45 to +51
shape = ops.shape(keys)
batch_size, num_layers, seq_len, num_heads = (
shape[0],
shape[1],
shape[2],
shape[3],
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

Using ops.shape(keys) to retrieve static dimensions like num_layers, seq_len, and num_heads can cause tracing issues in JAX/JIT when passed to ops.reshape. Retrieve the dynamic batch size using ops.shape(keys)[0] and the static dimensions using keys.shape to ensure compatibility with JIT compilation.

        batch_size = ops.shape(keys)[0]
        num_layers = keys.shape[1]
        seq_len = keys.shape[2]
        num_heads = keys.shape[3]

ops.ones_like(is_kept, dtype="float32"),
ops.full_like(ops.cast(is_kept, "float32"), _MASKED_SCORE),
)
score = ops.reshape(score, (batch_size, 1, 1, seq_len))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

Avoid passing a symbolic batch_size tensor to ops.reshape as it can cause tracing issues under JAX/JIT. Use -1 for the dynamic batch dimension instead.

Suggested change
score = ops.reshape(score, (batch_size, 1, 1, seq_len))
score = ops.reshape(score, (-1, 1, 1, seq_len))

Wires the same per-layer compression path used by GPT2CausalLM into
LlamaCausalLM.call_with_cache(). LlamaAttention and
LlamaTransformerDecoder gain a `rotary_start_index` argument so that
rotary position embeddings still line up correctly once a layer's
cache has been shrunk by a press.
Adds the two slot-mapping assertions and the
test_kv_cache_compression_retains_prompt_through_decoding regression test
that gpt2_causal_lm_test.py already had, so LlamaCausalLM gets the same
coverage for compressed-cache decoding.
Adds keras_hub/src/kv_press/serialization_test.py, mirroring
samplers/serialization_test.py, to cover keras_hub.press.get()'s
string, dict, and instance identifier paths. Adds a test_serialization
to each concrete press's own test file via the existing
run_serialization_test() helper, covering that press's
get_config()/from_config() and keras.saving round trip.
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