Skip to content

[Bug]: ROI tensor keeps its owner's strides after set_shape() expands the rank — ITensor::copy_to() then reads out of bounds (SIGSEGV in production) #38246

Description

@SyueYiLiao

OpenVINO Version

2026.5.0-23099-d015add94c1

Operating System

Other (Please specify in description)

Device used for inference

GPU

Framework

None

Model used

OpenVINO/gemma-4-26b-a4b-it-int4-ov

Issue description

System information

OpenVINO version 2026.5.0-23099-d015add94c1 (stock binary; also reproduced with a self-build of the same commit)
Still present on releases/2026/4 (9bcdbd5, 2026-09-17) and master (2d4c480, 2026-09-18) — make_tensor.cpp and itensor.cpp are byte-identical to the build we crashed on, and the patch below applies cleanly to both (git apply --check verified on each)
Consumer OpenVINO Model Server 2026.5.0 (openvino/model_server:weekly, digest sha256:fcc16047f413…), GenAI 2026.5.0.0-3455-a521ae75728
OS / compiler Ubuntu 24.04, gcc 13
Hardware Intel Core Ultra 7 356H (Panther Lake) + Arc Pro B70 32 GB, driver xe, kernel 6.6.129
Model OpenVINO/gemma-4-26b-a4b-it-int4-ov served through the OVMS VLM pipeline

Summary

RoiTensor::get_strides() returns the strides of the tensor it is a view of, but BaseRoiTensor::set_shape()
is allowed to expand the ROI's rank with leading ones (added in #22257). After such a set_shape(), the
tensor reports a shape of rank N and strides of rank M < N.

ITensor::copy_to() performs that set_shape() itself and then walks shape and strides in lockstep, so a
rank-1 ROI used as the destination of a rank-2 copy makes it read dst_strides[1] out of bounds. When the
stale value there happens to compare equal, the "find a step" loop never fires, both stride vectors are left
empty, and the copy loop evaluates src_strides[src_strides.size() - 1] — that is, src_strides[SIZE_MAX]
which dereferences data() - 8 on an empty vector and faults at 0xFFFFFFFFFFFFFFF8.

This kills the whole model server process. We hit it 9 times across independent runs on a stock image.

Minimal reproducer (no GenAI, no model server, no model)

#include <openvino/openvino.hpp>
#include <iostream>

static void dump(const char* when, const ov::Tensor& t) {
    std::cout << when << ": shape " << t.get_shape() << " (rank " << t.get_shape().size()
              << "), strides " << t.get_strides() << " (rank " << t.get_strides().size() << ")\n";
}

int main() {
    ov::Tensor owner(ov::element::i64, ov::Shape{4});                 // rank 1
    ov::Tensor dst_roi(owner, ov::Coordinate{0}, ov::Coordinate{1});  // rank-1 ROI, 1 element
    ov::Tensor src(ov::element::i64, ov::Shape{1, 1});                // rank 2, 1 element
    src.data<int64_t>()[0] = 42;

    dump("before", dst_roi);
    src.copy_to(dst_roi);      // copy_to() calls dst->set_shape({1,1}) first
    dump("after ", dst_roi);

    std::cout << (dst_roi.get_shape().size() == dst_roi.get_strides().size()
                  ? "OK: shape and strides have the same rank\n"
                  : "BUG: shape and strides have different ranks\n");
}
before: shape [1] (rank 1), strides Strides{8} (rank 1)
after : shape [1,1] (rank 2), strides Strides{8} (rank 1)      <-- inconsistent
BUG: shape and strides have different ranks

The invariant violation reproduces 100% of the time, on the 2026.5 build above and on the current
releases/2026/4 and master sources (those two files are unchanged there). The out-of-bounds read it causes is reported
deterministically by valgrind, so you do not need to reproduce the crash itself to see the defect:

