Skip to content

Commit a68f1ad

Browse files
_aten_to_core: implement masked_scatter (#16)
Description: Implements aten.masked_scatter.default with semantics out[mask] = source.flatten()[: mask.sum()]. Flattens self/mask/source, then for each flat position selects source[cumsum(mask) - 1] where mask is True and self otherwise; False-position indices are masked away by where, so out-of-range gathers there are harmless. Supports dynamic shapes by reshaping with the runtime shape vector (get_shape) and using -1 to infer numel in flat reshapes; mask broadcast also routes through the runtime shape when self is dynamic. Testing: python unit tests ci enables conversion of smol VLM model (static config)
1 parent ced5268 commit a68f1ad

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
@@ -2038,6 +2038,46 @@ def replace_logical_not(
20382038
return coreai.not_(x)
20392039

20402040

2041+
def replace_masked_scatter(
2042+
values_map: dict[str, Value], node: fx.Node, loc: Location
2043+
) -> Value:
2044+
"""Lowers aten.masked_scatter.default to:
2045+
2046+
out_flat = where(mask, source[cumsum(mask) - 1], self)
2047+
2048+
Out-of-range indices at False positions are harmless — where()
2049+
discards them.
2050+
"""
2051+
self_, mask, source = _get_operands(values_map, node, [0, 1, 2])
2052+
2053+
self_flat = coreai.reshape(self_, [-1])
2054+
if mask.type.shape != self_.type.shape:
2055+
target_shape = (
2056+
coreai.get_shape(self_)
2057+
if any(d < 0 for d in self_.type.shape)
2058+
else list(self_.type.shape)
2059+
)
2060+
mask = coreai.broadcast_to(mask, target_shape)
2061+
mask_flat = coreai.reshape(mask, [-1])
2062+
source_flat = coreai.reshape(source, [-1])
2063+
2064+
if source_flat.type.element_type != self_flat.type.element_type:
2065+
source_flat = coreai.cast(source_flat, self_flat.type.element_type)
2066+
2067+
mask_int = coreai.cast(mask_flat, np.int32)
2068+
idx = coreai.scan(mask_int, np.uint32(0), False, combiner="sum")
2069+
idx = coreai.broadcasting_sub(idx, coreai.constant(1, dtype=np.int32))
2070+
gathered = coreai.gather_along_axis(source_flat, idx, 0)
2071+
2072+
out_flat = coreai.broadcasting_where(mask_flat, gathered, self_flat)
2073+
out_shape = (
2074+
coreai.get_shape(self_)
2075+
if any(d < 0 for d in self_.type.shape)
2076+
else list(self_.type.shape)
2077+
)
2078+
return coreai.reshape(out_flat, out_shape)
2079+
2080+
20412081
def replace_maxpool2d_with_indices(
20422082
values_map: dict[str, Value], node: fx.Node, loc: Location
20432083
) -> OpResultList:
@@ -3480,6 +3520,7 @@ def sdpa_maskless(q: Value, k: Value, v: Value) -> Value:
34803520
"max.default": replace_max_default,
34813521
"max.dim": replace_max_dim,
34823522
"maximum.default": replace_binary_ops,
3523+
"masked_scatter.default": replace_masked_scatter,
34833524
"mean.default": replace_mean_default,
34843525
"mean.dim": replace_mean_dim,
34853526
"min.default": replace_min_default,

tests/ops/test_ops.py

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

43244324

4325+
class TestMaskedScatter:
4326+
"""Tests for aten.masked_scatter.default → coreai flatten / cumsum-1 /
4327+
gather / where lowering.
4328+
"""
4329+
4330+
class _Model(nn.Module):
4331+
def forward(self, self_: Tensor, mask: Tensor, src: Tensor) -> Tensor:
4332+
return torch.masked_scatter(self_, mask, src)
4333+
4334+
@staticmethod
4335+
def _shared_dynamic_shapes(self_: Tensor, src: Tensor) -> dict:
4336+
"""Build dynamic_shapes where self_ and mask share dim objects
4337+
(torch.export emits an equality guard when traced shapes match)
4338+
and src has its own independent dim.
4339+
"""
4340+
dims = {i: torch.export.Dim(f"d{i}", min=1) for i in range(self_.dim())}
4341+
return {
4342+
"self_": dims,
4343+
"mask": dims,
4344+
"src": {0: torch.export.Dim("s0", min=1)}
4345+
if src.dim() == 1
4346+
else _all_dims_dynamic(src, "s"),
4347+
}
4348+
4349+
@pytest.mark.ir
4350+
def test_lowers_ir_static(self) -> None:
4351+
"""Pin the full operand chain on a static shape."""
4352+
self_ = torch.zeros(2, 3)
4353+
mask = torch.tensor([[True, False, True], [False, True, False]])
4354+
src = torch.arange(1.0, 7.0)
4355+
program = torch.export.export(
4356+
self._Model(), args=(self_, mask, src)
4357+
).run_decompositions()
4358+
coreai_program = TorchConverter().add_exported_program(program).to_coreai()
4359+
filecheck_pattern(
4360+
str(coreai_program),
4361+
check_file="""
4362+
// CHECK-LABEL: coreai.graph @main
4363+
// CHECK-SAME: %arg0: tensor<2x3xf32>
4364+
// CHECK-SAME: %arg1: tensor<2x3xi1>
4365+
// CHECK-SAME: %arg2: tensor<6xf32>
4366+
// CHECK: %[[SF:.+]] = coreai.reshape %arg0, %{{.+}} : (tensor<2x3xf32>, tensor<1xsi32>) -> tensor<6xf32>
4367+
// CHECK: %[[MF:.+]] = coreai.reshape %arg1, %{{.+}} : (tensor<2x3xi1>, tensor<1xsi32>) -> tensor<6xi1>
4368+
// CHECK: %[[VF:.+]] = coreai.reshape %arg2, %{{.+}} : (tensor<6xf32>, tensor<1xsi32>) -> tensor<6xf32>
4369+
// CHECK: %[[MI:.+]] = coreai.cast %[[MF]] : tensor<6xi1> to tensor<6xsi32>
4370+
// CHECK: %[[CS:.+]] = coreai.scan %[[MI]], %{{.+}}, %{{.+}} combiner = <sum> : (tensor<6xsi32>, tensor<ui32>, tensor<i1>) -> tensor<6xsi32>
4371+
// CHECK: %[[IDX:.+]] = coreai.decomposable.broadcasting_sub %[[CS]], %{{.+}} : (tensor<6xsi32>, tensor<si32>) -> tensor<6xsi32>
4372+
// CHECK: %[[G:.+]] = coreai.gather_along_axis %[[VF]] at %[[IDX]] along %{{.+}} : (tensor<6xf32>, tensor<6xsi32>, tensor<si32>) to tensor<6xf32>
4373+
// CHECK: %[[W:.+]] = coreai.decomposable.broadcasting_where %[[MF]], %[[G]], %[[SF]] : (tensor<6xi1>, tensor<6xf32>, tensor<6xf32>) -> tensor<6xf32>
4374+
// CHECK: %[[OUT:.+]] = coreai.reshape %[[W]], %{{.+}} : (tensor<6xf32>, tensor<2xsi32>) -> tensor<2x3xf32>
4375+
// CHECK: coreai.output %[[OUT]]
4376+
""",
4377+
)
4378+
4379+
@pytest.mark.ir
4380+
def test_lowers_ir_dynamic(self) -> None:
4381+
"""Confirm the same op chain appears under dynamic shapes — shapes
4382+
become symbolic, but cast/scan/sub/gather/where/reshape order is
4383+
preserved.
4384+
"""
4385+
self_ = torch.zeros(2, 3)
4386+
mask = torch.zeros(2, 3, dtype=torch.bool)
4387+
src = torch.arange(6.0)
4388+
dynamic_shapes = self._shared_dynamic_shapes(self_, src)
4389+
program = torch.export.export(
4390+
self._Model(), args=(self_, mask, src), dynamic_shapes=dynamic_shapes
4391+
).run_decompositions()
4392+
coreai_program = TorchConverter().add_exported_program(program).to_coreai()
4393+
filecheck_pattern(
4394+
str(coreai_program),
4395+
check_file="""
4396+
// CHECK-LABEL: coreai.graph @main
4397+
// CHECK: coreai.reshape
4398+
// CHECK: coreai.reshape
4399+
// CHECK: coreai.reshape
4400+
// CHECK: coreai.cast {{.+}} to tensor<{{.*}}xsi32>
4401+
// CHECK: coreai.scan {{.+}} combiner = <sum>
4402+
// CHECK: coreai.decomposable.broadcasting_sub
4403+
// CHECK: coreai.gather_along_axis
4404+
// CHECK: coreai.decomposable.broadcasting_where
4405+
// CHECK: coreai.reshape
4406+
// CHECK: coreai.output
4407+
""",
4408+
)
4409+
4410+
@pytest.mark.parametrize("dynamic", [False, True])
4411+
@pytest.mark.parametrize("dtype", [torch.float32, torch.float16, torch.int32])
4412+
async def test_basic(self, dtype: torch.dtype, dynamic: bool) -> None:
4413+
"""self.shape == mask.shape, src is 1-D of length mask.numel()."""
4414+
self_ = torch.zeros(2, 3, dtype=dtype)
4415+
mask = torch.tensor([[True, False, True], [False, True, False]])
4416+
src = torch.arange(1, 7, dtype=dtype)
4417+
dynamic_shapes = self._shared_dynamic_shapes(self_, src) if dynamic else None
4418+
await validate_numerical_output(
4419+
model=self._Model().eval(),
4420+
self_=self_,
4421+
mask=mask,
4422+
src=src,
4423+
dynamic_shapes=dynamic_shapes,
4424+
)
4425+
4426+
@pytest.mark.parametrize("dynamic", [False, True])
4427+
async def test_all_false_mask(self, dynamic: bool) -> None:
4428+
"""No positions selected — output must equal self unchanged."""
4429+
self_ = torch.arange(6.0).reshape(2, 3)
4430+
mask = torch.zeros(2, 3, dtype=torch.bool)
4431+
src = torch.full((6,), -1.0)
4432+
dynamic_shapes = self._shared_dynamic_shapes(self_, src) if dynamic else None
4433+
await validate_numerical_output(
4434+
model=self._Model().eval(),
4435+
self_=self_,
4436+
mask=mask,
4437+
src=src,
4438+
dynamic_shapes=dynamic_shapes,
4439+
)
4440+
4441+
@pytest.mark.parametrize("dynamic", [False, True])
4442+
async def test_all_true_mask(self, dynamic: bool) -> None:
4443+
"""All positions selected — output must equal src reshaped to
4444+
self.shape, with self values fully overwritten.
4445+
"""
4446+
self_ = torch.zeros(2, 3)
4447+
mask = torch.ones(2, 3, dtype=torch.bool)
4448+
src = torch.arange(1.0, 7.0)
4449+
dynamic_shapes = self._shared_dynamic_shapes(self_, src) if dynamic else None
4450+
await validate_numerical_output(
4451+
model=self._Model().eval(),
4452+
self_=self_,
4453+
mask=mask,
4454+
src=src,
4455+
dynamic_shapes=dynamic_shapes,
4456+
)
4457+
4458+
async def test_src_larger_than_mask_sum(self) -> None:
4459+
"""src may carry more elements than mask.sum(); only the leading
4460+
mask.sum() are consumed (read flat). The rest are unused.
4461+
"""
4462+
self_ = torch.zeros(2, 3)
4463+
mask = torch.tensor([[True, False, True], [False, True, False]])
4464+
# 10 elements, only first 3 are needed (mask has 3 True positions)
4465+
src = torch.arange(1.0, 11.0)
4466+
await validate_numerical_output(
4467+
model=self._Model().eval(), self_=self_, mask=mask, src=src
4468+
)
4469+
4470+
async def test_broadcast_mask_lower_rank(self) -> None:
4471+
"""mask is rank-1 (3,) and self is rank-2 (2, 3) — mask is
4472+
right-aligned and broadcasts across the leading dim. Exercises
4473+
the broadcast_to branch in the lowering.
4474+
"""
4475+
self_ = torch.zeros(2, 3)
4476+
mask = torch.tensor([True, False, True])
4477+
# mask broadcasts to (2, 3) with 4 True positions
4478+
src = torch.arange(1.0, 5.0)
4479+
await validate_numerical_output(
4480+
model=self._Model().eval(), self_=self_, mask=mask, src=src
4481+
)
4482+
4483+
43254484
@pytest.mark.parametrize("dynamic", [False, True])
43264485
@pytest.mark.parametrize(
43274486
"x,dim,index",

0 commit comments

Comments
 (0)