Skip to content

Add a portable kernel for antialiased bicubic resize - #24400

Open
shoumikhin wants to merge 31 commits into
pytorch:mainfrom
shoumikhin:bicubic-aa-decomposition
Open

shoumikhin wants to merge 31 commits into
pytorch:mainfrom
shoumikhin:bicubic-aa-decomposition

Conversation

@shoumikhin

@shoumikhin shoumikhin commented Nov 1, 2026 •

Copy link
Copy Markdown
Contributor

to_edge cannot lower F.interpolate(x, mode="bicubic", antialias=True) today. It exports to aten._upsample_bicubic2d_aa, which is not a core ATen op, so to_edge stops with:

SpecViolationError: Operator torch._ops.aten._upsample_bicubic2d_aa.default is not in Core ATen opset

ExecuTorch also has no kernel for it.

What this change does

It adds a portable kernel for _upsample_bicubic2d_aa.out and lists the op as an exception in the edge verifier, as the antialiased bilinear resize does.

Like ATen's CPU kernel, it uses the Keys cubic filter (a = -0.5), widened when shrinking, resizes the width, then the height, and copies a dim that keeps its size. float16 and bfloat16, which ATen's CPU kernel lacks, are computed in float32 and rounded once. uint8 follows ATen's fixed-point path: 16-bit integer weights, and each pass rounds and clamps. Other integer types fail in the kernel, as they do in ATen.

Between passes it keeps only the rows one output row reads, so shrinking 1080x1920 to 224x224 needs about 95 KB of temporary memory (43 KB for uint8). It rejects scales F.interpolate never produces: too small for the input, or (uint8 only) with weights too large for 16 bits. Where ATen's generic CPU path is wrong (contiguous input, output width 1, changing height; PyTorch issue 200568), it differs from eager on purpose.

The shared upsample helper now ignores a non-positive scale, as ATen does; of the other upsample kernels, only antialiased bilinear can pass one. A stale llava comment is updated, and the Arm all-ops test marks the kernel as needing a recipe. Like several portable kernels, it has no experimental CPU backend route yet.

Testing

  • New kernel tests check against ATen: shrinking, align_corners, scales, windows past the input, kept dims, both layouts, a batch, all float types, uint8 bit for bit, a dynamic output, running out of temporary memory, and error cases. Each case catches a bug that no other case catches.
  • The to_edge test compares static and dynamic programs (both layouts) with eager in float32 and, bit for bit, in uint8. It fails without the kernel or the verifier exception.
  • Outside the suite, a random sweep matched ATen's uint8 results exactly on x86 and Arm, on both its vectorized and generic paths; float32 within 7e-6 for unit-scale inputs, half types within one unit in the last place.
  • With XNNPACK, the resize stays outside the delegate. On one laptop thread, shrinking a contiguous 1080x1920 input to 224x224 takes about 7 ms (12 ms channels last).

Copilot AI balanced review requested due to automatic review settings November 1, 2026 06:05
@shoumikhin shoumikhin added the release notes: exir Changes to any dialects and passes on these dialects, such as memory planning label Nov 1, 2026
@pytorch-bot

pytorch-bot Bot commented Nov 1, 2026 •

Copy link
Copy Markdown

🔗 Helpful Links

🧪 See artifacts and rendered test results at hud.pytorch.org/pr/pytorch/executorch/24400

Note: Links to docs will display an error until the docs builds have been completed.

✅ No Failures

As of commit 31bf43b with merge base 05092c1 (image):
💚 Looks good so far! There are no failures yet. 💚

This comment was automatically generated by Dr. CI and updates every 15 minutes.

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label Nov 1, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has insufficient remaining quota. Try requesting a review at a lower effort level.

`to_edge` cannot lower `F.interpolate(x, mode="bicubic", antialias=True)` today. It exports to `aten._upsample_bicubic2d_aa`, which PyTorch does not decompose into core ATen ops and no ExecuTorch kernel implements, so `to_edge` stops with:

```
SpecViolationError: Operator torch._ops.aten._upsample_bicubic2d_aa.default is not in Core ATen opset
```

## What this change does

`to_edge` now rewrites the op into core ATen ops that follow ATen's algorithm:

- Each output is a normalized, weighted sum over a window of input pixels, with the Keys cubic filter (a = -0.5). Shrinking widens the window.
- `align_corners`, explicit scale factors and dynamic sizes work. As in ATen's CPU kernel, a dim that keeps its size is copied.
- A NaN or inf reaches only the outputs whose window has it, as in ATen.
- float16 and bfloat16 are computed in float32 and rounded once. Integer inputs are not rewritten and still fail as above, because ATen rounds them in fixed point.

