diff --git a/docs/src/dev/custom_metal_kernels.rst b/docs/src/dev/custom_metal_kernels.rst index f5881b5c17..e907190cbf 100644 --- a/docs/src/dev/custom_metal_kernels.rst +++ b/docs/src/dev/custom_metal_kernels.rst @@ -95,6 +95,29 @@ dimension should be less than or equal to the corresponding grid dimension. Passing ``verbose=True`` to :func:`ast.metal_kernel.__call__` will print the generated code for debugging purposes. +Math Mode +--------- + +By default :func:`fast.metal_kernel` compiles kernels with +``compile_options={"math_mode": "safe"}`` so special values follow IEEE +behavior, for example ``exp(-inf) == 0``. This is important for kernels such as +masked softmax where causal or sliding-window masks depend on exponentiating +``-inf``. + +If your kernel does not rely on these edge cases, you can opt in to less strict +math with ``compile_options={"math_mode": "relaxed"}`` or +``compile_options={"math_mode": "fast"}``: + +.. code-block:: python + + kernel = mx.fast.metal_kernel( + name="my_kernel", + input_names=["x"], + output_names=["y"], + source=source, + compile_options={"math_mode": "relaxed"}, + ) + Using Shape/Strides ------------------- diff --git a/mlx/backend/common/metal_kernel.cpp b/mlx/backend/common/metal_kernel.cpp index 691feb554a..b1e722b83f 100644 --- a/mlx/backend/common/metal_kernel.cpp +++ b/mlx/backend/common/metal_kernel.cpp @@ -206,7 +206,8 @@ CustomKernelFunction metal_kernel( const std::string& source, const std::string& header /* = "" */, bool ensure_row_contiguous /* = true */, - bool atomic_outputs /* = false */) { + bool atomic_outputs /* = false */, + const CompileOptions& compile_options /* = {} */) { if (output_names.empty()) { throw std::invalid_argument( "[metal_kernel] Must specify at least one output."); @@ -360,7 +361,8 @@ CustomKernelFunction metal_kernel( init_value, std::vector{}, false, - 0), + 0, + compile_options.serialize()), std::move(inputs)); }; } diff --git a/mlx/backend/common/metal_kernel.h b/mlx/backend/common/metal_kernel.h new file mode 100644 index 0000000000..6ac0f4fdd0 --- /dev/null +++ b/mlx/backend/common/metal_kernel.h @@ -0,0 +1,27 @@ +// Copyright © 2026 Apple Inc. + +#pragma once + +namespace mlx::core { + +enum class MathMode { + Safe = 0, + Relaxed = 1, + Fast = 2, +}; + +struct CompileOptions { + MathMode math_mode = MathMode::Safe; + + CompileOptions() = default; + bool operator==(const CompileOptions&) const = default; + + // A simple way to make export work, needs more work when adding new options. + using Data = int; + CompileOptions(Data data) : math_mode(static_cast(data)) {} + Data serialize() const { + return static_cast(math_mode); + } +}; + +} // namespace mlx::core diff --git a/mlx/backend/metal/custom_kernel.cpp b/mlx/backend/metal/custom_kernel.cpp index 0ba491f4ff..0648ed221e 100644 --- a/mlx/backend/metal/custom_kernel.cpp +++ b/mlx/backend/metal/custom_kernel.cpp @@ -1,5 +1,6 @@ // Copyright © 2024 Apple Inc. +#include "mlx/backend/common/metal_kernel.h" #include "mlx/backend/gpu/copy.h" #include "mlx/backend/metal/jit/includes.h" #include "mlx/backend/metal/utils.h" @@ -8,7 +9,8 @@ namespace mlx::core::fast { struct CustomKernelCache { - std::unordered_map libraries; + std::unordered_map> + libraries; }; static CustomKernelCache& cache() { @@ -58,17 +60,20 @@ void CustomKernel::eval_gpu( auto& kernel_cache = cache(); if (auto it = kernel_cache.libraries.find(name_); it != kernel_cache.libraries.end()) { - if (it->second != source_) { + if (it->second.first != source_ || + it->second.second != compile_options_) { auto& d = metal::device(s.device); d.clear_library(name_); - it->second = source_; + it->second = std::make_tuple(source_, compile_options_); } } else { - kernel_cache.libraries.emplace(name_, source_); + kernel_cache.libraries.emplace( + name_, std::make_tuple(source_, compile_options_)); } } - auto lib = d.get_library(name_, [this] { return metal::utils() + source_; }); + auto lib = d.get_library( + name_, compile_options_, [this] { return metal::utils() + source_; }); auto kernel = d.get_kernel(name_, lib); auto& compute_encoder = metal::get_command_encoder(s); compute_encoder.set_compute_pipeline_state(kernel); diff --git a/mlx/backend/metal/device.cpp b/mlx/backend/metal/device.cpp index 2a8e15afd7..29eecf5562 100644 --- a/mlx/backend/metal/device.cpp +++ b/mlx/backend/metal/device.cpp @@ -34,6 +34,34 @@ namespace { constexpr const char* default_mtllib_path = METAL_PATH; +void set_compile_options( + MTL::CompileOptions* mtl_options, + const CompileOptions& compile_options) { + if (__builtin_available(macOS 15, iOS 18, tvOS 18, visionOS 2, *)) { + switch (compile_options.math_mode) { + case MathMode::Safe: + mtl_options->setMathMode(MTL::MathModeSafe); + break; + case MathMode::Relaxed: + mtl_options->setMathMode(MTL::MathModeRelaxed); + break; + case MathMode::Fast: + mtl_options->setMathMode(MTL::MathModeFast); + break; + default: + throw std::invalid_argument("[metal::Device] Invalid math mode."); + } + } else { + if (compile_options.math_mode == MathMode::Relaxed) { + throw std::runtime_error( + "[metal::Device] Metal math mode `relaxed` requires macOS 15, " + "iOS 18, tvOS 18, or visionOS 2."); + } + mtl_options->setFastMathEnabled( + compile_options.math_mode == MathMode::Fast); + } +} + auto get_metal_version() { auto get_metal_version_ = []() { if (__builtin_available(macOS 26, iOS 26, tvOS 26, visionOS 26, *)) { @@ -620,7 +648,8 @@ MTL::Library* Device::get_library( } NS::SharedPtr Device::build_library_( - const std::string& source_string) { + const std::string& source_string, + const CompileOptions& compile_options) { auto pool = new_scoped_memory_pool(); auto ns_code = @@ -628,7 +657,7 @@ NS::SharedPtr Device::build_library_( NS::Error* error = nullptr; auto options = MTL::CompileOptions::alloc()->init()->autorelease(); - options->setFastMathEnabled(false); + set_compile_options(options, compile_options); options->setLanguageVersion(get_metal_version()); #ifndef NDEBUG if (options->languageVersion() >= MTL::LanguageVersion3_2) { @@ -769,6 +798,7 @@ NS::SharedPtr Device::get_kernel_( MTL::Library* Device::get_library( const std::string& name, + const CompileOptions& compile_options, const std::function& builder) { { std::shared_lock rlock(library_mtx_); @@ -782,7 +812,7 @@ MTL::Library* Device::get_library( return it->second.get(); } - auto mtl_lib = build_library_(builder()); + auto mtl_lib = build_library_(builder(), compile_options); library_map_.insert({name, mtl_lib}); return mtl_lib.get(); } diff --git a/mlx/backend/metal/device.h b/mlx/backend/metal/device.h index bed0cd636e..871e95ccff 100644 --- a/mlx/backend/metal/device.h +++ b/mlx/backend/metal/device.h @@ -11,6 +11,7 @@ #include #include "mlx/array.h" +#include "mlx/backend/common/metal_kernel.h" #include "mlx/backend/metal/resident.h" #include "mlx/device.h" @@ -168,8 +169,15 @@ class MLX_API Device { MTL::Library* get_library( const std::string& name, + const CompileOptions& compile_options, const std::function& builder); + MTL::Library* get_library( + const std::string& name, + const std::function& builder) { + return get_library(name, {}, builder); + } + void clear_library(const std::string& name); MTL::ComputePipelineState* get_kernel( @@ -190,7 +198,9 @@ class MLX_API Device { } private: - NS::SharedPtr build_library_(const std::string& source_string); + NS::SharedPtr build_library_( + const std::string& source_string, + const CompileOptions& compile_options = {}); NS::SharedPtr get_function_( const std::string& name, diff --git a/mlx/fast.h b/mlx/fast.h index 1183aba8fe..934fadc2b7 100644 --- a/mlx/fast.h +++ b/mlx/fast.h @@ -6,6 +6,7 @@ #include #include "mlx/api.h" +#include "mlx/backend/common/metal_kernel.h" #include "mlx/utils.h" namespace mlx::core::fast { @@ -75,7 +76,8 @@ MLX_API CustomKernelFunction metal_kernel( const std::string& source, const std::string& header = "", bool ensure_row_contiguous = true, - bool atomic_outputs = false); + bool atomic_outputs = false, + const CompileOptions& compile_options = {}); MLX_API CustomKernelFunction cuda_kernel( const std::string& name, diff --git a/mlx/fast_primitives.h b/mlx/fast_primitives.h index 4434830875..0d2f861045 100644 --- a/mlx/fast_primitives.h +++ b/mlx/fast_primitives.h @@ -3,6 +3,7 @@ #include #include +#include "mlx/backend/common/metal_kernel.h" #include "mlx/primitives.h" namespace mlx::core::fast { @@ -375,7 +376,8 @@ class CustomKernel : public Primitive { std::optional init_value, std::vector scalar_arguments, bool is_precompiled, - int shared_memory) + int shared_memory, + CompileOptions::Data compile_options = {}) : Primitive(stream), name_(std::move(name)), source_(std::move(source)), @@ -386,7 +388,8 @@ class CustomKernel : public Primitive { init_value_(init_value), scalar_arguments_(std::move(scalar_arguments)), is_precompiled_(is_precompiled), - shared_memory_(shared_memory) {} + shared_memory_(shared_memory), + compile_options_(compile_options) {} void eval_cpu(const std::vector& inputs, std::vector& outputs) override { @@ -408,7 +411,8 @@ class CustomKernel : public Primitive { init_value_, scalar_arguments_, is_precompiled_, - shared_memory_); + shared_memory_, + compile_options_); } private: @@ -422,6 +426,7 @@ class CustomKernel : public Primitive { std::vector scalar_arguments_; bool is_precompiled_; int shared_memory_; + CompileOptions::Data compile_options_; }; } // namespace mlx::core::fast diff --git a/python/src/fast.cpp b/python/src/fast.cpp index 1a43d89d9b..cd30b0bacd 100644 --- a/python/src/fast.cpp +++ b/python/src/fast.cpp @@ -75,6 +75,43 @@ struct PyCustomKernelFunction { const char* tag_; }; +mx::MathMode parse_metal_math_mode(const std::string& math_mode) { + if (math_mode == "safe") { + return mx::MathMode::Safe; + } else if (math_mode == "relaxed") { + return mx::MathMode::Relaxed; + } else if (math_mode == "fast") { + return mx::MathMode::Fast; + } + throw std::invalid_argument( + "[metal_kernel] Expected math_mode to be 'safe', 'relaxed', or 'fast'."); +} + +mx::CompileOptions parse_compile_options(const nb::object& obj) { + mx::CompileOptions result; + if (obj.is_none()) { + return result; + } + + if (!nb::isinstance(obj)) { + throw std::invalid_argument( + "[metal_kernel] Expected `compile_options` to be a dict."); + } + + nb::dict dict = nb::cast(obj); + for (auto [key, value] : dict) { + auto key_str = nb::cast(key); + if (key_str == "math_mode") { + result.math_mode = parse_metal_math_mode(nb::cast(value)); + } else { + std::ostringstream msg; + msg << "[metal_kernel] Unknown compile option `" << key_str << "`."; + throw std::invalid_argument(msg.str()); + } + } + return result; +} + } // namespace void init_fast(nb::module_& parent_module) { @@ -304,7 +341,8 @@ void init_fast(nb::module_& parent_module) { const std::string& source, const std::string& header, bool ensure_row_contiguous, - bool atomic_outputs) { + bool atomic_outputs, + const nb::object& compile_options) { auto kernel = mx::fast::metal_kernel( name, input_names, @@ -312,7 +350,8 @@ void init_fast(nb::module_& parent_module) { source, header, ensure_row_contiguous, - atomic_outputs); + atomic_outputs, + parse_compile_options(compile_options)); return nb::cpp_function( PyCustomKernelFunction(std::move(kernel), "[metal_kernel]"), nb::kw_only(), @@ -356,6 +395,7 @@ void init_fast(nb::module_& parent_module) { "header"_a = "", "ensure_row_contiguous"_a = true, "atomic_outputs"_a = false, + "compile_options"_a = nb::none(), R"pbdoc( A jit-compiled custom Metal kernel defined from a source string. @@ -376,6 +416,12 @@ void init_fast(nb::module_& parent_module) { before the kernel runs. Default: ``True``. atomic_outputs (bool): Whether to use atomic outputs in the function signature e.g. ``device atomic``. Default: ``False``. + compile_options (dict, optional): Options to compile the Metal kernel + with. Supported options: + + * ``"math_mode"``: The Metal math mode: ``"safe"``, ``"relaxed"``, + or ``"fast"``. ``"safe"`` preserves IEEE behavior for special + values such as ``exp(-inf) == 0``. Default: ``"safe"``. Returns: Callable ``metal_kernel``. diff --git a/python/tests/test_export_import.py b/python/tests/test_export_import.py index 87e7b31ced..c45dd4a606 100644 --- a/python/tests/test_export_import.py +++ b/python/tests/test_export_import.py @@ -638,6 +638,38 @@ def call_cpu(a): with self.assertRaisesRegex(RuntimeError, "No Metal back-end"): mx.eval(mx.compile(call)(a)) + def test_export_custom_metal_kernel_with_math_mode(self): + source = """ + uint elem = thread_position_in_grid.x; + out[elem] = metal::exp(a[elem]); + """ + kernel = mx.fast.metal_kernel( + name="math_mode_export", + input_names=["a"], + output_names=["out"], + source=source, + compile_options={"math_mode": "safe"}, + ) + + def call(a): + return kernel( + inputs=[a], + grid=(a.size, 1, 1), + threadgroup=(min(a.size, 256), 1, 1), + output_shapes=[a.shape], + output_dtypes=[a.dtype], + stream=mx.gpu, + )[0] + + a = mx.array([-float("inf"), 0.0]) + path = os.path.join(self.test_dir, "metal_kernel_math_mode.mlxfn") + mx.export_function(path, call, a) + self.assertTrue(os.path.exists(path)) + + if mx.metal.is_available(): + imported = mx.import_function(path) + self.assertTrue(mx.array_equal(imported(a)[0], call(a))) + def test_export_import_multi_with_constants(self): path = os.path.join(self.test_dir, "fn.mlxfn") diff --git a/python/tests/test_fast.py b/python/tests/test_fast.py index b1c84c987d..d40dda0a09 100644 --- a/python/tests/test_fast.py +++ b/python/tests/test_fast.py @@ -1026,6 +1026,67 @@ def call_kernel(a: mx.array, source): out = call_kernel(a, source) self.assertTrue(mx.array_equal(out, mx.ones_like(out))) + @unittest.skipIf(not mx.metal.is_available(), "Metal is not available") + def test_custom_metal_kernel_math_mode(self): + with self.assertRaises(ValueError): + mx.fast.metal_kernel( + name="invalid_math_mode", + input_names=["inp"], + output_names=["out"], + source="out[0] = inp[0];", + compile_options={"math_mode": "precise"}, + ) + + with self.assertRaises(ValueError): + mx.fast.metal_kernel( + name="invalid_compile_options", + input_names=["inp"], + output_names=["out"], + source="out[0] = inp[0];", + compile_options={"unknown": "value"}, + ) + + # Numerical special cases such as exp(-inf) can agree between math + # modes, so they don't reliably detect whether the mode was applied. + # Branch on the compiler's __FAST_MATH__ macro instead: it is defined + # only when fast math is enabled, so the test fails if the selected + # math mode is not forwarded to the Metal compiler. + source = """ + uint elem = thread_position_in_grid.x; + #if defined(__FAST_MATH__) && __FAST_MATH__ + out[elem] = 1.0f; + #else + out[elem] = 0.0f; + #endif + """ + + a = mx.zeros((4,), dtype=mx.float32) + expected = { + "safe": mx.zeros_like(a), + "fast": mx.ones_like(a), + } + + # Reuse the same kernel name across modes so the library cache is forced + # to rebuild when the math mode changes, guarding against a stale build + # being returned for a different mode. + for mode, expected_out in expected.items(): + kernel = mx.fast.metal_kernel( + name="math_mode", + input_names=["inp"], + output_names=["out"], + source=source, + compile_options={"math_mode": mode}, + ) + out = kernel( + inputs=[a], + grid=(a.size, 1, 1), + threadgroup=(a.size, 1, 1), + output_shapes=[a.shape], + output_dtypes=[a.dtype], + stream=mx.gpu, + )[0] + self.assertTrue(mx.array_equal(out, expected_out)) + @unittest.skipIf(not mx.metal.is_available(), "Metal is not available") def test_custom_kernel_mixed_dtypes(self): # Calling the same kernel with different input dtypes in a single