Broadcasting in Julia: the Good, the Bad, and the Ugly

By: Blog by Bogumił Kamiński

Re-posted from: https://bkamins.github.io/julialang/2022/06/24/broadcasting.html

Introduction

Recently I have written a post about @view and @views macros.
In what followed I got a feedback, that the topic I touched there was
indeed useful. Therefore for today I decided to write about another macro.
This time I will share my thoughts on the @. macro.

This post was written under Julia 1.7.2.

Understanding what @. macro does

The @. macro is used to inject broadcasting into every function call
in an expression passed to it.

Let us have a look at its docstring:

Convert every function call or operator in expr into a “dot call”
(e.g. convert f(x) to f.(x)), and convert every
assignment in expr to a “dot assignment” (e.g. convert += to .+=).

If you want to avoid adding dots for selected function calls in expr, splice
those function calls in with $. For example,
@. sqrt(abs($sort(x))) is equivalent to sqrt.(abs.(sort(x))) (no dot for sort).

The @. marco is quite useful when we work with long expressions that
involve several operations that we want to be broadcasted together. Here
is a minimal example:

julia> using Statistics

julia> x = 1:10
1:10

julia> @. sin(x)^2 + cos(x)^2
10-element Vector{Float64}:
 1.0
 1.0
 0.9999999999999999
 1.0
 0.9999999999999999
 0.9999999999999999
 0.9999999999999999
 1.0
 0.9999999999999999
 1.0

Writing @. sin(x)^2 + cos(x)^2 is much more convenient than writing
sin.(x).^2 .+ cos.(x).^2.

However, what if we wanted to compute variance of x? A direct approach
would be to write this operation as:

julia> sum((x .- mean(x)) .^ 2) / (length(x) - 1)
9.166666666666666

As you can see I use broadcasting in only two places, while most of the
operations are not broadcasted. If we wanted to use @. macro we would
need to use $ escaping and write something like:

julia> @. $/($sum((x - $mean(x)) ^ 2), $-($length(x), 1))
9.166666666666666

which is equivalent and ugly. You can check it by using @macroexpand:

julia> @macroexpand @. $/($sum((x - $mean(x))^2), $-($length(x), 1))
:(sum((^).((-).(x, mean(x)), 2)) / (length(x) - 1))

In this case also correct (but not equivalent) and simpler way would be:

julia> @. $sum((x - $mean(x))^2) / ($length(x) - 1)
9.166666666666666

However, in this case you need to know and be sure that by not escaping-out the
/ and - function calls in the second part of the expression you will not
affect the correctness of your calculation.

In summary, I do not use @. in complex expressions as it is usually hard to
reason about it.

Let us now switch to some special cases of using @..

Be careful with broadcasted assignment

Other common source of bugs when using @. is broadcasted assignment.

Let us analyze the following code:

julia> x = ["a", "b"]
2-element Vector{String}:
 "a"
 "b"

julia> x = @. length(x)
2-element Vector{Int64}:
 1
 1

julia> x
2-element Vector{Int64}:
 1
 1

julia> y = ["a", "b"]
2-element Vector{String}:
 "a"
 "b"

julia> @. y = length(y)
ERROR: MethodError: Cannot `convert` an object of type Int64 to an object of type String

In the case of x variable the @. macro is on the right hand side of the
assignment. In this case we get a fresh binding of value to variable x.

In the case of y the @. encompasses the left hand side of the assignment.
In this case the operation is in-place. Therefore in this case we get an error,
because you cannot store integers in a vector of strings.

Things, of course, can be silently wrong, as in the following example of
Vector{Char}, as Char supports conversion from integer:

julia> z = ['a', 'b']
2-element Vector{Char}:
 'a': ASCII/Unicode U+0061 (category Ll: Letter, lowercase)
 'b': ASCII/Unicode U+0062 (category Ll: Letter, lowercase)

julia> @. z = length(z)
2-element Vector{Char}:
 '\x01': ASCII/Unicode U+0001 (category Cc: Other, control)
 '\x01': ASCII/Unicode U+0001 (category Cc: Other, control)

julia> z
2-element Vector{Char}:
 '\x01': ASCII/Unicode U+0001 (category Cc: Other, control)
 '\x01': ASCII/Unicode U+0001 (category Cc: Other, control)

Incorrect handling of named tuples

Consider the following code:

julia> v = 1:3
1:3

julia> [x in (a=1, c=3) for x in v]
3-element Vector{Bool}:
 1
 0
 1

We can rewrite it using broadcasting as:

julia> in.(v, Ref((a=1, c=3)))
3-element BitVector:
 1
 0
 1

Now we think we could use the @. here as follows:

julia> @. in(v, $Ref((a=1, c=3)))
ERROR: UndefVarError: c not defined

However, this fails as @. macro incorrectly handles = inside NamedTuple
definition. We have to write:

julia> @. in(v, $Ref((; a=1, c=3)))
3-element BitVector:
 1
 0
 1

The ; at the beginning of NamedTuple definition gives an equivalent object
but changes how the code expression is transformed by the Julia compiler
and it works. Here is how we can check the difference in the representation
of (a=1, c=3) and (; a=1, c=3):

julia> dump(:(a=1, c=3))
Expr
  head: Symbol tuple
  args: Array{Any}((2,))
    1: Expr
      head: Symbol =
      args: Array{Any}((2,))
        1: Symbol a
        2: Int64 1
    2: Expr
      head: Symbol =
      args: Array{Any}((2,))
        1: Symbol c
        2: Int64 3

julia> dump(:(; a=1, c=3))
Expr
  head: Symbol tuple
  args: Array{Any}((1,))
    1: Expr
      head: Symbol parameters
      args: Array{Any}((2,))
        1: Expr
          head: Symbol kw
          args: Array{Any}((2,))
            1: Symbol a
            2: Int64 1
        2: Expr
          head: Symbol kw
          args: Array{Any}((2,))
            1: Symbol c
            2: Int64 3

Fortunately the case of NamedTuple is not likely to be problematic in practice
as it is extremely rare.

Conclusions

The @. macro can be very convenient. However, in my experience, you
need to be careful when you use it as it is easy to get surprising results
if you work with complex expressions. Out of the possible problematic situations
I have covered in my post a most common one is forgetting to add $ to avoid
broadcasting of some function calls in a complex expression.

I hope that you will find this post useful and it will help you to avoid
bugs in your Julia code using broadcasting!