|
3 | 3 |
|
4 | 4 | """Autoregressive text generation using ONNX Runtime. |
5 | 5 |
|
6 | | -Provides a self-contained generation loop with KV cache management, |
7 | | -without depending on onnxruntime-genai. |
| 6 | +Provides self-contained generation loops with KV cache management, |
| 7 | +without depending on onnxruntime-genai. Supports causal-LM (decoder- |
| 8 | +only) and seq2seq (encoder-decoder) architectures. |
8 | 9 | """ |
9 | 10 |
|
10 | 11 | from __future__ import annotations |
@@ -164,6 +165,130 @@ def generate( |
164 | 165 | return all_ids |
165 | 166 |
|
166 | 167 |
|
| 168 | +class OnnxSeq2SeqGenerator: |
| 169 | + """Greedy generation for encoder-decoder (seq2seq) ONNX models. |
| 170 | +
|
| 171 | + Runs the encoder once, then autoregressively decodes using the |
| 172 | + decoder with cross-attention to encoder hidden states. Manages |
| 173 | + both self-attention and cross-attention KV caches. |
| 174 | +
|
| 175 | + Example:: |
| 176 | +
|
| 177 | + enc_session = OnnxModelSession(pkg["encoder"]) |
| 178 | + dec_session = OnnxModelSession(pkg["decoder"]) |
| 179 | + gen = OnnxSeq2SeqGenerator(enc_session, dec_session, config) |
| 180 | + output_ids = gen.generate(input_ids, max_new_tokens=20) |
| 181 | + """ |
| 182 | + |
| 183 | + def __init__( |
| 184 | + self, |
| 185 | + enc_session: OnnxModelSession, |
| 186 | + dec_session: OnnxModelSession, |
| 187 | + config: ArchitectureConfig, |
| 188 | + ): |
| 189 | + self.enc_session = enc_session |
| 190 | + self.dec_session = dec_session |
| 191 | + self.config = config |
| 192 | + |
| 193 | + def generate( |
| 194 | + self, |
| 195 | + input_ids: np.ndarray, |
| 196 | + max_new_tokens: int = 20, |
| 197 | + eos_token_id: int | None = None, |
| 198 | + decoder_start_token_id: int = 0, |
| 199 | + ) -> np.ndarray: |
| 200 | + """Generate tokens from encoder input using greedy decoding. |
| 201 | +
|
| 202 | + Args: |
| 203 | + input_ids: [batch, src_seq_len] int64 encoder input tokens. |
| 204 | + max_new_tokens: Maximum number of tokens to generate. |
| 205 | + eos_token_id: If set, stop when this token is produced. |
| 206 | + decoder_start_token_id: Token to seed the decoder. |
| 207 | +
|
| 208 | + Returns: |
| 209 | + [batch, generated_len] int64 array of generated token IDs |
| 210 | + (decoder start token + generated, no encoder input). |
| 211 | + """ |
| 212 | + batch_size = input_ids.shape[0] |
| 213 | + src_seq_len = input_ids.shape[1] |
| 214 | + |
| 215 | + # Step 1: Run encoder once |
| 216 | + enc_feeds = { |
| 217 | + "input_ids": input_ids, |
| 218 | + "attention_mask": np.ones_like(input_ids), |
| 219 | + } |
| 220 | + enc_outputs = self.enc_session.run(enc_feeds) |
| 221 | + |
| 222 | + # Extract encoder hidden states |
| 223 | + enc_hidden = None |
| 224 | + for key in ("encoder_hidden_states", "last_hidden_state"): |
| 225 | + if key in enc_outputs: |
| 226 | + enc_hidden = enc_outputs[key] |
| 227 | + break |
| 228 | + if enc_hidden is None: |
| 229 | + raise KeyError( |
| 230 | + f"Encoder output missing hidden states. Keys: {sorted(enc_outputs.keys())}" |
| 231 | + ) |
| 232 | + |
| 233 | + # Step 2: Initialize decoder KV caches |
| 234 | + num_kv_heads = self.config.num_key_value_heads |
| 235 | + head_dim = self.config.head_dim |
| 236 | + past_kv: dict[str, np.ndarray] = {} |
| 237 | + |
| 238 | + for name in self.dec_session.input_names: |
| 239 | + if not name.startswith("past_key_values."): |
| 240 | + continue |
| 241 | + if ".cross." in name: |
| 242 | + # Cross-attention cache: starts empty, populated on first step |
| 243 | + past_kv[name] = np.zeros( |
| 244 | + (batch_size, num_kv_heads, 0, head_dim), |
| 245 | + dtype=np.float32, |
| 246 | + ) |
| 247 | + else: |
| 248 | + # Self-attention cache: grows each step |
| 249 | + past_kv[name] = np.zeros( |
| 250 | + (batch_size, num_kv_heads, 0, head_dim), |
| 251 | + dtype=np.float32, |
| 252 | + ) |
| 253 | + |
| 254 | + # Step 3: Autoregressive decode loop |
| 255 | + cur_dec_ids = np.full((batch_size, 1), decoder_start_token_id, dtype=np.int64) |
| 256 | + all_ids = cur_dec_ids.copy() |
| 257 | + |
| 258 | + for _step in range(max_new_tokens): |
| 259 | + dec_feeds: dict[str, np.ndarray] = { |
| 260 | + "input_ids": cur_dec_ids, |
| 261 | + "encoder_hidden_states": enc_hidden, |
| 262 | + "attention_mask": np.ones((batch_size, src_seq_len), dtype=np.int64), |
| 263 | + **past_kv, |
| 264 | + } |
| 265 | + |
| 266 | + outputs = self.dec_session.run(dec_feeds) |
| 267 | + |
| 268 | + # Extract logits and take argmax of last token |
| 269 | + logits = outputs["logits"] # [batch, dec_seq_len, vocab] |
| 270 | + next_token = np.argmax(logits[:, -1, :], axis=-1, keepdims=True) |
| 271 | + |
| 272 | + all_ids = np.concatenate([all_ids, next_token], axis=1) |
| 273 | + |
| 274 | + # Check EOS |
| 275 | + if eos_token_id is not None and np.all(next_token == eos_token_id): |
| 276 | + break |
| 277 | + |
| 278 | + # Update KV caches from present outputs |
| 279 | + for name in list(past_kv.keys()): |
| 280 | + # past_key_values.N.self.key → present.N.self.key |
| 281 | + layer_suffix = name.replace("past_key_values.", "") |
| 282 | + present_name = f"present.{layer_suffix}" |
| 283 | + if present_name in outputs: |
| 284 | + past_kv[name] = outputs[present_name] |
| 285 | + |
| 286 | + # Next step: only the new token |
| 287 | + cur_dec_ids = next_token.astype(np.int64) |
| 288 | + |
| 289 | + return all_ids |
| 290 | + |
| 291 | + |
167 | 292 | def torch_generate_greedy( |
168 | 293 | model, |
169 | 294 | input_ids: np.ndarray, |
|
0 commit comments