Repository navigation
Keep Half and BFloat16 sums in float in portable matmul, var, cumsum and pooling kernels, and fix portable cat - #24401
Conversation
The portable addmm, mm and bmm kernels, and the int8 mixed_mm and mixed_linear kernels, kept their running sums in the tensor's own type. BFloat16 keeps only 8 significant bits, so a running sum of ones stops growing at 256, and a long dot product loses most of its accuracy. A BFloat16 nn.Linear with a bias exports to addmm. With 2560 inputs its output was off by about 8% of the output range, where PyTorch is off by about 0.25%. These kernels now multiply and add Half and BFloat16 values in float and round the sum once, as ATen does. addmm with a 1-D bias, the nn.Linear case, still rounds that sum once more when it adds the bias, so it lands within one rounding step of PyTorch (0.37% against 0.25%). For the int8 kernels this also stops a Half product such as 600 * 127 from overflowing before it is scaled. Products use the input type and sums the output type, so every other dtype keeps its exact results, even when mixed_linear writes a different output dtype. The Cadence HiFi mm and bmm kernels call the same helper for Half and BFloat16 and change the same way. The existing mixed_mm test now uses scales that Half holds exactly. Its expected values assumed exact 0.2 and 0.4, and the Half result now rounds once instead of matching them by chance.
…avg_pool2d These kernels also kept their running sums in the tensor's own type. In BFloat16, var over rows of 8192 values was off by about 50% of the output range and cumsum over rows of 4096 values by about 90%, where PyTorch is off by about 0.3%. avg_pool2d with an 8x8 window was about five times worse than PyTorch. They now keep the running sums of Half and BFloat16 values in float and round once at the end, as the portable sum, mean, softmax and avg_pool1d kernels already do. PyTorch also sums these types in a wider type. The 2D pooling helper gets the same accumulator type parameter as its 1D counterpart. For Half and BFloat16, cumsum keeps the sums of a block of columns in float while it walks the rows. Other dtypes keep the old code paths and their exact results.
🔗 Helpful Links🧪 See artifacts and rendered test results at hud.pytorch.org/pr/pytorch/executorch/24401
Note: Links to docs will display an error until the docs builds have been completed. ✅ No FailuresAs of commit e09832c with merge base 05092c1 ( This comment was automatically generated by Dr. CI and updates every 15 minutes. |
nn.Linear exports to addmm with a 1-D bias. That bias does not have the output's shape, so addmm took its broadcast path: it wrote the matmul result to the output in the tensor's own type, then added the bias in a second pass. For Half and BFloat16 that rounds every output twice, which left a BFloat16 nn.Linear with 2560 inputs at 0.37% of the output range, against PyTorch's 0.25%. A bias that is a single row, of shape [N] or [1, N], now goes through vec_addmm one output row at a time, so the sum and the bias meet in float and each output is rounded once. Column and scalar biases keep the broadcast path.
|
@claude /pr-review |
|
Claude finished @shoumikhin's task in 23m 54s —— View job Approve, no blockers: portable Detailed reviewPR Review: #24401SummaryThe PR makes the portable Code Quality
Testing
Performance
RecommendationApprove Nothing blocks. The two |
The portable cat kernel skipped every shape check for empty inputs. But get_cat_out_target_size can take the output shape from an empty input, and the copy loop sizes every input by the output shape. So cat of a 2x3 tensor and an empty 0x7 tensor gave a 2x7 output, and the copy read 14 values from an input that holds 6, past the end of its buffer. Follow PyTorch's rule in both functions: skip only 1-D empty tensors. Every other input, empty or not, must match the shape outside the cat dim, must have the cat dim in range, and adds its size along the cat dim to the output. Before, the output size added only non-empty inputs, so cat of empty 2x0 and 3x0 tensors gave 0x0 instead of 5x0. That wrong shape could not pass the stricter check in a later cat, so both change together. get_cat_out_target_size now also returns false for a dim that an input does not have, since the Cadence Fusion G3 kernel calls it before any other check. PyTorch rejects every input that this now rejects, so programs exported from PyTorch are not affected. Test: cat of a 2x3 tensor and an empty 0x7 tensor now fails. Before this change the kernel accepted it and read past the end of the 2x3 input. cat of a 1-D empty tensor with empty 2x0 and 3x0 tensors now gives 5x0, and an out-of-range dim with only an empty input now fails instead of aborting. The test that allowed an empty input of any shape now uses a 1-D empty tensor and an empty tensor of matching shape, and checks the values. Its old input, an empty tensor of another rank, now fails.
The portable cat kernel accepts inputs of different dtypes and converts each one to the output dtype. But when an input element had the same size as an output element, it copied the raw bytes instead. So int32 into a float32 output gave garbage, and so did int16 into half or int64 into double. Copy bytes only when the input and output types are the same, and convert otherwise, as the portable stack kernel already does. The choice is made at compile time, so each dtype pair keeps only the code it needs and the kernel does not grow much. Test: cat of a float32 and an int32 tensor into a float32 output now gives the converted values. Before this change, the int32 part held raw bits.
In the portable cat kernel, the complex branch returned from its loop at the first empty input. So cat([x, empty, y]) on complex tensors never copied y and left that part of the output untouched. Skip the empty input instead, as the real-dtype branch does. Test: the complex cat test now also concatenates x, an empty tensor and y, and expects the same result as x and y.
|
@claude /pr-review |
|
Claude finished @shoumikhin's task in 23m 21s —— View job Approve, no blockers: portable Detailed reviewPR Review: #24401SummaryThe PR makes the portable
Nothing was built or run for this review. The BFloat16 results below were worked out by hand from the code. Security
Code Quality
Performance
RecommendationApprove Nothing in the diff blocks this PR. The two |
This change named the accumulator type three ways: var, var_mean, cumsum and avg_pool2d used promote_types<T, T, /*half_to_float=*/true> as ACC, bmm used the same type as Acc, and vec_ops.h added its own opmath_t alias as Acc, AccT and AccU. Use the existing promote_types form and the name ACC everywhere, and drop opmath_t. In vec_ops.h the two input types are promoted together, so products and sums use one type. The grouped int8 matmul now also casts its scale to that type, as the other one does.
Only a full or single-row bias went through the float pass. A column, 1x1 or 0-D bias took the broadcast path, which rounded the matmul result to the tensor's type, then rounded again after each multiply and add. In BFloat16, addmm of a [2, 1] bias of ones with ones [2, 257] and [257, 3] gave 256 instead of 258. alpha and beta were also rounded to the tensor's type before use, so alpha 0.3 became 0.30078125 in BFloat16. vec_addmm now reads the bias through a row and a column stride, with 0 for a broadcast dim, so every bias shape takes one pass and each output is rounded once. alpha and beta are passed in the accumulator type, as ATen does. The broadcast path and the row-bias special case go away. Test: the BFloat16 bias test now covers row, column, 1x1 and 0-D biases and a non-unit alpha. The column, 1x1, 0-D and alpha cases fail without this change.
The Half and BFloat16 cumsum loop keeps float sums for a block of trailing positions and walks every row once per block. With 64 positions per block, a wide input took many strided passes: on Linux x86_64, BFloat16 cumsum along dim 0 of [4096, 4096] took 127 ms against 75 ms before this change. With 1024 positions it takes 80 to 83 ms (Half: 177 to 191 ms against 175 ms), at the cost of 4 KB of stack for these two dtypes. Also correct the comment: the rows are walked once per block, not once. Test: the BFloat16 cumsum test now has 1025 columns, so it also covers a second, partial block.
|
@claude /pr-review |
|
Claude finished @shoumikhin's task in 24m 13s —— View job Approve, no blockers: the new Half/BFloat16 Detailed reviewPR Review: #24401SummaryThe PR makes the portable Code Quality
Testing
Security
Performance
RecommendationApprove Nothing blocks. The |
The previous push made vec_ops.h use promote_types, which broke every build of the ATen-mode quantized kernels: with ATen's ScalarType helpers also in scope, the calls inside promote_types are ambiguous. Give vec_ops.h a small alias that needs only c10 and gives the same type, float for Half and BFloat16 and T otherwise, and drop the scalar_type_util dependency again. The kernels keep promote_types.
|
@claude /pr-review |
|
Claude finished @shoumikhin's task in 25m 30s —— View job Approve, no blockers: two minor issues remain. Clang may put the new 4 KB Half/BFloat16 Detailed reviewPR Review: #24401SummaryThe PR makes the portable Security
Backward Compatibility
RecommendationApprove Nothing blocks. The |
The Half and BFloat16 cumsum loop keeps a 4 KB block of float sums on the stack. cumsum_tensors has one call site per dtype, so Clang inlined every instantiation into cumsum_out and reserved the block in its frame for every dtype. Measured with clang -O2 -fstack-usage on Linux x86_64: cumsum_out's frame grew from 216 bytes on main to 4296 bytes. Marking cumsum_tensors ET_NOINLINE keeps the block in the Half and BFloat16 instantiations only (4280 bytes); every other dtype uses 184 bytes or less.
BFloat16BiasIsRoundedOnce sums 257 ones, so it already fails without float accumulation, which was all BFloat16LargeDimAccumulatesInFloat checked. Add the full-shape bias it had to the merged test and drop it.
get_cat_out_target_size returns false for a dim that an input does not have, for callers that skip check_cat_args. Portable cat runs check_cat_args first, so no test reached the check. Call the function directly, as the overflow tests do.
mixed_linear accepts a Double output when no dtype is given. Since its products moved to the output's accumulator type, a Float input with a Double output multiplied in double, so its results changed although neither dtype is Half or BFloat16. Multiply in the input's accumulator type and sum in the output's, so only Half and BFloat16 results change.
var and var_mean now divide the float sum by the element count, and the var_mean fast path by the denominator, in float. The tests used 1024 elements, which BFloat16 holds exactly, so rounding the count to BFloat16 first still passed, and their tolerance hid small errors. Use 2058 elements, which BFloat16 rounds to 2064, with values whose mean and variance move when the count or the denominator is rounded, and compare exactly. var_mean uses a correction of 1, so its denominator 2057 is not exact in BFloat16 either.
|
@claude /pr-review |
|
Claude finished @shoumikhin's task in 21m 41s —— View job Approve, no blockers: three minor issues remain: Half/BFloat16 Detailed reviewPR Review: #24401SummaryThe PR makes the portable Code Quality
Security
Backward Compatibility
RecommendationApprove Nothing blocks. The |
Half and BFloat16 cumsum reserved its 4 KB block of float sums on every call, including a cumsum along the last dim, which needs a single sum. That is the common case, for example in top-p sampling, and portable kernels run on targets with stacks of a few KB. Keep one float sum along the last dim, and move the blocked loop into its own function that is not inlined, so only the calls that use the block reserve it. The BFloat16 test now also covers the last dim and a leading dim of 2.
|
@claude /pr-review |
|
Claude finished @shoumikhin's task in 35m 29s —— View job Approve, no blockers: the Cadence HiFi and Fusion G3 cat kernels miss what this PR fixes in portable cat (pre-existing, fine as a follow-up), and mixed-dtype var/var_mean now skip ATen's rounding of inputs to the output dtype. Detailed reviewPR Review: #24401SummaryThe PR keeps Half and BFloat16 sums in float in the portable matmul, var, cumsum and pooling kernels and fixes portable cat. No blockers; two minor issues below. Findings come from reading the code; nothing was built or run. Security
Code Quality
RecommendationApprove Neither issue blocks merging: the Cadence one predates this PR, and the var one is a small change to the map lambdas. |
var and var_mean with a Float input and a Half or BFloat16 output used to convert each input to the output dtype and then sum, as ATen does. Summing in float converted the inputs straight to float, so Float [1000, 1001, 1002, 1003] gave a BFloat16 variance of about 1.66 instead of 4. Convert each input to the output dtype first, then to the float accumulator.
|
@claude /pr-review |
|
Claude finished @shoumikhin's task in 20m 35s —— View job Approve, no blockers: three minor issues remain. Half and BFloat16 Detailed reviewPR Review: #24401SummaryThe PR makes the portable Code Quality
Security
Backward Compatibility
RecommendationApprove Nothing blocks. The |
|
@pytorchbot cherry-pick --onto release/1.6 -c critical |
Cherry picking #24401The cherry pick PR is at #24443 and it is recommended to link a critical cherry pick PR with an issue. Details for Dev Infra teamRaised by workflow job |
### What is wrong today
`torch.cat` accepts tensors of different dtypes and promotes them:
```python
torch.cat([torch.ones(2, dtype=torch.bfloat16), torch.ones(2)]).dtype # torch.float32
```
`to_edge` rejects the same program:
```
SpecViolationError: These operators are taking Tensor inputs with mismatched dtypes:
... aten.cat.default ... {'tensors': torch.bfloat16, '__ret_0': torch.float32}
```
`torch.stack`, `torch.concat` and `torch.hstack` fail too, since they
become `cat`.
`cat([float32, int32])` is worse: it lowers without an error and gives
wrong values. The portable `cat` kernel fix behind those values is in
#24401.
### What this change does
1. `to_edge` casts the inputs of `cat` and `stack` to the dtype eager
promoted to. The `RemoveMixedTypeOperators` pass already did this for
binary ops like `add`. For `cat` and `stack` it takes that dtype from
the op's output, so an empty input dropped by an earlier pass still
counts.
2. The edge dtype check (`EdgeOpArgValidator`) now checks every tensor
of an input list, not only the first. That gap let `cat([float32,
int32])` pass.
3. `ConvertDtypePass` (used as `I64toI32`) left int64 topk indices in a
`cat` it made int32, which the check in item 2 now rejects. It now casts
the tensor-list inputs of ops it rewrites. Other inputs, like a `gather`
index, get no new cast.
Compatibility:
- A pass after `to_edge` that changes only some `cat` or `stack` inputs
to another dtype now fails the edge check. The Arm int64 passes, such as
`ConvertInt64OutputOpsToInt32Pass`, can do this when passed as transform
passes, as the Arm README shows. The Arm tester, scripts and recipes
lower with `_check_ir_validity=False`, so they are not affected, and the
README now says to do the same. An Arm test now expects this graph.
- A dropped empty input with a wider dtype now widens the output, as in
eager.
- Mixed `cat` programs that already ran, like `cat([float32, int64])`,
now carry a `_to_dim_order_copy` for each input of another dtype when
re-exported. A selective build needs that operator to load them.
### What was tested
- New tests, each failing without its fix:
`exir/program/test/test_program.py` (float, integer, empty and repeated
inputs), `exir/tests/test_arg_validator.py` and
`backends/transforms/test/test_convert_dtype_pass.py`.
- On the portable runtime, `cat` with mixed float, integer, bool and
empty inputs, `stack`, `concat` and `hstack` match eager.
- Apart from the new tests and the Arm test, `exir` and XNNPACK tests
and the Arm, Cadence, Qualcomm, Vulkan and `backends/transforms` pass
tests give the same results with and without this change. NXP and some
Cadence pass tests could not run here.
### What is wrong today
`torch.cat` accepts tensors of different dtypes and promotes them:
```python
torch.cat([torch.ones(2, dtype=torch.bfloat16), torch.ones(2)]).dtype # torch.float32
```
`to_edge` rejects the same program:
```
SpecViolationError: These operators are taking Tensor inputs with mismatched dtypes:
... aten.cat.default ... {'tensors': torch.bfloat16, '__ret_0': torch.float32}
```
`torch.stack`, `torch.concat` and `torch.hstack` fail too, since they
become `cat`.
`cat([float32, int32])` is worse: it lowers without an error and gives
wrong values. The portable `cat` kernel fix behind those values is in
#24401.
### What this change does
1. `to_edge` casts the inputs of `cat` and `stack` to the dtype eager
promoted to. The `RemoveMixedTypeOperators` pass already did this for
binary ops like `add`. For `cat` and `stack` it takes that dtype from
the op's output, so an empty input dropped by an earlier pass still
counts.
2. The edge dtype check (`EdgeOpArgValidator`) now checks every tensor
of an input list, not only the first. That gap let `cat([float32,
int32])` pass.
3. `ConvertDtypePass` (used as `I64toI32`) left int64 topk indices in a
`cat` it made int32, which the check in item 2 now rejects. It now casts
the tensor-list inputs of ops it rewrites. Other inputs, like a `gather`
index, get no new cast.
Compatibility:
- A pass after `to_edge` that changes only some `cat` or `stack` inputs
to another dtype now fails the edge check. The Arm int64 passes, such as
`ConvertInt64OutputOpsToInt32Pass`, can do this when passed as transform
passes, as the Arm README shows. The Arm tester, scripts and recipes
lower with `_check_ir_validity=False`, so they are not affected, and the
README now says to do the same. An Arm test now expects this graph.
- A dropped empty input with a wider dtype now widens the output, as in
eager.
- Mixed `cat` programs that already ran, like `cat([float32, int64])`,
now carry a `_to_dim_order_copy` for each input of another dtype when
re-exported. A selective build needs that operator to load them.
### What was tested
- New tests, each failing without its fix:
`exir/program/test/test_program.py` (float, integer, empty and repeated
inputs), `exir/tests/test_arg_validator.py` and
`backends/transforms/test/test_convert_dtype_pass.py`.
- On the portable runtime, `cat` with mixed float, integer, bool and
empty inputs, `stack`, `concat` and `hstack` match eager.
- Apart from the new tests and the Arm test, `exir` and XNNPACK tests
and the Arm, Cadence, Qualcomm, Vulkan and `backends/transforms` pass
tests give the same results with and without this change. NXP and some
Cadence pass tests could not run here.
cc @digantdesai @freddan80 @per @zingo @oscarandersson8218 @mansnils
@Sebastian-Larsson @robell @rascani
Co-authored-by: Anthony Shoumikhin <anthony@shoumikh.in>
What is wrong today
Several portable CPU kernels add up their results in the tensor's own type. BFloat16 keeps only 8 significant bits, so once a running sum of ones reaches 256, adding 1 no longer changes it.
The most visible case is
nn.Linearwith a bias, which exports toaten.addmmand runs on the portable kernel when no backend takes it, as with BFloat16 and the XNNPACK partitioner's defaults. With 2560 inputs, its output was off by about 8% of the output range, where PyTorch is off by 0.25%.What this change does
The affected kernels now sum Half and BFloat16 values in float and round once at the end. PyTorch and the portable
sum,mean,softmaxandavg_pool1dkernels already sum in a wider type. Other dtypes keep their exact results. For example,mixed_linearwith a Float input and a Double output still multiplies in float and only sums in double, as before.vec_ops.h: the dot product helpers behindaddmm,mm, and the int8mixed_mmandmixed_linearkernels. They also multiply in float, which for the int8 kernels stops a Half product like 600 * 127 from overflowing before it is scaled. The Cadence HiFimmandbmmkernels use them too.bmm: the same, in its own loop. ComplexHalf is unchanged; PyTorch has no ComplexHalfbmmon CPU.addmm: every bias shape, including the 1-D bias ofnn.Linear, is added in the same float pass, andalphaandbetastay in float, as in ATen. Whenbetais 0, the bias is now ignored, as PyTorch documents, so a NaN in it no longer turns the output into NaN.var,var_mean,cumsumandavg_pool2d. With a Float input and a Half or BFloat16 output,varandvar_meanstill convert each input to the output dtype before summing, as ATen does. The 2D pooling helper gets the accumulator type parameter of its 1D counterpart. For Half and BFloat16,cumsumkeeps its running sums in float: one sum along the last dim, or a block of column sums while it walks the rows; other dtypes keep the old loop.This change also fixes the portable
catkernel. It skipped every shape check for empty inputs, but the output shape could come from an empty input, socatof a 2x3 tensor and an empty 0x7 tensor read 14 values from an input that holds 6.check_cat_argsandget_cat_out_target_sizenow follow PyTorch: only a 1-D empty tensor is skipped, and other empty inputs must match the other inputs outside the cat dim and count toward the output size. Eager PyTorch rejects every input that is now rejected. The portable kernel also copied raw bytes between dtypes of the same size, so an int32 input in a float32catgave bit patterns. It now converts. Its complex branch stopped at the first empty input, socat([x, empty, y])never copiedy. It now keeps copying.What was tested
cumsumdtype sweep now includes Half and BFloat16, and themixed_mmtest uses scales Half holds exactly (it assumed exact 0.2 and 0.4). The disabledmixed_lineardtype tests for Half input or output now run, andaddmmwithbeta0 and a NaN bias returns the product.cattests, each failing without its fix: an empty input of another shape is rejected (without the fix, AddressSanitizer reports a heap buffer overflow), all-empty inputs get PyTorch's output shape, int32 converts into a float32 output, and complexcatcopies past an empty input.addmm, 1-D bias, 2560 inputsaddmm, 2-D bias, 2560 inputsmm, 2560 inputsvar, rows of 8192cumsum, rows of 4096avg_pool2d, 8x8 window