Repository navigation
Memory and compute improvements in internals of DistributedOperator and solvers #215
Copy link
Copy link
Open
Labels
enhancementNew feature or requestNew feature or request
Description
Motivation
This page captures a plan for 3-steps PR that aims to provide memory and compute improvements mostly arising from fewer temporaries, copies and collectives in DistributedArray and CG/CGLS. Each Phase below will become one commit in the PR.
Context
The rationale of this work comes from large-scale experiments that showed suspicious high peaks of memory usage and slowdowns due to use of swap memory. More specifically, when solving a large problem with pylops-mpi CGLS, about 40-50% of each iteration goes to full-vector copies and temporaries. On top of that, every small array operation runs blocking collectives.
Execution
Phase A - DistributedArray (pylops_mpi/DistributedArray.py)
A1. Build result arrays without collectives
- Add a private
_like(self, local_array=None, dtype=None). It creates the instance with
object.__new__and copies_global_shape,_base_comm,_base_comm_nccl,_partition,
_axis,_mask,_sub_comm(reused, never split again),_local_shape,_engineand
dtype. It then takeslocal_arrayor allocatesncp.empty(local_shape, dtype). It runs no
checks and no collectives, because the metadata comes from an array that is already valid. - Cache
local_shapesafter the first allgather, inself._local_shapes. If__init__was
givenlocal_shapes, store them there._likecopies the cache. - Use
_likein__neg__,add,multiply,conj,copy,zeros_likeandempty_like.
Also use it inStackedDistributedArray.zeros_likeand.empty_like, which currently call
distarray.local_shapes.
A2. Arithmetic with out= and real in-place operators
- Add one private helper,
_ufunc(self, ufunc, other, out):- If
otheris aDistributedArray, run the existing_check_partition_shapeand
_check_mask, then useother.local_array. Otherwise use the scalar or array as is. - If
outisNone, setout = self._like(dtype=result_dtype), whereresult_dtypefollows
the current behaviour (self.dtype). - For SCATTER and UNSAFE_BROADCAST:
ufunc(self.local_array, other_arr, out=out.local_array).
This is one pass with no temporary. numpy and cupy ufuncs both acceptout=. - For BROADCAST: keep
out[:] = ufunc(...), so the rank-0 broadcast in__setitem__still
runs and the semantics don't change.
- If
- Change the public methods to
add(x, out=None)andmultiply(x, out=None), and add
subtract(x, out=None). All three go through_ufunc, so existing calls behave the same. - Rewire the operators:
__sub__callssubtract. Today it isadd(-x), which allocates the negation first.__iadd__,__isub__and a new__imul__useout=self.iaddkeeps its name and callsadd(x, out=self).__neg__usesncp.negative(..., out=...).__rsub__is missing, soscalar - xfails today. Leave it out.
- Edge case: if
out=would need an unsafe cast (for example complex into a float32out),
numpy raises. Today[:]=casts silently with a warning. For that case, go back to the
allocate-then-assign path so behaviour stays the same.
A3. dot and norm
- Use
ravel()instead offlatten()indot(line 836) andnorm(line 997). For contiguous
local_array,ravel()is a view. - Add private
_local_dot(other, vdot)and_local_sqnorm(), which return the un-reduced
partial sum.dotbecomesallreduce(_local_dot(...)). The BROADCASTto_distpath stays as
it is. - Add a fast path for 2-norms in
_compute_vector_norm: computeabs(vdot(a, a))locally,
allreduce it as float64, then take the square root. This drops the two float64 temporaries.
Otherordvalues keep the current code.- Precision change: the local partial sum is accumulated in float32 for float32 data, the
same asdotalready does, instead of float64 today. Cost histories will move at the level
of float32 rounding.
- Precision change: the local partial sum is accumulated in float32 for float32 data, the
Phase B - StackedDistributedArray (same file, from line 1274)
- Change
add,iadd,multiply,__neg__,__sub__and__isub__, and addsubtractand
__imul__.- Each method allocates its result once with
self.empty_like()(now free of collectives), or
usesout/self. - It then calls the child method with
out=out[iarr], recursing into nested stacks. - Today, for example,
adddoesself.copy(), then a child add that builds a new array plus a
temporary, then[:]copies the result. That is three full passes where one is enough. - Add
out=Nonetoadd,subtractandmultiply, matching the DistributedArray API.
- Each method allocates its result once with
- Fused reductions in
dotandnorm(ord=2):- Fast path: walk the leaves, add up their
_local_dotor_local_sqnorm, and run a single
allreduce on the sharedbase_comm. - Only take it when every leaf is SCATTER with
mask is Noneand they all sharebase_comm
andbase_comm_nccl. Otherwise use the current per-child loop.
- Fast path: walk the leaves, add up their
Phase C - Solvers (pylops_mpi/optimization/cls_basic.py)
CGLS (setup at line 316, step at line 381)
- Persistent buffers:
x,c,s, andself.r(the previousr, reused as scratch).q
andrare new arrays each iteration only becauseOp.matvecandOp.rmatvecallocate them.
Operators are out of scope. - New
step, using the in-place operators from Phases A and B:qq = self.q.dot(self.q, vdot=True) if self.damp != 0.0: qq += self.damp * self.c.dot(self.c, vdot=True) a = float(np.abs(self.kold / qq)) self.c.multiply(a, out=self.r) # previous r is dead after the last c update x += self.r self.q *= a # q is replaced by the next matvec self.s -= self.q r = self.Op.rmatvec(self.s) if self.damp != 0.0: x.multiply(self.damp, out=self.r) r -= self.r k = float(np.abs(r.dot(r, vdot=True))) b = k / self.kold self.c *= b self.c += r self.r = r self.q = self.Op.matvec(self.c) ... cost1 = self.cost[-1] if self.damp == 0.0 else sqrt(cost**2 + damp * |x.x|)
- The arithmetic is the same as today:
x + a*c,s - a*q,r + b*c. The only differences
should come from how rounding falls without=. - Make the same changes in
setup: drop thex * dampandx.dot(x)work whendamp == 0,
and usevdot=Trueinstead of.conj(). - Leave alone: in
setup,damped_x = x * dampandcost1use the un-squareddamp, while
stepusesdamp**2. Serial pylops does the same (pylopscls_basic.pylines 581 and 605).
I'm noting it here, not fixing it in this change.
CG (step at line 114)
Opc = self.Op.matvec(self.c)
a = float(self.kold / np.abs(self.c.dot(Opc, vdot=True)))
Opc *= a
self.r -= Opc
self.c.multiply(a, out=Opc) # Opc is free now; same space as c
x += Opc
k = float(np.abs(self.r.dot(self.r, vdot=True)))
self.c *= k / self.kold
self.c += self.r- Watch the order of
c.dot(Opc): today it isc.dot(Opc.conj()), notvdot. For complex
data, keepself.c.dot(Opc.conj()), since its conj copy is unavoidable. Usevdotonly where
both operands are the same vector.
ISTA and FISTA (cls_sparsity.py)
No solver changes. They get the Phase A and B savings automatically.
Tests (tests/test_distributedarray.py, tests/test_stackedarray.py)
Reuse the existing par* fixtures. Add tests for:
out=results: each method without=matches the operator result (real and complex,
scalar and array operand). For+=,-=and*=, check thatlocal_arrayis the same object
before and after (is).- Masked arrays:
(a + b).sub_comm is a.sub_comm, so no new communicator is created. - BROADCAST: results are unchanged and equal on every rank.
- Stacked arrays: nested-stack arithmetic with
out=. A fuseddot/normequals the sum of
the per-child results, both on the fast path and the fallback path (mixed partitions or masked
children). - Solvers: the existing
test_cg*andtest_cgls*tests intests/test_solver.py
(broadcast and stacked variants) must pass unchanged.
Reactions are currently unavailable
Metadata
Metadata
Assignees
Labels
enhancementNew feature or requestNew feature or request
Activity