-
Notifications
You must be signed in to change notification settings - Fork 7
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Attempts at specialization transparency, ref #57 #58
base: main
Are you sure you want to change the base?
Conversation
Benchmark Results
Benchmark PlotsA plot of the benchmark results have been uploaded as an artifact to the workflow run for this PR. |
For what it's worth, this PR does nothing to fix the original problem that led me down this investigation. Turns out that's a thornier problem and needs a different MWE than #57. In fact, for said problem |
Maybe a bandaid fix is to just warn (in the docstring) about potential runtime dispatch for |
Well, it's not so clear-cut. As seen with the broadcasting implementation, Anyway, for the issue discussed here and in #57, adding For the issue I'm alluding to in my previous comment, however, |
I didn’t know this, how could this be true if it’s the same body and same signature? |
Explained in #57 (comment) and demonstrated in the benchmarks in the top post here. Briefly: if the body of the original function does not itself call To be clear, the function wouldn't specialize without |
Referring back to the top post here: The second benchmark reflects what DispatchDoctor currently does for The challenge is to strike the middle ground where the instability check is always compiled away, while the main body is only specialized to the extent it would be without |
Note that I'm just using allocations as a simple, deterministic proxy here. The more important issue is of course actual performance, and the difference can be many orders of magnitude for simple functions like this. |
I am thinking about if there are any issues with forcing Julia to always specialize. I suppose, no? It just means larger compilation cost, and that's it? Although I feel like that is implied anyway by using DispatchDoctor.jl, and is already pointed out in the caveats section, so maybe it's not a big deal. So yeah, I would be on board with that change. By the way, with your current attempt, would it also work on something like @stable mymap!((f,), y, x) = @mymap!_body i.e., where |
)::Tuple{Union{Expr,Symbol},Union{Expr,Nothing},Union{Symbol,Nothing}} | ||
genwhereparam || return ex, nothing, nothing | ||
whereparam = gensym("T") | ||
return Expr(:(::), ex, whereparam), nothing, whereparam | ||
end | ||
function sanitize_arg_for_stability_check( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Now that there are 3 returns, it might be nice to return a NamedTuple instead, for robustness. Returning tuples is always a bit risky because of things like
(x, y) = (1, 2, 3)
being valid Julia syntax.
e.g., could be:
return (; arg=Expr(:(::), ex, whereparam), destruct=nothing, whereparam=whereparam)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah, that's a good idea!
# (Composite case) | ||
# matches things like `::Type{T}=MyType` | ||
arg_ex, destructure_ex, whereparam = sanitize_arg_for_stability_check( | ||
first(args); genwhereparam | ||
) | ||
return Expr(head, arg_ex, last(args)), destructure_ex, whereparam |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Does this one need the genwhereparam
too? It already has the ::T
part, no?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It's needed because this is also the branch that matches a regular x=default
argument without a type parameter
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If it happens to be a ::Type{T}=MyType
argument, the inner recursive call will land in the branch that sets genwhereparam=false
as required
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I see, thanks!
( | ||
map(first, args_destructurings), | ||
filter(!isnothing, map(last, args_destructurings)), | ||
map(first, args_destructurings_typevars), |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The error messages might need to be treated since they now show ::var"..."
after every arg.
I think this is where I should acknowledge that I don't really know what I'm talking about when discussing compiler-related behavior and tradeoffs. But for what it's worth, your hunch aligns with mine.
Yes, the current implementation injects type parameters for every type of argument, turning this into something like function mymap!(var1::T1, y::T2, z::T3) where {T1,T2,T3}
<instability check>
(f,) = var1
@mymap!_body
end The change as it stands is fully functional. The only tests that don't pass are those that query the exact form of the generated code.
This may be incorrect, but I think argument destructuring lowers to code that's more or less equivalent to the code we're generating manually. That is, You can also combine destructuring and vararg into |
I don't think I can set aside time to take this from draft to ready for the next several days/weeks, so feel free to take it and run with it if you've developed a conviction about the way forward. |
One more comment here: the more I think about it, the more I land on the side that DispatchDoctor ought to err on the side of more specialization rather than potential runtime overhead. People who use |
Sorry I just realised I didn't respond. Regarding specialisation: yes, I totally am on board with your proposal to force more specialisation. Especially since you have shown that sometimes specialisation is negatively affected, so I think it's much better to flip it the other way. |
We should also add this as a test to ensure the new symbol extractor can handle it: @testitem "issue #59" begin
using DispatchDoctor
@stable function f(@nospecialize(x))
return x > 0 ? x : 0.0
end
@test_throws TypeInstabilityError f(1)
end It's a bit tricky though because you would want to have the Edit: I think the easiest way to do this is just keep the |
@danielwe I actually ran into something caused by this type of issue and was wondering if you could take a stab at finishing this PR? I am eager to get something like this into DD |
Would love to help out, but right now I’m rather swamped. I’ll try to get to it eventually if you or someone else don’t get there before me, but feel free to take this and run with it. As I remember, the solution is just to inject type parameters for every argument in the method definition, right? |
I think so. Would that cause any issues with the type hierarchy of Julia though? Like would it change position of methods in the type hierarchy by making them more specific? |
I don't think so if the type parameters are unconstrained, but I'm not particularly knowledgeable about this |
Exploring ways of dealing with #57. This prototype injects a type variable (
where
parameter) for every argument that doesn't already have one. I think this is a satisfactory fix forcodegen_level = "min"
, but it leads to more specialization than the user's code implies forcodegen_level = "debug"
(or less, if typevar injection is turned off for this case).Here's a script that's helpful for experimenting, using allocations as a convenient proxy for the performance impact of runtime instability checking:
With the state of the code in this PR, the output is
Setting
genwhereparam=(codegen_level == "min")
instead of simplytrue
on line 230 instabilization.jl
, we getWe see that
mymap!_debug
is either better or much worse than the undecoratedmymap!
, but never the same. Meanwhilemymap!_min
is faithful.If you switch from broadcasting to loopy implementation (by commenting/uncommenting lines in
mymap!_body
), both of the above settings work great---all three versions get zero allocations. If you setgenwhereparam=false
, you'll recover the behavior observed in #57, wherecodegen_level = "min"
results in lots of allocations.For a solution along these lines, my feeling right now is that
genwhereparam=(codegen_level == "min")
is the most appropriate setting. In this case, the documentation should clearly flag thatcodegen_level = "min"
is the choice for faithful behavior, whilecodegen_level = "debug"
mainly exists for@code_warntype
convenience and may be detrimental to performance. Perhaps the default should be changed tocodegen_level = "min"
.Hopefully there's a better solution to be found.