Skip to content
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

use pairwise order for mapreduce on arbitrary iterators #52397

Draft
wants to merge 22 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
4 changes: 2 additions & 2 deletions base/mathconstants.jl
Expand Up @@ -76,7 +76,7 @@ julia> Base.MathConstants.eulergamma
julia> dx = 10^-6;

julia> sum(-exp(-x) * log(x) for x in dx:dx:100) * dx
0.5772078382499133
0.5772078382090375
```
"""
γ, const eulergamma = γ
Expand Down Expand Up @@ -109,7 +109,7 @@ julia> Base.MathConstants.catalan
catalan = 0.9159655941772...

julia> sum(log(x)/(1+x^2) for x in 1:0.01:10^6) * 0.01
0.9159466120554123
0.9159466120556188
```
"""
catalan
Expand Down
2 changes: 1 addition & 1 deletion base/missing.jl
Expand Up @@ -296,7 +296,7 @@ function _mapreduce(f, op, ::IndexLinear, itr::SkipMissing{<:AbstractArray})
something(mapreduce_impl(f, op, itr, first(inds), last(inds)))
end

_mapreduce(f, op, ::IndexCartesian, itr::SkipMissing) = mapfoldl(f, op, itr)
_mapreduce(f, op, ::IndexCartesian, itr::SkipMissing) = mapreduce_impl(f, op, _InitialValue(), itr)

mapreduce_impl(f, op, A::SkipMissing, ifirst::Integer, ilast::Integer) =
mapreduce_impl(f, op, A, ifirst, ilast, pairwise_blocksize(f, op))
Expand Down
84 changes: 78 additions & 6 deletions base/reduce.jl
Expand Up @@ -277,6 +277,76 @@ end
mapreduce_impl(f, op, A::AbstractArrayOrBroadcasted, ifirst::Integer, ilast::Integer) =
mapreduce_impl(f, op, A, ifirst, ilast, pairwise_blocksize(f, op))

# repeat expr n times in a block
macro _repeat(n::Int, expr)
Expr(:block, fill(esc(expr), n)...)
end

# the following mapreduce_impl is called by mapreduce for non-array iterators,
# and implements an index-free in-order pairwise strategy:
function mapreduce_impl(f, op, ::_InitialValue, itr)
it = iterate(itr)
it === nothing && return mapreduce_empty_iter(f, op, itr)
a1, state = it
it = iterate(itr, state)
it === nothing && return mapreduce_first(f, op, a1)
a2, state = it
v = op(f(a1), f(a2))
# unroll a few iterations to reduce overhead for small iterators:
@_repeat 14 begin
it = iterate(itr, state)
it === nothing && return v
a, state = it
v = op(v, f(a))
end
n = max(2, pairwise_blocksize(f, op))
v, state = _mapreduce_impl(f, op, itr, v, state, n-16)
while state !== nothing
v, state = _mapreduce_impl(f, op, itr, v, state, n)
n *= 2
end
return v
end

# apply reduction to at most ≈ n elements of itr, in a pairwise recursive fashion,
# returning op(nt, ...n elements...), state --- state=nothing if itr ended
function _mapreduce_impl(f, op, itr, nt, state, n)
# for the pairwise algorithm we want to reduce this block
# separately *before* combining it with nt, so we peel
# off the first element to initialize the block reduction:
it = iterate(itr, state)
it === nothing && return nt, nothing
a1, state = it
it = iterate(itr, state)
it === nothing && return op(nt, f(a1)), nothing
a2, state = it
v = op(f(a1), f(a2))

if n ≤ max(2, pairwise_blocksize(f, op)) # coarsened base case
@simd for _ = 3:n
it = iterate(itr, state)
it === nothing && return op(nt, v), nothing
a, state = it
v = op(v, f(a))
end
return op(nt, v), state
else
n >>= 1
v, state = _mapreduce_impl(f, op, itr, v, state, n-2)
state === nothing && return op(nt, v), nothing
v, state = _mapreduce_impl(f, op, itr, v, state, n)
# break return statement into two cases to help type inference
return state === nothing ? (op(nt, v), nothing) : (op(nt, v), state)
end
end

# for an arbitrary initial value, we need to call foldl,
# because op(nt, itr[i]) may have a different type than op(nt, itr[j]))
# … it's not clear how to reliably do this without foldl associativity.
Comment on lines +343 to +345
Copy link
Sponsor Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I suppose it's not clear to me why this is necessarily a problem. Isn't it also true that op(op(itr[i], itr[j]), itr[k]) might have a different type than op(itr[i], op(itr[j], itr[k])? Why is having a separately-passed initial value different from itr[i]?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The problem is that re-association can introduce the initial value multiple times. e.g. if I do

sum(1:10, init=1)

then the result depends on how many times I split the pairwise reduction. i.e. if that is just

foldl(+, 1:10, init=1)

you get 56. But if you do a pairwise split in the middle you get

(foldl(+, 1:5, init=1) + foldl(+, 5:10, init=1))

then we have a result of 57. This is similar to but distinct from the problems you encounter when op is not associative. In this case, it turns out to be important that init must be an identity element under op, i.e. you must have op(init, x) ≈ x

Copy link
Sponsor Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But if you do a pairwise split in the middle you get

(foldl(+, 1:5, init=1) + foldl(+, 5:10, init=1))

Dumb question: why couldn't we do foldl(+, 1:5, init=1) + foldl(+, 5:10)? Isn't this already doing foldl for the first 14 operations anyway?

...

Oh. Right, I suppose this is where type comes into play. Because it'd be very weird and problematic if reduce(+, Int8.(1:100), init=big(0)) did half of its calculation mod $2^8$ and half in arbitrary precision. Yeah, ok.

Copy link
Sponsor Member

@mbauman mbauman Feb 20, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because it'd be very weird and problematic if reduce(+, Int8.(1:100), init=big(0)) did half of its calculation mod $2^8$ and half in arbitrary precision.

But on the other hand, isn't that what you sign up for with reduce? Since we very explicitly do not guarantee an evaluation order, I don't think you should be able to rely upon this sort of "cascading-promotion-from-the-init-value".

Is it really meaningfully different than the status quo:

julia> reduce(+, Integer[1; repeat([UInt8(1)], 10000)])
1297

julia> foldl(+, Integer[1; repeat([UInt8(1)], 10000)])
10001

?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what I infer from the docstring is that init must be the identity element of the monoid defined by op and eltype(itr)

so I might expect it to "work" when typeof(op(init, x)) <: eltype(itr) for all x in itr

but in the example reduce(+, Int8.(1:100), init=big(0)) since the promoted return type of +(::BigInt, ::Int8) does not subtype Int8 , I do not think that can reasonably be expected to always produce a sane result

Copy link
Sponsor Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh yeah, we even go further than that and say:

It is unspecified whether init is used for non-empty collections.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah the docstring seems to give us a lot of leeway to either drop the init or use it each time and give a 'surprising' result if it's not the identity element.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's continue this conversation in #53871, e.g. see my comment at #53871 (comment)

mapreduce_impl(f, op, nt, itr) = mapfoldl_impl(f, op, nt, itr)



"""
mapreduce(f, op, itrs...; [init])

Expand Down Expand Up @@ -304,9 +374,11 @@ implementations may reuse the return value of `f` for elements that appear multi
`itr`. Use [`mapfoldl`](@ref) or [`mapfoldr`](@ref) instead for
guaranteed left or right associativity and invocation of `f` for every value.
"""
mapreduce(f, op, itr; kw...) = mapfoldl(f, op, itr; kw...)
mapreduce(f, op, itr; init=_InitialValue()) = mapreduce_impl(f, op, init, itr)
mapreduce(f, op, itrs...; kw...) = reduce(op, Generator(f, itrs...); kw...)

mapreduce(f, op, itr::Union{Tuple,NamedTuple}; kw...) = mapfoldl(f, op, itr; kw...)

# Note: sum_seq usually uses four or more accumulators after partial
# unrolling, so each accumulator gets at most 256 numbers
pairwise_blocksize(f, op) = 1024
Expand Down Expand Up @@ -373,15 +445,15 @@ mapreduce_empty(::typeof(abs2), op, T) = abs2(reduce_empty(op, T))
mapreduce_empty(f::typeof(abs), ::typeof(max), T) = abs(zero(T))
mapreduce_empty(f::typeof(abs2), ::typeof(max), T) = abs2(zero(T))

mapreduce_empty_iter(f::F, op::OP, itr) where {F,OP} = reduce_empty_iter(_xfadjoint(op, Generator(f, itr))...)

# For backward compatibility:
mapreduce_empty_iter(f, op, itr, ItrEltype) =
reduce_empty_iter(MappingRF(f, op), itr, ItrEltype)

@inline reduce_empty_iter(op, itr) = reduce_empty_iter(op, itr, IteratorEltype(itr))
@inline reduce_empty_iter(op, itr, ::HasEltype) = reduce_empty(op, eltype(itr))
reduce_empty_iter(op, itr, ::EltypeUnknown) = throw(ArgumentError("""
reducing over an empty collection of unknown element type is not allowed.
You may be able to prevent this error by supplying an `init` value to the reducer."""))
reduce_empty_iter(op, itr, ::EltypeUnknown) = _empty_reduce_error()


# handling of single-element iterators
Expand Down Expand Up @@ -426,7 +498,7 @@ function _mapreduce(f, op, ::IndexLinear, A::AbstractArrayOrBroadcasted)
inds = LinearIndices(A)
n = length(inds)
if n == 0
return mapreduce_empty_iter(f, op, A, IteratorEltype(A))
return mapreduce_empty_iter(f, op, A)
elseif n == 1
@inbounds a1 = A[first(inds)]
return mapreduce_first(f, op, a1)
Expand All @@ -447,7 +519,7 @@ end

mapreduce(f, op, a::Number) = mapreduce_first(f, op, a)

_mapreduce(f, op, ::IndexCartesian, A::AbstractArrayOrBroadcasted) = mapfoldl(f, op, A)
_mapreduce(f, op, ::IndexCartesian, A::AbstractArrayOrBroadcasted) = mapreduce_impl(f, op, _InitialValue(), itr)

"""
reduce(op, itr; [init])
Expand Down
2 changes: 1 addition & 1 deletion base/reducedim.jl
Expand Up @@ -330,7 +330,7 @@ mapreduce(f, op, A::AbstractArrayOrBroadcasted...; kw...) =
reduce(op, map(f, A...); kw...)

_mapreduce_dim(f, op, nt, A::AbstractArrayOrBroadcasted, ::Colon) =
mapfoldl_impl(f, op, nt, A)
mapreduce_impl(f, op, nt, A)

_mapreduce_dim(f, op, ::_InitialValue, A::AbstractArrayOrBroadcasted, ::Colon) =
_mapreduce(f, op, IndexStyle(A), A)
Expand Down
2 changes: 1 addition & 1 deletion doc/src/manual/arrays.md
Expand Up @@ -407,7 +407,7 @@ sums a series without allocating memory:

```jldoctest
julia> sum(1/n^2 for n=1:1000)
1.6439345666815615
1.6439345666815597
```

When writing a generator expression with multiple dimensions inside an argument list, parentheses
Expand Down
1 change: 1 addition & 0 deletions test/reduce.jl
Expand Up @@ -613,6 +613,7 @@ test18695(r) = sum( t^2 for t in r )
@test_throws str -> ( occursin("reducing over an empty", str) &&
occursin("consider supplying `init`", str) &&
!occursin("or defining", str)) test18695(Any[])
@test_throws MethodError test18695(Any[])
Copy link
Sponsor Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I blindly went to add some tests here as I started reviewing this, and I wrote the following...

Suggested change
@test_throws MethodError test18695(Any[])
@test_throws MethodError test18695(Any[])
@testset "issue #52457: pairwise reduction of iterators" begin
@test foldl(+, Iterators.repeated(Float16(1.0), 10000)) == maxintfloat(Float16)
@test mapfoldl(x->x^2, +, Iterators.repeated(Float16(1.0), 10000)) == maxintfloat(Float16)
@test foldr(+, Iterators.repeated(Float16(1.0), 10000)) == maxintfloat(Float16)
@test mapfoldr(x->x^2, +, Iterators.repeated(Float16(1.0), 10000)) == maxintfloat(Float16)
@test reduce(+, Iterators.repeated(Float16(1.0), 10000)) 10000
@test mapreduce(x->x^2, +, Iterators.repeated(Float16(1.0), 10000)) 10000
@test_throws ArgumentError reduce((x,y)->x+y, Iterators.repeated(Float16(1.0), 0))
@test_throws ArgumentError mapreduce(identity, (x,y)->x+y, Iterators.repeated(Float16(1.0), 0))
@test reduce(+, Iterators.repeated(Float16(1.0), 0); init=Float16(0)) === Float16(0)
@test mapreduce(identity, +, Iterators.repeated(Float16(1.0), 0); init=Float16(0)) === Float16(0)
@test reduce(+, Iterators.repeated(Float16(1.0), 10000); init=Float16(0)) 10000
@test mapreduce(identity, +, Iterators.repeated(Float16(1.0), 10000); init=Float16(0)) 10000
@test reduce((x,y)->x+y, Iterators.repeated(Float16(1.0), 0); init=Float16(0)) === Float16(0)
@test mapreduce(x->x^2, (x,y)->x+y, Iterators.repeated(Float16(1.0), 0); init=Float16(0)) === Float16(0)
@test reduce((x,y)->x+y, Iterators.repeated(Float16(1.0), 10000); init=Float16(0)) 10000
@test mapreduce(x->x^2, (x,y)->x+y, Iterators.repeated(Float16(1.0), 10000); init=Float16(0)) 10000
end

But that fails, because supplying init pushes you back into foldl land. I see you've considered this... but it still feels pretty unexpected.


# For Core.IntrinsicFunction
@test_throws str -> ( occursin("reducing over an empty", str) &&
Expand Down