-
-
Notifications
You must be signed in to change notification settings - Fork 5.6k
Whole-array reductions always use init to start each reduction chain #58241
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
base: master
Are you sure you want to change the base?
Conversation
part of me feels like this is a problem with
is this "properly"? I could totally see an argument that |
It is about mapreduce at the end of the day; There are two orthogonal features of the reduction machinery at play. They're also fundamentally where the problem originates in the first place.
I've always thought of |
ok, I think I'm convinced. |
It's still taking me some time to actually figure out what this is doing, but I think I'm getting convinced myself, too. This example works pretty well at expressing all the possibilities in a single concise table. On Julia v1.12/current master, here's the representative universe of what using Base: reduce_empty as r0, reduce_first as r1, add_sum as +ₛ
const t, f = true, false
# #= high-level reduc =# === #= internal API =# === #= expand r/+ₛ +# === #= value =#
sum(Bool[]) === r0(+ₛ, Bool) === 0
sum([f]) === r1(+ₛ, f) === 0
sum([f,t]) === f +ₛ t === f + t === 1
sum([f,t,f,t]) === (f +ₛ t) +ₛ (f +ₛ t) === (f + t) + (f + t) === 2
sum(Bool[], init=0x00) === 0x00
sum([f], init=0x00) === 0x00 +ₛ f === 0x00 + f === 0x01
sum([f,t], init=0x00) === (0x00 +ₛ f) +ₛ t === (0x00 + f) + t === 0x01
sum([f,t,f,t],init=0x00) === (((0x00 +ₛ f) +ₛ t) +ₛ f) +ₛ t === (((0x00 + f) + t) + f) + t === 0x02 If we jam in a pairwise implementation with init like I tried at first, we get that extra promotion from the step that "combines" the two reduction chains. This is the only place where the sum([f,t,f,t],init=0x00) === ((0x00 +ₛ f) +ₛ t) +ₛ ((0x00 +ₛ f) +ₛ t) === UInt(0x00 + f + t) + UInt(0x00 + f + t) === UInt(0x01) + UInt(0x01) === UInt(2) The current state of this PR calls using Base: reduce_empty as r0, reduce_first as r1, add_sum as +ₛ
const t, f = true, false
# #= high-level reduc =# === #= internal implementation =# === #= expanding r0/r1/+ₛ =#
sum(Bool[]) === r0(+ₛ, Bool) === 0
sum([f]) === r1(+ₛ, f) === 0
sum([f,t]) === r1(+ₛ, f) +ₛ t === 0 + t === 1
sum([f,t,f,t]) === (r1(+ₛ, f) +ₛ t) +ₛ (r1(+ₛ, f) +ₛ t) === (0 + t) + (0 + t) === 2
sum(Bool[], init=0x00) === 0x00
sum([f], init=0x00) === 0x00 +ₛ r1(+ₛ, f) === 0x00 + 0 === 1
sum([f,t], init=0x00) === (0x00 +ₛ r1(+ₛ, f)) +ₛ t === (0x00 + 0) + t === 1
sum([f,t,f,t],init=0x00) === ((0x00 +ₛ r1(+ₛ, f)) +ₛ t) +ₛ ((0x00 +ₛ r1(+ₛ, f)) +ₛ t) === (0x00 + 0 + t) + (0x00 + 0 + t) === 2 This probably makes it easier to ensure subsequent type stability, because the type of It's probably worth noting that these effects are quite limited — there simply aren't many reduce_first(op, x) = x
reduce_first(::typeof(+), x::Bool) = Int(x)
reduce_first(::typeof(*), x::AbstractChar) = string(x)
reduce_first(::typeof(add_sum), x) = reduce_first(+, x)
reduce_first(::typeof(add_sum), x::BitSignedSmall) = Int(x)
reduce_first(::typeof(add_sum), x::BitUnsignedSmall) = UInt(x)
reduce_first(::typeof(mul_prod), x) = reduce_first(*, x)
reduce_first(::typeof(mul_prod), x::BitSignedSmall) = Int(x)
reduce_first(::typeof(mul_prod), x::BitUnsignedSmall) = UInt(x) |
Let's see what Nanosoldier thinks. I can still make this less breaking — most notably by relaxing the @nanosoldier |
The package evaluation job you requested has completed - possible new issues were detected. Report summary❗ Packages that crashed1 packages crashed only on the current version.
6 packages crashed on the previous version too. ✖ Packages that failed82 packages failed only on the current version.
1487 packages failed on the previous version too. ✔ Packages that passed tests18 packages passed tests only on the current version.
5183 packages passed tests on the previous version too. ~ Packages that at least loaded13 packages successfully loaded only on the current version.
2810 packages successfully loaded on the previous version too. ➖ Packages that were skipped altogether917 packages were skipped on the previous version too. |
Ah shucks. Looks like Nanosoldier isn't picking up my Statistics and SparseArrays branches from here. How does that work? Does it always grab the latest release? |
Well there are still a number of meaningful errors here, from most interesting to least:
I think I can fiddle around with some of the internal method signatures to make this a little more kind to the folks playing with fire. |
while clever, |
Yeah it's definitely not. They also test It feels like you could almost make this reliable with julia> foldl(merge!, ks, init = KahanSum(T)) |> sum
250439.49035165994
julia> ks_reduce = reduce((x,y)->merge!(copy(x), y), ks, init = KahanSum(T)) |> sum
250439.49035165994
julia> Base.reduce_first(::typeof(merge!), k::KahanSum) = copy(k) # prevent mutating the input array without init
julia> reduce(merge!, ks) |> sum
250439.49035165994 The trouble is, though, that it doesn't solve this footgun: julia> reduce(merge!, ks, init=KahanSum(T)) |> sum
8.360444161382082e18 I don't really see a simple solution; I think we'd have to promise so much about the internals in order to have a sensible way to rely on mutation... and even then I think it'd be quite fragile. |
This reverts commit c07a5f7. The Statistics branch is no longer needed; the Sparse branch is still beneficial but not required.
@nanosoldier |
there are still tests that use "illegal" init values but happen to be ok due to the number of elements or somesuch; I am intentionally leaving them as they may be helpful to flag if and when further refactorings change these fraught behaviors.
The package evaluation job you requested has completed - possible new issues were detected. Report summary❗ Packages that crashed1 packages crashed only on the current version.
✖ Packages that failed15 packages failed only on the current version.
8 packages failed on the previous version too. ✔ Packages that passed tests2 packages passed tests only on the current version.
53 packages passed tests on the previous version too. ~ Packages that at least loaded1 packages successfully loaded only on the current version.
3 packages successfully loaded on the previous version too. |
Great, that's much better. We still have Gridap's |
This implements the new
init
behaviors that were decided in #53945 — thatinit
is used one or more times to start each reduction chain. Unlike the dimensional counterpart (#55318), this can be done without a big refactor, which makes it much easier to evaluate the what the core behaviors should be. And, importantly, it makes it much easier for bigger refactorings — like #52397 — to be implemented subsequently. Perhaps most importantly, it's what we need to do in order to officially document that this is howinit
works. The current docs give us leeway to do this, and then once it's implemented, we can document the better semantics (and finally merge #53945).In implementing this, I relalized there's still an outstanding fundamental question on how reductions should work. #53945 clarifies how
init
itself is used, but there's still an outstanding question about what needs to happen next.The crux of it is how reductions should start. I talk in #55318 about the 2*3 different ways in which a reduction may start... and here I codified it all under a single function:
This brings all this chicanery into one consolidated place — and makes it clear why you need 6 cases when these are the definitions.
But there's (at least) one very interesting test that breaks, and it makes for a perfect example (just ignore the fact that it breaks the "neutral element" init rule; that's actually not the interesting part here!)
On master, Julia just happily returns a properly inferred 0x005. Why did this change? Well, now we can use an
init
in the pairwise implementation, so this now uses the pairwise implementation (hooray!). But now that adds an additional possible branch here. This could be computed (and master does so) as the foldl(0x0004 + false) + true
, where you only ever addUInt16
andBool
. Or (with more elements) you could do((0x0004 + false) + ...) + ((0x0004 + true) + ...)
, and that final summation is between twoUInt16
. And I lied, this isn't computed with+
, it'sadd_sum
, which very intentionally promotes that case (but not the other) toUInt
. So there's the instability.But is this actually how we want reductions to start? What if we always called
mapreduce_first
? I had been thinking it was the analog ofempty
— in that it only gets called when the entire reduction is just 1 element — and that's also how @stevengj had implemented the streaming iterable pairwise algorithm as well. But it's actually documented that it "may also be used to initialise the recursion." In other words, maybe the above_mapreduce_start
definition should be — more simply:And then you squint at it, and you realize the two-element case is no longer special — it's just the natural "next step" of the reduction after the one-element starting point in both of the init-or-not cases! This is great, as only doing something special to one element before launching into a for loop or recursion is drastically (and perhaps surprisingly so) simpler than needing to do something special to two elements. It removes a case from every single implementation, and every single implementation only needs to peel one element out of a loop instead of two. It also fixes one bit of breakage that I initially added here — that
mapreduce_impl
requires at least two elements with the previous definitions, but it only requires one element on master.Now the test above is still broken, but it's broken in a new and more interesting way:
There are really only a handful of meaningful things that
mapreduce_first
does, but one of them is to promoteBool
s in summations toInt
— andcount
is just asum
of bools. So now the instability is just stemming from our choice ofinit
itself.So that's now the question: is that a better behavior? Maybe? I've gone through a handful of tests and stdlibs locally, but I'm curious what CI (and eventually PkgEval) says.
This fixes #47216.