Skip to content

pipeline: Trim passes that do little for Julia code - #63747

Open
KristofferC wants to merge 3 commits into
JuliaLang:masterfrom
KristofferC:kc/pipeline-trim
Open

KristofferC wants to merge 3 commits into
JuliaLang:masterfrom
KristofferC:kc/pipeline-trim

Conversation

@KristofferC

@KristofferC KristofferC commented Oct 12, 2026 •

Copy link
Copy Markdown
Member

I had Claude look at our LLVM opt pass pipeline to see if it had some unnecessary passes that didn't actually do anything in practice or if it was something that had a benefit to be added. It ran extensive tests on my laptop (mac) and on an x86 machines, doing benchmarks, checking instruction counts etc etc. The actual changes below is written by Claude but it is written well enough in my opinion that writing it myself wouldn't really help. There is also an artifact that has more data that could be interesting to read at https://claude.ai/artifact/FD1CouQPviN6hMDXxe1Bvs.


Several passes in the default pipeline cost compile time without changing
the code we generate. Removed or replaced:

  • The O3-only scalar GVN and the second scalar DSE. They run right
    after GVN/DSE-like passes in the same stage and find almost nothing
    new.
  • InferFunctionAttrsPass. It only annotates known library calls, which
    Julia code rarely makes directly.
  • LibCallsShrinkWrapPass, which targets C math library calls with errno.
  • The GVN in the cleanup pipeline, now EarlyCSE with MemorySSA. After
    GC lowering there is little left for full GVN to do.

AllocOpt no longer runs through a CGSCC walk unless a plugin registered
CGSCC passes; without them the walk only costs call-graph construction.
Float2Int and LowerConstantIntrinsics move to the start of the vector
stage, where LLVM's own pipeline runs them.

InferAlignment is added after loop vectorization and after the
post-unroll SROA, as LLVM's pipeline does since LLVM 18.

Measured on the sysimage corpus, LLVM pass time drops 12% at O2 and 20% at
O3, and the CPU time of the sysimage output phase drops 6%. BaseBenchmarks
runtime is unchanged within noise on Apple M4 and AMD Zen 4.

One pass is also added: LoopFullUnroll now runs once before the
loop-pipeline IRCE. IRCE splits a short constant-trip inner loop under
bounds checks (e.g. for v in 1:5) into pre/main/post loops with variable
trip counts, which defeats the later full unroll and lets
LoopIdiomRecognize turn the loop into a variable-length memcpy per outer
iteration. Unrolling first only touches loops with a small constant trip
count. The kernel from #62798 goes from 269 µs to 84 µs (Apple M4); the
extra pass costs +0.4% LLVM pass time, and optimized code size is unchanged.

Fixes #62798

Assisted-by: Claude Code (Opus 5.5)

@KristofferC
KristofferC requested a review from gbaraldi October 12, 2026 09:08
@KristofferC KristofferC added latency Latency compiler:llvm For issues that relate to LLVM labels Oct 12, 2026
@vchuravy

Copy link
Copy Markdown
Member

Comment thread src/pipeline.cpp
FPM.addPass(InferAlignmentPass());
#endif
FPM.addPass(InstSimplifyPass());
// No TailCallElim: `tail` lets AA assume the callee cannot access caller allocas, but GC frames are reachable through the task

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fascinating.

@vchuravy vchuravy Oct 12, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Before GC lowering, GC frame allocas are reachable through the task's gcstack, and late lowering copies tail markers.

This is something that we have been debating. Yes, technically true, but practically false. We treat the gcstack mostly as inaccessiblemem. So the GC reads it, but we dont allow calls to read the gcstack of their parent (it knows nothing of that layout).

Not sure if there is a good way of telling LLVM that.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we'd have to make a dummy gcframe in codegen, if we ever ran the InferFunctionAttrs pass which could add the maytail attribut, so that it wouldn't actually infer anything (though calling into question why we wasted the cycles to schedule the pass).

@vchuravy

Copy link
Copy Markdown
Member

Generally looks LGTM.

The only thing that is annoying to lose is:

Dead IRCE post-loops.
IRCE splits a loop into a main loop and a post-loop whose bound is umin(smax(n, 0), n). Under the loop's guard n ≥ 1 the post-loop never runs. After GC lowering, GVN proves this and later passes delete it; EarlyCSE can't. In NBody.advance this grows the code from 174 to 273 lines of assembly. It does not show up in run time, because the dead loop is skipped by one compare.

Makes me wonder if IRCE annotates loops as cold, or if we should consider a hotness for branches pass.

@KristofferC

KristofferC commented Oct 12, 2026 •

Copy link
Copy Markdown
Member Author

The only thing that is annoying to lose is

Yeah, that's why that one is in a separate commit but I couldn't really justify leaving it out (10% extra opt time) for no visible practical runtime performance that I could find.

@KristofferC

Copy link
Copy Markdown
Member Author

@nanosoldier runbenchmarks(; vs=":master")

@vchuravy

Copy link
Copy Markdown
Member

Could we move the GVN pass to -O3 since its expected that O3 is more costly?

@KristofferC

Copy link
Copy Markdown
Member Author

Could we move the GVN pass to -O3 since its expected that O3 is more costly?

Sure, but we shouldn't have practically useless passes at any opt level. Imo, any optimization pass has to "earn its stay". So if there is code that actually benefits from it, we can of course add it but having a concrete example would be useful.

@nanosoldier

Copy link
Copy Markdown
Collaborator

Your job failed. Consult the server logs for more details.

vchuravy pushed a commit to EnzymeAD/Enzyme.jl that referenced this pull request Oct 12, 2026
…stage

Julia runs them there since JuliaLang/julia#63747.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UkUYyGrrrxDmwhV9mTKpr7
@KristofferC

Copy link
Copy Markdown
Member Author

TTFX (job): d6cc4d3925 (1.14.0-DEV.3619) against 04fa6fa18e (1.14.0-DEV.3617).

suite geomean change per block
Precompile -2.4%, -2.3% (improvement)
Load (cold) -0.0%, +0.8%
Run (cold) -3.4%, -3.5% (improvement)
Load+run (warm) -1.8%, -1.8%

@Keno Keno added the needs pkgeval Tests for all registered packages should be run with this change label Oct 12, 2026
@KristofferC

Copy link
Copy Markdown
Member Author

@nanosoldier runtests()

@KristofferC

Copy link
Copy Markdown
Member Author

@pkgeval runtests()

@gbaraldi

Copy link
Copy Markdown
Member

those LLVM version guards make no sense

Comment thread src/pipeline.cpp
Comment on lines +563 to +565
#if JL_LLVM_VERSION >= 180000
FPM.addPass(InferAlignmentPass());
#endif

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We have long dropped support for LLVM v18

@KristofferC

KristofferC commented Oct 13, 2026 •

Copy link
Copy Markdown
Member Author

those LLVM version guards make no sense

In statements like this, it is helpful to give some guidance as to why.

We have long dropped support for LLVM v18

Okay, but llvm-version.h claims >= 17.0.0:

julia/src/llvm-version.h

Lines 13 to 15 in 85abe47

#if JL_LLVM_VERSION < 170000
#error Only LLVM versions >= 17.0.0 are supported by Julia
#endif

and there are many checks like this in the codebase already, so this is consistent and should probably be removed wholesale in another PR?

julia/src/aotcompile.cpp

Lines 1654 to 1658 in 85abe47

#if JL_LLVM_VERSION >= 180000
auto type = asm_ ? CodeGenFileType::AssemblyFile : CodeGenFileType::ObjectFile;
#else
auto type = asm_ ? CGFT_AssemblyFile : CGFT_ObjectFile;
#endif

julia/src/aotcompile.cpp

Lines 2348 to 2353 in 85abe47

#if JL_LLVM_VERSION >= 180000
sysdata->setCodeModel(CodeModel::Large);
#else
if (TheTriple.isX86() && TheTriple.isArch64Bit() && TheTriple.isOSLinux())
sysdata->setSection(".ldata");
#endif

julia/src/aotcompile.cpp

Lines 2634 to 2638 in 85abe47

#if JL_LLVM_VERSION >= 180000
#define WritingMode SymtabWritingMode::NormalSymtab
#else
#define WritingMode true
#endif

julia/src/disasm.cpp

Lines 1283 to 1287 in 85abe47

#if JL_LLVM_VERSION >= 180000
if (TM->addPassesToEmitFile(PM, obj_OS, nullptr, CodeGenFileType::ObjectFile, false, nullptr))
#else
if (TM->addPassesToEmitFile(PM, obj_OS, nullptr, CGFT_ObjectFile, false, nullptr))
#endif

julia/src/jitlayers.cpp

Lines 28 to 32 in 85abe47

#if JL_LLVM_VERSION >= 180000
#include <llvm/ExecutionEngine/Orc/Debugging/DebugInfoSupport.h>
#include <llvm/ExecutionEngine/Orc/Debugging/PerfSupportPlugin.h>
#include <llvm/ExecutionEngine/Orc/TargetProcess/JITLoaderPerf.h>
#endif

julia/src/jitlayers.cpp

Lines 74 to 78 in 85abe47

#if JL_LLVM_VERSION >= 180000
# include <llvm/ExecutionEngine/Orc/Debugging/DebuggerSupportPlugin.h>
#else
# include <llvm/ExecutionEngine/Orc/DebuggerSupportPlugin.h>
#endif

julia/src/jitlayers.cpp

Lines 623 to 647 in 85abe47

#if JL_LLVM_VERSION >= 180000
CodeGenOptLevel CodeGenOptLevelFor(int optlevel)
{
#ifdef DISABLE_OPT
return CodeGenOptLevel::None;
#else
return optlevel == 0 ? CodeGenOptLevel::None :
optlevel == 1 ? CodeGenOptLevel::Less :
optlevel == 2 ? CodeGenOptLevel::Default :
CodeGenOptLevel::Aggressive;
#endif
}
#else
CodeGenOpt::Level CodeGenOptLevelFor(int optlevel)
{
#ifdef DISABLE_OPT
return CodeGenOpt::None;
#else
return optlevel == 0 ? CodeGenOpt::None :
optlevel == 1 ? CodeGenOpt::Less :
optlevel == 2 ? CodeGenOpt::Default :
CodeGenOpt::Aggressive;
#endif
}
#endif

julia/src/jitlayers.cpp

Lines 2375 to 2389 in 85abe47

#if JL_LLVM_VERSION >= 180000
if (TM->getTargetTriple().isOSBinFormatELF()) {
orc::SymbolMap PerfFunctions;
auto StartAddr = addAbsoluteToMap(PerfFunctions,llvm_orc_registerJITLoaderPerfStart);
auto EndAddr = addAbsoluteToMap(PerfFunctions,llvm_orc_registerJITLoaderPerfEnd);
auto ImplAddr = addAbsoluteToMap(PerfFunctions,llvm_orc_registerJITLoaderPerfImpl);
cantFail(JD.define(orc::absoluteSymbols(PerfFunctions)));
ObjectLayer.addPlugin(cantFail(DebugInfoPreservationPlugin::Create()));
//ObjectLayer.addPlugin(cantFail(PerfSupportPlugin::Create(
// ES.getExecutorProcessControl(), *JD, true, true)));
bool EmitDebugInfo = true, EmitUnwindInfo = true;
ObjectLayer.addPlugin(std::make_unique<PerfSupportPlugin>(
ES.getExecutorProcessControl(), StartAddr, EndAddr, ImplAddr, EmitDebugInfo, EmitUnwindInfo));
}
#endif

julia/src/jitlayers.cpp

Lines 2849 to 2853 in 85abe47

#if JL_LLVM_VERSION >= 180000
" .ltext,\"ax\",@progbits\n"
#else
" .text\n"
#endif

julia/src/jitlayers.cpp

