Skip to content

Commit 7a30b0a

Browse files
committed
Serve KerasHub CausalLM models through vLLM on TPU
1 parent 03bf390 commit 7a30b0a

20 files changed

Lines changed: 2240 additions & 3 deletions

keras_hub/api/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from keras_hub import samplers as samplers
1111
from keras_hub import tokenizers as tokenizers
1212
from keras_hub import utils as utils
13+
from keras_hub import vllm as vllm
1314
from keras_hub.src.utils.preset_utils import upload_preset as upload_preset
1415
from keras_hub.src.version import __version__ as __version__
1516
from keras_hub.src.version import version as version

keras_hub/api/vllm/__init__.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
"""DO NOT EDIT.
2+
3+
This file was autogenerated. Do not edit it by hand,
4+
since your modifications would be overwritten.
5+
"""
6+
7+
from keras_hub.src.vllm.registry import KerasHubLLM as KerasHubLLM

keras_hub/src/layers/modeling/cached_multi_head_attention.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
from keras import ops
33

44
from keras_hub.src.api_export import keras_hub_export
5+
from keras_hub.src.vllm.attention import vllm_paged_attention
6+
from keras_hub.src.vllm.context import get_vllm_context
57

68

79
@keras_hub_export("keras_hub.layers.CachedMultiHeadAttention")
@@ -79,6 +81,20 @@ def call(
7981
if key is None:
8082
key = value
8183

84+
# Route to vLLM paged attention while serving on TPU; otherwise the
85+
# original dense path below runs unchanged.
86+
vllm_context = get_vllm_context()
87+
if (
88+
vllm_context is not None
89+
and vllm_context.paged_attention_func is not None
90+
):
91+
if return_attention_scores:
92+
raise ValueError(
93+
"`return_attention_scores=True` is not supported while "
94+
"serving through vLLM paged attention."
95+
)
96+
return self._compute_vllm_attention(query, value, key, cache)
97+
8298
query = self._query_dense(query)
8399

84100
# If cache is not `None`, we will use the cache to compute the final key
@@ -129,3 +145,24 @@ def call(
129145
return attention_output, cache
130146
else:
131147
return attention_output
148+
149+
def _compute_vllm_attention(self, query, value, key, cache):
150+
"""vLLM paged-attention route (serving on TPU only).
151+
152+
Projects Q/K/V and hands them to the shared paged-attention bridge.
153+
Unlike the RoPE model routes, no positional encoding is applied here:
154+
models built on this layer (GPT-2, OPT) add learned position
155+
embeddings to the token embeddings before the transformer layers run,
156+
so the inputs to this layer already carry position information. The
157+
softmax scale matches Keras MHA's ``1/sqrt(key_dim)``.
158+
"""
159+
attention_output = vllm_paged_attention(
160+
self._query_dense(query),
161+
self._key_dense(key),
162+
self._value_dense(value),
163+
float(self._key_dim) ** -0.5,
164+
)
165+
attention_output = self._output_dense(attention_output)
166+
if cache is not None:
167+
return attention_output, cache
168+
return attention_output

keras_hub/src/layers/modeling/position_embedding.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
from keras import ops
33

44
from keras_hub.src.api_export import keras_hub_export
5+
from keras_hub.src.vllm.context import get_vllm_context
56

67

78
@keras_hub_export("keras_hub.layers.PositionEmbedding")
@@ -103,6 +104,13 @@ def call(self, inputs, start_index=0, positions=None):
103104
# trim to match the length of the input sequence, which might be less
104105
# than the sequence_length of the layer.
105106
position_embeddings = ops.convert_to_tensor(self.position_embeddings)
107+
if positions is None:
108+
# When serving through vLLM, the engine assigns each token an
109+
# absolute position (driving the paged KV cache) that a plain
110+
# 0..seq_len slice would not match. If a serving context is
111+
# active, take those positions from it; otherwise this is a
112+
# no-op and the standard slice path below runs.
113+
positions = _vllm_serving_positions()
106114
if positions is None:
107115
position_embeddings = ops.slice(
108116
position_embeddings,
@@ -122,3 +130,23 @@ def call(self, inputs, start_index=0, positions=None):
122130

123131
def compute_output_shape(self, input_shape):
124132
return input_shape
133+
134+
135+
def _vllm_serving_positions():
136+
"""Absolute position ids assigned by vLLM, or ``None`` outside serving.
137+
138+
Under vLLM's paged / continuous-batched decode, tokens from many requests
139+
are packed into one flat sequence and each carries its own absolute
140+
position, so learned position embeddings cannot use a contiguous
141+
``0..seq_len`` slice — they must index by these per-token positions to stay
142+
aligned with the paged KV cache.
143+
144+
Returns ``None`` unless a serving context is active, so normal training and
145+
inference are unaffected. When active, the positions are reshaped to
146+
``(num_tokens, 1)`` to match the flattened ``(num_tokens, 1, hidden)`` token
147+
layout the serving model feeds in.
148+
"""
149+
ctx = get_vllm_context()
150+
if ctx is None or ctx.positions is None:
151+
return None
152+
return ops.reshape(ctx.positions, (-1, 1))

keras_hub/src/models/gemma/gemma_attention.py

Lines changed: 58 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
from keras_hub.src.utils.keras_utils import gpu_supports_fused_attention_op
1111
from keras_hub.src.utils.keras_utils import running_on_gpu
1212
from keras_hub.src.utils.keras_utils import running_on_tpu
13+
from keras_hub.src.vllm.attention import vllm_paged_attention
14+
from keras_hub.src.vllm.context import get_vllm_context
1315

1416

1517
class CachedGemmaAttention(keras.layers.Layer):
@@ -97,9 +99,16 @@ def build(self, inputs_shape):
9799

98100
self.built = True
99101

100-
def _apply_rope(self, x, start_index):
101-
"""Rope rotate q or k."""
102-
x = self.rope_layer(x, start_index=start_index)
102+
def _apply_rope(self, x, start_index, positions=None):
103+
"""Rope rotate q or k.
104+
105+
When ``positions`` is given (vLLM serving), rotate at those absolute
106+
per-token positions instead of a contiguous ``start_index`` range.
107+
"""
108+
if positions is not None:
109+
x = self.rope_layer(x, positions=positions)
110+
else:
111+
x = self.rope_layer(x, start_index=start_index)
103112
# Gemma uses a different layout for positional embeddings.
104113
# The transformation below ensures the embeddings are numerically
105114
# equivalent to the original gemma implementation.
@@ -226,6 +235,43 @@ def _mask_sliding_window(
226235
sliding_mask = ops.expand_dims(sliding_mask, 0)
227236
return ops.logical_and(attention_mask, ops.cast(sliding_mask, "bool"))
228237

238+
def _compute_vllm_attention(self, x, cache, vllm_context):
239+
"""vLLM paged-attention route (serving on TPU only).
240+
241+
Projects Q/K/V, applies Gemma's RoPE (with its split/reshape layout) at
242+
the engine's per-token positions, and hands the unexpanded K/V to the
243+
shared paged-attention bridge. Gemma's softmax scale, sliding window,
244+
and optional logit soft cap (Gemma 2) are all supplied here.
245+
"""
246+
positions = ops.reshape(vllm_context.positions, (-1, 1))
247+
query = self._apply_rope(self.query_dense(x), 0, positions=positions)
248+
key = self._apply_rope(self.key_dense(x), 0, positions=positions)
249+
value = self.value_dense(x)
250+
251+
if self.query_head_dim_normalize:
252+
scale = 1.0 / np.sqrt(self.head_dim)
253+
else:
254+
scale = 1.0 / np.sqrt(self.hidden_dim // self.num_query_heads)
255+
sliding_window = (
256+
self.sliding_window_size
257+
if self.use_sliding_window_attention
258+
else None
259+
)
260+
261+
attention_vec = vllm_paged_attention(
262+
query,
263+
key,
264+
value,
265+
scale,
266+
num_kv_heads=self.num_key_value_heads,
267+
soft_cap=self.logit_soft_cap,
268+
sliding_window=sliding_window,
269+
)
270+
attention_output = self.output_dense(attention_vec)
271+
if cache is not None:
272+
return attention_output, cache
273+
return attention_output
274+
229275
def call(
230276
self,
231277
x,
@@ -234,6 +280,15 @@ def call(
234280
cache_update_index=0,
235281
training=False,
236282
):
283+
# Route to vLLM paged attention while serving on TPU; otherwise the
284+
# original dense path below runs unchanged.
285+
vllm_context = get_vllm_context()
286+
if (
287+
vllm_context is not None
288+
and vllm_context.paged_attention_func is not None
289+
):
290+
return self._compute_vllm_attention(x, cache, vllm_context)
291+
237292
query = self.query_dense(x)
238293
query = self._apply_rope(query, cache_update_index)
239294

keras_hub/src/models/gemma3/gemma3_attention.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111
from keras_hub.src.utils.keras_utils import gpu_supports_fused_attention_op
1212
from keras_hub.src.utils.keras_utils import running_on_gpu
1313
from keras_hub.src.utils.keras_utils import running_on_tpu
14+
from keras_hub.src.vllm.attention import vllm_paged_attention
15+
from keras_hub.src.vllm.context import get_vllm_context
1416

1517

1618
class CachedGemma3Attention(keras.layers.Layer):
@@ -150,6 +152,59 @@ def _apply_rope(self, x, start_index):
150152
x = self.rope_layer(x, start_index=start_index)
151153
return x
152154

155+
def _compute_vllm_attention(self, x, cache, vllm_context):
156+
"""vLLM paged-attention route for this layer.
157+
158+
Used only while serving through vLLM on TPU (when a serving context
159+
is active). It mirrors `call`'s projection + RoPE + output steps but
160+
applies RoPE at the engine's per-token positions and hands Q/K/V to
161+
the shared paged-attention bridge instead of the dense path. All
162+
Gemma3-specific choices (Q/K norm, the softmax scale, the sliding
163+
window, and the logit soft cap) are made here, in the model.
164+
"""
165+
# RoPE at vLLM's absolute positions: one flattened batch mixes many
166+
# requests, each at its own position, so the usual contiguous
167+
# start_index does not apply.
168+
positions = ops.reshape(vllm_context.positions, (-1, 1))
169+
170+
query = self.query_dense(x)
171+
if self.use_query_key_norm:
172+
query = self.query_norm(query)
173+
query = self.rope_layer(query, positions=positions)
174+
175+
key = self.key_dense(x)
176+
if self.use_query_key_norm:
177+
key = self.key_norm(key)
178+
key = self.rope_layer(key, positions=positions)
179+
180+
value = self.value_dense(x)
181+
182+
if self.query_head_dim_normalize:
183+
scale = 1.0 / np.sqrt(self.head_dim)
184+
else:
185+
scale = 1.0 / np.sqrt(self.hidden_dim // self.num_query_heads)
186+
187+
sliding_window = (
188+
self.sliding_window_size
189+
if self.use_sliding_window_attention
190+
else None
191+
)
192+
193+
attention_vec = vllm_paged_attention(
194+
query,
195+
key,
196+
value,
197+
scale,
198+
num_kv_heads=self.num_key_value_heads,
199+
soft_cap=self.logit_soft_cap,
200+
sliding_window=sliding_window,
201+
)
202+
203+
attention_output = self.output_dense(attention_vec)
204+
if cache is not None:
205+
return attention_output, cache
206+
return attention_output
207+
153208
def _use_fused_attention_op(self):
154209
if not fused_attention_op_available():
155210
return False
@@ -323,6 +378,15 @@ def call(
323378
cache_update_mask=None,
324379
training=False,
325380
):
381+
# Route to vLLM paged attention while serving on TPU; otherwise the
382+
# original dense path below runs unchanged.
383+
vllm_context = get_vllm_context()
384+
if (
385+
vllm_context is not None
386+
and vllm_context.paged_attention_func is not None
387+
):
388+
return self._compute_vllm_attention(x, cache, vllm_context)
389+
326390
query = self.query_dense(x)
327391

328392
if self.use_query_key_norm:

keras_hub/src/models/llama/llama_attention.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
)
99
from keras_hub.src.utils.keras_utils import clone_initializer
1010
from keras_hub.src.utils.keras_utils import fused_attention_op_available
11+
from keras_hub.src.vllm.attention import vllm_paged_attention
12+
from keras_hub.src.vllm.context import get_vllm_context
1113

1214

1315
class LlamaAttention(keras.layers.Layer):
@@ -135,6 +137,17 @@ def call(
135137
cache_update_index=None,
136138
training=None,
137139
):
140+
# Route to vLLM paged attention while serving on TPU; otherwise the
141+
# original dense path below runs unchanged.
142+
vllm_context = get_vllm_context()
143+
if (
144+
vllm_context is not None
145+
and vllm_context.paged_attention_func is not None
146+
):
147+
return self._compute_vllm_attention(
148+
hidden_states, cache, vllm_context
149+
)
150+
138151
start_index = (
139152
cache_update_index if cache_update_index is not None else 0
140153
)
@@ -197,6 +210,34 @@ def _masked_softmax(self, attention_scores, attention_mask=None):
197210
)
198211
return self._softmax(attention_scores)
199212

213+
def _compute_vllm_attention(self, hidden_states, cache, vllm_context):
214+
"""vLLM paged-attention route (serving on TPU only).
215+
216+
Projects Q/K/V, applies RoPE at the engine's per-token positions,
217+
and hands the (unexpanded) K/V to the shared paged-attention bridge.
218+
The kernel handles grouped-query attention via ``num_kv_heads``.
219+
"""
220+
positions = ops.reshape(vllm_context.positions, (-1, 1))
221+
query = self.rotary_embedding_layer(
222+
self._query_dense(hidden_states), positions=positions
223+
)
224+
key = self.rotary_embedding_layer(
225+
self._key_dense(hidden_states), positions=positions
226+
)
227+
value = self._value_dense(hidden_states)
228+
229+
attention_output = vllm_paged_attention(
230+
query,
231+
key,
232+
value,
233+
self._inv_norm_factor,
234+
num_kv_heads=self.num_key_value_heads,
235+
)
236+
attention_output = self._output_dense(attention_output)
237+
if cache is not None:
238+
return attention_output, cache
239+
return attention_output
240+
200241
def _compute_attention(self, query, key, value, attention_mask=None):
201242
if fused_attention_op_available():
202243
# Use `dot_product_attention` with Flash Attention support if

0 commit comments

Comments
 (0)