NPUW: Batched scoring as a composable element (reranker / embedding)#36375
NPUW: Batched scoring as a composable element (reranker / embedding)#36375dylanneve1 wants to merge 7 commits into
Conversation
f52ab66 to
5f0c2f0
Compare
There was a problem hiding this comment.
Pull request overview
Adds an NPUW v1 “batched” element that enables batch>1 scoring/embedding by unrolling a batched infer into per-row batch-1 inner inferences (resetting variable state between rows) and stacking outputs back into batched tensors. This is wired into LLMCompiledModel::create_sync_infer_request() behind a new opt-in NPUW_LLM_BATCH config option, and covered by new unit tests.
Changes:
- Introduce
ov::npuw::batched::{CompiledModel, InferRequest}decorator implementing row-wise batch unrolling for batch-1 inner requests. - Add
NPUW_LLM_BATCHoption and wrap LLM/embedding sync infer requests with the batched element when enabled. - Add unit tests for the batched element and wire the new source into the unit test build.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| src/plugins/intel_npu/tests/unit/npuw/batched_element_test.cpp | Adds unit coverage for batched element behavior (per-row slicing, state reset, output stacking, disabled path). |
| src/plugins/intel_npu/tests/unit/CMakeLists.txt | Links batched.cpp into the unit test target. |
| src/plugins/intel_npu/src/plugin/npuw/v1/elements/batched.hpp | Declares the new batched element compiled model + infer request decorator API. |
| src/plugins/intel_npu/src/plugin/npuw/v1/elements/batched.cpp | Implements batched unrolling, ROI slicing, per-row state reset, and output aggregation. |
| src/plugins/intel_npu/src/plugin/npuw/llm_compiled_model.cpp | Adds the NPUW_LLM_BATCH opt-in wrapping for sync infer request creation and exposes the property binding. |
| src/plugins/intel_npu/src/al/include/intel_npu/config/npuw_option_defs.inc | Defines the new NPUW_LLM_BATCH configuration option (default off). |
dfebddb to
b126060
Compare
b126060 to
f89467d
Compare
f89467d to
408eb96
Compare
a84e44c to
fa76732
Compare
275d1e5 to
007c23c
Compare
On NPU a reranker runs through NPUW, which compiles the model with a static batch dimension of 1. A single rerank() call scoring N documents therefore could not be served by one batched infer. Add utils::update_npu_config_text_rerank (mirroring the text embedding NPU config helper) and call it from TextRerankPipeline when the model targets NPU. It sets NPU_USE_NPUW/NPUW_LLM plus NPUW_TEXT_RERANK, which tags the model as a non-generating scoring pipeline so the plugin wraps the request in its batched-scoring element: a [N, L] batch is unrolled into N independent [1, L] inferences (resetting the KV-cache between rows) and stacked back into [N, ...]. Rows are scored independently, so the aggregated output matches per-row scoring. Unlike embedding, a reranker is a standard causal LM (no prefill-only rebuild), and the batched element derives the batch size from the input tensor, so no batch/seq-len/prompt-length overrides are set -- only the rerank tag. Requires the NPUW NPUW_TEXT_RERANK option (openvinotoolkit/openvino#36375).
087550c to
1de0a50
Compare
On NPU a reranker runs through NPUW, which compiles the model with a static batch dimension of 1. A single rerank() call scoring N documents therefore could not be served by one batched infer. Add utils::update_npu_config_text_rerank (mirroring the text embedding NPU config helper) and call it from TextRerankPipeline when the model targets NPU. It sets NPU_USE_NPUW/NPUW_LLM plus NPUW_TEXT_RERANK, which tags the model as a non-generating scoring pipeline so the plugin wraps the request in its batched-scoring element: a [N, L] batch is unrolled into N independent [1, L] inferences (resetting the KV-cache between rows) and stacked back into [N, ...]. Rows are scored independently, so the aggregated output matches per-row scoring. Unlike embedding, a reranker is a standard causal LM (no prefill-only rebuild), and the batched element derives the batch size from the input tensor, so no batch/seq-len/prompt-length overrides are set -- only the rerank tag. Requires the NPUW NPUW_TEXT_RERANK option (openvinotoolkit/openvino#36375).
1de0a50 to
efaf895
Compare
42450a3 to
3d557bb
Compare
On NPU a reranker runs through NPUW, which compiles the model with a static batch dimension of 1. A single rerank() call scoring N documents therefore could not be served by one batched infer. Add utils::update_npu_config_text_rerank (mirroring the text embedding NPU config helper) and call it from TextRerankPipeline when the model targets NPU. It sets NPU_USE_NPUW/NPUW_LLM plus NPUW_TEXT_RERANK, which tags the model as a non-generating scoring pipeline so the plugin wraps the request in its batched-scoring element: a [N, L] batch is unrolled into N independent [1, L] inferences (resetting the KV-cache between rows) and stacked back into [N, ...]. Rows are scored independently, so the aggregated output matches per-row scoring. Unlike embedding, a reranker is a standard causal LM (no prefill-only rebuild), and the batched element derives the batch size from the input tensor, so no batch/seq-len/prompt-length overrides are set -- only the rerank tag. Requires the NPUW NPUW_TEXT_RERANK option (openvinotoolkit/openvino#36375).
08d8b3c to
b43cad7
Compare
| bool has_batch_dim(const ov::SoPtr<ov::ITensor>& tensor) { | ||
| return !tensor->get_shape().empty(); | ||
| } |
There was a problem hiding this comment.
I am not sure if that's the right way to detect this
| init_public_tensors(); | ||
| } | ||
|
|
||
| void ov::npuw::batched::InferRequest::init_public_tensors() { |
| } else { | ||
| shape = pshape.to_shape(); | ||
| } | ||
| set_tensor(port, ov::get_tensor_impl(ov::Tensor(port.get_element_type(), shape))); |
There was a problem hiding this comment.
these are CPU tensors by default. Also, why should we do this?
| // Batch is the largest leading dim across inputs, so a shared [1, ...] input is broadcast | ||
| // rather than mistaken for the batch. Stays 1 when nothing is batched. | ||
| std::size_t batch = 1; | ||
| bool any_batched = false; | ||
| for (const auto& tensor : in_tensors) { | ||
| if (!has_batch_dim(tensor)) { | ||
| continue; | ||
| } | ||
| const std::size_t in_batch = tensor->get_shape()[0]; | ||
| batch = any_batched ? std::max(batch, in_batch) : in_batch; | ||
| any_batched = true; | ||
| } |
There was a problem hiding this comment.
This sounds overcomplicated
| for (std::size_t row = 0; row < batch; ++row) { | ||
| reset_inner_state(); | ||
| bind_row(row); | ||
| inner_infer(); | ||
|
|
||
| for (std::size_t i = 0; i < wrapper_outputs.size(); ++i) { | ||
| const auto inner_out = inner_get_tensor(out_ports[i]); | ||
| if (batch == 1) { | ||
| aggregated_outputs[i] = inner_out; | ||
| continue; | ||
| } | ||
| if (!aggregated_outputs[i]) { | ||
| ov::Shape out_shape = inner_out->get_shape(); | ||
| if (!out_shape.empty()) { | ||
| out_shape[0] = batch; | ||
| } | ||
| aggregated_outputs[i] = ov::get_tensor_impl(ov::Tensor(inner_out->get_element_type(), out_shape)); | ||
| } | ||
| if (has_batch_dim(inner_out)) { | ||
| inner_out->copy_to(row_slice(aggregated_outputs[i], row)._ptr); | ||
| } else { | ||
| inner_out->copy_to(aggregated_outputs[i]._ptr); | ||
| } | ||
| } | ||
| } | ||
|
|
There was a problem hiding this comment.
not sure what this handling is for here, too.
why do we need aggregated outputs? Don't you produce [N,...] tensor for [N,...] input?
| m_kvcache_desc.dim & m_kvcache_desc.max_generation_token_len & m_kvcache_desc.v_tensors_transposed_pre & | ||
| m_kvcache_desc.v_tensors_transposed_gen & m_prefill_chunk_size & m_use_chunk_prefill & m_max_lora_rank & | ||
| m_enable_prefix_caching & m_prefix_caching_block_size & m_prefix_caching_max_num_blocks & m_is_whisper & | ||
| m_eos_token_id & m_decomposed_sdpa_size & m_is_eagle & m_is_embedding & m_is_block_kv_cache; | ||
| m_eos_token_id & m_decomposed_sdpa_size & m_is_eagle & m_is_embedding & m_is_block_kv_cache & m_is_rerank; |
There was a problem hiding this comment.
we'll never make this compatible :(
| auto self = std::static_pointer_cast<const ov::ICompiledModel>(shared_from_this()); | ||
| return std::make_shared<ov::npuw::batched::InferRequest>(self, std::move(inner)); |
There was a problem hiding this comment.
this is strange. Batched is supposed to wrap the LLMCompiledModel for this particular rerank case. Why we have LLMCompiledModel wrapping itself here instead? This order doesn't look correct.
| auto use_text_embed_key = pop_option(other_props, std::string("NPUW_TEXT_EMBED")); | ||
| m_is_embedding = use_text_embed_key.value_or(false).as<bool>() == true; | ||
|
|
||
| auto use_text_rerank_key = pop_option(other_props, std::string("NPUW_TEXT_RERANK")); | ||
| m_is_rerank = use_text_rerank_key.value_or(false).as<bool>() == true; |
There was a problem hiding this comment.
I don't see where the Batched::CompiledModel is instantiated. I suppose it should be at npuw::CompiledModel::create as that's the entry point.
3ec8588 to
0c266a9
Compare
|
Tests passed, currently running smoke test |
Introduce ov::npuw::batched, a v1/elements decorator (alongside failsafe and accuracy_checked) that adds batch > 1 support on top of an inner compiled model that only supports a static batch size of 1, as NPUW's LLM and embedding pipelines do. It is a fan-out element: a single [N, ...] inference is unrolled into N independent [1, ...] inferences on the inner request, the inner variable state (KV-cache) is reset between rows, and per-row outputs are stacked back into [N, ...] along axis 0. This is exact for single-shot scoring workloads with independent rows (text reranking, text embedding); generation is not a valid inner. infer() rejects a zero-sized batch and validates that every batched input's batch dimension is either N or 1 (shared/broadcast). The element composes on top of the other elements and the inner request needs no awareness of batching. create() returns the inner unwrapped when disabled.
Wrap the single-sequence request created by LLMCompiledModel with the batched element so a [N, ...] scoring inference is unrolled into N independent [1, ...] inferences and stacked back. This makes GenAI's text reranking and text embedding pipelines work for more than one document/text on NPU through the unchanged batch-1 compiled graphs, with no application-side per-row loop. The element is enabled automatically for the non-generating scoring pipelines -- text embedding (m_is_embedding, already known) and text reranking (m_is_rerank, set from a new NPUW_TEXT_RERANK key mirroring NPUW_TEXT_EMBED) -- rather than via an application-facing option. A reranker is a plain causal LM, indistinguishable from chat at the plugin level, so the pipeline tags it; the KV-reset-between-rows element is therefore never wired into generation. The inner request is driven synchronously so it runs on the calling thread instead of being re-scheduled onto this model's task executor (nesting deadlocks).
Add unit tests for ov::npuw::batched driving the element through a synthetic Qwen-style reranker (build_reranker_test_model, added to llm_test_helpers.hpp: GQA + RMSNorm + per-head QK-norm) and a recording mock inner. The element is the unit under test; only the inner compiled model is mocked, keeping the tests deviceless and able to observe the per-row infer/reset. The mock echoes each row's first token, so the tests assert the element's core guarantee directly -- output row r reflects input row r -- across the disabled, enabled, multi-row, single-row, zero-batch and batch-mismatch paths. Following failsafe.cpp / accuracy_checked.cpp, an ordered event log checks the per-row state reset and the rejection paths assert the error message.
…le-row fast path, more tests Address review feedback on the batched scoring element: - Detect the batch as the largest leading dimension across inputs instead of the first input that has one, so a leading [1, ...] broadcast input no longer pins the batch to 1 when a non-leading input carries the real batch N. - Skip the aggregation buffer and per-row copy for a single row (publish the inner output as-is). - Snapshot the public input tensors once per infer() rather than re-fetching them for detection, validation, and per-row binding. - Tighten the comments on the empty check_tensors() and the output stacking. Tests: - SyncInnerConstructorScoresEachRow: exercises the sync-inner constructor used by the production LLM/rerank wiring (previously only the async-inner path was covered). - BroadcastInputSharedAcrossRows: a [1, ...] input is fed to every row unsliced. - BatchSizeFromNonLeadingInput: regression for the detection fix above. - MultipleOutputsStackedIndependently: each model output is stacked into its own [N, ...] tensor. All NPUWBatchedElementTest cases pass.
Rework the batched scoring element per review: - Instantiate batched::CompiledModel at the NPUW entry points -- npuw::ICompiledModel::create() on compilation and the plugin's NPUW import path on blob import -- wrapping the LLMCompiledModel built right there from the same model, instead of LLMCompiledModel wrapping its own infer request from the inside. - Make the element a true npuw::ICompiledModel decorator: inputs() and outputs() forward to the inner model, so the wrapper needs no ov::Model of its own (works on import too) and the request operates on the very same port objects as the inner. - Drop the sync/async inner-request duality: the request holds a single inner IAsyncInferRequest; its infer() runs inline on the calling thread, so no executor nesting is possible in the first place. - Drop init_public_tensors(): instead of allocating host tensors for every port, the request surfaces the inner request's own tensors as the defaults; nothing is allocated up front. - Use ov::npuw::util::view() for row slicing, simplify batch detection (batch is the largest leading dim; leading-dim-1 inputs are shared), and write rows straight into the [N, ...] public output tensors, reusing a caller-bound output that already fits. - Un-serialize the rerank flag: NPUW_TEXT_RERANK is consumed at the entry points, so LLMCompiledModel no longer carries m_is_rerank and the blob format / NPUW_SERIALIZATION_VERSION stay untouched. - Rework the unit tests for the new composition; add coverage for batched::requested(), I/O forwarding and pre-bound output reuse.
0c266a9 to
d2e03b8
Compare
AlexanderKalistratov
left a comment
There was a problem hiding this comment.
Is batch size decided at model compilation time or it is purely dynamic and depends on inputs batch?
| std::shared_ptr<ov::npuw::ICompiledModel> ov::npuw::batched::CompiledModel::create( | ||
| const std::shared_ptr<ov::npuw::ICompiledModel>& inner, | ||
| const std::shared_ptr<const ov::IPlugin>& plugin, | ||
| bool enabled) { |
There was a problem hiding this comment.
enabled as a parameter feels weird. Since it is passed it can be handled from outside more explicitly.
| void ov::npuw::batched::CompiledModel::export_model(std::ostream& model) const { | ||
| // The element is a runtime-only decorator: the blob is the inner's blob, and the | ||
| // entry points re-apply the wrapper on import based on the properties. | ||
| m_inner->export_model(model); |
There was a problem hiding this comment.
So... I don't think it would actually work. Even though it is just a runtime wrapper you must recreate it from somewhere to create it when you would deserialize. And I don't see where it is happens.
Need to figure out how to handle that properly.
Per review, pull the input snapshot / batch-size derivation out of InferRequest::infer() into a dedicated extract_batch(), returning the snapshotted tensors and the derived batch as a BatchedInputs struct. infer() is left as just the unroll: bind row, infer, copy row out. No behavioural change -- the validation and the "largest leading dim, leading-dim-1 is shared" rule are carried over verbatim. Also meter the unroll phases with the existing ov::npuw::perf::Profile (reported on request destruction under OPENVINO_NPUW_PROF=1 on a developer build), so extract_batch()'s cost can be read against the inference it wraps rather than guessed at. Measured on the deviceless unit-test harness (synthetic Qwen-style reranker, 4 inputs, seq len 512), calling extract_batch() directly over 200k iterations: batch=1 -> 239 ns/call batch=8 -> 239 ns/call batch=16 -> 240 ns/call batch=64 -> 241 ns/call It runs once per infer() and is flat in batch size and in tensor data size, as expected: the work is O(number of inputs) -- a get_tensor() lookup and a shape read per input, ~60 ns each -- and never touches tensor data. Against a real batched scoring call, where each of the N rows costs a full inner model inference in the millisecond range, the snapshot is a ~1e-5 fraction of the total and does not warrant caching across calls (which would have to be invalidated on every set_tensor() anyway).
Details
NPUW compiles every LLM-family graph with a static batch size of 1 (
reshape_to_staticpins the batch dim to 1 on all inputs and the KV-cache). As a result, a batched request that scores N independent inputs in a single inference — e.g. GenAI'sTextRerankPipelinescoring N documents, or a text-embedding model — fails for N>1, while N==1 works.This PR adds batched scoring support without changing the compiled graph, as a composable NPUW element: a runtime decorator (the same pattern as
failsafeandaccuracy_checkedunderv1/elements/) that wraps the unchanged single-sequence request and unrolls the batch row by row.On each batched
infer()the element:[1, L]slice of every input (zero-copy ROI view);[N, ...]output tensor.Because attention never crosses batch rows, batched and per-row scoring are the same arithmetic — the result is bit-for-bit identical to scoring each row separately. The element therefore targets single-shot scoring/embedding pipelines whose rows are independent; it resets state between rows and is not valid for autoregressive generation, so it is strictly opt-in.
What's in this PR
v1/elements/batched.{hpp,cpp}— thebatched::CompiledModel/batched::InferRequestdecorator.create()returns the inner unwrapped when disabled (zero-overhead path), and composes on top of the other elements, e.g.Batched(AccuracyChecked(Failsafe(NPU->CPU))). The inner request needs no awareness of batching.NPUW_LLM_BATCH_UNROLLoption (default off) and wiring inLLMCompiledModel::create_sync_infer_request: when set, the single-sequence LLM/embedding request is wrapped with the element. Driving the inner request synchronously avoids a nested-executor deadlock (the outer request is already async on this model's task executor).batched_element_test.cpp): a recording mock inner verifies that a batched infer matches per-row scoring, that state is reset between rows, that each row's input slice is fed correctly, and that outputs are stacked into the correct slots; plus the zero-overhead-when-disabled and single-row-transparent paths.Why an element (and not a compiled batch dimension)
An element is a runtime decorator over an unchanged inner request, so it keeps the batch-1 graph and the whole NPUW pipeline untouched — minimal blast radius, and one element transparently covers the reranker, the embedding pipeline, and any future single-shot scorer. (Compiling a true static batch-B graph is a separate, heavier direction with its own trade-offs; this PR is the lightweight, reusable path.)
Validation
NPUWBatchedElementTestunit tests pass; fullov_npu_unit_testssuite green (the batch-1 default path is unaffected).NPUW_DEVICES=CPU: a batched[N, L]scoring infer is bit-for-bit identical to per-row scoring (max_abs_diff = 0.0).TextRerankPipeline: multiple documents reranked in one call, deterministic and correctly ordered.Tickets
N/A
Checklist