[Perf] Integrate flash-maxsim Triton kernels for late-interaction scoring (#40337)
Signed-off-by: roi.pony <roi.pony@ibm.com> Co-authored-by: roi.pony <roi.pony@ibm.com> Co-authored-by: Taneem Ibrahim <taneem.ibrahim@gmail.com>
roipony committed
Oct 1, 2026 at 21:06 UTC
e9be5323f0f864c1dc5a22a138530f3e106ebbb6
10 files changed
+508
-4
tests/v1/worker/test_late_interaction_runner.py
+105
@@ -10,6 +10,7 @@ from vllm.v1.pool.late_interaction import (
10
LATE_INTERACTION_MODE_CACHE_QUERY,
11
build_late_interaction_doc_params,
12
build_late_interaction_query_params,
13
+ compute_maxsim_score_batched,
14
)
15
from vllm.v1.pool.late_interaction_runner import LateInteractionRunner
16
@@ -152,3 +153,107 @@ def test_invalid_query_uses_raises():
153
req_ids=["query-req"],
154
finished_mask=[True],
155
)
156
+
157
+
158
+# ---------------------------------------------------------------------------
159
+# Fused Triton scoring path (PR #40337).
160
+# ---------------------------------------------------------------------------
161
+@pytest.mark.skipif(not torch.cuda.is_available(), reason="needs CUDA")
162
+def test_score_flash_matches_fp64_oracle():
163
+ """Shared-query scoring through the fused kernel must match a per-pair
164
+ fp64 MaxSim oracle on ragged doc lengths, including length-1 docs."""
165
+ torch.manual_seed(0)
166
+ d = 128
167
+ q = torch.randn(32, d, device="cuda", dtype=torch.float16)
168
+ doc_lengths = [180, 37, 512, 1, 300, 64, 1030, 256]
169
+ docs = [
170
+ torch.randn(ld, d, device="cuda", dtype=torch.float16) for ld in doc_lengths
171
+ ]
172
+
173
+ runner = LateInteractionRunner()
174
+ assert runner._flash_enabled
175
+ scores = runner._score([q] * len(docs), docs)
176
+ assert runner._flash_enabled, "kernel path must not have fallen back"
177
+
178
+ for i, doc in enumerate(docs):
179
+ ref = (q.double() @ doc.double().T).max(dim=1).values.sum().float()
180
+ torch.testing.assert_close(
181
+ scores[i].to(torch.float32), ref, atol=5e-2, rtol=1e-3
182
+ )
183
+
184
+
185
+@pytest.mark.skipif(not torch.cuda.is_available(), reason="needs CUDA")
186
+def test_score_multi_query_uses_reference_path():
187
+ """Distinct queries per pair route to the reference scorer and agree
188
+ with it exactly (no kernel dispatch for the N:N pattern)."""
189
+ torch.manual_seed(1)
190
+ d = 128
191
+ queries = [torch.randn(16, d, device="cuda") for _ in range(3)]
192
+ docs = [torch.randn(ld, d, device="cuda") for ld in (50, 3, 77)]
193
+
194
+ runner = LateInteractionRunner()
195
+ scores = runner._score(queries, docs)
196
+ ref = compute_maxsim_score_batched(queries, docs)
197
+ for s, r in zip(scores, ref):
198
+ torch.testing.assert_close(s, r)
199
+
200
+
201
+def test_score_flash_failure_disables_and_falls_back(monkeypatch):
202
+ """A kernel failure must serve the batch via the reference scorer and
203
+ disable the kernel path for the rest of the process."""
204
+ runner = LateInteractionRunner()
205
+ runner._flash_enabled = True
206
+
207
+ def _boom(query, docs):
208
+ raise RuntimeError("synthetic kernel failure")
209
+
210
+ monkeypatch.setattr(runner, "_score_flash_shared_query", _boom)
211
+ q = torch.randn(8, 32)
212
+ docs = [torch.randn(5, 32), torch.randn(7, 32)]
213
+ # CPU tensors already skip the kernel; force the dispatch condition.
214
+ monkeypatch.setattr(torch.Tensor, "is_cuda", property(lambda self: True))
215
+ scores = runner._score([q, q], docs)
216
+ assert not runner._flash_enabled
217
+ ref = compute_maxsim_score_batched([q, q], docs)
218
+ for s, r in zip(scores, ref):
219
+ torch.testing.assert_close(s, r)
220
+
221
+
222
+def test_disabled_flag_uses_reference_path():
223
+ runner = LateInteractionRunner(enable_flash=False)
224
+ assert not runner._flash_enabled
225
+
226
+
227
+# ---------------------------------------------------------------------------
228
+# Autotune config pruning.
229
+# ---------------------------------------------------------------------------
230
+def _prune(monkeypatch, budget: int, named_args: dict):
231
+ from vllm.v1.pool.flash_maxsim import _common
232
+
233
+ monkeypatch.setattr(_common, "_smem_budget", lambda: budget)
234
+ return _common._prune_configs(_common._get_configs(), named_args)
235
+
236
+
237
+def _est(cfg, d_pad: int) -> int:
238
+ bq, bd = cfg.kwargs["BLOCK_Q"], cfg.kwargs["BLOCK_D"]
239
+ return (bq * d_pad + bd * d_pad) * 2 + bq * bd * 4
240
+
241
+
242
+def test_prune_configs_uses_padded_dim(monkeypatch):
243
+ """d=513 pads to 1024: survivors must fit the budget at d_pad, not at
244
+ the un-padded d (which would admit ~2x-oversized tiles)."""
245
+ budget = 166_912 # A100 opt-in
246
+ survivors = _prune(monkeypatch, budget, {"Lq": 1024, "d": 513, "d_pad": 1024})
247
+ assert survivors
248
+ assert all(_est(c, 1024) <= budget for c in survivors)
249
+
250
+
251
+def test_prune_configs_no_rejected_fallback(monkeypatch):
252
+ """When nothing fits, the fallback is the single smallest-footprint
253
+ config — never a slice of rejected configs."""
254
+ from vllm.v1.pool.flash_maxsim import _common
255
+
256
+ survivors = _prune(monkeypatch, 10_000, {"Lq": 1024, "d_pad": 1024})
257
+ assert len(survivors) == 1
258
+ smallest = min(_common._get_configs(), key=lambda c: _est(c, 1024))
259
+ assert survivors[0].kwargs == smallest.kwargs
vllm/config/pooler.py
+8
@@ -63,6 +63,14 @@ class PoolerConfig:
63
`None` uses the pooler's default, which is `True` in most cases.
64
"""
65
66
+ enable_flash_late_interaction: bool = True
67
+ """
68
+ Whether the engine-side late-interaction scorer may use the fused
69
+ flash-maxsim Triton kernel. Disabled automatically when the API server
70
+ is started with `--no-enable-flash-late-interaction`; the reference
71
+ scorer is used instead.
72
+ """
73
+
74
## for embedding models
75
dimensions: int | None = None
76
"""
vllm/entrypoints/cli/serve.py
+3
@@ -13,6 +13,7 @@ from vllm.entrypoints.cli.types import CLISubcommand
13
from vllm.entrypoints.launchers.api_server.entry import run_server, setup_server
14
from vllm.entrypoints.launchers.cli_args import (
15
make_arg_parser,
16
+ propagate_flash_late_interaction,
17
validate_parsed_serve_args,
18
)
19
from vllm.entrypoints.launchers.dp_supervisor import run_dp_supervisor
@@ -185,6 +186,7 @@ def run_headless(args: argparse.Namespace):
186
187
# Create the EngineConfig.
188
engine_args = vllm.AsyncEngineArgs.from_cli_args(args)
189
+ propagate_flash_late_interaction(args, engine_args)
190
usage_context = UsageContext.OPENAI_API_SERVER
191
vllm_config = engine_args.create_engine_config(
192
usage_context=usage_context, headless=True
@@ -295,6 +297,7 @@ def run_multi_api_server(args: argparse.Namespace):
297
listen_address, sock = setup_server(args, reuse_port=num_api_servers > 1)
298
299
engine_args = vllm.AsyncEngineArgs.from_cli_args(args)
300
+ propagate_flash_late_interaction(args, engine_args)
301
engine_args._api_process_count = num_api_servers
302
engine_args._api_process_rank = -1
303
vllm/entrypoints/launchers/api_server/entry.py
+3
@@ -51,6 +51,9 @@ async def build_async_engine_client(
51
# Context manager to handle engine_client lifecycle
52
# Ensures everything is shutdown and cleaned up on error/exit
53
engine_args = AsyncEngineArgs.from_cli_args(args)
54
+ from ..cli_args import propagate_flash_late_interaction
55
+
56
+ propagate_flash_late_interaction(args, engine_args)
57
if client_config:
58
engine_args._api_process_count = client_config.get("client_count", 1)
59
engine_args._api_process_rank = client_config.get("client_index", 0)
vllm/entrypoints/launchers/cli_args.py
+21
-1
@@ -363,7 +363,10 @@ class FrontendArgs(BaseFrontendArgs):
363
"""
364
enable_flash_late_interaction: bool = True
365
"""If set, run pooling score MaxSim on GPU in the API server process.
366
- Can significantly improve late-interaction scoring performance."""
366
+ Can significantly improve late-interaction scoring performance.
367
+ When disabled, the setting is also propagated to the engine's
368
+ `PoolerConfig`, so the engine-side scorer uses the reference MaxSim
369
+ path instead of the fused Triton kernel."""
370
371
@classmethod
372
def _customize_cli_kwargs(
@@ -498,3 +501,20 @@ def create_parser_for_docs() -> FlexibleArgumentParser:
501
prog="-m vllm.entrypoints.launchers.api_server.entry"
502
)
503
return make_arg_parser(parser_for_docs)
504
+
505
+
506
+def propagate_flash_late_interaction(args, engine_args) -> None:
507
+ """Propagate `--no-enable-flash-late-interaction` into the engine config.
508
+
509
+ The frontend flag alone only disables the API-server scoring path;
510
+ mirroring it into `PoolerConfig.enable_flash_late_interaction` lets the
511
+ engine-side scorer fall back to the reference MaxSim path too.
512
+ """
513
+ if getattr(args, "enable_flash_late_interaction", True):
514
+ return
515
+ from vllm.config.pooler import PoolerConfig
516
+
517
+ if engine_args.pooler_config is None:
518
+ engine_args.pooler_config = PoolerConfig(enable_flash_late_interaction=False)
519
+ else:
520
+ engine_args.pooler_config.enable_flash_late_interaction = False
vllm/v1/pool/flash_maxsim/__init__.py
new
+7
@@ -0,0 +1,7 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+"""Fused Triton kernels for late-interaction (MaxSim) scoring."""
4
+
5
+from .flash_maxsim_rerank import flash_maxsim_rerank_direct
6
+
7
+__all__ = ["flash_maxsim_rerank_direct"]
vllm/v1/pool/flash_maxsim/_common.py
new
+85
@@ -0,0 +1,85 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+"""Shared utilities for flash-maxsim kernels: padding, autotune configs,
4
+and pruning."""
5
+
6
+from vllm.triton_utils import triton
7
+
8
+
9
+def _next_pow2(x: int) -> int:
10
+ return 1 << (x - 1).bit_length()
11
+
12
+
13
+def _get_configs() -> list:
14
+ """Autotune grid over (BLOCK_Q, BLOCK_D); `_prune_configs` filters by
15
+ SMEM budget and Lq at runtime, so one list is safe across GPUs."""
16
+ return [
17
+ triton.Config({"BLOCK_Q": 16, "BLOCK_D": 16}, num_warps=2, num_stages=2),
18
+ triton.Config({"BLOCK_Q": 16, "BLOCK_D": 32}, num_warps=2, num_stages=2),
19
+ triton.Config({"BLOCK_Q": 32, "BLOCK_D": 16}, num_warps=2, num_stages=2),
20
+ triton.Config({"BLOCK_Q": 32, "BLOCK_D": 32}, num_warps=4, num_stages=2),
21
+ triton.Config({"BLOCK_Q": 32, "BLOCK_D": 64}, num_warps=4, num_stages=2),
22
+ triton.Config({"BLOCK_Q": 32, "BLOCK_D": 128}, num_warps=8, num_stages=2),
23
+ triton.Config({"BLOCK_Q": 64, "BLOCK_D": 64}, num_warps=4, num_stages=2),
24
+ triton.Config({"BLOCK_Q": 64, "BLOCK_D": 128}, num_warps=8, num_stages=1),
25
+ triton.Config({"BLOCK_Q": 128, "BLOCK_D": 64}, num_warps=8, num_stages=1),
26
+ triton.Config({"BLOCK_Q": 128, "BLOCK_D": 128}, num_warps=8, num_stages=1),
27
+ ]
28
+
29
+
30
+def _smem_budget() -> int:
31
+ """Per-block opt-in shared-memory budget of the active device (bytes):
32
+ Triton driver query, else a conservative capability table."""
33
+ try:
34
+ driver = triton.runtime.driver.active
35
+ props = driver.utils.get_device_properties(driver.get_current_device())
36
+ return int(props["max_shared_mem"])
37
+ except Exception:
38
+ try:
39
+ from vllm.platforms import current_platform
40
+
41
+ cap = current_platform.get_device_capability()
42
+ if cap is not None:
43
+ if cap.major >= 9:
44
+ return 232_448 # Hopper+: 227 KB opt-in
45
+ if cap.major == 8:
46
+ return 101_376 # worst sm_8x (86/89); A100 has more
47
+ except Exception:
48
+ pass
49
+ return 98_304 # conservative pre-Ampere default
50
+
51
+
52
+def _prune_configs(configs, named_args, **kwargs):
53
+ """Reject configs that exceed the GPU's shared-memory budget.
54
+
55
+ BLOCK_Q and BLOCK_D load fp16 tiles of size [BLOCK_*, d_pad] — the
56
+ kernels index with ``tl.arange(0, d_pad)``, so the estimate must use
57
+ the padded dimension, not the real one (d=513 pads to 1024 and
58
+ nearly doubles the tile) — and the [BLOCK_Q, BLOCK_D] score tile is
59
+ fp32. Total:
60
+ (BLOCK_Q*d_pad + BLOCK_D*d_pad)*2 + BLOCK_Q*BLOCK_D*4 bytes.
61
+ """
62
+ Lq = named_args.get("Lq", 32)
63
+ d_pad = named_args.get("d_pad")
64
+ if d_pad is None:
65
+ d_pad = _next_pow2(max(named_args.get("d", 128), 16))
66
+ smem_limit = _smem_budget()
67
+
68
+ def _est(cfg):
69
+ bq = cfg.kwargs["BLOCK_Q"]
70
+ bd = cfg.kwargs["BLOCK_D"]
71
+ return (bq * d_pad + bd * d_pad) * 2 + bq * bd * 4
72
+
73
+ pruned = []
74
+ for cfg in configs:
75
+ if cfg.kwargs["BLOCK_Q"] > Lq * 2:
76
+ continue
77
+ if _est(cfg) > smem_limit:
78
+ continue
79
+ pruned.append(cfg)
80
+ if pruned:
81
+ return pruned
82
+ # Nothing fits the estimate (large d_pad on a small-SMEM device).
83
+ # Return the single smallest-footprint config — a deterministic
84
+ # least-bad launch attempt — never arbitrary rejected configs.
85
+ return [min(configs, key=_est)]
vllm/v1/pool/flash_maxsim/flash_maxsim_rerank.py
new
+204
@@ -0,0 +1,204 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+"""Flash-MaxSim rerank: one query vs variable-length docs in a packed tensor.
4
+
5
+The kernel reads doc embeddings directly from a packed [total_tokens, d]
6
+tensor using per-doc (offset, length) pairs — no padding, no per-doc
7
+tensors, no materialised [B, Lq, Ld] similarity tensor.
8
+
9
+Entry point:
10
+ flash_maxsim_rerank_direct(Q, batch_tensor, doc_offsets, doc_lengths, max_ld)
11
+ doc_offsets[i] = start token index of doc i in batch_tensor.
12
+ doc_lengths[i] = number of tokens in doc i.
13
+ Docs can be scattered (non-contiguous) in the batch tensor.
14
+"""
15
+
16
+import torch
17
+
18
+from vllm.triton_utils import tl, triton
19
+
20
+from ._common import _get_configs, _next_pow2, _prune_configs
21
+
22
+
23
+def _round_to_bucket(x):
24
+ """Round up to nearest bucket: 32, 64, 128, 256, 512, 1024, 2048, 4096."""
25
+ for b in [32, 64, 128, 256, 512, 1024, 2048, 4096]:
26
+ if x <= b:
27
+ return b
28
+ return _next_pow2(x)
29
+
30
+
31
+# Kernel: shared Q, scattered D via (offset, length) per doc
32
+
33
+
34
+@triton.autotune(
35
+ configs=_get_configs(),
36
+ key=["Lq_bucket", "max_Ld_bucket", "d_pad"],
37
+ prune_configs_by={"early_config_prune": _prune_configs},
38
+)
39
+@triton.jit
40
+def _maxsim_rerank_kernel(
41
+ Q_ptr,
42
+ D_ptr,
43
+ doc_offsets_ptr,
44
+ doc_lengths_ptr,
45
+ scores_ptr,
46
+ B,
47
+ Lq,
48
+ Lq_bucket: tl.constexpr,
49
+ max_Ld_bucket: tl.constexpr,
50
+ d: tl.constexpr,
51
+ d_pad: tl.constexpr,
52
+ stride_q_t,
53
+ stride_q_d,
54
+ stride_d_t,
55
+ stride_d_d,
56
+ BLOCK_Q: tl.constexpr,
57
+ BLOCK_D: tl.constexpr,
58
+):
59
+ """One program per doc. Q shared (read from same location). D scattered."""
60
+ pid = tl.program_id(0)
61
+ if pid >= B:
62
+ return
63
+
64
+ # Each doc has its own offset and length in the batch tensor.
65
+ # int64: (d_start + d_off) * stride overflows int32 past ~16.7M batch
66
+ # tokens (same widen-on-load as the pairs kernel below; the host-side
67
+ # offsets tensor stays int32, which indexes any realistic batch).
68
+ d_start = tl.load(doc_offsets_ptr + pid).to(tl.int64)
69
+ d_len = tl.load(doc_lengths_ptr + pid).to(tl.int32)
70
+
71
+ k_off = tl.arange(0, d_pad)
72
+ k_mask = k_off < d
73
+ score_acc = tl.zeros([], dtype=tl.float32)
74
+
75
+ # Q is shared — always read from start of Q_ptr
76
+ for q_blk in tl.static_range(0, Lq_bucket, BLOCK_Q):
77
+ q_off = q_blk + tl.arange(0, BLOCK_Q)
78
+ q_valid = q_off < Lq
79
+
80
+ Q_block = tl.load(
81
+ Q_ptr + q_off[:, None] * stride_q_t + k_off[None, :] * stride_q_d,
82
+ mask=q_valid[:, None] & k_mask[None, :],
83
+ other=0.0,
84
+ ).to(tl.float16)
85
+
86
+ m = tl.full([BLOCK_Q], float("-inf"), dtype=tl.float32)
87
+
88
+ for d_blk in range(0, max_Ld_bucket, BLOCK_D):
89
+ d_off = d_blk + tl.arange(0, BLOCK_D)
90
+ d_valid = d_off < d_len
91
+
92
+ D_block = tl.load(
93
+ D_ptr
94
+ + (d_start + d_off[:, None]) * stride_d_t
95
+ + k_off[None, :] * stride_d_d,
96
+ mask=d_valid[:, None] & k_mask[None, :],
97
+ other=0.0,
98
+ ).to(tl.float16)
99
+
100
+ S = tl.dot(Q_block, tl.trans(D_block))
101
+ S = tl.where(d_valid[None, :], S, float("-inf"))
102
+ m = tl.maximum(m, tl.max(S, axis=1))
103
+
104
+ m = tl.where(q_valid, m, 0.0)
105
+ score_acc += tl.sum(m)
106
+
107
+ tl.store(scores_ptr + pid, score_acc)
108
+
109
+
110
+# Internal helper
111
+
112
+
113
+def _run_rerank_kernel(Q, D_tensor, doc_offsets, doc_lengths, max_seqlen_d):
114
+ """Common kernel launch for both rerank and rerank_direct."""
115
+ assert Q.is_cuda, f"Q must be on CUDA, got {Q.device}"
116
+ assert D_tensor.is_cuda, f"D must be on CUDA, got {D_tensor.device}"
117
+ Lq, d = Q.shape
118
+ B = doc_offsets.shape[0]
119
+ d_pad = _next_pow2(max(d, 16))
120
+
121
+ Q = Q.contiguous().half()
122
+ # D_tensor is NOT copied — kernel reads from it directly
123
+ if not D_tensor.is_contiguous():
124
+ D_tensor = D_tensor.contiguous()
125
+ if D_tensor.dtype != torch.float16:
126
+ D_tensor = D_tensor.half()
127
+
128
+ doc_offsets = doc_offsets.to(torch.int32).contiguous()
129
+ doc_lengths = doc_lengths.to(torch.int32).contiguous()
130
+ scores = torch.empty(B, device=Q.device, dtype=torch.float32)
131
+
132
+ if B == 0:
133
+ return scores
134
+
135
+ # Pad Q dimension if needed (Q is small, safe to copy).
136
+ # D is NOT padded — the kernel uses k_mask to handle d < d_pad.
137
+ # This preserves zero-copy: D_tensor is read in-place.
138
+ if d < d_pad:
139
+ Q_pad = torch.zeros(Lq, d_pad, device=Q.device, dtype=Q.dtype)
140
+ Q_pad[:, :d] = Q
141
+ else:
142
+ Q_pad = Q
143
+ D_pad = D_tensor
144
+
145
+ Lq_bucket = _round_to_bucket(Lq)
146
+ max_ld_bucket = _round_to_bucket(max_seqlen_d)
147
+
148
+ _maxsim_rerank_kernel[(B,)](
149
+ Q_pad,
150
+ D_pad,
151
+ doc_offsets,
152
+ doc_lengths,
153
+ scores,
154
+ B,
155
+ Lq,
156
+ Lq_bucket,
157
+ max_ld_bucket,
158
+ d,
159
+ d_pad,
160
+ Q_pad.stride(0),
161
+ Q_pad.stride(1),
162
+ D_pad.stride(0),
163
+ D_pad.stride(1),
164
+ )
165
+ return scores
166
+
167
+
168
+# Public API
169
+
170
+
171
+def flash_maxsim_rerank_direct(
172
+ Q: torch.Tensor,
173
+ batch_tensor: torch.Tensor,
174
+ doc_offsets: torch.Tensor,
175
+ doc_lengths: torch.Tensor,
176
+ max_seqlen_d: int,
177
+) -> torch.Tensor:
178
+ """TRUE zero-copy: score query against docs scattered in a batch tensor.
179
+
180
+ The kernel reads doc embeddings directly from batch_tensor at the
181
+ positions specified by doc_offsets. No torch.stack, no torch.cat,
182
+ no copy of any kind. The batch tensor is the model's output.
183
+
184
+ Memory for doc scoring: 0 bytes additional.
185
+
186
+ Args:
187
+ Q: [Lq, d] — single query embedding (from cache)
188
+ batch_tensor: [total_tokens, d] — the model's projected output tensor.
189
+ Contains ALL requests' tokens (queries + docs + others).
190
+ doc_offsets: [B] int32 — start token index of each doc in batch_tensor
191
+ doc_lengths: [B] int32 — number of tokens per doc
192
+ max_seqlen_d: int — max(doc_lengths)
193
+
194
+ Returns:
195
+ scores: [B] float32 — one MaxSim score per document
196
+
197
+ """
198
+ assert Q.dim() == 2, f"Q must be 2D [Lq, d], got {Q.dim()}D"
199
+ assert batch_tensor.dim() == 2, (
200
+ f"batch_tensor must be 2D, got {batch_tensor.dim()}D"
201
+ )
202
+ assert Q.shape[1] == batch_tensor.shape[1]
203
+
204
+ return _run_rerank_kernel(Q, batch_tensor, doc_offsets, doc_lengths, max_seqlen_d)
vllm/v1/pool/late_interaction_runner.py
+67
-2
@@ -4,6 +4,7 @@ from collections.abc import Iterable
4
5
import torch
6
7
+from vllm.logger import init_logger
8
from vllm.pooling_params import PoolingParams
9
from vllm.v1.outputs import PoolerOutput
10
from vllm.v1.pool.late_interaction import (
@@ -12,17 +13,29 @@ from vllm.v1.pool.late_interaction import (
13
compute_maxsim_score_batched,
14
)
15
16
+logger = init_logger(__name__)
17
+
18
19
class LateInteractionRunner:
20
"""Worker-side state and postprocessing for late-interaction scoring."""
21
19
- def __init__(self) -> None:
22
+ def __init__(self, enable_flash: bool = True) -> None:
23
# query_key -> token embeddings for late-interaction scoring.
24
self._query_cache: dict[str, torch.Tensor] = {}
25
# query_key -> remaining number of docs that should use this query.
26
self._query_uses: dict[str, int] = {}
27
# doc request id -> query key.
28
self._doc_query_keys: dict[str, str] = {}
29
+ # Fused Triton scoring (PoolerConfig.enable_flash_late_interaction
30
+ # gates it; a runtime kernel failure disables it for the process).
31
+ if enable_flash:
32
+ try:
33
+ from vllm.v1.pool.flash_maxsim import ( # noqa: F401
34
+ flash_maxsim_rerank_direct,
35
+ )
36
+ except ImportError:
37
+ enable_flash = False
38
+ self._flash_enabled = enable_flash
39
40
def clear(self) -> None:
41
self._query_cache.clear()
@@ -116,7 +129,7 @@ class LateInteractionRunner:
129
raise ValueError(f"Unsupported late-interaction mode: {mode!r}")
130
131
if score_indices:
119
- score_values = compute_maxsim_score_batched(score_queries, score_docs)
132
+ score_values = self._score(score_queries, score_docs)
133
for i, req_id, query_key, score in zip(
134
score_indices, score_req_ids, score_query_keys, score_values
135
):
@@ -126,6 +139,58 @@ class LateInteractionRunner:
139
140
return outputs
141
142
+ def _score(
143
+ self,
144
+ queries: list[torch.Tensor],
145
+ docs: list[torch.Tensor],
146
+ ) -> list[torch.Tensor]:
147
+ """Score (query_i, doc_i) pairs: the fused Triton kernel handles the
148
+ common rerank pattern (one shared query, CUDA inputs); multiple
149
+ distinct queries, CPU tensors, or a kernel failure fall back to the
150
+ reference scorer."""
151
+ if self._flash_enabled and docs and queries[0].is_cuda:
152
+ first = queries[0]
153
+ if all(q is first for q in queries):
154
+ try:
155
+ return self._score_flash_shared_query(first, docs)
156
+ except Exception as exc:
157
+ # A persistent compile/launch failure must not take down
158
+ # requests: serve through the reference scorer and stop
159
+ # trying the kernel for the rest of the process.
160
+ self._flash_enabled = False
161
+ logger.warning(
162
+ "flash-maxsim scoring failed (%s); falling back to "
163
+ "the reference MaxSim path for this process.",
164
+ exc,
165
+ )
166
+ return compute_maxsim_score_batched(queries, docs)
167
+
168
+ @staticmethod
169
+ def _score_flash_shared_query(
170
+ query: torch.Tensor,
171
+ docs: list[torch.Tensor],
172
+ ) -> list[torch.Tensor]:
173
+ """One kernel launch for all docs against a single shared query:
174
+ docs pack into one [total_tokens, d] tensor (single cat, no padding)
175
+ and the kernel reads each by (offset, length) — the [B, Lq, Ld]
176
+ similarity tensor is never materialised."""
177
+ from vllm.v1.pool.flash_maxsim import flash_maxsim_rerank_direct
178
+
179
+ device = docs[0].device
180
+ lengths = [int(d.shape[0]) for d in docs]
181
+ offsets = [0] * len(lengths)
182
+ for i in range(1, len(lengths)):
183
+ offsets[i] = offsets[i - 1] + lengths[i - 1]
184
+ packed = docs[0] if len(docs) == 1 else torch.cat(docs, dim=0)
185
+ scores = flash_maxsim_rerank_direct(
186
+ query,
187
+ packed,
188
+ torch.tensor(offsets, device=device, dtype=torch.int32),
189
+ torch.tensor(lengths, device=device, dtype=torch.int32),
190
+ max(lengths),
191
+ )
192
+ return list(scores.unbind(0))
193
+
194
def _release_query_use(self, query_key: str) -> None:
195
remaining = self._query_uses.get(query_key, 1) - 1
196
if remaining <= 0:
vllm/v1/worker/gpu/pool/pooling_runner.py
+5
-1
@@ -51,7 +51,11 @@ class PoolingRunner:
51
self.pooling_params: dict[int, PoolingParams] = {}
52
self.pooling_states: dict[int, PoolingStates] = {}
53
self.prompt_token_ids: dict[int, torch.Tensor] = {}
54
- self.late_interaction_runner = LateInteractionRunner()
54
+ pooler_config = self.model_config.pooler_config
55
+ self.late_interaction_runner = LateInteractionRunner(
56
+ enable_flash=pooler_config is None
57
+ or getattr(pooler_config, "enable_flash_late_interaction", True)
58
+ )
59
60
@staticmethod
61
def get_supported_tasks(model: nn.Module) -> list[PoolingTask]: