Skip to content

Commit 5131d6e

Browse files
_aten_to_core: implement masked_scatter
Register a lowering for aten.masked_scatter.default. Semantics: out = self.clone() out[mask] = source.flatten()[: mask.sum()] Implementation flattens self/mask/source, then for each flat position picks from source[cumsum(mask) - 1] if mask is True else self. False positions select away the gathered values via where(), so out-of-range indices there are harmless. Indices for True positions are guaranteed in [0, mask.sum() - 1] by construction. Supports dynamic shapes by reshaping with the runtime shape vector (coreai.get_shape) instead of the type-level shape, which carries a sentinel for unknown dims, and by using -1 in the flat reshapes to let coreai.reshape infer the numel. Mask broadcast also routes through the runtime shape when self is dynamic. Adds TestMaskedScatter covering: - Static and dynamic IR FileCheck (full operand chain pinned for static; op order asserted for dynamic). - Numerical validation across {f32, f16, i32} × {static, dynamic}. - Corner cases: all-False mask (output == self), all-True mask (output == src reshape), src larger than mask.sum() (extras unused), and a lower-rank mask that broadcasts right-aligned onto self.
1 parent 7171b3b commit 5131d6e

2 files changed

Lines changed: 200 additions & 0 deletions

File tree

coreai_torch/_aten_to_core.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1985,6 +1985,46 @@ def replace_logical_not(
19851985
return coreai.not_(x)
19861986

19871987

1988+
def replace_masked_scatter(
1989+
values_map: dict[str, Value], node: fx.Node, loc: Location
1990+
) -> Value:
1991+
"""Lowers aten.masked_scatter.default to:
1992+
1993+
out_flat = where(mask, source[cumsum(mask) - 1], self)
1994+
1995+
Out-of-range indices at False positions are harmless — where()
1996+
discards them.
1997+
"""
1998+
self_, mask, source = _get_operands(values_map, node, [0, 1, 2])
1999+
2000+
self_flat = coreai.reshape(self_, [-1])
2001+
if mask.type.shape != self_.type.shape:
2002+
target_shape = (
2003+
coreai.get_shape(self_)
2004+
if any(d < 0 for d in self_.type.shape)
2005+
else list(self_.type.shape)
2006+
)
2007+
mask = coreai.broadcast_to(mask, target_shape)
2008+
mask_flat = coreai.reshape(mask, [-1])
2009+
source_flat = coreai.reshape(source, [-1])
2010+
2011+
if source_flat.type.element_type != self_flat.type.element_type:
2012+
source_flat = coreai.cast(source_flat, self_flat.type.element_type)
2013+
2014+
mask_int = coreai.cast(mask_flat, np.int32)
2015+
idx = coreai.scan(mask_int, np.uint32(0), False, combiner="sum")
2016+
idx = coreai.broadcasting_sub(idx, coreai.constant(1, dtype=np.int32))
2017+
gathered = coreai.gather_along_axis(source_flat, idx, 0)
2018+
2019+
out_flat = coreai.broadcasting_where(mask_flat, gathered, self_flat)
2020+
out_shape = (
2021+
coreai.get_shape(self_)
2022+
if any(d < 0 for d in self_.type.shape)
2023+
else list(self_.type.shape)
2024+
)
2025+
return coreai.reshape(out_flat, out_shape)
2026+
2027+
19882028
def replace_maxpool2d_with_indices(
19892029
values_map: dict[str, Value], node: fx.Node, loc: Location
19902030
) -> OpResultList:
@@ -3408,6 +3448,7 @@ def sdpa_maskless(q: Value, k: Value, v: Value) -> Value:
34083448
"max.default": replace_max_default,
34093449
"max.dim": replace_max_dim,
34103450
"maximum.default": replace_binary_ops,
3451+
"masked_scatter.default": replace_masked_scatter,
34113452
"mean.default": replace_mean_default,
34123453
"mean.dim": replace_mean_dim,
34133454
"min.default": replace_min_default,

tests/ops/test_ops.py

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4066,6 +4066,165 @@ def forward(self, x: Tensor, index: Tensor, src: Tensor) -> Tensor:
40664066
)
40674067

40684068

4069+
class TestMaskedScatter:
4070+
"""Tests for aten.masked_scatter.default → coreai flatten / cumsum-1 /
4071+
gather / where lowering.
4072+
"""
4073+
4074+
class _Model(nn.Module):
4075+
def forward(self, self_: Tensor, mask: Tensor, src: Tensor) -> Tensor:
4076+
return torch.masked_scatter(self_, mask, src)
4077+
4078+
@staticmethod
4079+
def _shared_dynamic_shapes(self_: Tensor, src: Tensor) -> dict:
4080+
"""Build dynamic_shapes where self_ and mask share dim objects
4081+
(torch.export emits an equality guard when traced shapes match)
4082+
and src has its own independent dim.
4083+
"""
4084+
dims = {i: torch.export.Dim(f"d{i}", min=1) for i in range(self_.dim())}
4085+
return {
4086+
"self_": dims,
4087+
"mask": dims,
4088+
"src": {0: torch.export.Dim("s0", min=1)}
4089+
if src.dim() == 1
4090+
else _all_dims_dynamic(src, "s"),
4091+
}
4092+
4093+
@pytest.mark.ir
4094+
def test_lowers_ir_static(self) -> None:
4095+
"""Pin the full operand chain on a static shape."""
4096+
self_ = torch.zeros(2, 3)
4097+
mask = torch.tensor([[True, False, True], [False, True, False]])
4098+
src = torch.arange(1.0, 7.0)
4099+
program = torch.export.export(
4100+
self._Model(), args=(self_, mask, src)
4101+
).run_decompositions()
4102+
coreai_program = TorchConverter().add_exported_program(program).to_coreai()
4103+
filecheck_pattern(
4104+
str(coreai_program),
4105+
check_file="""
4106+
// CHECK-LABEL: coreai.graph @main
4107+
// CHECK-SAME: %arg0: tensor<2x3xf32>
4108+
// CHECK-SAME: %arg1: tensor<2x3xi1>
4109+
// CHECK-SAME: %arg2: tensor<6xf32>
4110+
// CHECK: %[[SF:.+]] = coreai.reshape %arg0, %{{.+}} : (tensor<2x3xf32>, tensor<1xsi32>) -> tensor<6xf32>
4111+
// CHECK: %[[MF:.+]] = coreai.reshape %arg1, %{{.+}} : (tensor<2x3xi1>, tensor<1xsi32>) -> tensor<6xi1>
4112+
// CHECK: %[[VF:.+]] = coreai.reshape %arg2, %{{.+}} : (tensor<6xf32>, tensor<1xsi32>) -> tensor<6xf32>
4113+
// CHECK: %[[MI:.+]] = coreai.cast %[[MF]] : tensor<6xi1> to tensor<6xsi32>
4114+
// CHECK: %[[CS:.+]] = coreai.scan %[[MI]], %{{.+}}, %{{.+}} combiner = <sum> : (tensor<6xsi32>, tensor<ui32>, tensor<i1>) -> tensor<6xsi32>
4115+
// CHECK: %[[IDX:.+]] = coreai.decomposable.broadcasting_sub %[[CS]], %{{.+}} : (tensor<6xsi32>, tensor<si32>) -> tensor<6xsi32>
4116+
// CHECK: %[[G:.+]] = coreai.gather_along_axis %[[VF]] at %[[IDX]] along %{{.+}} : (tensor<6xf32>, tensor<6xsi32>, tensor<si32>) to tensor<6xf32>
4117+
// CHECK: %[[W:.+]] = coreai.decomposable.broadcasting_where %[[MF]], %[[G]], %[[SF]] : (tensor<6xi1>, tensor<6xf32>, tensor<6xf32>) -> tensor<6xf32>
4118+
// CHECK: %[[OUT:.+]] = coreai.reshape %[[W]], %{{.+}} : (tensor<6xf32>, tensor<2xsi32>) -> tensor<2x3xf32>
4119+
// CHECK: coreai.output %[[OUT]]
4120+
""",
4121+
)
4122+
4123+
@pytest.mark.ir
4124+
def test_lowers_ir_dynamic(self) -> None:
4125+
"""Confirm the same op chain appears under dynamic shapes — shapes
4126+
become symbolic, but cast/scan/sub/gather/where/reshape order is
4127+
preserved.
4128+
"""
4129+
self_ = torch.zeros(2, 3)
4130+
mask = torch.zeros(2, 3, dtype=torch.bool)
4131+
src = torch.arange(6.0)
4132+
dynamic_shapes = self._shared_dynamic_shapes(self_, src)
4133+
program = torch.export.export(
4134+
self._Model(), args=(self_, mask, src), dynamic_shapes=dynamic_shapes
4135+
).run_decompositions()
4136+
coreai_program = TorchConverter().add_exported_program(program).to_coreai()
4137+
filecheck_pattern(
4138+
str(coreai_program),
4139+
check_file="""
4140+
// CHECK-LABEL: coreai.graph @main
4141+
// CHECK: coreai.reshape
4142+
// CHECK: coreai.reshape
4143+
// CHECK: coreai.reshape
4144+
// CHECK: coreai.cast {{.+}} to tensor<{{.*}}xsi32>
4145+
// CHECK: coreai.scan {{.+}} combiner = <sum>
4146+
// CHECK: coreai.decomposable.broadcasting_sub
4147+
// CHECK: coreai.gather_along_axis
4148+
// CHECK: coreai.decomposable.broadcasting_where
4149+
// CHECK: coreai.reshape
4150+
// CHECK: coreai.output
4151+
""",
4152+
)
4153+
4154+
@pytest.mark.parametrize("dynamic", [False, True])
4155+
@pytest.mark.parametrize("dtype", [torch.float32, torch.float16, torch.int32])
4156+
async def test_basic(self, dtype: torch.dtype, dynamic: bool) -> None:
4157+
"""self.shape == mask.shape, src is 1-D of length mask.numel()."""
4158+
self_ = torch.zeros(2, 3, dtype=dtype)
4159+
mask = torch.tensor([[True, False, True], [False, True, False]])
4160+
src = torch.arange(1, 7, dtype=dtype)
4161+
dynamic_shapes = self._shared_dynamic_shapes(self_, src) if dynamic else None
4162+
await validate_numerical_output(
4163+
model=self._Model().eval(),
4164+
self_=self_,
4165+
mask=mask,
4166+
src=src,
4167+
dynamic_shapes=dynamic_shapes,
4168+
)
4169+
4170+
@pytest.mark.parametrize("dynamic", [False, True])
4171+
async def test_all_false_mask(self, dynamic: bool) -> None:
4172+
"""No positions selected — output must equal self unchanged."""
4173+
self_ = torch.arange(6.0).reshape(2, 3)
4174+
mask = torch.zeros(2, 3, dtype=torch.bool)
4175+
src = torch.full((6,), -1.0)
4176+
dynamic_shapes = self._shared_dynamic_shapes(self_, src) if dynamic else None
4177+
await validate_numerical_output(
4178+
model=self._Model().eval(),
4179+
self_=self_,
4180+
mask=mask,
4181+
src=src,
4182+
dynamic_shapes=dynamic_shapes,
4183+
)
4184+
4185+
@pytest.mark.parametrize("dynamic", [False, True])
4186+
async def test_all_true_mask(self, dynamic: bool) -> None:
4187+
"""All positions selected — output must equal src reshaped to
4188+
self.shape, with self values fully overwritten.
4189+
"""
4190+
self_ = torch.zeros(2, 3)
4191+
mask = torch.ones(2, 3, dtype=torch.bool)
4192+
src = torch.arange(1.0, 7.0)
4193+
dynamic_shapes = self._shared_dynamic_shapes(self_, src) if dynamic else None
4194+
await validate_numerical_output(
4195+
model=self._Model().eval(),
4196+
self_=self_,
4197+
mask=mask,
4198+
src=src,
4199+
dynamic_shapes=dynamic_shapes,
4200+
)
4201+
4202+
async def test_src_larger_than_mask_sum(self) -> None:
4203+
"""src may carry more elements than mask.sum(); only the leading
4204+
mask.sum() are consumed (read flat). The rest are unused.
4205+
"""
4206+
self_ = torch.zeros(2, 3)
4207+
mask = torch.tensor([[True, False, True], [False, True, False]])
4208+
# 10 elements, only first 3 are needed (mask has 3 True positions)
4209+
src = torch.arange(1.0, 11.0)
4210+
await validate_numerical_output(
4211+
model=self._Model().eval(), self_=self_, mask=mask, src=src
4212+
)
4213+
4214+
async def test_broadcast_mask_lower_rank(self) -> None:
4215+
"""mask is rank-1 (3,) and self is rank-2 (2, 3) — mask is
4216+
right-aligned and broadcasts across the leading dim. Exercises
4217+
the broadcast_to branch in the lowering.
4218+
"""
4219+
self_ = torch.zeros(2, 3)
4220+
mask = torch.tensor([True, False, True])
4221+
# mask broadcasts to (2, 3) with 4 True positions
4222+
src = torch.arange(1.0, 5.0)
4223+
await validate_numerical_output(
4224+
model=self._Model().eval(), self_=self_, mask=mask, src=src
4225+
)
4226+
4227+
40694228
@pytest.mark.parametrize("dynamic", [False, True])
40704229
@pytest.mark.parametrize(
40714230
"x,dim,index",

0 commit comments

Comments
 (0)