The new private module `exir/_decompositions.py` has bilinear and bicubic functions. `to_edge` uses only the bicubic one, since the bilinear op has a portable kernel. Programs without the bicubic op do not change. To keep the op for your own kernel, list it in `EdgeCompileConfig(preserve_ops=...)` for `to_edge`; `_core_aten_ops_exception_list` alone no longer keeps it. A backend that runs the op can return it from its partitioner's `ops_to_not_decompose`.

The rewrite holds every output's window at once, so it needs more memory than a kernel: about 200 MiB to shrink one 3-channel float32 1080x1920 image to 224x224. With dynamic output sizes, memory is planned for a window bound that can be far too large, so prefer a fixed output size, or a scale factor without `align_corners` or `recompute_scale_factor=True`.

The CUDA backend keeps its own rewrite, which follows ATen's CUDA kernel, and could switch to these functions later.

## Testing

- A new test in `exir/program/test/test_program.py` runs lowered bicubic resizes (static, dynamic, channels_last, NaN inputs, a dim that keeps its size) on the portable kernels against eager, and checks both functions against ATen directly. It fails without this change.
- On CPU, over many sizes and scales, float64 matches eager ATen to about 1e-14 and float32 to about 5e-6, except an output width of 1 with a changing height, where ATen's CPU kernel disagrees with its own transposed result. bfloat16 and float16 match float32 ATen rounded once, within one unit in the last place except near zero.
- Resizes lowered for the portable kernels and XNNPACK, static and dynamic, match eager.
- Apart from the new test, the `exir`, XNNPACK and `backends/transforms` test suites give the same results with and without this change.
@shoumikhin
shoumikhin force-pushed the bicubic-aa-decomposition branch from 5b2469c to f98cc73 Compare November 1, 2026 07:14
Copilot AI balanced review requested due to automatic review settings November 1, 2026 07:14

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has insufficient remaining quota. Try requesting a review at a lower effort level.

Nothing calls it: the bilinear op keeps its portable kernel. With bicubic as
the only user, the helpers no longer take a filter and its support.
A window never holds more input pixels than the input has, so the extra taps
only cost memory. Capping them helps extreme shrinks and bounds the memory
planned for dynamic sizes.
Copilot AI balanced review requested due to automatic review settings November 1, 2026 09:58

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has insufficient remaining quota. Try requesting a review at a lower effort level.

@shoumikhin

Copy link
Copy Markdown
Contributor Author

@claude /pr-review

@claude

claude Bot commented Nov 1, 2026 •

Copy link
Copy Markdown

Claude finished @shoumikhin's task in 26m 32s —— View job


Needs discussion: this lets to_edge lower antialiased bicubic resizes, but the rewrite is on by default and replaces the op without any message for users who kept it. With dynamic sizes it can plan orders of magnitude more memory than it needs, where the bilinear op uses a portable kernel. It also copies the CUDA backend's rewrite, and the ways to keep the op that the description gives are untested.

Full review

PR Review: #24400

Summary

Adds a decomposition of aten._upsample_bicubic2d_aa into core ATen ops and registers it in _default_decomposition_table, so to_edge can lower F.interpolate(mode="bicubic", antialias=True).

Problems found:

  • The op is now replaced, with no message, for users who kept it.
  • preserve_ops does not keep the op in to_edge_transform_and_lower once a partitioner is given.
  • With dynamic sizes, the rewrite can plan orders of magnitude more memory than it needs.
  • It copies the CUDA backend's rewrite.
  • The ways to keep the op that the description gives are untested.

Nothing was run for this review because Python was blocked in this CI sandbox. Numerical agreement with ATen was not tested here.

Backward Compatibility

  • exir/tracer.py:672: users who kept the op now lose it, with no message.
    • to_edge removes only preserve_ops from the table (exir/program/_program.py:1483-1488). _core_aten_ops_exception_list only affects the verifier.
    • The verifier's error for this op (exir/verification/verifier.py:186-187) tells users to add it to _core_aten_ops_exception_list and link a kernel. Some users did that (with a custom kernel or an ATen-mode runtime), and some set _check_ir_validity=False. For float inputs, all of them now get the rewrite. Their kernel stops running and planned memory grows, with no warning.
    • The replacement the description gives, preserve_ops, does not work in to_edge_transform_and_lower once a partitioner is given. _gen_edge_manager_for_partitioners removes config.preserve_ops only for methods that have no partitioner (_program.py:1185-1189). The tables at :1202, :1232 and :1250 keep it.
    • With several partitioners, each one's pass decomposes every op it does not return from ops_to_not_decompose (:1192-1253). So the op survives only if every partitioner lists it, which limits the description's partitioner advice.
    • Suggest removing config.preserve_ops from those tables in the partitioner branch too. Also warn when this op is in _core_aten_ops_exception_list but not in preserve_ops. Fix this →

