Feat: WIP - Enable ND-Distributed arrays on operators#198
Conversation
|
@mrava87, thanks this makes sense, looks like a good start. I will add some sub-task pointers in a couple of days here itself, which we would need to cover 🙂 . |
|
@rohanbabbar04 sounds good, feel free to take it from here 😉 |
269a3a9 to
2286152
Compare
|
@mrava87, I made some changes to make our dot work with ND arrays matching
# Running with 2 ranks
second_deriv = MPISecondDerivative(dims=200)
b_diag = MPIBlockDiag(ops=[MatrixMult(A=np.ones((100, 100)))])
summa = b_diag + second_derivFor
|
@rohanbabbar04 thanks for continuing on this 😄 Let me have a look in the next couple of days so I can provide a more informed feedback |
| from pylops_mpi.DistributedArray import local_split | ||
|
|
||
|
|
||
| def reshaped( |
There was a problem hiding this comment.
I follow this but I still have a question 😉
Before we always assumed that the input of a _matvec/_rmatvec was a 1D Distributed Array so when we decorated them with @Reshaped, inside here we will always have to create a new ND Distributed array and fill it (for a moment I am ignoring the ghost cell path).
Now, we want to enable the scenario when a ND Distributed array is passed directly to the _matvec/_rmatvec of an operator, so when we get into this the array may already by ND. At that stage if cells_front=cells_back=0, would you not say that this method could just pass x to f?
Also, again along similar lines
if len(y.global_shape) > 1:
# Make sure y is distributed along axis=0 before applying ravel
y = y.redistribute(axis=0).ravel()
does this not mean that the output is still again always flattened, so we can't yet have an operator that takes a ND Distributed arrays and returns a ND Distributed array? Maybe somehow related to you second point ('I actually...) But then, is this not defeating a bit the real purpose of this PR?
PS: whether we can or not enable ND Arrays flowing through ops and solvers, one by-product of this PR, which we should definitely merge is the improvement in efficiency when it comes to the setting of local_array in various places 😄
There was a problem hiding this comment.
@rohanbabbar04 I left some comments (mainly questions and thoughts).
In general:
- I do agree with your new
local_arrayapproach, I think it already saves quite a few numpy array copies we used to make 😄 _local_dimsand_local_dimsd, I do see their value (and fine withSimpleNamespacesapproach)... I was just not sure if they are always added or just in some cases (ie for some operators); mostly unsure if this will lead to scenarios where an operator expects them but they may not be there?- Not sure about your example, but just to be clear, we do not want/can to allow every possible combination of operators to lead to a ndarray compatible case.. I tried to mimic something like this with PyLops
`second_deriv = SecondDerivative(dims=200)
b_diag = BlockDiag(ops=[MatrixMult(A=np.ones((100, 100))), MatrixMult(A=np.ones((100, 100)))])
summa = b_diag + second_deriv
print(b_diag.dims, second_deriv.dims)
x = np.ones(200)
y = summa @ x
print(y.shape)
x = np.ones((2, 100))
y = summa @ x
print(y.shape)
and get (2, 100) (np.int64(200),) (200,) (2, 100), so basically the dims of the 2 ops differ but I think because we flatten in dot -> reshape in each op -> reflatten in each op -> reshape in dot, both case work... so I guess the same applies to the pylops-mpi, hence you sticking to this flatten in dot -> reshape in each op -> reflatten in each op -> reshape in dot pattern?
One last suggestion: try to have a go with one of the operators you have now enabled ND arrays to go end-to-end with the solution of an inverse problem. Can we go all the way ND? One of the initial use case that led me to think about this was your FFT, which may benefit from avoid to ever go back to 1d with redistribution but sticking all the time to ND (other than any code we have fro example in tutorials where it would be nicer for a user not to have to think in flattened local arrays of a global nd array but just work with nd local arrays
|
|
||
| y = Op.matvec(u) # Op * u | ||
| x = Op.rmatvec(v) # Op'* v | ||
| y = Op * u # Op * u |
There was a problem hiding this comment.
Nice idea about the local_array
| Distribution axis in the reshaped global array. Defaults to ``0``. | ||
| """ | ||
| local_shapes = self.base_comm.allgather(local_shape) | ||
| global_shape = list(local_shapes[0]) |
There was a problem hiding this comment.
Does this assume that if the array is scattered all dims but the one of axis are the same? Do you think its worth a check+raise or we shall just assume the user does it correctly (at least maybe mention this in the docstring?)
| Engine used to store array (``numpy`` or ``cupy``) | ||
| dtype : :obj:`str`, optional | ||
| Type of elements in input array. Defaults to ``numpy.float64``. | ||
| local_array : :obj:`numpy.ndarray` or :obj:`cupy.ndarray`, optional |
There was a problem hiding this comment.
Since most of the inputs are positional, I would move this a bit earlier maybe close to local_shapes... and a few things to consider when one passes local_array:
- dtype can be conflicting, so maybe we should say if local_array is passed dtype is ignored and local_array.dtype used instead?
- same for engine?
- similarly local_shapes may be contrasting, maybe here I would raise an error if both are passed to avoid confusion?
I understand users may not find this useful (aka it is more for developers) but since it surface as an input of the class constructor, I think we should treat it as is and make sure it does not lead to confusing scenarios...
| self._copy_attributes( | ||
| Op, | ||
| exclude=['dims'] | ||
| exclude=['dims', '_local_dims'] |
There was a problem hiding this comment.
So this _local_dims is not set in MPILinearOperator constructor, so if one does MPILinearOperator(Op) with Op being a PyLops operator, this will not be present... thinking if this could lead to any scenario where it is always expected but it may not always be there?
There was a problem hiding this comment.
For example here you don't set _local_dimsd ?
| raise ValueError(msg) | ||
| is_dims_shaped = x.global_shape == self.dims | ||
| if is_dims_shaped: | ||
| x = x.redistribute(axis=0).ravel() |
There was a problem hiding this comment.
Mmh back to one of my original comments: why do we need ravel here, and is with the new local_array now a much cheaper (view-only) operation?
There was a problem hiding this comment.
But since redistributed is for sure not cheap, why you see this as needed here? And why ravel, other than maybe to be as close as possible to what we do in pylops?
There was a problem hiding this comment.
@mrava87 My main thought was to try and mimic what we do in pylops, which was flattening and then apply the _matvec and then reshape back to dimsd like we do in pylops. With the introduction of local_array, copy would be cheap, but yeah with redistribution it will be expensive. So would you suggest we keep NDArray only for specific ops like FFTs(and maybe other....), since we used to redistribute(expensive operation) it to axis 0 to apply the ravel at the end.
There was a problem hiding this comment.
@rohanbabbar04 I am not sure, my initial thought was 'let's follow the PyLops steps' but then already when I started toying around I saw the issue with us recreating and reassigning all of these numpy arrays (as our local arrays) so I had the idea that maybe for the purpose of pylops-mpi (solve one very large problem in a distributed manner*) we could just avoid doing any ravel-(reshape-apply-flattened)-reshape with (reshape-apply-flattened) done in the reshape decorator if an input was not a 1d array.
Now if this process was cheap as it seems now (up to redistribute) this would be purely stylistical but given the fact we do that, maybe we need to rethink the entire process instead of fitting the PyLops pattern in it?
- for pylops the idea of solving one or more flattened problems (with multiple RHS) makes somehow sense as in some applications one has a small problem, but many of them... this kind of goes away in pylops-mpi as if you reach for it the likely cause is your problem is so big it cant even fit in one single machine VRAM (or is very slow when solved in single-cpu mode), so my thought that maybe we can loosely allow a solver to operate with either flat or unflattened arrays just considering them all the time as if they were flattened (where basically only for dot and norm I see we needed some modifications, all +/-/* are already transparent to that)
. So would you suggest we keep NDArray only for specific ops like FFTs(and maybe other....), since we used to redistribute(expensive operation) it to axis 0 to apply the ravel at the end.
but what I pointed here is applied to all operators where one passes a ND-array and self.dims is not 1d, so I am not sure what you say would work? Perhaps I would suggest we take a tutorial, say poststack and see if what we have now (plus likely some small changes to the solvers) would allow going from doing
# Create flattened model data
m3d_dist = pylops_mpi.DistributedArray(global_shape=ny * nx * nz)
m3d_dist[:] = m3d_i.flatten()
to
# Create distributed model data
m3d_dist = pylops_mpi.DistributedArray(global_shape=(ny, nx, nz), axis=0)
m3d_dist[:] = m3d_i
where note that here this is kind of just stylistic and for ease of use (I always thought it would be nice for a user not to think in 1d distributed flattened arrays, but for other scenarios where an operator like FFT is part of the final Op it would become more important.
Right now I have the feeling that even doing d_dist = BDiag @ m3d_dist to the unflatted m3d_dist would hit the heavy redistribute?
There was a problem hiding this comment.
Hmm... Yeah, I was also thinking about the best way to tackle ND arrays, especially for operators like FFTs and derivatives where users would naturally want to preserve the multidimensional shape.
I was thinking of changing matvec slightly:
def matvec(self, x: DistributedArray) -> DistributedArray:
M, N = self.shape
if x.ndim == 1:
if x.global_shape != (N,):
raise ValueError("dimension mismatch")
return self._matvec(x)
if np.prod(x.global_shape) != N:
raise ValueError("dimension mismatch")
return self._matvec_nd(x)
The idea would be that _matvec_nd is optional. Operators that make more sense/reduce communication work with ND arrays (such as FFTs or derivatives) can implement it and preserve the input shape. Operators that don't have a meaningful ND implementation can simply leave _matvec_nd unimplemented (e.g., raising NotImplementedError) and continue to support only 1D distributed arrays through _matvec.
For FFTs, for example, we could move the implementation entirely into _matvec_nd, since the FFT is expensive when we are creating a 1-d array and the users would typically want to work with the ND dimensions rather than flattening the array.
Again, this is just an idea for now, and we can think it through some more. 🙂
There was a problem hiding this comment.
Right, the idea seems appealing but l would need to know a bit more to see if this makes sense 😉
First question: as we want to mantain backward compatibility, I guess for operators where it makes sense to implement _matvec_nd their _matvec would still leverage the @reshape decorator and then just call _matvec_nd?
Second question: this is something I am always very careful we honor, as I think it's one of the great features of pylops. Users should always be able to mix any combination of operators (provided shapes make sense) in the most transparent possible way (i.e., not having to know all the internals of the actual implementations of the operators). Now let' say we go for this _matvec_nd implementation, do you think we can end up in a situation where:
- a user can always create a combined operator and pass a 1D distributed array, even if somehow performance-wise suboptimal, like when you have an FFT operatr
- a user can always create a combined operator and pass a ND distributed array provided all of the operators in the combined operator support this (implement _matvec_nd)?
Again, I would maybe start by taking a tutorial like I suggested above and see if any of what you are thinking would enable/not-enable solving that problem with ND distributed arrays, and then maybe work backwards adding support to more operators and checking against other tutorials/examples
There was a problem hiding this comment.
Yup, sounds good, we can start with a tutorial and see the best way possible 🙂.
| base_comm: MPI.Comm = MPI.COMM_WORLD, | ||
| dtype: DTypeLike = np.float64): | ||
| dims = _value_or_sized_to_tuple(dims) | ||
| self._local_dims = self._local_dimsd = SimpleNamespace(dim=local_split(dims, base_comm, Partition.SCATTER, axis=0), axis=0) |
There was a problem hiding this comment.
Mmh I have not tried if it is possible but say we define the input DistributedArray with custom local_shapes (x) and then Op=MPIFirstDerivative and then do Op @ x... this is legit right, but now the local_split dims would not match the dims of the input array?
|
|
||
| @pytest.mark.mpi(min_size=2) | ||
| @pytest.mark.parametrize("par", [(par1), (par1j), (par2), (par2j)]) | ||
| def test_blockdiag_(par): |
There was a problem hiding this comment.
What is this testing (the name is cryptic)?
You do global_shape=BDiag_MPI.dims but Op as is defined is supposed (even in numpy) to take a 1d array (you would need to specify otherdims for this operator to allow one to pass a Numpy ndarray... or is this having global_dims[0] equal to the number of ranks so it is really a (1,par['nx']) local shape?
Just toying around with the idea of allowing ND-Distributed arrays to be consumed by operators (like in PyLops)
A few thoughts from this initial experiment:
dimsanddimsdto MPI Operators #199.@rohanbabbar04 FYI, this is as far as I got, some tentative code changes, an example, and some thoughts that came to my mind whilst toying around 😄