Should scalar calculation in Broadcast be "lazy"?

At present, the broadcast in julia is always lazy, no matter the inputs’ axes.

I think, at least for numeric calcution, if both the input(s) and output are scalar, we could perform the calculation immediately, as the storage of Intermediate variable is always negligible. In other words we might merge the scalars during the broadcasted chain.

At present, a pre-defined function could merge const scalars well, like:

julia> a = randn(1000); b = randn(1000); c = similar(a,(1000,1000));

julia> f(x,y) = sin(exp(2pi)*x*y);

julia> @btime @. $c = f($a,$b');
  16.042 ms (0 allocations: 0 bytes)

while, a nested Broadcasted object not:

julia> @btime @. $c = sin(exp(2pi)*$a*$b');
  38.451 ms (0 allocations: 0 bytes)

Of course, we could avoid it by merge the scalars ourselves, like:

julia> temp = exp(2pi); @btime @. $c = sin($temp*$a*$b');
  16.034 ms (0 allocations: 0 bytes)

But I think such operation could be done during the broadcasted chain, just add a muti-dispatch like:

const AbstractScalar = Union{Number,AbstractArray{<:Number,0}}
broadcasted(::S, f, args::Vararg{AbstractScalar}) where {S<:BroadcastStyle}=
    combine_eltypes(f, args) <: Number ?  f(map(first,args)...) : Broadcasted{S}(f, args)

and

@btime @. $c = sin(exp(2pi)*$a*$b');
  16.306 ms (0 allocations: 0 bytes)

Since a * b * c will be transformed to *(a,b,c), the broadcasteded call for these expandable funtion should be expanded at once, i.e.

for op in (:+, :*, :&, :|, :xor, :min, :max, :kron)
    @eval begin
        @inline broadcasted(::typeof($op),x,y) = begin
            x′ = broadcastable(x)
            y′ = broadcastable(y)
            broadcasted(combine_styles(x′,y′), $op, x′, y′)
        end
        broadcasted(::typeof($op),x,y,args...) = begin
            temp = broadcasted($op,x,y)
            broadcasted($op,temp,args...)
        end
    end
end
1 Like

Maybe one shouldn’t change how the dot notation works, but only modify the @. macro to map to a version of broadcast that is not lazy for scalars.

When writing out each dot explicitly, it is easy to control exactly which operations are lazy. e.g.

@btime $c .= sin.(exp(2pi).*$a.*$b') # (fast) 

Sometimes broadcasted operations have side effects, for example:

println.(v) # print each element of v on a separate line

Hence, changing what operations are lazy will be breaking.

1 Like

I agree with compatibility is important, so a constraint that all the inputs and output are scalar numbers is made.

Scalar inputs ensure that the function will be only called once, and scalar output ensure that it can be savely replaced with a non-lazy version.

I didn’t run a test for this, if there’s other side effect, may be we can add some constaints for the broadcasted function, just like the broadcast between Range and Scalar/Range and Range

After all, the expansion of +(a,b,c) is need for consistency.
At present:

julia> @. (1:2) + (2:3) + 1
2-element Array{Int64,1}:
 4
 6

while

julia> (1:2) .+ (2:3) .+ 1
4:2:6
1 Like

A scalar function might be called more than once. For example, if I want to print out a string three times, I can do this:

@. isnothing(println("Hello")) * (1:3);

Changing how broadcasting works might make this code run faster, but then it would only print the string once, which would be a breaking change.

1 Like

I tried it, and it accually print 3 times with the additional dispatch. As

Meta.lower(Main,:(@. isnothing(println("Hello")) * (1:3);))
:($(Expr(:thunk, CodeInfo(
    @ none within `top-level scope'
1 ─ %1 = Base.broadcasted(println, "Hello")
│   %2 = Base.broadcasted(isnothing, %1)
│   %3 = 1:3
│   %4 = Base.broadcasted(*, %2, %3)
│   %5 = Base.materialize(%4)
└──      return %5
))))

The first line return nothing, thus it keeps lazy.

But if we define a function like f(x) = isnothing(println("Hello")), and call @. f(1) * (1:3), only one line will be printed.

As Scalar could also be seen as special Range, maybe we should limit this within built-in math function.

Really? That would be a bug then. On which version of Julia did you get this result?

Not a bug, the result of one “Hello” happened only if the addition dispatch has been created. :grinning:

julia> @. rand()*[1 1 1]
1×3 Array{Float64,2}:
 0.868946  0.0510332  0.869655

rand() produces a scalar number, yet modifying the behaviour changes the outcome and would thus be breaking.

You can achieve the behaviour you want by wrapping your expression in Ref:

julia> @. Ref(rand())[]*[1 1 1]
1×3 Array{Float64,2}:
 0.12594  0.12594  0.12594
4 Likes

Not sure what you mean. I tried this on Julia 1.0.5, Julia 1.4.2 and 1.5.0. In each case it prints “Hello” three times.
(In Julia 1.0.5 the function needs to be written as println("Hello") isa Nothing, but the result is the same.)

I meant I ran the code below before test.

const AbstractScalar = Union{Number,AbstractArray{<:Number,0}}
broadcasted(::S, f, args::Vararg{AbstractScalar}) where {S<:BroadcastStyle}=
    combine_eltypes(f, args) <: Number ?  f(map(first,args)...) : Broadcasted{S}(f, args)

Anyway, Sukera has shown another example with rand().

And the “non-lazy” is far from compatible.

I see. Yes, the rand() example is much better (and much more likely to appear in real code).

Fundamentally, this is a question of “do what I mean” vs “do what I say”. Julia typically stands on the “what I say” side, while many older languages contain a few “what I mean” heuristics. I like the fact that adding a dot guarantees that a function call will be broadcasted. If I don’t want that, I’ll just omit the dot…

2 Likes

Much easier to just use $ to suppress broadcasting of specific functions with @.:

julia> @. $rand() * [1,1,1]
3-element Array{Float64,1}:
 0.45567040553963833
 0.45567040553963833
 0.45567040553963833

This is described in the documentation for @..

Besides being breaking, I don’t think this is possible. Like any macro, @. is a syntactic transformation. It only knows how the expression “spelled”, not what anything means — it doesn’t know which functions or variables represent “scalars” and which are vectors.

7 Likes

Oh wow, I did not know about that! I might begin to use @. then :smiley:

1 Like