Skip to content

codegen: Propagate ipo_purity_bits to LLVM function attributes - #61394

Merged
IanButterworth merged 7 commits into
JuliaLang:masterfrom
IanButterworth:ib/fib
Apr 20, 2026
Merged

IanButterworth merged 7 commits into
JuliaLang:masterfrom
IanButterworth:ib/fib

Conversation

@IanButterworth

@IanButterworth IanButterworth commented Mar 23, 2026 •

Copy link
Copy Markdown
Member

Updated after @gbaraldi (who previously explored this in #47844) helped out:

Translate Julia's inferred effects (consistent, effect_free, nothrow, terminates, notaskstate) into LLVM function attributes so that middle-end passes like GVN, LICM, and DSE can exploit them.

The key design insight is that GC interactions don't need to be visible before GC lowering. Call-site declarations get optimistic memory attributes (e.g. memory(argmem: read)) that enable pre-GC optimizations, then LateLowerGCFrame widens them to memory(readwrite) before safepoint analysis so post-GC passes see correct semantics.

Attributes added:

  • nounwind: for nothrow functions (with uwtable(async) on definitions
    to preserve .eh_frame for stack scanning)
  • mustprogress: for terminating functions
  • willreturn: for nothrow+terminating functions
  • memory(argmem: read): for consistent+effect_free functions with no
    user-facing pointer arguments (call-site declarations only)
  • speculatable: for the above when also nothrow+terminating Not used
  • readnone on gcstack param: for notaskstate functions, so LICM can
    hoist pure calls past heap stores
  • "julia.safepoint" marker: on all call-site declarations, used by
    LateLowerGCFrame to identify and widen optimistic attrs

LateLowerGCFrame strips all optimistic attributes (memory effects,
readnone on gcstack) from both call instructions and function
declarations before safepoint analysis runs.

Developed with Claude

@IanButterworth IanButterworth added performance Must go faster compiler:codegen Generation of LLVM IR and native code labels Mar 23, 2026
@oscardssmith

Copy link
Copy Markdown
Member

I'm pretty sure @gbaraldi had a previous version of this PR. The really hard part is iirc, From LLVM's point of view, some of the things that we consider effect free are effect-full.

@IanButterworth

Copy link
Copy Markdown
Member Author

Ah ok! I didn't find anything that looked similar. Note that macOS tests passed.
(I'm not currently sure why Test.jl isn't precompiling on other platforms)

@oscardssmith

Copy link
Copy Markdown
Member

I think it's #50188

@giordano

Copy link
Copy Markdown
Member

Comparing to the Rust equivalent

How did you compile the Rust code? By default rustc doesn't do an optimised build, which makes performance comparisons very misleading.

@gbaraldi

Copy link
Copy Markdown
Member

The memory read ones are potentially illegal because they ignore the safe point

@IanButterworth

Copy link
Copy Markdown
Member Author

How did you compile the Rust code?

@giordano it was with -o (mentioned in the table), which is opt-level 3

@vchuravy

Copy link
Copy Markdown
Member

The memory read ones are potentially illegal because they ignore the safe point

The issue is that we can't reorder writes across function calls due to safepoints. if LLVM moves a store to a pointer past a function call we potentially miss a rooting event.

@oscardssmith

Copy link
Copy Markdown
Member

I think it might be a good idea to experiment with LLVM's gcwrite for pointer stores since it would make these sorts of optimizations legal (since gcwrite carries the writebarrier with it automatically). Doing so would potentially give us stackmaps for "free"

@IanButterworth

Copy link
Copy Markdown
Member Author

Ok. Just to summarize

  • The main perf gain here (memory(argmem: read)) isn't legal in this form
  • The nounwind thing only seems to be valid on macOS aarch64 (and I didn't look into how much perf gain this alone gives)

So the current PR, even if it disabled those two, doesn't seem to have much merit.

@gbaraldi
gbaraldi force-pushed the ib/fib branch 2 times, most recently from f63e243 to 6f6b447 Compare March 27, 2026 17:48
@gbaraldi
gbaraldi marked this pull request as ready for review March 27, 2026 18:48
@gbaraldi

Copy link
Copy Markdown
Member

@nanosoldier runbenchmarks(ALL, vs=":master")

@vchuravy

Copy link
Copy Markdown
Member

Is this enough to LICM calls. Like sin?

@IanButterworth

Copy link
Copy Markdown
Member Author

Maybe not? Some benchmarking locally

bench_ipo_effects.jl.txt

Screenshot 2026-03-27 at 10 43 36 PM

@IanButterworth

Copy link
Copy Markdown
Member Author

Because of !n ?

Screenshot 2026-03-27 at 10 48 46 PM

@oscardssmith

Copy link
Copy Markdown
Member

yeah LLVM can't licm functions that may throw. I really think DomainError was a mistake (and we should return NaN), but we obviously can't fix that now. I have thought about introducing Base.NaNMath which the "normal" math functions would then add DomainErrors to so that users could get the good version easily if they want it.

@nanosoldier

Copy link
Copy Markdown
Collaborator

Your benchmark job has completed - possible performance regressions were detected. A full report can be found here.

@Zentrik

Zentrik commented Mar 28, 2026

Copy link
Copy Markdown
Member

I guess this will subsume #51120 as well

@giordano

Copy link
Copy Markdown
Member

We probably need a few llvm pass tests to make sure the optimisations don't disappear with an llvm bump?

@IanButterworth

Copy link
Copy Markdown
Member Author

@gbaraldi I just force pushed because my email was wrong and picking up another author. also drop an unrelated whitespace change in Test.jl

@giordano giordano added the needs tests Unit tests are required for this change label Mar 28, 2026
@IanButterworth

IanButterworth commented Mar 28, 2026 •

Copy link
Copy Markdown
Member Author

Disabling emission of speculatable changes the ziggurat regression into to an improvement

Screenshot 2026-03-28 at 2 22 13 PM

Note: I explored adding a cheap effect and gating speculatable on it, but didn't get the performance back with that approach IanButterworth@83cd7ed

@IanButterworth

Copy link
Copy Markdown
Member Author

@gbaraldi confirmed with the LLVM folk that they never infer speculatable, so I disabled it

@IanButterworth

Copy link
Copy Markdown
Member Author

@nanosoldier runbenchmarks(ALL, vs=":master")

@nanosoldier

Copy link
Copy Markdown
Collaborator

The benchmark job you requested has completed - possible performance regressions were detected. 4818 benchmarks were executed, 120 showed regressions, and 127 showed improvements.
Commits: 7fb9330 vs 3181c36
The full report is available.

@IanButterworth

Copy link
Copy Markdown
Member Author

Using the benchmark plot summary code from JuliaCI/Nanosoldier.jl#227 on #61394 (comment)

mock_report_plot

@IanButterworth

Copy link
Copy Markdown
Member Author

I couldn't reproduce those biggest regressions locally on mac.

@gbaraldi good to merge?

@IanButterworth IanButterworth removed the needs nanosoldier run This PR should have benchmarks run on it label Apr 19, 2026
@giordano

Copy link
Copy Markdown
Member

Is this enough to LICM calls. Like sin?

While it doesn't do LICM on sin (but it does on exp, because it doesn't throw), it already does common subexpression elimination also on sin and cos:

julia> code_llvm((Float64,); debuginfo=:none) do x sin(x) * sin(x) + cos(x) * cos(x) end
; Function Signature: var"#92"(Float64)
define double @"julia_#92_0"(double %"x::Float64") local_unnamed_addr #0 {
top:
  %0 = call double @j_sin_0(double %"x::Float64") #10
  %1 = fmul double %0, %0
  %2 = call double @j_cos_0(double %"x::Float64") #10
  %3 = fmul double %2, %2
  %4 = fadd double %1, %3
  ret double %4
}

This is going to make code involving rotation matrices a lot simpler to write: you can repeat those sin(θ) and cos(θ) calls multiple times without fear 😁 Compare with nightly

julia> code_llvm((Float64,); debuginfo=:none) do x sin(x) * sin(x) + cos(x) * cos(x) end
; Function Signature: var"#50"(Float64)
define double @"julia_#50_0"(double %"x::Float64") local_unnamed_addr #0 {
top:
  %0 = call double @j_sin_0(double %"x::Float64")
  %1 = call double @j_sin_0(double %"x::Float64")
  %2 = fmul double %0, %1
  %3 = call double @j_cos_0(double %"x::Float64")
  %4 = call double @j_cos_0(double %"x::Float64")
  %5 = fmul double %3, %4
  %6 = fadd double %2, %5
  ret double %6
}

Also fastmath code can be better optimised with this PR:

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

When no-NaNs can be assumed, the compiler can simplify the code more effectively (just sad it doesn't remove the dead code, not even with exp). Compare with current nightly

julia> code_llvm((Float64,); debuginfo=:none) do x; @fastmath sin(x) - sin(x) end
; Function Signature: var"#47"(Float64)
define double @"julia_#47_0"(double %"x::Float64") local_unnamed_addr #0 {
top:
  %0 = call double @j_sin_0(double %"x::Float64")
  %1 = call double @j_sin_0(double %"x::Float64")
  %2 = fsub fast double %0, %1
  ret double %2
}

Comment thread src/aotcompile.cpp Outdated
@gbaraldi

Copy link
Copy Markdown
Member

Outside of that dangling comment it seems good

@IanButterworth IanButterworth added the merge me PR is reviewed. When all tests are passing merge, making sure commit message is good. label Apr 20, 2026
@IanButterworth
IanButterworth merged commit d50d2a5 into JuliaLang:master Apr 20, 2026
8 checks passed
@IanButterworth
IanButterworth deleted the ib/fib branch April 20, 2026 20:38
@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 referenced this pull request Apr 28, 2026
This lack of specialization is problematic, because the result of the
map is used in the function body. Without specialization, this causes
excessive allocations.

This was discovered by @aplavin with this benchmark:
```julia
using BenchmarkTools

a = (x1=[1.0], x2=[2.0], x3=[3.0], x4=[4.0], x5=[5.0], x6=[6.0], x7=[7.0], x8=[8.0], x9=[9.0], x10=[10.0], x11=[11.0])
b = map(copy, a)

function bench(a, b, N)
    s = 0
    for _ in 1:N; s += length(map(copyto!, a, b).x1); end; s
end
bench(a, b, 1)

@showtime bench(a, b, 1_000_000)
```

Results:
* `map` on master: `bench(a, b, 1000000): 0.495330 seconds (4.00 M
allocations: 366.211 MiB, 34.93% gc time)`
* `map` on PR: `bench(a, b, 1000000): 0.050094 seconds (1 allocation: 16
bytes)`
hardikxk pushed a commit to hardikxk/julia that referenced this pull request May 18, 2026
…chmarking (JuliaLang#61438)

Adds `Base.blackbox(x)` and the corresponding `:blackbox` setting for
`compilerbarrier`, motivated by the LICM-hoisting issue seen in JuliaLang#61394.

`Base.blackbox(x)` returns `x` but treats the output as opaque to the
optimizer: it cannot be constant-folded, CSE'd, or treated as
loop-invariant. This is Julia's equivalent of Google Benchmark's
`DoNotOptimize`.

https://google.github.io/benchmark/user_guide.html#preventing-optimization

`blackbox` is implemented as `compilerbarrier(:blackbox, x)`, keeping
all compiler-barrier functionality consolidated in one builtin rather
than introducing a new one.

## Implementation

**Julia-level (inference):** `:blackbox` has `consistent=ALWAYS_FALSE`
and returns `widenconst(val)`, preventing constant propagation and CSE
at the abstract interpretation level.

**Codegen level**, three cases based on the value's LLVM type:

- **Non-pointer scalars** (integers, floats — anything that fits in a
register): emits `asm "" "=r,0"` directly, tying the output register to
the input.
- **Boxed/GC-tracked pointers**: cannot use register-tied asm on
GC-tracked address spaces. Instead, emits a `julia.blackbox` intrinsic
call that is lowered to `asm "" "=r,0"` on the raw untracked pointer
after GC frame expansion.
- **Unboxed aggregates / unboxed pointers** (e.g., structs, `Ptr{T}`):
uses `asm sideeffect "" "~{memory}"` to clobber memory, making the value
appear non-invariant without needing a register constraint.

Ghost types (`Nothing`, etc.) pass through unchanged with no code
emitted.

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Valentin Churavy <v.churavy@gmail.com>
Co-authored-by: Keno Fischer <1291671+Keno@users.noreply.github.com>
hardikxk pushed a commit to hardikxk/julia that referenced this pull request May 18, 2026
…iaLang#61394)

Translate Julia's inferred effects (consistent, effect_free, nothrow,
terminates, notaskstate) into LLVM function attributes so that
middle-end passes like GVN, LICM, and DSE can exploit them.

The key design insight is that GC interactions don't need to be visible
before GC lowering. Call-site declarations get optimistic memory
attributes (e.g. memory(argmem: read)) that enable pre-GC optimizations,
then LateLowerGCFrame widens them to memory(readwrite) before safepoint
analysis so post-GC passes see correct semantics.

Attributes added:
- nounwind: for nothrow functions (with uwtable(async) on definitions
  to preserve .eh_frame for stack scanning)
- mustprogress: for terminating functions
- willreturn: for nothrow+terminating functions
- memory(argmem: read): for consistent+effect_free functions with no
  user-facing pointer arguments (call-site declarations only)
- readnone on gcstack param: for notaskstate functions, so LICM can
  hoist pure calls past heap stores
- "julia.safepoint" marker: on all call-site declarations, used by
  LateLowerGCFrame to identify and widen optimistic attrs

LateLowerGCFrame strips all optimistic attributes (memory effects,
readnone on gcstack) from both call instructions and function
declarations before safepoint analysis runs.

Previously explored in JuliaLang#47844

Developed with Claude

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Gabriel Baraldi <28694980+gbaraldi@users.noreply.github.com>
mkitti pushed a commit to mkitti/julia that referenced this pull request May 22, 2026
…chmarking (JuliaLang#61438)

Adds `Base.blackbox(x)` and the corresponding `:blackbox` setting for
`compilerbarrier`, motivated by the LICM-hoisting issue seen in JuliaLang#61394.

`Base.blackbox(x)` returns `x` but treats the output as opaque to the
optimizer: it cannot be constant-folded, CSE'd, or treated as
loop-invariant. This is Julia's equivalent of Google Benchmark's
`DoNotOptimize`.

https://google.github.io/benchmark/user_guide.html#preventing-optimization

`blackbox` is implemented as `compilerbarrier(:blackbox, x)`, keeping
all compiler-barrier functionality consolidated in one builtin rather
than introducing a new one.

## Implementation

**Julia-level (inference):** `:blackbox` has `consistent=ALWAYS_FALSE`
and returns `widenconst(val)`, preventing constant propagation and CSE
at the abstract interpretation level.

**Codegen level**, three cases based on the value's LLVM type:

- **Non-pointer scalars** (integers, floats — anything that fits in a
register): emits `asm "" "=r,0"` directly, tying the output register to
the input.
- **Boxed/GC-tracked pointers**: cannot use register-tied asm on
GC-tracked address spaces. Instead, emits a `julia.blackbox` intrinsic
call that is lowered to `asm "" "=r,0"` on the raw untracked pointer
after GC frame expansion.
- **Unboxed aggregates / unboxed pointers** (e.g., structs, `Ptr{T}`):
uses `asm sideeffect "" "~{memory}"` to clobber memory, making the value
appear non-invariant without needing a register constraint.

Ghost types (`Nothing`, etc.) pass through unchanged with no code
emitted.

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Valentin Churavy <v.churavy@gmail.com>
Co-authored-by: Keno Fischer <1291671+Keno@users.noreply.github.com>
mkitti pushed a commit to mkitti/julia that referenced this pull request May 22, 2026
…iaLang#61394)

Translate Julia's inferred effects (consistent, effect_free, nothrow,
terminates, notaskstate) into LLVM function attributes so that
middle-end passes like GVN, LICM, and DSE can exploit them.

The key design insight is that GC interactions don't need to be visible
before GC lowering. Call-site declarations get optimistic memory
attributes (e.g. memory(argmem: read)) that enable pre-GC optimizations,
then LateLowerGCFrame widens them to memory(readwrite) before safepoint
analysis so post-GC passes see correct semantics.

Attributes added:
- nounwind: for nothrow functions (with uwtable(async) on definitions
  to preserve .eh_frame for stack scanning)
- mustprogress: for terminating functions
- willreturn: for nothrow+terminating functions
- memory(argmem: read): for consistent+effect_free functions with no
  user-facing pointer arguments (call-site declarations only)
- readnone on gcstack param: for notaskstate functions, so LICM can
  hoist pure calls past heap stores
- "julia.safepoint" marker: on all call-site declarations, used by
  LateLowerGCFrame to identify and widen optimistic attrs

LateLowerGCFrame strips all optimistic attributes (memory effects,
readnone on gcstack) from both call instructions and function
declarations before safepoint analysis runs.

Previously explored in JuliaLang#47844

Developed with Claude

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Gabriel Baraldi <28694980+gbaraldi@users.noreply.github.com>
vtjnash added a commit that referenced this pull request Aug 20, 2026
Revert "codegen: Propagate `ipo_purity_bits` to LLVM function attributes (#61394)"

Closes #62613 while we work on ensuring the correctness of #61394.
@IanButterworth IanButterworth added the reverted This PR has since been reverted label Aug 20, 2026
topolarity pushed a commit to topolarity/julia that referenced this pull request Oct 5, 2026
Calls tagged "julia.safepoint" are Julia calls whose memory effects
codegen narrowed from inferred effects. They can still reach a GC
safepoint, so LocalScan must treat them as safepoints regardless of
what the memory effects claim, and the effects must be stripped before
any post-lowering pass could use them against the now-explicit GC frame
stores.

This is the rooting half of re-landing JuliaLang#61394 (reverted in JuliaLang#62697,
see JuliaLang#62613): rooting no longer depends on pass ordering having stripped
the attributes first.

Assisted-by: Claude Code (Fable 5.1)
xal-0 pushed a commit that referenced this pull request Oct 6, 2026
Calls tagged "julia.safepoint" are Julia calls whose memory effects
codegen narrowed from inferred effects. They can still reach a GC
safepoint, so LocalScan must treat them as safepoints regardless of
what the memory effects claim, and the effects must be stripped before
any post-lowering pass could use them against the now-explicit GC frame
stores.

This is the rooting half of re-landing #61394 (reverted in #62697,
see #62613): rooting no longer depends on pass ordering having stripped
the attributes first.

Assisted-by: Claude Code (Fable 5.1)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

compiler:codegen Generation of LLVM IR and native code compiler:effects effect analysis performance Must go faster reverted This PR has since been reverted

Projects

None yet

Development

Successfully merging this pull request may close these issues.

10 participants