|
| 1 | +"""The serving model that runs a KerasHub `CausalLM` on vLLM's TPU backend. |
| 2 | +
|
| 3 | +`KerasHubForCausalLM` lives here in keras-hub and is registered with |
| 4 | +tpu-inference's model registry (and vLLM's) by tpu-inference's plugin hook, |
| 5 | +so the native `flax_nnx` loader resolves it by architecture name like any |
| 6 | +other model. It is only ever imported on the serving path, where flax and |
| 7 | +tpu-inference are installed. |
| 8 | +""" |
| 9 | + |
| 10 | +import jax |
| 11 | +from flax import nnx |
| 12 | +from jax.sharding import Mesh |
| 13 | +from keras import ops |
| 14 | + |
| 15 | +from keras_hub.src.models.causal_lm import CausalLM |
| 16 | +from keras_hub.src.vllm.context import get_vllm_context |
| 17 | +from keras_hub.src.vllm.context import vllm_context_scope |
| 18 | + |
| 19 | + |
| 20 | +class KerasHubForCausalLM(nnx.Module): |
| 21 | + """Serves a KerasHub `CausalLM` on tpu-inference's native JAX path. |
| 22 | +
|
| 23 | + An adapter, not a conversion: it implements the model interface the |
| 24 | + native `flax_nnx` runner drives (resolved by the `KerasHubForCausalLM` |
| 25 | + architecture name through the standard model registry), reusing the |
| 26 | + preset's existing backbone and weights. Keras's NNX mode |
| 27 | + (`KERAS_NNX_ENABLED=true`) makes the backbone's variables nnx state, so |
| 28 | + the runner's `nnx.split`/`nnx.merge` machinery carries the weights with |
| 29 | + no conversion: |
| 30 | +
|
| 31 | + - `__init__` records the preset name and dtype; it builds nothing, so |
| 32 | + it is safe under the loader's `nnx.eval_shape` abstract pass. |
| 33 | + - `load_weights` builds the model and loads the preset weights with a |
| 34 | + single `CausalLM.from_preset` call. The loader always calls it |
| 35 | + eagerly, so KerasHub construction and weight IO are unrestricted. |
| 36 | + - `__call__` runs one forward step: it publishes the serving context |
| 37 | + (the paged-attention kernel, the per-layer paged KV caches, and |
| 38 | + vLLM's per-token positions), then delegates to the backbone's own |
| 39 | + forward; each attention layer's vLLM route reads that context and |
| 40 | + dispatches to the paged-attention kernel. |
| 41 | + - `compute_logits` projects hidden states to vocabulary logits through |
| 42 | + the tied token embedding. |
| 43 | + """ |
| 44 | + |
| 45 | + def __init__( |
| 46 | + self, vllm_config, rng_key: jax.Array, mesh: Mesh = None |
| 47 | + ) -> None: |
| 48 | + """Records what `load_weights` needs; builds nothing. |
| 49 | +
|
| 50 | + The loader constructs models under `nnx.eval_shape`, where concrete |
| 51 | + tensor work cannot run (KerasHub constructors compute values such |
| 52 | + as Gemma's embedding scale). Keeping `__init__` free of any Keras |
| 53 | + construction makes it safe to trace; the model itself is built in |
| 54 | + `load_weights`, which the loader always calls eagerly. |
| 55 | +
|
| 56 | + Args: |
| 57 | + vllm_config: The vLLM config; `model_config.hf_config` carries |
| 58 | + the `keras_hub_preset` written by `setup_vllm_model`. |
| 59 | + rng_key: JAX PRNG key (unused; weights come from the preset). |
| 60 | + mesh: JAX device mesh used by the paged-attention kernel. |
| 61 | + """ |
| 62 | + self.vllm_config = vllm_config |
| 63 | + self.mesh = mesh |
| 64 | + self.preset_name = vllm_config.model_config.hf_config.keras_hub_preset |
| 65 | + # Serving dtype comes from the config vLLM resolved (written as |
| 66 | + # torch_dtype by setup_vllm_model), not a hardcoded value. |
| 67 | + self._dtype = str( |
| 68 | + getattr( |
| 69 | + vllm_config.model_config.hf_config, |
| 70 | + "torch_dtype", |
| 71 | + "bfloat16", |
| 72 | + ) |
| 73 | + ) |
| 74 | + |
| 75 | + def load_weights(self, *args, **kwargs) -> None: |
| 76 | + """Builds the KerasHub model and loads the preset weights. |
| 77 | +
|
| 78 | + One `CausalLM.from_preset` call, the same as any other KerasHub |
| 79 | + usage: it builds the model and loads the preset weights in place. |
| 80 | + Only the backbone is kept: serving never calls the task wrapper, |
| 81 | + and a compiled task carries a layer-keyed dict (Keras 3.15's |
| 82 | + `_compiled_trainable_state`) whose keys nnx's graph flatten cannot |
| 83 | + sort. |
| 84 | +
|
| 85 | + Args: |
| 86 | + *args: Variable length argument list (unused). |
| 87 | + **kwargs: Arbitrary keyword arguments (unused). |
| 88 | + """ |
| 89 | + model = CausalLM.from_preset(self.preset_name, dtype=self._dtype) |
| 90 | + self.backbone = model.backbone |
| 91 | + |
| 92 | + def __call__( |
| 93 | + self, |
| 94 | + kv_caches, |
| 95 | + input_ids, |
| 96 | + attention_metadata, |
| 97 | + *args, |
| 98 | + **kwargs, |
| 99 | + ): |
| 100 | + """Runs one forward step with vLLM's paged KV cache. |
| 101 | +
|
| 102 | + Publishes the serving context (the paged-attention kernel, the |
| 103 | + per-layer paged KV caches, and vLLM's per-token positions), then |
| 104 | + delegates to the KerasHub backbone's own forward, which keeps every |
| 105 | + model-specific detail (embedding scaling, learned vs. rotary |
| 106 | + positions, per-family norms) in the model. Each attention layer's |
| 107 | + vLLM route and KerasHub's `PositionEmbedding` read the context in |
| 108 | + place; the scope clears it when the step finishes, even on error. |
| 109 | +
|
| 110 | + Returns: |
| 111 | + `(updated_kv_caches, hidden_states, None, None)` — the tuple |
| 112 | + the native runner expects. |
| 113 | + """ |
| 114 | + positions = getattr(attention_metadata, "input_positions", None) |
| 115 | + if positions is None: |
| 116 | + positions = kwargs.get("positions") |
| 117 | + |
| 118 | + token_ids = input_ids |
| 119 | + if len(token_ids.shape) == 1: |
| 120 | + token_ids = ops.expand_dims(token_ids, axis=-1) |
| 121 | + # vLLM presents already-packed tokens; there is no padding to mask. |
| 122 | + padding_mask = ops.ones_like(token_ids) |
| 123 | + |
| 124 | + with self._serving_context(kv_caches, attention_metadata, positions): |
| 125 | + hidden_states = self.backbone( |
| 126 | + {"token_ids": token_ids, "padding_mask": padding_mask}, |
| 127 | + training=False, |
| 128 | + ) |
| 129 | + # Tokens ride as (num_tokens, 1); drop the seq axis the runner |
| 130 | + # does not expect. |
| 131 | + if len(hidden_states.shape) == 3 and hidden_states.shape[1] == 1: |
| 132 | + hidden_states = ops.squeeze(hidden_states, axis=1) |
| 133 | + |
| 134 | + # The bridge stored each layer's kernel-updated cache here. It |
| 135 | + # is None on a cacheless pass (e.g. vLLM's startup profiling |
| 136 | + # run). |
| 137 | + ctx = get_vllm_context() |
| 138 | + updated_kv_caches = ( |
| 139 | + list(ctx.updated_kv_caches) |
| 140 | + if ctx.updated_kv_caches is not None |
| 141 | + else None |
| 142 | + ) |
| 143 | + # Every attention layer must have dispatched to the kernel |
| 144 | + # exactly once; a mismatch means one silently ran its dense |
| 145 | + # path (or ran twice), which would corrupt the paged cache. |
| 146 | + caches = ctx.kv_caches |
| 147 | + num_caches = len(caches) if caches is not None else None |
| 148 | + if num_caches is not None and ctx.layer_index != num_caches: |
| 149 | + raise RuntimeError( |
| 150 | + f"Paged-attention dispatch ran {ctx.layer_index} " |
| 151 | + f"time(s) for {num_caches} transformer layers. An " |
| 152 | + "attention layer skipped the vLLM dispatch (or " |
| 153 | + "dispatched more than once); serving this model would " |
| 154 | + "produce incorrect output." |
| 155 | + ) |
| 156 | + |
| 157 | + return updated_kv_caches, hidden_states, None, None |
| 158 | + |
| 159 | + def compute_logits(self, hidden_states, *args, **kwargs): |
| 160 | + """Projects hidden states to vocab logits via the tied embedding. |
| 161 | +
|
| 162 | + Args: |
| 163 | + hidden_states: Tensor. A tensor of hidden states from the |
| 164 | + backbone. |
| 165 | + *args: Variable length argument list (unused). |
| 166 | + **kwargs: Arbitrary keyword arguments (unused). |
| 167 | +
|
| 168 | + Returns: |
| 169 | + Tensor. A tensor of logits. |
| 170 | + """ |
| 171 | + return self.backbone.token_embedding(hidden_states, reverse=True) |
| 172 | + |
| 173 | + def _serving_context(self, kv_caches, attention_metadata, positions): |
| 174 | + """Builds the serving-context scope for one forward step. |
| 175 | +
|
| 176 | + Returns keras-hub's ``vllm_context_scope`` context manager, which |
| 177 | + publishes the thread-local serving context on entry and always |
| 178 | + clears it on exit, even when the forward raises. |
| 179 | +
|
| 180 | + The published function wraps tpu-inference's ragged-paged-attention |
| 181 | + kernel (`_jax_attn_func`) behind a small stable contract carrying |
| 182 | + only what a KerasHub attention layer knows:: |
| 183 | +
|
| 184 | + fn(kv_cache, q, k, v, scale, head_size, num_heads, num_kv_heads, |
| 185 | + sliding_window=None, soft_cap=None) -> (new_kv_cache, output) |
| 186 | +
|
| 187 | + Engine-side arguments (the attention metadata, the mesh) are closed |
| 188 | + over here, so kernel signature changes stay local to this repo. The |
| 189 | + per-layer paged KV caches ride in the context too: KerasHub's shared |
| 190 | + bridge consumes them in layer-call order, so the layers run their |
| 191 | + plain `cache=None` path and need no cache threading. |
| 192 | + """ |
| 193 | + # tpu-inference is imported here, not at module top: it is an |
| 194 | + # optional dependency of keras-hub, and the tpu-inference plugin |
| 195 | + # hook imports this module, so a top-level import would be |
| 196 | + # circular. A missing kernel fails loudly with the real cause. |
| 197 | + try: |
| 198 | + from tpu_inference.layers.vllm.backends.flash_attn import ( |
| 199 | + _jax_attn_func, |
| 200 | + ) |
| 201 | + except ImportError as e: |
| 202 | + raise ImportError( |
| 203 | + "Serving a KerasHub model requires tpu-inference " |
| 204 | + "(vllm-tpu), which provides the paged-attention kernel." |
| 205 | + ) from e |
| 206 | + |
| 207 | + mesh = self.mesh |
| 208 | + |
| 209 | + def paged_attention( |
| 210 | + kv_cache, |
| 211 | + q, |
| 212 | + k, |
| 213 | + v, |
| 214 | + scale, |
| 215 | + head_size, |
| 216 | + num_heads, |
| 217 | + num_kv_heads, |
| 218 | + sliding_window=None, |
| 219 | + soft_cap=None, |
| 220 | + ): |
| 221 | + return _jax_attn_func( |
| 222 | + kv_cache=kv_cache, |
| 223 | + q=q, |
| 224 | + k=k, |
| 225 | + v=v, |
| 226 | + sinks=None, # KerasHub attention layers have no sinks. |
| 227 | + attention_metadata=attention_metadata, |
| 228 | + shared_attention_metadata=None, # single-chip serving |
| 229 | + mesh=mesh, |
| 230 | + scale=scale, |
| 231 | + head_size=head_size, |
| 232 | + num_heads=num_heads, |
| 233 | + num_kv_heads=num_kv_heads, |
| 234 | + sliding_window=sliding_window, |
| 235 | + soft_cap=soft_cap, |
| 236 | + ) |
| 237 | + |
| 238 | + block_tables = getattr(attention_metadata, "block_tables", None) |
| 239 | + slot_mapping = getattr( |
| 240 | + attention_metadata, |
| 241 | + "slot_mapping_tensor", |
| 242 | + getattr(attention_metadata, "slot_mapping", None), |
| 243 | + ) |
| 244 | + return vllm_context_scope( |
| 245 | + block_tables, |
| 246 | + slot_mapping, |
| 247 | + attention_metadata, |
| 248 | + paged_attention, |
| 249 | + self.mesh, |
| 250 | + positions=positions, |
| 251 | + kv_caches=kv_caches, |
| 252 | + ) |
0 commit comments