Testing

  • exir/program/test/test_program.py:349 does not cover several things the description promises.
    • Neither way to keep the op is tested. One is EdgeCompileConfig(preserve_ops=[torch.ops.aten._upsample_bicubic2d_aa.default]) through to_edge. The other is a partitioner's ops_to_not_decompose through to_edge_transform_and_lower.
    • The static case runs only contiguous inputs, and the dynamic case only channels_last ones (:376). So the common case, dynamic sizes with contiguous inputs, never runs on the runtime.
    • The description says XNNPACK lowering matches eager, but no test covers XNNPACK.
    • The output-size-1 handling at exir/_decompositions.py:47-51 exists for dynamic sizes, but only static sizes reach it (([1, 1], True)).
    • Integer inputs are checked only for the eager NotImplemented (:413-414). Nothing checks that to_edge still rejects them.
    • No input contains inf, though the description says a NaN or an inf reaches only the outputs whose window has it.
    • The description reports a mismatch with eager for an output width of 1 with a changing height. Neither a test nor a comment records it.
    • The lowering loop (:376-385) has no subTest. A failure does not say which resize, input or shape mode broke.
    • Fix this →

Performance

  • exir/tracer.py:672, exir/_decompositions.py:61, :73, :138-139: the rewrite is on by default, and with dynamic sizes its planned memory can be orders of magnitude too large, with no warning.
    • Each pass keeps taps and taps * weights alive together. In the width pass each has N·C·H·out_w·tap_count elements. The bilinear op _upsample_bilinear2d_aa has a portable kernel instead (kernels/portable/functions.yaml:1028, allow-listed at exir/verification/verifier.py:140).
    • On the num/den path, the planned bound for tap_count pairs the largest num with the smallest den, as the comment at :58-60 says.
    • Example, worked out by hand: F.interpolate(x, scale_factor=2, mode="bicubic", align_corners=True, antialias=True) with Dim(min=8, max=1024) on H and W. Here den is in [15, 2047] and num goes up to 1023. So reach ≤ 4092 // 15 = 272 and tap_count ≤ 545, where 5 are needed.
    • The width pass's taps is then planned at 1×3×1024×2048×545×4 B, about 13.7 GB, and the product needs as much again. About 126 MB is actually needed. The user finds out only from the planned arena size or on the device.
    • Suggest adding bicubic to kernels/portable/cpu/op_upsample_bilinear2d_aa.cpp by making its filter (bilinear_aa_filter, :26) and support (:50-52) parameters, instead of putting the rewrite in the default table.
    • If the rewrite stays, at least warn at export when out_size is symbolic on the num/den path. That happens with a scale factor and align_corners=True, a size taken from a dynamic tensor, or recompute_scale_factor=True, which are the cases the description tells users to avoid. Fix this →

Code Quality

  • exir/_decompositions.py:21-90 copies the CUDA backend's rewrite.
    • _cubic_filter is _cubic_kernel (backends/cuda/cuda_backend.py:558-564) line for line.
    • _antialias_taps follows CUDA's _antialias_taps (:567-615) step for step, comments included.
    • A fix to the shared window logic now has to be made twice. The in_size cap from c482697, for example, exists only here.
    • Suggest that CUDA import _cubic_filter now. Then a single _antialias_taps could take parameters for the ways the CPU and CUDA versions differ, such as copying unchanged dims and padding or masking out-of-window taps. CUDA's version already takes support and filter as parameters. Fix this →
  • examples/models/llava/model.py:217-219 is now out of date. The TODO says the bicubic resize is skipped "due to missing _upsample_bicubic_aa kernel in portable", but with this PR the op lowers through the rewrite. Update the reason (for example, the rewrite's memory) or enable the resize. Fix this →

Recommendation

Needs Discussion

The main open question is whether a rewrite with this memory cost belongs in the default table for every to_edge user, or whether the bilinear antialiasing portable kernel should also cover bicubic. If the rewrite stays, these need fixing:

  • Users who kept the op need at least a warning.
  • preserve_ops should work in to_edge_transform_and_lower.
  • The ways to keep the op need tests.

to_edge rejected F.interpolate(mode="bicubic", antialias=True) because the op
it exports to, aten._upsample_bicubic2d_aa, is not a core ATen op and
ExecuTorch had no kernel for it. The earlier commits on this branch decomposed
it into gathers, which planned very large buffers for dynamic sizes.

This replaces the decomposition with a portable kernel, the way the
antialiased bilinear resize already works: the op is an exception in the edge
verifier and runs on _upsample_bicubic2d_aa.out. The kernel follows ATen's CPU
algorithm. It resizes the width and then the height with the same weights, and
copies a dim whose size does not change. Half and bfloat16 are computed in
float. Between the two passes it keeps only the rows that one output row reads,
so its temporary memory stays small.

Graphs without this op are unchanged.
The note said the resize was skipped because the portable library had no
antialiased bicubic kernel. It has one now. The exported preprocessing still
only pads, because prepare_image() resizes the image before export, so the
note says that instead.
compute_scales_value is a port of ATen's helper, but it dropped ATen's check
that a scale is positive. A scale of zero, a negative scale or NaN then
became the resize ratio. ATen ignores such a scale and uses the input and
output sizes instead, so do the same, and drop the bicubic kernel's own copy
of the check, which also let NaN through.

Of the other upsample kernels, only the antialiased bilinear one can pass
such a scale to the helper, and it now matches ATen too. A test covers it.
The others check their scales earlier.
A scale far smaller than the output size implies made the kernel's
temporary sizes overflow, and it then wrote past its buffer. Very short
outputs also asked for more temporary memory than a whole resized plane.

- Reject a scale whose inverse is larger than the input size. F.interpolate
  never produces one, and it would overflow the window bounds.
- Reject an empty input height or width, as ATen does.
- Cap the window at the input size, and keep a ring of rows between the
  passes only when it is smaller than all of them.
- Keep the start of an empty window inside the input.
- Check that each temporary size fits in size_t, and align each buffer for
  its element type.
The tests now also run a batch of two, resizes of one dim in both layouts,
an output width of one, float64, NaN inputs at both ends of a window, and a
uint8 input, which the kernel rejects. A new float16 and bfloat16 case
averages two rows of opposite sign, so a kernel that rounded its first pass
to the half type would fail it, which the earlier cases could not catch. The
to_edge test reports the shape mode and the input shape of a failure.

For a contiguous input with an output width of one and a changing height,
ATen's CPU kernel gives every row the first row's values. The kernel resizes
the height there too, and its comment now says so, so that a later
comparison with ATen does not take it for a bug.
The bespoke-recipe sections now list six operators, not five.
windows.h defines IN as an empty macro, so a template parameter named IN
breaks the build if that header is ever included first. Use CTYPE_IN, as
other portable kernels do.
Copilot AI balanced review requested due to automatic review settings November 1, 2026 14:39
@shoumikhin shoumikhin removed the release notes: exir Changes to any dialects and passes on these dialects, such as memory planning label Nov 1, 2026
@shoumikhin shoumikhin changed the title Support antialiased bicubic resize in to_edge Add a portable kernel for antialiased bicubic resize Nov 1, 2026
@shoumikhin shoumikhin added the release notes: ops & kernels Changes to the opset and any new / changed kernel implementations label Nov 1, 2026
Copilot AI balanced review requested due to automatic review settings November 1, 2026 19:13

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has insufficient remaining quota. Try requesting a review at a lower effort level.

@shoumikhin

Copy link
Copy Markdown
Contributor Author

@claude /pr-review

@claude

claude Bot commented Nov 1, 2026 •

Copy link
Copy Markdown

Claude finished @shoumikhin's task in 26m 49s —— View job


Request Changes: listing the op in preserve_ops or _core_aten_ops_exception_list, which otherwise do nothing for it, now turns off the new integer-input check for the whole program without saying so, and partitioners can do the same; the other findings are small test gaps.

Full review

PR Review: #24400

Summary

Adds a portable _upsample_bicubic2d_aa.out kernel. It also lists the op among the edge verifier's built-in exceptions and rejects integer inputs at to_edge unless the config lists the op. The problem is how that rejection gets turned off. The other findings are small test gaps.

API Design

  • Two config fields that mean something else turn off the integer-input check (exir/verification/verifier.py:372-377).
    • No other effect: the op is already in the built-in exception list (:168), and nothing decomposes it (the default to_edge in test_program.py keeps all three nodes). So for this op, listing it in _core_aten_ops_exception_list or preserve_ops only turns off _check_bicubic_aa_inputs_are_float.

    • Not what the docs say: exir/capture/_config.py:43-51 describes the exception list as skipping only the core ATen check ("continue with the rest of the checks"), and preserve_ops as only preventing decomposition.

    • Whole program: once the op is listed, the check is off for every node of it, not just the nodes another kernel runs.

    • Partitioners can turn it off too:

      • to_edge_transform_and_lower adds the ops that partitioners keep to preserve_ops. It does this when it builds the edge program (exir/program/_program.py:918-928, :1239-1259) and again in its final check (:1418-1444).
      • Say a partitioner lists this op in ops_to_not_decompose but leaves a uint8 node outside the delegate. That node gets through export, then fails at run time in the kernel's dtype switch. That is the failure the check is there to stop.
      • No in-tree partitioner triggers this yet. AotiPartitioner is the only one that lists the op, because it lists every op (backends/aoti/aoti_partitioner.py:127-141), but it also delegates every remaining node.
      • test_program.py tests the opt-out only through to_edge.
    • Two copies of the dtype list: the verifier (verifier.py:78) and the kernel's switch (kernel :301-303) each hold one, and only a comment keeps them in step.

    • Fix:

      • Simplest: take integer inputs in the kernel, as the sibling does (op_upsample_bilinear2d_aa.cpp:270, ET_SWITCH_REALHBF16_TYPES). Compute in float, round and clamp at the output casts, and delete the check and its opt-out.
      • If integer inputs stay unsupported: give the opt-out its own documented field in EdgeCompileConfig, make sure ops that partitioners keep can't turn it off, and test it through to_edge_transform_and_lower.

      Fix this →

Testing

  • Two paths that resize both dims have no test.

    • Row buffer allocation failure (kernel :200-209): FailsWithoutTempMemory (test :446-461) removes the allocator and changes only the width, so it fails at the first weights allocation. The fixture's 16 KB pool can reach this failure instead. Resizing [1, 1, 3, 600] to (2, 300) uses 15,656 bytes for the weights and then needs 3,600 more for the rows.
    • Empty height windows (kernel :243-262): the only empty-window case (test :284-307) keeps the height at 3, so it runs the width-only branch. Resizing kSmallInput to (5, 3) with scales_h=0.4 leaves the last two rows with empty windows while the width changes too.
  • The Half and BFloat16 tolerance allows almost twice what its comment says (test :191-196).

    • The comment says "at most one unit in the last place".
    • rtol is 1.0 / 1024 for Half and 1.0 / 128 for BFloat16. Multiplied by the expected value, that is one unit at the bottom of each power-of-two range and almost two at the top.
    • Either say "two units" or compute the bound from each expected value's exponent.

    Fix this →

Recommendation

Request Changes

The opt-out has to change. Either support integer inputs and drop the check, or make the opt-out its own documented setting that partitioners can't turn on by accident. The test items are small.

Progress

  • Read the diff and the PR discussion
  • Run three independent reviews
  • Consolidate findings
  • Fact-check findings
  • Post the review

Nothing was built or run for this review. The findings come from reading the code, and the byte counts were worked out by hand from the kernel's allocation sizes.

Images often reach the resize as uint8. ATen computes this case with
weights in fixed point and rounds after each pass, and the kernel now does
the same, so its uint8 results match ATen exactly. The edge verifier now
lets uint8 through.
Listing the op in the config's exception list or preserve_ops turned the
check off for the whole program, and partitioners fill preserve_ops on
their own. Now that uint8 works, the check only caught dtypes that ATen's
CPU kernel rejects too. Without it such a resize fails in the kernel's
dtype check, as the antialiased bilinear resize does.
Running out of temporary memory for the rows between the passes, and height
windows past the input while the width also changes, had no test. The half
type tolerance comment now says what the tolerance allows.
A scale that F.interpolate never gives can make a window's weights nearly
cancel out, so the normalized weights get too large for 16-bit fixed
point. The kernel then shifted by a negative amount. It now fails with
InvalidArgument, as ATen's generic CPU path fails on the same input.
The test compares uint8 bit for bit and float32 within 1e-5, so random
inputs should not change between runs.
Copilot AI balanced review requested due to automatic review settings November 1, 2026 20:50

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has insufficient remaining quota. Try requesting a review at a lower effort level.

@shoumikhin

Copy link
Copy Markdown
Contributor Author

@claude /pr-review

@claude

claude Bot commented Nov 1, 2026 •

Copy link
Copy Markdown

Claude finished @shoumikhin's task in 41m 16s —— View job


Approve: two minor uint8 problems are left. A negative weight can overflow int16 without raising an error, and dead double weights take about 70% of the uint8 path's temporary memory.

Full review

PR Review: #24400

Summary

Adds a portable _upsample_bicubic2d_aa.out kernel and lists the op among the edge verifier's exceptions. Two minor problems remain, both in the uint8 path:

  • the fixed-point check misses negative weights that overflow int16;
  • the double weights stay allocated in temporary memory after they are converted.

Code Quality

  • The uint8 fit check only bounds the largest positive weight (kernels/portable/cpu/op_upsample_bicubic2d_aa.cpp:153-170, :177-184).

    • Problem: the precision is chosen from max_weight, the largest positive normalized weight. When a window's raw total is negative, its most negative normalized weight can be larger in magnitude than any positive one. static_cast<int16_t> then wraps it into a large positive weight, and the output is wrong with no error. The check added in 13c0d61 does not catch this, though its message says it rejects weights that "do not fit in fixed point".
    • Example, worked out by hand: a constant uint8 input of 100 with shape [1, 1, 1, 19], output_size=[1, 6] and scales_w=0.25.
      • The ratio is 4, which passes the check at :88.
      • Output 5 reads inputs 14 to 18, at filter positions −1.875 to −0.875. The raw total is −0.081, so the normalized weights are 0.084, 0.542, 0.904, 0.590 and −1.120.
      • The largest positive weight over all outputs is 0.904, which gives precision 15. Then −1.120 · 2^15 = −36,716, which wraps to +28,820.
      • Output 5 comes out as 255 instead of 100.
    • When it happens: only with a scale that disagrees with output_size, which F.interpolate never passes. ATen's uint8 path also takes the precision from the largest positive weight, so from memory it most likely wraps the same way.
    • Fix: in the conversion loop, return InvalidArgument when a rounded weight is outside the int16 range. Every input that is not rejected keeps ATen's precision rule, so bit-exactness is unchanged.

    Fix this →

Performance

  • About 70% of the uint8 path's temporary memory is dead double weights (kernels/portable/cpu/op_upsample_bicubic2d_aa.cpp:150-152, :171).

    • Problem: the double weights from compute_weights<double> stay allocated after they are converted, because the temp allocator cannot free them before the kernel returns. The int16 copies then go into a second buffer.
    • Example: take the description's case, shrinking 1080x1920 to 224x224.
      • The doubles take 37,632 + 66,304 = 103,936 B of the 146,496 B total.
      • The memory still in use after conversion is 42,560 B: first/size, the int16 weights and the row buffer. That is less than the float path's 96,768 B.
    • Fix, either of:
      • Write the int16 values over the doubles in place, in ascending order, as ATen does. Entry i only overwrites part of double i / 4, which has already been read. This saves the 25,984 B second buffer.
      • Find the precision in a first pass, then recompute each window into a max_size scratch buffer and quantize it directly. This brings uint8 down to about 43 KB, and the weights stay bit-identical.

    Fix this →

Recommendation

Approve

Neither point blocks the merge. The overflow needs a scale that F.interpolate never produces, and the memory saving only matters where temporary memory is tight.

Progress

  • Read the diff and the PR discussion
  • Run three independent reviews
  • Consolidate findings
  • Fact-check findings
  • Post the review

Nothing was built or run: Python needs approval in this sandbox, and there is no PyTorch source here. The findings come from reading the code, and the weights and byte counts were worked out by hand. The checkout is shallow, so the diff was taken against origin/main for the 15 changed files; it matches the PR's +1130/−9.

The fixed-point precision only bounds the largest weight. With a scale
that F.interpolate never gives, a window can sum to a negative value, so
its most negative weight is larger than any positive one and wrapped when
stored in 16 bits. ATen returns a wrong value there (255 where the float
result is 100). The kernel now fails with InvalidArgument instead.
Copilot AI balanced review requested due to automatic review settings November 1, 2026 21:37

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has insufficient remaining quota. Try requesting a review at a lower effort level.

@shoumikhin

Copy link
Copy Markdown
Contributor Author

@claude /pr-review

@claude

claude Bot commented Nov 1, 2026 •

Copy link
Copy Markdown

Claude finished @shoumikhin's task in 54m 59s —— View job


Approve: a few minor uint8 problems remain: the int sum can overflow for a scale that passes every check, the height-only path and the precision > 0 guard are untested, a comment misstates the precision rule, and dead double weights still take about 70% of the uint8 temporary memory.

Full review

PR Review: #24400

Summary

Adds a portable _upsample_bicubic2d_aa kernel, including ATen's fixed-point uint8 path, and an edge verifier exception so that the op stays whole through to_edge. Five minor problems remain, all in the uint8 path.

Code Quality

  • The uint8 sum can overflow int (kernels/portable/cpu/op_upsample_bicubic2d_aa.cpp:224-228).

    • Problem: the checks at :88-92, :166-170 and :183-187 bound the ratio and each weight, but nothing bounds the sum over a window. With a scale smaller than the output size implies, one window can hold thousands of weights that each fit in int16, while their sum times 255 does not fit in int.
    • Trigger: a uint8 [1, 1, 1, 8000] input that is 255 in columns 6579-7999 and 0 elsewhere, with output_size = {1, 3} and scales_w = 2.28e-4. The ratio of 4386 passes :88. Output 2 reads columns 2193-7999, the precision is 20, and its weights run from about −5,700 to 24,600, so every check passes. Its positive weights add up to about 1.5e7, and 255 times that is about 3.8e9, which is more than INT_MAX. That is undefined behavior; with wraparound the output is 0 instead of 255. Even a uniform 255 input overflows partway through, on the negative lobe.
    • Fix: accumulate in int64_t, which gives the same result whenever int does not overflow. Or, to keep rejecting such scales as the other checks do, fail in the conversion loop when (1 << (p - 1)) + 255 * pos > INT_MAX or (1 << (p - 1)) + 255 * neg < INT_MIN, where pos and neg are the sums of a window's positive and negative int16 weights.

    Fix this →

  • The comment on the uint8 weights is off by one bit (kernels/portable/cpu/op_upsample_bicubic2d_aa.cpp:140-142). It says the precision keeps twice the largest weight below 2^15. The loop at :159-163 stops once twice the weight would not fit, so it keeps the largest weight itself below 2^15: a largest weight of 1 gives precision 14, and twice 2^14 is 2^15. It also disagrees with the comment at :181-182.

    Fix this →

Testing

  • No test resizes only the height of a uint8 input (kernels/portable/cpu/op_upsample_bicubic2d_aa.cpp:323-331). Both cases in Uint8MatchesATenExactly (kernels/test/op_upsample_bicubic2d_aa_test.cpp:462-479) change both dims, and no output of test_antialiased_resize (exir/program/test/test_program.py:348-406) keeps its width, so this path never runs for uint8. Add a case such as {1, 1, 6, 8} to {5, 8}, with the expected values from ATen.

    Fix this →

  • RejectsUint8WeightsTooLargeForFixedPoint does not pin the precision > 0 check (kernels/test/op_upsample_bicubic2d_aa_test.cpp:580-596, kernel :166-170). Its weights are in the millions, so the int16 check at :183-187 rejects them too, and ET_EXPECT_KERNEL_FAILURE only checks that the call fails. The test still passes if the precision check is deleted. That check is the only guard when the largest weight is between 16,383.75 and 32,767.5 and no weight is below −32,768.5: the precision is then 0, and :224 would compute 1 << -1. A case in that band would pin it. Finding a scale for it takes a search, so this is low priority.

    Fix this →

Performance

  • uint8 still keeps its double weights in temporary memory (kernels/portable/cpu/op_upsample_bicubic2d_aa.cpp:150-152, :171), as raised in the last review. Shrinking 1080x1920 to 224x224 in uint8 takes 146,496 B, of which 103,936 B are double weights that nothing reads after the conversion; float takes 96,768 B. Writing the int16 weights over the double buffer brings uint8 down to 120,512 B. Computing one window at a time in two passes, the first finding the largest weight, brings it to about 43 KB with bit-identical weights. The PR description's 145 KB figure would change with it.

    Fix this →

Recommendation

Approve

None of these blocks the merge. The overflow and the untested guard only matter for scales that F.interpolate never produces, and the memory only matters where temporary memory is tight.

Progress

  • Read the diff and the PR discussion
  • Run three independent reviews
  • Consolidate findings
  • Fact-check findings
  • Post the review

Nothing was built or run: Python and the compiler need approval in this sandbox. The findings come from reading the code, and the weights, sums and byte counts were worked out by hand. The checkout is shallow, so the diff was taken against origin/main for the 15 changed files; it matches the PR's +1156/−9.

The uint8 path computed every double weight before converting them, and
the temporary memory held them until the kernel returned. It now computes
each window twice, once to find the largest weight and once to convert it,
so only one window of doubles is kept. Shrinking 1x3x1080x1920 to 224x224
now needs 43,024 bytes of temporary memory instead of 146,496. The float
path computes the same weights as before.
ATen adds up a uint8 window in int. With a scale that F.interpolate never
gives, one window can hold thousands of weights, and the sum overflows int.
For example, resizing a [1, 1, 1, 8000] input that is 255 from column 6579
on to a width of 3 with scales_w 2.28e-4 gave 0 for the last output, where
the float result is about 3660 and so 255 after clamping. The sum is now
64-bit, which gives the same result whenever int does not overflow.
The test for weights too large for fixed point used a scale whose weights
are also outside the int16 range, so it passed without the precision
check. It now uses a scale whose largest weight leaves no fraction bit
while every weight still fits in 16 bits. The uint8 test also resizes only
the height now, a path no uint8 case ran. The comment on the precision
rule said twice the largest weight stays below 2^15; it is the largest
weight itself.
HalfTypesMatchFloat32ATen resized kInput to (3, 5) and kOppositeRows to
(1, 5) in both float16 and bfloat16. Both cases take the same code path
in both types, and only the second one also catches a first pass rounded
to a half type, so the first one adds nothing.
uint8 allocates its fixed-point weights on its own path, which no test
ran without temporary memory. FailsWithoutTempMemory now checks it too,
with a new context so that the earlier failure does not make the check
pass on its own.
The kernel capped the number of weights per window at the input size,
which ATen does not do. Windows never hold more inputs than there are, so
the cap only saved a little memory when shrinking to one to three outputs,
and no result depended on it. The weights and results are unchanged.
Every case left now fails for some change to the kernel that no other
case catches. Cases that ran the same path as another case are removed:
growing in both dims, an output width of 1, NaN inputs, align_corners
with a single row, explicit scales on both dims, empty windows in the
width alone, the contiguous kept-dim resizes (the channels-last ones
remain) and two of the three uint8 sizes.

Two cases now also cover code no case reached:
- The scale guard test resizes the width too, so a height error that
  the width's weights overwrote would be caught, and it uses a ratio
  whose weights fit in memory, so the guard itself is what fails.
- The test that compares one resize with two takes a taller input, so
  that keeping every resized row between the passes would not fit in
  the fixture's temporary memory.
The program is exported with the first size and run on both. The other
three sizes ran the same program path again.
Copilot AI balanced review requested due to automatic review settings November 2, 2026 02:18

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has insufficient remaining quota. Try requesting a review at a lower effort level.

@shoumikhin

Copy link
Copy Markdown
Contributor Author

@claude /pr-review

@claude

claude Bot commented Nov 2, 2026 •

Copy link
Copy Markdown

Claude finished @shoumikhin's task in 25m 55s —— View job


Approve: none of the issues raised in this round held up when I checked them against the code.

Full review

PR Review: #24400

Summary

Adds a portable _upsample_bicubic2d_aa.out kernel and lists the op among the edge verifier's exceptions, so to_edge can lower F.interpolate(mode="bicubic", antialias=True). The kernel follows ATen's separable CPU path, including its uint8 fixed-point weights. No issues were found.

Recommendation

Approve

Nothing blocks the merge.

Progress

  • Read the diff and the PR discussion
  • Run three independent reviews
  • Consolidate findings
  • Fact-check findings
  • Post the review

Nothing was built or run, because commands such as git fetch need approval in this sandbox. The review comes from reading the code. ATen's source is not in this checkout, so the kernel was compared with ATen's CPU code from memory, and the ATen values pasted into the tests were not re-derived.

The description's temporary-memory figures for 1080x1920 to 224x224 were worked out by hand from the kernel's allocations, and they match: 96,768 B for float32 and 43,024 B for uint8. The checkout is shallow, so the diff was taken against origin/main for the 15 changed files; it matches the PR's +1092/−9.

This branch has not been deployed

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

Labels

ciflow/trunk CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. module: arm Issues related to arm backend release notes: ops & kernels Changes to the opset and any new / changed kernel implementations

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants