Relax NumPy requirement in UCX (#3731)
Relax NumPy requirement in UCX (#3731)

* Make `device_array`'s shape a `tuple`

While it works to have this be a single `int` (as it will be coerced to
a `tuple`), go ahead and make it a `tuple` for clarity and to match more
closely to the Numba case.

* Use `"u1"` to specify `uint8` typed arrays

This is equivalent to using NumPy's `uint8`, but has the added benefit
of not requiring NumPy be imported to work.

* Rename `is_cudas` to `cuda_frames`

Matches the variable name in the `send` case to make things easier to
follow.

* Use `pack`/`unpack` for UCX frame metadata

As `struct.pack` and `struct.unpack` are able to build `bytes` objects
containing the frame metadata needed by UCX easily, just use these
functions instead of creating NumPy arrays each time. Helps soften the
NumPy requirement a bit.

* Rename `cuda_array` to `device_array`

Matches more closely to the name used by RMM and Numba.

* Create function to allocate arrays on host

To relax the NumPy requirement completely, add a function to allocate
arrays on host. If NumPy is not present, this falls back to just
allocating `bytearray` objects, which work just as well.

* Fix formatting with black

* Define `cuda_frames` with other frame definitions

* Store `nframes` for simplicity

Avoids multiple calls to `len(frames)`, is a bit easier to read, and
matches the receive code path more closely.

* Collect sizes along with other frame info

* Use `sizes` to pick out non-trivial frames to send

* Simply call `sum` on `sizes` for bytes sent

* Use `host_array` to make buffers to receive into

* Pack per frame metadata into one message

To send fewer and larger messages, pack both which frames are on device
and how large each frame is into one message.

* Note what `struct` lines are packing/unpacking
blaze/distributed,dask/distributed,dask/distributed,dask/distributed,dask/distributed,blaze/distributed