Skip to content

Commit 7f4dc5e

Browse files
authored
Repair invalidations during precompilation (#14)
This allows one to monitor invalidations and recompile them. The main targets are "Startup" packages that load other packages that may not know about one another, although there does not appear to be any reason that "regular" packages couldn't use it too. This also enhances the didactic portions of the documentation, most notably by adding an explanation of invalidation.
1 parent 327d359 commit 7f4dc5e

File tree

8 files changed

+471
-155
lines changed

8 files changed

+471
-155
lines changed

docs/make.jl

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ makedocs(;
1616
),
1717
pages=[
1818
"Home" => "index.md",
19+
"Invalidations" => "invalidations.md",
20+
"How PrecompileTools works" => "explanations.md",
1921
"Reference" => "reference.md",
2022
],
2123
)

docs/src/explanations.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
# How PrecompileTools works
2+
3+
Julia itself has a function `precompile`, to which you can pass specific signatures to force precompilation.
4+
For example, `precompile(foo, (ArgType1, ArgType2))` will precompile `foo(::ArgType1, ::ArgType2)` *and all of its inferrable callees*.
5+
Alternatively, you can just execute some code at "top level" within the module, and during precompilation any method or signature "owned" by your package will also be precompiled.
6+
Thus, base Julia itself has substantial facilities for precompiling code.
7+
8+
## The `workload` macros
9+
10+
`@compile_workload` adds one key feature: the *non-inferrable callees* (i.e., those called via runtime dispatch) that get
11+
made inside the `@compile_workload` block will also be cached, *regardless of module ownership*. In essence, it's like you're adding
12+
an explicit `precompile(noninferrable_callee, (OtherArgType1, ...))` for every runtime-dispatched call made inside `@compile_workload`.
13+
14+
These `workload` macros add other features as well:
15+
16+
- Statements that occur inside a `@compile_workload` block are executed only if the package is being actively precompiled; it does not run when the package is loaded, nor if you're running Julia with `--compiled-modules=no`.
17+
- Compared to just running some workload at top-level, `@compile_workload` ensures that your code will be compiled (it disables the interpreter inside the block)
18+
- PrecompileTools also defines `@setup_workload`, which you can use to create data for use inside a `@compile_workload` block. Like `@compile_workload`, this code only runs when you are precompiling the package, but it does not necessarily result in the `@setup_workload` code being stored in the package precompile file.
19+
20+
## `@recompile_invalidations`
21+
22+
`@recompile_invalidations` activates logging of invalidations before executing code in the block.
23+
It then parses the log to extract the "leaves" of the trees of invalidations, which generally represent
24+
the top-level calls (typically made by runtime dispatch). It then triggers their recompilation.
25+
Note that the recompiled code may return different results than the original (this possibility is
26+
why the code had to be invalidated in the first place).

docs/src/index.md

Lines changed: 78 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,10 @@ The main tool in `PrecompileTools` is a macro, `@compile_workload`, which precom
1212
It also includes a second macro, `@setup_workload`, which can be used to "mark" a block of code as being relevant only
1313
for precompilation but which does not itself force compilation of `@setup_workload` code. (`@setup_workload` is typically used to generate
1414
test data using functions that you don't need to precompile in your package.)
15+
Finally, `PrecompileTools` includes `@recompile_invalidations` to mitigate the undesirable consequences of *invalidations*.
16+
These different tools are demonstrated below.
1517

16-
## Tutorial
18+
## Tutorial: forcing precompilation with workloads
1719

1820
No matter whether you're a package developer or a user looking to make your own workloads start faster,
1921
the basic workflow of `PrecompileTools` is the same.
@@ -139,6 +141,81 @@ All the packages will be loaded, together with their precompiled code.
139141
!!! tip
140142
If desired, the [Reexport package](https://github.com/simonster/Reexport.jl) can be used to ensure these packages are also exported by `Startup`.
141143

144+
## Tutorial: "healing" invalidations
145+
146+
Julia sometimes *invalidates* previously compiled code (see [Why does Julia invalidate code?](@ref)).
147+
PrecompileTools provides a mechanism to recompile the invalidated code so that you get the full benefits
148+
of precompilation. This capability can be used in "Startup" packages (like the one described
149+
above), as well as by package developers.
150+
151+
!!! tip
152+
Excepting [piracy](https://docs.julialang.org/en/v1/manual/style-guide/#Avoid-type-piracy) (which is heavily discouraged),
153+
*type-stable (i.e., well-inferred) code cannot be invalidated.* If invalidations are a problem, an even better option
154+
than "healing" the invalidations is improving the inferrability of the "victim": not only will you prevent
155+
invalidations, you may get faster performance and slimmer binaries. Packages that can help identify
156+
inference problems and invalidations include [SnoopCompile](https://github.com/timholy/SnoopCompile.jl),
157+
[JET](https://github.com/aviatesk/JET.jl), and [Cthulhu](https://github.com/JuliaDebug/Cthulhu.jl).
158+
159+
The basic usage is simple: wrap expressions that might invalidate with `@recompile_invalidations`.
160+
Invalidation can be triggered by defining new methods of external functions, including during
161+
package loading. Using the "Startup" package above, you might wrap the `using` statements:
162+
163+
```julia
164+
module Startup
165+
166+
using PrecompileTools
167+
@recompile_invalidations begin
168+
using LotsOfPackages...
169+
end
170+
171+
# Maybe a @compile_workload here?
172+
173+
end
174+
```
175+
176+
Note that recompiling invalidations can be useful even if you don't add any additional workloads.
177+
178+
Alternatively, if you're a package developer worried about "collateral damage" you may cause by extending functions
179+
owned by Base or other package (i.e., those that require `import` or module-scoping when defining the method),
180+
you can wrap those method definitions:
181+
182+
```julia
183+
module MyContainers
184+
185+
using AnotherPackage
186+
using PrecompileTools
187+
188+
struct Container
189+
list::Vector{Any}
190+
end
191+
192+
# This is a function created by this package, so it doesn't need to be wrapped
193+
make_container() = Container([])
194+
195+
@recompile_invalidations begin
196+
# Only those methods extending Base or other packages need to go here
197+
Base.push!(obj::Container, x) = ...
198+
function AnotherPackage.foo(obj::Container)
199+
200+
end
201+
end
202+
203+
end
204+
```
205+
206+
You can have more than one `@recompile_invalidations` block in a module. For example, you might use one to wrap your
207+
`using`s, and a second to wrap your method extensions.
208+
209+
!!! warning
210+
Package developers should be aware of the tradeoffs in using `@recompile_invalidations` to wrap method extensions:
211+
212+
- the benefit is that you might deliver a better out-of-the-box experience for your users, without them needing to customize anything
213+
- the downside is that it will increase the precompilation time for your package. Worse, what can be invalidated once can sometimes be invalidated again by a later package, and if that happens the time spent recompiling is wasted.
214+
215+
Using `@recompile_invalidations` in a "Startup" package is, in a sense, safer because it waits for all the code to be loaded before recompiling anything. On the other hand, this requires users to implement their own customizations.
216+
217+
Package developers are encouraged to try to fix "known" invalidations rather than relying reflexively on `@recompile_invalidations`.
218+
142219
## When you can't run a workload
143220

144221
There are cases where you might want to precompile code but cannot safely *execute* that code: for example, you may need to connect to a database, or perhaps this is a plotting package but you may be currently on a headless server lacking a display, etc.
@@ -198,20 +275,3 @@ julia> include("src/MyPackage.jl");
198275
This will only show the direct- or runtime-dispatched method instances that got precompiled (omitting their inferrable callees).
199276
For a more comprehensive list of all items stored in the compile_workload file, see
200277
[PkgCacheInspector](https://github.com/timholy/PkgCacheInspector.jl).
201-
202-
## How PrecompileTools works
203-
204-
Julia itself has a function `precompile`, to which you can pass specific signatures to force precompilation.
205-
For example, `precompile(foo, (ArgType1, ArgType2))` will precompile `foo(::ArgType1, ::ArgType2)` *and all of its inferrable callees*.
206-
Alternatively, you can just execute some code at "top level" within the module, and during precompilation any method or signature "owned" by your package will also be precompiled.
207-
Thus, base Julia itself has substantial facilities for precompiling code.
208-
209-
`@compile_workload` adds one key feature: the *non-inferrable callees* (i.e., those called via runtime dispatch) that get
210-
made inside the `@compile_workload` block will also be cached, *regardless of module ownership*. In essence, it's like you're adding
211-
an explicit `precompile(noninferrable_callee, (OtherArgType1, ...))` for every runtime-dispatched call made inside `@compile_workload`.
212-
213-
`PrecompileTools` adds other features as well:
214-
215-
- Statements that occur inside a `@compile_workload` block are executed only if the package is being actively precompiled; it does not run when the package is loaded, nor if you're running Julia with `--compiled-modules=no`.
216-
- Compared to just running some workload at top-level, `@compile_workload` ensures that your code will be compiled (it disables the interpreter inside the block)
217-
- PrecompileTools also defines `@setup_workload`, which you can use to create data for use inside a `@compile_workload` block. Like `@compile_workload`, this code only runs when you are precompiling the package, but it does not necessarily result in the `@setup_workload` code being stored in the package precompile file.

docs/src/invalidations.md

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
# Why does Julia invalidate code?
2+
3+
Julia may be unique among computer languages in supporting all four of the following features:
4+
5+
1. interactive development
6+
2. "method overloading" by packages that don't own the function
7+
3. aggressive compilation
8+
4. consistent compilation: same result no matter how you got there
9+
10+
The combination of these features *requires* that you sometimes "throw away" code that you have previously compiled.
11+
12+
To illustrate: suppose you have a function `numchildren` with one method,
13+
14+
```
15+
numchildren(::Any) = 1
16+
```
17+
18+
and then write
19+
20+
```
21+
total_children(list) = sum(numchildren.(list))
22+
```
23+
24+
Now let `list` be a `Vector{Any}`. You can compile a fast `total_children(::Vector{Any})` (*aggressive compilation*) by leveraging the fact that you know there's only one possible method of `numchildren`, and you know that it returns 1
25+
for every input. Thus, `total_children(list)` gives you just `length(list)`, which would indeed be a very highly-optimized implementation!
26+
27+
But now suppose you add a second method (*interactive development* + *method overloading*)
28+
29+
```
30+
numchildren(::BinaryNode) = 2
31+
```
32+
33+
where `BinaryNode` is a new type you've defined (so it's not type-piracy). If you want to get the right answer (*consistent compilation*) from an arbitrary `list::Vector{Any}`, there are only two options:
34+
35+
> **Option A**: plan for this eventuality from the beginning, by making every `numchildren(::Any)` be called by runtime dispatch. But when there is only one method of `numchildren`, forcing runtime dispatch makes the code vastly slower. Thus, this option at least partly violates *aggressive compilation*.
36+
37+
> **Option B**: throw away the code for `total_children` that you created when there was only one method of `numchildren`, and recompile it in this new world where there are two.
38+
39+
Julia does a mix of these: it does **B** up to 3 methods, and then **A** thereafter. (Recent versions of Julia have experimental support for customizing this behavior with `Base.Experimental.@max_methods`.)
40+
41+
This example was framed as an experiment at the REPL, but it is also relevant if you load two packages: `PkgX` might define `numchildren` and `total_children`, and `PkgY` might load `PkgX` and define a second method of `PkgX.numchildren`.
42+
Any precompilation that occurs in `PkgX` doesn't know what's going to happen in `PkgY`.
43+
Therefore, unless you want to defer *all* compilation, including for Julia itself, until the entire session is loaded and then closed to further extension (similar to how compilers for C, Rust, etc. work), you have to make the same choice between options **A** and **B**.
44+
45+
Given that invalidation is necessary if Julia code is to be both fast and deliver the answers you expect, invalidation is a good thing!
46+
But sometimes Julia "defensively" throws out code that might be correct but can't be proved to be correct by Julia's type-inference machinery; such cases of "spurious invalidation" serve to (uselessly) increase latency and worsen the Julia experience.
47+
Except in cases of [piracy](https://docs.julialang.org/en/v1/manual/style-guide/#Avoid-type-piracy), invalidation is a risk
48+
only for poorly-inferred code. With our example of `numchildren` and `total_children` above, the invalidations were necessary because `list` was
49+
a `Vector{Any}`, meaning that the elements might be of `Any` type and therefore Julia can't predict in advance which
50+
method(s) of `numchildren` would be applicable. Were one to create `list` as, say, `list = Union{BinaryNode,TrinaryNode}[]` (where `TrinaryNode` is some other kind of object with children), Julia would know much more
51+
about the types of the objects to which it applies `numchildren`: defining yet another new method like `numchildren(::ArbitraryNode)` would not trigger invalidations of code
52+
that was compiled for a `list::Vector{Union{BinaryNode,TrinaryNode}}`.

src/PrecompileTools.jl

Lines changed: 11 additions & 137 deletions
Original file line numberDiff line numberDiff line change
@@ -3,151 +3,25 @@ module PrecompileTools
33
if VERSION >= v"1.6"
44
using Preferences
55
end
6-
export @setup_workload, @compile_workload
6+
export @setup_workload, @compile_workload, @recompile_invalidations
77

88
const verbose = Ref(false) # if true, prints all the precompiles
99
const have_inference_tracking = isdefined(Core.Compiler, :__set_measure_typeinf)
1010
const have_force_compile = isdefined(Base, :Experimental) && isdefined(Base.Experimental, Symbol("#@force_compile"))
1111

12-
function workload_enabled(mod::Module)
13-
try
14-
load_preference(mod, "precompile_workload", true)
15-
catch
16-
true
17-
end
12+
function precompile_mi(mi)
13+
precompile(mi.specTypes) # TODO: Julia should allow one to pass `mi` directly (would handle `invoke` properly)
14+
verbose[] && println(mi)
15+
return
1816
end
1917

20-
"""
21-
check_edges(node)
22-
23-
Recursively ensure that all callees of `node` are precompiled. This is (rarely) necessary
24-
because sometimes there is no backedge from callee to caller (xref https://github.com/JuliaLang/julia/issues/49617),
25-
and `staticdata.c` relies on the backedge to trace back to a MethodInstance that is tagged `mi.precompiled`.
26-
"""
27-
function check_edges(node)
28-
parentmi = node.mi_info.mi
29-
for child in node.children
30-
childmi = child.mi_info.mi
31-
if !(isdefined(childmi, :backedges) && parentmi childmi.backedges)
32-
precompile(childmi.specTypes)
33-
end
34-
check_edges(child)
35-
end
36-
end
37-
38-
function precompile_roots(roots)
39-
@assert have_inference_tracking
40-
for child in roots
41-
mi = child.mi_info.mi
42-
precompile(mi.specTypes) # TODO: Julia should allow one to pass `mi` directly (would handle `invoke` properly)
43-
verbose[] && println(mi)
44-
check_edges(child)
45-
end
46-
end
47-
48-
"""
49-
@compile_workload f(args...)
50-
51-
`precompile` (and save in the compile_workload file) any method-calls that occur inside the expression. All calls (direct or indirect) inside a
52-
`@compile_workload` block will be cached.
53-
54-
`@compile_workload` has three key features:
55-
56-
1. code inside runs only when the package is being precompiled (i.e., a `*.ji`
57-
precompile compile_workload file is being written)
58-
2. the interpreter is disabled, ensuring your calls will be compiled
59-
3. both direct and indirect callees will be precompiled, even for methods defined in other packages
60-
and even for runtime-dispatched callees (requires Julia 1.8 and above).
61-
62-
!!! note
63-
For comprehensive precompilation, ensure the first usage of a given method/argument-type combination
64-
occurs inside `@compile_workload`.
65-
66-
In detail: runtime-dispatched callees are captured only when type-inference is executed, and they
67-
are inferred only on first usage. Inferrable calls that trace back to a method defined in your package,
68-
and their *inferrable* callees, will be precompiled regardless of "ownership" of the callees
69-
(Julia 1.8 and higher).
70-
71-
Consequently, this recommendation matters only for:
72-
73-
- direct calls to methods defined in Base or other packages OR
74-
- indirect runtime-dispatched calls to such methods.
75-
"""
76-
macro compile_workload(ex::Expr)
77-
local iscompiling = if Base.VERSION < v"1.6"
78-
:(ccall(:jl_generating_output, Cint, ()) == 1)
79-
else
80-
:((ccall(:jl_generating_output, Cint, ()) == 1 && $PrecompileTools.workload_enabled(@__MODULE__)))
81-
end
82-
if have_force_compile
83-
ex = quote
84-
begin
85-
Base.Experimental.@force_compile
86-
$ex
87-
end
88-
end
89-
else
90-
# Use the hack on earlier Julia versions that blocks the interpreter
91-
ex = quote
92-
while false end
93-
$ex
94-
end
95-
end
96-
if have_inference_tracking
97-
ex = quote
98-
Core.Compiler.Timings.reset_timings()
99-
Core.Compiler.__set_measure_typeinf(true)
100-
try
101-
$ex
102-
finally
103-
Core.Compiler.__set_measure_typeinf(false)
104-
Core.Compiler.Timings.close_current_timer()
105-
end
106-
$PrecompileTools.precompile_roots(Core.Compiler.Timings._timings[1].children)
107-
end
108-
end
109-
return esc(quote
110-
if $iscompiling || $PrecompileTools.verbose[]
111-
$ex
112-
end
113-
end)
114-
end
115-
116-
"""
117-
@setup_workload begin
118-
vars = ...
119-
120-
end
121-
122-
Run the code block only during package precompilation. `@setup_workload` is often used in combination
123-
with [`@compile_workload`](@ref), for example:
124-
125-
@setup_workload begin
126-
vars = ...
127-
@compile_workload begin
128-
y = f(vars...)
129-
g(y)
130-
131-
end
132-
end
133-
134-
`@setup_workload` does not force compilation (though it may happen anyway) nor intentionally capture
135-
runtime dispatches (though they will be precompiled anyway if the runtime-callee is for a method belonging
136-
to your package).
137-
"""
138-
macro setup_workload(ex::Expr)
139-
local iscompiling = if Base.VERSION < v"1.6"
140-
:(ccall(:jl_generating_output, Cint, ()) == 1)
141-
else
142-
:((ccall(:jl_generating_output, Cint, ()) == 1 && $PrecompileTools.workload_enabled(@__MODULE__)))
18+
include("workloads.jl")
19+
if VERSION >= v"1.9.0-rc2"
20+
include("invalidations.jl")
21+
else
22+
macro recompile_invalidations(ex::Expr)
23+
return esc(ex)
14324
end
144-
return esc(quote
145-
let
146-
if $iscompiling || $PrecompileTools.verbose[]
147-
$ex
148-
end
149-
end
150-
end)
15125
end
15226

15327
end

0 commit comments

Comments
 (0)