Repository navigation
math: fix spurious !nothrow effects on asinh, frexp, hypot, and unsafe_trunc - #61616
Merged
IanButterworth merged 3 commits intoApr 21, 2026
Merged
Conversation
IanButterworth
force-pushed
the
ib/more_effects_fix
branch
from
April 21, 2026 03:30
472cd6b to
89d60ce
Compare
oscardssmith
approved these changes
Apr 21, 2026
vchuravy
reviewed
Apr 21, 2026
vchuravy
reviewed
Apr 21, 2026
vchuravy
reviewed
Apr 21, 2026
vchuravy
reviewed
Apr 21, 2026
Three math functions were inferred as potentially throwing even though they never do for IEEEFloat inputs: - `asinh(Float32/Float64)`: the function guards all non-finite inputs at the top (`!isfinite(x) && return x`), then calls `log`/`log1p` with provably positive arguments. The compiler conservatively marks it `!nothrow` because `log` itself is `!nothrow`. Fixed with `@assume_effects :nothrow`. - `frexp(Float64)`: used `Int(xs >> significand_bits(T))` where the value after masking and shifting is always ≤ 2047, but `Int(::UInt64)` is `!nothrow` (can overflow). Changed to `rem(..., Int)`, matching the pattern already used in `_exponent_finite_nonzero`. - `_hypot(Float32/Float16)`: calls `sqrt(muladd(x,x,y*y))` where the argument is always ≥ 0 (so sqrt never throws), but the compiler can't see through `sqrt`'s `!nothrow` signature. The `isinf` guards handle all Inf inputs. Fixed with `@assume_effects :nothrow`. All three are now `(+c,+e,+n,+t,+s,+m,+u,+o,+r)` (removable if unused), enabling DCE of calls to these functions with unused results. Co-authored-by: GitHub Copilot <copilot@github.com>
`Int(::UInt64)` throws when the value exceeds `typemax(Int)`, which
spuriously taints the inferred effects of math routines that bit-shift a
floating-point bit pattern. The shifted value always fits within the
exponent field width (≤ 11 bits for Float64), so `(x) % Int` is
semantically equivalent and the truncating reinterpretation lets
inference prove `:nothrow`.
Affects:
- `unsafe_trunc(::Type{UInt128}, ::Float64)` — directly gains `:nothrow`,
cascading to `unsafe_trunc(::Type{Int128}, ::Float64)`
- `exponent`, `_log(::Float64, ...)`, `log1p(::Float64)` — removes one
taint source (these still throw via explicit domain-error branches, so
top-level effects are unchanged but the bit-shift no longer contributes)
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Valentin Churavy <v.churavy@gmail.com>
IanButterworth
force-pushed
the
ib/more_effects_fix
branch
from
April 21, 2026 13:13
f86a6fd to
4d94e26
Compare
IanButterworth
added a commit
that referenced
this pull request
May 6, 2026
Continuation of #61616 #61615 Developed with claude: --- Several `Char` predicates and a couple of `String` predicates in `base/strings/unicode.jl` and `base/strings/string.jl` are spuriously inferred as `nothrow=false`, even though they are provably total. This prevents the compiler from constant-folding them or from eliminating them when their result is unused. The root cause is that the compiler cannot prove that the `ismalformed` / `is_overlong_enc` guards rule out the throwing paths in their bodies — for example the `Int(::Cint)` conversion after the `utf8proc` ccall, or the `UInt32(::AbstractChar)` conversion in `category_code`. The `AbstractChar` contract guarantees that `UInt32(c)` only throws when `ismalformed(c)` returns `true`, and the `utf8proc_charwidth` result fits in a `Cint` so the `Int` conversion never throws `InexactError`. This PR annotates the affected definitions with `@assume_effects` and adds regression tests in `test/char.jl` and `test/strings/basic.jl`. ### Scope `Char`: - `textwidth`, `category_code`, `category_abbrev`, `category_string`, `isassigned`, `islowercase`, `isuppercase` `String`: - `isascii`, `lastindex`, `textwidth` `SubString{String}`: - `length`, `isascii`, `lastindex`, `textwidth` `String` / `SubString{String}` predicates: - `startswith`, `endswith`, `in(::Char, _)` Annotations are added on narrow concrete-type method overloads so they don't constrain user-defined `AbstractChar` / `AbstractString` subtypes. A static `@assert length(category_strings) == 32` is added so the `category_string(::Char)` annotation stays sound if the table is ever edited. The bodies of `startswith` / `endswith` are `_memcmp` byte-bounded by `sizeof(b) ≤ ncodeunits(a)` plus `nextind` / `thisind` calls on indices proven in range, so they cannot throw even on strings containing arbitrary, malformed UTF-8 byte sequences. A stress test exercises every annotated function on adversarial byte sequences and on `Char` values constructed from invalid bit-patterns to lock in the `:nothrow` soundness against future refactors. ### Effect on generated code `textwidth("hello world")` now constant-folds: **nightly:** ```julia-repl julia> code_typed(() -> textwidth("hello world"), ())[1] CodeInfo( 1 ─ %1 = invoke Base._foldl_impl( Base.MappingRF{typeof(textwidth), Base.BottomRF{typeof(+)}}( textwidth, Base.BottomRF{typeof(+)}(+))::Base.MappingRF{...}, 0::Int64, "hello world"::String)::Int64 └── return %1 ) => Int64 ``` **this PR:** ```julia-repl julia> code_typed(() -> textwidth("hello world"), ())[1] CodeInfo( 1 ─ return 11 ) => Int64 ``` Likewise, `startswith` / `endswith` / `in` over literal `String`s now constant-fold. **nightly:** ```julia-repl julia> code_typed(() -> startswith("https://example.com/foo", "https://"), ())[1] CodeInfo( 1 ─ %1 = invoke Main.startswith("https://example.com/foo"::String, "https://"::String)::Bool └── return %1 ) => Bool julia> code_typed(() -> endswith("foo.jl", ".jl"), ())[1] CodeInfo( 1 ─ %1 = invoke Main.endswith("foo.jl"::String, ".jl"::String)::Bool └── return %1 ) => Bool julia> code_typed(() -> in('a', "aeiou"), ())[1] CodeInfo( 1 ─ %1 = invoke Base.codeunits("aeiou"::String)::Base.CodeUnits{UInt8, String} │ %2 = $(Expr(:gc_preserve_begin, :(%1))) │ %3 = builtin Base.getfield(%1, :s)::String │ %4 = invoke Base.cconvert(Ptr{UInt8}::Type{Ptr{UInt8}}, %3::String)::String │ %5 = $(Expr(:foreigncall, :((:jl_string_ptr,)), Ptr{UInt8}, svec(Any), 0, :(:ccall), :(%4)))::Ptr{UInt8} │ %6 = intrinsic Base.add_ptr(%5, 0x0000000000000001)::Ptr{UInt8} │ %7 = intrinsic Base.sub_ptr(%6, 0x0000000000000001)::Ptr{UInt8} │ %8 = $(Expr(:foreigncall, :((:memchr,)), Ptr{UInt8}, svec(Ptr{UInt8}, Int32, UInt64), 0, :(:ccall), :(%7), 97, 0x0000000000000005, 0x0000000000000005, 97, :(%7)))::Ptr{UInt8} │ $(Expr(:gc_preserve_end, :(%2))) │ %10 = intrinsic Core.bitcast(Core.UInt, %8)::UInt64 │ %11 = builtin (%10 === 0x0000000000000000)::Bool └── goto #3 if not %11 2 ─ goto #4 3 ─ goto #4 4 ┄ %15 = φ (#2 => true, #3 => false)::Bool │ %16 = intrinsic Core.Intrinsics.not_int(%15)::Bool └── goto #5 5 ─ return %16 ) => Bool ``` **this PR:** ```julia-repl julia> code_typed(() -> startswith("https://example.com/foo", "https://"), ())[1] CodeInfo( 1 ─ return true ) => Bool julia> code_typed(() -> endswith("foo.jl", ".jl"), ())[1] CodeInfo( 1 ─ return true ) => Bool julia> code_typed(() -> in('a', "aeiou"), ())[1] CodeInfo( 1 ─ return true ) => Bool ``` And `SubString{String}` queries can now be DCE'd when their result is unused. With `describe(ss)` calling `length`, `lastindex`, `textwidth`, `isascii`: **nightly:** ```llvm define i64 @julia_caller_0(...) { top: %"sret::NamedTuple" = alloca [4 x i64], align 8 call void @j_describe_0(ptr ... sret(...) align 8 %"sret::NamedTuple", ...) ret i64 42 } ``` **this PR:** ```llvm define i64 @julia_caller_0(...) { top: ret i64 42 } ``` --------- Co-authored-by: Claude <noreply@anthropic.com>
hardikxk
pushed a commit
to hardikxk/julia
that referenced
this pull request
May 18, 2026
hardikxk
pushed a commit
to hardikxk/julia
that referenced
this pull request
May 18, 2026
…ang#61625) Continuation of JuliaLang#61616 JuliaLang#61615 Developed with claude: --- Several `Char` predicates and a couple of `String` predicates in `base/strings/unicode.jl` and `base/strings/string.jl` are spuriously inferred as `nothrow=false`, even though they are provably total. This prevents the compiler from constant-folding them or from eliminating them when their result is unused. The root cause is that the compiler cannot prove that the `ismalformed` / `is_overlong_enc` guards rule out the throwing paths in their bodies — for example the `Int(::Cint)` conversion after the `utf8proc` ccall, or the `UInt32(::AbstractChar)` conversion in `category_code`. The `AbstractChar` contract guarantees that `UInt32(c)` only throws when `ismalformed(c)` returns `true`, and the `utf8proc_charwidth` result fits in a `Cint` so the `Int` conversion never throws `InexactError`. This PR annotates the affected definitions with `@assume_effects` and adds regression tests in `test/char.jl` and `test/strings/basic.jl`. ### Scope `Char`: - `textwidth`, `category_code`, `category_abbrev`, `category_string`, `isassigned`, `islowercase`, `isuppercase` `String`: - `isascii`, `lastindex`, `textwidth` `SubString{String}`: - `length`, `isascii`, `lastindex`, `textwidth` `String` / `SubString{String}` predicates: - `startswith`, `endswith`, `in(::Char, _)` Annotations are added on narrow concrete-type method overloads so they don't constrain user-defined `AbstractChar` / `AbstractString` subtypes. A static `@assert length(category_strings) == 32` is added so the `category_string(::Char)` annotation stays sound if the table is ever edited. The bodies of `startswith` / `endswith` are `_memcmp` byte-bounded by `sizeof(b) ≤ ncodeunits(a)` plus `nextind` / `thisind` calls on indices proven in range, so they cannot throw even on strings containing arbitrary, malformed UTF-8 byte sequences. A stress test exercises every annotated function on adversarial byte sequences and on `Char` values constructed from invalid bit-patterns to lock in the `:nothrow` soundness against future refactors. ### Effect on generated code `textwidth("hello world")` now constant-folds: **nightly:** ```julia-repl julia> code_typed(() -> textwidth("hello world"), ())[1] CodeInfo( 1 ─ %1 = invoke Base._foldl_impl( Base.MappingRF{typeof(textwidth), Base.BottomRF{typeof(+)}}( textwidth, Base.BottomRF{typeof(+)}(+))::Base.MappingRF{...}, 0::Int64, "hello world"::String)::Int64 └── return %1 ) => Int64 ``` **this PR:** ```julia-repl julia> code_typed(() -> textwidth("hello world"), ())[1] CodeInfo( 1 ─ return 11 ) => Int64 ``` Likewise, `startswith` / `endswith` / `in` over literal `String`s now constant-fold. **nightly:** ```julia-repl julia> code_typed(() -> startswith("https://example.com/foo", "https://"), ())[1] CodeInfo( 1 ─ %1 = invoke Main.startswith("https://example.com/foo"::String, "https://"::String)::Bool └── return %1 ) => Bool julia> code_typed(() -> endswith("foo.jl", ".jl"), ())[1] CodeInfo( 1 ─ %1 = invoke Main.endswith("foo.jl"::String, ".jl"::String)::Bool └── return %1 ) => Bool julia> code_typed(() -> in('a', "aeiou"), ())[1] CodeInfo( 1 ─ %1 = invoke Base.codeunits("aeiou"::String)::Base.CodeUnits{UInt8, String} │ %2 = $(Expr(:gc_preserve_begin, :(%1))) │ %3 = builtin Base.getfield(%1, :s)::String │ %4 = invoke Base.cconvert(Ptr{UInt8}::Type{Ptr{UInt8}}, %3::String)::String │ %5 = $(Expr(:foreigncall, :((:jl_string_ptr,)), Ptr{UInt8}, svec(Any), 0, :(:ccall), :(%4)))::Ptr{UInt8} │ %6 = intrinsic Base.add_ptr(%5, 0x0000000000000001)::Ptr{UInt8} │ %7 = intrinsic Base.sub_ptr(%6, 0x0000000000000001)::Ptr{UInt8} │ %8 = $(Expr(:foreigncall, :((:memchr,)), Ptr{UInt8}, svec(Ptr{UInt8}, Int32, UInt64), 0, :(:ccall), :(%7), 97, 0x0000000000000005, 0x0000000000000005, 97, :(%7)))::Ptr{UInt8} │ $(Expr(:gc_preserve_end, :(%2))) │ %10 = intrinsic Core.bitcast(Core.UInt, %8)::UInt64 │ %11 = builtin (%10 === 0x0000000000000000)::Bool └── goto JuliaLang#3 if not %11 2 ─ goto JuliaLang#4 3 ─ goto JuliaLang#4 4 ┄ %15 = φ (JuliaLang#2 => true, JuliaLang#3 => false)::Bool │ %16 = intrinsic Core.Intrinsics.not_int(%15)::Bool └── goto JuliaLang#5 5 ─ return %16 ) => Bool ``` **this PR:** ```julia-repl julia> code_typed(() -> startswith("https://example.com/foo", "https://"), ())[1] CodeInfo( 1 ─ return true ) => Bool julia> code_typed(() -> endswith("foo.jl", ".jl"), ())[1] CodeInfo( 1 ─ return true ) => Bool julia> code_typed(() -> in('a', "aeiou"), ())[1] CodeInfo( 1 ─ return true ) => Bool ``` And `SubString{String}` queries can now be DCE'd when their result is unused. With `describe(ss)` calling `length`, `lastindex`, `textwidth`, `isascii`: **nightly:** ```llvm define i64 @julia_caller_0(...) { top: %"sret::NamedTuple" = alloca [4 x i64], align 8 call void @j_describe_0(ptr ... sret(...) align 8 %"sret::NamedTuple", ...) ret i64 42 } ``` **this PR:** ```llvm define i64 @julia_caller_0(...) { top: ret i64 42 } ``` --------- Co-authored-by: Claude <noreply@anthropic.com>
mkitti
pushed a commit
to mkitti/julia
that referenced
this pull request
May 22, 2026
mkitti
pushed a commit
to mkitti/julia
that referenced
this pull request
May 22, 2026
…ang#61625) Continuation of JuliaLang#61616 JuliaLang#61615 Developed with claude: --- Several `Char` predicates and a couple of `String` predicates in `base/strings/unicode.jl` and `base/strings/string.jl` are spuriously inferred as `nothrow=false`, even though they are provably total. This prevents the compiler from constant-folding them or from eliminating them when their result is unused. The root cause is that the compiler cannot prove that the `ismalformed` / `is_overlong_enc` guards rule out the throwing paths in their bodies — for example the `Int(::Cint)` conversion after the `utf8proc` ccall, or the `UInt32(::AbstractChar)` conversion in `category_code`. The `AbstractChar` contract guarantees that `UInt32(c)` only throws when `ismalformed(c)` returns `true`, and the `utf8proc_charwidth` result fits in a `Cint` so the `Int` conversion never throws `InexactError`. This PR annotates the affected definitions with `@assume_effects` and adds regression tests in `test/char.jl` and `test/strings/basic.jl`. ### Scope `Char`: - `textwidth`, `category_code`, `category_abbrev`, `category_string`, `isassigned`, `islowercase`, `isuppercase` `String`: - `isascii`, `lastindex`, `textwidth` `SubString{String}`: - `length`, `isascii`, `lastindex`, `textwidth` `String` / `SubString{String}` predicates: - `startswith`, `endswith`, `in(::Char, _)` Annotations are added on narrow concrete-type method overloads so they don't constrain user-defined `AbstractChar` / `AbstractString` subtypes. A static `@assert length(category_strings) == 32` is added so the `category_string(::Char)` annotation stays sound if the table is ever edited. The bodies of `startswith` / `endswith` are `_memcmp` byte-bounded by `sizeof(b) ≤ ncodeunits(a)` plus `nextind` / `thisind` calls on indices proven in range, so they cannot throw even on strings containing arbitrary, malformed UTF-8 byte sequences. A stress test exercises every annotated function on adversarial byte sequences and on `Char` values constructed from invalid bit-patterns to lock in the `:nothrow` soundness against future refactors. ### Effect on generated code `textwidth("hello world")` now constant-folds: **nightly:** ```julia-repl julia> code_typed(() -> textwidth("hello world"), ())[1] CodeInfo( 1 ─ %1 = invoke Base._foldl_impl( Base.MappingRF{typeof(textwidth), Base.BottomRF{typeof(+)}}( textwidth, Base.BottomRF{typeof(+)}(+))::Base.MappingRF{...}, 0::Int64, "hello world"::String)::Int64 └── return %1 ) => Int64 ``` **this PR:** ```julia-repl julia> code_typed(() -> textwidth("hello world"), ())[1] CodeInfo( 1 ─ return 11 ) => Int64 ``` Likewise, `startswith` / `endswith` / `in` over literal `String`s now constant-fold. **nightly:** ```julia-repl julia> code_typed(() -> startswith("https://example.com/foo", "https://"), ())[1] CodeInfo( 1 ─ %1 = invoke Main.startswith("https://example.com/foo"::String, "https://"::String)::Bool └── return %1 ) => Bool julia> code_typed(() -> endswith("foo.jl", ".jl"), ())[1] CodeInfo( 1 ─ %1 = invoke Main.endswith("foo.jl"::String, ".jl"::String)::Bool └── return %1 ) => Bool julia> code_typed(() -> in('a', "aeiou"), ())[1] CodeInfo( 1 ─ %1 = invoke Base.codeunits("aeiou"::String)::Base.CodeUnits{UInt8, String} │ %2 = $(Expr(:gc_preserve_begin, :(%1))) │ %3 = builtin Base.getfield(%1, :s)::String │ %4 = invoke Base.cconvert(Ptr{UInt8}::Type{Ptr{UInt8}}, %3::String)::String │ %5 = $(Expr(:foreigncall, :((:jl_string_ptr,)), Ptr{UInt8}, svec(Any), 0, :(:ccall), :(%4)))::Ptr{UInt8} │ %6 = intrinsic Base.add_ptr(%5, 0x0000000000000001)::Ptr{UInt8} │ %7 = intrinsic Base.sub_ptr(%6, 0x0000000000000001)::Ptr{UInt8} │ %8 = $(Expr(:foreigncall, :((:memchr,)), Ptr{UInt8}, svec(Ptr{UInt8}, Int32, UInt64), 0, :(:ccall), :(%7), 97, 0x0000000000000005, 0x0000000000000005, 97, :(%7)))::Ptr{UInt8} │ $(Expr(:gc_preserve_end, :(%2))) │ %10 = intrinsic Core.bitcast(Core.UInt, %8)::UInt64 │ %11 = builtin (%10 === 0x0000000000000000)::Bool └── goto JuliaLang#3 if not %11 2 ─ goto JuliaLang#4 3 ─ goto JuliaLang#4 4 ┄ %15 = φ (JuliaLang#2 => true, JuliaLang#3 => false)::Bool │ %16 = intrinsic Core.Intrinsics.not_int(%15)::Bool └── goto JuliaLang#5 5 ─ return %16 ) => Bool ``` **this PR:** ```julia-repl julia> code_typed(() -> startswith("https://example.com/foo", "https://"), ())[1] CodeInfo( 1 ─ return true ) => Bool julia> code_typed(() -> endswith("foo.jl", ".jl"), ())[1] CodeInfo( 1 ─ return true ) => Bool julia> code_typed(() -> in('a', "aeiou"), ())[1] CodeInfo( 1 ─ return true ) => Bool ``` And `SubString{String}` queries can now be DCE'd when their result is unused. With `describe(ss)` calling `length`, `lastindex`, `textwidth`, `isascii`: **nightly:** ```llvm define i64 @julia_caller_0(...) { top: %"sret::NamedTuple" = alloca [4 x i64], align 8 call void @j_describe_0(ptr ... sret(...) align 8 %"sret::NamedTuple", ...) ret i64 42 } ``` **this PR:** ```llvm define i64 @julia_caller_0(...) { top: ret i64 42 } ``` --------- Co-authored-by: Claude <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Looking for more opportunities to tighten up effects motivated by #61394 and similar to #61615
Developed with claude:
Several math functions were inferred as potentially throwing even though they never do for IEEEFloat inputs:
asinh(Float32/Float64): the function guards all non-finite inputs at the top (!isfinite(x) && return x), then callslog/log1pwith provably positive arguments. The compiler conservatively marks it!nothrowbecauselogitself is!nothrow. Fixed with@assume_effects :nothrow.frexp(Float64): usedInt(xs >> significand_bits(T))where the value after masking and shifting is always ≤ 2047, butInt(::UInt64)is!nothrow(can overflow). Changed torem(..., Int), matching the pattern already used in_exponent_finite_nonzero._hypot(Float32/Float16): callssqrt(muladd(x,x,y*y))where the argument is always ≥ 0 (so sqrt never throws), but the compiler can't see throughsqrt's!nothrowsignature. Theisinfguards handle all Inf inputs. Fixed with@assume_effects :nothrow.unsafe_trunc(::Type{UInt128}, ::Float64)(cascading to theInt128variant): usedInt(xu >> 52)for the same reason asfrexp. Replaced with(xu >> 52) % Int, which is semantically identical when the value fits (it always does — at most 11 bits) and preserves:nothrow.The same
Int(::UInt64) → % Intcleanup is also applied inexponent,_log(::Float64), andlog1p(::Float64). These functions still infer as!nothrowbecause of explicit domain-error branches, so the change is preparatory and does not flip their top-level effect.All four
asinh/frexp/hypot/unsafe_truncfunctions are now(+c,+e,+n,+t,+s,+m,+u,+o,+r)(removable if unused), enabling DCE of calls to these functions with unused results.