Skip to content

math: fix exp_impl_fast Float64 nothrow inference - #61615

Merged
IanButterworth merged 1 commit into
JuliaLang:masterfrom
IanButterworth:ib/fastmath_exp_effects
Apr 21, 2026
Merged

IanButterworth merged 1 commit into
JuliaLang:masterfrom
IanButterworth:ib/fastmath_exp_effects

Conversation

@IanButterworth

@IanButterworth IanButterworth commented Apr 20, 2026 •

Copy link
Copy Markdown
Member

Addresses the exp finding in #61394 (comment) @giordano

#61394

julia> code_llvm((Float64,); debuginfo=:none) do x; @fastmath exp(x) - exp(x) end
; Function Signature: var"#5"(Float64)
define double @"julia_#5_0"(double %"x::Float64") local_unnamed_addr #0 {
top:
  %0 = call double @j_exp_fast_0(double %"x::Float64") #9
  ret double 0.000000e+00
}

This PR

julia> code_llvm((Float64,); debuginfo=:none) do x; @fastmath exp(x) - exp(x) end
; Function Signature: var"#5"(Float64)
define double @"julia_#5_0"(double %"x::Float64") local_unnamed_addr #0 {
top:
  ret double 0.000000e+00
}

Developed with claude:


Use table_unpack(N) in exp_impl_fast for Float64 instead of a raw @inbounds J_TABLE[N&255 + 1] indexing. The compiler cannot prove the @inbounds access is safe, causing the function to be inferred as !n (not nothrow) and !u (not noundef), unlike its non-fast counterpart exp_impl which already uses table_unpack (annotated with @assume_effects :nothrow). This affected exp_fast, exp2_fast, and exp10_fast for Float64 since they all go through exp_impl_fast.

Use `table_unpack(N)` in `exp_impl_fast` for Float64 instead of a raw
`@inbounds J_TABLE[N&255 + 1]` indexing. The compiler cannot prove the
`@inbounds` access is safe, causing the function to be inferred as `!n`
(not nothrow) and `!u` (not noundef), unlike its non-fast counterpart
`exp_impl` which already uses `table_unpack` (annotated with
`@assume_effects :nothrow`). This affected `exp_fast`, `exp2_fast`, and
`exp10_fast` for Float64 since they all go through `exp_impl_fast`.

Co-authored-by: Claude <noreply@anthropic.com>
@IanButterworth IanButterworth added maths Mathematical functions compiler:effects effect analysis labels Apr 20, 2026
@gbaraldi
gbaraldi marked this pull request as ready for review April 20, 2026 16:43
@gbaraldi
gbaraldi requested a review from oscardssmith as a code owner April 20, 2026 16:43
@IanButterworth
IanButterworth merged commit b22e053 into JuliaLang:master Apr 21, 2026
9 of 10 checks passed
@IanButterworth
IanButterworth deleted the ib/fastmath_exp_effects branch April 21, 2026 00:24
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.

2 participants