Repository navigation
Dynamic-shape input validation only enforces total numel β€ upper_boundΒ #20546
Description
π Describe the bug
For a method with DYNAMIC_BOUND inputs, the only shape validation performed at set_input is a single scalar check, numel(new_sizes) <= numel_bound_. The per-dimension lower bounds (Dim(min=β¦)) and divisibility / derived-dim constraints (e.g. 32*Dim(...)) declared at torch.export time are not enforced at runtime. Inputs that violate them are silently accepted and produce wrong-shaped / silently-incorrect outputs - no error is raised.
Where it happens - runtime/core/portable_type/tensor_impl.cpp, TensorImpl::internal_resize_contiguous(), the DYNAMIC_BOUND case:
case TensorShapeDynamism::DYNAMIC_BOUND:
case TensorShapeDynamism::DYNAMIC_UNBOUND: {
auto new_numel = safe_numel(new_sizes.data(), dim_).get();
ET_CHECK_OR_RETURN_ERROR(
static_cast<size_t>(new_numel) <= numel_bound_, // <-- the ONLY guard
NotSupported,
"Attempted to resize a bounded tensor with a maximum capacity of %zu elements to %zu elements.",
numel_bound_, new_numel);
...
}DYNAMIC_UNBOUNDfalls through to the same case (the// TODO(T175194371)treats unbounded as upper-bounded), so even "unbounded" dynamic tensors are silently numel-capped and never per-dim validated.
Root cause - schema/program.fbs, table Tensor, serializes only the upper-bound sizes for a dynamic dim (field comment: "dynamism == DYNAMIC_BOUND: sizes field represents the upper bound shape") plus a shape_dynamism enum. The Dim lower bound and divisibility are dropped during AOT serialization, so the runtime has nothing to validate against except the precomputed numel_bound_ ceiling.
Reproduction - a model exported with a dynamic square input H, W = 32*Dim(min=8, max=40) (valid set = multiples of 32 in [256, 1280]); detect output is [1, 134, H/4, W/4]. Observed at set_input/execute:
| Input | Expected | Actual |
|---|---|---|
| 1280Γ1280 (max, valid) | OK | β
OK β [1,134,320,320] |
| 640Γ640 (valid) | OK | β
OK β [1,134,160,160] |
| 256Γ256 (min, valid) | OK | β
OK β [1,134,64,64] |
| 96Γ96 (below min) | reject | [1,134,24,24] |
| 300Γ300 (not Γ·32) | reject | [1,134,80,80] - note 80 β 300/4 = 75; the internal /4 downsample ratio is broken, so downstream geometry (coordinate decode, grid_sample, etc.) is silently wrong |
| 1312Γ1312 (above max) | reject | β
set_input() returns Error::NotSupported - but only because numel exceeds the bound, not because a per-dim max is checked |
So in practice only the upper limit is guarded. The off-divisor case returns a normal-looking tensor whose dim/divisor relationship is wrong, and no error surfaces.
Expected behavior: set_input / resize_tensor should reject (or the framework should provide a supported way to reject) shapes that violate the declared per-dim min/max or divisibility, instead of silently accepting any shape whose total numel fits the bound. (Closely related: there is currently no API to query these bounds either)
Versions
PyTorch version: 2.12.0
Is debug build: False
CUDA used to build PyTorch: None
ROCM used to build PyTorch: N/A
OS: macOS 26.2 (arm64)
GCC version: Could not collect
Clang version: 21.0.0 (clang-2100.1.1.101)
CMake version: version 3.31.10
Libc version: N/A
Python version: 3.10.19 (main, Oct 27 2025, 17:05:40) [Clang 17.0.0 (clang-1700.3.19.1)] (64-bit runtime)
Python platform: macOS-26.2-arm64-arm-64bit
Is CUDA available: False
CUDA runtime version: No CUDA
CUDA_MODULE_LOADING set to: N/A
GPU models and configuration: No CUDA
Nvidia driver version: No CUDA
cuDNN version: No CUDA
Is XPU available: False
HIP runtime version: N/A
MIOpen runtime version: N/A
Is XNNPACK available: True
Caching allocator config: N/A
CPU:
Apple M4 Pro
Versions of relevant libraries:
[pip3] executorch==1.3.1+10f535e
[pip3] flake8==6.1.0
[pip3] flake8-breakpoint==1.1.0
[pip3] flake8-bugbear==24.4.26
[pip3] flake8-comprehensions==3.14.0
[pip3] flake8-plugin-utils==1.3.3
[pip3] flake8-pyi==23.5.0
[pip3] mypy==1.14.1
[pip3] mypy_extensions==1.1.0
[pip3] numpy==2.2.6
[pip3] onnx==1.20.1
[pip3] onnx-ir==0.2.0
[pip3] onnxruntime==1.23.2
[pip3] onnxscript==0.6.2
[pip3] optree==0.19.0
[pip3] pytorch_tokenizers==1.3.0
[pip3] tf2onnx==1.17.0
[pip3] torch==2.12.0
[pip3] torchao==0.17.0+git02105d46c
[pip3] torchaudio==2.11.0
[pip3] torchdata==0.11.0+cpu
[pip3] torchsr==1.0.4
[pip3] torchtune==0.0.0
[pip3] torchvision==0.27.0
Metadata
Metadata
Assignees
Labels
Type
Projects
- StatusShow more project fieldsTo triage
Activity
[-]Dynamic-shape input validation only enforces total `numel β€ upper_bound` - below-min and non-divisible shapes are silently accepted and produce wrong outputs[/-][+]Dynamic-shape input validation only enforces total `numel β€ upper_bound`[/+]JacobSzwejbka commentedon Jul 14, 2026
This is a fair issue. Its an uncommon case to have a non trivial minimum size lets think about a non painful way to serialize this. Maybe just throw it in extra tensor info? cc @Gasoonjia
AxelNoun commentedon Aug 2, 2026
@JacobSzwejbka @benITo47 Hello ! Hereβs the plan Iβd like to execute, with the two design points that need sign-off flagged below.
Root cause recap: the DYNAMIC_BOUND branch of TensorImpl::internal_resize_contiguous() guards a single scalar, numel(new_sizes) <= numel_bound_. Everything else from torch.exportβs range_constraints per-dim min/max and derived-dim relations like 32*Dim(...) is dropped at serialization (program.fbs keeps only the upper-bound sizes + shape_dynamism), so the runtime canβt enforce what it never receives. Concretely, 2560Γ640 passes today on a model bounded at 1280Γ1280 (same numel), and 300Γ300 silently breaks the /4 downsample geometry.
PR 1 : per-dim upper bound, no schema change. The bound shape is already available (itβs the serialized sizes the tensor is constructed with), but sizes_ is mutated in place on resize so snapshot it at construction (or keep a pointer to the flatbuffer-backed sizes, which outlive the Method) and add new_sizes[i] <= bound_sizes[i] to the DYNAMIC_BOUND case. This strictly tightens validation; if anyone relies on numel-trading across dims, it can go behind a strict-validation flag.
PR 2 : serialize min + divisibility. Append-only, BC-safe fields on table Tensor: dim_min_sizes:[int] plus per-dim (divisor, offset), emitted from the ExportedProgramβs range_constraints in the exir serializer; enforce in resize_tensor with actionable errors. DYNAMIC_UNBOUND keeps its current fall-through until T175194371 lands.
PR 3 : introspection. Expose per-dim min/max/divisor via MethodMeta/TensorInfo so callers can query the valid input space instead of probing it.
Tests: unit coverage on the resize path + the repro matrix from the issue (below-min, non-Γ·32, per-dim-max violated with numel under bound the case the current check misses).
Needs sign-off: (a) strict per-dim check on by default vs opt-in; (b) preferred schema encoding for derived dims (divisor, offset) pairs vs a small expression table.
I can start on PR 1 right away Mb.
benITo47 commentedon Aug 3, 2026
If it were up to me, I'd go for strict per-dim check being an opt-out rather than opt-in.
As for the schema - no preference here
Gasoonjia commentedon Aug 3, 2026
Happy to see @AxelNoun again! Thanks for sharing your thoughts!
I think the impl plan looks good to me! Thanks for doing that.
I prefer to default turning it on as well; by default the check should turn on otherwise the runtime output maybe UDB
I;m thinking if we can do the third way by using transaction.
Most derived dims in range_constraints we see in practice are affine: d2 = d1 / 4, d = 32 * d_blocks, d1 = d0, etc. We don't need a full expression table for d1 = f(d0, d1).
We can keep the per-dim simplicity of (A) but get βΌ99% of (B) by canonicalizing to root + affine transform + divisibility:
Proposed per-dim fields (still O(1) per dim, append-only on Tensor):
At serialization time in exir, we topologically sort the derived dims. If dim = (parent * a + b) // c, we propagate its root and compute its tightest divisor via gcd. So:
Then at runtime in resize_tensor, check is still just integer arithmetic, no AST evaluator. 300x300 fails on dim % 32 != 0 with an actionable message, so the /4 geometry is protected transitively.
Complex non-affine cases like d0 + d1 can't be canonicalized - for those we can fallback to storing the tightest implied divisor, or make the exporter error until we need a full table.
This gives us the simplicity / BC story of A, but covers the downsample case that motivated B.
WDYT?
AxelNoun commentedon Aug 4, 2026
@Gasoonjia thanks I like the affine canonicalization. It subsumes the flat
(divisor, offset)idea, keeps the O(1)-per-dim / append-only story of (A), and protects the transitive downsample case that motivated (B), withresize_tensorstaying pure integer arithmetic. +1 on the direction.On (a): sounds like we're all aligned strict per-dim check on by default. I'll ship PR 1 default-on with an explicit escape hatch (flag restoring the numel-only behavior) plus a release note, since models accidentally relying on numel-trading will start failing loudly at resize instead of producing UB. The flag can be dropped after a deprecation cycle.
On the encoding : one thing to pin down: what does
root_dimindex?In
range_constraintsthe root is a symbol, and it's often never materialized as a dim of the tensor being resized:H = 32 * Dim("h_blocks")β onlyHexists as a dim;h_blocksis purely symbolic.imagewithH = 32kandmaskwithH' = 8k, or a batch symbol shared by every input.So a same-tensor dim index can't express the general case and
resize_tensor, which sees one tensor at a time, couldn't enforce it anyway. Suggestion: keep the canonicalization exactly as you describe, but split what it produces into two layers (computed in the same exir pass):divisor/remainderplusmin/max, obtained by pushing the root's range and divisibility through the affine map (gcd for divisibility β carrying theaddoffset sod = 2k + 1projects tod % 2 == 1β and interval arithmetic for the range). This is whatresize_tensorenforces: PR 2 as planned.300Γ300fails here transitively with the% 32message, exactly as in your example.(symbol_id, mul, add, div)per dim, with a small per-method symbol table (symbol_id β {min, max}). Not checkable insideresize_tensor, but cheap to verify at method level (onset_input/ beforeexecute: recover the symbol per dim via(dim * div β add) / mul, require integrality + agreement across dims), and it's the right thing to surface inMethodMeta/TensorInfofor PR 3 so callers can query the joint valid input space rather than per-dim boxes. Enforcement can land as a follow-up to PR 2 with no schema churn if we reserve the fields now.Concretely (append-only on
table Tensor; defaults are sentinels so an old.ptedecodes to "unconstrained" and keeps exactly PR 1 semantics this also foldsdim_min_sizesinto the same table instead of a parallel array):Two semantics worth fixing now:
div > 1only when export can prove the numerator divisible (as inH % 32 == 0 β H/4); otherwise treat the dim as non-affine.d0 + d1, β¦): project + warn, don't hard-error. The range always projects via interval arithmetic and gcd yields a (possibly trivial) divisor, so we still emit the unary layer and log that the relation itself won't be runtime-enforced. Hard-erroring the exporter would break models that export fine today, which seems wrong for a validation feature.On top of the issue's repro matrix I'll add tests for: chained derivation (
H = 32k,H_down = H/4, feed300β actionable% 32error), an offset case (2k + 1), and a shared batch symbol across two inputs for the method-level check.If that works for you both, I'll start PR 1 now and put the
.fbslayout up as a short design PR so we can settle the encoding asynchronously before PR 2's implementation.msluszniak commentedon Sep 2, 2026
Hi @AxelNoun, have you worked by any chance on this one?
AxelNoun commentedon Sep 2, 2026
Hi ! As mentioned earlier in the discussion, I previously suggested a few approaches and have begun working on them locally. Please feel free to reach out if you need any assistance or additional context. I'm happy to help.
Gasoonjia commentedon Sep 2, 2026
Hi @AxelNoun sry for the late response on your proposal!
Overall i love your proposal; only thing in my mind is where we do the check. My recommendation is instead of updating
resize_tensor(), i would recommend to do the check inset_input()for single dimension regulation andset_inputs()for cross dimension regulation, which will be better fit for the semantics and limit the impact to the right place (resize_tensorused everywhere in the system.)AxelNoun commentedon Sep 4, 2026
@msluszniak A patch is ready; happy to hand it over instead of duplicating effort if you'd rather take it.
@JacobSzwejbka asking for your go-ahead before I push and open the PR.
@Gasoonjia I took the recommendation into account and it seems relevant to me, it serves the perimeter but I don't think it's a bad thing
What the change does
runtime/executor/method.cpponly, +33/β1. The existinginput_tensor_meta()call is hoisted aboveresize_tensor(itsResultis now checked rather than dereferenced insideis_memory_planned()), and then, only when the serialized plan declaresDYNAMIC_BOUNDand the ranks match, each dimension is compared against its bound.tensor_impl.cpp,program.fbs, the exir emitter,export_program.pyand the build files are untouched.This is not new policy:
schema/program.fbs:163-165already states the per-dimension constraint explicitly. Only the first sentence of that contract was being enforced; the patch enforces the second.Verified by execution
set_input((2,6))returnsOkβ test passes, bug reproduced in-tree-Wformatvalidated the messagemethod_testlintrunner -aNo lint issues., no patch appliedsize_test.text38,881 β 39,099 (+218 B, +0.56%);.data/.bssunchangedNot verified: the ATen-mode build. OSS cmake doesn't compile
method.cppin that variant (that's Buck or the pybind preset). I checked the types by handint64_twhere portable usesssize_t, signed/signed comparison β but that's reasoning, not a compile; CI will cover it. The Buck build wasn't run either.Two things worth knowing before review
DYNAMIC_UNBOUNDhas no test coverage, and structurally can't:tensor_parser_portable.cpp:56-60rejects those programs at load time (T175194371), so in a portable build which is whatmethod_testuses no UNBOUND input can reachset_input, whatever model is added. That path is only live in ATen mode. The guard is still needed.SetInputRejectsOverflowingSizesnow trips the new guard beforeresize_tensor's. It still passes, but its error code changes fromNotSupportedtoInvalidArgument. That feeds question 1 below.Three open design questions
InvalidArgument;resize_tensorreturnedNotSupportedon the overflow path. Do you wantInvalidArgumentuniformly for bound violations, or should the overflow case keepNotSupported?I've deliberately left all three to you rather than picking defaults.
The PR description will read "Part of #20546", not
Fixes, so merging won't close the issue. Ready to push as soon as you confirm.AxelNoun commentedon Oct 27, 2026
@JacobSzwejbka I asked for your go-ahead on 4 Sep and have not heard back, so I opened #24203 to make the change easy to review. I will rework or close it if you would rather handle this differently. @msluszniak if you had started on this, tell me and we can sort out which one to keep.
It adds the per-dimension upper-bound check in
set_input(), as @Gasoonjia recommended, and is marked "Part of #20546". The three questions from my 4 Sep comment (error code, opt-out, release note) are still open and repeated in the PR. On 4 Aug I said the first PR would include an opt-out flag and a release note; it has neither yet, pending those answers. The cross-input check inset_inputs(), lower bounds and divisibility, and introspection throughMethodMetaare not part of it.msluszniak commentedon Oct 27, 2026
Nah, I haven't stared work on this one so go ahead :))