==336== Invalid read of size 8
==336==    at 0x507C73F: ov::ITensor::copy_to(std::shared_ptr<ov::ITensor> const&) const (in /ovms/lib/libopenvino.so.2026.5.0)
==336==    by 0x50782DF: ov::Tensor::copy_to(ov::Tensor) const (in /ovms/lib/libopenvino.so.2026.5.0)
==336==    by 0x10A781: main (/roi_repro.cpp:18)
==336==  Address 0x618c7f8 is 0 bytes after a block of size 8 alloc'd
==336==    at 0x4846FA3: operator new(unsigned long) (in /usr/libexec/valgrind/vgpreload_memcheck-amd64-linux.so)
==336==    by 0x4C94226: ov::Strides::Strides(std::initializer_list<unsigned long> const&) (in /ovms/lib/libopenvino.so.2026.5.0)
==336==    by 0x507C0F8: ov::ITensor::copy_to(std::shared_ptr<ov::ITensor> const&) const (in /ovms/lib/libopenvino.so.2026.5.0)
==336==    by 0x50782DF: ov::Tensor::copy_to(ov::Tensor) const (in /ovms/lib/libopenvino.so.2026.5.0)
==336==    by 0x10A781: main (/roi_repro.cpp:18)
==336== ERROR SUMMARY: 2 errors from 2 contexts (suppressed: 0 from 0)

Whether the process actually dies depends on what that out-of-bounds read returns, which is why the
production failure looks random while always landing on the same instruction.

Root cause

src/inference/src/dev/make_tensor.cpp:

void BaseRoiTensor::set_shape(ov::Shape new_shape) {       // :427
    OPENVINO_ASSERT(new_shape.size() >= m_shape.size());   // :428 rank may grow
    ...                                                     // the added dimensions must be 1
    m_shape = std::move(new_shape);
}

const Strides& RoiTensor::get_strides() const {            // :477 (RoiRemoteTensor :527 is identical)
    return m_owner->get_strides();                         // still the owner's rank
}

src/core/src/runtime/itensor.cpp:

const auto& shape = get_shape();
if (shape != dst->get_shape())
    dst->set_shape(shape);                                  // :84  dst's shape rank grows here
...
src_strides = get_strides();                                // :110
dst_strides = dst->get_strides();                           // :111 rank does NOT grow
ov::Strides src_str, dst_str;                               // :113 left empty unless a step is found
for (size_t inverted_idx = shape_rank - 1; inverted_idx < shape_rank; --inverted_idx) {
    if (default_strides[inverted_idx] == src_strides[inverted_idx] &&      // :119
        src_strides[inverted_idx] == dst_strides[inverted_idx]) {          // :120 OOB read when ranks differ
        continue;
    }
    ... // only here are src_str/dst_str resized
}
src_strides = std::move(src_str);                           // :150 stays empty if nothing above fired
...
copy_function(src_data + src_idx, dst_data + dst_idx,
              src_strides[src_strides.size() - 1]);         // :174 SIZE_MAX index on an empty vector

How it shows up in a real service

OVMS serving a VLM model reaches this on every decode step: ModelRunner::forward()
(openvino.genai, continuous_batching/model_runner.hpp:804) copies a per-token position_ids element of
shape {1,1} into a ROI of the rank-1 position_ids model input.

The run below used only synthetic requests (a fictional transit-operations assistant): one streaming
tool-calling chat request that enters a repetition loop and is cut off by the client after 20 s, followed by
one unary /v3/responses request. The server died on the 16th such cycle. The servable was configured
with no explicit parser, so OVMS auto-detected gemma4 for both the tool and reasoning parsers — the
recommended configuration for this model. The core is the stock binary; only GenAI was swapped for a
self-build of the same commit so that frames carry file and line numbers. The first crash of this
investigation happened on a completely unmodified image, with no self-built component at all — the
GenAI swap only made the frames readable, it did not create the fault.

