Repository navigation
Guard dim_t overflow in compute_size, reshape and grow - #2097
Conversation
The model loader validates shapes with a per-multiplication overflow check (since OpenNMT#2091), but the StorageView API paths still multiply dims unchecked: compute_size() and StorageView::reshape() can wrap int64_t (e.g. a shape of {2^62+1, 8} wraps to 8), yielding a view whose shape claims far more elements than _size, and grow() can wrap its addition. Apply the same per-multiplication guard used by the loader to compute_size() and reshape(), add an overflow check to grow(), and reject the overflowing cases with std::invalid_argument in two new unit tests, mirroring the OpenNMT#2094 test style.
A zero dimension yields an empty tensor, which cannot overflow, but the guards divided by it to compare with the running product: x86_64 raises SIGFPE, while AArch64 division by zero returns 0 and made every later check reject valid zero-dim shapes. Skip the division when the dimension is zero.
|
The 4 CI failures came from the overflow guards added in this PR: they evaluated 3864162 skips the division when the dimension is zero (an empty tensor cannot overflow) and adds a regression test covering the case. The failed jobs should turn green on this push. |
|
The zero-dimension fix still seems to depend on where the zero appears. Could we check for zero dimensions before multiplying, while still validating negative dimensions? A regression test with an overflowing prefix followed by zero would cover this; the current |
A zero dimension makes the size 0 whatever its position, so the other dimensions are never multiplied and an overflowing prefix next to a zero dimension is not an error. Skipping the division is not enough: the prefix can overflow before the zero is reached, which made the result depend on where the zero appeared in the shape. The same ordering in reshape() also left the known size at 0 when the new shape combined a zero dimension with an unknown one, and that 0 is the divisor of the compatibility check below (crash on master today). The unknown dimension is now rejected in that case.
|
Good catch: skipping the division still leaves the prefix, so the result depended on where the zero sat. Both sites now scan the shape first: negative dimensions are rejected, a zero dimension is recorded, and the multiplication (with the overflow guard) only runs on shapes that have no zero dimension. Restructuring Tests: |
|
Could you also add tests for the new |
Split the negative increment out of the overflow condition so it gets its own
message, and pin the three cases: a negative size (which used to silently shrink
the dimension), an overflowing addition (tested on {INT64_MAX, 0}, where the zero
dimension keeps the allocation empty), and normal growth.
|
Thanks, and for merging master in. Added in 6feb9b1:
One small product change: Verification: the gtest binary still doesn't start on this machine (exits before printing anything), so I ran the same cases as a standalone program linked against the built library. 8 of those assertions fail against |
|
Thanks! |
Merge master again (OpenNMT#2097, OpenNMT#2117)
Summary
compute_size()(include/ctranslate2/storage_view.h) and the inline product inStorageView::reshape()multiply dimensions without overflow checks, andgrow()adds unchecked. The model loader validates shapes with a per-multiplication guard since #2091, but theStorageViewAPI paths still accept shapes whose true product overflowsdim_t.Concretely, a shape of
{2^62 + 1, 8}has a true product of2^65 + 8, which wraps to 8 in int64 arithmetic — verified against currentmain.reserve(8)then succeeds and the resulting view claims2^62 + 1rows while_sizeis 8, a shape/size inconsistent state that downstream ops cannot reason about. The existing #2094 guards only coverreserve()itself, so they cannot catch a size that wrapped before entering it.What changed
compute_size(): reject negative dimensions and apply the same per-multiplication guard the model loader uses (size > max / dim).StorageView::reshape(): same guard for its inlineknown_size *= dimproduct.StorageView::grow(): rejectsize < 0and_shape[dim] + size > maxbefore the addition.{2^62 + 1, 8, 0}and{0, 2^62 + 1, 8}both give 0) and the remaining dimensions are never multiplied. Negative dimensions are still rejected across the whole shape in that case.reshape(), an unknown dimension (-1) next to a zero dimension is now rejected: the known size is 0 there, and that 0 is the divisor of the compatibility check below. This terminates the process on currentmain, independently of the overflow guards (StorageView(Shape{8}, DataType::INT32).reshape(Shape{0, -1})).tests/storage_view_test.ccmirroring the Check for overflow in StorageView::reserve before allocation #2094 test style: overflowing shapes and reshapes must throw, and the three zero-dimension rules above are pinned.This is defense-in-depth consistent with the current hardening wave (#2068, #2073, #2091, #2094). I audited the remaining model-parsing surface while preparing this:
consume<is used only insrc/models/model.cc, the variable loop is covered by the #2091 guard, string/rank reads are length-bounded,vocabulary.ccreserves are driven by in-memory vector sizes, and the 32-bitconsume<uint32_t> * item_sizeproduct on the pre-v4 path can only shrinknum_bytes, which the downstream int64 equality check against the true product rejects — so I found no other exploitable site.Testing
BUILD_TESTS=ON,OPENMP_RUNTIME=COMP,BUILD_SHARED_LIBS=OFF,ENABLE_CPU_DISPATCH=OFF,WITH_CUDA=OFF,WITH_MKL=OFF,WITH_DNNL=OFF.ctranslate2_test. Note: the gtest binary still crashes at startup (0xC0000409, even for--gtest_list_tests) in my local environment, both withENABLE_CPU_DISPATCH=ONandOFF, and the same crash reproduces on unmodifiedmain— a local toolchain quirk rather than something this patch introduces, so the CI suite is the authoritative check for these tests.ctranslate2library: 15 assertions covering the overflow rejections, the position-independence of zero dimensions in bothcompute_size()andreshape(), the negative-dimension checks, the-1next to a zero dimension, and the unchanged happy paths ({16}->{2, -1}->{-1, 4}->{4, 4},{2, 0, 2}).storage_view_test.cctests are untouched and the diff adds no new failure modes on the happy path (valid shapes are unaffected: the guard only rejects values that would previously wrap).AI usage disclosure
Per the contribution policy: this fix was developed with AI assistance (analysis of the overflow paths, drafting the guards and tests, and building/verifying locally). I have reviewed every line of the diff, understand the wrap arithmetic and the interaction with the #2091/#2094 guards, and take full responsibility for correctness, performance, and design.