Set vs vector lookup in Julia: a closer look

By: Blog by Bogumił Kamiński

Re-posted from: https://bkamins.github.io/julialang/2022/04/08/fast2.html

Introduction

It was April 1st, when I was writing my last post, but my friends, after
having read it, told me that it was not very funny. So today I get back to boring
style.

In the previous post I was comparing lookup speed in vector vs set in Julia and
considered a scenario when lookup in vector was faster. This is not something
that normally we should expect, but clearly I must have chosen the benchmark
settings in a way that lead to such a result.

Today let me go back to that example and discuss it in more detail.

The post was written under Julia 1.7.0, DataFrames.jl 1.3.2,
BenchmarkTools.jl 1.3.1, and Plots.jl 1.27.1.

The experiment

Let me remind you the experiment setting. I tested the lookup performance for
collections from size 10 to 40. I used random strings and then performed a check
if every value stored in the collection is indeed present in it.

Here is the test code I used with one twist. In my original post I have measured
total time of lookup. This time I measure time of lookup per single element
(the difference is in the plot command where now I divide everything by df.i).

using DataFrames
using BenchmarkTools
using Random
using Plots

f(x, r) = all(in(x), r) # function testing lookup speed

df = DataFrame()
Random.seed!(1234)

for i in 10:40
    v = [randstring(rand(1:1000)) for _ in 1:i] # randomly generate a Vector
    s = Set(v) # turn it to Set for comparison
    @assert length(s) == i # make sure we had no duplicates
    t1 = @belapsed f($s, $v)
    t2 = @belapsed f($v, $v)
    # display the intermetiate results so that I can monitor the progress
    @show (i, t1, t2)
    push!(df, (;i, t1, t2))
end

plot(df.i, [df.t1 df.t2] ./ df.i;
     labels=["Set" "Vector"],
     legend=:topleft,
     xlabel="collection size",
     ylabel="time per lookup in seconds")

The code produces the following plot:

Benchmarks plot 1

We can see that the lookup time of one element in Set is roughly constant, but
for Vector it grows linearly. This is expected. Amortized time of random
lookup in Set is O(1) while for Vector it is O(n), where n is
collection size.

Last week I have computed total time, which is roughly linear for Set while it
is quadratic for Vector. However, I have scaled the plot in a way that it was
not very visible that the curve for Vector is a parabola.

Now we can clearly see that we can expect that for larger collections Set will
be faster. Let us pick i = 1000 as an example (not very large number, but big
enough):

julia> i = 1000;

julia> v = [randstring(rand(1:1000)) for _ in 1:i];

julia> s = Set(v);

julia> @assert length(s) == i

julia> @belapsed f($s, $v)
8.8383e-5

julia> @belapsed f($v, $v)
0.001030348

As you can see now Vector is much slower.

But why was Vector faster than Set for the original data?

Understanding lookup process in Set and in Vector

When you search for a value in a Set Julia roughly does the following steps:

  1. compute hash of the value you look for;
  2. check if Set contains some values that have the same hash (there might be
    more than one such value due to collisions);
  3. if yes then check if any of them are equal to the value we have provided.

When you search for a value in a Vector the process is simpler: Julia checks
sequentially checks elements of a vector if they are equal to the value you
passed. So as you can see the extra cost of using Set is that you need to
perform hashing.

In my example I have generated the data using the following expression:
[randstring(rand(1:1000)) for _ in 1:i]. As you can see these are random
strings that in general can be quite long (up to 1000 characters). Hashing such
strings is expensive. The additional trick I did was to generate the strings so
that with high probability they have a different length. I did this to make
sure that string comparison is fast. As you can check in the implementation of
isequal in Julia it falls back to == in this case, which for String is:

function ==(a::String, b::String)
    pointer_from_objref(a) == pointer_from_objref(b) && return true
    al = sizeof(a)
    return al == sizeof(b) && 0 == _memcmp(a, b, al)
end

We can observe that if strings have unequal lengths they are compared very fast.

In summary, I have baked the example so that hashing is expensive but comparison
is cheap. If I used shorter strings, e.g. of length 2, then even for i = 10
on the average Set lookup would be faster than Vector lookup:

julia> i = 10;

julia> v = [randstring(2) for _ in 1:i];

julia> s = Set(v);

julia> @assert length(s) == i

julia> @belapsed f($s, $v)
1.47515625e-7

julia> @belapsed f($v, $v)
1.8996439628482973e-7

Of course for very short collections, like e.g. length 1 Set will be slower
even in this case as it still would compute hash.

Understanding volatility of Set timing

An additional issue that we have noticed last week is smoothness of Vector
plot and volatility of Set plot. What is the reason for this? There are two
factors here in play:

  1. For each i I use random strings of different lengths. This means that
    hashing cost differs per experiment. On the other hand for Vector we do
    not need to do hashing and, as I commented above, we can immediately decide
    that strings are different just by looking at their length.
  2. For different i Set has different percentage of occupied slots, which
    means that number of hash collisions that can happen differs.

Conclusions

My takeaways from today’s post are:

  • benchmarking is useful, but it is important to understand the data structures
    and algorithms that one uses;
  • when doing visualization it is really important to think what measures to use
    so that the plots are informative;
  • and finally: Set’s not dead (although sometimes indeed it is not an optimal
    data structure; in general I recommend you to have a look at the options
    available in DataStructures.jl).

oneAPI.jl status update

By: Tim Besard

Re-posted from: https://juliagpu.org/post/2022-04-06-oneapi_update/index.html

It has been over a year since the last update on oneAPI.jl, the Julia package for programming Intel GPUs (and other accelerators) using the oneAPI toolkit. Since then, the package has been under steady development, and several new features have been added to improve the developer experience and usability of the package.

@atomic intrinsics

oneAPI.jl now supports atomic operations, which are required to implement a variety of parallel algorithms. Low-level atomic functions (atomic_add!, atomic_xchg!, etc) are available as unexported methods in the oneAPI module:

a = oneArray(Int32[0])function kernel(a)
    oneAPI.atomic_add!(pointer(a), Int32(1))
    return
end@oneapi items=256 kernel(a)
@test Array(a)[1] == 256

Note that these methods are only available for those types that are supported by the underlying OpenCL intrinsics. For example, the atomic_add! from above can only be used with Int32 and UInt32 inputs.

Most users will instead rely on the higher-level @atomic macro, which can be easily put in front of many array operations to make them behave atomically. To avoid clashing with the new @atomic macro in Julia 1.7, this macro is also unexported:

a = oneArray(Int32[0])function kernel(a)
    oneAPI.@atomic a[1] += Int32(1)
    return
end@oneapi items=256 kernel(a)
@test Array(a)[1] == 512

When used with operations that are supported by OpenCL, this macro will lower to calls like atomic_add!. For other operations, a compare-and-exchange loop will be used. Note that for now, this is still restricted to 32-bit operations, as we do not support the cl_khr_int64_base_atomics extension for 64-bit atomics.

Initial integration with vendor libraries

One significant missing features is the integration with vendor libraries like oneMKL. These integrations are required to ensure good performance for important operations like matrix multiplication, which currently fall-back to generic implementations in Julia that may not always perform as good.

To improve this situation, we are working on a wrapper library that allows us to integrate with oneMKL and other oneAPI and SYCL libraries. Currently, only matrix multiplication is supported, but once the infrastructural issues are worked out we expect to quickly support many more operations.

If you need support for specific libraries, please have a look at this PR. As the API surface is significant, we will need help to extend the wrapper library and integrate it with high-level Julia libraries like LinearAlgebra.jl.

Correctness issues

In porting existing Julia GPU applications to oneAPI.jl, we fixed several issues that caused correctness issues when executing code on Intel GPUs:

  • when the garbage collector frees GPU memory, it now blocks until all outstanding commands (which may include uses of said memory) are completes

  • the barrier function to synchronize threads is now marked as convert to avoid LLVM miscompilations

Note that if you are using Tiger Lake hardware, there is currently a known issue in the back-end Intel compiler that affects oneAPI.jl, causing correctness issues that can be spotted by running the oneAPI.jl test suite.

Future work

To significantly improve usability of oneAPI.jl, we will add support to the KernelAbstraction.jl package. This library is used by many other packages for adding GPU acceleration to algorithms that cannot be easily expressed using only array operations. As such, support for oneAPI.jl will make it possible to use your oneAPI GPUs with all of these packages.

Astonishing performance of lookup in vectors in Julia

By: Blog by Bogumił Kamiński

Re-posted from: https://bkamins.github.io/julialang/2022/04/01/fast.html

Introduction

Several years ago I switched to Julia because of its speed. However, every day I
am positively surprised how fast it is.

Today I was writing a simulation where I needed to check if some value was
contained in some collection. Traditionally programmers are recommended to use
the Set structure to perform such operation. However, since I needed to
squeeze out every last bit of performance in my code I investigated all
available options. Julia developers positively surprised me as it seems that
they managed to implement an extremely fast lookup in a standard Vector
type.

Therefore in my post today I decided to share some benchmark results showing
how fast lookup in Vector container is against Set lookup.

The post was written under Julia 1.7.0, DataFrames.jl 1.3.2,
BenchmarkTools.jl 1.3.1, and Plots.jl 1.27.1.

The experiment

In order to test scalability of lookup I decided to test the performance for
collections from size 10 to 40 to see if some trend can be observed. I wanted
to store in them random strings and then perform a check if every value stored
in the collection is indeed present in it.

To ensure fair evaluation of the results I used the @belapsed macro
from the BenchmarkTools.jl package. Here is the test code:

using DataFrames
using BenchmarkTools
using Random
using Plots

f(x, r) = all(in(x), r) # function testing lookup speed

df = DataFrame()
Random.seed!(1234)

for i in 10:40
    v = [randstring(rand(1:1000)) for _ in 1:i] # randomly generate a Vector
    s = Set(v) # turn it to Set for comparison
    @assert length(s) == i # make sure we had no duplicates
    t1 = @belapsed f($s, $v)
    t2 = @belapsed f($v, $v)
    # display the intermetiate results so that I can monitor the progress
    @show (i, t1, t2)
    push!(df, (;i, t1, t2))
end

plot(df.i, [df.t1 df.t2];
     labels=["Set" "Vector"],
     legend=:topleft,
     xlabel="collection size",
     ylabel="time in seconds")

The code produces the following plot:

Benchmarks plot 1

In the plot it seems that performance of function f doing lookup for both
collections scales approximately linearly in the tested range. The lookup in
Set is slower than the lookup in Vector. Additionally, which is positive,
lookup time in Vector has a more stable timing (Set timings are strangely
jagged). Finally, it seems that the difference in the timing increases with
collection size. Let us visualize this:

plot(df.i, df.t1-df.t2;
     labels="Set-Vector",
     legend=:topleft,
     xlabel="collection size",
     ylabel="time difference in seconds")

Benchmarks plot 2

Indeed it seems that the timing difference increased in the tested range of
collection sizes.

Conclusions

It is getting late today, and I do not want to delay my weekly posts. Therefore,
I will do some more testing in the coming week and report you the results in my
next post.

From today’s experiments we can see that in the test setting I used
Vector lookup was significantly faster than Set lookup.

As a side note, it was super easy to implement these benchmarks in Julia.