Skip to content
NEWKreativwit is open for business • Now onboarding new clients
Kreativwit
12 MIN READRAY 2.XPYTORCH 2.XCUDA

Moving GPU Tensors Between Ray Workers Without the Serialization Tax

Split a model across Ray actors and the GPUs often end up waiting on data rather than doing maths. This guide traces where a tensor actually goes when it crosses a Ray boundary, how to measure what that costs you, and four fixes in the order we would try them.

Ansul Agrawal
CTO • Kreativwit
SECTION 01 — THE SYMPTOM

Expensive GPUs, low utilisation

The pattern is common. A pipeline is split into stages, each stage runs as a Ray actor on its own GPU, and Ray passes the output of one stage to the next. It works, it scales out on paper, and then nvidia-smi shows the GPUs sitting well below full utilisation while requests queue up.

Adding GPUs rarely helps, because the GPUs are not the bottleneck. The time is going into moving data between them. Every handoff between stages has a cost that is easy to miss in the code and very visible on the bill.

SECTION 02 — WHERE THE TIME GOES

What happens when a CUDA tensor crosses a Ray boundary

Ray's object store lives in host memory, not on the GPU. So when one actor returns a CUDA tensor and another actor receives it, the data takes a long way round:

Each step on this path is a copy or a conversion, and each one runs on the critical path of the request:

DEFAULT PATH FOR A CUDA TENSOR BETWEEN TWO RAY ACTORS4 HOPS
  1. Stage A • GPU memory
    Output tensor produced on the device
    DEVICE → HOST
  2. Host memory
    Copied off the GPU and serialised
    SERIALISE
  3. Ray object store
    Written to shared memory, or sent to another node
    HOST → DEVICE
  4. Stage B • GPU memory
    Deserialised and copied back onto a device

A copy off the GPU and another back onto it over PCIe, a serialisation pass, a deserialisation pass and possibly a network hop, all to move data that could have gone straight from one GPU to the other. Two GPUs in the same machine may even share an NVLink connection that this path never touches.

pipeline.py • Python • the innocent-looking versionEVERY HANDOFF ROUND-TRIPS THROUGH THE HOST
@ray.remote(num_gpus=1)class Encoder:    def forward(self, batch):        return self.model(batch.cuda())  # returns a CUDA tensor @ray.remote(num_gpus=1)class Decoder:    def forward(self, features):        return self.head(features) # Ray stores the encoder's output and hands it to the decoder.features = encoder.forward.remote(batch)result = ray.get(decoder.forward.remote(features))
WORKSBUT PAYS THE TAX ON EVERY REQUEST
SECTION 03 — MEASURE FIRST

Prove where the time goes before changing anything

Do not start rewriting the data path on a hunch. Three tools, used in this order, will tell you whether handoffs are really the problem and which one hurts most.

Work from coarse to fine:

  1. Ray timeline

    Run ray timeline while the pipeline is under load and open the trace in Chrome or Perfetto. Long gaps between a task finishing on one actor and starting on the next are time spent moving data or waiting to be scheduled.

  2. PyTorch profiler

    Wrap one stage in torch.profiler.profile and look for cudaMemcpy and aten::copy_ entries. If copies take a large share of each step next to the actual kernels, handoffs are your bottleneck.

  3. Nsight Systems

    When you need the full picture, nsys profile shows PCIe transfers, kernel launches and idle gaps on one timeline. It is the clearest way to see a GPU waiting on a copy.

Write down three numbers before you touch the code: time per request, GPU utilisation per stage, and how many bytes cross each handoff. Every fix below should move at least one of them, and you want to know which.

SECTION 04 — FIX 1 • CPU HANDOFFS

Hand off NumPy arrays, not tensors, when data is on the CPU

Many pipelines start with a CPU stage: decoding audio or images, tokenising, resizing. Ray can read NumPy arrays out of its shared-memory object store without copying them, as long as the reader is on the same node. The array you get back is a read-only view into shared memory. Large data handed to Ray as other Python objects is usually rebuilt with a copy instead, so check the serialisation notes for the Ray version you run.

preprocess.py • Python • zero-copy read on the same nodeONE COPY INTO PINNED MEMORY, THEN ASYNC TO THE GPU
@ray.remotedef preprocess(raw) -> np.ndarray:    x = decode(raw)  # your CPU work    return np.ascontiguousarray(x, dtype=np.float32) @ray.remote(num_gpus=1)class Model:    def predict(self, x: np.ndarray):        # x is a read-only view into the node's object store        t = torch.from_numpy(x)  # no copy (warns that x is read-only)        t = t.pin_memory().to("cuda", non_blocking=True)        return self.model(t).cpu().numpy()
NO SERIALISATION COPY ON READCONTIGUOUS FLOAT32

Two habits make this pay off. Batch before you hand off, because each Ray object has a fixed overhead and thousands of tiny objects cost more than one large one. And keep arrays contiguous with a fixed dtype, so nothing has to be converted on the other side.

SECTION 05 — FIX 2 • GPU HANDOFFS

Keep GPU-to-GPU data off the object store

If both stages run on GPUs, the cleanest fix is to stop sending the tensor through Ray at all. Let Ray do what it is good at, scheduling, placement and restarts, and move the data with NCCL, the library PyTorch already uses for distributed training. NCCL uses NVLink between GPUs in the same machine when it is available, and the network between machines.

stage_handoff.py • Python • torch.distributed with the NCCL backendDATA GOES GPU TO GPU
# Once, when each actor starts: join the same NCCL group.dist.init_process_group(    "nccl", init_method=f"tcp://{head_ip}:29500",    rank=rank, world_size=2,) # Stage A (rank 0): features is a CUDA tensor.dist.send(features, dst=1) # Stage B (rank 1): receive straight into GPU memory.features = torch.empty(shape, dtype=torch.float16, device="cuda")dist.recv(features, src=0)
RAY SCHEDULES THE WORKNCCL MOVES THE BYTES

The trade-off is coordination. Both sides must agree on shape and dtype before the transfer, and a missing send leaves the receiver waiting, so send a small header with the shape first or keep shapes fixed per batch size. Recent Ray releases also include Compiled Graphs, which can move tensors between actors over NCCL for you. It is worth evaluating before you hand-roll the plumbing above, since it handles the coordination.

SECTION 06 — FIX 3 • OVERLAP

Hide the copies you cannot remove

Some copies are unavoidable, such as the first transfer from host to device. You can still stop the GPU from waiting on them. Copy from pinned host memory on a separate CUDA stream while the model works on the previous batch, so the transfer and the compute run at the same time.

prefetch.py • Python • copy the next batch while computing this oneCOPY AND COMPUTE IN PARALLEL
copy_stream = torch.cuda.Stream() with torch.cuda.stream(copy_stream):    next_gpu = next_cpu.to("cuda", non_blocking=True)  # next_cpu is pinned out = model(current_gpu)  # runs on the default stream meanwhile torch.cuda.current_stream().wait_stream(copy_stream)next_gpu.record_stream(torch.cuda.current_stream())current_gpu = next_gpu
PINNED SOURCE MEMORYSEPARATE COPY STREAM

The record_stream call matters. Without it, PyTorch's caching allocator only knows the tensor was used on the copy stream and may hand its memory to someone else while the default stream is still reading it. That bug produces wrong results, not crashes, which makes it painful to find.

SECTION 07 — FIX 4 • ONLY IF YOU MUST

When a custom shared-memory transport is worth it

Teams sometimes reach for a hand-built transport: a pinned shared-memory ring buffer, CUDA IPC handles passed between processes, a small native daemon. These can be very fast. They also bring memory management, crash recovery, back-pressure and synchronisation bugs that only show up under load.

“Build your own transport only after the standard ones are measured, tuned and still not fast enough. Most pipelines never get that far.”

— Ansul Agrawal, CTO, Kreativwit

If you do go there, keep control messages and data apart. Ray passes a small descriptor, such as a buffer index or an IPC handle, while the bytes move through your transport. Then load-test at full saturation for hours, not minutes. Lock and memory-pressure bugs in this kind of code tend to appear long after a short benchmark has passed.

SECTION 08 — CHECKLIST

Before you add another GPU

Run through this list before scaling a Ray GPU pipeline out:

  1. Measure each handoff

    Know the time per request, GPU utilisation per stage and bytes moved per handoff, from a profile rather than a guess.

  2. Return NumPy from CPU stages

    Contiguous arrays with a fixed dtype get zero-copy reads from the object store on the same node.

  3. Batch before handing off

    Fewer, larger objects beat many small ones. Per-object overhead adds up fast.

  4. Move GPU data with NCCL

    Keep device-to-device traffic off the object store and let Ray handle scheduling and fault tolerance.

  5. Overlap the copies you keep

    Pinned memory, a separate copy stream and record_stream so the GPU never waits on PCIe.

  6. Place stages on the same machine

    Placement groups that pack connected stages onto one node keep transfers on NVLink or local memory instead of the network.