Skip to content

Commit e9eb406

Browse files
committed
Fix aten.min.dim argmin indices at dtype-extremal minima
replace_min_dim computed argmin as argmax(x * -1), which is wrong at integer dtype extremes: uint8 min 0 negates to 0 (not the range top), and int8 min -128 overflows on negation. Reverse order with the bitwise complement ~x = x ^ -1 (broadcasting_bitwise_xor) for integer dtypes -- a strictly decreasing, overflow-free bijection that preserves first-index tie-breaking; keep x * -1 for float. Adds dtype-extremal and tie-break coverage.
1 parent 698f11a commit e9eb406

2 files changed

Lines changed: 121 additions & 5 deletions

File tree

coreai_torch/_aten_to_core.py

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2303,19 +2303,35 @@ def replace_min_dim(
23032303
) -> OpResultList:
23042304
"""Computes min along a specific dimension, returning (values, indices).
23052305
2306-
Argmin is computed as argmax(-x), since Core AI has argmax but not argmin.
2306+
Core AI has argmax but not argmin, so argmin is computed as argmax of an
2307+
order-reversed copy of ``x``. For integer (non-bool) types the reversal is
2308+
the bitwise complement ``~x`` (``x ^ -1``), which is a strictly decreasing
2309+
bijection over the full range of every signed/unsigned integer dtype and
2310+
never overflows -- unlike the ``-x`` negation trick, which is wrong at
2311+
dtype-extremal minima (e.g. uint8 ``0`` or int8 ``-128``). For float (and
2312+
bool) types the ``-x`` negation is exact, so it is kept.
23072313
"""
23082314
x = _get_operand(values_map, node, 0)
23092315
dim = node.args[1]
23102316
keepdim = len(node.args) >= 3 and bool(node.args[2])
23112317
dim = dim + x.type.rank if dim < 0 else dim
23122318

2319+
element_type = x.type.element_type
2320+
is_integer = isinstance(element_type, IntegerType)
2321+
is_bool = element_type == IntegerType.get_signless(1)
2322+
if is_integer and not is_bool:
2323+
# ~x == x ^ ALL_ONES; -1 has all bits set in two's complement.
2324+
reversed_x = coreai.broadcasting_bitwise_xor(
2325+
x, coreai.constant(-1, dtype=element_type)
2326+
)
2327+
else:
2328+
reversed_x = coreai.broadcasting_mul(
2329+
x, coreai.constant(-1, dtype=element_type)
2330+
)
2331+
23132332
min_values = coreai.reduce_min(x, [dim])
23142333
argmin_indices = coreai.cast(
2315-
coreai.argmax(
2316-
coreai.broadcasting_mul(x, coreai.constant(-1, dtype=x.type.element_type)),
2317-
dim,
2318-
),
2334+
coreai.argmax(reversed_x, dim),
23192335
np.int32,
23202336
)
23212337

tests/ops/test_ops.py

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3003,6 +3003,106 @@ def forward(self, x: Tensor) -> tuple[Tensor, Tensor]:
30033003
await validate_numerical_output(model=model, x=x, dynamic_shapes=dynamic_shapes)
30043004

30053005

3006+
class TestMinDimArgminDtypeExtremes:
3007+
"""min.dim argmin indices must be correct even at dtype-extremal minima.
3008+
3009+
Core AI has argmax but not argmin, so ``replace_min_dim`` derives the
3010+
argmin index by reversing the order of ``x`` and taking ``argmax``. The
3011+
reversal must be exact at the extremes of the input dtype's range:
3012+
3013+
* uint8 minimum ``0`` — a plain ``-x`` (i.e. ``x * -1``) negation maps
3014+
``0`` to ``0`` rather than the top of the unsigned range, so the true
3015+
minimum stops being the argmax.
3016+
* int8 minimum ``-128`` — ``-128 * -1`` overflows the signed range and
3017+
(under wrapping arithmetic) lands back on ``-128``.
3018+
3019+
Both were previously wrong on interpreter/cpu/gpu. The fix reverses integer
3020+
inputs with the bitwise complement ``~x`` (a strictly decreasing bijection
3021+
over the whole integer range that never overflows), leaving the exact float
3022+
negation path untouched. Ground truth is ``torch.min(x, dim=...)``.
3023+
"""
3024+
3025+
class MinDimModel(nn.Module):
3026+
def __init__(self, dim: int, keepdim: bool) -> None:
3027+
super().__init__()
3028+
self.dim = dim
3029+
self.keepdim = keepdim
3030+
3031+
def forward(self, x: Tensor) -> tuple[Tensor, Tensor]:
3032+
return torch.min(x, dim=self.dim, keepdim=self.keepdim)
3033+
3034+
@pytest.mark.parametrize("keepdim", [False, True])
3035+
@pytest.mark.parametrize(
3036+
"values,dtype",
3037+
[
3038+
# uint8: true minimum is 0 (bottom of the unsigned range).
3039+
([3, 1, 0, 5], torch.uint8),
3040+
# Signed dtypes at their exact minimum (INT_MIN), which overflows
3041+
# under the old ``x * -1`` negation trick.
3042+
([3, 1, -128, 5], torch.int8),
3043+
([3, 1, -(2**15), 5], torch.int16),
3044+
([3, 1, -(2**31), 5], torch.int32),
3045+
# NOTE: coreai-torch narrows int64 inputs to int32 at the graph
3046+
# boundary, so the exercisable extreme for the int64 dtype *path*
3047+
# is int32's minimum (a true int64 min of -2**63 would be
3048+
# truncated at that boundary, unrelated to this argmin fix).
3049+
([3, 1, -(2**31), 5], torch.int64),
3050+
],
3051+
)
3052+
async def test_dtype_min_value(
3053+
self, values: list[int], dtype: torch.dtype, keepdim: bool
3054+
) -> None:
3055+
x = torch.tensor(values, dtype=dtype)
3056+
model = self.MinDimModel(dim=0, keepdim=keepdim).eval()
3057+
await validate_numerical_output(model=model, x=x)
3058+
3059+
@pytest.mark.parametrize("keepdim", [False, True])
3060+
@pytest.mark.parametrize(
3061+
"values,dtype",
3062+
[
3063+
# Duplicate minima at the dtype extreme: torch returns the FIRST
3064+
# index (index 0 here), which the reversal must preserve.
3065+
([-128, -128, 5], torch.int8),
3066+
([0, 0, 5], torch.uint8),
3067+
],
3068+
)
3069+
async def test_duplicate_minima_first_index(
3070+
self, values: list[int], dtype: torch.dtype, keepdim: bool
3071+
) -> None:
3072+
x = torch.tensor(values, dtype=dtype)
3073+
# Sanity-check the ground-truth tie-break we are asserting against.
3074+
assert int(torch.min(x, dim=0).indices) == 0
3075+
model = self.MinDimModel(dim=0, keepdim=keepdim).eval()
3076+
await validate_numerical_output(model=model, x=x)
3077+
3078+
@pytest.mark.parametrize("keepdim", [False, True])
3079+
async def test_float_path_untouched(self, keepdim: bool) -> None:
3080+
# Float negation is exact/no-overflow; confirm it still validates.
3081+
x = torch.tensor([3.0, 1.0, -5.0, 2.0], dtype=torch.float32)
3082+
model = self.MinDimModel(dim=0, keepdim=keepdim).eval()
3083+
await validate_numerical_output(model=model, x=x)
3084+
3085+
@pytest.mark.parametrize("keepdim", [False, True])
3086+
async def test_bool_input_reduce_min_unsupported(self, keepdim: bool) -> None:
3087+
# torch.min(dim) on a bool tensor is legal in torch, and the argmin fix
3088+
# routes bool down the (mul-based) reversal branch — NOT the integer
3089+
# bitwise-complement branch — since ~x on i1 is not what we want. That
3090+
# branch is fine, but bool min.dim is independently unsupported at the
3091+
# *values* path: coreai.reduce_min rejects an i1 operand (unrelated to
3092+
# this argmin fix, and true on origin/main too). Pin that limitation so
3093+
# a future reduce_min-on-bool change is noticed here; if reduce_min
3094+
# gains i1 support, this test should flip to validate_numerical_output.
3095+
x = torch.tensor([True, False, True, False])
3096+
assert x.dtype == torch.bool
3097+
# First False is the minimum; torch returns its first index.
3098+
assert int(torch.min(x, dim=0).indices) == 1
3099+
model = self.MinDimModel(dim=0, keepdim=keepdim).eval()
3100+
# coreai.reduce_min's MLIR verifier rejects the i1 operand; save_asset
3101+
# surfaces it as a RuntimeError wrapping the underlying MLIRError.
3102+
with pytest.raises(RuntimeError):
3103+
await validate_numerical_output(model=model, x=x)
3104+
3105+
30063106
@pytest.mark.parametrize("dynamic", [False, True])
30073107
@pytest.mark.parametrize(
30083108
"shape,dim,keepdim,dtype",

0 commit comments

Comments
 (0)