Promoting a broadcasted array

Consider:

julia> ifelse.([true, false], [1,2], [3.0,4.5])
2-element Vector{Real}:
 1
 4.5

This isn’t ideal; in this case clearly a concrete eltype would be better. I know I can write it as float.(ifelse.(...)) to solve this particular problem, but I’m curious to know if there’s a general solution through the broadcasting machinery. We have a macro that uses @. over user-written expressions that contain ifelse, and the goal is to end up with a concretely typed output without asking the user to change their code.

Alternatively, is there an efficient Base function that promotes all values in an Array to a concrete type? promote_to_same_type(Any[1,2,9.4]) that yields [1.0,2.0,9.4]?

One way:

julia> _ifelse(cond::Bool, x, y) = ifelse(cond, promote(x, y)...)

julia> _ifelse.([true, false], [1,2], [3.0,4.5])
2-element Vector{Float64}:
 1.0
 4.5

I don’t know why ifelse doesn’t work this way by default – when is it a good idea to avoid the branch of cond ? x : y while preserving a type instability?

2 Likes

I did think of that promote, but for us we also have missings, so _ifelse(true, 1, missing) has no option but return 1.

Perhaps this is a quirk, but stack does this:

julia> stack(Any[1,2,9.4])
3-element Vector{Float64}:
 1.0
 2.0
 9.4

julia> stack(Any[1,2,9.4,missing])   # is this a bug? missing is weird.
ERROR: MethodError: no method matching length(::Missing)

It accepts this input because numbers are iterable, but the precise promotion rules weren’t really chosen for this purpose.

[Edit] But this won’t help with missing, maybe nothing can.

2 Likes

I don’t think it makes sense to promote to a concrete type for ifelse because it wouldn’t occur for each element (ifelse(true, 0, 1.1) vs ifelse(false, 0, 1.1)) or in the equivalent if _ else _ end. However, the output array’s eltype could very plausibly be a small Union instead of Real. Base.Broadcast.combine_eltypes figures out the output’s eltype, and you actually lose that small Union along the way:

julia> let bb = Base.Broadcast, f = +, args = ([1, 2], [3.0, 4.5])
       bb.combine_eltypes(f, args) |> println
       (argT = bb.eltypes(args)) |> println
       (rt = Base._return_type(f, argT)) |> println
       Base.promote_typejoin_union(rt) |> println
       end
Float64
Tuple{Int64, Float64}
Float64
Float64

julia> let bb = Base.Broadcast, f = ifelse, args = ([true, false], [1, 2], [3.0, 4.5])
       bb.combine_eltypes(f, args) |> println
       (argT = bb.eltypes(args)) |> println
       (rt = Base._return_type(f, argT)) |> println
       Base.promote_typejoin_union(rt) |> println
       end
Real
Tuple{Bool, Int64, Float64}
Union{Float64, Int64}
Real

I don’t know if it would count as a breaking change to keep the small Union. You can skip combine_eltype with in-place broadcasting:

julia> zeros(2) .= ifelse.([true, false], [1,2], [3.0,4.5])
2-element Vector{Float64}:
 1.0
 4.5

julia> zeros(Union{Int, Float64}, 2) .= ifelse.([true, false], [1,2], [3.0,4.5])
2-element Vector{Union{Float64, Int64}}:
 1
 4.5
1 Like

Linking related old post.

FWIW, the following promote_array() function, inspired by the code above, seems to run a bit faster:

promote_array(v) = convert(Array{eltype(promote(map(zero, unique(typeof(v) for v in v))...))}, v)

v = rand((1, 4.5), 100_000)
promote_array(v)
@btime promote_array($v)  # 3.6 ms (99526 allocs: 2.28 MiB)
1 Like