From d494973488e6f3b3291daeb27e0d218ea9eebf97 Mon Sep 17 00:00:00 2001 From: Neven Sajko Date: Thu, 28 Aug 2025 14:45:03 +0200 Subject: [PATCH 1/3] avoid method proliferation for `Tuple` functions * Introducing new types and methods for a callable can invalidate already compiled method instances of a function for which world-splitting is enabled (`max_methods`). * Invalidation of sysimage or package precompiled code worsens latency due to requiring recompilation. * Lowering the `max_methods` setting for a function often causes inference issues for existing code that is not completely type-stable (which is a lot of code). In many cases this is easy to fix by avoiding method proliferation, such as by merging some methods and introducing branching into the merged method. This PR aims to fix the latter issue for some `Tuple`-related methods of some functions where decreasing `max_methods` might be interesting. Seeing as branching was deliberately avoided in the bodies of many of these methods, I opted for the approach of introducing local functions which preserve the dispatch logic as before, without branching. Thus there should be no regressions, except perhaps because of changed inlining costs. This PR is a prerequisite for PRs which try to decrease `max_methods` for select functions, such as PR: * #59377 --- base/essentials.jl | 10 +++- base/operators.jl | 7 --- base/tuple.jl | 132 ++++++++++++++++++++++++++++----------------- test/tuple.jl | 12 +++++ 4 files changed, 102 insertions(+), 59 deletions(-) diff --git a/base/essentials.jl b/base/essentials.jl index c998e492469de..4e48307bb93bd 100644 --- a/base/essentials.jl +++ b/base/essentials.jl @@ -531,8 +531,14 @@ julia> Base.tail(()) ERROR: ArgumentError: Cannot call tail on an empty tuple. ``` """ -tail(x::Tuple) = argtail(x...) -tail(::Tuple{}) = throw(ArgumentError("Cannot call tail on an empty tuple.")) +function tail(x::Tuple) + f(x::Tuple) = argtail(x...) + function f(::Tuple{}) + @noinline + throw(ArgumentError("Cannot call tail on an empty tuple.")) + end + f(x) +end function unwrap_unionall(@nospecialize(a)) @_foldable_meta diff --git a/base/operators.jl b/base/operators.jl index 51729b852070d..627bfb3e9def6 100644 --- a/base/operators.jl +++ b/base/operators.jl @@ -223,13 +223,6 @@ isless(x::AbstractFloat, y::AbstractFloat) = (!isnan(x) & (isnan(y) | signless(x isless(x::Real, y::AbstractFloat) = (!isnan(x) & (isnan(y) | signless(x, y))) | (x < y) isless(x::AbstractFloat, y::Real ) = (!isnan(x) & (isnan(y) | signless(x, y))) | (x < y) -# Performance optimization to reduce branching -# This is useful for sorting tuples of integers -# TODO: remove this when the compiler can optimize the generic version better -# See #48724 and #48753 -isless(a::Tuple{BitInteger, BitInteger}, b::Tuple{BitInteger, BitInteger}) = - isless(a[1], b[1]) | (isequal(a[1], b[1]) & isless(a[2], b[2])) - """ isgreater(x, y) diff --git a/base/tuple.jl b/base/tuple.jl index 62a07f7ecf6a2..69c7730fc05c3 100644 --- a/base/tuple.jl +++ b/base/tuple.jl @@ -263,9 +263,14 @@ end @eval split_rest(t::Tuple, n::Int, i=1) = ($(Expr(:meta, :aggressive_constprop)); (t[i:end-n], t[end-n+1:end])) -# Use dispatch to avoid a branch in first -first(::Tuple{}) = throw(ArgumentError("tuple must be non-empty")) -first(t::Tuple) = t[1] +function first(t::Tuple) + f(t::Tuple) = t[1] + function f(::Tuple{}) + @noinline + throw(ArgumentError("tuple must be non-empty")) + end + f(t) +end # eltype @@ -570,46 +575,54 @@ function _eq(t1::Any32, t2::Any32) end const tuplehash_seed = UInt === UInt64 ? 0x77cfa1eef01bca90 : 0xf01bca90 -hash(::Tuple{}, h::UInt) = h ⊻ tuplehash_seed -hash(t::Tuple, h::UInt) = hash(t[1], hash(tail(t), h)) -function hash(t::Any32, h::UInt) - out = h ⊻ tuplehash_seed - for i = length(t):-1:1 - out = hash(t[i], out) +function hash(t::Tuple, h::UInt) + f(::Tuple{}, h::UInt) = h ⊻ tuplehash_seed + f(t::Tuple, h::UInt) = hash(t[1], hash(tail(t), h)) + function f(t::Any32, h::UInt) + out = h ⊻ tuplehash_seed + for i = length(t):-1:1 + out = hash(t[i], out) + end + return out end - return out + f(t, h) end -<(::Tuple{}, ::Tuple{}) = false -<(::Tuple{}, ::Tuple) = true -<(::Tuple, ::Tuple{}) = false function <(t1::Tuple, t2::Tuple) - a, b = t1[1], t2[1] - eq = (a == b) - if ismissing(eq) - return missing - elseif !eq - return a < b - end - return tail(t1) < tail(t2) -end -function <(t1::Any32, t2::Any32) - n1, n2 = length(t1), length(t2) - for i = 1:min(n1, n2) - a, b = t1[i], t2[i] + f(::Tuple{}, ::Tuple{}) = false + f(::Tuple{}, ::Tuple) = true + f(::Tuple, ::Tuple{}) = false + function f(t1::Tuple, t2::Tuple) + a, b = t1[1], t2[1] eq = (a == b) if ismissing(eq) return missing elseif !eq - return a < b + return a < b + end + return tail(t1) < tail(t2) + end + function f(t1::Any32, t2::Any32) + n1, n2 = length(t1), length(t2) + for i = 1:min(n1, n2) + a, b = t1[i], t2[i] + eq = (a == b) + if ismissing(eq) + return missing + elseif !eq + return a < b + end end + return n1 < n2 end - return n1 < n2 + f(t1, t2) end -isless(::Tuple{}, ::Tuple{}) = false -isless(::Tuple{}, ::Tuple) = true -isless(::Tuple, ::Tuple{}) = false +# copy of `BitInteger` defined later during bootstrap in int.jl +const _BitInteger = Union{ + Int8, Int16, Int32, Int64, Int128, + UInt8, UInt16, UInt32, UInt64, UInt128, +} """ isless(t1::Tuple, t2::Tuple) @@ -617,24 +630,40 @@ isless(::Tuple, ::Tuple{}) = false Return `true` when `t1` is less than `t2` in lexicographic order. """ function isless(t1::Tuple, t2::Tuple) - a, b = t1[1], t2[1] - isless(a, b) || (isequal(a, b) && isless(tail(t1), tail(t2))) -end -function isless(t1::Any32, t2::Any32) - n1, n2 = length(t1), length(t2) - for i = 1:min(n1, n2) - a, b = t1[i], t2[i] - if !isequal(a, b) - return isless(a, b) + f(::Tuple{}, ::Tuple{}) = false + f(::Tuple{}, ::Tuple) = true + f(::Tuple, ::Tuple{}) = false + function f(t1::Tuple, t2::Tuple) + a, b = t1[1], t2[1] + isless(a, b) || (isequal(a, b) && isless(tail(t1), tail(t2))) + end + function f(t1::Any32, t2::Any32) + n1, n2 = length(t1), length(t2) + for i = 1:min(n1, n2) + a, b = t1[i], t2[i] + if !isequal(a, b) + return isless(a, b) + end end + return n1 < n2 + end + # Performance optimization to reduce branching + # This is useful for sorting tuples of integers + # TODO: remove this when the compiler can optimize the generic version better + # See #48724 and #48753 + function f(a::Tuple{_BitInteger, _BitInteger}, b::Tuple{_BitInteger, _BitInteger}) + isless(a[1], b[1]) | (isequal(a[1], b[1]) & isless(a[2], b[2])) end - return n1 < n2 + f(t1, t2) end ## functions ## -isempty(x::Tuple{}) = true -isempty(@nospecialize x::Tuple) = false +function isempty(x::Tuple) + f(x::Tuple{}) = true + f(@nospecialize x::Tuple) = false + f(x) +end revargs() = () revargs(x, r...) = (revargs(r...)..., x) @@ -672,11 +701,14 @@ empty(@nospecialize x::Tuple) = () foreach(f, itr::Tuple) = foldl((_, x) -> (f(x); nothing), itr, init=nothing) foreach(f, itr::Tuple, itrs::Tuple...) = foldl((_, xs) -> (f(xs...); nothing), zip(itr, itrs...), init=nothing) -circshift((@nospecialize t::Union{Tuple{},Tuple{Any}}), @nospecialize _::Integer) = t -circshift(t::Tuple{Any,Any}, shift::Integer) = iseven(shift) ? t : reverse(t) -function circshift(x::Tuple{Any,Any,Any,Vararg{Any,N}}, shift::Integer) where {N} - @inline - len = N + 3 - j = mod1(shift, len) - ntuple(k -> getindex(x, k-j+ifelse(k>j,0,len)), Val(len))::Tuple +function circshift(t::Tuple, shift::Integer) + f((@nospecialize t::Union{Tuple{},Tuple{Any}}), @nospecialize _::Integer) = t + f(t::Tuple{Any,Any}, shift::Integer) = iseven(shift) ? t : reverse(t) + function f(x::Tuple{Any,Any,Any,Vararg{Any,N}}, shift::Integer) where {N} + @inline + len = N + 3 + j = mod1(shift, len) + ntuple(k -> getindex(x, k-j+ifelse(k>j,0,len)), Val(len))::Tuple + end + f(t, shift) end diff --git a/test/tuple.jl b/test/tuple.jl index 30782367803c5..c15f33be2d5f7 100644 --- a/test/tuple.jl +++ b/test/tuple.jl @@ -824,6 +824,18 @@ namedtup = (;a=1, b=2, c=3) @test Val{Tuple{Int64, Vararg{Int32,N}} where N} === Val{Tuple{Int64, Vararg{Int32}}} @test Val{Tuple{Int32, Vararg{Int64}}} === Val{Tuple{Int32, Vararg{Int64,N}} where N} +@testset "avoid method proliferation" begin + t = isone ∘ length ∘ methods + @test t(circshift, Tuple{Tuple, Integer}) + @test t(hash, Tuple{Tuple, UInt}) + for f in (Base.tail, first, isempty) + @test t(f, Tuple{Tuple}) + end + for f in (<, isless, ==, isequal) + @test t(f, Tuple{Tuple, Tuple}) + end +end + @testset "from Pair, issue #52636" begin pair = (1 => "2") @test (1, "2") == @inferred Tuple(pair) From 17ca436930a1f5c93c4d130dd94c8530e262335d Mon Sep 17 00:00:00 2001 From: Neven Sajko Date: Sat, 21 Jun 2025 19:27:06 +0200 Subject: [PATCH 2/3] decrease `max_methods` world-splitting setting for some functions A function which is meant to have methods added to it by users should have a small `max_methods` value, as world-splitting just leads to unnecessary invalidation in that case, in the context of the package ecosystem, where users are allowed to add arbitrarily many methods to such functions. xref PR #57884 xref PR #58788 xref PR #58829 xref PR #59091 --- base/Base_compiler.jl | 2 ++ base/abstractarray.jl | 4 --- base/broadcast.jl | 5 ++++ base/interface_callables_base.jl | 48 ++++++++++++++++++++++++++++++++ 4 files changed, 55 insertions(+), 4 deletions(-) create mode 100644 base/interface_callables_base.jl diff --git a/base/Base_compiler.jl b/base/Base_compiler.jl index 5d786a325940a..0801094e92c2a 100644 --- a/base/Base_compiler.jl +++ b/base/Base_compiler.jl @@ -346,6 +346,8 @@ include("anyall.jl") include("ordering.jl") using .Order +include("interface_callables_base.jl") + include("coreir.jl") include("module.jl") diff --git a/base/abstractarray.jl b/base/abstractarray.jl index d4d430a5a1811..5457049e2f5c1 100644 --- a/base/abstractarray.jl +++ b/base/abstractarray.jl @@ -934,10 +934,6 @@ end ## from general iterable to any array -# This is `Experimental.@max_methods 1 function copyto! end`, which is not -# defined at this point in bootstrap. -typeof(function copyto! end).name.max_methods = UInt8(1) - function copyto!(dest::AbstractArray, src) destiter = eachindex(dest) y = iterate(destiter) diff --git a/base/broadcast.jl b/base/broadcast.jl index b86baf08ddfe0..b2134f116347d 100644 --- a/base/broadcast.jl +++ b/base/broadcast.jl @@ -1396,4 +1396,9 @@ function Base.show(io::IO, op::BroadcastFunction) end Base.show(io::IO, ::MIME"text/plain", op::BroadcastFunction) = show(io, op) +# interface callables, like in interface_callables_base.jl, but for `Broadcast` instead of for `Base`. +for f ∈ Any[broadcastable, instantiate] + Base._stable_typeof(f).name.max_methods = 0x1 +end + end # module diff --git a/base/interface_callables_base.jl b/base/interface_callables_base.jl new file mode 100644 index 0000000000000..70483bb834c17 --- /dev/null +++ b/base/interface_callables_base.jl @@ -0,0 +1,48 @@ +# This file is a part of Julia. License is MIT: https://julialang.org/license + +# Interface functions defined in `Base`: define any that are not defined yet. +for c ∈ Symbol[ + :propertynames, :getproperty, :setproperty!, + :show, :print, + :nextind, :prevind, :thisind, + :length, :iterate, :eltype, :size, :axes, :isdone, :isempty, + :firstindex, :lastindex, :getindex, :setindex!, + :copy, :copyto!, + :isone, :iszero, + :strides, :stride, :elsize, + :ndims, :one, :zero, :oneunit, :widen, + :promote_rule, :convert, + :similar, + :+, :-, :*, :/, ://, :<<, :>>, :>>>, :div, :fld, :cld, +] + @eval function $c end +end + +# Disable world splitting for callables to which users should add new methods. +for c ∈ Any[ + propertynames, getproperty, setproperty!, + show, print, + nextind, prevind, thisind, + length, iterate, size, axes, isdone, isempty, + firstindex, lastindex, getindex, setindex!, + copy, :copyto!, + isone, iszero, + strides, stride, + +, -, *, /, //, <<, >>, >>>, div, fld, cld, +] + Base._stable_typeof(c).name.max_methods = 0x1 +end + +# Callables which take type arguments and need a method for the bottom type need a +# `max_methods` value of two for good inference, because the bottom type subtypes +# each type. +# +# TODO: add `eltype` +for c ∈ Any[ + elsize, + ndims, one, zero, oneunit, widen, + promote_rule, convert, + similar, +] + Base._stable_typeof(c).name.max_methods = 0x2 +end From 78499ab614ec8be2e4d97abdf645002abf5c4627 Mon Sep 17 00:00:00 2001 From: Neven Sajko Date: Thu, 28 Aug 2025 11:18:29 +0200 Subject: [PATCH 3/3] relax a test in test/rational.jl As noted by simeonschaub on the original issue #41489, this only used to work by accident anyway. --- test/rational.jl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/rational.jl b/test/rational.jl index fe707cb7d3521..73125da7c71c5 100644 --- a/test/rational.jl +++ b/test/rational.jl @@ -803,7 +803,7 @@ end @test Core.Compiler.return_type(-, NTuple{2, Rational}) == Rational A=Rational[1 1 1; 2 2 2; 3 3 3] - @test @inferred(A*A) isa Matrix{Rational} + @test @inferred(A*A) isa Matrix end @testset "issue #42560" begin