diff --git a/benchmarks/python/blas/bench_gemv.py b/benchmarks/python/blas/bench_gemv.py index 01a4339c..3cfc5eba 100644 --- a/benchmarks/python/blas/bench_gemv.py +++ b/benchmarks/python/blas/bench_gemv.py @@ -1,6 +1,5 @@ # Copyright © 2023 Apple Inc. -import argparse import os import subprocess import time diff --git a/benchmarks/python/masked_scatter.py b/benchmarks/python/masked_scatter.py new file mode 100644 index 00000000..71857c54 --- /dev/null +++ b/benchmarks/python/masked_scatter.py @@ -0,0 +1,212 @@ +import math +import os +import subprocess +import time +from copy import copy +from functools import partial + +import matplotlib.pyplot as plt +import mlx.core as mx +import numpy as np +import torch +from matplotlib.ticker import FuncFormatter + +RESULTS_DIR = "./results" + + +if not os.path.isdir(RESULTS_DIR): + os.mkdir(RESULTS_DIR) + +DEVICE_NAME = subprocess.check_output(["sysctl", "-n", "machdep.cpu.brand_string"]) +DEVICE_NAME = DEVICE_NAME.decode("utf-8").strip("\n") + +TORCH_DEVICE = torch.device( + "mps" + if torch.backends.mps.is_available() + else ("cuda" if torch.cuda.is_available() else "cpu") +) + + +N_WARMUP = 5 +N_ITER_BENCH = 50 +N_ITER_FUNC = 20 + +VECTOR_LENGTHS = [4096 * (2**i) for i in range(10)] +MASK_DENSITIES = [0.01, 0.1, 0.25, 0.5] +D_TYPES = ("float32", "float16") + + +def _power_of_two_formatter(value, _position): + if value <= 0: + return "" + exponent = int(round(math.log2(value))) + if abs(value - (1 << exponent)) / value > 1e-6: + return f"{value:g}" + return f"$2^{{{exponent}}}$" + + +def torch_sync(): + if TORCH_DEVICE.type == "cuda": + torch.cuda.synchronize() + elif TORCH_DEVICE.type == "mps": + torch.mps.synchronize() + + +def masked_scatter_mlx(self_arr, mask_arr, src_arr): + outs = [] + for _ in range(N_ITER_FUNC): + out = copy(self_arr) + out[mask_arr] = src_arr + outs.append(out) + mx.eval(outs) + return outs + + +@torch.no_grad() +def masked_scatter_torch(self_tensor, mask_tensor, src_tensor): + outs = [] + for _ in range(N_ITER_FUNC): + out = self_tensor.clone() + out.masked_scatter_(mask_tensor, src_tensor) + outs.append(out) + torch_sync() + return outs + + +def measure(fn): + for _ in range(N_WARMUP): + fn() + start = time.perf_counter_ns() + for _ in range(N_ITER_BENCH): + fn() + end = time.perf_counter_ns() + return (end - start) * 1e-9 + + +def bytes_touched(length, true_count, item_size): + mask_bytes = length + self_bytes = length * item_size * 2 # read + write + src_bytes = true_count * item_size + return (mask_bytes + self_bytes + src_bytes) * N_ITER_FUNC * N_ITER_BENCH + + +def build_case(length, density, np_dtype, torch_dtype): + true_count = max(1, int(round(length * density))) + + rng = np.random.default_rng() + self_np = rng.normal(0.0, 1.0, length).astype(np_dtype) + mask_np = np.zeros(length, dtype=bool) + mask_np[:true_count] = True + rng.shuffle(mask_np) + src_np = rng.normal(0.0, 1.0, true_count).astype(np_dtype) + + self_mlx = mx.array(self_np) + mask_mlx = mx.array(mask_np) + src_mlx = mx.array(src_np) + + self_torch = torch.from_numpy(self_np).to(device=TORCH_DEVICE, dtype=torch_dtype) + mask_torch = torch.from_numpy(mask_np).to(device=TORCH_DEVICE) + src_torch = torch.from_numpy(src_np).to(device=TORCH_DEVICE, dtype=torch_dtype) + + # Correctness check once per configuration + mx_out = mx.array(self_np) + mx_out[mask_mlx] = src_mlx + mx.eval(mx_out) + torch_out = self_torch.clone() + torch_out.masked_scatter_(mask_torch, src_torch) + + atol = 5e-3 if np_dtype == np.float16 else 1e-5 + if not np.allclose(np.array(mx_out), torch_out.cpu().numpy(), atol=atol): + raise AssertionError("masked_scatter results diverged between MLX and Torch") + + return (self_mlx, mask_mlx, src_mlx, self_torch, mask_torch, src_torch, true_count) + + +def bench_case(length, density, dtype): + np_dtype = getattr(np, dtype) + torch_dtype = getattr(torch, dtype) + ( + self_mlx, + mask_mlx, + src_mlx, + self_torch, + mask_torch, + src_torch, + true_count, + ) = build_case(length, density, np_dtype, torch_dtype) + + time_mlx = measure(partial(masked_scatter_mlx, self_mlx, mask_mlx, src_mlx)) + time_torch = measure( + partial(masked_scatter_torch, self_torch, mask_torch, src_torch) + ) + + total_bytes = bytes_touched(length, true_count, np_dtype().itemsize) + bytes_per_gb = float(1024**3) + mlx_gbps = (total_bytes / bytes_per_gb) / time_mlx + torch_gbps = (total_bytes / bytes_per_gb) / time_torch + + return time_mlx, time_torch, mlx_gbps, torch_gbps + + +def plot_density(ax_perf, ax_speedup, density, dtype): + mlx_gbps = [] + torch_gbps = [] + mlx_times = [] + torch_times = [] + + for length in VECTOR_LENGTHS: + t_mlx, t_torch, gbps_mlx, gbps_torch = bench_case(length, density, dtype) + mlx_gbps.append(gbps_mlx) + torch_gbps.append(gbps_torch) + mlx_times.append(t_mlx) + torch_times.append(t_torch) + + ax_perf.plot(VECTOR_LENGTHS, mlx_gbps, "tab:blue", label="MLX") + ax_perf.plot(VECTOR_LENGTHS, torch_gbps, "tab:red", label="Torch") + ax_perf.set_xscale("log", base=2) + ax_perf.set_xticks(VECTOR_LENGTHS) + formatter = FuncFormatter(_power_of_two_formatter) + ax_perf.xaxis.set_major_formatter(formatter) + ax_perf.set_title(f"density={density:.2f}") + ax_perf.set_ylabel("GB/s") + ax_perf.grid(True, which="both", linestyle=":", alpha=0.4) + ax_perf.legend() + + speedup = np.array(torch_times) / np.array(mlx_times) + ax_speedup.plot(VECTOR_LENGTHS, speedup, "tab:green") + ax_speedup.axhline(1.0, color="tab:gray", linestyle="--") + ax_speedup.set_xscale("log", base=2) + ax_speedup.set_xticks(VECTOR_LENGTHS) + ax_speedup.xaxis.set_major_formatter(formatter) + ax_speedup.set_ylabel("Speedup (Torch_t / MLX_t)") + ax_speedup.grid(True, which="both", linestyle=":", alpha=0.4) + + +def main(): + for dtype in D_TYPES: + fig, axs = plt.subplots( + len(MASK_DENSITIES), + 2, + figsize=(10, 12), + layout="constrained", + sharex=True, + ) + + for i, density in enumerate(MASK_DENSITIES): + plot_density(axs[i][0], axs[i][1], density, dtype) + axs[i][0].set_xlabel("vector length") + axs[i][1].set_xlabel("vector length") + + fig.suptitle( + f"{DEVICE_NAME.replace('Apple ', '')} ({TORCH_DEVICE.type}) | dtype={dtype}" + ) + output_path = os.path.join( + RESULTS_DIR, + f"{DEVICE_NAME.replace(' ', '_')}_masked_scatter_{dtype}.pdf", + ) + fig.savefig(output_path) + plt.close(fig) + + +if __name__ == "__main__": + main() diff --git a/docs/src/usage/indexing.rst b/docs/src/usage/indexing.rst index f454a3d5..2cc33526 100644 --- a/docs/src/usage/indexing.rst +++ b/docs/src/usage/indexing.rst @@ -70,7 +70,8 @@ Differences from NumPy * Indexing does not perform bounds checking. Indexing out of bounds is undefined behavior. - * Boolean mask based indexing is not yet supported. + * Boolean mask based indexing is supported for assignment only (see + :ref:`boolean-mask-assignment`). The reason for the lack of bounds checking is that exceptions cannot propagate from the GPU. Performing bounds checking for array indices before launching the @@ -143,3 +144,51 @@ expected. For example: In the above ``dfdx`` will have the correct gradient, namely zeros at ``idx`` and ones elsewhere. + +.. _boolean-mask-assignment: + +Boolean Mask Assignment +----------------------- + +MLX supports boolean indices using NumPy syntax. A mask must already be +a :class:`bool_` MLX :class:`array` or a NumPy ``ndarray`` with ``dtype=bool``. +Other index types are routed through the standard scatter code. + +.. code-block:: shell + + >>> a = mx.array([1.0, 2.0, 3.0]) + >>> mask = mx.array([True, False, True]) + >>> updates = mx.array([5.0, 6.0]) + >>> a[mask] = updates + >>> a + array([5.0, 2.0, 6.0], dtype=float32) + +Scalar assignments broadcast to every ``True`` entry in ``mask``. For non-scalar +assignments, ``updates`` must provide at least as many elements as there are +``True`` entries in ``mask``. + +.. code-block:: shell + + >>> a = mx.zeros((2, 3)) + >>> mask = mx.array([[True, False, True], + [False, False, True]]) + >>> a[mask] = 1.0 + >>> a + array([[1.0, 0.0, 1.0], + [0.0, 0.0, 1.0]], dtype=float32) + +Boolean masks follow NumPy semantics: + +- The mask shape must match the shape of the axes it indexes exactly. No mask + broadcasting occurs. +- Any axes not covered by the mask are taken in full. + +.. code-block:: shell + + >>> a = mx.arange(1000).reshape(10, 10, 10) + >>> a[mx.random.randn(10, 10) > 0.0] = 0 # valid: mask covers axes 0 and 1 + +The mask of shape ``(10, 10)`` applies to the first two axes, so ``a[mask]`` +selects the 1-D slices ``a[i, j, :]`` where ``mask[i, j]`` is ``True``. +Shapes such as ``(1, 10, 10)`` or ``(10, 10, 1)`` do not match the indexed +axes and therefore raise errors. diff --git a/mlx/backend/cpu/indexing.cpp b/mlx/backend/cpu/indexing.cpp index 6daced6f..47fb881a 100644 --- a/mlx/backend/cpu/indexing.cpp +++ b/mlx/backend/cpu/indexing.cpp @@ -747,4 +747,108 @@ void ScatterAxis::eval_cpu(const std::vector& inputs, array& out) { }); } +template +void masked_scatter_impl(const array& mask, const array& src, array& out) { + ContiguousIterator mask_it(mask); + ContiguousIterator src_it(src); + ContiguousIterator out_it(out); + + const bool* mask_ptr = mask.data(); + const T* src_ptr = src.data(); + T* dst_ptr = out.data(); + + const size_t batch_count = mask.shape(0); + const size_t mask_batch_size = mask.size() / batch_count; + const size_t src_batch_size = src.size() / batch_count; + + for (uint b = 0; b < batch_count; ++b) { + size_t src_consumed = 0; + src_it.seek(b * src_batch_size); + + for (size_t i = 0; i < mask_batch_size; ++i) { + if (mask_ptr[mask_it.loc]) { + if (src_consumed >= src_batch_size) { + throw std::runtime_error( + "[MaskedScatter::eval_cpu] Source does not have enough elements for mask."); + } + dst_ptr[out_it.loc] = src_ptr[src_it.loc]; + src_it.step(); + ++src_consumed; + } + mask_it.step(); + out_it.step(); + } + } +} + +void MaskedScatter::eval_cpu(const std::vector& inputs, array& out) { + assert(inputs.size() == 3); + + auto& dst = inputs[0]; + auto& mask = inputs[1]; + auto& src = inputs[2]; + + // Copy src into out (copy allocates memory for out) + auto ctype = + dst.flags().row_contiguous ? CopyType::Vector : CopyType::General; + copy_cpu(dst, out, ctype, stream()); + + if (mask.size() == 0) { + return; + } + + auto& encoder = cpu::get_command_encoder(stream()); + encoder.set_input_array(mask); + encoder.set_input_array(src); + encoder.set_output_array(out); + encoder.dispatch([mask = array::unsafe_weak_copy(mask), + src = array::unsafe_weak_copy(src), + out = array::unsafe_weak_copy(out)]() mutable { + switch (out.dtype()) { + case bool_: + masked_scatter_impl(mask, src, out); + break; + case uint8: + masked_scatter_impl(mask, src, out); + break; + case uint16: + masked_scatter_impl(mask, src, out); + break; + case uint32: + masked_scatter_impl(mask, src, out); + break; + case uint64: + masked_scatter_impl(mask, src, out); + break; + case int8: + masked_scatter_impl(mask, src, out); + break; + case int16: + masked_scatter_impl(mask, src, out); + break; + case int32: + masked_scatter_impl(mask, src, out); + break; + case int64: + masked_scatter_impl(mask, src, out); + break; + case float16: + masked_scatter_impl(mask, src, out); + break; + case float32: + masked_scatter_impl(mask, src, out); + break; + case float64: + masked_scatter_impl(mask, src, out); + break; + case bfloat16: + masked_scatter_impl(mask, src, out); + break; + case complex64: + masked_scatter_impl(mask, src, out); + break; + } + }); +} + } // namespace mlx::core diff --git a/mlx/backend/cuda/primitives.cpp b/mlx/backend/cuda/primitives.cpp index 48995b09..0132ceb8 100644 --- a/mlx/backend/cuda/primitives.cpp +++ b/mlx/backend/cuda/primitives.cpp @@ -37,6 +37,7 @@ NO_GPU(Inverse) NO_GPU(Cholesky) NO_GPU_MULTI(Eig) NO_GPU_MULTI(Eigh) +NO_GPU(MaskedScatter) namespace distributed { NO_GPU_MULTI(Send) diff --git a/mlx/backend/metal/CMakeLists.txt b/mlx/backend/metal/CMakeLists.txt index 2baa6c05..3cfd0b22 100644 --- a/mlx/backend/metal/CMakeLists.txt +++ b/mlx/backend/metal/CMakeLists.txt @@ -28,6 +28,7 @@ make_jit_source(binary_ops) make_jit_source(ternary_ops) make_jit_source(reduce_utils kernels/atomic.h kernels/reduction/ops.h) make_jit_source(indexing/scatter kernels/indexing/indexing.h) +make_jit_source(indexing/masked_scatter) make_jit_source(indexing/gather kernels/indexing/indexing.h) make_jit_source(indexing/gather_front kernels/indexing/indexing.h) make_jit_source(indexing/gather_axis) diff --git a/mlx/backend/metal/indexing.cpp b/mlx/backend/metal/indexing.cpp index 02509875..51a286aa 100644 --- a/mlx/backend/metal/indexing.cpp +++ b/mlx/backend/metal/indexing.cpp @@ -1,4 +1,5 @@ // Copyright © 2023-2024 Apple Inc. + #include #include "mlx/backend/common/compiled.h" @@ -8,7 +9,9 @@ #include "mlx/backend/metal/jit/includes.h" #include "mlx/backend/metal/jit/indexing.h" #include "mlx/backend/metal/kernels.h" +#include "mlx/backend/metal/scan.h" #include "mlx/backend/metal/utils.h" +#include "mlx/dtype.h" #include "mlx/primitives.h" #include "mlx/utils.h" @@ -641,4 +644,84 @@ void ScatterAxis::eval_gpu(const std::vector& inputs, array& out) { compute_encoder.dispatch_threads(grid_dims, group_dims); } +void MaskedScatter::eval_gpu(const std::vector& inputs, array& out) { + const array& dst = inputs[0]; + const array& mask = inputs[1]; + const array& src = inputs[2]; + + auto& s = stream(); + auto& d = metal::device(s.device); + + const size_t total = mask.size(); + const CopyType ct = (total == 1) + ? CopyType::Scalar + : (dst.flags().row_contiguous ? CopyType::Vector : CopyType::General); + copy_gpu(dst, out, ct, s); + if (total == 0) { + return; + } + + array mask_flat = flatten_in_eval(mask, 1, -1, s); + if (mask_flat.data() != mask.data()) { + d.add_temporary(mask_flat, s.index); + } + + if (!mask_flat.flags().row_contiguous) { + mask_flat = contiguous_copy_gpu(mask_flat, s); + d.add_temporary(mask_flat, s.index); + } + + // Prefix (exclusive) of mask → scatter_offsets + array scatter_offsets(mask_flat.shape(), uint32, nullptr, {}); + scatter_offsets.set_data(allocator::malloc(scatter_offsets.nbytes())); + d.add_temporary(scatter_offsets, s.index); + + scan_gpu_inplace( + mask_flat, + scatter_offsets, + Scan::Sum, + /*axis=*/1, + /*reverse=*/false, + /*inclusive=*/false, + s); + + // Kernel selection/build + static constexpr std::string_view kBaseName = "masked_assign"; + const std::string dtype_tag = type_to_name(out.dtype()); + const std::string value_type = get_type_string(out.dtype()); + const std::string contiguous = + (src.flags().row_contiguous) ? "true" : "false"; + const std::string kernel_name = + fmt::format("{}_{}_{}", kBaseName, dtype_tag, contiguous); + + auto lib = d.get_library(kernel_name, [&]() { + std::string source = metal::utils(); + source += metal::masked_scatter(); + source += fmt::format( + std::string(masked_assign_kernel), kernel_name, value_type, contiguous); + return source; + }); + auto kernel = d.get_kernel(kernel_name, lib); + + // Binding + int bind_idx = 0; + const int ndim = static_cast(src.ndim()); + auto& compute_encoder = d.get_command_encoder(s.index); + compute_encoder.set_compute_pipeline_state(kernel); + compute_encoder.set_input_array(mask_flat, bind_idx++); + compute_encoder.set_input_array(scatter_offsets, bind_idx++); + compute_encoder.set_input_array(src, bind_idx++); + compute_encoder.set_output_array(out, bind_idx++); + compute_encoder.set_vector_bytes(src.shape(), bind_idx++); + compute_encoder.set_vector_bytes(src.strides(), bind_idx++); + compute_encoder.set_bytes(ndim, bind_idx++); + compute_encoder.set_bytes(src.size() / src.shape(0), bind_idx++); + compute_encoder.set_bytes(mask_flat.size() / mask.shape(0), bind_idx++); + + // Dispatch + auto group_dims = get_block_dims(total, 1, 1); + MTL::Size grid_dims(total, 1, 1); + compute_encoder.dispatch_threads(grid_dims, group_dims); +} + } // namespace mlx::core diff --git a/mlx/backend/metal/jit/includes.h b/mlx/backend/metal/jit/includes.h index c12e576c..1c7246b8 100644 --- a/mlx/backend/metal/jit/includes.h +++ b/mlx/backend/metal/jit/includes.h @@ -11,6 +11,7 @@ const char* ternary_ops(); const char* reduce_utils(); const char* gather(); const char* scatter(); +const char* masked_scatter(); const char* arange(); const char* unary(); diff --git a/mlx/backend/metal/jit/indexing.h b/mlx/backend/metal/jit/indexing.h index 57bafc22..fa141fcc 100644 --- a/mlx/backend/metal/jit/indexing.h +++ b/mlx/backend/metal/jit/indexing.h @@ -70,3 +70,7 @@ constexpr std::string_view scatter_kernels = R"( gid); }} )"; + +constexpr std::string_view masked_assign_kernel = R"( +template [[host_name("{0}")]] [[kernel]] decltype(masked_assign_impl<{1}, {2}>) masked_assign_impl<{1}, {2}>; +)"; diff --git a/mlx/backend/metal/kernels/indexing/masked_scatter.h b/mlx/backend/metal/kernels/indexing/masked_scatter.h new file mode 100644 index 00000000..1fd19e2f --- /dev/null +++ b/mlx/backend/metal/kernels/indexing/masked_scatter.h @@ -0,0 +1,38 @@ +// Copyright © 2025 Apple Inc. + +#pragma once + +template +[[kernel]] void masked_assign_impl( + const device bool* mask [[buffer(0)]], + const device uint* scatter_offsets [[buffer(1)]], + const device T* src [[buffer(2)]], + device T* out [[buffer(3)]], + const constant int* src_shapes [[buffer(4)]], + const constant int64_t* src_strides [[buffer(5)]], + const constant int& src_ndim [[buffer(6)]], + const constant int64_t& src_batch_size [[buffer(7)]], + const constant int64_t& mask_batch_size [[buffer(8)]], + uint idx [[thread_position_in_grid]]) { + const bool mask_value = mask[idx]; + if (!mask_value) { + return; + } + + const uint src_index = scatter_offsets[idx]; + if (src_index >= src_batch_size) { + return; + } + + const uint batch_idx = idx / mask_batch_size; + + if (src_contiguous) { + out[idx] = src[batch_idx * src_batch_size + src_index]; + } else { + out[idx] = src[elem_to_loc( + batch_idx * src_batch_size + src_index, + src_shapes, + src_strides, + src_ndim)]; + } +} diff --git a/mlx/backend/metal/kernels/scan.metal b/mlx/backend/metal/kernels/scan.metal index f38f8757..854db9da 100644 --- a/mlx/backend/metal/kernels/scan.metal +++ b/mlx/backend/metal/kernels/scan.metal @@ -51,6 +51,7 @@ using namespace metal; instantiate_strided_scan(reverse_exclusive_##name, itype, otype, op, false, true, nreads) instantiate_scan_helper(sum_bool__int32, bool, int32_t, CumSum, 4) +instantiate_scan_helper(sum_bool__uint32, bool, uint32_t, CumSum, 4) instantiate_scan_helper(sum_uint8_uint8, uint8_t, uint8_t, CumSum, 4) instantiate_scan_helper(sum_uint16_uint16, uint16_t, uint16_t, CumSum, 4) instantiate_scan_helper(sum_uint32_uint32, uint32_t, uint32_t, CumSum, 4) diff --git a/mlx/backend/metal/scan.cpp b/mlx/backend/metal/scan.cpp index 0b636c16..b35de64c 100644 --- a/mlx/backend/metal/scan.cpp +++ b/mlx/backend/metal/scan.cpp @@ -6,52 +6,40 @@ #include "mlx/backend/gpu/copy.h" #include "mlx/backend/metal/device.h" #include "mlx/backend/metal/kernels.h" +#include "mlx/backend/metal/scan.h" #include "mlx/backend/metal/utils.h" #include "mlx/primitives.h" namespace mlx::core { -void Scan::eval_gpu(const std::vector& inputs, array& out) { - assert(inputs.size() == 1); - - auto& s = stream(); +void scan_gpu_inplace( + array in, + array& out, + Scan::ReduceType reduce_type, + int axis, + bool reverse, + bool inclusive, + const Stream& s) { auto& d = metal::device(s.device); - bool donate = inputs[0].is_donatable(); - auto in = inputs[0]; - if (in.flags().contiguous && in.strides()[axis_] != 0) { - if (donate && in.itemsize() == out.itemsize()) { - out.copy_shared_buffer(in); - } else { - out.set_data( - allocator::malloc(in.data_size() * out.itemsize()), - in.data_size(), - in.strides(), - in.flags()); - } - } else { - in = contiguous_copy_gpu(in, s); - out.copy_shared_buffer(in); - } + bool contiguous = in.strides()[axis] == 1; - bool contiguous = in.strides()[axis_] == 1; - - std::string reduce_type; - switch (reduce_type_) { + std::string reduce_type_str; + switch (reduce_type) { case Scan::Sum: - reduce_type = "sum"; + reduce_type_str = "sum"; break; case Scan::Prod: - reduce_type = "prod"; + reduce_type_str = "prod"; break; case Scan::Max: - reduce_type = "max"; + reduce_type_str = "max"; break; case Scan::Min: - reduce_type = "min"; + reduce_type_str = "min"; break; case Scan::LogAddExp: - reduce_type = "logaddexp"; + reduce_type_str = "logaddexp"; break; } @@ -60,23 +48,23 @@ void Scan::eval_gpu(const std::vector& inputs, array& out) { kname, contiguous ? "contig_" : "strided_", "scan_", - reverse_ ? "reverse_" : "", - (inclusive_) ? "inclusive_" : "exclusive_", - reduce_type, + reverse ? "reverse_" : "", + inclusive ? "inclusive_" : "exclusive_", + reduce_type_str, "_", type_to_name(in), "_", type_to_name(out)); auto kernel = - get_scan_kernel(d, kname, reverse_, inclusive_, reduce_type, in, out); + get_scan_kernel(d, kname, reverse, inclusive, reduce_type_str, in, out); if (contiguous) { auto& compute_encoder = d.get_command_encoder(s.index); compute_encoder.set_compute_pipeline_state(kernel); compute_encoder.set_input_array(in, 0); compute_encoder.set_output_array(out, 1); - size_t size = in.shape(axis_); + size_t size = in.shape(axis); compute_encoder.set_bytes(size, 2); // Compute the thread grid @@ -95,7 +83,7 @@ void Scan::eval_gpu(const std::vector& inputs, array& out) { thread_group_size, static_cast(kernel->maxTotalThreadsPerThreadgroup())); auto tmp_grid_dims = - get_2d_grid_dims(in.shape(), in.strides(), /** divisor= */ size); + get_2d_grid_dims(in.shape(), in.strides(), /*divisor=*/size); MTL::Size grid_dims( thread_group_size, tmp_grid_dims.width, tmp_grid_dims.height); MTL::Size group_dims(thread_group_size, 1, 1); @@ -106,8 +94,8 @@ void Scan::eval_gpu(const std::vector& inputs, array& out) { compute_encoder.set_input_array( in.data_shared_ptr() == nullptr ? out : in, 0); compute_encoder.set_output_array(out, 1); - size_t size = in.shape(axis_); - size_t stride = in.strides()[axis_]; + size_t size = in.shape(axis); + size_t stride = in.strides()[axis]; int bn = 32; size_t stride_blocks = (stride + bn - 1) / bn; compute_encoder.set_bytes(size, 2); @@ -118,8 +106,8 @@ void Scan::eval_gpu(const std::vector& inputs, array& out) { int n_reads = (in.itemsize() <= 4) ? 4 : 2; int n_simdgroups = bn / n_reads; int thread_group_size = n_simdgroups * 32; - auto tmp_grid_dims = get_2d_grid_dims( - in.shape(), in.strides(), /** divisor= */ size * stride); + auto tmp_grid_dims = + get_2d_grid_dims(in.shape(), in.strides(), /*divisor=*/size * stride); if (tmp_grid_dims.width * stride_blocks <= UINT_MAX) { tmp_grid_dims.width *= stride_blocks; } else { @@ -132,4 +120,27 @@ void Scan::eval_gpu(const std::vector& inputs, array& out) { } } +void Scan::eval_gpu(const std::vector& inputs, array& out) { + assert(inputs.size() == 1); + + auto in = inputs[0]; + if (in.flags().contiguous && in.strides()[axis_] != 0) { + if (in.is_donatable() && in.itemsize() == out.itemsize()) { + out.copy_shared_buffer(in); + } else { + out.set_data( + allocator::malloc(in.data_size() * out.itemsize()), + in.data_size(), + in.strides(), + in.flags()); + } + } else { + in = contiguous_copy_gpu(in, stream()); + out.copy_shared_buffer(in); + } + + scan_gpu_inplace( + in, out, reduce_type_, axis_, reverse_, inclusive_, stream()); +} + } // namespace mlx::core diff --git a/mlx/backend/metal/scan.h b/mlx/backend/metal/scan.h new file mode 100644 index 00000000..dab79c50 --- /dev/null +++ b/mlx/backend/metal/scan.h @@ -0,0 +1,17 @@ +#pragma once + +#include "mlx/array.h" +#include "mlx/primitives.h" + +namespace mlx::core { + +void scan_gpu_inplace( + array in, + array& out, + Scan::ReduceType reduce_type, + int axis, + bool reverse, + bool inclusive, + const Stream& s); + +} // namespace mlx::core diff --git a/mlx/backend/no_cpu/primitives.cpp b/mlx/backend/no_cpu/primitives.cpp index b32e074e..c2636b61 100644 --- a/mlx/backend/no_cpu/primitives.cpp +++ b/mlx/backend/no_cpu/primitives.cpp @@ -87,6 +87,7 @@ NO_CPU(LogSumExp) NO_CPU_MULTI(LUF) NO_CPU(Matmul) NO_CPU(Maximum) +NO_CPU(MaskedScatter) NO_CPU(Minimum) NO_CPU(Multiply) NO_CPU(Negative) diff --git a/mlx/backend/no_gpu/primitives.cpp b/mlx/backend/no_gpu/primitives.cpp index ba625947..25432165 100644 --- a/mlx/backend/no_gpu/primitives.cpp +++ b/mlx/backend/no_gpu/primitives.cpp @@ -154,6 +154,7 @@ NO_GPU(Cholesky) NO_GPU_MULTI(Eigh) NO_GPU_MULTI(Eig) NO_GPU(View) +NO_GPU(MaskedScatter) namespace fast { NO_GPU_USE_FALLBACK(LayerNorm) diff --git a/mlx/ops.cpp b/mlx/ops.cpp index f58c73a6..fbe67993 100644 --- a/mlx/ops.cpp +++ b/mlx/ops.cpp @@ -3458,6 +3458,91 @@ array scatter_min( return scatter(a, indices, updates, axes, Scatter::Min, s); } +array masked_scatter( + const array& a, + const array& mask, + const array& value, + StreamOrDevice s /* = {} */) { + if (mask.dtype() != bool_) { + throw std::invalid_argument("[masked_scatter] The mask has to be boolean."); + } + if (mask.ndim() == 0) { + throw std::invalid_argument( + "[masked_scatter] Scalar masks are not supported."); + } else if (mask.ndim() > a.ndim()) { + throw std::invalid_argument( + "[masked_scatter] The mask cannot have more dimensions than the target."); + } + + int unmasked_dims = a.ndim() - mask.ndim(); + + if (value.ndim() > unmasked_dims + 1) { + std::ostringstream msg; + msg << "[masked_scatter] Value array shape must be broadcastable with the last " + << unmasked_dims << " dimensions of the input."; + throw std::invalid_argument(msg.str()); + } + + // Check if the start of the mask is compatible + if (!std::equal( + mask.shape().begin(), mask.shape().end(), a.shape().begin())) { + std::ostringstream msg; + msg << "[masked_scatter] The boolean mask should have the same shape as the " + << "beginning of the indexed array but the mask has shape " + << mask.shape() << " and the array has shape " << a.shape(); + throw std::invalid_argument(msg.str()); + } + + array expanded_mask = mask; + array expanded_value = astype(value, a.dtype(), s); + + // Broadcast both the mask with the last unmasked_dims of a + if (unmasked_dims > 0) { + auto mask_shape = mask.shape(); + while (mask_shape.size() < a.ndim()) { + mask_shape.push_back(1); + } + expanded_mask = broadcast_to(reshape(mask, mask_shape, s), a.shape(), s); + } + + // Broadcast the value with the unmasked dims plus one extra dimension of + // size mask.size(). If that dim is already provided leave it as is. + if (value.ndim() < unmasked_dims + 1) { + Shape value_shape(unmasked_dims + 1 - value.ndim(), 1); + value_shape.insert( + value_shape.end(), value.shape().begin(), value.shape().end()); + expanded_value = reshape(expanded_value, value_shape, s); + + value_shape[0] = mask.size(); + for (int i = 1; i < unmasked_dims + 1; i++) { + value_shape[i] = a.shape(i - unmasked_dims - 1); + } + expanded_value = broadcast_to(expanded_value, value_shape, s); + } else if (!std::equal( + value.shape().begin() + 1, + value.shape().end(), + a.shape().end() - unmasked_dims)) { + auto value_shape = value.shape(); + for (int i = 1; i < unmasked_dims + 1; i++) { + value_shape[i] = a.shape(i - unmasked_dims - 1); + } + expanded_value = broadcast_to(expanded_value, value_shape, s); + } + + array expanded_a = expand_dims(a, 0, s); + expanded_mask = expand_dims(expanded_mask, 0, s); + expanded_value = expand_dims(expanded_value, 0, s); + + return squeeze( + array( + expanded_a.shape(), + expanded_a.dtype(), + std::make_shared(to_stream(s)), + {expanded_a, expanded_mask, expanded_value}), + 0, + s); +} + array sqrt(const array& a, StreamOrDevice s /* = {} */) { auto dtype = at_least_float(a.dtype()); return array( diff --git a/mlx/ops.h b/mlx/ops.h index 6c44c032..ff77baf3 100644 --- a/mlx/ops.h +++ b/mlx/ops.h @@ -1198,6 +1198,12 @@ inline array scatter_min( return scatter_min(a, {indices}, updates, std::vector{axis}, s); } +array masked_scatter( + const array& a, + const array& mask, + const array& src, + StreamOrDevice s = {}); + /** Square root the elements of an array. */ array sqrt(const array& a, StreamOrDevice s = {}); diff --git a/mlx/primitives.cpp b/mlx/primitives.cpp index 976200ba..5d96301b 100644 --- a/mlx/primitives.cpp +++ b/mlx/primitives.cpp @@ -1317,15 +1317,15 @@ Shape Convolution::conv_out_shape( if (pads_lo[i - 1] < 0 || pads_hi[i - 1] < 0) { std::ostringstream msg; - msg << "[conv] Padding sizes must be non-negative." << " Got padding " + msg << "[conv] Padding sizes must be non-negative. Got padding " << pads_lo << " | " << pads_hi << "."; throw std::invalid_argument(msg.str()); } if (strides[i - 1] <= 0) { std::ostringstream msg; - msg << "[conv] Stride sizes must be positive." << " Got strides " - << strides << "."; + msg << "[conv] Stride sizes must be positive." + << " Got strides " << strides << "."; throw std::invalid_argument(msg.str()); } @@ -4348,6 +4348,145 @@ bool ScatterAxis::is_equivalent(const Primitive& other) const { return reduce_type_ == s_other.reduce_type_ && axis_ == s_other.axis_; } +std::vector MaskedScatter::vjp( + const std::vector& primals, + const std::vector& cotangents, + const std::vector& argnums, + const std::vector&) { + auto& s = stream(); + const array& dst = primals[0]; + const array& mask = primals[1]; + const array& src = primals[2]; + const array mask_b = broadcast_to(mask, dst.shape(), s); + const array& cotan = cotangents[0]; + + std::vector vjps; + vjps.reserve(argnums.size()); + + for (int arg : argnums) { + if (arg == 0) { + vjps.push_back(where(mask_b, zeros_like(cotan, s), cotan, s)); + } else if (arg == 2) { + const array mask_flat = flatten(mask_b, s); + const array cotan_flat = flatten(cotan, s); + + const array idx_src = + cumsum(astype(mask_flat, int32, s), 0, false, false, s); + const array cotan_src = + where(mask_flat, cotan_flat, array(0, cotan_flat.dtype()), s); + + array gsrc_flat = + zeros({static_cast(src.size())}, cotan_src.dtype(), s); + if (src.size() > 0) { + const array cotan_updates = + reshape(cotan_src, {static_cast(idx_src.size()), 1}, s); + gsrc_flat = scatter_add(gsrc_flat, idx_src, cotan_updates, 0, s); + } + + vjps.push_back(reshape(gsrc_flat, src.shape(), s)); + } else { + throw std::invalid_argument( + "[masked_scatter] Cannot calculate VJP with respect to mask."); + } + } + return vjps; +} + +std::vector MaskedScatter::jvp( + const std::vector& primals, + const std::vector& tangents, + const std::vector& argnums) { + auto& s = stream(); + const array& dst = primals[0]; + const array& mask = primals[1]; + array mask_b = mask; + if (mask_b.ndim() < dst.ndim()) { + std::vector axes(dst.ndim() - mask_b.ndim(), 0); + std::iota(axes.begin(), axes.end(), mask_b.ndim()); + mask_b = expand_dims(mask_b, axes, s); + } + + array out = zeros_like(dst, s); + for (int arg : argnums) { + if (arg == 0) { + out = where(mask_b, out, tangents[0], s); + } else if (arg == 2) { + out = array( + out.shape(), + out.dtype(), + std::make_shared(s), + {out, mask, tangents[1]}); + } else { + throw std::invalid_argument("[masked_scatter] invalid arg index in JVP"); + } + } + return {out}; +} + +std::pair, std::vector> MaskedScatter::vmap( + const std::vector& inputs, + const std::vector& axes) { + auto& s = stream(); + + // The inputs all had batching in the 0-th dim. So vectorization amounts to + // - Move the vectorized axis first + // - Expand and broadcast the unvectorized inputs + // - Flatten the first two dims (the new and old batch axes) + // - Masked scatter + // - Unflatten the vectorized axis again + + // Find the batch dim if any + int batch_dim = -1; + for (int i = 0; i < axes.size(); i++) { + if (axes[i] >= 0) { + batch_dim = inputs[i].shape(axes[i]); + } + } + + // Early exit if it's not vmapped + if (batch_dim < 0) { + return { + {array( + inputs[0].shape(), + inputs[0].dtype(), + std::make_shared(to_stream(s)), + inputs)}, + {-1}}; + } + + // Move vmapped axis to 0-th dim and broadcast the non-vectorized ones + auto v_in = inputs; + for (int i = 0; i < axes.size(); i++) { + if (axes[i] > 0) { + v_in[i] = moveaxis(v_in[i], axes[i], 0, s); + } else if (axes[i] < 0) { + v_in[i] = expand_dims(v_in[i], 0, s); + auto in_shape = v_in[i].shape(); + in_shape[0] = batch_dim; + v_in[i] = broadcast_to(v_in[i], in_shape, s); + } + } + + // Flatten the first 2 dims + for (int i = 0; i < 3; i++) { + v_in[i] = flatten(v_in[i], 0, 1, s); + } + + // Masked scatter + const auto result_shape = v_in[0].shape(); + const auto result_dtype = v_in[0].dtype(); + array result( + result_shape, + result_dtype, + std::make_shared(to_stream(s)), + std::move(v_in)); + + // Now unflatten so the vectorized axis is nice and separate + result = unflatten(result, 0, {batch_dim, -1}, s); + + return {{result}, {0}}; +} + std::vector Sigmoid::vjp( const std::vector& primals, const std::vector& cotangents, @@ -5111,14 +5250,18 @@ std::vector BlockMaskedMM::vjp( // - dB_m = A_m.T [..., K, M] @ dC [..., M, N] // - dA = dA_m * mask_b_lhs [..., MP, KP] // - dB = dB_m * mask_b_rhs [..., KP, MP] - // - dmask_b_lhs = dA_m [..., M, K] * A [..., M, K] // need [..., MP, KP] - // - dmask_b_rhs = dB_m [..., K, N] * B [..., K, N] // need [..., KP, NP] + // - dmask_b_lhs = dA_m [..., M, K] * A [..., M, K] // need [..., MP, + // KP] + // - dmask_b_rhs = dB_m [..., K, N] * B [..., K, N] // need [..., KP, + // NP] // // Observations: - // * If dmask_b_lhs is not needed, then dA can be calulated in one go as a - // as a block_masked_mm with mask_b_lhs as the out_mask without needing to - // materialize the intermediate dA_m. Similar for dB. - // * If dmask_b_lhs is needed, we need to materialize dA_m directly and then + // * If dmask_b_lhs is not needed, then dA can be calulated in one go as + // a + // as a block_masked_mm with mask_b_lhs as the out_mask without needing + // to materialize the intermediate dA_m. Similar for dB. + // * If dmask_b_lhs is needed, we need to materialize dA_m directly and + // then // point-wise multiply with A. But the output needs to be padded std::vector vjps; diff --git a/mlx/primitives.h b/mlx/primitives.h index d3260f0f..87aad33b 100644 --- a/mlx/primitives.h +++ b/mlx/primitives.h @@ -1928,6 +1928,20 @@ class ScatterAxis : public UnaryPrimitive { int axis_; }; +class MaskedScatter : public UnaryPrimitive { + public: + explicit MaskedScatter(Stream stream) : UnaryPrimitive(stream) {} + + void eval_cpu(const std::vector& inputs, array& out) override; + void eval_gpu(const std::vector& inputs, array& out) override; + + DEFINE_VMAP(); + DEFINE_GRADS(); + DEFINE_NAME(MaskedScatter); + DEFINE_DEFAULT_IS_EQUIVALENT(); + DEFINE_INPUT_OUTPUT_SHAPE(); +}; + class Sigmoid : public UnaryPrimitive { public: explicit Sigmoid(Stream stream) : UnaryPrimitive(stream) {} diff --git a/python/src/indexing.cpp b/python/src/indexing.cpp index 95c6e2a1..5bd9f5bf 100644 --- a/python/src/indexing.cpp +++ b/python/src/indexing.cpp @@ -1,5 +1,6 @@ // Copyright © 2023-2024 Apple Inc. #include +#include #include #include "python/src/convert.h" @@ -885,6 +886,22 @@ auto mlx_slice_update( return std::make_pair(true, out); } +std::optional extract_boolean_mask(const nb::object& obj) { + using NDArray = nb::ndarray; + if (nb::isinstance(obj)) { + auto mask = nb::cast(obj); + if (mask.dtype() == mx::bool_) { + return mask; + } + } else if (nb::isinstance(obj)) { + auto mask = nb::cast(obj); + if (mask.dtype() == nb::dtype()) { + return nd_array_to_mlx(mask, mx::bool_); + } + } + return std::nullopt; +} + void mlx_set_item( mx::array& src, const nb::object& obj, @@ -895,6 +912,13 @@ void mlx_set_item( return; } + if (auto mask = extract_boolean_mask(obj)) { + auto updates = to_array(v, src.dtype()); + auto result = masked_scatter(src, *mask, updates); + src.overwrite_descriptor(result); + return; + } + auto [indices, updates, axes] = mlx_compute_scatter_args(src, obj, v); if (indices.size() > 0) { auto out = scatter(src, indices, updates, axes); diff --git a/python/tests/cuda_skip.py b/python/tests/cuda_skip.py index 4723bda6..2d5ef804 100644 --- a/python/tests/cuda_skip.py +++ b/python/tests/cuda_skip.py @@ -56,4 +56,8 @@ cuda_skip = { "TestQuantized.test_throw", "TestQuantized.test_vjp_scales_biases", "TestExportImport.test_export_quantized_model", + # Masked scatter + "TestOps.test_masked_scatter", + "TestVmap.test_vmap_masked_scatter", + "TestArray.test_setitem_with_boolean_mask", } diff --git a/python/tests/test_array.py b/python/tests/test_array.py index a8f9474e..72564f73 100644 --- a/python/tests/test_array.py +++ b/python/tests/test_array.py @@ -1928,6 +1928,18 @@ class TestArray(mlx_tests.MLXTestCase): anp[:, idx] = 4 self.assertTrue(np.array_equal(a, anp)) + def test_setitem_with_boolean_mask(self): + mask_np = np.zeros((10, 10), dtype=bool) + mx.arange(1000).reshape(10, 10, 10)[mask_np] = 0 + + mask_np = np.zeros((1, 10, 10), dtype=bool) + with self.assertRaises(ValueError): + mx.arange(1000).reshape(10, 10, 10)[mask_np] = 0 + + mask_np = np.zeros((10, 10, 1), dtype=bool) + with self.assertRaises(ValueError): + mx.arange(1000).reshape(10, 10, 10)[mask_np] = 0 + def test_array_namespace(self): a = mx.array(1.0) api = a.__array_namespace__() diff --git a/python/tests/test_ops.py b/python/tests/test_ops.py index 38921e56..d1589d49 100644 --- a/python/tests/test_ops.py +++ b/python/tests/test_ops.py @@ -1260,7 +1260,6 @@ class TestOps(mlx_tests.MLXTestCase): def test_put_along_axis(self): for ax in [None, 0, 1, 2]: - a_np = np.arange(16).reshape(2, 2, 4).astype(np.int32) a_mlx = mx.array(a_np) @@ -3138,6 +3137,69 @@ class TestOps(mlx_tests.MLXTestCase): out = mx.depends(b, c) self.assertTrue(mx.array_equal(out, b)) + def test_masked_scatter(self): + # boolean mask updates matching numpy semantics + a = mx.array([1.0, 2.0, 3.0]) + mask = mx.array([True, False, True]) + src = mx.array([5.0, 6.0]) + expected = mx.array([5.0, 2.0, 6.0]) + a[mask] = src + self.assertTrue(mx.array_equal(a, expected)) + + # non-boolean mask raises + b = mx.array([1.0, 2.0, 3.0]) + bad_mask = mx.array([1, 0, 1]) + src = mx.array([4.0, 5.0]) + with self.assertRaises((TypeError, ValueError)): + b[bad_mask] = src + + # mask matching leading dimension selects entire trailing slices + c = mx.array([[0.0, 0.0, 0.0], [1.0, 1.0, 1.0]]) + mask = mx.array([True, False]) + src = mx.array([2.0, 3.0, 4.0]) + expected = mx.array([[2.0, 3.0, 4.0], [1.0, 1.0, 1.0]]) + c[mask] = src + self.assertTrue(mx.array_equal(c, expected)) + + # scalar source applies to all selected entries + c = mx.array([[0.0, 0.0, 0.0], [1.0, 1.0, 1.0]]) + mask = mx.array([True, False]) + src = 2.0 + expected = mx.array([[2.0, 2.0, 2.0], [1.0, 1.0, 1.0]]) + c[mask] = src + self.assertTrue(mx.array_equal(c, expected)) + + # mask with no updates leaves values unchanged + d = mx.array([[7.0, 8.0], [9.0, 10.0]]) + mask = mx.zeros_like(d).astype(mx.bool_) + src = mx.array([1.0]) + d[mask] = src + self.assertTrue(mx.array_equal(d, mx.array([[7.0, 8.0], [9.0, 10.0]]))) + + # empty mask leaves array unchanged + e = mx.zeros((0,), dtype=mx.float32) + mask = mx.zeros((0,), dtype=mx.bool_) + src = mx.zeros((0,), dtype=mx.float32) + e[mask] = src + self.assertTrue(mx.array_equal(e, mx.zeros((0,), dtype=mx.float32))) + + # strided target, mask, and source derived from slices + target = mx.arange(10.0, dtype=mx.float32)[1::2] + mask = mx.array( + [False, True, False, False, True, False, False, True, False, False], + dtype=mx.bool_, + )[1::2] + src = mx.arange(-4.0, 0.0, dtype=mx.float32)[::2] + + target[mask] = src + self.assertTrue( + mx.array_equal( + target, mx.array([-4.0, 3.0, 5.0, -2.0, 9.0], dtype=mx.float32) + ) + ) + + +class TestBroadcast(mlx_tests.MLXTestCase): def test_broadcast_shapes(self): # Basic broadcasting self.assertEqual(mx.broadcast_shapes((1, 2, 3), (3,)), (1, 2, 3)) diff --git a/python/tests/test_vmap.py b/python/tests/test_vmap.py index a88e5958..07025be8 100644 --- a/python/tests/test_vmap.py +++ b/python/tests/test_vmap.py @@ -723,6 +723,93 @@ class TestVmap(mlx_tests.MLXTestCase): out = mx.vmap(gconv, in_axes=(0, 0))(x, w) self.assertTrue(mx.allclose(expected, out)) + def test_vmap_masked_scatter(self): + def scatter_fn(x, m, src): + x[m] = src + return x + + # Batched sources + a = mx.array([[10, 20, 30, 40], [50, 60, 70, 80]]) + mask = mx.array([[False, True, True, True], [True, False, True, True]]) + src = mx.array([[1, 2, 3], [4, 5, 6]]) + + expected = mx.array([[10, 1, 2, 3], [4, 60, 5, 6]]) + vmap_scatter = mx.vmap(scatter_fn, in_axes=(0, 0, 0)) + out = vmap_scatter(a, mask, src) + self.assertTrue(mx.array_equal(expected, out)) + + # Shared source across batch (matching mask populations) + a = mx.array([[0, 0, 0], [5, 5, 5]]) + mask = mx.array([[True, False, True], [False, True, True]]) + src = mx.array([9, 8]) + + expected = mx.array([[9, 0, 8], [5, 9, 8]]) + vmap_scatter = mx.vmap(scatter_fn, in_axes=(0, 0, None)) + out = vmap_scatter(a, mask, src) + self.assertTrue(mx.array_equal(expected, out)) + + # Shared destination with batched mask and sources + a = mx.array([10, 20, 30, 40]) + mask = mx.array([[True, False, False, True], [False, True, True, False]]) + src = mx.array([[1, 2], [3, 4]]) + + expected = mx.array([[1, 20, 30, 2], [10, 3, 4, 40]]) + vmap_scatter = mx.vmap(scatter_fn, in_axes=(None, 0, 0)) + out = vmap_scatter(a, mask, src) + self.assertTrue(mx.array_equal(expected, out)) + + # Shared mask across batch with batched sources + a = mx.array([[0, 0, 0, 0], [10, 20, 30, 40]]) + mask = mx.array([True, False, True, False]) + src = mx.array([[7, 8], [9, 10]]) + + expected = mx.array([[7, 0, 8, 0], [9, 20, 10, 40]]) + vmap_scatter = mx.vmap(scatter_fn, in_axes=(0, None, 0)) + out = vmap_scatter(a, mask, src) + self.assertTrue(mx.array_equal(expected, out)) + + # Uneven mask populations with scalar broadcast + a = mx.array([[0.0, 0.0, 0.0, 0.0], [10.0, 20.0, 30.0, 40.0]]) + mask = mx.array([[True, False, True, True], [False, True, False, False]]) + shared_src = mx.array(1.5) + + expected = mx.array( + [[1.5, 0.0, 1.5, 1.5], [10.0, 1.5, 30.0, 40.0]], dtype=a.dtype + ) + vmap_scatter = mx.vmap(scatter_fn, in_axes=(0, 0, None)) + out = vmap_scatter(a, mask, shared_src) + self.assertTrue(mx.array_equal(expected, out)) + + # Shared src with identical masks must restart for each batch + a = mx.array([[0, 0, 0, 0, 0], [10, 20, 30, 40, 50]]) + mask = mx.array( + [[True, True, True, False, False], [True, True, True, False, False]] + ) + src = mx.array([1, 2, 3, 4, 5]) + + expected = mx.array([[1, 2, 3, 0, 0], [1, 2, 3, 40, 50]]) + vmap_scatter = mx.vmap(scatter_fn, in_axes=(0, 0, None)) + out = vmap_scatter(a, mask, src) + self.assertTrue(mx.array_equal(expected, out)) + + # Double vmap + a = mx.zeros((8, 8, 8)) + mask = mx.random.normal((8, 8, 8)) > 0 + src = mx.random.normal((8, 8)) + expected = mx.stack( + [ + mx.stack( + [scatter_fn(a[i, j] + 0, mask[i, j], src[i]) for j in range(8)] + ) + for i in range(8) + ] + ) + double_scatter = mx.vmap( + mx.vmap(scatter_fn, in_axes=(0, 0, None)), in_axes=(0, 0, 0) + ) + out = double_scatter(a + 0, mask, src) + self.assertTrue(mx.array_equal(expected, out)) + if __name__ == "__main__": mlx_tests.MLXTestRunner() diff --git a/tests/autograd_tests.cpp b/tests/autograd_tests.cpp index 3a373fb1..ff8d986b 100644 --- a/tests/autograd_tests.cpp +++ b/tests/autograd_tests.cpp @@ -13,6 +13,8 @@ #include "mlx/graph_utils.h" #include "mlx/mlx.h" +#include "mlx/backend/cuda/cuda.h" + using namespace mlx::core; TEST_CASE("test stop gradient") { @@ -1353,3 +1355,45 @@ TEST_CASE("test grad dynamic slices") { CHECK(allclose(outs[1], ones({1, 2})).item()); } } + +TEST_CASE("test masked_scatter autograd") { + if (cu::is_available()) { + INFO("Skipping masked_scatter cuda autograd tests"); + return; + } + + // Test jvp + { + auto self = array({10.f, 20.f, 30.f, 40.f}, {4}); + auto mask = array({false, true, false, true}, bool_); + auto src = array({7.f, 8.f}, {2}); + + auto self_tan = array({1.f, 2.f, 3.f, 4.f}, {4}); + auto src_tan = array({9.f, 11.f}, {2}); + + auto fun = [&mask](const std::vector& in) { + return std::vector{masked_scatter(in[0], mask, in[1])}; + }; + + auto outs = jvp(fun, {self, src}, {self_tan, src_tan}).second; + CHECK_EQ(outs.size(), 1); + CHECK(array_equal(outs[0], array({1.f, 9.f, 3.f, 11.f}, {4})).item()); + } + + // Test vjp + { + auto self = array({10.f, 20.f, 30.f, 40.f}, {4}); + auto mask = array({true, false, false, true}, bool_); + auto src = array({7.f, 8.f}, {2}); + + auto f_sum = [&mask](const std::vector& xs) { + return std::vector{sum(masked_scatter(xs[0], mask, xs[1]))}; + }; + + auto v = vjp(f_sum, {self, src}, {array(1.f)}); + const auto& grads = v.second; + + CHECK(array_equal(grads[0], array({0.f, 1.f, 1.f, 0.f}, {4})).item()); + CHECK(array_equal(grads[1], array({1.f, 1.f}, {2})).item()); + } +} diff --git a/tests/ops_tests.cpp b/tests/ops_tests.cpp index a0e0b154..62fd8c59 100644 --- a/tests/ops_tests.cpp +++ b/tests/ops_tests.cpp @@ -7,6 +7,7 @@ #include "doctest/doctest.h" +#include "mlx/backend/cuda/cuda.h" #include "mlx/mlx.h" using namespace mlx::core; @@ -2435,6 +2436,49 @@ TEST_CASE("test scatter") { } } +TEST_CASE("test masked_scatter") { + if (cu::is_available()) { + INFO("Skipping masked_scatter cuda ops tests"); + return; + } + + // Wrong mask dtype + CHECK_THROWS(masked_scatter(array({1, 2}), array({1, 2}), array({1, 2}))); + + // Mask must be broadcastable to self array + CHECK_THROWS(masked_scatter( + array({1, 2, 3, 4}, {2, 2}), + array({false, true, true, false}, {4, 1}), + array({1, 2}))); + + // 1D mask + { + auto self = zeros({4}, int32); + auto mask = array({true, true, false, true}); + auto source = array({1, 2, 4}); + auto out = masked_scatter(self, mask, source); + CHECK(array_equal(out, array({1, 2, 0, 4})).item()); + } + + // Empty mask + { + auto self = zeros({4}, int32); + auto mask = array({false, false, false, false}); + auto source = array({1, 2, 4}); + auto out = masked_scatter(self, mask, source); + CHECK(array_equal(out, self).item()); + } + + // Broadcasted mask + { + auto self = zeros({2, 2}, int32); + auto mask = array({true, false}); + auto source = array({5, 6, 7, 8}, {2, 2}); + auto out = masked_scatter(self, mask, source); + CHECK(array_equal(out, array({5, 6, 0, 0}, {2, 2})).item()); + } +} + TEST_CASE("test is positive infinity") { array x(1.0f); CHECK_FALSE(isposinf(x).item());