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

CircularBuffer: Add benchmarks and improve performance #641

Open
wants to merge 8 commits into
base: master
Choose a base branch
from

Conversation

vyu
Copy link

@vyu vyu commented Jul 8, 2020

This PR adds a set of benchmarks for CircularBuffer and also improves its performance.

Here are some PkgBenchmark reports using the benchmarks and comparing the new performance:

The benchmark file can be called directly by PkgBenchmark using the script keyword. For example, PkgBenchmark.benchmarkpkg("DataStructures"; script="benchmark/bench_circular_buffer.jl").

I've added review comments to explain the rationale behind some of the changes.

There was some inconsistency in the code over whether to use accessor functions (capacity(cb)) or dot notation (cb.capacity) to read field values. For consistency within the file, I've modified them to use accessor functions. They generate identical code since the functions get inlined.

I haven't worked on append! and fill! yet because improving those is more complicated. I might get to them in the future if this PR goes through.

Thanks!

Comment on lines -41 to +37
return ifelse(idx > n, idx - n, idx)
return idx > n ? idx - n : idx
Copy link
Author

Choose a reason for hiding this comment

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

Based on testing, using the ternary operator here rather than the branchless conditional helps the compiler optimize away an allocation when iterating through Iterators.Reverse(cb). This is used for example in foldr.

@@ -18,6 +18,8 @@ end

CircularBuffer(capacity) = CircularBuffer{Any}(capacity)

Base.IndexStyle(::Type{<:CircularBuffer}) = IndexLinear()
Copy link
Author

Choose a reason for hiding this comment

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

IndexLinear (as opposed to the default IndexCartesian for AbstractArrays) allows functions to use an optimized Base._mapreduce. This, for example, speeds up sum.

Comment on lines -31 to -36
Base.@propagate_inbounds function _buffer_index_checked(cb::CircularBuffer, i::Int)
@boundscheck if i < 1 || i > cb.length
throw(BoundsError(cb, i))
end
_buffer_index(cb, i)
end
Copy link
Author

Choose a reason for hiding this comment

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

This can be collapsed into _buffer_index, which can be simplified without loss of performance. Callers can use @inbounds to enable or disable bounds checking.

Comment on lines -52 to 51
@inline Base.@propagate_inbounds function Base.getindex(cb::CircularBuffer, i::Int)
cb.buffer[_buffer_index_checked(cb, i)]
Base.@propagate_inbounds function Base.getindex(cb::CircularBuffer, i::Int)
j = _buffer_index(cb, i)
@inbounds return cb.buffer[j]
end
Copy link
Author

Choose a reason for hiding this comment

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

If i is inbounds for cb, then _buffer_index(cb, i) is guaranteed inbounds for cb.buffer. We can separate these into two lines and annotate the second with @inbounds.

Also, @inline here is redundant because @propagate_inbounds implies @inline.

@@ -154,25 +154,21 @@ end

Return the number of elements currently in the buffer.
"""
Base.length(cb::CircularBuffer) = cb.length

Base.eltype(::Type{CircularBuffer{T}}) where T = T
Copy link
Author

Choose a reason for hiding this comment

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

This is already defined for the AbstractArray{T} supertype.

@@ -154,25 +154,21 @@ end

Return the number of elements currently in the buffer.
"""
Base.length(cb::CircularBuffer) = cb.length
Copy link
Author

Choose a reason for hiding this comment

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

Base.length(::AbstractArray) is already a generic function that calls size. For consistency with Julia Base, we can define just size instead.


"""
size(cb::CircularBuffer)

Return a tuple with the size of the buffer.
"""
Base.size(cb::CircularBuffer) = (length(cb),)

Base.convert(::Type{Array}, cb::CircularBuffer{T}) where {T} = T[x for x in cb]
Copy link
Author

Choose a reason for hiding this comment

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

This convert was slower than the generic convert.

Comment on lines -205 to -208
Base.@propagate_inbounds function Base.last(cb::CircularBuffer)
@boundscheck (cb.length == 0) && throw(BoundsError(cb, 1))
return cb.buffer[_buffer_index(cb, cb.length)]
end
Copy link
Author

Choose a reason for hiding this comment

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

This was no faster than the generic last function. The situation is different for first because the generic first does not know about cb.first, so the specialized first here is a shortcut. There is no such shortcut for last because we don't track the last index directly.

Copy link
Author

@vyu vyu Jul 11, 2020

Choose a reason for hiding this comment

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

Reverted the complete removal and instead replaced with a shorter definition. I was mistaken. Even though benchmarks showed no benefit, it might still be helpful on some architectures or code because the specialized method allows the bounds check to be elided by @inbounds, whereas the generic last does not.

@vyu vyu marked this pull request as ready for review July 8, 2020 03:21
Copy link
Member

@eulerkochy eulerkochy left a comment

Choose a reason for hiding this comment

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

Great work!! A few minor changes spotted at first glance !

cap = capacity(cb)
cb.length = cap
total = 0
for _ in 1:cap
Copy link
Member

Choose a reason for hiding this comment

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

In this codebase, we use the convention for i = 1:num for iterating over ranges. Also, I know it's trivial, but consider replacing _. It's there in few other places too.

cap = capacity(cb)
cb.length = cap
total = 0
for _ in 1:cap
Copy link
Member

Choose a reason for hiding this comment

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

Here, too!

@vyu
Copy link
Author

vyu commented Jul 8, 2020

Thanks! I've modified lines with range iteration to follow your suggestion.

@vyu
Copy link
Author

vyu commented Jul 10, 2020

I just noticed that setindex!(cb::CircularBuffer, item, i) returns cb. This has been the case since the beginning (when CircularBuffer was added in v0.4.3) and this PR retains that behavior. But per setindex! in Julia Base, it should really return item, not cb. I think this is a bug that ought to be fixed, but fixing it is potentially breaking if there is code out there that depends on this unconventional behavior. I can open a separate PR for it.

Previous removal was in error because adding this definition allows the
boundscheck to be elided by `@inbounds`.
Copy link
Member

@oxinabox oxinabox left a comment

Choose a reason for hiding this comment

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

Sorry for losing track of this. This PR looks good.
Can it be rebased, then we can merge it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants