Skip to content

Commit 6e6fd52

Browse files
feat(prefill-router): run batched prefill inference
Signed-off-by: nachiketb <nachiketb@nvidia.com>
1 parent 819e462 commit 6e6fd52

9 files changed

Lines changed: 550 additions & 1171 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/prefill-router/Cargo.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
[package]
55
name = "prefill-router"
66
version.workspace = true
7-
description = "Prefill feature extraction for Switchyard routing"
7+
description = "Learned prefill routing for Switchyard"
88
authors.workspace = true
99
edition.workspace = true
1010
license.workspace = true
@@ -13,7 +13,7 @@ rust-version.workspace = true
1313

1414
[dependencies]
1515
pyo3 = { version = "0.28.3", features = ["auto-initialize"] }
16-
serde_json.workspace = true
16+
switchyard-protocol.workspace = true
1717
thiserror.workspace = true
1818
tracing.workspace = true
1919

crates/prefill-router/pyproject.toml

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,12 @@ name = "prefill-router-tests"
66
version = "0.0.0"
77
requires-python = ">=3.10"
88
dependencies = [
9+
"accelerate>=1.0",
910
"numpy>=1.26",
1011
"torch>=2.0",
1112
"transformers>=5.0",
1213
]
1314

14-
[dependency-groups]
15-
dev = [
16-
"model-router-toolkit @ https://github.com/NVIDIA-AI-Blueprints/llm-router/archive/8a9d3509fbde879d9795258081bab9553b458e04.tar.gz",
17-
]
18-
1915
[tool.uv]
2016
package = false
2117

Lines changed: 171 additions & 118 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
22
# SPDX-License-Identifier: Apache-2.0
33

4-
"""Hugging Face Transformers prefill forward used by the Rust crate."""
4+
"""Complete Transformers prefill and checkpoint inference for the Rust crate."""
55

66
from __future__ import annotations
77

8+
from pathlib import Path
89
from typing import Any
910

1011

@@ -37,125 +38,148 @@ def _resolve_device(torch: Any, override: str | None) -> str:
3738

3839

3940
class TransformersForward:
40-
"""Lazily load a causal LM and return pooled prefill hidden states."""
41+
"""Run encoder extraction and learned confidence inference in one pass."""
4142

4243
def __init__(
4344
self,
44-
model: str,
45+
checkpoint_path: str | Path,
4546
*,
4647
device: str | None = None,
4748
cache_dir: str | None = None,
4849
) -> None:
49-
self._model_path = model
50+
import numpy as np
51+
import torch
52+
53+
checkpoint = torch.load(checkpoint_path, map_location="cpu", weights_only=True)
54+
if checkpoint["format_version"] != 1:
55+
raise ValueError("unsupported checkpoint format_version")
56+
57+
encoder = checkpoint["encoder"]
58+
architecture = checkpoint["architecture"]
59+
pipeline = checkpoint["feature_pipeline"]
60+
if encoder["view"] != "task_prompt_only":
61+
raise ValueError("checkpoint encoder view must be task_prompt_only")
62+
if pipeline["pooling"] != "mean of independently standardized selected layers":
63+
raise ValueError("unsupported checkpoint feature pooling")
64+
if architecture["activation"] != "ReLU":
65+
raise ValueError("checkpoint activation must be ReLU")
66+
if architecture["ensemble_reduction"] != "mean(sigmoid(logits))":
67+
raise ValueError("unsupported checkpoint ensemble reduction")
68+
69+
self._numpy = np
70+
self._torch = torch
71+
self._model_path = str(encoder["name"])
72+
self._expected_layers = int(encoder["n_layers"])
73+
self._hidden_dim = int(encoder["hidden_dim"])
74+
self._models = [str(model) for model in checkpoint["models"]]
75+
self._selected_layers = [int(layer) for layer in pipeline["selected_layers"]]
76+
self._layer_mean = torch.stack(
77+
[pipeline["layer_mean"][str(layer)].float() for layer in self._selected_layers]
78+
).numpy()
79+
self._layer_std = torch.stack(
80+
[pipeline["layer_std"][str(layer)].float() for layer in self._selected_layers]
81+
).numpy()
82+
self._scaler_mean = pipeline["scaler_mean"].numpy()
83+
self._scaler_scale = pipeline["scaler_scale"].numpy()
84+
self._pca_mean = pipeline["pca_mean"].numpy()
85+
self._pca_components = pipeline["pca_components"].numpy()
86+
self._states = checkpoint["model_state_dicts"]
5087
self._cache_dir = cache_dir
5188
self._device_override = device
5289
self._model = None
5390
self._tokenizer = None
54-
self._torch = None
55-
self.n_layers = 0
56-
self.hidden_dim = 0
5791

58-
def _ensure_loaded(self) -> str:
92+
if not self._selected_layers or len(set(self._selected_layers)) != len(
93+
self._selected_layers
94+
):
95+
raise ValueError("checkpoint selected layers must be non-empty and unique")
96+
if not self._models or not self._states:
97+
raise ValueError("checkpoint must contain models and ensemble members")
98+
if self._layer_mean.shape != self._layer_std.shape or self._layer_mean.shape != (
99+
len(self._selected_layers),
100+
self._hidden_dim,
101+
):
102+
raise ValueError("checkpoint layer normalization shape is inconsistent")
103+
if not bool(np.all(self._layer_std > 0)) or not bool(np.all(self._scaler_scale > 0)):
104+
raise ValueError("checkpoint normalization scales must be positive")
105+
106+
def metadata(self) -> tuple[str, int]:
107+
"""Return the encoder and ordered output count consumed by Rust."""
108+
return self._model_path, len(self._models)
109+
110+
def _ensure_loaded(self) -> None:
59111
if self._model is not None:
60-
return str(self._model.device)
112+
return
61113

62-
import torch
63114
from transformers import AutoModelForCausalLM, AutoTokenizer
64115

65-
self._torch = torch
116+
torch = self._torch
66117
device = _resolve_device(torch, self._device_override)
67-
if device == "cpu":
68-
dtype = torch.float32
69-
elif device == "mps":
70-
dtype = torch.float16
71-
else:
72-
dtype = (
73-
torch.bfloat16
74-
if torch.cuda.get_device_capability(device)[0] >= 8
75-
else torch.float16
76-
)
77-
118+
dtype = (
119+
torch.float32
120+
if device == "cpu"
121+
else torch.float16
122+
if device == "mps"
123+
else torch.bfloat16
124+
if torch.cuda.get_device_capability(device)[0] >= 8
125+
else torch.float16
126+
)
127+
model_kwargs: dict[str, Any] = {}
128+
if device == "cuda":
129+
model_kwargs["device_map"] = "auto"
130+
elif device.startswith("cuda:"):
131+
model_kwargs["device_map"] = {"": device}
78132
self._tokenizer = AutoTokenizer.from_pretrained(
79133
self._model_path,
80134
cache_dir=self._cache_dir,
81135
)
82136
if self._tokenizer.pad_token is None:
83137
self._tokenizer.pad_token = self._tokenizer.eos_token
84-
85-
load_kwargs: dict[str, Any] = {
86-
"dtype": dtype,
87-
"cache_dir": self._cache_dir,
88-
}
89-
90-
self._model = AutoModelForCausalLM.from_pretrained(
138+
causal_model = AutoModelForCausalLM.from_pretrained(
91139
self._model_path,
92-
**load_kwargs,
140+
dtype=dtype,
141+
cache_dir=self._cache_dir,
142+
**model_kwargs,
93143
)
94-
if device != "cpu":
144+
self._model = causal_model.base_model
145+
del causal_model
146+
if device == "mps":
95147
self._model.to(device)
96148
self._model.eval()
97-
self.n_layers = self._model.config.num_hidden_layers
98-
self.hidden_dim = self._model.config.hidden_size
99-
return str(self._model.device)
149+
model_config = getattr(self._model.config, "text_config", self._model.config)
150+
if (
151+
model_config.num_hidden_layers != self._expected_layers
152+
or model_config.hidden_size != self._hidden_dim
153+
):
154+
raise ValueError("loaded encoder dimensions do not match checkpoint metadata")
100155

101-
def extract_batch(
102-
self,
103-
prompts: list[str],
104-
*,
105-
chat_template_kwargs: dict[str, Any] | None = None,
106-
extract_layers: list[int] | str = "upper_half",
107-
pooling_modes: list[str] | None = None,
108-
batch_size: int = 4,
109-
max_length: int = 2048,
110-
) -> dict[str, Any]:
111-
"""Extract pooled hidden states using the blueprint's direct indexing."""
156+
def forward(self, prompts: list[str], batch_size: int, max_length: int) -> bytes:
157+
"""Return a row-major F32 probability matrix for ordered prompts."""
112158
self._ensure_loaded()
159+
if not prompts or any(not prompt for prompt in prompts):
160+
raise ValueError("prompts must be non-empty")
161+
if batch_size <= 0 or max_length <= 0:
162+
raise ValueError("batch_size and max_length must be positive")
113163

114-
if extract_layers == "all":
115-
layers = list(range(self.n_layers))
116-
elif extract_layers == "upper_half":
117-
layers = list(range(self.n_layers // 2, self.n_layers))
118-
elif isinstance(extract_layers, list):
119-
layers = [int(layer) for layer in extract_layers]
120-
else:
121-
raise ValueError(f"Unsupported layer selection: {extract_layers}")
122-
if not layers:
123-
raise ValueError("extract_layers resolved to an empty list")
124-
invalid = [layer for layer in layers if layer < 0 or layer >= self.n_layers]
125-
if invalid:
126-
raise ValueError(
127-
f"Requested indexes {invalid} are outside hidden-state range 0..{self.n_layers - 1}"
164+
formatted = [
165+
self._tokenizer.apply_chat_template(
166+
[{"role": "user", "content": prompt}],
167+
tokenize=False,
168+
add_generation_prompt=True,
128169
)
129-
130-
pools = {"last", "mean"} if pooling_modes is None else set(pooling_modes)
131-
unknown_pools = pools - {"last", "mean"}
132-
if unknown_pools:
133-
raise ValueError(f"Unknown pooling modes: {sorted(unknown_pools)}")
134-
if not pools:
135-
raise ValueError("At least one pooling mode is required")
136-
137-
template_kwargs = chat_template_kwargs or {}
138-
conversations = [[{"role": "user", "content": prompt}] for prompt in prompts]
139-
formatted = self._tokenizer.apply_chat_template(
140-
conversations,
141-
tokenize=False,
142-
add_generation_prompt=True,
143-
**template_kwargs,
144-
)
145-
all_last = {layer: [] for layer in layers} if "last" in pools else {}
146-
all_mean = {layer: [] for layer in layers} if "mean" in pools else {}
147-
148-
for batch_start in range(0, len(formatted), batch_size):
170+
for prompt in prompts
171+
]
172+
predictions = []
173+
for start in range(0, len(formatted), batch_size):
149174
inputs = self._tokenizer(
150-
formatted[batch_start : batch_start + batch_size],
175+
formatted[start : start + batch_size],
151176
return_tensors="pt",
152177
padding=True,
153178
truncation=True,
154179
max_length=max_length,
155180
)
156181
input_ids = inputs["input_ids"].to(self._model.device)
157182
attention_mask = inputs["attention_mask"].to(self._model.device)
158-
159183
with self._torch.inference_mode():
160184
outputs = self._model(
161185
input_ids=input_ids,
@@ -164,45 +188,74 @@ def extract_batch(
164188
use_cache=False,
165189
)
166190

167-
hidden_states = outputs.hidden_states
168-
token_mask = attention_mask.bool()
169-
token_count = token_mask.sum(dim=1, keepdim=True)
170-
positions = self._torch.arange(token_mask.shape[1], device=token_mask.device).expand_as(
171-
token_mask
191+
layers = []
192+
for layer in self._selected_layers:
193+
hidden = outputs.hidden_states[layer].float()
194+
# The checkpoint consumes the last real token from each selected layer.
195+
token_mask = attention_mask.to(hidden.device).bool()
196+
positions = self._torch.arange(hidden.shape[1], device=hidden.device)
197+
last_token = (
198+
positions.expand_as(token_mask).masked_fill(~token_mask, -1).max(dim=1).values
199+
)
200+
pooled = hidden[
201+
self._torch.arange(hidden.shape[0], device=hidden.device), last_token
202+
]
203+
layers.append(pooled.cpu().numpy())
204+
predictions.append(self._predict(layers))
205+
206+
return self._numpy.ascontiguousarray(
207+
self._numpy.concatenate(predictions, axis=0), dtype=self._numpy.float32
208+
).tobytes()
209+
210+
def _predict(self, layers: list[Any]) -> Any:
211+
np = self._numpy
212+
torch = self._torch
213+
stacked = np.stack(layers)
214+
if stacked.ndim != 3 or stacked.shape[0] != len(self._selected_layers):
215+
raise ValueError("encoder returned invalid selected-layer features")
216+
standardized = (stacked - self._layer_mean[:, None, :]) / self._layer_std[:, None, :]
217+
pooled = standardized.mean(axis=0)
218+
scaled = (pooled - self._scaler_mean) / self._scaler_scale
219+
features = torch.from_numpy(
220+
np.ascontiguousarray(
221+
(scaled - self._pca_mean) @ self._pca_components.T,
222+
dtype=np.float32,
172223
)
173-
last_index = positions.masked_fill(~token_mask, -1).max(dim=1).values
174-
batch_index = self._torch.arange(token_mask.shape[0], device=token_mask.device)
175-
176-
for layer in layers:
177-
hidden = hidden_states[layer].float()
178-
if "last" in pools:
179-
all_last[layer].append(hidden[batch_index, last_index].cpu())
180-
if "mean" in pools:
181-
masked = hidden.masked_fill(~token_mask.unsqueeze(-1), 0)
182-
all_mean[layer].append((masked.sum(dim=1) / token_count).cpu())
183-
184-
del outputs, hidden_states, input_ids, attention_mask
185-
186-
return {
187-
"hidden_last": {
188-
layer: self._torch.cat(rows).contiguous().numpy().tobytes()
189-
for layer, rows in all_last.items()
190-
},
191-
"hidden_mean": {
192-
layer: self._torch.cat(rows).contiguous().numpy().tobytes()
193-
for layer, rows in all_mean.items()
194-
},
195-
"n_layers": self.n_layers,
196-
"hidden_dim": self.hidden_dim,
197-
}
224+
)
225+
226+
members = []
227+
with torch.inference_mode():
228+
for state in self._states:
229+
logits = []
230+
for index in range(len(self._models)):
231+
adapter = torch.nn.functional.relu(
232+
torch.nn.functional.linear(
233+
features,
234+
state[f"adapters.{index}.weight"],
235+
state[f"adapters.{index}.bias"],
236+
)
237+
)
238+
trunk = torch.nn.functional.relu(
239+
torch.nn.functional.linear(
240+
adapter,
241+
state["trunk.0.weight"],
242+
state["trunk.0.bias"],
243+
)
244+
)
245+
logits.append(
246+
torch.nn.functional.linear(
247+
trunk,
248+
state[f"heads.{index}.weight"],
249+
state[f"heads.{index}.bias"],
250+
)
251+
)
252+
members.append(torch.sigmoid(torch.cat(logits, dim=1)))
253+
return torch.stack(members).mean(dim=0).contiguous().numpy()
198254

199255
def unload(self) -> None:
200256
self._model = None
201257
self._tokenizer = None
202-
self.n_layers = 0
203-
self.hidden_dim = 0
204-
if self._torch is not None:
205-
if self._torch.cuda.is_available():
206-
self._torch.cuda.empty_cache()
207-
if hasattr(self._torch, "mps") and self._torch.backends.mps.is_available():
208-
self._torch.mps.empty_cache()
258+
if self._torch.cuda.is_available():
259+
self._torch.cuda.empty_cache()
260+
if hasattr(self._torch, "mps") and self._torch.backends.mps.is_available():
261+
self._torch.mps.empty_cache()

0 commit comments

Comments
 (0)