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.
…sion family Three converters disagreed with torch on what a division returns: - `div.Tensor` / `div.Scalar` went through the generic `_binary_op`, which keeps the promoted dtype. torch's true division always yields a floating result, so `int64 / 2` and `int64 / int64` are float32 there and were an integer quotient here: `[3, 4, 5] / 2` came back as int64 `[1, 2, 2]` instead of float32 `[1.5, 2, 2.5]`. - `div.Tensor_mode` (which `x // 2` and `torch.div(x, 2, rounding_mode=...)` decompose to) built its scalar constant with `relax.const(inp_2)` and no dtype, i.e. int32, so every float tensor and every non-int32 integer tensor failed the same-dtype check: `x // 2` raised `TypeError` for float32 and int64 inputs alike. The int32 case passed only because relax.const's default dtype happens to be int32. - `reciprocal.default`, which `scalar / x` decomposes to, divided `const(1, x.dtype)` by x, so `2 / int_tensor` was an integer quotient as well. The converter was duplicated in both translators; there is now one in the base class. The two promotion closures inside `_binary_op` become methods (`_promote_binary_operands`, `_promote_scalar_operand`) so the division converters share them, and `_true_division_operands` adds the one rule true division has on top of `torch.result_type`: an integral or bool pair is cast to the default float dtype. `div.Tensor` / `div.Scalar` dispatch to a new `_true_divide`; `_div` promotes its operands the same way and then keeps the promoted dtype for `floor` and `trunc`. Integer division in relax truncates toward zero, so `trunc` on an integer pair is a plain divide; `floor` is `floor_divide`; floats go through divide + trunc as before. int64 [3, 4, 5] / 2 torch float32 [1.5, 2.0, 2.5] before int64 [1, 2, 2] bool [T, T, F] / 2 torch float32 [0.5, 0.5, 0.0] before bool 2 / int64 [3, 4, 5] torch float32 [0.67, 0.5, 0.4] before int64 [0, 0, 0] int64 [-7, -3, 3, 7] // 2 torch int64 [-4, -2, 1, 3] before TypeError float32 x // 2 torch float32 before TypeError torch.div(x, 2, "trunc") torch int64 [-3, -1, 1, 3] before TypeError Same 864-program sweep as the previous commit (18 binary ops x 8 dtypes x 6 Python scalars, built with relax.build(llvm), result dtype and values compared with torch), measured against that commit as the base: base 645 matched 52 wrong dtype or values 132 raised after 799 matched 0 wrong 30 raised no case fails after this change that did not fail before it; 154 repaired The 30 left are the same pre-existing edges as before: relax rejecting arithmetic on a bool tensor with a bool scalar, a uint8 tensor against a negative scalar (torch wraps, relax.const raises), and `x ** True` on an integer tensor. Tests: an IR-level check that `int64 / 2` casts both operands to float32 before the divide; numeric checks of `x / s`, `s / x`, `x / (x + 1)` and `torch.reciprocal(x)` over int64, int32, uint8 and bool tensors; and `x // 2`, `torch.div(..., "floor")`, `torch.div(..., "trunc")` and a tensor divisor on `[-7, -3, 3, 7]` for int64, int32 and float32, where the negative inputs separate floor from trunc. 8 of 9 fail against the previous head; the int32 rounding-mode case passes there for the int32-default reason above and pins that it keeps working.
hiyufan
force-pushed
the
fix/relax-torch-division-dtype
branch
from
September 17, 2026 08:27
49d7915 to
ed4b454
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.
Stacked on #20372 — the first commit here is that PR; this PR is the second commit. It reuses the scalar-promotion helpers from #20372, so it should land after it (or I can rebase onto
mainonce that merges).Problem
Three converters disagree with torch on what a division returns.
mainx / 2[3, 4, 5][1.5, 2.0, 2.5][1, 2, 2]x / (x + 1)[0.75, 0.8, 0.83][0, 0, 0]x / 2[0.5, 0.5, 0.0]2 / x[3, 4, 5][0.67, 0.5, 0.4][0, 0, 0]torch.reciprocal(x)x // 2TypeError(same-dtype check)torch.div(x, 2, rounding_mode="trunc")[-3, -1, 1, 3]TypeErrortorch.div(x, 2, rounding_mode="floor")[-4, -2, 1, 3]TypeErrorThree causes:
div.Tensor/div.Scalarwent through the generic_binary_op, which keeps the promoted dtype. torch's true division always yields a floating result —int64 / 2andint64 / int64are float32 — which is a rule on top oftorch.result_type(an int scalar alone does not widen an int tensor), so it needs its own converter.div.Tensor_mode— whatx // 2andtorch.div(x, s, rounding_mode=…)decompose to underrun_decompositions()— built its constant withrelax.const(inp_2)and no dtype, i.e. int32. Every float tensor and every non-int32 integer tensor then failed relax's same-dtype check. The int32 case passed only because relax.const's default dtype happens to be int32.reciprocal.default, whatscalar / xdecomposes to, dividedconst(1, x.dtype)byx, so2 / int_tensorwas an integer quotient too. The converter was duplicated infx_translator.pyandexported_program_translator.py; there is now one in the base class.Fix
The two promotion closures inside
_binary_opbecome methods (_promote_binary_operands,_promote_scalar_operand) so the division converters can share them._true_division_operandsapplies torch's one extra rule for/:div.Tensor/div.Scalar(and fxtruediv) → new_true_divide._div(rounding modes) promotes the same way and then keeps the promoted dtype:floor→floor_divide;truncon an integer pair → plaindivide, since integer division in relax truncates toward zero (checked:[-7, -3, 3, 7] / 2→[-3, -1, 1, 3]); floats keep divide +trunc._reciprocal→_true_division_operands(1, x)then divide, in the base class.Verification
The same 864-program sweep as #20372 (18 binary ops × 8 tensor dtypes × 6 Python scalars, built with
relax.build(llvm), result dtype and values compared with torch), measured against #20372's head as the base:Per-case diff of the failure lists: no case fails with this change that did not fail before it; 154 repaired. Every remaining
wrongcase is gone. The 30 that still raise are the same pre-existing edges noted in #20372 — relax rejecting arithmetic on a bool tensor with a bool scalar, a uint8 tensor against a negative scalar (torch wraps modulo 256,relax.const(-3, "uint8")raises), andx ** Trueon an integer tensor — plus the four division ops on that sameuint8 / -3input.test_frontend_from_exported_program.pyandtest_frontend_from_fx.py: failure sets identical before and after apart from the new tests (8 and 15 pre-existing in my environment).ruff check/ruff format --checkclean on all four touched files.Tests
test_true_division_of_integers_gives_float— IR-level:int64 / 2casts both operands to float32 before the divide.test_true_division_values—x / s,s / x,x / (x + 1)andtorch.reciprocal(x)over int64, int32, uint8 and bool tensors (and a float scalar), asserting dtype and values.test_division_with_rounding_mode—x // 2,torch.div(…, "floor"),torch.div(…, "trunc")and a tensor divisor on[-7, -3, 3, 7]for int64, int32 and float32; the negative inputs are what separate floor from trunc.8 of the 9 fail against #20372's head:
The ninth,
test_division_with_rounding_mode[int32], passes there for the int32-default reason above; it stays in to pin that the path keeps working.This change was prepared with AI assistance (Claude). I have reviewed and verified it, and can speak to it in review.