[2026-09-18 04:16:41][serving][info][server.cpp:116] OpenVINO backend 2026.5.0-23099-d015add94c1
[2026-09-18 04:16:41][llm_calculator][info][servable_initializer.cpp:231] Auto-detected tool_parser: gemma4
[2026-09-18 04:16:41][llm_calculator][info][servable_initializer.cpp:241] Auto-detected reasoning_parser: gemma4
[2026-09-18 04:18:51.001][157][llm_executor][info][legacy_executor.cpp:76] All requests: 1;

Thread 140 "ovms" received signal SIGSEGV, Segmentation fault.
0x00007ffff2779275 in ov::ITensor::copy_to(std::shared_ptr<ov::ITensor> const&) const

#0  ov::ITensor::copy_to(std::shared_ptr<ov::ITensor> const&) const     libopenvino.so.2650
#1  ov::Tensor::copy_to(ov::Tensor) const                               libopenvino.so.2650
#2  ov::genai::ModelRunner::forward(...)                     continuous_batching/model_runner.hpp:804
#3  ov::genai::ContinuousBatchingPipeline::ContinuousBatchingImpl::step(...)
#4  ov::genai::ContinuousBatchingPipeline::ContinuousBatchingImpl::generate(...)  pipeline_impl.cpp:766
#5  ov::genai::ContinuousBatchingPipeline::IContinuousBatchingPipeline::generate(...) pipeline_base.cpp:482
#6  ov::genai::ContinuousBatchingPipeline::IContinuousBatchingPipeline::generate(...) pipeline_base.cpp:581
#7  ov::genai::ContinuousBatchingPipeline::generate(...)                 pipeline.cpp:668
#8  ov::genai::ContinuousBatchingPipeline::generate<...>(...)            continuous_batching_pipeline.hpp:337
#9  ov::genai::VLMPipeline::VLMContinuousBatchingAdapter::generate(...)  continuous_batching_adapter.hpp:93
#12 ov::genai::VLMPipeline::generate(...)                                visual_language/pipeline.cpp:1051
#13 ovms::VisualLanguageModelLegacyExecutor::processRequest()
#14 ovms::VisualLanguageModelLegacyExecutorWrapper::run(...)

The tensors at the faulting call, read out of the inferior at the stop:

shape strides
src (position_ids_elem) {1, 1} {8, 8}
dst (the ROI, after copy_to's set_shape) {1, 1} {8}
dst's owner (position_ids model input) {1} {8}
ROI coordinates begin {0}, end {1} element type i64

The faulting instruction is mov rax,[rax-0x8] with rax = 0 — an empty std::vector indexed with
size() - 1. The kernel logs segfault at fffffffffffffff8 ... in libopenvino.so.2026.5.0. Seven crashes
across independent runs all landed on the same instruction (ip - vma_base identical every time) but at
unpredictable request counts (after 1, 1, 7, 8, 10, 16, 16, 27, 27 cycles), which is what you would expect
when an out-of-bounds read decides the outcome. The full gdb output is attached.

Suggested fix

Keep a ROI's strides at the same rank as its shape. The expanded dimensions are ones, so their stride is the
row-major continuation of the dimension below. RoiTensor and RoiRemoteTensor have the same mismatch, so
the helper lives in their shared base:

diff --git a/src/inference/src/dev/make_tensor.cpp b/src/inference/src/dev/make_tensor.cpp
index 9ef2032db1..ba9bca2e24 100644
--- a/src/inference/src/dev/make_tensor.cpp
+++ b/src/inference/src/dev/make_tensor.cpp
@@ -448,6 +448,7 @@ public:
             std::distance(new_shape.cbegin(), new_dim.base()) - 1);
 
         m_shape = std::move(new_shape);
+        update_padded_strides();
     }
 
     size_t get_offset() const {
@@ -455,10 +456,36 @@ public:
     }
 
 protected:
+    // set_shape() above may expand the rank of the ROI with leading ones, while the strides of a
+    // ROI are the owner's and keep the owner's rank. A tensor whose shape and strides have
+    // different ranks breaks every consumer that walks the two in lockstep -- ITensor::copy_to()
+    // reads dst_strides[i] past the end of the vector -- so keep a padded copy to hand out
+    // instead. The expanding dimensions are ones (set_shape() enforces that), so their stride is
+    // whatever keeps the row-major relation with the dimension below.
+    void update_padded_strides() {
+        const auto& owner_strides = m_owner->get_strides();
+        if (m_shape.size() <= owner_strides.size()) {
+            m_padded_strides.clear();
+            return;
+        }
+        const auto pad = m_shape.size() - owner_strides.size();
+        m_padded_strides.assign(m_shape.size(), 0);
+        std::copy(owner_strides.begin(), owner_strides.end(), m_padded_strides.begin() + pad);
+        for (auto i = pad; i-- > 0;) {
+            m_padded_strides[i] = m_shape[i + 1] * m_padded_strides[i + 1];
+        }
+    }
+
+    const Strides& roi_strides() const {
+        const auto& owner_strides = m_owner->get_strides();
+        return m_padded_strides.size() == m_shape.size() ? m_padded_strides : owner_strides;
+    }
+
     std::shared_ptr<ITensor> m_owner;
     Shape m_shape;
     const Shape m_capacity;
     const size_t m_offset;
+    Strides m_padded_strides;
 };
 
 /**
@@ -475,7 +502,7 @@ public:
     }
 
     const Strides& get_strides() const override {
-        return m_owner->get_strides();
+        return roi_strides();
     }
 
     const Shape& get_shape() const override {
@@ -525,7 +552,7 @@ public:
     }
 
     const Strides& get_strides() const override {
-        return m_owner->get_strides();
+        return roi_strides();
     }
 
     const Shape& get_shape() const override {

That is the whole change: 29 added / 2 removed lines in one file, against d015add94c1. It applies cleanly
to master and to releases/2026/4 as well.

With it applied the reproducer prints strides Strides{8, 8} (rank 2), valgrind reports 0 errors, and
copy_to() takes the fast memcpy path — the strides now equal the destination's and the source is
contiguous — instead of the strided walk.

Worth hardening either way: itensor.cpp:174 indexes src_strides[src_strides.size() - 1] without
checking that the vector is non-empty, and the loop at :118 indexes dst_strides using the shape's rank.
An assert that both stride vectors have the shape's rank, or a fallback to the contiguous path when no step
is found, would turn any future instance of this class of bug into a diagnosable error rather than a wild
read.

What we verified

  • Patched core built from d015add94c1 and dropped into the stock model server image — only
    libopenvino.so replaced; every plugin and GenAI is the stock binary.
  • Every symbol the 17 in-image consumers resolve against the core is still defined (0 missing).
  • Greedy-decoding output byte-identical to the stock build across 15 requests.
  • The workload that used to kill the server within ~13 request cycles on average survived 137 and
    120 cycles in two independent runs, with no crash and no new segfault in dmesg.
    (Soak runs were driven under both parser configurations; the crash reproduces under both, so neither the
    failure nor the fix is configuration-specific.)

Note on the caller

The GenAI side has an inconsistency worth a look, though it is not the defect: for a rank-2 position_ids
element ({1,1}), ModelRunner::forward() allocates the position_ids model input as rank-1
{total_num_tokens} (model_runner.hpp:573), while the 3-D (M-RoPE) branch keeps the original rank. That is
what produces the rank-mismatched pair here. With the core fixed the usage becomes legal, so we have not
filed it separately — happy to do so if you prefer.

ovms-vlm-segv-gdb.txt

Step-by-step reproduction

No response

Relevant log output

Issue submission checklist

  • I'm reporting an issue. It's not a question.
  • I checked the problem with the documentation, FAQ, open issues, Stack Overflow, etc., and have not found a solution.
  • There is reproducer code and related data files such as images, videos, models, etc.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions