recent broadcast changes (iterate by default), scalar struct, and `@.`

The current broadcasting behavior is non-intuitive when used with @.
Julia Version 0.7.0-DEV.5222,
Commit 7144b6b816 (2018-05-25 20:23 UTC)

This is a minimal example, stripped down from a real use case.

struct Material
    a::Float64
end
chi(m::Material, energy) = m.a / energy

m1 = Material(1.0)
energy = 1.5:0.5:2.5
chi1 = @. chi(m1, energy)

┌ Warning: broadcast will default to iterating over its arguments in the future. Wrap arguments of
│ type `x::Material` with `Ref(x)` to ensure they broadcast as "scalar" elements.

This is probably following
https://github.com/JuliaLang/julia/pull/26435

Replacing m1 with Ref(m1) in the last line, as suggested by the error message, results in an error:

chi1 = @. chi(Ref(m1), energy)
┌ Warning: broadcast will default to iterating over its arguments in the future. Wrap arguments of
│ type `x::Material` with `Ref(x)` to ensure they broadcast as "scalar" elements.
│   caller = ip:0x0
└ @ Core :-1
ERROR: MethodError: no method matching chi(::Base.RefValue{Material}, ::Float64)
Closest candidates are:
  chi(::Material, ::Any) at REPL[2]:1

Following the doc and adding
Base.broadcastable(m::Material) = Ref(m)
does help (no warning, no error).
But it this is a very surprising behavior:
by default, a struct should be considered as a scalar.

I’ll have to read again relevant issues such as
https://github.com/JuliaLang/julia/issues/18379
https://github.com/JuliaLang/julia/issues/18618
for now, it is not clear why broadcasting could not be an opt-in behavior,
with iterators opting-in in their interface definition.

Another take, showing that the Ref() tip does not work:

f(x, y) = println(x, " ",  y)
x = 1:3
@. f(x, Ref(x))
1 Base.RefValue{Int64}(1)
2 Base.RefValue{Int64}(2)
3 Base.RefValue{Int64}(3)

Trying to prepend a $ to prevent broadcasting (like before a function)
did not work either.

@. f(x, $x)
1 1
2 2
3 3
2 Likes

Because @. is a syntax-level transformation, it doesn’t treat Ref as a special case in any way. So your

@. chi(Ref(m1), energy)

becomes

chi.(Ref.(m1), energy)

which does indeed try to call chi(Ref(m1), e) for each e in energy and thus produces the error you’re seeing.

If you don’t use @., then the Ref works fine:

julia> chi.(Ref(m1), energy)
3-element Array{Float64,1}:
 0.6666666666666666
 0.5               
 0.4 

But I agree that the deprecation warning isn’t very helpful in this case, and you probably won’t be the only person to encounter this issue.

I’m not sure what could be done here. Using a 1-element tuple instead of a Ref also fixes the warning and works fine with @.:

julia> @. chi((m1,), energy)
3-element Array{Float64,1}:
 0.6666666666666666
 0.5               
 0.4 

Perhaps we should be recommending that instead?

6 Likes

I think that the cleanest solution would be a dedicated wrapper type that just protects from broadcast and does nothing else, along the lines of #18379. All other uses can clash with something.

But even a dedicated wrapper type would still do the wrong thing in the case of @. . If you write:

@. f(Wrapper(x))

then this expands to f.(Wrapper.(x)) which will try to actually call f on a Wrapper object, rather than on x, just as with the Ref above.

Of course, the @. macro could be modified to special-case :Wrapper calls, but that would break referential transparency, as const my_wrapper = Wrapper would cause broadcasting to behave differently.

The tuple trick works here because (x,) isn’t a function call and doesn’t have to be transformed by @. .

Actually, maybe I’m wrong? It might be possible to use the fancy new lazy broadcast to make this work. I’m still learning the new API.

Would this work?

@. chi($Ref(m1), energy)

Checkout the transformation with macroexpand:

Julia-0.7.0-DEV> macroexpand(Main, quote @. chi(Ref(m1), energy) end)
quote
    #= REPL[21]:1 =#
    chi.(Ref.(m1), energy)
end

Julia-0.7.0-DEV> macroexpand(Main, quote @. chi($Ref(m1), energy) end)
quote
    #= REPL[23]:1 =#
    chi.((Ref)(m1), energy)
end
2 Likes

indeed, not pretty, but it works.

Thanks for the explanations.

@. chi((m1,), energy)

works, but because of the additional coma, seems less readable than

@. chi([m1], energy)

Admittedly the @. macro could intercept these single element array construction and transform them to Ref().

But it is still surprising that a simple struct type is not treated as simply as possible by broadcasting.
That is, as a scalar.

Thinking about this, overly eager broadcasting is only a problem with @., which is a syntactic transformation, so escaping it should be achieved with syntax, too; and thus using $ is probably the best solution.

Note that above the problem comes not from broadcasting per se, but the @. macro, for which all f(...) forms are function calls, and so it can’t distinguish those functions which happen to be constructors for a type.

With basic broadcasting the recommended Ref() does work, which is not the case in a @. macro, true.

But there is a warning too:

chi.(m1, energy)
┌ Warning: broadcast will default to iterating over its arguments in the future. Wrap arguments of
│ type `x::Material` with `Ref(x)` to ensure they broadcast as "scalar" elements.

Having to use Ref() here is non-intuitive.
Should basic types be treated as scalars, Ref() would not be needed at all.

Perhaps my point was not clearly communicated: there are no “types” in the expression @. gets, only function calls in an abstract syntax tree. Even type constructors are just function calls, and for a f(args...) expression, @. can’t know of f is an (inner) constructor, for multiple reasons.

Consider:

ex1 = :(@. Foo(x, y))

ex2 = macroexpand(ex1)

Then both

struct Foo
    x
    y
end

eval(:(let x = $([1, 2]),
           y = $([3, 5])
       $ex2
       end))

and

Foo(x, y) = x+ y

eval(:(let x = $([1, 2]),
           y = $([3, 5])
       $ex2
       end))

are valid.

Thanks for bringing this up — it’s a very good point that the suggested deprecation simply won’t work in a @. deprecation. There are several alternatives, but they all have their downsides:

  • You can protect the Ref from being dotted by using $Ref like was suggested above. This is a special syntax that just applies to the macro, though, so it’s not terribly intuitive.
  • You can also use a tuple literal like @. f(x, (y,)) to treat y kind-of like a scalar. For most purposes this should be just as fast and scalar-like as a Ref, but it’s not 100% scalar-like: if x is scalar-like or a zero-dimensional array, f.(x, Ref(y)) will return an unwrapped scalar, whereas f.(x, (y,)) will return a 1-tuple. Some array types might also not support broadcasting with tuples as nicely as they do scalars.

The solution here, I think, will be a special syntax that will work everywhere to put values in a Ref. We’ve been talking about &y. That’s currently available and would make for a better alternative all around.

8 Likes

Is it that advantageous to not treat structs as scalars? It seems like this adds a lot of complexity for what appears to me would be the most popular use-case…

1 Like

I advocated pretty strongly for the new broadcast behavior, so I can address that part. The first problem is that it’s useless for f.(x) to be equivalent to f(x) — you bothered to write a dot, so we ought to do something with it.

We could potentially base broadcasting on whether an argument implements iterate, but that makes the calling code kind of opaque: given f.(a, b, c, d) you have to “guess” which arguments are iterable. It’s also conceivable that a type could add or remove an iterate method, and that would silently change the behavior of your program. So we’re erring on the side of explicitness here.

3 Likes

There’s also a number of asymmetries here:

  • In the future, we’ll iterate over the object or throw an error. You can always treat objects as scalars, but you cannot always treat them as iterables. By defaulting to errors for non-iterables, it’s a great big signal for library creators that they can opt-in to acting like a scalar if it is appropriate.
  • End-users can opt-in to scalar-like behaviors by wrapping in another broadcastable container (like Ref or 1-tuples or 0-dimensional arrays). Were we to default to treating everything like a scalar, that means that they would have to collect an iterable into a different broadcastable structure in order to iterate over it.
  • In 0.6 and prior, we had it the other way around —defaulting to scalar-like behavior it meant that it was up to each library and type to decide how they’d behave. Even with base itself, this led to lots of surprises — some folks wanted to iterate over sets but treat dicts as scalars. Or broadcasting the array-ish Linear Algebra I object was quite a surprise.

There’s going to be some pain in converting to this new system, but I think the resulting consistency will make it much easier to understand what a given broadcasting expression will do.

3 Likes

Does that mean that in the future, something like rand(3,3) .+ 3 would throw? I guess not, meaning that numbers will be treated specially.

Also, since any type can be treated as a scalar with Base.broadcastable(m::T) = Ref(m), knowing what an expression will do is not more transparent.

Couldn’t we broadcast iterate over iterables and instead of throwing an error, just treat non-iterables as scalars?

The point is that we strongly advocate not implementing broadcastable(m::T) = Ref(m) if m is iterable. Sure, custom types can break this norm (and, in fact, strings do), but we want the exceptions to this rule to be rare and, well, exceptional. Numbers are actually iterable and 0-dimensional-like structures, so they’re not a special case. Types are a good example of deciding to opt-in to scalar-like behaviors (for things like round.(Int, A)), but they’re not iterable, so they don’t violate the norm.

If we were to default to scalars, then we end up with a mishmash of behaviors because some iterables will — intentionally or not — use that default while others will implement the fancier behavior.

Now, you’re right, we could try to do what you mean in the fallback, based upon the existence of the iterate method. That’s not something we’ve really done in any base API yet, and as far as I know, it’s not really optimized to be used as such yet. By being conservative here, we can turn this error into the scalar functionality if we implement the fast optimization and decide we want this in the future.

I agree. I am trying to see is if we can avoid having to do that if m is not iterable.

Hmm that seems strange to me, shouldn’t numbers not be iterable? What does it mean to iterate over a single number?

Hmmm, I don’t see why. The default would be to iterate if the object is iterable, and treat as a scalar otherwise.

If someone makes a particular object that is both iterable and behaves as a scalar under broadcast (by using the above broadcastable definition), that definitely would be confusing, but that situation is not changed by my proposal.

How do we currently decide if an object will be iterated under broadcasting?

To make sure I understand properly, this is my summary:

it seems like the current design is

if x is iterable; iterate under broadcast
elseif x is a Ref (or some kind of 0D object); treat x as a scalar under broadcast
else throw error

which I am suggesting be replaced with the simpler

if x is iterable; iterate under broadcast
else; treat x as a scalar under broadcast
1 Like

There are a number of different designs bouncing around in this thread:

  • The 0.6 behavior of defaulting to scalar — this is what I was addressing in “If we were to default to scalars…”
  • The 0.7 behavior with depwarns — this behavior is the worst of all worlds and a transition to the:
  • The 1.0 behavior of defaulting to iteration — this will error if the struct isn’t iterable
  • Your proposed test for iterability and either iterate or treat as scalar

Broadcasting requires axes, indexing, and ndims. We call broadcastable on all arguments to allow them to return something that supports those methods.

In 1.0, the default broadcastable method will be:

broadcastable(x) = collect(x)

Custom types can add their own optimizations to avoid allocating the intermediate structure — for example, arrays support those three methods so we define broadcastable(A::AbstractArray) = A for them.

1 Like

Ah I see, currently there is no test for iterability: we just call the method and if it doesn’t exist it errors out. I thought there might have been a trait for that.

Yes in that case my suggestion is harder to implement.