diff --git a/docs/make.jl b/docs/make.jl index d58ce6ee..47e09a21 100644 --- a/docs/make.jl +++ b/docs/make.jl @@ -36,6 +36,7 @@ function main() "man/performance.md", "man/compatibility.md", "man/debugging.md", + "man/julia_differences.md", "man/comparison.md", ], "API reference" => [ diff --git a/docs/src/index.md b/docs/src/index.md index 2ecc78f7..22c38c84 100644 --- a/docs/src/index.md +++ b/docs/src/index.md @@ -78,6 +78,8 @@ CUDA.jl's documentation for that shared functionality. flow. - [Comparison with cuTile Python](man/comparison.md) — if you are porting from `cuda.tile`, start here. +- [Differences from Julia](man/julia_differences.md) — where kernel semantics + diverge from host Julia. - [Debugging](man/debugging.md) — inspecting generated Tile IR. The [Tile IR specification](https://docs.nvidia.com/cuda/tile-ir/) is the diff --git a/docs/src/man/comparison.md b/docs/src/man/comparison.md index e5b959e5..843f70b4 100644 --- a/docs/src/man/comparison.md +++ b/docs/src/man/comparison.md @@ -270,6 +270,10 @@ in cuTile Python's operation reference. dimension arguments are 1-based, and `step` is cuTile.jl's name for Python's `traversal_steps`. +Both APIs keep tile loads and stores checked by default: partial loads use their +requested padding and partial stores clip to the array. In both, an explicit +`check_bounds=false` is the unsafe promise that the whole tile is in bounds. + ### Factory | cuTile Python | cuTile.jl | diff --git a/docs/src/man/element_types.md b/docs/src/man/element_types.md index 46680e81..6823ce39 100644 --- a/docs/src/man/element_types.md +++ b/docs/src/man/element_types.md @@ -66,4 +66,4 @@ v13.3 or newer. Float-to-integer conversions do not throw in kernels; they truncate toward zero rather than raising `InexactError`. See [Differences from - Julia](kernels.md#Differences-from-Julia). + Julia](julia_differences.md#Some-operations-are-non-throwing). diff --git a/docs/src/man/julia_differences.md b/docs/src/man/julia_differences.md new file mode 100644 index 00000000..6affaa32 --- /dev/null +++ b/docs/src/man/julia_differences.md @@ -0,0 +1,35 @@ +# Differences from Julia + +cuTile kernels are ordinary Julia functions, and cuTile.jl uses Julia semantics +where Tile IR can express them. This page covers the important exceptions. + + +## Exceptions become traps + +Tile IR has no exceptions or stack unwinding. A conditional `throw`, `error`, +or failing `Base.@assert` becomes a device-side assertion; an unconditional +one becomes a compile-time diagnostic. `try`/`catch` is not supported. Use +`ct.@assert` for explicit runtime checks. + + +## Some operations are non-throwing + +Float-to-integer conversions such as `Int32(x)`, `trunc(Int32, x)`, and +`round(Int32, x, RoundToZero)` truncate toward zero rather than throwing +`InexactError`. They behave like Julia's explicit `unsafe_trunc` primitive. + + +## Tile bounds handling is not `BoundsError` + +Julia bounds checks are protective: a correct access has the same result with +or without its check. Tile IR's checked memory operations instead define +partial edge tiles: loads pad missing elements and stores clip them. A tile +whose origin is valid may still extend past the array, so that padding or +clipping is part of a correct kernel's result. + +Consequently, `@inbounds` does not alter `ct.load` or `ct.store` bounds +handling. `@inbounds` and `--check-bounds` still control ordinary Julia +`@boundscheck` blocks in a kernel, such as checks in Base range indexing. For +tile memory, use `check_bounds=false` only as an explicit promise that the +whole tile lies within its array. It drops padding, selects Tile IR's unchecked +encoding, and requires bytecode v13.4 or newer. diff --git a/docs/src/man/kernels.md b/docs/src/man/kernels.md index 8ca4ea1d..623d5130 100644 --- a/docs/src/man/kernels.md +++ b/docs/src/man/kernels.md @@ -45,14 +45,6 @@ operations: ## Differences from Julia -### Some operations are non-throwing - -cuTile kernels cannot throw Julia exceptions. Operations that would throw in standard Julia -silently produce truncated or wrapped results instead: - -- **Float-to-integer conversions:** `Int32(x)`, `trunc(Int32, x)`, and - `round(Int32, x, RoundToZero)` silently truncate toward zero rather than throwing - `InexactError` for non-integer or out-of-range values. Use `unsafe_trunc` for the explicit - non-throwing primitive. - -Use `ct.@assert` to add runtime checks in kernels; see [Debugging](debugging.md). +Kernels compile with Julia semantics wherever Tile IR can express them, but +exceptions, some conversions, and bounds handling need different machinery. +See [Differences from Julia](julia_differences.md). diff --git a/docs/src/man/memory.md b/docs/src/man/memory.md index a0164e16..b85ed00c 100644 --- a/docs/src/man/memory.md +++ b/docs/src/man/memory.md @@ -30,6 +30,21 @@ requirements mentioned on this page are collected in [Compatibility](compatibility.md). +## Bounds checking + +`ct.load` and `ct.store` are bounds-checked by default: a tile that partially +extends past the array is padded on load (per `padding_mode`) and clipped on +store. Set `check_bounds=false` only when every element of the tile is in the +underlying array. That is an unsafe full-tile promise: it drops load padding, +emits Tile IR's `inbounds` attribute, and requires bytecode v13.4 or newer. + +Tile memory bounds handling is not Julia's protective bounds checking. +`@inbounds` and `--check-bounds=yes|no` affect real Julia `@boundscheck` +blocks, but do not change the padding, clipping, masks, or Tile IR attributes +of `ct.load`, `ct.store`, `eachtile`, sparse views, gathers, scatters, or +atomics. Use the explicit keyword when the stronger full-tile promise holds. + + ## Automatic rank matching `ct.load` and `ct.store` automatically match the tile rank to that of the target: diff --git a/src/compiler/transform/canonicalize.jl b/src/compiler/transform/canonicalize.jl index d8871a3d..9b3dc6a7 100644 --- a/src/compiler/transform/canonicalize.jl +++ b/src/compiler/transform/canonicalize.jl @@ -34,12 +34,14 @@ const INTRINSIC_RULES = RewriteRule[ @rewrite Core.Intrinsics.or_int(~x, ~y) => Intrinsics.ori(~x, ~y) @rewrite Core.Intrinsics.xor_int(~x, ~y) => Intrinsics.xori(~x, ~y) - # not_int: xori with all-ones constant (type-dependent) - @rewrite Core.Intrinsics.not_int(~x::Tile{Bool}) => Intrinsics.xori(~x, $(true)) - @rewrite Core.Intrinsics.not_int(~x::Tile{Int32}) => Intrinsics.xori(~x, $(Int32(-1))) - @rewrite Core.Intrinsics.not_int(~x::Tile{Int64}) => Intrinsics.xori(~x, $(Int64(-1))) - @rewrite Core.Intrinsics.not_int(~x::Tile{UInt32}) => Intrinsics.xori(~x, $(~UInt32(0))) - @rewrite Core.Intrinsics.not_int(~x::Tile{UInt64}) => Intrinsics.xori(~x, $(~UInt64(0))) + # not_int: xori with all-ones constant (type-dependent). Unlike the binary + # bitwise ops above, the constant makes these rules type-directed, so each + # one has to admit the scalar form too (`!` on a Bool, `~` on an integer). + @rewrite Core.Intrinsics.not_int(~x::Union{Bool, Tile{Bool}}) => Intrinsics.xori(~x, $(true)) + @rewrite Core.Intrinsics.not_int(~x::Union{Int32, Tile{Int32}}) => Intrinsics.xori(~x, $(Int32(-1))) + @rewrite Core.Intrinsics.not_int(~x::Union{Int64, Tile{Int64}}) => Intrinsics.xori(~x, $(Int64(-1))) + @rewrite Core.Intrinsics.not_int(~x::Union{UInt32, Tile{UInt32}}) => Intrinsics.xori(~x, $(~UInt32(0))) + @rewrite Core.Intrinsics.not_int(~x::Union{UInt64, Tile{UInt64}}) => Intrinsics.xori(~x, $(~UInt64(0))) # Float arithmetic @rewrite Core.Intrinsics.add_float(~x, ~y) => Intrinsics.addf(~x, ~y) diff --git a/src/language/operations.jl b/src/language/operations.jl index 855644a7..3e9566d5 100644 --- a/src/language/operations.jl +++ b/src/language/operations.jl @@ -498,17 +498,17 @@ given padding mode on loads and clipped stores. build_tiled_view(a, Val(tile_shape), Val(step), Val(padding_mode), Val(order)) end -@inline function make_tile_view(tiles::TiledView{A, RequestedShape, Shape, Shape, Padding}) where - {A, RequestedShape, Shape, Padding} +@inline function make_tile_view(tiles::TiledView{A, RequestedShape, Shape, Shape, Padding}, + padding=Padding) where {A, RequestedShape, Shape, Padding} parent = tiles.parent tv = Intrinsics.make_tensor_view(typeof(parent), parent.ptr, parent.sizes, parent.strides) - Intrinsics.make_partition_view(tv, tiled_view_shape(tiles), Padding, tiled_view_order(tiles)) + Intrinsics.make_partition_view(tv, tiled_view_shape(tiles), padding, tiled_view_order(tiles)) end -@inline function make_tile_view(tiles::TiledView{A, RequestedShape, Shape, Step, Padding}) where - {A, RequestedShape, Shape, Step, Padding} +@inline function make_tile_view(tiles::TiledView{A, RequestedShape, Shape, Step, Padding}, + padding=Padding) where {A, RequestedShape, Shape, Step, Padding} parent = tiles.parent tv = Intrinsics.make_tensor_view(typeof(parent), parent.ptr, parent.sizes, parent.strides) - Intrinsics.make_strided_view(tv, tiled_view_shape(tiles), tiled_view_step(tiles), Padding, + Intrinsics.make_strided_view(tv, tiled_view_shape(tiles), tiled_view_step(tiles), padding, tiled_view_order(tiles)) end @@ -526,7 +526,8 @@ end latency::Union{Int, Nothing}=nothing, allow_tma::Union{Bool, Nothing}=nothing) where {A, N} N == ndims(tiles) || throw(ArgumentError("eachtile: expected $(ndims(tiles)) tile indices, got $N")) - view = make_tile_view(tiles) + view = check_bounds ? make_tile_view(tiles) : + make_tile_view(tiles, PaddingMode.Undetermined) tile = load_tile_view(view, latency, allow_tma, promote(index...) .- One(), check_bounds) reshape(tile, tiled_view_requested_shape(tiles)) end @@ -631,7 +632,9 @@ tile = ct.load(arr, (bidy, bidx), (TN, TM); order=(2, 1)) allow_tma::Union{Bool, Nothing}=nothing) matched = _match_shape(Val(shape), Val(ndims(arr))) tv = Intrinsics.make_tensor_view(typeof(arr), arr.ptr, arr.sizes, arr.strides) - pv = Intrinsics.make_partition_view(tv, matched, padding_mode, order) + pv = check_bounds ? + Intrinsics.make_partition_view(tv, matched, padding_mode, order) : + Intrinsics.make_partition_view(tv, matched, PaddingMode.Undetermined, order) tile = Intrinsics.load_partition_view( pv, latency, allow_tma, promote(index...) .- One(), check_bounds) reshape(tile, shape) @@ -709,8 +712,11 @@ The sparse tile index and dense range starts are one-based at this boundary. parent_array = parent(view) tensor_view = Intrinsics.make_tensor_view(typeof(parent_array), parent_array.ptr, parent_array.sizes, parent_array.strides) - gather_view = Intrinsics.make_gather_scatter_view( - tensor_view, shape, gather_scatter_sparse_dim(view), padding_mode) + sparse_dim = gather_scatter_sparse_dim(view) + gather_view = check_bounds ? + Intrinsics.make_gather_scatter_view(tensor_view, shape, sparse_dim, padding_mode) : + Intrinsics.make_gather_scatter_view(tensor_view, shape, sparse_dim, + PaddingMode.Undetermined) Intrinsics.load_gather_scatter_view( gather_view, latency, allow_tma, _gather_scatter_indices(view), check_bounds) end diff --git a/test/codegen/boundscheck_views.jl b/test/codegen/boundscheck_views.jl new file mode 100644 index 00000000..dae3a473 --- /dev/null +++ b/test/codegen/boundscheck_views.jl @@ -0,0 +1,107 @@ +# Tile memory bounds handling is distinct from Julia's protective bounds checks: +# `@inbounds` must not discard a partial tile's padding or store clipping. + +const view_spec = ct.ArraySpec{1}(64, true) +const ViewArray = ct.TileArray{Float32, 1, view_spec} +const view2_spec = ct.ArraySpec{2}(64, true) +const ViewArray2 = ct.TileArray{Float32, 2, view2_spec} + +direct_plain(a) = (ct.store(a, ct.bid(1), + ct.load(a, ct.bid(1), (16,); padding_mode=ct.PaddingMode.Zero)); return) +direct_inbounds(a) = (@inbounds ct.store(a, ct.bid(1), + ct.load(a, ct.bid(1), (16,); padding_mode=ct.PaddingMode.Zero)); return) +direct_explicit_checked(a) = (@inbounds ct.store(a, ct.bid(1), + ct.load(a, ct.bid(1), (16,); + padding_mode=ct.PaddingMode.Zero, + check_bounds=true)); return) +direct_explicit_unchecked(a) = (ct.store(a, ct.bid(1), + ct.load(a, ct.bid(1), (16,); + padding_mode=ct.PaddingMode.Zero, + check_bounds=false); + check_bounds=false); return) + +function tiled_plain(a) + tiles = ct.eachtile(a, (16,); padding_mode=ct.PaddingMode.Zero) + tiles[ct.bid(1)] = tiles[ct.bid(1)] + return +end +function tiled_inbounds(a) + @inbounds begin + tiles = ct.eachtile(a, (16,); padding_mode=ct.PaddingMode.Zero) + tiles[ct.bid(1)] = tiles[ct.bid(1)] + end + return +end +function strided_inbounds(a) + @inbounds begin + tiles = ct.eachtile(a, (16,); step=(32,), padding_mode=ct.PaddingMode.Zero) + tiles[ct.bid(1)] = tiles[ct.bid(1)] + end + return +end + +function gather_scatter_plain(a) + rows = ct.arange(4) + view = @view a[rows, Int32(1):Int32(4)] + tile = ct.load(view, (4, 4); padding_mode=ct.PaddingMode.Zero) + ct.store(view, tile) + return +end +function gather_scatter_inbounds(a) + @inbounds begin + rows = ct.arange(4) + view = @view a[rows, Int32(1):Int32(4)] + tile = ct.load(view, (4, 4); padding_mode=ct.PaddingMode.Zero) + ct.store(view, tile) + end + return +end + +scalar_plain(a) = (a[1] = a[2]; return) +scalar_inbounds(a) = (@inbounds (a[1] = a[2]); return) + +function view_ir(@nospecialize(f), @nospecialize(argtypes); bytecode_version) + sprint(io -> code_tiled(io, f, argtypes; bytecode_version)) +end + +unchecked_view_ops(f, argtypes; bytecode_version) = + count(line -> occursin("inbounds = [true", line), + split(view_ir(f, argtypes; bytecode_version), '\n')) + +has_padding(f, argtypes; bytecode_version) = + occursin("padding_value = zero", view_ir(f, argtypes; bytecode_version)) + +@testset "Tile views do not inherit @inbounds" begin + one_dim = Tuple{ViewArray} + two_dim = Tuple{ViewArray2} + + # v13.3 cannot encode unchecked views, but `@inbounds` is irrelevant to + # Tile memory operations: all of these must stay checked and padded. + for (f, argtypes) in ((direct_plain, one_dim), (direct_inbounds, one_dim), + (direct_explicit_checked, one_dim), + (tiled_plain, one_dim), (tiled_inbounds, one_dim), + (strided_inbounds, one_dim), + (gather_scatter_plain, two_dim), + (gather_scatter_inbounds, two_dim), + (scalar_plain, one_dim), (scalar_inbounds, one_dim)) + @test unchecked_view_ops(f, argtypes; bytecode_version=v"13.3") == 0 + end + + for f in (direct_plain, direct_inbounds, direct_explicit_checked, + tiled_plain, tiled_inbounds, strided_inbounds) + @test has_padding(f, one_dim; bytecode_version=v"13.3") + end + for f in (gather_scatter_plain, gather_scatter_inbounds) + @test has_padding(f, two_dim; bytecode_version=v"13.3") + end + + if ct.bytecode_version() >= v"13.4" + # An explicit false is the only way to select the v13.4 `inbounds` + # encoding. It also drops padding, which Tile IR rejects on an + # unchecked view. + @test unchecked_view_ops(direct_explicit_unchecked, one_dim; + bytecode_version=ct.bytecode_version()) == 2 + @test !has_padding(direct_explicit_unchecked, one_dim; + bytecode_version=ct.bytecode_version()) + end +end diff --git a/test/device/hints.jl b/test/device/hints.jl index 1d7b3574..da109e78 100644 --- a/test/device/hints.jl +++ b/test/device/hints.jl @@ -265,6 +265,31 @@ end @test Array(b) ≈ Array(a) end +@testset "tile bounds do not inherit @inbounds" begin + function ragged_tiles(a::ct.TileArray{Float32,1}, b::ct.TileArray{Float32,1}) + pid = ct.bid(1) + tile = ct.load(a, pid, (16,); padding_mode=ct.PaddingMode.Zero) + ct.store(b, pid, sum(tile; dims=1)) + return nothing + end + function ragged_tiles_inbounds(a::ct.TileArray{Float32,1}, b::ct.TileArray{Float32,1}) + pid = ct.bid(1) + @inbounds begin + tile = ct.load(a, pid, (16,); padding_mode=ct.PaddingMode.Zero) + ct.store(b, pid, sum(tile; dims=1)) + end + return nothing + end + + a = CUDA.rand(Float32, 17) + expected = Float32[sum(Array(a)[1:16]), Array(a)[17]] + for kernel in (ragged_tiles, ragged_tiles_inbounds) + b = CUDA.zeros(Float32, 2) + @cuda backend=cuTile blocks=2 kernel(a, b) + @test Array(b) ≈ expected + end +end + if cuTile.bytecode_version() >= v"13.4" @testset "load/store with check_bounds=false" begin function vadd_unchecked(a::ct.TileArray{Float32,1}, @@ -303,6 +328,21 @@ end @test Array(b) ≈ Array(a) end + +@testset "unchecked load ignores padding_mode" begin + function padded_unchecked(a::ct.TileArray{Float32,1}, b::ct.TileArray{Float32,1}) + pid = ct.bid(1) + tile = ct.load(a, pid, (16,); padding_mode=ct.PaddingMode.Zero, + check_bounds=false) + ct.store(b, pid, tile; check_bounds=false) + return nothing + end + + a = CUDA.rand(Float32, 1024) + b = CUDA.zeros(Float32, 1024) + @cuda backend=cuTile blocks=64 padded_unchecked(a, b) + @test Array(b) ≈ Array(a) +end end end diff --git a/test/device/math.jl b/test/device/math.jl index 91980216..d18a56ad 100644 --- a/test/device/math.jl +++ b/test/device/math.jl @@ -68,6 +68,28 @@ end @test Array(c) == Array(a) .⊻ Array(b) end +@testset "not_int on scalars" begin + # `!` on a Bool and `~` on an integer both lower through `not_int`, which + # needs a per-type all-ones constant — so unlike the binary ops above, its + # rules are type-directed and have to cover the scalar form explicitly. + function scalar_not_kernel(a::ct.TileArray{Int32,1}, b::ct.TileArray{Int32,1}) + pid = ct.bid(1) + flag = pid > Int32(1) + value = a[pid] + ct.store(b, pid, (!flag ? Int32(1) : Int32(0)) + ~value) + return + end + + n = 64 + a = CuArray(rand(Int32(0):Int32(0x7fff_ffff), n)) + b = CUDA.zeros(Int32, n) + + @cuda backend=cuTile blocks=n scalar_not_kernel(a, b) + + expected = (.~Array(a)) .+ Int32[i == 1 for i in 1:n] + @test Array(b) == expected +end + @testset "shli (shift left)" begin function shift_left_kernel(a::ct.TileArray{Int32,1}, b::ct.TileArray{Int32,1}) pid = ct.bid(1)