Skip to content

[Fix][Relax][Frontend][Torch] Follow torch's dtype rules for the division family - #20373

Open
hiyufan wants to merge 2 commits into
apache:mainfrom
hiyufan:fix/relax-torch-division-dtype
Open

hiyufan wants to merge 2 commits into
apache:mainfrom
hiyufan:fix/relax-torch-division-dtype

Conversation

@hiyufan

@hiyufan hiyufan commented Sep 17, 2026

Copy link
Copy Markdown
Contributor

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 main once that merges).

Problem

Three converters disagree with torch on what a division returns.

expression tensor dtype torch frontend on main
x / 2 int64 [3, 4, 5] float32 [1.5, 2.0, 2.5] int64 [1, 2, 2]
x / (x + 1) int64 float32 [0.75, 0.8, 0.83] int64 [0, 0, 0]
x / 2 bool float32 [0.5, 0.5, 0.0] bool
2 / x int64 [3, 4, 5] float32 [0.67, 0.5, 0.4] int64 [0, 0, 0]
torch.reciprocal(x) int64 float32 int64
x // 2 int64 / float32 int64 / float32 TypeError (same-dtype check)
torch.div(x, 2, rounding_mode="trunc") int64 int64 [-3, -1, 1, 3] TypeError
torch.div(x, 2, rounding_mode="floor") int64 int64 [-4, -2, 1, 3] TypeError

Three causes:

  1. div.Tensor / div.Scalar went through the generic _binary_op, which keeps the promoted dtype. torch's true division always yields a floating result — int64 / 2 and int64 / int64 are float32 — which is a rule on top of torch.result_type (an int scalar alone does not widen an int tensor), so it needs its own converter.
  2. div.Tensor_mode — what x // 2 and torch.div(x, s, rounding_mode=…) decompose to under run_decompositions() — built its constant with relax.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.
  3. reciprocal.default, what scalar / x decomposes to, divided const(1, x.dtype) by x, so 2 / int_tensor was an integer quotient too. The converter was duplicated in fx_translator.py and exported_program_translator.py; there is now one in the base class.

Fix

The two promotion closures inside _binary_op become methods (_promote_binary_operands, _promote_scalar_operand) so the division converters can share them. _true_division_operands applies torch's one extra rule for /:

lhs, rhs = self._promote_binary_operands(lhs, rhs)
if dtype is integral or bool:
    lhs, rhs = astype(lhs, "float32"), astype(rhs, "float32")
  • div.Tensor / div.Scalar (and fx truediv) → new _true_divide.
  • _div (rounding modes) promotes the same way and then keeps the promoted dtype: floorfloor_divide; trunc on an integer pair → plain divide, 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:

matched wrong dtype or values raised
#20372 645 52 132
this PR 799 0 30

Per-case diff of the failure lists: no case fails with this change that did not fail before it; 154 repaired. Every remaining wrong case 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), and x ** True on an integer tensor — plus the four division ops on that same uint8 / -3 input.

test_frontend_from_exported_program.py and test_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 --check clean on all four touched files.

Tests

  • test_true_division_of_integers_gives_float — IR-level: int64 / 2 casts both operands to float32 before the divide.
  • test_true_division_valuesx / s, s / x, x / (x + 1) and torch.reciprocal(x) over int64, int32, uint8 and bool tensors (and a float scalar), asserting dtype and values.
  • test_division_with_rounding_modex // 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:

test_true_division_values[int64-2]          result dtype int64, torch gives torch.float32
test_division_with_rounding_mode[int64]     TypeError: ... R.floor_divide(x, R.const(2, "int32")) ... T.int64 on the LHS
test_division_with_rounding_mode[float32]   TypeError: ... T.float32 on the LHS ... T.int32 on the RHS

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.

…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.
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.

1 participant