Skip to content

Commit e955628

Browse files
committed
NPUW: Batched element review fixes -- broadcast-aware detection, single-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.
1 parent c85f060 commit e955628

3 files changed

Lines changed: 204 additions & 66 deletions

File tree

src/plugins/intel_npu/src/plugin/npuw/v1/elements/batched.cpp

Lines changed: 45 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
#include "batched.hpp"
66

7+
#include <algorithm>
78
#include <utility>
89

910
#include "openvino/core/except.hpp"
@@ -168,29 +169,35 @@ void ov::npuw::batched::InferRequest::infer() {
168169
OPENVINO_ASSERT(wrapper_inputs.size() == in_ports.size() && wrapper_outputs.size() == out_ports.size(),
169170
"Batched element: inner request I/O does not match the wrapped model");
170171

171-
// Batch size is taken from the first input that carries a batch dimension.
172-
std::size_t batch = 1;
172+
// Snapshot the public inputs once -- read repeatedly below.
173+
std::vector<ov::SoPtr<ov::ITensor>> in_tensors;
174+
in_tensors.reserve(wrapper_inputs.size());
173175
for (const auto& port : wrapper_inputs) {
174-
const auto tensor = get_tensor(port);
175-
if (has_batch_dim(tensor)) {
176-
batch = tensor->get_shape()[0];
177-
break;
176+
in_tensors.push_back(get_tensor(port));
177+
}
178+
179+
// Batch is the largest leading dim across inputs, so a shared [1, ...] input is broadcast
180+
// rather than mistaken for the batch. Stays 1 when nothing is batched.
181+
std::size_t batch = 1;
182+
bool any_batched = false;
183+
for (const auto& tensor : in_tensors) {
184+
if (!has_batch_dim(tensor)) {
185+
continue;
178186
}
187+
const std::size_t in_batch = tensor->get_shape()[0];
188+
batch = any_batched ? std::max(batch, in_batch) : in_batch;
189+
any_batched = true;
179190
}
180-
// A zero-sized batch has no rows to score and would leave the outputs unpopulated
181-
// (publishing null tensors). Reject it explicitly rather than silently no-op.
191+
// A zero-sized batch has no rows and would publish unpopulated outputs; reject it.
182192
OPENVINO_ASSERT(batch > 0, "Batched element: batch size must be > 0, got an input with batch dimension 0.");
183193

184-
// Validate every batched input up front: each row-carrying input must have a batch
185-
// dimension equal to `batch`, or exactly 1 (a shared/broadcast input passed to every row).
186-
// Anything else (e.g. a [M, ...] input with M != batch and M != 1) cannot be sliced per row
187-
// and would otherwise be fed whole into the batch-1 inner request -> wrong results.
194+
// Every batched input must match `batch` or be a broadcast (dim 0 == 1); anything else
195+
// cannot be sliced per row.
188196
for (std::size_t i = 0; i < wrapper_inputs.size(); ++i) {
189-
const auto full = get_tensor(wrapper_inputs[i]);
190-
if (!has_batch_dim(full)) {
197+
if (!has_batch_dim(in_tensors[i])) {
191198
continue;
192199
}
193-
const std::size_t in_batch = full->get_shape()[0];
200+
const std::size_t in_batch = in_tensors[i]->get_shape()[0];
194201
OPENVINO_ASSERT(in_batch == batch || in_batch == 1,
195202
"Batched element: input '",
196203
wrapper_inputs[i].get_any_name(),
@@ -201,56 +208,61 @@ void ov::npuw::batched::InferRequest::infer() {
201208
" nor 1 (broadcast).");
202209
}
203210

204-
// Aggregated [batch, ...] outputs, allocated lazily once the per-row output shape is known.
205-
std::vector<ov::SoPtr<ov::ITensor>> aggregated_outputs(wrapper_outputs.size());
206-
207-
// The inner request's variable states are stable across rows, so query them once and reset
208-
// them per row rather than re-querying (which may allocate) on every iteration.
211+
// States are stable across rows: query once, reset per row so row i never sees row i-1.
209212
const auto inner_states = inner_query_state();
210-
211-
for (std::size_t row = 0; row < batch; ++row) {
212-
// Rows are independent prompts: clear the inner request's variable state
213-
// (KV-cache) so row i never sees row i-1. Harmless for stateless inners.
213+
const auto reset_inner_state = [&] {
214214
for (const auto& state : inner_states) {
215215
state->reset();
216216
}
217+
};
217218

218-
// Bind the row's slice of every batched input; pass shared ([1, ...] or non-batched)
219-
// inputs through unchanged.
219+
// Slice per-row inputs out of [batch, ...]; pass broadcast/non-batched inputs through whole.
220+
const auto bind_row = [&](std::size_t row) {
220221
for (std::size_t i = 0; i < wrapper_inputs.size(); ++i) {
221-
const auto full = get_tensor(wrapper_inputs[i]);
222+
const auto& full = in_tensors[i];
222223
const bool sliceable = batch > 1 && has_batch_dim(full) && full->get_shape()[0] == batch;
223224
inner_set_tensor(in_ports[i], sliceable ? row_slice(full, row) : full);
224225
}
226+
};
227+
228+
// Per-row outputs stacked along axis 0. A single row has nothing to stack, so its output is
229+
// published as-is (no aggregation buffer or copy); a non-batched output collapses to the last row.
230+
std::vector<ov::SoPtr<ov::ITensor>> aggregated_outputs(wrapper_outputs.size());
225231

232+
for (std::size_t row = 0; row < batch; ++row) {
233+
reset_inner_state();
234+
bind_row(row);
226235
inner_infer();
227236

228-
// Stack each per-row output into row i of the aggregated [batch, ...] tensor.
229237
for (std::size_t i = 0; i < wrapper_outputs.size(); ++i) {
230238
const auto inner_out = inner_get_tensor(out_ports[i]);
239+
if (batch == 1) {
240+
aggregated_outputs[i] = inner_out;
241+
continue;
242+
}
231243
if (!aggregated_outputs[i]) {
232244
ov::Shape out_shape = inner_out->get_shape();
233245
if (!out_shape.empty()) {
234246
out_shape[0] = batch;
235247
}
236248
aggregated_outputs[i] = ov::get_tensor_impl(ov::Tensor(inner_out->get_element_type(), out_shape));
237249
}
238-
if (batch > 1 && has_batch_dim(inner_out)) {
239-
const auto slot = row_slice(aggregated_outputs[i], row);
240-
inner_out->copy_to(slot._ptr);
250+
if (has_batch_dim(inner_out)) {
251+
inner_out->copy_to(row_slice(aggregated_outputs[i], row)._ptr);
241252
} else {
242253
inner_out->copy_to(aggregated_outputs[i]._ptr);
243254
}
244255
}
245256
}
246257

247-
// Publish the aggregated outputs as this request's public output tensors.
248258
for (std::size_t i = 0; i < wrapper_outputs.size(); ++i) {
249259
set_tensor(wrapper_outputs[i], aggregated_outputs[i]);
250260
}
251261
}
252262

253-
void ov::npuw::batched::InferRequest::check_tensors() const {}
263+
void ov::npuw::batched::InferRequest::check_tensors() const {
264+
// No-op: batched tensors are unrolled and validated per row by the inner request.
265+
}
254266

255267
std::vector<ov::SoPtr<ov::IVariableState>> ov::npuw::batched::InferRequest::query_state() const {
256268
// The batched element resets inner state between rows and exposes no

src/plugins/intel_npu/src/plugin/npuw/v1/elements/batched.hpp

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -71,12 +71,13 @@ class CompiledModel final : public ov::ICompiledModel {
7171
// Sync infer request that unrolls a batched inference over a single-sequence
7272
// inner request.
7373
//
74-
// On each infer() call it determines the batch size N from the inputs, then for
75-
// each row 0..N-1: resets the inner request's variable state, binds the row's
76-
// [1, ...] slice of every input, runs the inner request, and copies the inner
77-
// [1, ...] output into row i of the wrapper's [N, ...] output tensors. The
78-
// public input/output tensors are held by the ISyncInferRequest base; only the
79-
// inner request sees [1, ...] shapes.
74+
// On each infer() call it takes the batch size N as the largest leading dimension
75+
// across the inputs (so a shared [1, ...] input is broadcast, not mistaken for the
76+
// batch), then for each row 0..N-1: resets the inner request's variable state, binds
77+
// the row's [1, ...] slice of every per-row input, runs the inner request, and copies
78+
// the inner [1, ...] output into row i of the wrapper's [N, ...] output tensors.
79+
// N == 1 publishes the inner outputs directly. The public input/output tensors are
80+
// held by the ISyncInferRequest base; only the inner request sees [1, ...] shapes.
8081
//
8182
// It is constructed from the compiled model whose I/O it exposes plus the inner
8283
// request to drive. This lets it be produced both by Batched::CompiledModel

0 commit comments

Comments
 (0)