Add a new macro @outline, and use it in @assert.#57122
Add a new macro @outline, and use it in @assert.#57122
@outline, and use it in @assert.#57122Conversation
There was a problem hiding this comment.
I'm not sure if this file was the right place to put the @outline tests. It seems decent, but a bit oxymoronic. :D Please let me know if you have any better suggestions!
Macro usage: ```julia @BoundsCheck i > 1 && i <= len || @outline throw(BoundsError(x, i)) ``` This commit applies the above to Assertions, e.g.: ```julia julia> @macroexpand @Assert x != x "x == x: $x" :(if x != x nothing else #= REPL[3]:36 =# var"#17#outline"(x) = begin $(Expr(:meta, :noinline)) #= REPL[3]:36 =# (throw)((AssertionError)(((Main).Base.inferencebarrier((Main).Base.string))("x == x: $(x)"))) end #= REPL[3]:38 =# var"#17#outline"(x) end) ``` This can improve performance for fast code that uses assertions, e.g.: Before: ```julia julia> @Btime Base.Sort.WithoutMissingVector($(Any[1]))[$1] 3.041 ns (0 allocations: 0 bytes) 1 ``` After: ```julia julia> @Btime Base.Sort.WithoutMissingVector($(Any[1]))[$1] 2.250 ns (0 allocations: 0 bytes) 1 ``` The number of instructions in that function according to `@code_native` (on an aarch64 M2 MacBook) reduced from ~90 to ~40.
Co-authored-by: adienes <[email protected]>
2fb251c to
91876ea
Compare
| end | ||
| _free_vars(s::Symbol) = [s] | ||
| _free_vars(_) = [] | ||
| _free_vars(e::Expr) = isempty(e.args) ? [] : unique!(mapreduce(_free_vars, vcat, e.args)) |
There was a problem hiding this comment.
I think unique! is not defined yet so this fails to compile for me
although, what happens if you use outline as a zero-arg closure and don't pass the vars in explicitly at all? like
macro outline2(expr)
local fname = gensym(:outlined_expr)
quote
@noinline $(fname)() = $(esc(expr))
$(fname)()
end
end
comparing the @code_native of
foo1() = @outline rand() > 0.5 || throw(AssertionError("abc"))
foo2() = @outline2 rand() > 0.5 || throw(AssertionError("abc"))
it seems a lot simpler, but I'm not an expert at these things so maybe I'm missing something
There was a problem hiding this comment.
One difference is in how they handle type instability:
julia> function bar()
x = rand(Bool) ? 7 : 7.0
@outline x*x+x*x+x*x*x+x/x-x+x
end
bar (generic function with 1 method)
julia> function bar2()
x = rand(Bool) ? 7 : 7.0
@outline2 x*x+x*x+x*x*x+x/x-x+x
end
bar2 (generic function with 1 method)
julia> @b bar
6.373 ns
julia> @b bar2
75.544 ns (2.40 allocs: 38.346 bytes)There was a problem hiding this comment.
Yeah, the issue with 0-arg closures is that they can box their arguments in a bunch of caess. I was hoping to avoid that by taking advantage of macro-magic. :)
There was a problem hiding this comment.
Thanks for the bug report though - shouldn't be too hard to fix. I didn't notice working locally with Revise.
|
This implements #21925 I think. |
LilithHafner
left a comment
There was a problem hiding this comment.
_free_vars extracts all vars, not just free vars. This is an issue when the outlined body defines bound variables:
julia> function bar(n)
x = rand(Bool) ? 7 : 7.0
@outline sum(x^2 for _ in 1:n)
end
ERROR: syntax: all-underscore identifiers are write-only and their values cannot be used in expressions around REPL[1]:6
Stacktrace:
[1] top-level scope
@ REPL[13]:1
julia> function bar(n)
x = rand(Bool) ? 7 : 7.0
@outline sum(x^2 for this_name_not_defined_in_main in 1:n)
end
bar (generic function with 1 method)
julia> bar(4)
ERROR: UndefVarError: `this_name_not_defined_in_main` not defined in `Main`
Suggestion: check for spelling errors or missing imports.
Stacktrace:
[1] macro expansion
@ ./REPL[1]:6 [inlined]
[2] bar(n::Int64)
@ Main ./REPL[40]:3
[3] top-level scope
@ REPL[41]:1Also tagging triage to discuss a significant new feature.
Co-authored-by: Lilith Orion Hafner <[email protected]>
| # message is an expression needing evaluating | ||
| # N.B. To reduce the risk of invalidation caused by the complex callstack involved | ||
| # with `string`, use `inferencebarrier` here to hide this `string` from the compiler. | ||
| msg = :(Main.Base.inferencebarrier(Main.Base.string)($(esc(msg)))) |
|
|
Triage likes the idea; nice feature to have if we can get a good implementation. |
|
Thanks all!
Ah, yeah interesting. Thanks for the explanation of the issue here, @LilithHafner and @JeffBezanson. It makes sense to me now. I originally considered a 0-arg closure, like @adienes suggested, but as discussed above, julia closures currently have a bunch of perf landmines that makes this not a great option. I'm not sure if this is something that can be resolved alone by a macro without perf impact, then. Maybe this would need compiler support, to be able to identify truly free variables? EDIT: @LilithHafner 🤔 is this not something that is statically determinable from the syntax? I.e. is this not something that |
@JeffBezanson i'm not 100% sure, but i don't think so: The goal of this change is to (as @StefanKarpinski suggested in #29688) outline not just the actual throw statement itself, but to also outline all the logic used to generate the exception message. As a simple example, if the user writes: @assert x > 0 && x < len(foo) && foo[x] < 0 "Either $x is out of bounds or $(foo)[$x] = $(foo[x]) is not negative"this generates: if x > 0 && x < len(foo) && foo[x] < 0
throw(AssertionError("Either $x is out of bounds or $(foo)[$x] = $(foo[x]) is not negative"))
endand the function body is going to blow up with all the code to pretty-print So we are already going to compile all this code anyway, but currently we're going to compile it into the callsite. |
|
OK, I was thinking the goal was just to outline the allocation and throw. It's different if the assertion has a complex message expression, which not every assertion does. But I still think it's too much to make a function for every single assertion. Outlining this code is a micro-optimization that is not always needed. Doing it where needed is fine. But scaling this over every assertion expression can indeed put a lot of strain on the compiler and just feels very bulky for something so simple. I think a good compromise is to evaluate the message expression (which is often a constant!) in-line and call a |
|
What we would ideally do is move all the code on the error path to a part of the assembly such that it didn't get branch predicted which should be the best of both worlds. Doing so, of course would require teaching LLVM a lot more about Julia exceptions. |
|
Oscar that would definitely help, although in practice I think some of the perf issues also come from just having a bloated LLVM IR from the error message string construction and the throw, rather than having a nice tight few-instructions function, which can get inlined, and is friendlier on instruction caches, etc. For example, |
Adds a new macro
@outline, and uses it in@assert.This implements the simplest suggestion in #29688.
It would still be excellent if the compiler would automatically perform this optimization for any
throw()statement, but it sounds like introducing automatic outlining for such cases would be a difficult implementation task in the Compiler.And in either case, even if we had such an optimization, there may be cases where a human will want to perform this optimization themselves for a number of reasons, including code-size reduction, improving type stability, outlining rare branches, etc, and having this in a macro is a nice way to automate it, and make this optimization more accessible. 😊
Example macro usage:
This commit applies this new macro
@outlineto@assert, producing essentially:or in a more complete example:
This can improve performance for fast code that uses assertions. For example, take this definition of
getindexforWithoutMissingVector:julia/base/sort.jl
Lines 597 to 601 in 5058dba
Before:
After:
The number of instructions in that function according to
@code_native(on an aarch64 M2 MacBook) reduced from ~90 to ~40.