Broadcasting over an anonymous function much slower than map

I am on Julia 1.3. I do not understand why broadcasting over an anonymous function is much slower than map:

julia> x1, x2 = rand(10^6), rand(10^6);

julia> @time ((x1, x2) -> x1 < x2).(x1, x2);
  0.265166 seconds (241.28 k allocations: 11.668 MiB, 55.32% gc time)

julia> @time ((x1, x2) -> x1 < x2).(x1, x2);
  0.127725 seconds (241.26 k allocations: 11.668 MiB)

julia> @time ((x1, x2) -> x1 < x2).(x1, x2);
  0.118113 seconds (241.27 k allocations: 11.667 MiB)

julia> @time map((x1, x2) -> x1 < x2, x1, x2);
  0.049027 seconds (75.05 k allocations: 4.463 MiB)

julia> @time map((x1, x2) -> x1 < x2, x1, x2);
  0.039501 seconds (75.05 k allocations: 4.462 MiB)

julia> @time map((x1, x2) -> x1 < x2, x1, x2);
  0.060107 seconds (75.07 k allocations: 4.466 MiB, 13.32% gc time)

Notes:

  1. if anonymous function is defined in inner scope (e.g. within a function) this does not happen
  2. if anonymous function is passed as an argument to a function and used in this function then this does still happen
    (for normal functions this is not the case)
  3. this does not happen for normal functions

CC
@nalimilan - this is related to auto-splatting design
@mbauman - maybe you know the answer immediately (or this question has been asked)

It’s simply due to using @time at global scope. Every time you write an anonymous function, Julia creates a new type (and I mean “write” literally — it’s a syntax thing). This means that repeatedly @timeing an expression that includes a newly written anonymous function will result in compilation overhead.

As you might imagine, there’s a bigger compilation overhead for broadcasting than there is for map.

julia> x1, x2 = rand(10^6), rand(10^6);

julia> @time ((x1, x2) -> x1 < x2).(x1, x2);
  0.108282 seconds (305.77 k allocations: 15.174 MiB, 5.31% gc time)

julia> @time map((x1, x2) -> x1 < x2, x1, x2);
  0.081244 seconds (249.05 k allocations: 13.298 MiB)

julia> f1(x1, x2) = ((x1, x2) -> x1 < x2).(x1, x2)
f1 (generic function with 1 method)

julia> f2(x1, x2) = map((x1, x2) -> x1 < x2, x1, x2)
f2 (generic function with 1 method)

julia> @time f1(x1, x2);
  0.075796 seconds (274.78 k allocations: 13.131 MiB, 6.82% gc time)

julia> @time f1(x1, x2);
  0.002112 seconds (8 allocations: 126.578 KiB)

julia> @time f2(x1, x2);
  0.031752 seconds (81.93 k allocations: 4.838 MiB)

julia> @time f2(x1, x2);
  0.002374 seconds (9 allocations: 976.922 KiB)

Better to use BenchmarkTools, in general.

8 Likes

Right - thank you! I need to use @time because I am also interested in timings that include compilation overhead.

1 Like

Yeah, you can see this even with the simple:

julia> a = ()->nothing
#7 (generic function with 1 method)

julia> b = ()->nothing
#9 (generic function with 1 method)

julia> a === b
false

julia> typeof(a) === typeof(b)
false

So when you pass a newly written anonymous function to a function, there will be some new compilation — even if you passed the syntactically identical anonymous function before — because the newly written one has a new type.

There’s a possible future optimization that would identify syntactically identical anonymous functions to re-use the same types when possible (although perhaps just at the REPL #21113),

7 Likes

Note that, by the same token, you can use anonymous functions without incurring compile overhead more than once in the global scope by simply preassigning them:

julia> x1, x2 = rand(10^6), rand(10^6);

julia> f = (x1, x2) -> x1 < x2
#3 (generic function with 1 method)

julia> @time f.(x1, x2);
  0.198223 seconds (435.04 k allocations: 20.128 MiB, 10.93% gc time)

julia> @time f.(x1, x2);
  0.001045 seconds (6 allocations: 126.469 KiB)

julia> @time map(f, x1, x2);
  0.088391 seconds (248.75 k allocations: 13.249 MiB)

julia> @time map(f, x1, x2);
  0.002658 seconds (5 allocations: 976.766 KiB)

Then broadcast can be faster than map

4 Likes