From 17e802902f29b550f8860e4c480c2fd89c365e5d Mon Sep 17 00:00:00 2001 From: Tim Besard Date: Tue, 28 Jul 2026 08:02:23 +0200 Subject: [PATCH 01/15] Make `is_restricted_float` extensible Replace the closed `RestrictedFloat` union with method-based dispatch so package extensions can register their own restricted float types (FP8, FP4) alongside the built-in `TFloat32`. Mirrors cuTile Python's `NumericDTypeCategories.RestrictedFloat`, which covers tfloat32 and all the FP8/FP4 formats. The `reduce`/`scan` call sites run inside the compilation pipeline, whose world is frozen at `cuTile.__init__`, so they cannot see methods added by extensions loaded afterwards. Route them through `Base.invokelatest`, like `lookup_dtype!` already does for `julia_to_tile_dtype!`. Co-Authored-By: Claude Fable 5 --- src/compiler/intrinsics/core.jl | 13 ++++++++----- src/language/types.jl | 23 +++++++++++------------ 2 files changed, 19 insertions(+), 17 deletions(-) diff --git a/src/compiler/intrinsics/core.jl b/src/compiler/intrinsics/core.jl index 225f122d..31f7d2bb 100644 --- a/src/compiler/intrinsics/core.jl +++ b/src/compiler/intrinsics/core.jl @@ -812,10 +812,12 @@ function emit_reduce!(ctx::CGCtx, args) for (k, tv) in enumerate(tile_tvs) etype = eltype(CC.widenconst(tv.jltype)) - # Restricted floats (TFloat32, future FP8/FP4) lack the arithmetic - # support that reduce body subprograms typically require, so reject - # them at the SCI boundary with a clear error. - is_restricted_float(etype) && + # Restricted floats (TFloat32, FP8/FP4 from the extensions) lack the + # arithmetic support that reduce body subprograms typically require, so + # reject them at the SCI boundary with a clear error. Resolved in the + # latest world for the same reason as `lookup_dtype!`: extensions + # register their types after the pipeline's world was frozen. + Base.invokelatest(is_restricted_float, etype)::Bool && throw(IRError("reduce: element type $etype is a restricted float and unsupported")) push!(elem_types, etype) dtype = lookup_dtype!(tt, etype) @@ -990,7 +992,8 @@ function emit_intrinsic!(ctx::CGCtx, ::typeof(Intrinsics.scan), args) for (k, tv) in enumerate(tile_tvs) etype = eltype(CC.widenconst(tv.jltype)) - is_restricted_float(etype) && + # latest-world lookup, see `emit_reduce!` + Base.invokelatest(is_restricted_float, etype)::Bool && throw(IRError("scan: element type $etype is a restricted float and unsupported")) push!(elem_types, etype) dtype = lookup_dtype!(tt, etype) diff --git a/src/language/types.jl b/src/language/types.jl index 77540619..8e8e1b42 100644 --- a/src/language/types.jl +++ b/src/language/types.jl @@ -683,22 +683,21 @@ const ScalarInt = Union{Int8, UInt8, Int16, UInt16, Int32, UInt32, Int64, UInt64 """Scalar floating-point types supported by Tile IR (f16, bf16, tf32, f32, f64).""" const ScalarFloat = Union{Float16, BFloat16, Float32, Float64, TFloat32} -""" -Restricted floats — types whose op coverage is intentionally limited -(no general arithmetic, reductions, scans, …). Currently `TFloat32`; -future FP8/FP4 dtypes will join this union. Mirrors cuTile Python's -`NumericDTypeCategories.RestrictedFloat`. -""" -const RestrictedFloat = Union{TFloat32} - """ is_restricted_float(::Type) -> Bool -True if `T` is a restricted float. Used by `reduce` / `scan` (and other -arithmetic-requiring ops) to reject unsupported element types early -with a clear error rather than letting tileiras fail downstream. +True if `T` is a restricted float: a storage / tensor-core operand format whose +op coverage is intentionally limited (no general arithmetic, reductions or +scans). Used by `reduce` / `scan` and by the tile arithmetic guards +([`check_arithmetic`](@ref)) to reject unsupported element types early with a +clear error rather than letting tileiras fail downstream. + +`TFloat32` is built in; package extensions register their own types by adding +methods (e.g. `DLFP8TypesExt` for `Float8_E4M3FN`). Mirrors cuTile Python's +`NumericDTypeCategories.RestrictedFloat`. """ -@inline is_restricted_float(::Type{T}) where {T} = T <: RestrictedFloat +is_restricted_float(::Type) = false +is_restricted_float(::Type{TFloat32}) = true """Integer tile types.""" const IntTile{S} = Tile{T, S} where {T <: ScalarInt} From 9130d567d163bd5de58f6d72d15be5bccabed77d Mon Sep 17 00:00:00 2001 From: Tim Besard Date: Tue, 28 Jul 2026 08:02:34 +0200 Subject: [PATCH 02/15] Reject restricted floats in direct tile arithmetic `Tile{T}` arithmetic that dispatches straight to the float intrinsics (`addf`/`subf`/`negf`/`mulf`/`divf`) bypasses the scalar broadcast path, so a restricted float element type reached tileiras unchecked and failed the MLIR verifier with "operand #0 must be tile of f16 or bf16 or f32 or f64 values". That hit both `tf32_tile + tf32_tile` and `f8_tile + f8_tile`. Guard the six affected methods with `check_arithmetic`, which folds away for arithmetic floats and leaves an unconditional throw otherwise; `lower_throws!` turns that into a collected compile-time diagnostic. The message is a module-level constant rather than an interpolation of `op` and `T`: kernel-side throws only report their text when it reconstructs to a compile-time constant, and a run-time `string(op, ..., T, ...)` degrades to "ArgumentError was thrown". The operator and element type are still named, by `check_arithmetic`'s own frame in the diagnostic's stacktrace. Co-Authored-By: Claude Fable 5 --- src/language/arithmetic.jl | 47 +++++++++++++++++++++++++++++++++----- 1 file changed, 41 insertions(+), 6 deletions(-) diff --git a/src/language/arithmetic.jl b/src/language/arithmetic.jl index 69582283..95702806 100644 --- a/src/language/arithmetic.jl +++ b/src/language/arithmetic.jl @@ -90,13 +90,45 @@ conventions: the remainder takes the sign of the divisor. @inline divmod(x::Tile{T,S}, y::Tile{T,S}) where {T<:Integer, S} = (div.(x, y, RoundDown), mod.(x, y)) +# Kernel-side throws only report their message when it reconstructs to a +# compile-time constant (see `throw_constant` in transform/throws.jl): a message +# assembled at run time from `op` and `T` degrades to "ArgumentError was thrown". +# Keep one constant message; the collected diagnostic's stacktrace already names +# the operator and element type through `check_arithmetic`'s own frame. +const RESTRICTED_ARITHMETIC_MESSAGE = + "arithmetic on a restricted float element type is not supported; " * + "perform an explicit cast instead, e.g. convert(Tile{Float32}, x)" + +""" + check_arithmetic(op, T) + +Reject `op` on a restricted float element type `T`. Restricted floats +(`TFloat32`, and the FP8/FP4 types registered by the package extensions) are +storage and tensor-core operand formats: the elementwise float intrinsics only +accept f16/bf16/f32/f64, so an unguarded `op` produces an opaque tileiras +verifier failure. Fail early with an actionable error instead. + +`op` is unused beyond naming the offending operator in the error's stacktrace. +The check folds away for arithmetic floats, so it must be called directly (not +through `invokelatest`) to stay free on the common path. +""" +function check_arithmetic(op, ::Type{T}) where {T} + if is_restricted_float(T) + throw(ArgumentError(RESTRICTED_ARITHMETIC_MESSAGE)) + end + return nothing +end + # direct operators (same shape required) -@inline Base.:(+)(a::Tile{T, S}, b::Tile{T, S}) where {T <: AbstractFloat, S} = Intrinsics.addf(a, b) +@inline Base.:(+)(a::Tile{T, S}, b::Tile{T, S}) where {T <: AbstractFloat, S} = + (check_arithmetic(+, T); Intrinsics.addf(a, b)) @inline Base.:(+)(a::Tile{T, S}, b::Tile{T, S}) where {T <: Integer, S} = Intrinsics.addi(a, b) -@inline Base.:(-)(a::Tile{T, S}, b::Tile{T, S}) where {T <: AbstractFloat, S} = Intrinsics.subf(a, b) +@inline Base.:(-)(a::Tile{T, S}, b::Tile{T, S}) where {T <: AbstractFloat, S} = + (check_arithmetic(-, T); Intrinsics.subf(a, b)) @inline Base.:(-)(a::Tile{T, S}, b::Tile{T, S}) where {T <: Integer, S} = Intrinsics.subi(a, b) -@inline Base.:(-)(a::Tile{T}) where {T <: AbstractFloat} = Intrinsics.negf(a) +@inline Base.:(-)(a::Tile{T}) where {T <: AbstractFloat} = + (check_arithmetic(-, T); Intrinsics.negf(a)) @inline Base.:(-)(a::Tile{T}) where {T <: Integer} = Intrinsics.negi(a) # All other tile arithmetic (*, -, /, ^, comparisons, ifelse, etc.) is handled @@ -117,6 +149,9 @@ end ## mixed arithmetic # direct operators (tile * scalar, tile / scalar) -@inline Base.:(*)(a::Tile{T}, b::Number) where {T <: AbstractFloat} = Intrinsics.mulf(a, broadcast_to(Tile(T(b)), size(a))) -@inline Base.:(*)(a::Number, b::Tile{T}) where {T <: AbstractFloat} = Intrinsics.mulf(broadcast_to(Tile(T(a)), size(b)), b) -@inline Base.:(/)(a::Tile{T}, b::Number) where {T <: AbstractFloat} = Intrinsics.divf(a, broadcast_to(Tile(T(b)), size(a))) +@inline Base.:(*)(a::Tile{T}, b::Number) where {T <: AbstractFloat} = + (check_arithmetic(*, T); Intrinsics.mulf(a, broadcast_to(Tile(T(b)), size(a)))) +@inline Base.:(*)(a::Number, b::Tile{T}) where {T <: AbstractFloat} = + (check_arithmetic(*, T); Intrinsics.mulf(broadcast_to(Tile(T(a)), size(b)), b)) +@inline Base.:(/)(a::Tile{T}, b::Number) where {T <: AbstractFloat} = + (check_arithmetic(/, T); Intrinsics.divf(a, broadcast_to(Tile(T(b)), size(a)))) From 2fac99424724060fe9bb104323e9dd67e832dd7b Mon Sep 17 00:00:00 2001 From: Tim Besard Date: Tue, 28 Jul 2026 08:04:18 +0200 Subject: [PATCH 03/15] Block implicit FP8/FP4 arithmetic in kernels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DLFP8Types and Microfloats both define scalar arithmetic as a Float32 round-trip. Inside a kernel that combines with cuTile's `ftof` constructor overlays, so `f8_tile .+ f8_tile` compiled silently into ftof → addf → ftof: an implicit upcast with an extra rounding per operation and no diagnostic. cuTile Python rejects the same expression with "has non-arithmetic dtype". Register both packages' types as restricted floats, and shadow the upstream arithmetic with erroring overlays in `cuTileMethodTable` so the broadcast path reports the same error as the tile-level operators. Conversions, comparisons and MMA are untouched, as is all host-side arithmetic. The registration covers every `FP8` / `Microfloat` subtype rather than only the ones with a Tile IR dtype: the overlays are written against those same abstract supertypes to shadow exactly the methods upstream defines, and a subtype that was not registered would fall through the guard and silently return `nothing`. Unary negation is an exact sign-bit flip upstream and would have compiled, but the tile-level `-(::Tile{T})` lowers to `negf` and is blocked, and having `-x` and `(-).(x)` disagree on the same tile is worse than rejecting both. Co-Authored-By: Claude Fable 5 --- ext/DLFP8TypesExt.jl | 32 ++++++++++++++++++++++++++++++++ ext/MicrofloatsExt.jl | 43 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+) diff --git a/ext/DLFP8TypesExt.jl b/ext/DLFP8TypesExt.jl index 841b44c3..d78982da 100644 --- a/ext/DLFP8TypesExt.jl +++ b/ext/DLFP8TypesExt.jl @@ -1,6 +1,7 @@ module DLFP8TypesExt import cuTile as ct +import DLFP8Types using DLFP8Types: Float8_E4M3FN, Float8_E5M2 @@ -42,4 +43,35 @@ for F8 in FP8Types end end +# FP8 is a storage / tensor-core operand format, not an arithmetic type: the +# Tile IR elementwise float ops only accept f16/bf16/f32/f64. Registered for +# every `FP8` subtype, not just the two with a Tile IR dtype, so the blocking +# overlays below cover exactly the methods DLFP8Types defines. +ct.is_restricted_float(::Type{<:DLFP8Types.FP8}) = true + +# DLFP8Types implements scalar arithmetic as a Float32 round-trip +# (`T(op(Float32(a), Float32(b)))`, src/DLFP8Types.jl). Combined with our `ftof` +# constructor overlays above, a kernel-side `f8_tile .+ f8_tile` would compile +# silently into ftof → addf → ftof: an implicit upcast with an extra rounding +# per operation, and no hint that a cast happened. Shadow those methods so the +# broadcast path reports the same error as the tile-level operators. +# +# Plain `@overlay`, not `@consistent_overlay`: throwing is deliberately +# inconsistent with the shadowed method. Host-side FP8 arithmetic is untouched. +for op in (:+, :-, :*, :/, :\, :^) + @eval Base.Experimental.@overlay ct.cuTileMethodTable Base.$op(a::T, b::T) where {T<:DLFP8Types.FP8} = + ct.check_arithmetic(Base.$op, T) +end +for op in (:sin, :cos, :tan, :asin, :acos, :atan, :sinh, :cosh, :tanh, :asinh, + :acosh, :atanh, :exp, :exp2, :exp10, :expm1, :log, :log2, :log10, + :sqrt, :cbrt, :log1p) + @eval Base.Experimental.@overlay ct.cuTileMethodTable Base.$op(a::T) where {T<:DLFP8Types.FP8} = + ct.check_arithmetic(Base.$op, T) +end +# Unary negation is an exact sign-bit flip upstream, so it would compile — but +# the tile-level `-(::Tile{T})` is blocked (it lowers to `negf`), and `-x` and +# `(-).(x)` disagreeing on the same tile is worse than rejecting both. +Base.Experimental.@overlay ct.cuTileMethodTable Base.:(-)(a::T) where {T<:DLFP8Types.FP8} = + ct.check_arithmetic(Base.:(-), T) + end diff --git a/ext/MicrofloatsExt.jl b/ext/MicrofloatsExt.jl index e01ed539..c807fa78 100644 --- a/ext/MicrofloatsExt.jl +++ b/ext/MicrofloatsExt.jl @@ -56,4 +56,47 @@ for MF in MicrofloatTypes end end +# Microfloats are storage / tensor-core operand formats, not arithmetic types: +# the Tile IR elementwise float ops only accept f16/bf16/f32/f64. Registered for +# every `Microfloat`, not just the four with a Tile IR dtype, so the blocking +# overlays below cover exactly the methods Microfloats defines. +ct.is_restricted_float(::Type{<:Microfloats.Microfloat}) = true + +# Microfloats implements scalar arithmetic as a Float32 round-trip +# (`T(op(Float32(a), Float32(b)))`, src/ops.jl). Combined with our `ftof` +# constructor overlays above, a kernel-side `f8_tile .+ f8_tile` would compile +# silently into ftof → addf → ftof: an implicit upcast with an extra rounding +# per operation, and no hint that a cast happened. Shadow those methods so the +# broadcast path reports the same error as the tile-level operators. +# +# Plain `@overlay`, not `@consistent_overlay`: throwing is deliberately +# inconsistent with the shadowed method. Host-side microfloat arithmetic is +# untouched. Comparisons (`< <= == isless`) are left alone — they upcast to +# Float32 too, but losslessly, and cuTile Python allows them as well. +for op in (:+, :-, :*, :/, :\, :^) + @eval Base.Experimental.@overlay ct.cuTileMethodTable Base.$op(a::T, b::T) where {T<:Microfloats.Microfloat} = + ct.check_arithmetic(Base.$op, T) +end +Base.Experimental.@overlay ct.cuTileMethodTable Base.:^(a::T, b::Integer) where {T<:Microfloats.Microfloat} = + ct.check_arithmetic(Base.:^, T) +for op in (:sin, :cos, :tan, :asin, :acos, :atan, :sinh, :cosh, :tanh, :asinh, + :acosh, :atanh, :exp, :exp2, :exp10, :expm1, :log, :log2, :log10, + :sqrt, :cbrt, :log1p, :modf, :mod2pi) + @eval Base.Experimental.@overlay ct.cuTileMethodTable Base.$op(a::T) where {T<:Microfloats.Microfloat} = + ct.check_arithmetic(Base.$op, T) +end +for op in (:atan, :hypot) + @eval Base.Experimental.@overlay ct.cuTileMethodTable Base.$op(a::T, b::T) where {T<:Microfloats.Microfloat} = + ct.check_arithmetic(Base.$op, T) +end +for op in (:frexp, :ldexp) + @eval Base.Experimental.@overlay ct.cuTileMethodTable Base.$op(a::T, b::Int) where {T<:Microfloats.Microfloat} = + ct.check_arithmetic(Base.$op, T) +end +# Unary negation is an exact sign-bit flip upstream, so it would compile — but +# the tile-level `-(::Tile{T})` is blocked (it lowers to `negf`), and `-x` and +# `(-).(x)` disagreeing on the same tile is worse than rejecting both. +Base.Experimental.@overlay ct.cuTileMethodTable Base.:(-)(a::T) where {T<:Microfloats.Microfloat} = + ct.check_arithmetic(Base.:(-), T) + end From 654e60c02febc5fb0c0bd01caa282489bd545823 Mon Sep 17 00:00:00 2001 From: Tim Besard Date: Tue, 28 Jul 2026 08:06:58 +0200 Subject: [PATCH 04/15] Cast explicitly in the FP8 multiply-add tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both `fma_e4m3` kernels did `muladd.(ta, tb, tc)` on FP8 tiles, which is exactly the implicit-upcast pattern that is now rejected: broadcast `muladd` expands to the scalar `x * y + z`, whose operators are blocked. Round each input through FP8 and back, then compute in Float32. That is what the test was really checking — that representable values survive the round-trip — and the inputs keep products and sums exact, so the assertion is unchanged. Use `.*`/`.+` rather than `muladd.`: there is no elementwise `muladd` on Float32 tiles (tile `muladd` is the MMA path, and the scalar one lowers to the unmapped `muladd_float`). The FP8 kernels only reached it through Base's generic `muladd(x, y, z) = x * y + z` fallback for non-IEEE floats. Co-Authored-By: Claude Fable 5 --- test/extensions/DLFP8Types.jl | 17 +++++++++++------ test/extensions/Microfloats/device.jl | 17 +++++++++++------ 2 files changed, 22 insertions(+), 12 deletions(-) diff --git a/test/extensions/DLFP8Types.jl b/test/extensions/DLFP8Types.jl index f18371e4..fb81ddc7 100644 --- a/test/extensions/DLFP8Types.jl +++ b/test/extensions/DLFP8Types.jl @@ -78,15 +78,20 @@ function rt_e5m2(a::ct.TileArray{Float32,1}, b::ct.TileArray{Float32,1}) ct.store(b, pid, convert(ct.Tile{Float32}, convert(ct.Tile{Float8_E5M2}, tile))) return end -# FMA in FP8: load Float32, convert to FP8, multiply-add in FP8, convert back. -# Inputs whose products and sums also stay representable, so the result is exact. +# Round a Float32 tile through FP8 and back. FP8 is a restricted float, so +# arithmetic on it is rejected: kernels cast explicitly instead. +round_e4m3(t) = convert(ct.Tile{Float32}, convert(ct.Tile{Float8_E4M3FN}, t)) + +# Multiply-add on FP8-rounded inputs: load Float32, round each input through +# FP8, then compute in Float32. Inputs whose products and sums also stay +# representable in FP8, so the result is exact. function fma_e4m3(a::ct.TileArray{Float32,1}, b::ct.TileArray{Float32,1}, c::ct.TileArray{Float32,1}, d::ct.TileArray{Float32,1}) pid = ct.bid(1) - ta = convert(ct.Tile{Float8_E4M3FN}, ct.load(a, pid, (16,))) - tb = convert(ct.Tile{Float8_E4M3FN}, ct.load(b, pid, (16,))) - tc = convert(ct.Tile{Float8_E4M3FN}, ct.load(c, pid, (16,))) - ct.store(d, pid, convert(ct.Tile{Float32}, muladd.(ta, tb, tc))) + ta = round_e4m3(ct.load(a, pid, (16,))) + tb = round_e4m3(ct.load(b, pid, (16,))) + tc = round_e4m3(ct.load(c, pid, (16,))) + ct.store(d, pid, ta .* tb .+ tc) return end # Non-scaled FP8 matmul with both allowed accumulator dtypes (f16 and f32). diff --git a/test/extensions/Microfloats/device.jl b/test/extensions/Microfloats/device.jl index 97ede9a5..9a542e52 100644 --- a/test/extensions/Microfloats/device.jl +++ b/test/extensions/Microfloats/device.jl @@ -30,13 +30,17 @@ function rt_f4(a::ct.TileArray{Float32,1}, b::ct.TileArray{Float32,1}) ct.store(b, pid, convert(ct.Tile{Float32}, convert(ct.Tile{Float4_E2M1FN}, tile))) return end +# Round a Float32 tile through FP8 and back. FP8 is a restricted float, so +# arithmetic on it is rejected: kernels cast explicitly instead. +round_e4m3(t) = convert(ct.Tile{Float32}, convert(ct.Tile{Float8_E4M3FN}, t)) + function fma_e4m3(a::ct.TileArray{Float32,1}, b::ct.TileArray{Float32,1}, c::ct.TileArray{Float32,1}, d::ct.TileArray{Float32,1}) pid = ct.bid(1) - ta = convert(ct.Tile{Float8_E4M3FN}, ct.load(a, pid, (16,))) - tb = convert(ct.Tile{Float8_E4M3FN}, ct.load(b, pid, (16,))) - tc = convert(ct.Tile{Float8_E4M3FN}, ct.load(c, pid, (16,))) - ct.store(d, pid, convert(ct.Tile{Float32}, muladd.(ta, tb, tc))) + ta = round_e4m3(ct.load(a, pid, (16,))) + tb = round_e4m3(ct.load(b, pid, (16,))) + tc = round_e4m3(ct.load(c, pid, (16,))) + ct.store(d, pid, ta .* tb .+ tc) return end @@ -56,8 +60,9 @@ if capability(device()) >= v"9" @test Array(b) == representable8 end - # FMA in FP8: load f32, convert to FP8, multiply-add in FP8, convert back. - # Inputs whose products and sums stay representable, so the result is exact. + # Multiply-add on FP8-rounded inputs: load f32, round each input through + # FP8, then compute in f32. Inputs whose products and sums stay + # representable in FP8, so the result is exact. let av = Float32[1.0, 2.0, 0.5, 4.0, 1.5, 2.0, -1.0, -0.5, 3.0, 0.5, 1.0, 2.0, -2.0, 1.0, 0.5, 4.0], bv = Float32[2.0, 1.0, 4.0, 0.5, 2.0, 3.0, 2.0, 4.0, 1.0, 2.0, 1.0, 0.5, 2.0, 1.0, 2.0, 1.0], cv = Float32[0.0, 1.0, 0.0, 0.0, 1.0, 1.0, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0, 0.0] From c00c205bb75bf07fd6f7ff9b257e827a1bb83a9d Mon Sep 17 00:00:00 2001 From: Tim Besard Date: Tue, 28 Jul 2026 08:10:10 +0200 Subject: [PATCH 05/15] Test that restricted floats reject arithmetic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cover the four rejection paths per element type: broadcast, the direct tile operator, unary math through broadcast, and tile × scalar. FP8 adds broadcast `muladd` (which expands to `x * y + z`), and Microfloats adds a Float4_E2M1FN case to pin that the overlays dispatching on `Microfloat` really do cover every variant. Also pin the two things that must keep working: comparisons still lower to `ftof` + `cmpf`, and host-side FP8 arithmetic is unaffected by the overlays. Co-Authored-By: Claude Fable 5 --- test/codegen/operations.jl | 54 ++++++++++++++++++++ test/extensions/DLFP8Types.jl | 56 ++++++++++++++++++++ test/extensions/Microfloats/codegen.jl | 71 ++++++++++++++++++++++++++ 3 files changed, 181 insertions(+) diff --git a/test/codegen/operations.jl b/test/codegen/operations.jl index 4d2c413f..02eca158 100644 --- a/test/codegen/operations.jl +++ b/test/codegen/operations.jl @@ -2001,6 +2001,60 @@ end end end end + + # TFloat32 is a restricted float: a tensor-core operand format the + # elementwise float ops do not accept. The direct tile operators used to + # emit `addf`/`subf`/`negf`/`mulf`/`divf` on it anyway, which failed the + # tileiras verifier; they now reject it up front. + @testset "restricted float arithmetic" begin + spec_tf32 = ct.ArraySpec{1}(16, true) + AT = ct.TileArray{ct.TFloat32,1,spec_tf32} + + @test_throws "restricted float" code_tiled(devnull, + (a, b, c) -> begin + pid = ct.bid(1) + ct.store(c, pid, ct.load(a, pid, (16,)) + ct.load(b, pid, (16,))) + return + end, Tuple{AT, AT, AT}) + + @test_throws "restricted float" code_tiled(devnull, + (a, b, c) -> begin + pid = ct.bid(1) + ct.store(c, pid, ct.load(a, pid, (16,)) - ct.load(b, pid, (16,))) + return + end, Tuple{AT, AT, AT}) + + @test_throws "restricted float" code_tiled(devnull, + (a, b) -> begin + pid = ct.bid(1) + ct.store(b, pid, -ct.load(a, pid, (16,))) + return + end, Tuple{AT, AT}) + + # tile × scalar and tile / scalar take the mixed-arithmetic path + @test_throws "restricted float" code_tiled(devnull, + (a, b) -> begin + pid = ct.bid(1) + ct.store(b, pid, ct.load(a, pid, (16,)) * 2.0f0) + return + end, Tuple{AT, AT}) + + @test_throws "restricted float" code_tiled(devnull, + (a, b) -> begin + pid = ct.bid(1) + ct.store(b, pid, ct.load(a, pid, (16,)) / 2.0f0) + return + end, Tuple{AT, AT}) + + # The broadcast path never had a TFloat32 scalar method to begin with, + # so it keeps failing through Base's `no_op_err`. + @test_throws "+ not defined for" code_tiled(devnull, + (a, b, c) -> begin + pid = ct.bid(1) + ct.store(c, pid, ct.load(a, pid, (16,)) .+ ct.load(b, pid, (16,))) + return + end, Tuple{AT, AT, AT}) + end end #========================================================================= diff --git a/test/extensions/DLFP8Types.jl b/test/extensions/DLFP8Types.jl index fb81ddc7..dae7e05a 100644 --- a/test/extensions/DLFP8Types.jl +++ b/test/extensions/DLFP8Types.jl @@ -60,6 +60,62 @@ end end +# FP8 is a restricted float: a storage / tensor-core operand format without +# general arithmetic. DLFP8Types' scalar fallbacks would otherwise let the +# broadcast path compile into a silent ftof/op/ftof round-trip, and the direct +# tile operators into an `addf` the tileiras verifier rejects. +@testset "restricted arithmetic" begin + +AT = ct.TileArray{Float8_E4M3FN,1,spec1d} + +# broadcast (upstream's Float32 round-trip fallback) +@test_throws "restricted float" code_tiled(devnull, + (a, b, c) -> begin + pid = ct.bid(1) + ct.store(c, pid, ct.load(a, pid, (16,)) .+ ct.load(b, pid, (16,))) + return + end, Tuple{AT, AT, AT}) + +# direct tile operator (previously an MLIR verifier failure) +@test_throws "restricted float" code_tiled(devnull, + (a, b, c) -> begin + pid = ct.bid(1) + ct.store(c, pid, ct.load(a, pid, (16,)) + ct.load(b, pid, (16,))) + return + end, Tuple{AT, AT, AT}) + +# unary math via broadcast +@test_throws "restricted float" code_tiled(devnull, + (a, b) -> begin + pid = ct.bid(1) + ct.store(b, pid, sqrt.(ct.load(a, pid, (16,)))) + return + end, Tuple{AT, AT}) + +# tile × scalar (the mixed-arithmetic guard) +@test_throws "restricted float" code_tiled(devnull, + (a, b) -> begin + pid = ct.bid(1) + ct.store(b, pid, ct.load(a, pid, (16,)) * 2.0f0) + return + end, Tuple{AT, AT}) + +# broadcast `muladd` expands to the scalar `x * y + z` +@test_throws "restricted float" code_tiled(devnull, + (a, b, c, d) -> begin + pid = ct.bid(1) + ct.store(d, pid, muladd.(ct.load(a, pid, (16,)), ct.load(b, pid, (16,)), + ct.load(c, pid, (16,)))) + return + end, Tuple{AT, AT, AT, AT}) + +# The overlays live in cuTile's method table, so host arithmetic is untouched. +@test Float8_E4M3FN(1.0f0) + Float8_E4M3FN(1.0f0) == Float8_E4M3FN(2.0f0) +@test -Float8_E4M3FN(1.0f0) == Float8_E4M3FN(-1.0f0) +@test sqrt(Float8_E4M3FN(4.0f0)) == Float8_E4M3FN(2.0f0) + +end + # Execution kernels are plain top-level functions, each defined next to the # test that exercises it. Kernels parametric on accumulator dtype must stay at # top level — defining them inside a testset scope boxes them into closures. diff --git a/test/extensions/Microfloats/codegen.jl b/test/extensions/Microfloats/codegen.jl index efbdf698..564f9fd5 100644 --- a/test/extensions/Microfloats/codegen.jl +++ b/test/extensions/Microfloats/codegen.jl @@ -239,4 +239,75 @@ end end end +# Microfloats are restricted floats: storage / tensor-core operand formats +# without general arithmetic. Microfloats' scalar fallbacks would otherwise let +# the broadcast path compile into a silent ftof/op/ftof round-trip, and the +# direct tile operators into an `addf` the tileiras verifier rejects. +@testset "restricted arithmetic" begin + AT = ct.TileArray{Float8_E4M3FN,1,spec1d} + + # broadcast (upstream's Float32 round-trip fallback) + @test_throws "restricted float" code_tiled(devnull, + (a, b, c) -> begin + pid = ct.bid(1) + ct.store(c, pid, ct.load(a, pid, (16,)) .+ ct.load(b, pid, (16,))) + return + end, Tuple{AT, AT, AT}) + + # direct tile operator (previously an MLIR verifier failure) + @test_throws "restricted float" code_tiled(devnull, + (a, b, c) -> begin + pid = ct.bid(1) + ct.store(c, pid, ct.load(a, pid, (16,)) + ct.load(b, pid, (16,))) + return + end, Tuple{AT, AT, AT}) + + # unary math via broadcast + @test_throws "restricted float" code_tiled(devnull, + (a, b) -> begin + pid = ct.bid(1) + ct.store(b, pid, sqrt.(ct.load(a, pid, (16,)))) + return + end, Tuple{AT, AT}) + + # tile × scalar (the mixed-arithmetic guard) + @test_throws "restricted float" code_tiled(devnull, + (a, b) -> begin + pid = ct.bid(1) + ct.store(b, pid, ct.load(a, pid, (16,)) * 2.0f0) + return + end, Tuple{AT, AT}) + + # The overlays dispatch on `Microfloat`, so every variant is covered, not + # just the two FP8 types DLFP8Types also provides. + F4 = ct.TileArray{Float4_E2M1FN,1,spec1d} + @test_throws "restricted float" code_tiled(devnull, + (a, b, c) -> begin + pid = ct.bid(1) + ct.store(c, pid, ct.load(a, pid, (16,)) .* ct.load(b, pid, (16,))) + return + end, Tuple{F4, F4, F4}; bytecode_version=v"13.3") + + # Comparisons stay allowed (cuTile Python allows them too): upstream + # implements them as a Float32 upcast, which is lossless without a result + # to round, so they lower to `ftof` + `cmpf`. + @test @filecheck begin + @check_label "entry" + code_tiled(Tuple{AT, AT, ct.TileArray{Int32,1,spec1d}}) do a, b, c + ta = ct.load(a, ct.bid(1), (16,)) + tb = ct.load(b, ct.bid(1), (16,)) + @check "ftof" + @check "ftof" + @check "cmpf" + ct.store(c, ct.bid(1), ifelse.(ta .< tb, Int32(1), Int32(0))) + return + end + end + + # The overlays live in cuTile's method table, so host arithmetic is untouched. + @test Float8_E4M3FN(1.0f0) + Float8_E4M3FN(1.0f0) == Float8_E4M3FN(2.0f0) + @test -Float8_E4M3FN(1.0f0) == Float8_E4M3FN(-1.0f0) + @test sqrt(Float8_E4M3FN(4.0f0)) == Float8_E4M3FN(2.0f0) +end + end From 1aa7efd4e2f18baa6d5d3d2f337707f4b86446fb Mon Sep 17 00:00:00 2001 From: Tim Besard Date: Tue, 28 Jul 2026 08:11:05 +0200 Subject: [PATCH 06/15] Document how restricted-float arithmetic is rejected Name the actual error and show the cast that fixes it, and note that comparisons on the FP8/FP4 types stay available. Co-Authored-By: Claude Fable 5 --- docs/src/man/element_types.md | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/docs/src/man/element_types.md b/docs/src/man/element_types.md index 46680e81..33a7cfc0 100644 --- a/docs/src/man/element_types.md +++ b/docs/src/man/element_types.md @@ -32,7 +32,18 @@ reductions or scans. Operations that need those reject a numeric float up front with an error, rather than letting it fail deeper down in `tileiras`. To compute with such values -element-wise, convert to an arithmetic float first. +element-wise, convert to an arithmetic float first. For example, `x .+ y` on two +`Float8_E4M3FN` tiles fails at kernel compile time with *"arithmetic on a +restricted float element type is not supported"*; casting first is what makes +the intermediate precision explicit: + +```julia +f32(t) = convert(ct.Tile{Float32}, t) +sum = f32(x) .+ f32(y) +``` + +Comparisons on the FP8 and FP4 types are the exception, and stay available: +they upcast losslessly, having no result to round. This is why a `Float32` matmul that wants tensor cores converts its *operands* to `TFloat32` while leaving the accumulator `Float32`: the operands only ever From 415cc424db44c55ece6e5718b36eaac229e5e147 Mon Sep 17 00:00:00 2001 From: Tim Besard Date: Tue, 28 Jul 2026 09:46:23 +0200 Subject: [PATCH 07/15] Test that reduce and scan reject extension-registered restricted floats The codegen-time checks in `emit_reduce!` and the scan intrinsic resolve `is_restricted_float` through `invokelatest` because the compilation pipeline runs in the world frozen at `__init__`, before the extensions load. Nothing exercised that with an extension type: a frozen-world call would return `false` for FP8 and fall through to an opaque failure in the reduce body instead of the early diagnostic. Co-Authored-By: Claude Fable 5 --- test/extensions/DLFP8Types.jl | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/test/extensions/DLFP8Types.jl b/test/extensions/DLFP8Types.jl index dae7e05a..ec058f64 100644 --- a/test/extensions/DLFP8Types.jl +++ b/test/extensions/DLFP8Types.jl @@ -109,6 +109,26 @@ AT = ct.TileArray{Float8_E4M3FN,1,spec1d} return end, Tuple{AT, AT, AT, AT}) +# reduce and scan check `is_restricted_float` at codegen time, which must +# resolve in the latest world to see the extension's method (`invokelatest` +# in `emit_reduce!`/`emit_intrinsic!`; a frozen-world call would miss it and +# fall through to an opaque failure in the reduce body). +@test_throws "restricted float" code_tiled(devnull, + (a, b) -> begin + pid = ct.bid(1) + ta = ct.load(a, pid, (16,)) + s = mapreduce(identity, max, ta; dims=1, init=Float8_E4M3FN(0.0f0)) + ct.store(b, pid, ct.broadcast_to(s, (16,))) + return + end, Tuple{AT, AT}) + +@test_throws "restricted float" code_tiled(devnull, + (a, b) -> begin + pid = ct.bid(1) + ct.store(b, pid, cumsum(ct.load(a, pid, (16,)); dims=1)) + return + end, Tuple{AT, AT}) + # The overlays live in cuTile's method table, so host arithmetic is untouched. @test Float8_E4M3FN(1.0f0) + Float8_E4M3FN(1.0f0) == Float8_E4M3FN(2.0f0) @test -Float8_E4M3FN(1.0f0) == Float8_E4M3FN(-1.0f0) From 4cdafbed1397cf34e28592f8683eb98169bee6eb Mon Sep 17 00:00:00 2001 From: Tim Besard Date: Tue, 28 Jul 2026 09:47:04 +0200 Subject: [PATCH 08/15] Overlay DLFP8Types comparisons as Float32 upcasts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The docs promise that comparisons on restricted floats stay available, and they did for Microfloats — whose upstream definitions upcast through Float32 and ride the extension's `ftof` constructor overlays. DLFP8Types instead implements `<`, `==`, and `isless` at the bit level (`bitcast`, `_fpint`, with `isnan`/`iszero` guards), which does not compile in kernels: the guards bitcast broadcast scalars, tripping a tile shape mismatch in codegen. Shadow them with an exact Float32 upcast. f8 → f32 conversion is exact and injective (NaNs map to NaN), so IEEE comparison on the upcast values matches the bit-level ordering, including the NaN and ±0 cases, making `@consistent_overlay` appropriate. `<=` gets a direct overlay too: it has no upstream method, and comparing once beats Base's `Real` fallback composing it from `<` and `==`. `isless` still does not compile, but no longer for an FP8-specific reason: Base's `isless(::Float32, ::Float32)` itself fails under broadcast for any float tile (its `isnan` guard hits the same scalar-vs-tile mismatch), so the test pinning it is marked broken until that underlying issue is fixed. Co-Authored-By: Claude Fable 5 --- ext/DLFP8TypesExt.jl | 14 +++++++++++++ test/extensions/DLFP8Types.jl | 39 +++++++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+) diff --git a/ext/DLFP8TypesExt.jl b/ext/DLFP8TypesExt.jl index d78982da..050b854d 100644 --- a/ext/DLFP8TypesExt.jl +++ b/ext/DLFP8TypesExt.jl @@ -43,6 +43,20 @@ for F8 in FP8Types end end +# DLFP8Types implements comparisons at the bit level (`bitcast`, `_fpint`, with +# `isnan`/`iszero` guards), which does not compile in kernels: the guards +# bitcast broadcast scalars, tripping a tile shape mismatch in codegen. Route +# them through an exact Float32 upcast instead, like Microfloats' upstream +# definitions. The results are identical: f8 → f32 conversion is exact and +# injective (NaNs map to NaN), so IEEE comparison on the upcast values matches +# the bit-level ordering, including the NaN and ±0 cases. `<=` has no upstream +# method (Base's `Real` fallback composes it from `<` and `==`); the direct +# overlay compares once instead of twice. +for op in (:(<), :(<=), :(==), :isless) + @eval Base.Experimental.@consistent_overlay ct.cuTileMethodTable Base.$op(a::T, b::T) where {T<:DLFP8Types.FP8} = + Base.$op(Float32(a), Float32(b)) +end + # FP8 is a storage / tensor-core operand format, not an arithmetic type: the # Tile IR elementwise float ops only accept f16/bf16/f32/f64. Registered for # every `FP8` subtype, not just the two with a Tile IR dtype, so the blocking diff --git a/test/extensions/DLFP8Types.jl b/test/extensions/DLFP8Types.jl index ec058f64..841b5376 100644 --- a/test/extensions/DLFP8Types.jl +++ b/test/extensions/DLFP8Types.jl @@ -129,10 +129,49 @@ AT = ct.TileArray{Float8_E4M3FN,1,spec1d} return end, Tuple{AT, AT}) +# Comparisons stay allowed (cuTile Python allows them too). DLFP8Types +# implements them at the bit level, which does not compile in kernels; the +# extension overlays them as a lossless Float32 upcast, so they lower to +# `ftof` + `cmpf` like Microfloats' upstream definitions. +@test @filecheck begin + @check_label "entry" + code_tiled(Tuple{AT, AT, ct.TileArray{Int32,1,spec1d}}) do a, b, c + ta = ct.load(a, ct.bid(1), (16,)) + tb = ct.load(b, ct.bid(1), (16,)) + @check "ftof" + @check "ftof" + @check "cmpf" + ct.store(c, ct.bid(1), ifelse.(ta .< tb, Int32(1), Int32(0))) + return + end +end + +# The remaining comparison overlays, compile-only: `<=` additionally covers +# Base's `Real` fallback being shadowed by the direct overlay. The function +# barrier keeps the kernel closure's captured `f` a concrete singleton. +function compiles_cmp(f) + isnothing(code_tiled(devnull, + (a, b, c) -> begin + pid = ct.bid(1) + ta = ct.load(a, pid, (16,)) + tb = ct.load(b, pid, (16,)) + ct.store(c, pid, ifelse.(f.(ta, tb), Int32(1), Int32(0))) + return + end, Tuple{AT, AT, ct.TileArray{Int32,1,spec1d}})) +end +@test compiles_cmp(<=) +@test compiles_cmp(==) +# `isless` forwards to `isless(::Float32, ::Float32)`, whose Base definition +# does not compile under broadcast for any float tile yet (its `isnan` guard +# trips a scalar-vs-tile shape mismatch in cmpf). The overlay is still what +# makes FP8 reach that point; this flips when the underlying issue is fixed. +@test_broken compiles_cmp(isless) + # The overlays live in cuTile's method table, so host arithmetic is untouched. @test Float8_E4M3FN(1.0f0) + Float8_E4M3FN(1.0f0) == Float8_E4M3FN(2.0f0) @test -Float8_E4M3FN(1.0f0) == Float8_E4M3FN(-1.0f0) @test sqrt(Float8_E4M3FN(4.0f0)) == Float8_E4M3FN(2.0f0) +@test Float8_E4M3FN(1.0f0) < Float8_E4M3FN(2.0f0) end From 9dc8d2ff43ba66c79a68d12dbe89938449e2d043 Mon Sep 17 00:00:00 2001 From: Tim Besard Date: Tue, 28 Jul 2026 09:47:25 +0200 Subject: [PATCH 09/15] Drop the FP8 multiply-add execution tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After the restricted-arithmetic rewrite these kernels rounded each input through FP8 and then computed entirely in Float32, on inputs chosen to make that exact — reducing them to the round-trip coverage directly above plus ordinary Float32 arithmetic. The pattern they originally pinned (elementwise FP8 muladd through the upstream fallback) is now rejected by design and covered by the restricted-arithmetic testsets. Co-Authored-By: Claude Fable 5 --- test/extensions/DLFP8Types.jl | 25 ------------------------- test/extensions/Microfloats/device.jl | 25 ------------------------- 2 files changed, 50 deletions(-) diff --git a/test/extensions/DLFP8Types.jl b/test/extensions/DLFP8Types.jl index 841b5376..8426c82e 100644 --- a/test/extensions/DLFP8Types.jl +++ b/test/extensions/DLFP8Types.jl @@ -193,22 +193,6 @@ function rt_e5m2(a::ct.TileArray{Float32,1}, b::ct.TileArray{Float32,1}) ct.store(b, pid, convert(ct.Tile{Float32}, convert(ct.Tile{Float8_E5M2}, tile))) return end -# Round a Float32 tile through FP8 and back. FP8 is a restricted float, so -# arithmetic on it is rejected: kernels cast explicitly instead. -round_e4m3(t) = convert(ct.Tile{Float32}, convert(ct.Tile{Float8_E4M3FN}, t)) - -# Multiply-add on FP8-rounded inputs: load Float32, round each input through -# FP8, then compute in Float32. Inputs whose products and sums also stay -# representable in FP8, so the result is exact. -function fma_e4m3(a::ct.TileArray{Float32,1}, b::ct.TileArray{Float32,1}, - c::ct.TileArray{Float32,1}, d::ct.TileArray{Float32,1}) - pid = ct.bid(1) - ta = round_e4m3(ct.load(a, pid, (16,))) - tb = round_e4m3(ct.load(b, pid, (16,))) - tc = round_e4m3(ct.load(c, pid, (16,))) - ct.store(d, pid, ta .* tb .+ tc) - return -end # Non-scaled FP8 matmul with both allowed accumulator dtypes (f16 and f32). function mma_dl_fp8(A::ct.TileArray{Float8_E4M3FN,2}, B::ct.TileArray{Float8_E4M3FN,2}, C::ct.TileArray{Tacc,2}, D::ct.TileArray{Float32,2}) where {Tacc<:Union{Float16,Float32}} @@ -236,15 +220,6 @@ let a = CuArray(representable), b = CUDA.zeros(Float32, length(representable)) @test Array(b) == representable end -let av = Float32[1.0, 2.0, 0.5, 4.0, 1.5, 2.0, -1.0, -0.5, 3.0, 0.5, 1.0, 2.0, -2.0, 1.0, 0.5, 4.0], - bv = Float32[2.0, 1.0, 4.0, 0.5, 2.0, 3.0, 2.0, 4.0, 1.0, 2.0, 1.0, 0.5, 2.0, 1.0, 2.0, 1.0], - cv = Float32[0.0, 1.0, 0.0, 0.0, 1.0, 1.0, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0, 0.0] - a, b, c = CuArray(av), CuArray(bv), CuArray(cv) - d = CUDA.zeros(Float32, length(av)) - @cuda backend=cuTile blocks=1 fma_e4m3(a, b, c, d) - @test Array(d) == av .* bv .+ cv -end - @testset "mma → $Tacc acc" for Tacc in (Float32, Float16) M = 16 ah = Float8_E4M3FN.(Float32.(rand(0:2, M, M)) ./ 2) diff --git a/test/extensions/Microfloats/device.jl b/test/extensions/Microfloats/device.jl index 9a542e52..87653f9a 100644 --- a/test/extensions/Microfloats/device.jl +++ b/test/extensions/Microfloats/device.jl @@ -30,20 +30,6 @@ function rt_f4(a::ct.TileArray{Float32,1}, b::ct.TileArray{Float32,1}) ct.store(b, pid, convert(ct.Tile{Float32}, convert(ct.Tile{Float4_E2M1FN}, tile))) return end -# Round a Float32 tile through FP8 and back. FP8 is a restricted float, so -# arithmetic on it is rejected: kernels cast explicitly instead. -round_e4m3(t) = convert(ct.Tile{Float32}, convert(ct.Tile{Float8_E4M3FN}, t)) - -function fma_e4m3(a::ct.TileArray{Float32,1}, b::ct.TileArray{Float32,1}, - c::ct.TileArray{Float32,1}, d::ct.TileArray{Float32,1}) - pid = ct.bid(1) - ta = round_e4m3(ct.load(a, pid, (16,))) - tb = round_e4m3(ct.load(b, pid, (16,))) - tc = round_e4m3(ct.load(c, pid, (16,))) - ct.store(d, pid, ta .* tb .+ tc) - return -end - # Standalone f32 → microfloat → f32 conversion round-trips exactly for every # microfloat type on representable inputs. FP8 (e4m3/e5m2) needs Hopper (sm_90+); # E8M0FNU and Float4_E2M1FN need Blackwell (sm_100+). E8M0 is exponent-only, so @@ -60,17 +46,6 @@ if capability(device()) >= v"9" @test Array(b) == representable8 end - # Multiply-add on FP8-rounded inputs: load f32, round each input through - # FP8, then compute in f32. Inputs whose products and sums stay - # representable in FP8, so the result is exact. - let av = Float32[1.0, 2.0, 0.5, 4.0, 1.5, 2.0, -1.0, -0.5, 3.0, 0.5, 1.0, 2.0, -2.0, 1.0, 0.5, 4.0], - bv = Float32[2.0, 1.0, 4.0, 0.5, 2.0, 3.0, 2.0, 4.0, 1.0, 2.0, 1.0, 0.5, 2.0, 1.0, 2.0, 1.0], - cv = Float32[0.0, 1.0, 0.0, 0.0, 1.0, 1.0, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0, 0.0] - a, b, c = CuArray(av), CuArray(bv), CuArray(cv) - d = CUDA.zeros(Float32, length(av)) - @cuda backend=cuTile blocks=1 fma_e4m3(a, b, c, d) - @test Array(d) == av .* bv .+ cv - end end if capability(device()) >= v"10" # E8M0 round-trip: exponent-only, so representable values are powers of two. From 871c692e1d383751536bc8193630ad000c8dbeb7 Mon Sep 17 00:00:00 2001 From: Tim Besard Date: Tue, 28 Jul 2026 09:47:43 +0200 Subject: [PATCH 10/15] Drop the operator argument from check_arithmetic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The operator was unused beyond appearing in the diagnostic's stacktrace, where the frame right below — the guarded method itself — already names it. `check_arithmetic(T)` simplifies all six tile-level guards and every blocking overlay in the extensions. The message stays a module-level constant so kernel-side throw reconstruction keeps reporting it verbatim. Co-Authored-By: Claude Fable 5 --- ext/DLFP8TypesExt.jl | 6 ++--- ext/MicrofloatsExt.jl | 12 +++++----- src/language/arithmetic.jl | 49 +++++++++++++++++--------------------- 3 files changed, 31 insertions(+), 36 deletions(-) diff --git a/ext/DLFP8TypesExt.jl b/ext/DLFP8TypesExt.jl index 050b854d..1a911900 100644 --- a/ext/DLFP8TypesExt.jl +++ b/ext/DLFP8TypesExt.jl @@ -74,18 +74,18 @@ ct.is_restricted_float(::Type{<:DLFP8Types.FP8}) = true # inconsistent with the shadowed method. Host-side FP8 arithmetic is untouched. for op in (:+, :-, :*, :/, :\, :^) @eval Base.Experimental.@overlay ct.cuTileMethodTable Base.$op(a::T, b::T) where {T<:DLFP8Types.FP8} = - ct.check_arithmetic(Base.$op, T) + ct.check_arithmetic(T) end for op in (:sin, :cos, :tan, :asin, :acos, :atan, :sinh, :cosh, :tanh, :asinh, :acosh, :atanh, :exp, :exp2, :exp10, :expm1, :log, :log2, :log10, :sqrt, :cbrt, :log1p) @eval Base.Experimental.@overlay ct.cuTileMethodTable Base.$op(a::T) where {T<:DLFP8Types.FP8} = - ct.check_arithmetic(Base.$op, T) + ct.check_arithmetic(T) end # Unary negation is an exact sign-bit flip upstream, so it would compile — but # the tile-level `-(::Tile{T})` is blocked (it lowers to `negf`), and `-x` and # `(-).(x)` disagreeing on the same tile is worse than rejecting both. Base.Experimental.@overlay ct.cuTileMethodTable Base.:(-)(a::T) where {T<:DLFP8Types.FP8} = - ct.check_arithmetic(Base.:(-), T) + ct.check_arithmetic(T) end diff --git a/ext/MicrofloatsExt.jl b/ext/MicrofloatsExt.jl index c807fa78..e97e7d89 100644 --- a/ext/MicrofloatsExt.jl +++ b/ext/MicrofloatsExt.jl @@ -75,28 +75,28 @@ ct.is_restricted_float(::Type{<:Microfloats.Microfloat}) = true # Float32 too, but losslessly, and cuTile Python allows them as well. for op in (:+, :-, :*, :/, :\, :^) @eval Base.Experimental.@overlay ct.cuTileMethodTable Base.$op(a::T, b::T) where {T<:Microfloats.Microfloat} = - ct.check_arithmetic(Base.$op, T) + ct.check_arithmetic(T) end Base.Experimental.@overlay ct.cuTileMethodTable Base.:^(a::T, b::Integer) where {T<:Microfloats.Microfloat} = - ct.check_arithmetic(Base.:^, T) + ct.check_arithmetic(T) for op in (:sin, :cos, :tan, :asin, :acos, :atan, :sinh, :cosh, :tanh, :asinh, :acosh, :atanh, :exp, :exp2, :exp10, :expm1, :log, :log2, :log10, :sqrt, :cbrt, :log1p, :modf, :mod2pi) @eval Base.Experimental.@overlay ct.cuTileMethodTable Base.$op(a::T) where {T<:Microfloats.Microfloat} = - ct.check_arithmetic(Base.$op, T) + ct.check_arithmetic(T) end for op in (:atan, :hypot) @eval Base.Experimental.@overlay ct.cuTileMethodTable Base.$op(a::T, b::T) where {T<:Microfloats.Microfloat} = - ct.check_arithmetic(Base.$op, T) + ct.check_arithmetic(T) end for op in (:frexp, :ldexp) @eval Base.Experimental.@overlay ct.cuTileMethodTable Base.$op(a::T, b::Int) where {T<:Microfloats.Microfloat} = - ct.check_arithmetic(Base.$op, T) + ct.check_arithmetic(T) end # Unary negation is an exact sign-bit flip upstream, so it would compile — but # the tile-level `-(::Tile{T})` is blocked (it lowers to `negf`), and `-x` and # `(-).(x)` disagreeing on the same tile is worse than rejecting both. Base.Experimental.@overlay ct.cuTileMethodTable Base.:(-)(a::T) where {T<:Microfloats.Microfloat} = - ct.check_arithmetic(Base.:(-), T) + ct.check_arithmetic(T) end diff --git a/src/language/arithmetic.jl b/src/language/arithmetic.jl index 95702806..f0ec4c30 100644 --- a/src/language/arithmetic.jl +++ b/src/language/arithmetic.jl @@ -90,45 +90,40 @@ conventions: the remainder takes the sign of the divisor. @inline divmod(x::Tile{T,S}, y::Tile{T,S}) where {T<:Integer, S} = (div.(x, y, RoundDown), mod.(x, y)) -# Kernel-side throws only report their message when it reconstructs to a -# compile-time constant (see `throw_constant` in transform/throws.jl): a message -# assembled at run time from `op` and `T` degrades to "ArgumentError was thrown". -# Keep one constant message; the collected diagnostic's stacktrace already names -# the operator and element type through `check_arithmetic`'s own frame. -const RESTRICTED_ARITHMETIC_MESSAGE = - "arithmetic on a restricted float element type is not supported; " * - "perform an explicit cast instead, e.g. convert(Tile{Float32}, x)" - """ - check_arithmetic(op, T) - -Reject `op` on a restricted float element type `T`. Restricted floats -(`TFloat32`, and the FP8/FP4 types registered by the package extensions) are -storage and tensor-core operand formats: the elementwise float intrinsics only -accept f16/bf16/f32/f64, so an unguarded `op` produces an opaque tileiras -verifier failure. Fail early with an actionable error instead. - -`op` is unused beyond naming the offending operator in the error's stacktrace. -The check folds away for arithmetic floats, so it must be called directly (not -through `invokelatest`) to stay free on the common path. + check_arithmetic(T) + +Reject arithmetic on a restricted float element type `T` (`TFloat32`, and the +FP8/FP4 types registered by the package extensions): the elementwise float +intrinsics only accept f16/bf16/f32/f64, so an unguarded operation produces an +opaque tileiras verifier failure. Fail early with an actionable error instead; +the collected diagnostic's stacktrace names the offending operator. The check +folds away for arithmetic floats. + +The message must stay a compile-time constant (see `throw_constant` in +transform/throws.jl): one assembled at run time from `T` degrades to +"ArgumentError was thrown". """ -function check_arithmetic(op, ::Type{T}) where {T} +function check_arithmetic(::Type{T}) where {T} if is_restricted_float(T) throw(ArgumentError(RESTRICTED_ARITHMETIC_MESSAGE)) end return nothing end +const RESTRICTED_ARITHMETIC_MESSAGE = + "arithmetic on a restricted float element type is not supported; " * + "perform an explicit cast instead, e.g. convert(Tile{Float32}, x)" # direct operators (same shape required) @inline Base.:(+)(a::Tile{T, S}, b::Tile{T, S}) where {T <: AbstractFloat, S} = - (check_arithmetic(+, T); Intrinsics.addf(a, b)) + (check_arithmetic(T); Intrinsics.addf(a, b)) @inline Base.:(+)(a::Tile{T, S}, b::Tile{T, S}) where {T <: Integer, S} = Intrinsics.addi(a, b) @inline Base.:(-)(a::Tile{T, S}, b::Tile{T, S}) where {T <: AbstractFloat, S} = - (check_arithmetic(-, T); Intrinsics.subf(a, b)) + (check_arithmetic(T); Intrinsics.subf(a, b)) @inline Base.:(-)(a::Tile{T, S}, b::Tile{T, S}) where {T <: Integer, S} = Intrinsics.subi(a, b) @inline Base.:(-)(a::Tile{T}) where {T <: AbstractFloat} = - (check_arithmetic(-, T); Intrinsics.negf(a)) + (check_arithmetic(T); Intrinsics.negf(a)) @inline Base.:(-)(a::Tile{T}) where {T <: Integer} = Intrinsics.negi(a) # All other tile arithmetic (*, -, /, ^, comparisons, ifelse, etc.) is handled @@ -150,8 +145,8 @@ end # direct operators (tile * scalar, tile / scalar) @inline Base.:(*)(a::Tile{T}, b::Number) where {T <: AbstractFloat} = - (check_arithmetic(*, T); Intrinsics.mulf(a, broadcast_to(Tile(T(b)), size(a)))) + (check_arithmetic(T); Intrinsics.mulf(a, broadcast_to(Tile(T(b)), size(a)))) @inline Base.:(*)(a::Number, b::Tile{T}) where {T <: AbstractFloat} = - (check_arithmetic(*, T); Intrinsics.mulf(broadcast_to(Tile(T(a)), size(b)), b)) + (check_arithmetic(T); Intrinsics.mulf(broadcast_to(Tile(T(a)), size(b)), b)) @inline Base.:(/)(a::Tile{T}, b::Number) where {T <: AbstractFloat} = - (check_arithmetic(/, T); Intrinsics.divf(a, broadcast_to(Tile(T(b)), size(a)))) + (check_arithmetic(T); Intrinsics.divf(a, broadcast_to(Tile(T(b)), size(a)))) From 4ee2b7db1206460bd6246acbb25d0c480c9a171b Mon Sep 17 00:00:00 2001 From: Tim Besard Date: Tue, 28 Jul 2026 10:16:39 +0200 Subject: [PATCH 11/15] Gate restricted floats at the broadcast and map layer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restricted-float scalars only ever come into existence inside cuTile's own broadcast/map machinery: kernels cannot load one any other way, and tile-level ops never consult scalar methods. So gate that single choke point instead of shadowing upstream scalar methods one by one — conversion, `ifelse` selection and comparisons pass through, everything else is rejected before dispatch. Comparisons upcast their restricted operands to Float32 and re-apply, which is exact and injective for every restricted format, and produces the same ftof/ftof/cmpf lowering as before. TFloat32 broadcast now reports the restricted-float error instead of falling through to Base's `no_op_err`, so all restricted types share one message. Co-Authored-By: Claude Opus 5 (1M context) --- src/language/arithmetic.jl | 11 ++++--- src/language/broadcast.jl | 61 +++++++++++++++++++++++++++++++++++++- src/language/operations.jl | 9 ++++-- src/language/types.jl | 7 +++-- test/codegen/operations.jl | 7 +++-- 5 files changed, 81 insertions(+), 14 deletions(-) diff --git a/src/language/arithmetic.jl b/src/language/arithmetic.jl index f0ec4c30..029981c8 100644 --- a/src/language/arithmetic.jl +++ b/src/language/arithmetic.jl @@ -100,9 +100,8 @@ opaque tileiras verifier failure. Fail early with an actionable error instead; the collected diagnostic's stacktrace names the offending operator. The check folds away for arithmetic floats. -The message must stay a compile-time constant (see `throw_constant` in -transform/throws.jl): one assembled at run time from `T` degrades to -"ArgumentError was thrown". +This guards the direct tile operators below; the broadcast and `map` paths are +gated in `_apply_broadcast` (language/broadcast.jl), which shares the message. """ function check_arithmetic(::Type{T}) where {T} if is_restricted_float(T) @@ -110,8 +109,12 @@ function check_arithmetic(::Type{T}) where {T} end return nothing end + +# The message must stay a compile-time constant (see `throw_constant` in +# transform/throws.jl): one assembled at run time from `T` degrades to +# "ArgumentError was thrown". The stacktrace names the offending operation. const RESTRICTED_ARITHMETIC_MESSAGE = - "arithmetic on a restricted float element type is not supported; " * + "operations on a restricted float element type are not supported; " * "perform an explicit cast instead, e.g. convert(Tile{Float32}, x)" # direct operators (same shape required) diff --git a/src/language/broadcast.jl b/src/language/broadcast.jl index c737b8e4..d0b70e3e 100644 --- a/src/language/broadcast.jl +++ b/src/language/broadcast.jl @@ -96,10 +96,69 @@ end (a, _broadcast_all(S, rest...)...) # Convert args to scalars, apply f, wrap result back into a Tile. +# +# Restricted floats (FP8/FP4/TFloat32) are gated here rather than by shadowing +# the upstream scalar methods one by one: kernels can only ever obtain a +# restricted-float scalar through this function (and `map`), so this is the one +# choke point that covers every present and future upstream method. Only +# conversion, selection and comparison are let through; everything else — +# arithmetic, math functions, user lambdas — is rejected before dispatch, so no +# upstream fallback implementation is ever consulted. @inline function _apply_broadcast(f, args...) - Intrinsics.from_scalar(f(map(_to_scalar, args)...), _result_shape(args...)) + if _restricted_args(args...) + _restricted_broadcast(f, args...) + else + _broadcast_scalars(f, args...) + end end +@inline _broadcast_scalars(f, args...) = + Intrinsics.from_scalar(f(map(_to_scalar, args)...), _result_shape(args...)) + +# Does any Tile argument have a restricted float element type? Tuple peeling +# rather than `any` with a closure: the argument tuple is heterogeneous (Tiles +# and Refs). Folds to `false` at inference time for arithmetic element types, +# keeping the common path branch-free. `is_restricted_float` is called directly, +# not through `invokelatest`: kernel inference sees the extension methods, and +# the fold depends on it. +@inline _restricted_args() = false +@inline _restricted_args(a::Tile, rest...) = + is_restricted_float(eltype(a)) || _restricted_args(rest...) +@inline _restricted_args(a, rest...) = _restricted_args(rest...) + +const ComparisonOps = Union{typeof(<), typeof(<=), typeof(>), typeof(>=), + typeof(==), typeof(!=), typeof(isless)} + +# Explicit element-type conversion (`Float32.(tile)`, `convert.(Float32, tile)`, +# and `convert(Tile{T}, tile)` via `map`) is the sanctioned escape hatch: pass it +# through to the constructor overlays, which lower it to a single `ftof`. +@inline _restricted_broadcast(f::Union{Type,typeof(convert)}, args...) = + _broadcast_scalars(f, args...) + +# `ifelse` selects between unmodified values and lowers via `Core.ifelse`, so no +# upstream restricted-float method is involved. +@inline _restricted_broadcast(f::typeof(ifelse), args...) = + _broadcast_scalars(f, args...) + +# Comparisons stay available (as they do in cuTile Python), but Tile IR has no +# native fp8/fp4 comparison: upcast the restricted operands to Float32 and +# re-apply. That is exact and injective for every restricted format (NaN → NaN, +# ±0 preserved), so the result matches the host's ordering. Going through the +# upcast rather than the upstream scalar `<` also keeps their implementation +# details (bit tricks, `isnan` guards) out of the kernel. +@inline _restricted_broadcast(f::ComparisonOps, args...) = + _apply_broadcast(f, map(_upcast_restricted, args)...) + +# Everything else — arithmetic, math functions, user lambdas — is rejected. +# Upstream implements scalar arithmetic on these formats as a Float32 round-trip, +# which would otherwise compile into a silent ftof/op/ftof with an extra rounding +# per operation, and no hint that a cast happened. +@inline _restricted_broadcast(f, args...) = throw(ArgumentError(RESTRICTED_ARITHMETIC_MESSAGE)) + +@inline _upcast_restricted(a::Tile{T}) where {T} = + is_restricted_float(T) ? convert(Tile{Float32}, a) : a +@inline _upcast_restricted(a) = a + # Reinterpret arguments as scalars for broadcast application: Tiles via # to_scalar, Refs via their contents. The Ref{Type{T}} method recovers the # Type from the type parameter, mirroring Base's `_broadcast_getindex`. diff --git a/src/language/operations.jl b/src/language/operations.jl index b9982a2e..881d804f 100644 --- a/src/language/operations.jl +++ b/src/language/operations.jl @@ -1386,6 +1386,10 @@ The function `f` must be a zero-size callable (singleton or capture-free lambda) All tiles must have the same shape `S` — use broadcasting (`.+` etc.) or explicit `broadcast_to` for shape-mismatched operands. +Tiles with a restricted float element type only accept the same `f` as +broadcasting does (conversion, `ifelse`, comparisons); anything else, including +a lambda that merely casts, is rejected. + # Examples ```julia result = map(abs, tile) # Element-wise absolute value @@ -1393,9 +1397,8 @@ result = map(x -> x * x, tile) # Element-wise square result = map(+, a, b) # Element-wise addition (same shape required) ``` """ -@inline function Base.map(f, a::Tile{<:Any,S}, rest::Tile{<:Any,S}...) where {S} - Intrinsics.from_scalar(f(Intrinsics.to_scalar(a), map(Intrinsics.to_scalar, rest)...), S) -end +@inline Base.map(f, a::Tile{<:Any,S}, rest::Tile{<:Any,S}...) where {S} = + _apply_broadcast(f, a, rest...) """ mapreduce(identity, f, tile::Tile{T,S}; dims, init) -> Tile{T, reduced_shape} diff --git a/src/language/types.jl b/src/language/types.jl index 8e8e1b42..afecc10c 100644 --- a/src/language/types.jl +++ b/src/language/types.jl @@ -688,9 +688,10 @@ const ScalarFloat = Union{Float16, BFloat16, Float32, Float64, TFloat32} True if `T` is a restricted float: a storage / tensor-core operand format whose op coverage is intentionally limited (no general arithmetic, reductions or -scans). Used by `reduce` / `scan` and by the tile arithmetic guards -([`check_arithmetic`](@ref)) to reject unsupported element types early with a -clear error rather than letting tileiras fail downstream. +scans). Used by the broadcast / `map` gate, by `reduce` / `scan`, and by the +tile arithmetic guards ([`check_arithmetic`](@ref)) to reject unsupported +element types early with a clear error rather than letting tileiras fail +downstream. `TFloat32` is built in; package extensions register their own types by adding methods (e.g. `DLFP8TypesExt` for `Float8_E4M3FN`). Mirrors cuTile Python's diff --git a/test/codegen/operations.jl b/test/codegen/operations.jl index 02eca158..ffe468ab 100644 --- a/test/codegen/operations.jl +++ b/test/codegen/operations.jl @@ -2046,9 +2046,10 @@ end return end, Tuple{AT, AT}) - # The broadcast path never had a TFloat32 scalar method to begin with, - # so it keeps failing through Base's `no_op_err`. - @test_throws "+ not defined for" code_tiled(devnull, + # The broadcast path is gated before scalar dispatch, so it reports the + # same error as the direct operators (it used to fall through to Base's + # `no_op_err`, TFloat32 having no scalar `+` to begin with). + @test_throws "restricted float" code_tiled(devnull, (a, b, c) -> begin pid = ct.bid(1) ct.store(c, pid, ct.load(a, pid, (16,)) .+ ct.load(b, pid, (16,))) From c50ea85efee44ce27dbe0284ad44af7f9ac46a2a Mon Sep 17 00:00:00 2001 From: Tim Besard Date: Tue, 28 Jul 2026 10:19:58 +0200 Subject: [PATCH 12/15] Drop the restricted-float blocklist overlays MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The per-extension blocklists enumerated the upstream method tables at a point in time: the moment DLFP8Types or Microfloats gains an `abs`, `rem` or `hypot`, the implicit-upcast hole silently reopens. The broadcast/map gate covers every present and future upstream method by construction, so ~35 shadowing overlays and the DLFP8Types comparison replacements can go. That also removes the `@consistent_overlay` for `<=`, which had no upstream method of the same signature — a pattern the macro's own docs caution against. Co-Authored-By: Claude Opus 5 (1M context) --- ext/DLFP8TypesExt.jl | 46 +++----------------------- ext/MicrofloatsExt.jl | 44 +++--------------------- test/extensions/DLFP8Types.jl | 15 ++++----- test/extensions/Microfloats/codegen.jl | 14 ++++---- 4 files changed, 25 insertions(+), 94 deletions(-) diff --git a/ext/DLFP8TypesExt.jl b/ext/DLFP8TypesExt.jl index 1a911900..c6fd517a 100644 --- a/ext/DLFP8TypesExt.jl +++ b/ext/DLFP8TypesExt.jl @@ -43,49 +43,13 @@ for F8 in FP8Types end end -# DLFP8Types implements comparisons at the bit level (`bitcast`, `_fpint`, with -# `isnan`/`iszero` guards), which does not compile in kernels: the guards -# bitcast broadcast scalars, tripping a tile shape mismatch in codegen. Route -# them through an exact Float32 upcast instead, like Microfloats' upstream -# definitions. The results are identical: f8 → f32 conversion is exact and -# injective (NaNs map to NaN), so IEEE comparison on the upcast values matches -# the bit-level ordering, including the NaN and ±0 cases. `<=` has no upstream -# method (Base's `Real` fallback composes it from `<` and `==`); the direct -# overlay compares once instead of twice. -for op in (:(<), :(<=), :(==), :isless) - @eval Base.Experimental.@consistent_overlay ct.cuTileMethodTable Base.$op(a::T, b::T) where {T<:DLFP8Types.FP8} = - Base.$op(Float32(a), Float32(b)) -end - # FP8 is a storage / tensor-core operand format, not an arithmetic type: the # Tile IR elementwise float ops only accept f16/bf16/f32/f64. Registered for -# every `FP8` subtype, not just the two with a Tile IR dtype, so the blocking -# overlays below cover exactly the methods DLFP8Types defines. +# every `FP8` subtype, not just the two with a Tile IR dtype, so that the +# broadcast/map gate and the tile-level guards recognize them all. The gate +# rejects the operations up front, so DLFP8Types' own scalar implementations — +# the Float32 round-trip arithmetic and the bit-level comparisons — are never +# consulted in a kernel; host-side FP8 stays untouched. ct.is_restricted_float(::Type{<:DLFP8Types.FP8}) = true -# DLFP8Types implements scalar arithmetic as a Float32 round-trip -# (`T(op(Float32(a), Float32(b)))`, src/DLFP8Types.jl). Combined with our `ftof` -# constructor overlays above, a kernel-side `f8_tile .+ f8_tile` would compile -# silently into ftof → addf → ftof: an implicit upcast with an extra rounding -# per operation, and no hint that a cast happened. Shadow those methods so the -# broadcast path reports the same error as the tile-level operators. -# -# Plain `@overlay`, not `@consistent_overlay`: throwing is deliberately -# inconsistent with the shadowed method. Host-side FP8 arithmetic is untouched. -for op in (:+, :-, :*, :/, :\, :^) - @eval Base.Experimental.@overlay ct.cuTileMethodTable Base.$op(a::T, b::T) where {T<:DLFP8Types.FP8} = - ct.check_arithmetic(T) -end -for op in (:sin, :cos, :tan, :asin, :acos, :atan, :sinh, :cosh, :tanh, :asinh, - :acosh, :atanh, :exp, :exp2, :exp10, :expm1, :log, :log2, :log10, - :sqrt, :cbrt, :log1p) - @eval Base.Experimental.@overlay ct.cuTileMethodTable Base.$op(a::T) where {T<:DLFP8Types.FP8} = - ct.check_arithmetic(T) -end -# Unary negation is an exact sign-bit flip upstream, so it would compile — but -# the tile-level `-(::Tile{T})` is blocked (it lowers to `negf`), and `-x` and -# `(-).(x)` disagreeing on the same tile is worse than rejecting both. -Base.Experimental.@overlay ct.cuTileMethodTable Base.:(-)(a::T) where {T<:DLFP8Types.FP8} = - ct.check_arithmetic(T) - end diff --git a/ext/MicrofloatsExt.jl b/ext/MicrofloatsExt.jl index e97e7d89..8ce20d56 100644 --- a/ext/MicrofloatsExt.jl +++ b/ext/MicrofloatsExt.jl @@ -58,45 +58,11 @@ end # Microfloats are storage / tensor-core operand formats, not arithmetic types: # the Tile IR elementwise float ops only accept f16/bf16/f32/f64. Registered for -# every `Microfloat`, not just the four with a Tile IR dtype, so the blocking -# overlays below cover exactly the methods Microfloats defines. +# every `Microfloat`, not just the four with a Tile IR dtype, so that the +# broadcast/map gate and the tile-level guards recognize them all. The gate +# rejects the operations up front, so Microfloats' own scalar implementations — +# arithmetic as a Float32 round-trip (src/ops.jl) — are never consulted in a +# kernel; host-side microfloat arithmetic stays untouched. ct.is_restricted_float(::Type{<:Microfloats.Microfloat}) = true -# Microfloats implements scalar arithmetic as a Float32 round-trip -# (`T(op(Float32(a), Float32(b)))`, src/ops.jl). Combined with our `ftof` -# constructor overlays above, a kernel-side `f8_tile .+ f8_tile` would compile -# silently into ftof → addf → ftof: an implicit upcast with an extra rounding -# per operation, and no hint that a cast happened. Shadow those methods so the -# broadcast path reports the same error as the tile-level operators. -# -# Plain `@overlay`, not `@consistent_overlay`: throwing is deliberately -# inconsistent with the shadowed method. Host-side microfloat arithmetic is -# untouched. Comparisons (`< <= == isless`) are left alone — they upcast to -# Float32 too, but losslessly, and cuTile Python allows them as well. -for op in (:+, :-, :*, :/, :\, :^) - @eval Base.Experimental.@overlay ct.cuTileMethodTable Base.$op(a::T, b::T) where {T<:Microfloats.Microfloat} = - ct.check_arithmetic(T) -end -Base.Experimental.@overlay ct.cuTileMethodTable Base.:^(a::T, b::Integer) where {T<:Microfloats.Microfloat} = - ct.check_arithmetic(T) -for op in (:sin, :cos, :tan, :asin, :acos, :atan, :sinh, :cosh, :tanh, :asinh, - :acosh, :atanh, :exp, :exp2, :exp10, :expm1, :log, :log2, :log10, - :sqrt, :cbrt, :log1p, :modf, :mod2pi) - @eval Base.Experimental.@overlay ct.cuTileMethodTable Base.$op(a::T) where {T<:Microfloats.Microfloat} = - ct.check_arithmetic(T) -end -for op in (:atan, :hypot) - @eval Base.Experimental.@overlay ct.cuTileMethodTable Base.$op(a::T, b::T) where {T<:Microfloats.Microfloat} = - ct.check_arithmetic(T) -end -for op in (:frexp, :ldexp) - @eval Base.Experimental.@overlay ct.cuTileMethodTable Base.$op(a::T, b::Int) where {T<:Microfloats.Microfloat} = - ct.check_arithmetic(T) -end -# Unary negation is an exact sign-bit flip upstream, so it would compile — but -# the tile-level `-(::Tile{T})` is blocked (it lowers to `negf`), and `-x` and -# `(-).(x)` disagreeing on the same tile is worse than rejecting both. -Base.Experimental.@overlay ct.cuTileMethodTable Base.:(-)(a::T) where {T<:Microfloats.Microfloat} = - ct.check_arithmetic(T) - end diff --git a/test/extensions/DLFP8Types.jl b/test/extensions/DLFP8Types.jl index 8426c82e..beee9be5 100644 --- a/test/extensions/DLFP8Types.jl +++ b/test/extensions/DLFP8Types.jl @@ -131,8 +131,8 @@ AT = ct.TileArray{Float8_E4M3FN,1,spec1d} # Comparisons stay allowed (cuTile Python allows them too). DLFP8Types # implements them at the bit level, which does not compile in kernels; the -# extension overlays them as a lossless Float32 upcast, so they lower to -# `ftof` + `cmpf` like Microfloats' upstream definitions. +# broadcast gate never consults that, upcasting the operands to Float32 +# instead, so they lower to `ftof` + `cmpf`. @test @filecheck begin @check_label "entry" code_tiled(Tuple{AT, AT, ct.TileArray{Int32,1,spec1d}}) do a, b, c @@ -146,9 +146,8 @@ AT = ct.TileArray{Float8_E4M3FN,1,spec1d} end end -# The remaining comparison overlays, compile-only: `<=` additionally covers -# Base's `Real` fallback being shadowed by the direct overlay. The function -# barrier keeps the kernel closure's captured `f` a concrete singleton. +# The remaining comparisons, compile-only. The function barrier keeps the +# kernel closure's captured `f` a concrete singleton. function compiles_cmp(f) isnothing(code_tiled(devnull, (a, b, c) -> begin @@ -163,11 +162,11 @@ end @test compiles_cmp(==) # `isless` forwards to `isless(::Float32, ::Float32)`, whose Base definition # does not compile under broadcast for any float tile yet (its `isnan` guard -# trips a scalar-vs-tile shape mismatch in cmpf). The overlay is still what -# makes FP8 reach that point; this flips when the underlying issue is fixed. +# trips a scalar-vs-tile shape mismatch in cmpf). The gate's upcast is what +# gets FP8 to exactly that point; this flips when the underlying issue is fixed. @test_broken compiles_cmp(isless) -# The overlays live in cuTile's method table, so host arithmetic is untouched. +# The gate only applies inside kernels, so host arithmetic is untouched. @test Float8_E4M3FN(1.0f0) + Float8_E4M3FN(1.0f0) == Float8_E4M3FN(2.0f0) @test -Float8_E4M3FN(1.0f0) == Float8_E4M3FN(-1.0f0) @test sqrt(Float8_E4M3FN(4.0f0)) == Float8_E4M3FN(2.0f0) diff --git a/test/extensions/Microfloats/codegen.jl b/test/extensions/Microfloats/codegen.jl index 564f9fd5..29810250 100644 --- a/test/extensions/Microfloats/codegen.jl +++ b/test/extensions/Microfloats/codegen.jl @@ -278,8 +278,9 @@ end return end, Tuple{AT, AT}) - # The overlays dispatch on `Microfloat`, so every variant is covered, not - # just the two FP8 types DLFP8Types also provides. + # The gate is trait-based (`is_restricted_float`, registered for the whole + # `Microfloat` supertype), so every variant is covered with no per-type + # code — not just the two FP8 types DLFP8Types also provides. F4 = ct.TileArray{Float4_E2M1FN,1,spec1d} @test_throws "restricted float" code_tiled(devnull, (a, b, c) -> begin @@ -288,9 +289,10 @@ end return end, Tuple{F4, F4, F4}; bytecode_version=v"13.3") - # Comparisons stay allowed (cuTile Python allows them too): upstream - # implements them as a Float32 upcast, which is lossless without a result - # to round, so they lower to `ftof` + `cmpf`. + # Comparisons stay allowed: the gate upcasts the operands to Float32, which + # is lossless without a result to round, so they lower to `ftof` + `cmpf`. + # cuTile Python's frontend accepts them too, but has no such upcast and dies + # in tileiras — Tile IR cannot compare fp8 natively. @test @filecheck begin @check_label "entry" code_tiled(Tuple{AT, AT, ct.TileArray{Int32,1,spec1d}}) do a, b, c @@ -304,7 +306,7 @@ end end end - # The overlays live in cuTile's method table, so host arithmetic is untouched. + # The gate only applies inside kernels, so host arithmetic is untouched. @test Float8_E4M3FN(1.0f0) + Float8_E4M3FN(1.0f0) == Float8_E4M3FN(2.0f0) @test -Float8_E4M3FN(1.0f0) == Float8_E4M3FN(-1.0f0) @test sqrt(Float8_E4M3FN(4.0f0)) == Float8_E4M3FN(2.0f0) From 89ec986cbca197247c7c76d97535825d8162af2e Mon Sep 17 00:00:00 2001 From: Tim Besard Date: Tue, 28 Jul 2026 10:21:01 +0200 Subject: [PATCH 13/15] Test what the restricted-float gate lets through Conversion in both directions and `ifelse` selection are the operations that stay available besides comparisons; a lambda that only casts is not, which is the part of the contract that is easiest to get wrong. Co-Authored-By: Claude Opus 5 (1M context) --- test/extensions/DLFP8Types.jl | 47 +++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/test/extensions/DLFP8Types.jl b/test/extensions/DLFP8Types.jl index beee9be5..d787b057 100644 --- a/test/extensions/DLFP8Types.jl +++ b/test/extensions/DLFP8Types.jl @@ -166,6 +166,53 @@ end # gets FP8 to exactly that point; this flips when the underlying issue is fixed. @test_broken compiles_cmp(isless) +# Explicit conversion is the sanctioned escape hatch, in both directions: it +# passes through the gate to the constructor overlays and lowers to one `ftof`. +@test @filecheck begin + @check_label "entry" + code_tiled(Tuple{AT, ct.TileArray{Float32,1,spec1d}}) do a, b + pid = ct.bid(1) + @check "ftof" + @check_not "ftof" + ct.store(b, pid, Float32.(ct.load(a, pid, (16,)))) + return + end +end +@test @filecheck begin + @check_label "entry" + code_tiled(Tuple{ct.TileArray{Float32,1,spec1d}, AT}) do a, b + pid = ct.bid(1) + @check "ftof" + @check_not "ftof" + ct.store(b, pid, Float8_E4M3FN.(ct.load(a, pid, (16,)))) + return + end +end + +# `ifelse` selects between unmodified values, so it stays available too and +# lowers to a plain `select` — no conversion in sight. +@test @filecheck begin + @check_label "entry" + code_tiled(Tuple{AT, AT, ct.TileArray{Int32,1,spec1d}, AT}) do a, b, m, c + pid = ct.bid(1) + mask = ct.load(m, pid, (16,)) .> Int32(0) + @check "select" + @check_not "ftof" + ct.store(c, pid, ifelse.(mask, ct.load(a, pid, (16,)), ct.load(b, pid, (16,)))) + return + end +end + +# Element-wise application of anything else is rejected, even a lambda whose +# body is only a cast: cuTile Python has no per-element operations on restricted +# types either. `convert(Tile{Float32}, tile)` is the supported spelling. +@test_throws "restricted float" code_tiled(devnull, + (a, b) -> begin + pid = ct.bid(1) + ct.store(b, pid, map(x -> Float32(x), ct.load(a, pid, (16,)))) + return + end, Tuple{AT, ct.TileArray{Float32,1,spec1d}}) + # The gate only applies inside kernels, so host arithmetic is untouched. @test Float8_E4M3FN(1.0f0) + Float8_E4M3FN(1.0f0) == Float8_E4M3FN(2.0f0) @test -Float8_E4M3FN(1.0f0) == Float8_E4M3FN(-1.0f0) From 70c02cad67719a4fc72e87ed415361227e486d1e Mon Sep 17 00:00:00 2001 From: Tim Besard Date: Tue, 28 Jul 2026 10:21:31 +0200 Subject: [PATCH 14/15] Document what numeric floats still allow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Selection joins comparisons in the "stays available" list, and element-wise application of a custom function is now explicitly out — the gate rejects it even when the lambda only casts. Co-Authored-By: Claude Opus 5 (1M context) --- docs/src/man/element_types.md | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/docs/src/man/element_types.md b/docs/src/man/element_types.md index 33a7cfc0..7153989b 100644 --- a/docs/src/man/element_types.md +++ b/docs/src/man/element_types.md @@ -33,8 +33,8 @@ reductions or scans. Operations that need those reject a numeric float up front with an error, rather than letting it fail deeper down in `tileiras`. To compute with such values element-wise, convert to an arithmetic float first. For example, `x .+ y` on two -`Float8_E4M3FN` tiles fails at kernel compile time with *"arithmetic on a -restricted float element type is not supported"*; casting first is what makes +`Float8_E4M3FN` tiles fails at kernel compile time with *"operations on a +restricted float element type are not supported"*; casting first is what makes the intermediate precision explicit: ```julia @@ -42,8 +42,14 @@ f32(t) = convert(ct.Tile{Float32}, t) sum = f32(x) .+ f32(y) ``` -Comparisons on the FP8 and FP4 types are the exception, and stay available: -they upcast losslessly, having no result to round. +Comparisons and `ifelse` selection are the exceptions, and stay available: +comparisons upcast losslessly, having no result to round, and selection leaves +the values themselves alone. + +The rejection is per operation, not per function: applying a custom function +element-wise (`map`, or broadcasting a lambda) over a numeric-float tile is +rejected as well, even when every step inside it is a cast. Convert the tile +with `convert(ct.Tile{T}, tile)` or `T.(tile)` instead. This is why a `Float32` matmul that wants tensor cores converts its *operands* to `TFloat32` while leaving the accumulator `Float32`: the operands only ever From 595aca6303e1fac19b0dacac4d2d54a5227252ae Mon Sep 17 00:00:00 2001 From: Tim Besard Date: Tue, 28 Jul 2026 10:26:33 +0200 Subject: [PATCH 15/15] Describe the actual broadcast/map relationship `map` now delegates to `_apply_broadcast` rather than the other way around, so pointing at operations.jl for the mixed-type implementation reads backwards. Co-Authored-By: Claude Opus 5 (1M context) --- src/language/broadcast.jl | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/language/broadcast.jl b/src/language/broadcast.jl index d0b70e3e..7eb060e5 100644 --- a/src/language/broadcast.jl +++ b/src/language/broadcast.jl @@ -43,8 +43,9 @@ Base.Broadcast.broadcastable(t::Tile) = t # This handles all element-wise operations: scalar @overlay methods provide # the implementation for overlaid ops, while Julia's native scalar functions # (compiled to Core intrinsics) handle the rest. Mixed-type and type-changing -# operations (comparisons, ifelse) are supported by the mixed-type map methods -# in operations.jl. +# operations (comparisons, ifelse) need nothing extra — `f` decides the result +# element type. `Base.map` (operations.jl) enters the same path at +# `_apply_broadcast`, its tiles already sharing a shape. @inline function Base.copy(bc::Broadcasted{TileStyle}) args = _materialize_args(bc.args) promoted = _promote_to_tiles(args...)