[Fix][Relax][Frontend][Torch] Promote the tensor, not the scalar, in binary ops with a Python scalar - #20372
Open
hiyufan wants to merge 1 commit into
Open
[Fix][Relax][Frontend][Torch] Promote the tensor, not the scalar, in binary ops with a Python scalar#20372hiyufan wants to merge 1 commit into
hiyufan wants to merge 1 commit into
Conversation
…binary ops with a Python scalar `_binary_op` built the constant for a Python scalar operand in the tensor's own dtype, so a float scalar against an integer or bool tensor was truncated before the op ran: `x * 0.5` on an int64 tensor became `x * 0`, `x + 0.5` became `x + 0`, `x < 1.5` became `x < 1`, and `bool_tensor * 2.5` stayed bool. torch promotes the other way. A Python scalar takes part in type promotion at a lower priority than a tensor and widens it only when its category is higher: a float scalar promotes an integer or bool tensor to the default float dtype, an int scalar promotes a bool tensor to int64, and otherwise the tensor's dtype wins. Use torch.result_type as the oracle for that rule, cast the tensor when it has to widen, and build the constant in the promoted dtype. Tensor-tensor promotion was already right and is untouched. The two Constant-vs-scalar dispatch branches pre-cast the scalar the same wrong way and now go through the same path. int64 tensor * 0.5 torch float32 [0.5, 1.0, 1.5] before int64 [0, 0, 0] int64 tensor + 0.5 torch float32 [1.5, 2.5, 3.5] before int64 [1, 2, 3] int64 tensor < 1.5 torch [T, F, F] before [F, F, F] bool tensor * 2.5 torch float32 [2.5, 0, 2.5] before bool [T, F, T] bool tensor + 1 torch int64 [2, 1, 2] before InternalError int64 tensor ** 0.5 torch float32 before InternalError Swept 18 binary ops x 8 tensor dtypes x 6 Python scalars (864 programs, 829 that torch accepts), each built with relax.build(llvm) and compared with torch on result dtype and values: before 477 matched 149 wrong dtype or values 203 raised after 645 matched 52 wrong 132 raised no case fails after this change that did not fail before it; 168 repaired Of what is left, 157 are the division family, where true division of two integers has to give a float even for an int scalar and div.Tensor_mode builds its constant with no dtype at all; that is a separate change. The rest are relax rejecting arithmetic on bool tensors, a uint8 tensor against a negative scalar (torch wraps, relax.const raises), and `x ** True` on an integer tensor. test_linspace's expected IR encoded the truncation: torch's decomposition splits the range at `i < 4.5`, which the frontend emitted as `R.less(i, R.const(4, "int64"))`. It now promotes the index to float32 and compares against 4.5, and the expected module is updated to match. Tests: an IR-level check that `int64 + 0.5` emits astype plus a float32 constant, and numeric checks over add/mul/lt/ge/eq (both operand orders) and sub/rsub/pow/ remainder across int64, int32, uint8, float16, float32 and bool tensors with int, float and bool scalars, asserting both the result dtype and the values. 22 of the 44 fail against the previous head; the other 22 are cases where the tensor's dtype wins, and pin that nothing there moved.
hiyufan
force-pushed
the
fix/relax-torch-scalar-promotion
branch
from
September 17, 2026 08:27
0b01144 to
1e8abd0
Compare
This was referenced Sep 17, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
When a binary op has a Python scalar operand,
_binary_opbuilds the constant in the tensor's own dtype:For a float scalar against an integer or bool tensor that truncates the scalar before the op runs. torch promotes the other way — a Python scalar takes part in type promotion at a lower priority than a tensor and widens it only when its category is higher — so the two disagree on both dtype and values:
mainx * 0.5[1, 2, 3][0.5, 1.0, 1.5][0, 0, 0]x + 0.5[1.5, 2.5, 3.5][1, 2, 3]0.5 - x[-0.5, -1.5, -2.5][-1, -2, -3]x < 1.5[T, F, F][F, F, F]x * 2.5[T, F, T][2.5, 0, 2.5][T, F, T]x + 1[2, 1, 2]InternalError(relax add on bool)x ** 0.5InternalError(power only applies to float)Nothing raises in the common rows, so
x * 0.5on an integer tensor silently imports as a tensor of zeros. Tensor-tensor promotion (int_tensor * float_tensor) was already handled by_promote_common_dtypeand is correct; only the scalar path was wrong.The rule torch applies is
torch.result_type(tensor, scalar):Fix
_scalar_result_dtypeaskstorch.result_typefor the promoted dtype; the scalar branch ofpromote_binary_op_argscasts the tensor when it has to widen and builds the constant in that dtype. The twoConstant-vs-scalar dispatch branches pre-cast the scalar the same wrong way, and now go through the same path.This is the shared
_binary_op, so it coversadd/sub/mul/pow/remainder/the six comparisons/maximum/minimum/atan2/logaddexpin bothfrom_fxandfrom_exported_program.Verification
A sweep of 18 binary ops × 8 tensor dtypes (bool, uint8, int8, int32, int64, float16, float32, float64) × 6 Python scalars (
True,2,-3,0.5,1.5,-0.5), each built withrelax.build(llvm)and compared with torch on result dtype and values — a shape or dtype-only check would not see[0, 0, 0]:main864 programs, 829 accepted by torch. Diffing the two failure lists per case: no case fails with this change that did not fail before it; 168 repaired.
What is left is not this change:
x / 2,x // 2,torch.div(x, 2, rounding_mode=…)). True division of two integers has to give a float even for an int scalar — a rule on top ofresult_type— anddiv.Tensor_modebuilds its constant with no dtype at all, which is whyx // 2currently raises on every float tensor. That is a separate PR on top of this one.bool_tensor + True), 11 are a uint8 tensor against a negative scalar (torch wraps modulo 256,relax.const(-3, "uint8")raises), 6 arex ** Trueon integer tensors. All pre-existing and out of scope.test_linspace's expected IR encoded the truncation: torch'slinspacedecomposition splits the range atlt.Scalar(arange, 4.5), which the frontend emitted asR.less(i, R.const(4, "int64")). It now promotes the index to float32 and compares against4.5; the expected module is updated. (Forlinspace(0, 1, 9)both branches of thewherecompute the same values, which is why the truncated split never showed up numerically.)tests/python/relax/test_frontend_from_exported_program.pyandtest_frontend_from_fx.py: the failure sets are identical before and after apart from the new tests (9 and 15 pre-existing failures in my environment, alltest_dtypes/test_prodand friends).ruff checkandruff format --checkare clean on both touched files.Tests
test_binary_python_scalar_promotes_the_tensor— IR-level:int64 + 0.5emitsastypeplusR.const(0.5, "float32").test_binary_python_scalar_promotion_values—add/mul/lt/ge/eq, both operand orders, over the eight(dtype, scalar)rows of the table above, asserting the result dtype and the values.test_binary_python_scalar_promotion_sub_pow_remainder— the three ops torch does not define on a bool tensor, on their own case list.22 of the 44 parametrised cases fail against the previous head with the messages above (
result dtype int64, torch gives torch.float32); the other 22 are the rows where the tensor's dtype wins, and pin that nothing there moved.This change was prepared with AI assistance (Claude). I have reviewed and verified it, and can speak to it in review.