Difference between generating function forms

If I have a vector

vals = [1, 2, 3, 4, 5]

and want to check if all values are e.g. < 7 I could either do

all(val -> val < 7, vals)

or

all(val < 7 for val in vals)

Is there a difference between these approaches? I have some vague recollection of a situation where I used the former form (although the situation might have been a bit different) and someone advice be against using anonymous functions.

There is no one specific place where I want to use this, but I realise I have a package where both have been used, and I don’t really know if there are situations where one is preferred. Also, I will likely write a lot of call like this as I continue to use Julia, so it would be useful to know if there is a difference.

3 Likes

Using @which and @less it seems that calling any(iter) (the latter case) just defaults to any(identity, iter) (the former case).

So the only difference is that in the latter case the comparison is wrapped in a Base.Generator and in the former the comparison is performed by the call to any.

Some basic testing shows no difference in performance and I suspect that the performance of generator expressions (ie f(x) for x in X) has been extensively optimised so I wouldn’t expect there to be any significant difference for most, if not all, cases.

ETA: I think the normal advice against anonymous functions is only when they reference variables not passed to them (ie f(x) = x + a). There are issues of scope and capture here that can affect performance. But for pure functions I don’t think there are any performance implications, especially compared to using a generator expression which does essentially the same thing.

2 Likes

The advice against capturing (and reassigning) variables also applies to generator expressions and comprehensions. From the docs:

Generators are implemented via inner functions. Just like inner functions used elsewhere in the language, variables from the enclosing scope can be “captured” in the inner function. For example, sum(p[i] - q[i] for i=1:n) captures the three variables p , q and n from the enclosing scope. Captured variables can present performance challenges; see performance tips.

Whether you manually instantiate a generator or manually instantiate a function for different all methods, either way you’re still 1) making a function, 2) iterating said function over vals, and 3) checking if all results return true and short-circuiting false otherwise.

3 Likes

To make this a bit more precise: Capturing variables is completely fine as long as the variables are effectively constant, i.e. never assigned to twice.
A rather minimal example:

julia> function foo(x)
       a = 5
       res1 = sum(y->y*x+a, 1:10)
       a = 6 # a is assigned twice
       res2 = sum(y->y*x+a, 1:10)
       return res1,res2
       end
foo (generic function with 1 method)

julia> foo(5); @time foo(5); # shows allocations
  0.000015 seconds (2 allocations: 48 bytes)

julia> function bar(x)
       a = 5
       res1 = sum(y->y*x+a, 1:10)
       b = 6 # just renamed the second a to b
       res2 = sum(y->y*x+b, 1:10)
       return res1,res2
       end
foo (generic function with 1 method)

julia> bar(5); @time bar(5); # no allocations
  0.000002 seconds

This is always a bit surprising since the second assignment to a can never influence any of the closures (first one has ran and the second one has not yet run) but Julia’s lowering currently does not know about these dependencies yet and thus always boxes variables if they are used in closures and assigned to multiple times.

3 Likes

There’s a third way:

all(<(7), vals)

NB:

julia> <(7)
(::Base.Fix2{typeof(<), Int64}) (generic function with 1 method)

julia> <(7) === Base.Fix2(<, 7)
true
3 Likes

Thanks everyone, this is really useful!

Does that mean that stuff like

all(check(val) for val in vals)

might actually allocate? Right now I am using this all the time to explicitly avoid allocation, instead of e.g.

all(check.(vals))

It shouldn’t allocate unnecessarily, but the two argument version has potential to be more efficient.

1 Like

Well, but this assumes the closure is actually called at that place:

julia> function foo2(x)
             a = 5
             res1 = sum(y->y*x+a, 1:10)
             tmp1 = map(y->y*x+a, 1:10)  # eager version
             tmp2 = Iterators.map(y->y*x+a, 1:10)  # lazy variant
             a = 6 # a is assigned twice
             res2 = sum(y->y*x+a, 1:10)
             return res1,sum(tmp1),res2,sum(tmp2)
       end
foo2 (generic function with 1 method)

julia> foo2(5)  # oops
(325, 325, 335, 335)
1 Like

Yes I understand that :slight_smile: That’s the reason for the box after all.
However in my innocent example it is somewhat “obvious” for every human and still the compiler fails to emit reasonably efficient code.

In your example, you could still optimize and avoid boxes by rearranging the code slightly (without changing its semantics). I think, if closures don’t ever leave the function body, then it should be possible to completely avoid any form of box. At least if the closures never write to the closed over variables.

The other obvious inefficiency is that Box is always untyped (due to limitations of the lowering stage essentially) which introduces unnecessary type instabilities to code that superficially looks perfectly type-stable (such as yours). This makes this compiler limitation quite surprising I’d say. I am looking forward to someone writing another optimization pass to eliminate (or at least type) Boxes to remove this footgun :slight_smile:

1 Like