Lines 2875 to 2879 in 85abe47

#if JL_LLVM_VERSION >= 180000
".ltext;\n" // Section-relative offset (if using COFF and JITLink, this can be relative to __ImageBase instead, though then we could possibly use pdata/xdata directly then)
#else
".text;\n"
#endif

julia/src/jitlayers.h

Lines 987 to 991 in 85abe47

#if JL_LLVM_VERSION >= 180000
CodeGenOptLevel CodeGenOptLevelFor(int optlevel) JL_NOTSAFEPOINT;
#else
CodeGenOpt::Level CodeGenOptLevelFor(int optlevel) JL_NOTSAFEPOINT;
#endif

julia/src/julia_internal.h

Lines 117 to 119 in 85abe47

#elif defined(_CPU_AARCH64_) || defined(_CPU_RISCV64_) || (JL_LLVM_VERSION >= 180000 && (defined(_CPU_X86_64_) || defined(_CPU_X86_)) || (JL_LLVM_VERSION >= 200000 && defined(_CPU_PPC64_)))
// int128 is 16 bytes aligned on aarch64 and riscv, and on x86 with LLVM >= 18 and on ppc64 with LLVM >= 20
#define MAX_ALIGN 16

julia/src/julia_internal.h

Lines 127 to 131 in 85abe47

#if JL_LLVM_VERSION >= 180000
#define MAX_ALIGN 16
#else
#define MAX_ALIGN 8
#endif

julia/src/llvm_api.cpp

Lines 49 to 55 in 85abe47

#if JL_LLVM_VERSION >= 180000
DEFINE_SIMPLE_CONVERSION_FUNCTIONS(orc::SymbolStringPoolEntryUnsafe::PoolEntry,
LLVMOrcSymbolStringPoolEntryRef)
#else
DEFINE_SIMPLE_CONVERSION_FUNCTIONS(orc::OrcV2CAPIHelper::PoolEntry,
LLVMOrcSymbolStringPoolEntryRef)
#endif

julia/src/llvm_api.cpp

Lines 111 to 115 in 85abe47

#if JL_LLVM_VERSION >= 180000
return wrap(orc::SymbolStringPoolEntryUnsafe::take(unwrap(JIT)->mangle(Name)).rawPtr());
#else
return wrap(orc::OrcV2CAPIHelper::moveFromSymbolStringPtr(unwrap(JIT)->mangle(Name)));
#endif

@KristofferC

Copy link
Copy Markdown
Member Author

@nanosoldier runbenchmarks()

@gbaraldi

Copy link
Copy Markdown
Member

Right, we haven't removed the old ones but shouldn't add new ones

@KristofferC

KristofferC commented Oct 13, 2026 •

Copy link
Copy Markdown
Member Author

It's just a quite odd situation where we have a header file that claims v17 support, a bunch of v18 version checks, but no code is supposed to not add them... And adding them "makes no sense"?

@gbaraldi

Copy link
Copy Markdown
Member

We don't actually support LLVM 18, so those branches are just dead and got forgotten. (I have a robot doing the cleanup right now)

@KristofferC

Copy link
Copy Markdown
Member Author

Yeah, I was just pushing back on the "makes sense" part.

@gbaraldi

Copy link
Copy Markdown
Member

-500 #63791 😸

@pkgeval

pkgeval commented Oct 13, 2026

Copy link
Copy Markdown

@KristofferC: run gh-6139757717 finished — possible new issues: 198 packages ❌; 2 newly skipped

Full report: https://pkgeval-reports.julialang.org/?run=gh-6139757717
Estimated compute cost: $31.70 (EC2 spot)

@KristofferC

This comment was marked as outdated.

@nanosoldier

Copy link
Copy Markdown
Collaborator

The benchmark job you requested has completed - possible performance regressions were detected. 4829 benchmarks were executed, 79 showed regressions, and 83 showed improvements.
Commits: c38b8a2 vs 04fa6fa (comparing against merge-base of master)
The full report is available.

@pkgeval

pkgeval commented Oct 14, 2026

Copy link
Copy Markdown

@KristofferC: run gh-6154693682 finished — possible new issues: 109 packages ❌

Full report: https://pkgeval-reports.julialang.org/?run=gh-6154693682
Estimated compute cost: $1.57 (EC2 spot)

@KristofferC

Copy link
Copy Markdown
Member Author

A lot of PkgEval noise due to JuliaCI/PkgEvalFarm.jl#63, rebasing this and rerunning should fix it.

Several passes in the default pipeline cost compile time without changing
the code we generate. Removed or replaced:

- The O3-only scalar `GVN` and the second scalar `DSE`. They run right
  after `GVN`/`DSE`-like passes in the same stage and find almost nothing
  new.
- `InferFunctionAttrsPass`. It only annotates known library calls, which
  Julia code rarely makes directly.
- `LibCallsShrinkWrapPass`, which targets C math library calls with `errno`.
- The `GVN` in the cleanup pipeline, now `EarlyCSE` with MemorySSA. After
  GC lowering there is little left for full `GVN` to do.

`AllocOpt` no longer runs through a CGSCC walk unless a plugin registered
CGSCC passes; without them the walk only costs call-graph construction.
`Float2Int` and `LowerConstantIntrinsics` move to the start of the vector
stage, where LLVM's own pipeline runs them.

`InferAlignment` is added after loop vectorization and after the
post-unroll `SROA`, as LLVM's pipeline does since LLVM 18.

Measured on the sysimage corpus, LLVM pass time drops 12% at O2 and 20% at
O3, and the CPU time of the sysimage output phase drops 6%. BaseBenchmarks
runtime is unchanged within noise on Apple M4 and AMD Zen 4.

Assisted-by: Claude Code (Opus 5.5)
After the intrinsic lowering passes, `GVN` mostly merges the address and load
computations that GC lowering emits per allocation, which `EarlyCSE` with
MemorySSA also handles. The cleanup `EarlyCSE` that followed becomes
redundant and is removed.

This trades `GVN`'s partial redundancy elimination and non-dominating load
elimination at this late point for compile time: LLVM pass time on the
sysimage drops a further 11% at O2 and 10% at O3, and the CPU time of the
sysimage output phase a further 3%. Generated code is 0.09% smaller.
BaseBenchmarks is unchanged within noise on Apple M4 and AMD Zen 4, as are
package workloads (DataFrames, CSV, OrdinaryDiffEq, Optim).

Assisted-by: Claude Code (Opus 5.5)
`IRCE` splits a short constant-trip inner loop under bounds checks (e.g.
`for v in 1:5`) into pre/main/post loops with variable trip counts. That
defeats the later `LoopFullUnroll`, and `LoopIdiomRecognize` instead turns
the main loop into a variable-length `memcpy` per outer iteration. Running
`LoopFullUnroll` once before `IRCE` only touches loops with a small
constant trip count, so it removes this case without changing other loops.

The kernel from JuliaLang#62798 goes from 269 µs to 84 µs (Apple M4). Compile cost
is +0.4% pass instructions at O2/O3 on the sysimage, and optimized code
size is unchanged (-0.01% text).

Fixes JuliaLang#62798

Assisted-by: Claude Code (Opus 5.5)
@KristofferC

This comment was marked as outdated.

@pkgeval

pkgeval commented Oct 14, 2026

Copy link
Copy Markdown

@KristofferC: run gh-6162282261 finished — possible new issues: 4 packages ❌

Full report: https://pkgeval-reports.julialang.org/?run=gh-6162282261
Estimated compute cost: $0.26 (EC2 spot)

@KristofferC KristofferC removed the needs pkgeval Tests for all registered packages should be run with this change label Oct 14, 2026
@KristofferC

Copy link
Copy Markdown
Member Author

PkgEval looks good, Nanosoldier looks good, just gonna confirm the TTFX run here and then it should be good to go.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

compiler:llvm For issues that relate to LLVM latency Latency

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Performance regressions since Julia 1.11

7 participants