-
Notifications
You must be signed in to change notification settings - Fork 19
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 custom iterators for replace(), skip() and fail() #50
Conversation
# once in done() to find the next non-null entry, and once in next() to return it. | ||
# This works around the type instability problem of the generic fallback. | ||
@inline function _next_nonnull_ind(x::AbstractArray, s) | ||
idx = eachindex(x) |
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 couldn't find a simpler way to work with indices which would be completely generic. Linear indices would of course work, but they would be slow for LinearSlow
arrays.
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.
Couldn't we dispatch and use linear indices for most arrays but a specialized implementation for LinearSlow
?
Any ideas about why |
Codecov Report
@@ Coverage Diff @@
## master #50 +/- ##
==========================================
+ Coverage 92.15% 93.91% +1.76%
==========================================
Files 1 1
Lines 102 148 +46
==========================================
+ Hits 94 139 +45
- Misses 8 9 +1
Continue to review full report at Codecov.
|
This dramatically improves the performance of skip() on arrays by getting rid of the type instability which is currently not well handled. This optimization cannot be applied to non-array iterables since it relies on passing indices and accessing an entry several times in some cases. Hovewer, forcing inlining makes the code somewhat faster even for non-arrays. Performance improvements are smaller but still significant for replace() and skip(). There is 2× regression when passing a generator to fail(), though, but the gain for the array case is worth it. The second advantage of using custom iterators is that eltype() returns Nulls.T(eltype(x)) when x is an array, while when using plain generators it returned Any.
src/Nulls.jl
Outdated
Union{Nulls.T(eltype(itr.x)), typeof(itr.replacement)} | ||
@inline function Base.next(itr::EachReplaceNull, state) | ||
v, s = next(itr.x, state) | ||
((isnull(v) ? itr.replacement : v)::eltype(itr), s) |
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.
Would ifelse
in place of the explicit branch help at all here?
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.
Actually, it gives slightly slower performance for some obscure reason. I suspect that the consequences of type instability propagate more with ifelse
. Anyway I think the compiler is able to get rid of simple branches like this if it considers it's faster. Though we should revisit this once Union
s handling will have improved.
src/Nulls.jl
Outdated
Base.start(itr::EachReplaceNull) = start(itr.x) | ||
Base.done(itr::EachReplaceNull, state) = done(itr.x, state) | ||
Base.eltype(itr::EachReplaceNull) = | ||
Union{Nulls.T(eltype(itr.x)), typeof(itr.replacement)} |
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 the user is replacing null
values, presumably they would expect the resulting element type to be typejoin(Nulls.T(eltype(itr.x)), typeof(itr.replacement))
. Otherwise, wouldn't replace([1,null,3], 2.0)
have element type Union{Int, Float64}
? Seems like this might cause some slowdowns.
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.
Hmm, that's a subtle distinction and I never know which one is the most appropriate. Wouldn't typejoin
always return an abstract type when replacement is of a different type? I'm not sure Real
is better than Union{Int, Float64}
. A third option is to use promotion to choose the best type, and perform conversion on the fly. I'm really not sure what's the best approach, maybe in part because I don't have a use case in mind.
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.
Oh sorry, not typejoin
, I mean promote types.
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.
OK. Makes sense to use promote
indeed. I'll do that unless somebody is of a different opinion.
FWIW, for a 1M vector like in the benchmark above, both R and DataArrays (with |
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.
Benchmarks and tests look great, thanks Milan!
OK, I've added a commit to use promotion. |
Woops, it seems to kill performance. Need to investigate why. |
Unfortunately, I couldn't the same performance as before. Calling |
end | ||
|
||
""" | ||
Nulls.fail(itr) |
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 still don't really understand the use for this iterator/function. Can you remind me?
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.
Well, it can be used to be sure you don't pass an iterator containing nulls to a function which would accept it silently. It's faster than any(isnull, x)
since checking happens on the fly. Currently sum(Nulls.fail(x))
also much faster than sum(x)
since there's no type instability, but with compiler improvements I guess this difference could go away.
src/Nulls.jl
Outdated
Base.eltype(itr::EachReplaceNull) = Nulls.T(eltype(itr.x)) | ||
@inline function Base.next(itr::EachReplaceNull, state) | ||
v, s = next(itr.x, state) | ||
((isnull(v) ? itr.replacement : v)::eltype(itr), s) |
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 get a slight speedup by doing
if v isa Null
return (itr.replacement, s)
else
return (v, s)
end
Don't ask me why or even if it will work for you, but worth a try?
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.
Wow, indeed that makes a significant difference (I've updated the timings above). Now we're about as fast as R. It's unfortunate that it isn't extensible to custom types, but for now it's certainly worth it.
I couldn't use isa Null
for fail
though because of a weird codegen bug: JuliaLang/julia#24177.
Merge? |
This dramatically improves the performance of
skip()
on arrays by gettingrid of the type instability which is currently not well handled.
This optimization cannot be applied to non-array iterables since it relies
on passing indices and accessing an entry several times in some cases.
Hovewer, forcing inlining makes the code somewhat faster even for non-arrays.
Performance improvements are smaller but still significant for
replace()
and
skip()
. There is 2× regression when passing a generator to fail(), though,but the gain for the array case is worth it.
The second advantage of using custom iterators is that
eltype()
returnsNulls.T(eltype(x))
whenx
is an array, while when using plain generatorsit returned
Any
.Cf. https://discourse.julialang.org/t/nulls-skip-is-very-slow/6351. With these changes,
sum(Nulls.fail(::Array{Union{Int, Null}}))
andsum(Nulls.skip(::Array{Union{Int, Null}}))
are about 13-25 times slower than a plainsum(::Array{Int})
. But they are 8-17 times faster thansum(::Array{Union{Int, Null}})
.Full benchmark on Julia 0.6.0: