Skip to content

Lower scalar and vector LLVM math intrinsics through device libraries - #3433

Draft
maleadt wants to merge 10 commits into
mainfrom
tb/llvm-math-intrinsics
Draft

maleadt wants to merge 10 commits into
mainfrom
tb/llvm-math-intrinsics

Conversation

@maleadt

@maleadt maleadt commented Oct 20, 2026 •

Copy link
Copy Markdown
Member

Math functions on vectors fail to compile today (JuliaGPU/GPUCompiler.jl#679), because NVPTX can't select LLVM's math intrinsics, and our overlays only cover scalar Julia calls. This PR lets those intrinsics survive optimization, and lowers them afterwards with GPUCompiler's new lower_intrinsics! hook (JuliaGPU/GPUCompiler.jl#1044). The hook scalarizes vector calls and turns each intrinsic into a call to libdevice, or, for Float16 and BFloat16, to a Julia library that ports NVIDIA's half-precision routines. The resulting references are resolved like any other device-library function. Raw llvmcalls and SIMD.jl code get the same treatment.

Libraries are now declared through device_libraries(job): the CUDA runtime, the half-precision math library, and libdevice. Libdevice is linked before optimization, as before. The half-precision library is linked late, because the references to it only appear during lowering.

Overlays kept as a workaround

LLVM's cost model treats intrinsics as cheap, even when the target will lower them to library calls. Loops over math intrinsics therefore get unrolled before they are lowered. For some functions the lowered code is also worse: the slow path of sin/cos keeps its array in local memory. To avoid regressions, the following functions keep calling libdevice directly, each marked # COST MODEL in the code:

  • sin and cos for Float32 and Float64, including the fast variants;
  • tan, sincos, atan2 and pow for Float32;
  • the Float16 and BFloat16 functions that main computes in Float32.

Without them, for example, a Float32 loop summing sin grows from 123 to 566 SASS instructions and spills to local memory, and sin of large arguments is 65% slower. The late lowering still supports all of these operations, and a new test compiles every operation through the raw intrinsic and compares the result bit for bit with the device function. The overlays can be removed once LLVM's cost model accounts for these calls.

All other math functions go through intrinsics. They are neutral or faster: Float32 log loops are about 10% faster; Float16 exp and log are about 30% faster, and Float16 pow is 3.5× faster.

Other fixes

  • The @fastmath intrinsic wrapper used the full-module form of llvmcall. Julia doesn't mark that alwaysinline, so every @fastmath call stayed out of line. The IR now sets the attribute. It also called CUDACore.fill instead of Base.fill.
  • The half-precision helpers are now device functions, so compiling everything on the host no longer trips over their inline assembly.
  • FastMath.tanh_fast(::Float16) is inlined again: the added PTX ISA check made it too large for Julia's inliner, which made @fastmath tanh 1.86× slower in loops.
  • @fastmath exp/exp10 on Float64 use Julia's implementation again, which is faster than __nv_exp.

Validation

On an RTX 5080, Julia 1.12 and 1.13, compared to main:

  • Tests: the core group passes, as do the llvm_math tests (226 on 1.13).
  • Generated code: SASS is identical across our codegen kernels, except:
    • exp on Float16 is shorter (33 → 22 instructions, no call);
    • pow on Float64 grows from 235 to 239 instructions;
    • vector math compiles, where main fails.
  • Runtime: every benchmarked kernel is within ±1%.
  • Launch overhead: unchanged. cufunction takes about 200 ns, @cuda about 1.7 µs, with the same allocations.
  • First compile: a first @cuda is 4% slower on 1.12 (1.60 vs 1.53 s) and 8% slower on 1.13 (1.09 vs 1.01 s).

Depends on JuliaGPU/GPUCompiler.jl#1044. The [sources] pins in Project.toml, CUDACore/Project.toml and test/Project.toml still point at an old revision of that PR and need to be updated, then replaced by a compat bound once it is released.

@maleadt

maleadt commented Oct 20, 2026

Copy link
Copy Markdown
Member Author

FWIW, the motivation here is to allow LLVM to const-prop things like sin(1f0).

Expose the pin in the root environment for Julia versions without workspaces and read URL sources explicitly in the Julia 1.10 CI setup. Keep SIMD test dependencies sorted and bounded.
Track the resolver fix for linkonce exports and the public bitcode metadata snapshot accessor added while migrating AMDGPU.
Use the infrastructure revision tested with typed-pointer ROCm imports, keeping both backend migrations on the same dependency revision.
GPUCompiler's lower_intrinsics! hook now only takes the job and module, and device
libraries are imported before optimization as well as afterwards. The runtime is warmed
through load_runtime again, as selected_device_libraries and load_device_library are no
longer public.
`fill` resolved to CUDACore's array constructor in the generated function, so every
`@fastmath` math call failed to compile. The wrapper's llvmcall entry also needs to be
`alwaysinline`: Julia does not mark full-module llvmcall functions as such, so the call
stayed out of line.
They contain inline PTX, so compiling them for the host (e.g. during ahead-of-time
compilation) fails. Like other intrinsics, define them with `@device_functions`; the
device library still finds them through the method table.
LLVM costs math intrinsics that are lowered to calls as a single instruction, so loops
calling them are unrolled before the libdevice implementation is substituted, and that
implementation is not optimized along with the kernel anymore. For `sin`, `cos`, `tan`
and `sincos` this leaves the slow-path reduction's array in local memory, and `atan` (two
arguments), `pow` and the Float64 trigonometric functions run slower in loops. Keep calling
libdevice directly for those, compute the affected half-precision functions in Float32 as
before, and keep using Julia's faster `exp_fast` and `exp10_fast` for Float64.

The late lowering still supports all of these intrinsics; test that every scalar
intrinsic lowers to what the corresponding function computes.
The added PTX ISA check made the method too large for Julia's inliner, leaving an
out-of-line call that halves the throughput of `@fastmath tanh` on Float16.
@maleadt
maleadt force-pushed the tb/llvm-math-intrinsics branch from 58281cb to 8ba4839 Compare October 21, 2026 11:34
Pin GPUCompiler to the tb/device-libraries branch rather than a commit, so the
source follows JuliaGPU/GPUCompiler.jl#1044 as it is rebased.
@codecov

codecov Bot commented Oct 23, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 84.69751% with 43 lines in your changes missing coverage. Please review.
✅ Project coverage is 39.40%. Comparing base (44ac4eb) to head (0a475b4).
⚠️ Report is 11 commits behind head on main.

Files with missing lines Patch % Lines
CUDACore/src/device/intrinsics/math.jl 65.95% 32 Missing ⚠️
CUDACore/src/compiler/math.jl 90.90% 8 Missing ⚠️
CUDACore/src/device/intrinsics/half_math.jl 96.93% 3 Missing ⚠️
Additional details and impacted files
@@             Coverage Diff             @@
##             main    #3433       +/-   ##
===========================================
- Coverage   87.46%   39.40%   -48.07%     
===========================================
  Files         191      193        +2     
  Lines       19646    19157      -489     
===========================================
- Hits        17184     7548     -9636     
- Misses       2462    11609     +9147     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

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

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant