Skip to content

math: fix spurious !nothrow effects on asinh, frexp, hypot, and unsafe_trunc - #61616

Merged
IanButterworth merged 3 commits into
JuliaLang:masterfrom
IanButterworth:ib/more_effects_fix
Apr 21, 2026
Merged

IanButterworth merged 3 commits into
JuliaLang:masterfrom
IanButterworth:ib/more_effects_fix

Conversation

@IanButterworth

@IanButterworth IanButterworth commented Apr 20, 2026 •

Copy link
Copy Markdown
Member

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 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.

  • unsafe_trunc(::Type{UInt128}, ::Float64) (cascading to the Int128 variant): used Int(xu >> 52) for the same reason as frexp. 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) → % Int cleanup is also applied in exponent, _log(::Float64), and log1p(::Float64). These functions still infer as !nothrow because of explicit domain-error branches, so the change is preparatory and does not flip their top-level effect.

All four asinh/frexp/hypot/unsafe_trunc functions are now (+c,+e,+n,+t,+s,+m,+u,+o,+r) (removable if unused), enabling DCE of calls to these functions with unused results.

@IanButterworth IanButterworth added maths Mathematical functions compiler:effects effect analysis labels Apr 20, 2026
@IanButterworth IanButterworth changed the title math: fix spurious !nothrow effects on asinh, frexp, and hypot math: fix spurious !nothrow effects on asinh, frexp, hypot, and unsafe_trunc Apr 21, 2026
Comment thread base/float.jl Outdated
Comment thread base/math.jl Outdated
Comment thread base/special/log.jl Outdated
Comment thread base/special/log.jl Outdated
IanButterworth and others added 3 commits April 21, 2026 09:13
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 IanButterworth added the merge me PR is reviewed. When all tests are passing merge, making sure commit message is good. label Apr 21, 2026
@IanButterworth
IanButterworth merged commit 59ef345 into JuliaLang:master Apr 21, 2026
10 checks passed
@IanButterworth
IanButterworth deleted the ib/more_effects_fix branch April 21, 2026 17:11
@DilumAluthge DilumAluthge removed the merge me PR is reviewed. When all tests are passing merge, making sure commit message is good. label Apr 21, 2026
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

compiler:effects effect analysis maths Mathematical functions

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants