Skip to content

Restore and modernize the precompile workload - #49

Merged
ChrisRackauckas merged 1 commit into
JuliaSIMD:mainfrom
ChrisRackauckas-Claude:precompile-workload
Aug 9, 2026
Merged

Restore and modernize the precompile workload#49
ChrisRackauckas merged 1 commit into
JuliaSIMD:mainfrom
ChrisRackauckas-Claude:precompile-workload

Conversation

@ChrisRackauckas-Claude

@ChrisRackauckas-Claude ChrisRackauckas-Claude commented Aug 9, 2026

Copy link
Copy Markdown

⚠️ Draft — please ignore until reviewed by @ChrisRackauckas.

The @setup_workload block at the bottom of src/TriangularSolve.jl has been sitting inside a #= ... =# since before 0.2.0, and PrecompileTools was not in [deps]. Nothing in this package precompiled. It also could not simply be uncommented: the block calls __init__(), and this module defines no __init__. Uncommenting it verbatim and precompiling gives

ERROR: LoadError: UndefVarError: `__init__` not defined in `TriangularSolve`

This restores it, modernizes it to the current API (the old block knew only about rdiv!-upper and ldiv!-lower matrix solves; the native lower-rdiv!/upper-ldiv! matrix drivers and the 0.2.5 vector kernels did not exist yet), and adds PrecompileTools to [deps]/[compat].

Why N = 8 is enough

div_dispatch!, div_dispatch_L! and _naive_vsolve! branch on runtime sizes, so type inference — which is whole-body — already covers the blocked, threaded and remainder branches from a single small call. A separate blocked-size workload case caches nothing extra. What is not free is anything that changes a type: the eltype, the wrapper (each ldiv! transposes its arguments, so the drivers respecialize), the unit diagonal, and the Val(true)/Val(false) thread flag. The workload is exactly the cross product of those.

Coverage, before and after

TTFX = in-process @elapsed of the first call in a fresh process, min over 5 processes, n = 8, Julia 1.12.6. Before = this branch's merge-base (identical to registered 0.2.5).

Entry point before F64 after F64 before F32 after F32
rdiv!(C, A, UpperTriangular, Val(false)) 3.907 s 0.00022 s 3.366 s 0.00039 s
rdiv!(C, A, UpperTriangular) (threaded) 4.010 s 0.00018 s 4.967 s 0.00018 s
rdiv!(C, A, UnitUpperTriangular, Val(false)) 4.381 s 0.00024 s 3.974 s 0.00023 s
ldiv!(C, LowerTriangular, A, Val(false)) 6.193 s 0.00024 s 8.013 s 0.00028 s
ldiv!(C, LowerTriangular, A) (threaded) 6.894 s 0.00024 s 8.706 s 0.00026 s
rdiv!(C, A, LowerTriangular, Val(false)) 4.903 s 0.00024 s 4.348 s 0.00041 s
rdiv!(C, A, LowerTriangular) (threaded) 5.321 s 0.00021 s 4.868 s 0.00020 s
ldiv!(C, UpperTriangular, A, Val(false)) 6.912 s 0.00025 s 8.755 s 0.00024 s
ldiv!(C, UpperTriangular, A) (threaded) 7.614 s 0.00017 s 9.460 s 0.00019 s
ldiv!(UpperTriangular, b::Vector) 0.214 s 0.00016 s 0.222 s 0.00020 s
ldiv!(LowerTriangular, b::Vector) 0.184 s 0.00017 s 0.194 s 0.00016 s
ldiv!(c, UnitLowerTriangular, b::Vector) 0.158 s 0.00018 s 0.178 s 0.00019 s

Every matrix entry point was previously uncached; the vector entry points (new in 0.2.5) too. Nothing in the table was covered before.

The cache reaches downstream unchanged

RecursiveFactorization's NotIPIV backsolve and LinearSolve's RFLU _rf_ldiv! both call these kernels with a plain Matrix factor, which is exactly what the workload caches. Holding RecursiveFactorization at the registered 0.2.29 and swapping only TriangularSolve:

first call, fresh process RF 0.2.29 + TS 0.2.5 RF 0.2.29 + this PR
ldiv!(F::LU{Float64,..,NotIPIV}, b::Vector) 0.261 s 0.022 s
ldiv!(F::LU{Float64,..,NotIPIV}, B::Matrix) 3.105 s 0.030 s
ldiv!(F::LU{Float32,..,NotIPIV}, b::Vector) 0.323 s 0.097 s
ldiv!(F::LU{Float32,..,NotIPIV}, B::Matrix) 3.667 s 0.110 s

No change to RecursiveFactorization was needed for that.

Cost

Package precompile wall time, and the pkgimage the workload produces. Precompile numbers are the min over 7 alternating off/on rounds on a loaded 128-core box, so treat them as upper bounds on the delta.

workload precompile pkgimage @elapsed using TriangularSolve (min of 5)
none (master) 3.7 s 0.63 MB 0.613 s
vectors only, both eltypes 8.6 s 1.96 MB
+ Float64 matrices 32.9 s 9.72 MB
+ Float32 matrices (this PR) 52.7 s 22.6 MB 0.721 s

Same thing measured the other way, on a genuinely cold JULIA_DEPOT_PATH (34 dependencies, nothing cached):

$ JULIA_DEPOT_PATH=<fresh> julia +1.12 --project=<fresh> -e 'using Pkg; Pkg.develop(path=...); Pkg.instantiate(); Pkg.precompile()'
master:    4186.8 ms ✓ TriangularSolve — 34 dependencies successfully precompiled in 74 seconds
this PR:  50977.3 ms ✓ TriangularSolve — 34 dependencies successfully precompiled in 117 seconds

So: +49 s precompile, once per depot/version; +0.108 s load, per process; −3.4 to −9.5 s on the first solve, per process. Load time is the only recurring cost and it is 30-90x smaller than the TTFX it buys back on any covered path.

Nothing was dropped as not paying for itself. The two levers a reviewer might want to pull, with their measured price:

  • Float32 matrices are the single largest item (+20 s precompile, +12.8 MB). They stay in because they have the largest TTFX saving in the table (up to 9.5 s — nothing at all was cached for Float32) and because LinearSolve dispatches its default band on eltype <: Union{Float32, Float64}, so this is a first-class path.
  • Vector kernels save only ~0.2 s each, the smallest saving here, for +4.9 s of precompile. They stay in because they are cheap in pkgimage terms and because they are the path RecursiveFactorization's NotIPIV backsolve and LinearSolve's RFLU _rf_ldiv! take, both of which call them with a plain Matrix — exactly the specializations cached here.

Verification

$ julia +1.12 --project=. -e 'using Pkg; Pkg.test()'
Test Summary:      |   Pass   Total      Time
TriangularSolve.jl | 149626  149626  26m20.7s
     Testing TriangularSolve tests passed

$ julia +1.10 --project=. -e 'using Pkg; Pkg.test()'
Test Summary:      |   Pass   Total      Time
TriangularSolve.jl | 149626  149626  18m26.8s
     Testing TriangularSolve tests passed

Both include the Aqua testset (Compat bounds, Piracy, Persistent tasks all pass — the new PrecompileTools entry carries a [compat] bound).

Downstream smoke check, since this PR adds a dependency: with this branch and RecursiveFactorization 0.2.30 both deved into one environment, RecursiveFactorization.lu(A, Val(false)) followed by ldiv!(F, b) / ldiv!(F, B) at n = 100 gives relative residuals 4.8e-16 / 4.5e-16 (Float64) and 2.3e-7 / 2.8e-7 (Float32).

Formatting: the added block is a fixed point of JuliaFormatter.format_text under this repo's .JuliaFormatter.toml (indent = 2, margin = 80, ...). I deliberately did not run the formatter over the whole file — the JuliaFormatter version available here reflows a lot of pre-existing code, and that mechanical churn does not belong in this diff.

Not verified

  • typos reports 8 pre-existing hits on the identifier Nd in src/TriangularSolve.jl (it wants And). They are on master and untouched here; this repo has no typos CI job.
  • Only x86-64 AVX2/AVX512 was exercised. Vector widths are pick_vector_width(T)-dependent, so the precompiled specializations are host-specific by construction (as they already were).
  • I did not measure the effect on downstream packages' own precompile times.
  • Note for CI budgeting: Pkg.test() precompiles a second time under --check-bounds=yes, so the +49 s is paid twice in a test run.

🤖 Generated with Claude Code

The `@setup_workload` block at the bottom of the module has been inside a
`#= ... =#` since before 0.2.0, and PrecompileTools was not in [deps], so no
kernel precompiled at all. The block also could not be uncommented as-is: it
calls `__init__()`, which this module does not define.

The restored workload covers all four wrapper families (both `rdiv!` and
`ldiv!` respecialize per family because `ldiv!` transposes its arguments),
unit and non-unit diagonals, the `Val(true)`/`Val(false)` thread variants,
both `Float32` and `Float64`, and the vector right-hand-side kernels added in
0.2.5.

Co-Authored-By: Chris Rackauckas <accounts@chrisrackauckas.com>
@codecov

codecov Bot commented Aug 9, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 95.22%. Comparing base (4e4f8f6) to head (daa92b2).
⚠️ Report is 3 commits behind head on main.

Additional details and impacted files
@@           Coverage Diff           @@
##             main      #49   +/-   ##
=======================================
  Coverage   95.22%   95.22%           
=======================================
  Files           1        1           
  Lines         942      942           
=======================================
  Hits          897      897           
  Misses         45       45           

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

@ChrisRackauckas
ChrisRackauckas marked this pull request as ready for review August 9, 2026 09:23
@ChrisRackauckas
ChrisRackauckas merged commit 31d7866 into JuliaSIMD:main Aug 9, 2026
7 checks passed
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.

2 participants