TTSX: Recompilation on every run

Colleagues and I are developing Muscade.jl. Muscade is deliberately putting a high workload on the Julia 1.11 compiler. I have done nothing with module precompilation so far. Thus I have a significant TTFX, but this is not our topic today (I’ll use .

What puzzles me is that re-running the same analysis still triggers considerable compilation. TTSX !?! I study this with SnoopCompile.jl. My findings are

  1. There seems to be zero invalidations on the second run: invs = @snoop_invalidations using Muscade, Test, StaticArrays,SparseArrays; returns an empty array.
  2. Using @snoop_inference, I find that most of the recompilation concerns Muscade code (on first execution, quite a few instances of Julia methods where created). The code is mostly linear algebra (StaticArrays) and other maths, specialised for automatic differentiation. However I am fairly convinced these are recompilation of instances already created on the first run. I my mind, this is supported by the “zero invalidation” finding (inference makes out 6 seconds of TTSX of 4 minutes).
  3. One class of entries in the flattened inference list puzzles me (look at the end of this line):
InferenceTiming: 0.010487/0.040255 on (::StaticArrays.var"#280#281"{∂ℝ{3, 1, ∂ℝ{2, 24, ∂ℝ{1, 24, Float64}}}})(∂ℝ{3, 1, ∂ℝ{2, 24, ∂ℝ{1, 24, Float64}}}(∂ℝ{2, 24, ∂ℝ{1, 24, Float64}}(∂ℝ{1, 24, Float64}(0.0, [0.0, 0.0, 0.0, 0.0, 0.0,...

How should I interpret StaticArrays.var"#280#281"?

∂ℝ is my homebrew forward automatic differentiation (I know, but ForwardDiff.jl did not exist yet when I started on this…), but why does 0.0, [0.0, 0.0, 0.0, 0.0, 0.0,..., a value of an automatic differentiation variable ever appear in the analysis of a compilation process? Is this a clue to something triggering recompilation, or is this irrelevant to the recompilation issue?

Do you know what triggers recompilation? Are there tools/techniques I could use to understand what triggers recompilation?

:grinning_face:

Can you share a small example? Below in your post you only show imports but this sounds different.

I suppose if you have closure or anonymous function created, it might trigger re-compilation, and depending on how much you do with them, it might accumulate to something visible to users?

I guess it’s a capture of the closure that is getting inferred. Julia includes the values of the captures when printing closures.

You might wish to look into the command-line options:

  • --trace-compile

  • --trace-compile-timing

  • --trace-dispatch

There’s also matching macros:

  • @trace_compile

  • @trace_dispatch

Some of that functionality requires at least Julia v1.12 (in beta). See the in-development version of the Performance tips, section “Execution latency, package loading and package precompiling time”:

As someone else says, possibly you’re creating new closures on each run, so Julia has to infer them anew?

I wish I knew how to isolate the problem! (I’d be halfway to the solution :slight_smile: ).

Because you asked, I pushed a commit 49a5d9bbca18e14ec6ad42b65b31e91e5b1fc0ae to branch performance, and script /test/TestEigXU.jl is where the action is.

But we agree, I do not expect you or anyone to go through all this, this will be quite time consuming.

I will explore this further.

  1. The top script (that calls the Muscade functionality) typically creates anonymous functions (in the global scope) passed on to constructors. As I understand it, each time the function definition is parsed (each time I run the script), this redefines the anonymous function, giving it a new type, triggering recompilation.
  2. Within Muscade code, methods that take a function as argument also have default aargument values
  3. There are do blocks here and there in the code.

Hmm, I have two “howevers”, however.

  1. If a module is unchanged since last compilation, I imagine that an anonymous function definition, is not reparsed and thus keeps its type and does not trigger recompilation of methods it is passed to. Or?
  2. Among the functions recompiled are functions high on the call stack that are untouched by any anonymous functions.

Great tip, I will try this out! :slight_smile:

1 Like

That’s not an issue, Julia knows the identity is the same, see for yourself:

julia> f(a = x -> 3*x) = a
f (generic function with 2 methods)

julia> a = f()
#f##0 (generic function with 1 method)

julia> b = f()
#f##0 (generic function with 1 method)

julia> a === b
true

That might be an issue, depending on the location of the do block. If it appears as a global statement, I guess it’s creating a new function (with a new identity) each time the statement is run. If, on the other hand, the do block is part of a larger function body, it should not be an issue.

Here’s an example where that’s not the case:

julia> a = x -> 3*x
#2 (generic function with 1 method)

julia> b = x -> 3*x
#5 (generic function with 1 method)

julia> a === b
false

Yepp, I think this is at play when anonymous functions are defined in a script and passed to Muscade-functions, and the script is reparsed: anything in muscade that takes these anonymous functions as input will recompile.

But I have loads of Muscade-functions not touched by that that recompile.

Helmet, head torch… if I’m not back by monday morning, you’ll know where to look for me! :smiley:

2 Likes