Defining a function with Iterators.filter - slower performance?

Hello! Fairly new to Julia, I’m having an unexpected performance issue in defining a function that returns a Iterators.filter object.

I have a simulation that frequently requires me filtering all of my agents on some property, so I thought it would be DRY to capture that in a function – conditional_property_iterator below. However, when I attempt to actually run that function, it’s making almost twice as many allocations as the raw iterator and taking almost 2x as long.

What am I missing here that’s causing this performance issue?

I’ve got an MRE below, any help would be greatly appreciated. Note the adding rand() in the example below is just to do something inside the loop, rather than the actual logic I have in my simulation.

using BenchmarkTools

struct Thing
    somevalue::Int
end

things = [Thing(rand(1:5)) for _ = 1:100000]

threes_getprop = Iterators.filter(a -> getproperty(a, :somevalue) === 3, things)
threes_prop = Iterators.filter(a -> a.somevalue === 3, things)

conditional_property_iterator(iterable, property::Symbol, value) = Iterators.filter(a -> getproperty(a, property) === value, iterable)
threes_cond = conditional_property_iterator(things, :somevalue, 3)

function test_threes_getproperty()
    for i in threes_getprop
        i.somevalue + rand(Int)
    end
end

function test_threes_prop()
    for i in threes_prop
        i.somevalue + rand(Int)
    end
end

function test_threes_cond()
    for i in threes_cond
        i.somevalue + rand(Int)
    end
end
julia> @btime test_threes_getproperty()
  2.834 ms (119301 allocations: 2.12 MiB)

julia> @btime test_threes_prop()
  2.750 ms (119301 allocations: 2.12 MiB)

julia> @btime test_threes_cond()
  4.491 ms (219301 allocations: 3.65 MiB)

I am not entirely sure what causes what you are seeing. But there are many global variables in quite tight loops, so I’m not sure that I would trust any of these numbers very much.

What may capture what you’re asking, without globals, is this comparison:

julia> @btime sum(1 for a in $things if a.somevalue === 3)  # uses Iterators.filter, hard-coded .somevalue
  107.542 μs (0 allocations: 0 bytes)
19792

julia> sum_prop(field, things) = sum(1 for a in things if getproperty(a, field) === 3);

julia> @btime sum_prop(:somevalue, $things)  # uses Iterators.filter, field name as an argument
  107.875 μs (0 allocations: 0 bytes)
19792

julia> @btime sum_prop($(Ref(:somevalue))[], $things)  # trying to defeat constant propagation
  1.686 ms (100000 allocations: 1.53 MiB)
19792

Even faster, here, is to avoid Iterators.filter entirely. If the loop predictably moves forwards, instead of having to check every element, it can often be optimised better. This may even pay the cost of allocating an intermediate array to store things:

julia> @btime count(a.somevalue === 3 for a in $things)  # iterates all elements, no Iterators.Filter
  15.541 μs (0 allocations: 0 bytes)
19792

julia> @btime sum(map(a -> a.somevalue == 3, $things))  # makes an Array, could use `filter`
  43.583 μs (2 allocations: 97.73 KiB)
19792

Above I’m using the syntax for Iterators.filter, instead of writing that out:

julia> (x^2 for x in 1:10 if x>5)
Base.Generator{Base.Iterators.Filter{var"#60#62", UnitRange{Int64}}, var"#59#61"}(var"#59#61"(), Base.Iterators.Filter{var"#60#62", UnitRange{Int64}}(var"#60#62"(), 1:10))

julia> [x^2 for x in 1:10 if x>5]  # collect(ans)
5-element Vector{Int64}:
  36
  49
  64
  81
 100
2 Likes

Thanks for the response! I wasn’t aware of the alternative syntax for Iterators.filter, which is super h elpful.

I appreciate all the alternatives you provided, but I’m still not understanding why my function version is slower. In my application I’m not accumulating or doing any exclusively forward loops. I’m still iterating through each individual item and then running some complex logic for each item, and it doesn’t reduce to anything at the end. For more context, I am also using FLoops.jl to speed up these loops, so I am still looking for something that would allow me to iterate through items in a for loop.

I overlooked your comment about the global variables and rewrote the testing function. Still seeing more allocations/longer for the last one.

using BenchmarkTools

struct Thing
    somevalue::Int
end

things = [Thing(rand(1:5)) for _ = 1:100000]

threes_getprop = Iterators.filter(a -> getproperty(a, :somevalue) === 3, things)
threes_prop = Iterators.filter(a -> a.somevalue === 3, things)

conditional_property_iterator(iterable, property::Symbol, value) =
    Iterators.filter(a -> getproperty(a, property) === value, iterable)
threes_cond = conditional_property_iterator(things, :somevalue, 3)

function test_threes(iter)
    for i in iter
        i.somevalue + rand(Int)
    end
end


@btime test_threes(threes_getprop)
@btime test_threes(threes_prop)
@btime test_threes(threes_cond)
julia scratch/mre.jl
  293.765 μs (0 allocations: 0 bytes)
  289.539 μs (0 allocations: 0 bytes)
  1.961 ms (100000 allocations: 1.53 MiB)

I’m sure how to look into those allocations more from what I have here.

Seems like the culprit is the capturing of the property value by the closure argument of filter. This exhibits the same behaviour:

function conditional_property_iterator2(iterable, value)
    property = :somevalue
    Iterators.filter(a -> getproperty(a, property) === value, iterable)
end