A new "outer_similar"
ChrisRackauckas opened this issue · 11 comments
While trying to get generic codes working for AbstractArray
s, I realized that there's a pretty crucial abstraction missing: the ability to create the same array type with a different element type.
There is a distinction to be made here. copy(A)
creates a copy, so its type is the same. You cannot change the element type. Using the constructors can require different syntax for each AbstractArray
for clear reasons.
Now, the clear way to go seems to be similar
, but similar
is not guaranteed to return the same outer array type. For one thing, it returns a similar
mutable array type, so a similar(::SArray)::MArray
. In addition, similar(::SharedArray)::Array
for a reason that doesn't make much sense to me, but maybe that's because I am using similar
differently:
Even here @tkelman noted:
I agree with the opinion that similar is underspecified and has conflicting uses, and choosing the type it should return is all too often a subjective judgement call or dependent on how many arguments it's called with. For example the case of what to return for similar(::SharedArray) has gone back and forth, ref #5893 and #12964, and I think either choice has left multiple people unsettled and disagreeing. That's a pretty good sign that something is off with the abstraction.
So I am proposing that we have a new function like similar
which guarantees that the returned array matches in the outer type. While outer_similar(A)
is essentially a copy, the main usages being for outer_similar(A,T)
to change the element type and outer_similar(A,T,indices...)
to change the size can be very useful in generic codes regardless of the arrays used.
Example
This comes up in many areas, and areas I have experienced it lately include DiffEq, numerical linear algebra, rootfinding, and neural nets. Essentially, the library code can be written all in terms of broadcasted and matrix operations. These scientific computing algorithms don't actually need to make use of indexing at all outside of a user's function: they just need to do "array-wide" operations. So as long as they can properly allocate arrays of the right type for their A .= B.*C
type of algorithms, they will generally work on all of these array types (even "weird arrays" like GPUArray
s which don't even have indexing defined).
Assume C
is the user's input array that you want to match types with. Without this proposed outer_similar
, there is no good way to define the pre-allocated A
in a way that you know it matches the array types of the user input, since similar
can change things. In simple algorithms, one can get by with copy
. But if you want the algorithm to be unit-safe, for example here you would want to define B
to match the user's input array but be unitless. In this case you would want to B=similar(C,T)
where T
is the unitless element-type of A
. But this is where we run into the issue that similar
isn't guarenteed to return the same type. If you did A = copy(C); B = similar(C,T)
, now you have two SharedArray
s and an Array
, or things like that. For StaticArrays, this is also an issue if you want to make an initial B
in a mutable type which you update later, since this will silently change to an MArray
instead of an SArray
and worsen the performance (though in that case we will need to use A = B.*C
, but that's a different issue). If we want this broadcast to be automatically distributed or parallel, this type of type-changing will cause "auto-parallelism of generic algorithms to fail".
This makes me happy because Julia is so so so close to having auto-parallelism of generic algorithms work, as shown by tests on DiffEq (SciML/DifferentialEquations.jl#176).
Current Workaround
There is a current workaround in some cases. You can pre-allocate and change types using broadcast. Want to make a unitless version? B = C./C
. Need to promote the types? B = C .+ D
. But this is an overly expensive way to get around the fact that similar
is not the right abstract for this job.
I am open to ideas for a different name than outer_similar
. It's bad, but I thought writing down this argument was better than waiting for a good name.
Doesn't this use case require a mutable array? If so, then the fact that similar
returns something mutable even if it's a different kind of array is just what's needed here. I think the wrong decision was made for SharedArray
; the original purpose of similar
was absolutely to return an array of the same type if possible. (Re: immutability, linking to #19992). Are there other kinds of mutable arrays where similar
does the wrong thing for this purpose?
Another spelling is B = convert(AbstractArray{NewT}, C)
. This isn't guaranteed to not alias, though, so Base.LinAlg
uses copy_oftype
for this purpose.
Doesn't this use case require a mutable array?
No. It's perfectly fine to iteratively update the values of a type regardless of the implementation detail of mutable or immutable. Simple case: initial value u0
and solving u_n = a*u_{n-1} + 1
. We can do
mutable struct MyType{T<:Number,T2<:AbstractArray}
a::T
u::T2
end
You can do something like:
function evolve!(mt::MyType{T,T2}) where T where T2<:AbstractArray{T3} where T3<:Union{Number,SArray} # Immutable dispatch, since there's no immutable trait
mt.u = mt.a .* mt.u .+ 1
end
function evolve!(mt::MyType)
mt.u .= mt.a .* mt.u .+ 1
end
so that way all work with the reasonable interpretation:
mt = MyType(2.0,2.0)
mt = MyType(2.0,[2.0])
mt = MyType(2.0,@SVector([2.0]))
This is essentially a minimal example for diffeq, rootfinding, iterative numerical linear algebra solvers, optimization, and nerual nets BTW. Most packages don't have the extra code path for immutables since it can lead to a lot of code duplication, but it can be very useful if Number
and small arrays make sense in applications (example: Black-Scholes SDE) (though another issue may fix this).
Now that example works with "standard numbers", but if you do this with unitful numbers it can fail due to 1
not matching units. And if you want that 1
to be some type of stored array, for example u_n = a*u_{n-1} + u_{n-2}
under some interpretation where units make sense, you need to make sure that the extra value is pre-set with the correct units, whether or not it's a number, SharedArray, GPUArray, etc.
I don't quite see how a similar
-like function fits in there. If similar
returned an immutable array, what would you then do with it?
If similar returned an immutable array, what would you then do with it?
For the u_n = a*u_{n-1} + u_{n-2}
case:
mutable struct MyType{T<:Number,T2<:AbstractArray}
a::T
u::T2
u2::T3
end
Assume you want to start with u_{n} = 0
for n<0
. You would want to iterate using evolve!
, and update the saved u2
array, either u2 .= u
and u2 = u
depending on immutability, with a unit-fix in there. But the extra values are just an implementation detail, you still want the user to just provide u0
and a
. So how do you get constructors
mt = MyType(2.0,2.0)
mt = MyType(2.0,[2.0])
mt = MyType(2.0,@SVector([2.0]))
to work? If you use u2=similar(u,T)
(in the array case), then u2
will unnecessarily end up an MVector
in a setup when everything should be immutables.
As mentioned earlier, the generic way to handle the units here would be to initialize with u2=u./a
, but it seems heavy-handed to do a full array operation just to get the correct type.
Another spelling is B = convert(AbstractArray{NewT}, C). This isn't guaranteed to not alias, though, so Base.LinAlg uses copy_oftype for this purpose.
Yes, copy_oftype
is what I am looking for, and it seems to be used for a very similar purpose.
Ah, so it's a placeholder value that will never be read? I guess you could leave the field undefined at first. That's a bit unsatisfying, but creating values intended not to be used seems pretty marginal to me as well.
It's not totally clear to me if you want a copy, or just a new array with undefined or zero'd contents. If a copy, then T.(a)
(where T
is the new element type) might work.
It's not totally clear to me if you want a copy, or just a new array with undefined or zero'd contents.
That completely depends on the application. But it's still all the same: an abstraction for "get me the same kind of array, but change the element type to T
".
It's not totally clear to me if you want a copy, or just a new array with undefined or zero'd contents. If a copy, then T.(a) (where T is the new element type) might work.
That might actually be a good solution.
Nevermind:
using GPUArrays
u0 = GPUArray(rand(Float32, 32, 32))
Float64.(u0)
errors, while similar(u0,Float64)
does just fine.
For the case of SharedArray
, perhaps the main reason for returning an array is illustrated here (with julia -p 2
:
julia> sima(dims) = (a = Array{Float64}(dims); sum(a))
sima (generic function with 1 method)
julia> sims(dims) = (a = SharedArray{Float64}(dims); sum(a))
sims (generic function with 1 method)
julia> using BenchmarkTools
julia> @benchmark sima((3,3)) seconds=1 gcsample=true
BenchmarkTools.Trial:
memory estimate: 160 bytes
allocs estimate: 1
--------------
minimum time: 38.926 ns (0.00% GC)
median time: 39.664 ns (0.00% GC)
mean time: 40.159 ns (0.00% GC)
maximum time: 41.671 ns (0.00% GC)
--------------
samples: 5
evals/sample: 434
julia> @benchmark sims((3,3)) seconds=1 gcsample=true
BenchmarkTools.Trial:
memory estimate: 13.34 KiB
allocs estimate: 349
--------------
minimum time: 899.431 μs (0.00% GC)
median time: 937.509 μs (0.00% GC)
mean time: 948.774 μs (0.00% GC)
maximum time: 1.021 ms (0.00% GC)
--------------
samples: 4
evals/sample: 1
We create a lot of arrays for temporary purposes, and that performance gap is so extraordinary that it makes a certain amount of sense to say "if you want a shared array, you'd better create it explicitly."
I also agree that the alternative makes sense; with our current API I am not sure there's a good answer here.
Is there any good reason for Float64.(a)
to give an error for some type of array? It seems to me that should be the spelling for "copy this array to a similar array with a different element type". It can even be overloaded specifically if necessary.