Skip to content

Guard dim_t underflow in shrink - #2128

Merged
jordimas merged 2 commits into
OpenNMT:masterfrom
kyo-zzz:hardening/shrink-guards
Oct 24, 2026
Merged

jordimas merged 2 commits into
OpenNMT:masterfrom
kyo-zzz:hardening/shrink-guards

Conversation

@kyo-zzz

@kyo-zzz kyo-zzz commented Oct 23, 2026

Copy link
Copy Markdown
Contributor

Problem

shrink() is the counterpart of grow(), but the guards added to grow() in #2097 have no shrink() equivalent: shrink() still forwards the raw _shape[dim] - size subtraction to resize().

Verified against current master, on a StorageView(Shape{4}, DataType::INT32):

  • shrink(0, -1) returns without an error and the shape silently becomes {5}, with the fifth element uninitialized — the exact mirror of the negative-increment bug fixed in Guard dim_t overflow in compute_size, reshape and grow #2097 (a negative grow() silently shrank the dimension; a negative shrink() silently grows it and exposes uninitialized memory).
  • shrink(0, INT64_MIN) overflows the subtraction (signed overflow, so formally UB; it wraps in practice), and the wrapped negative dimension then trips compute_size()'s "shape contains a negative dimension" — an error thrown far from the offending call, and only thanks to the wrap behavior.
  • shrink(0, 5) on a dimension of size 4 also surfaces as "shape contains a negative dimension" from compute_size(), with nothing pointing at the shrink() call that caused it.

What changed

  • StorageView::shrink() now mirrors grow()'s two guards:
    • size < 0 → std::invalid_argument("can't shrink a dimension by a negative size")
    • size > _shape[dim] → std::invalid_argument("new dimension size leads to an underflowing tensor size")
  • With size checked to be in [0, _shape[dim]], the subtraction can no longer overflow or go negative, so resize() receives a valid size and the rejection happens in shrink() itself, with the storage left untouched.
  • Valid calls are unaffected: shrinking down to exactly zero (shrink(0, dim)) keeps working.

Tests

Three tests in tests/storage_view_test.cc, mirroring the grow() tests from #2097:

  • ShrinkRejectsNegativeDecrement — -1 and INT64_MIN both rejected with the negative-size message, storage untouched.
  • ShrinkRejectsUnderflow — shrinking by more than the current size rejected with the underflow message, storage untouched.
  • ShrinkDecreasesShape — normal decreases on a {3, 4} view, shrinking a dimension to exactly zero, and a shrink/grow round-trip.

Testing

Same approach as #2097, for the same reason: the gtest binary still fails to start in my local environment (0xC0000409, reproduces on unmodified master), so the CI suite is the authoritative check for the tests.

  • Both touched translation units (src/storage_view.cc, tests/storage_view_test.cc) compile cleanly with MSVC.
  • The behavior itself is verified locally by a standalone program linked against the built ctranslate2 library, run in A/B: against master's storage_view.cc it reports 5 failures (the silent {4} → {5} growth above, plus the two misleading/guard-less rejections), against this patch 0 failures, with the happy paths (decrease, shrink-to-zero, shrink/grow round-trip) unchanged in both.

AI usage disclosure

Per the contribution policy: this fix was developed with AI assistance (analysis of the underflow paths, drafting the guards and tests, and building/verifying locally). I have reviewed every line of the diff, understand the wrap arithmetic and the symmetry with the #2097 grow() guards, and take full responsibility for correctness, performance, and design.

@jordimas

Copy link
Copy Markdown
Collaborator

Could you add tests for shrinking by zero and shrinking an already empty dimension? The description also mentions a shrink/grow round-trip test, but I don’t see it in the diff.

@kyo-zzz

kyo-zzz commented Oct 23, 2026

Copy link
Copy Markdown
Contributor Author

Done, added in 0064bb1:

  • ShrinkDecreasesShape now covers shrinking by zero (a valid no-op), shrinking an already empty dimension by zero, and the shrink/grow round-trip that the description mentioned but the diff was indeed missing — you were right, it only existed in my local standalone verification, not in the committed test. The emptied dimension is grown back and the size is checked restored.
  • ShrinkRejectsUnderflow now also covers shrinking an already empty dimension by more than zero (shrink(0, 1) on a {0, 2} view), which the underflow guard rejects while leaving the storage untouched.

Both cases (empty dim shrunk by zero, empty dim over-shrunk) are also exercised in my local standalone check against master, where the over-shrink surfaces as the old generic "shape contains a negative dimension" error, confirming the guard is what turns it into the clear underflow message.

@jordimas

jordimas commented Oct 24, 2026 •

Copy link
Copy Markdown
Collaborator

Thanks so much!

@jordimas
jordimas merged commit f48a6de into OpenNMT:master Oct 24, 2026
26 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants