I am working on something that is in a sense a generalization of collect
(except it would pick named tuples apart into vectors, apply some simple RLE compression, etc), and I wanted to understand the type widening part better. For my problem, I do not know the element type or the size ex ante, so the following type is defined to make results comparable (all the code is available in a gist for quick copy&paste):
# define an iterator with unknown type and length
struct MyItr N::Int end
Base.IteratorSize(::Type{MyItr}) = Base.SizeUnknown()
Base.IteratorEltype(::Type{MyItr}) = Base.EltypeUnknown()
function Base.iterate(itr::MyItr, state = 1)
if state > itr.N
nothing
else
val = state == 200 ? missing : state
(val, state + 1)
end
end
Then I try a “functional” implementation, where a new container is returned, along with a flag that tells the caller if it changed type, in which case we recurse so that the type does not change within the function:
# "functional" implementation
store(collection::Vector{T}, elt::S) where {T, S <: T} =
(push!(collection, elt); (collection, false))
store(collection, elt) = (vcat(collection, elt), true)
function mycollect_fun(itr)
y = iterate(itr)
y ≡ nothing && error("not allowed")
elt, state = y
_mycollect_fun([elt], itr, state)
end
function _mycollect_fun(collection, itr, state)
while true
y = iterate(itr, state)
y ≡ nothing && return collection
elt, state = y
collection, changed = store(collection, elt)
changed && return _mycollect_fun(collection, itr, state)
end
end
Finally, a “stateful” implementation, in which the caller queries the collection if it can accommodate the new value, and if not, expand:
# "stateful" implementation
canstore(collection::Vector{T}, elt::S) where {T, S <: T} = true
canstore(collection, elt) = false
store!(collection::Vector{T}, elt::S) where {T, S <: T} = push!(collection, elt)
function expand(collection::Vector{T}, elt::S) where {T,S}
U = Base.promote_typejoin(T, S)
newcollection = Vector{U}(undef, length(collection))
copyto!(newcollection, collection)
push!(newcollection, elt)
newcollection
end
function mycollect_mut(itr)
y = iterate(itr)
y ≡ nothing && error("not allowed")
elt, state = y
_mycollect_mut([elt], itr, state)
end
function _mycollect_mut(collection, itr, state)
while true
y = iterate(itr, state)
y ≡ nothing && return collection
elt, state = y
!canstore(collection, elt) && return _mycollect_mut(expand(collection, elt), itr, state)
store!(collection, elt)
end
end
Now some benchmarks:
julia> using BenchmarkTools
julia> itr = MyItr(1000)
MyItr(1000)
julia> @btime collect($itr);
60.646 ÎĽs (14 allocations: 30.94 KiB)
julia> @btime mycollect_fun($itr);
36.679 ÎĽs (1520 allocations: 70.88 KiB)
julia> @btime mycollect_mut($itr);
39.187 ÎĽs (990 allocations: 46.30 KiB)
julia> VERSION
v"1.1.0-DEV.281"
Surprisingly, the functional version is fastest (which is great news, as it is the easiest for me to implement for the complicated problem), and even more surprisingly, it is faster than Base.collect
.
Am I mismeasuring something, eg because the MWE is so trivial? And if this is a viable pattern, can I make this even faster?