Pipe vs shared memory: what actually happens when you pass a tensor between processes
I use Ray every day at work — submitting tasks, passing tensors between actors, watching the object store fill up. I have a rough mental model of what’s happening, but “rough” has been bothering me. What actually moves when I call ray.get()? Why is it fast? What would happen if I had to build that myself?
So I went back to basics. This post is about one specific question: when you pass a tensor between two Python processes, what actually happens at the OS level? I built a small benchmark — pickle over a pipe versus shared memory — and used it as an excuse to get concrete about virtual memory, kernel buffers, and why the difference matters.
The goal isn’t to reimplement Ray. It’s to understand the OS primitives well enough that Ray’s design stops feeling like magic — and starts feeling like a set of reasonable engineering decisions I can reason about.
The setup
The data is a synthetic sensor frame: a 1080p RGB image (shape 1920×1080×3, ~6MB) and a LiDAR point cloud (65,536 points × 4 floats, ~1MB). Together about 7MB per frame — realistic for an autonomous vehicle simulation tick.
I ran two experiments with the same data:
- Method A: producer pickles the frame and sends it over
multiprocessing.Pipe, consumer unpickles and wraps it in a torch tensor - Method B: producer writes into a
multiprocessing.shared_memoryblock, passes only the name and shape metadata over a pipe, consumer attaches to the same block and reads directly
Same data, same two processes, same machine. The only difference is how the bytes travel.
The results (1080p RGB + LiDAR point cloud, ~7.3MB per frame):
| Method | Throughput | Avg latency | CPU time (10k frames) | Effective bandwidth |
|---|---|---|---|---|
| Pipe + pickle | 95 fps | 0.84 ms | 53.8 s | 691 MB/s |
| Python shared memory | 268 fps | 0.13 ms | 1.40 s | 1,947 MB/s |
| Python ring buffer | 263 fps | 0.12 ms | 1.30 s | 1,910 MB/s |
| C++ lock-free ring buffer | 261 fps | 0.11 ms | 1.23 s | 1,897 MB/s |
The headline number: 2.8× more throughput, 6× lower latency, ~38× less CPU per frame by eliminating pickle and the kernel copies. The C++ ring buffer’s advantage over plain Python shared memory is surprisingly small — because the bottleneck is memory bandwidth (writing 7MB into the shared block), not the coordination mechanism.
(The numbers tell the story — but let’s look at why.)
Method A: Pipe + Pickle
Here’s the producer side:
def producer(conn: Connection, num_frames: int = 300) -> None:
for _ in range(num_frames):
rgb, cloud = make_frame()
payload = pickle.dumps((rgb, cloud), protocol=pickle.HIGHEST_PROTOCOL)
conn.send_bytes(payload)
conn.send_bytes(b"__done__")
And the consumer:
def consumer(conn: Connection) -> list[float]:
while True:
raw_bytes = conn.recv_bytes()
if raw_bytes == b"__done__":
break
rgb, cloud = pickle.loads(raw_bytes)
tensor = torch.from_numpy(rgb.copy())
Looks simple. But here’s what the OS is actually doing on every single frame — four distinct steps:
There are actually two separate problems bundled together:
- Serialization cost —
pickle.dumps()andpickle.loads()burn CPU converting Python objects to bytes and back - Two kernel copies — the bytes cross the kernel boundary twice: once on
write()into the pipe buffer, once onread()out
Each send_bytes / recv_bytes is a syscall, which forces a context switch from user mode into kernel mode and back. At 60fps that’s 120 context switches per second, before any real work is done.
Full picture — Method A annotated top to bottom:
from multiprocessing import Pipe, Process
import pickle, torch, numpy as np
# ── Setup ─────────────────────────────────────────────────────────────────────
parent_conn, child_conn = Pipe() # OS creates an anonymous pipe (two file descriptors)
# fork(): child inherits child_conn fd; page table is copied (copy-on-write)
p = Process(target=producer, args=(child_conn, NUM_FRAMES))
p.start()
# ── Producer (child process) ───────────────────────────────────────────────────
def producer(conn, num_frames):
for _ in range(num_frames):
rgb, cloud = make_frame() # 7.3MB allocated on heap
payload = pickle.dumps((rgb, cloud), protocol=5) # CPU: walk object graph → malloc new buffer
conn.send_bytes(payload) # syscall write(): 7.3MB → kernel pipe buffer ← COPY #1
conn.send_bytes(b"__done__")
# ── Consumer (parent process) ──────────────────────────────────────────────────
def consumer(conn):
while True:
raw = conn.recv_bytes() # syscall read(): blocks until data arrives
# kernel copies 7.3MB → consumer heap ← COPY #2
if raw == b"__done__":
break
rgb, cloud = pickle.loads(raw) # CPU: deserialize bytes back to numpy arrays
tensor = torch.from_numpy(rgb.copy())
# ── Teardown ───────────────────────────────────────────────────────────────────
latencies = consumer(parent_conn) # blocks here until producer sends __done__
p.join()
The pipe is the only coordination mechanism: data and signal travel together, serialized inside the same bytes.
Method B: Shared Memory
The shared memory version allocates a block once at startup. The producer writes directly into it:
class SharedFrame:
def write(self, rgb: np.ndarray, cloud: np.ndarray) -> None:
buf = self._shm.buf
ptr = np.frombuffer(buf, dtype=np.uint8, count=FRAME_BYTES)
ptr[:] = rgb.ravel() # writes directly into shared memory
One subtlety worth noting: ptr[:] = rgb.ravel() is not the same as ptr = rgb.ravel(). The first writes into the shared buffer in-place. The second just rebinds the local variable — the shared buffer is never touched.
The consumer attaches to the same block by name and reads from it:
def read(self) -> tuple[np.ndarray, np.ndarray]:
buf = self._shm.buf
rgb = np.frombuffer(buf, dtype=np.uint8, ...).reshape(FRAME_SHAPE).copy()
What the OS is doing this time:
The bytes never move. The pipe only carries a short name string — something like "zcm_frame_slot". Both processes map that name to the same physical pages via their own virtual address spaces. Producer’s virtual address A and consumer’s virtual address B are different numbers, but they point to the same physical RAM.
Under the hood, multiprocessing.SharedMemory is wrapping three POSIX calls:
# What Python does for you:
shm_open("zcm_frame_slot", O_CREAT | O_RDWR, 0600) # create named shm
ftruncate(fd, size) # set size
mmap(NULL, size, PROT_READ|PROT_WRITE, MAP_SHARED, fd, 0) # map into VA space
One wrinkle: page faults on first access. The first time the producer writes into the shared block, the OS hasn’t yet allocated physical pages for it — those pages only get faulted in as you touch them. A 1080p RGB frame spans ~1,518 4KB pages, so the first write triggers ~1,518 page faults. Subsequent writes are fast — the OS mapping is already in place. Production pipelines pre-warm the buffer at startup to absorb this cost before real frames arrive.
Full picture — Method B annotated top to bottom:
from multiprocessing import Pipe, Process
from multiprocessing.shared_memory import SharedMemory
import numpy as np, torch
FRAME_BYTES = 1920 * 1080 * 3 # 6.2MB RGB
CLOUD_BYTES = 65536 * 4 * 4 # 1.0MB point cloud
TOTAL_BYTES = FRAME_BYTES + CLOUD_BYTES
# ── Setup ─────────────────────────────────────────────────────────────────────
# shm_open() + ftruncate() + mmap(): OS allocates physical pages, maps into VA space
shm = SharedMemory(name="zcm_frame_slot", create=True, size=TOTAL_BYTES)
parent_conn, child_conn = Pipe() # pipe carries only signals now, not frame data
p = Process(target=producer_shm, args=(child_conn, NUM_FRAMES))
p.start()
# ── Producer (child process) ───────────────────────────────────────────────────
def producer_shm(conn, num_frames):
# second mmap to the same backing file → same physical pages, different virtual address
local_shm = SharedMemory(name="zcm_frame_slot", create=False, size=TOTAL_BYTES)
buf = np.frombuffer(local_shm.buf, dtype=np.uint8)
for _ in range(num_frames):
rgb, cloud = make_frame()
buf[:FRAME_BYTES] = rgb.ravel() # user-space write into shared pages — no kernel crossing
conn.send("ready") # syscall: ~5 bytes over pipe, NOT the 7.3MB frame ← signal only
conn.send(None)
local_shm.close()
# ── Consumer (parent process) ──────────────────────────────────────────────────
def consumer_shm(shm, conn):
# same physical pages, consumer's own virtual address
buf = np.frombuffer(shm.buf, dtype=np.uint8)
while True:
msg = conn.recv() # blocks on the 5-byte signal, not the frame data
if msg is None:
break
# zero-copy read: buf already points at shared pages, no kernel crossing
rgb = buf[:FRAME_BYTES].reshape(1080, 1920, 3).copy()
tensor = torch.from_numpy(rgb)
# ── Teardown ───────────────────────────────────────────────────────────────────
latencies = consumer_shm(shm, parent_conn)
p.join()
shm.close() # munmap: remove this process's virtual mapping
shm.unlink() # unlink: delete /dev/shm/zcm_frame_slot (backing file)
Pipe carries only the signal ("ready" or None). The 7.3MB frame never touches the kernel.
The signaling problem — and a hidden race condition
Shared memory eliminates copies, but creates two new questions:
- How does the consumer know the data is ready?
- How does the producer know the consumer has finished reading before it overwrites?
The single-slot code above only solves question 1. It ignores question 2 entirely — and that’s a real bug.
The OS pipe buffers messages in a FIFO queue (~64KB on Linux). The producer never blocks on conn.send("ready") unless that buffer is full. So with a fast producer:
producer: write frame 1 → shm, send "ready" # pipe: [ready]
producer: write frame 2 → shm, send "ready" # OVERWRITES frame 1 pipe: [ready, ready]
consumer: recv() → "ready", reads shm → gets frame 2 # frame 1 is gone
producer: write frame 3 → shm, send "ready" # overwrites again...
One shared slot, multiple buffered signals — every read is racing against the next write.
The naive fix is an explicit ACK — consumer tells producer “I’m done, you can write now”:
# producer
buf[:FRAME_BYTES] = rgb.ravel()
conn.send("ready")
conn.recv() # ← wait for consumer to finish before overwriting
# consumer
conn.recv() # wait for signal
rgb = buf[:FRAME_BYTES].reshape(...).copy()
conn.send("ack") # ← release producer to write next frame
Now producer and consumer strictly take turns. No overwrites, no corrupted reads. But throughput is cut nearly in half — the producer sits idle while the consumer processes, and the consumer sits idle while the producer writes. No pipelining.
| Signaling approach | Race condition? | Throughput | Coordination cost |
|---|---|---|---|
| Single slot + pipe signal | Yes — producer overwrites before consumer reads | Full | None, but data is silently lost |
| Single slot + explicit ACK | No | ~Half — producer and consumer take strict turns | 2 round-trip syscalls per frame |
| Ring buffer (N slots) | No | Full — producer and consumer run concurrently | Lock/atomic on each slot state transition |
The ring buffer solves both problems at once. Instead of a single slot, it uses N slots — each with its own state variable embedded in the shared memory itself. Each slot cycles through four states:
EMPTY → WRITING → READY → READING → EMPTY
Here is the complete Python implementation (mutex-based, easier to follow):
EMPTY, WRITING, READY, READING = 0, 1, 2, 3
class PyRingBuffer:
def __init__(self, name: str, num_slots: int, slot_bytes: int):
self._num_slots = num_slots
self._slot_bytes = slot_bytes
self._shm = SharedMemory(name=name, create=True,
size=num_slots * slot_bytes)
self._states = Array('i', [EMPTY] * num_slots) # shared, has built-in lock
self._write_head = Value('I', 0)
self._read_head = Value('I', 0)
# ── Producer ──────────────────────────────────────────────────
def acquire_write(self) -> tuple[int, memoryview]:
while True:
idx = self._write_head.value % self._num_slots
with self._states.get_lock():
if self._states[idx] == EMPTY:
self._states[idx] = WRITING
self._write_head.value += 1
offset = idx * self._slot_bytes
return idx, self._shm.buf[offset: offset + self._slot_bytes]
time.sleep(0) # yield CPU, wait for consumer to free a slot
def commit_write(self, slot_idx: int) -> None:
with self._states.get_lock():
self._states[slot_idx] = READY
# ── Consumer ──────────────────────────────────────────────────
def acquire_read(self) -> tuple[int, memoryview]:
while True:
idx = self._read_head.value % self._num_slots
with self._states.get_lock():
if self._states[idx] == READY:
self._states[idx] = READING
self._read_head.value += 1
offset = idx * self._slot_bytes
return idx, self._shm.buf[offset: offset + self._slot_bytes]
time.sleep(0)
def release_read(self, slot_idx: int) -> None:
with self._states.get_lock():
self._states[slot_idx] = EMPTY
Here is how the producer and consumer are wired together:
# main process — create ring buffer, spawn producer, run consumer
SLOT_BYTES = FRAME_BYTES + CLOUD_BYTES # 7.3MB per slot
rb = PyRingBuffer("zcm_ring", num_slots=8, slot_bytes=SLOT_BYTES)
parent_conn, child_conn = Pipe()
producer_proc = Process(target=producer, args=(rb, child_conn, NUM_FRAMES))
producer_proc.start()
latencies = consumer(rb, parent_conn)
producer_proc.join()
rb.close()
rb.unlink()
# producer process
def producer(rb, conn, num_frames):
for _ in range(num_frames):
rgb, cloud = make_frame()
slot_idx, mv = rb.acquire_write()
np.copyto(np.frombuffer(mv[:FRAME_BYTES], dtype=np.uint8), rgb.ravel())
np.copyto(np.frombuffer(mv[FRAME_BYTES:], dtype=np.float32), cloud.ravel())
rb.commit_write(slot_idx)
conn.send(slot_idx) # signal: "slot N is ready"
conn.send(None)
# consumer process
def consumer(rb, conn):
while True:
msg = conn.recv() # wait for signal
if msg is None:
break
slot_idx, mv = rb.acquire_read()
rgb = np.frombuffer(mv[:FRAME_BYTES], dtype=np.uint8).reshape(FRAME_SHAPE)
tensor = torch.from_numpy(rgb.copy())
rb.release_read(slot_idx)
Notice the pipe is still there — but it now carries a 4-byte slot index, not 7.3MB. The data never touches the kernel. The producer calls acquire_write() to claim a slot, writes directly into the returned memoryview, calls commit_write() to mark it READY, then sends the index as a nudge. The consumer waits for the nudge, calls acquire_read() to get the slot view, reads it zero-copy, and calls release_read() to free the slot.
What Ray actually does
Once you understand Method B, Ray’s Plasma object store is immediately recognisable. The OS mechanism is identical — Ray just productionises the parts I left manual.
Scope: everything below describes workers on the same node as the Plasma store, for large objects (> 100KB). Objects smaller than 100KB are stored directly on the owner worker’s heap (the “in-process store”) and copied by value to any reader — no shared memory involved. For the 7.3MB frames in this post, Plasma always applies. Workers on a different node trigger a network transfer first — the local raylet fetches the object from the remote raylet over gRPC, depositing a copy into the receiving node’s Plasma store. Once that copy exists locally, the same mmap path applies.
The physical law doesn’t change — for same-node access. A tensor still moves from producer heap into the shared block exactly once. The consumer’s virtual address is still a different number pointing to the same physical pages. Ray wraps all of this in a production API, but the OS primitive underneath is identical to what this experiment does.
Under the hood: how Plasma avoids locks
The key difference from the ring buffer is that Plasma objects are immutable once written. A worker writes into a mutable Plasma buffer; the worker’s own CoreWorker then seals it — a one-way transition that makes it permanently read-only. (The raylet embeds the Plasma store and manages the memory pool, but the seal call is issued by the producing worker’s CoreWorker, not the raylet itself.) After sealing, any number of workers can call ray.get() simultaneously and each gets an mmap view to the same physical pages. Because no writer can ever touch the buffer again, concurrent reads need zero coordination.
One practical consequence of immutability: ray.get() returns numpy arrays as read-only views into the shared buffer. Any attempt to write to them raises an error. That’s why production Ray code nearly always ends with arr = arr.copy() before doing in-place work — the copy buys writability at the cost of one extra allocation, just like the .copy() calls in Method B above.
This is the opposite trade-off from the ring buffer:
| Ring buffer | Plasma (Ray) | |
|---|---|---|
| Slot lifecycle | READING → EMPTY → WRITING (reused) | Write once, sealed → immutable forever |
| Concurrent readers | 1 reader per slot at a time | Unlimited — no coordination needed |
| Coordination | Lock or atomic on every state transition | None after seal |
| Memory use | Fixed N-slot pool, bounded | Objects live until all refs hit zero across cluster |
| Best suited for | Streaming pipelines with fixed-size frames | Arbitrary objects shared across many workers |
When to free: distributed reference counting
Since objects are never overwritten, Ray needs a distributed protocol to decide when to free the physical pages. Every ObjectRef has an owner — the worker that created it — who tracks five reference count components. The object is freed only when all reach zero across the entire cluster.
When an ObjectRef is passed to another process as a task argument, that process becomes a borrower. The owner adds it to a borrower set and waits for a “count = 0” report via async RPC. Borrowers chain recursively — if B passes the ref to C, B includes C in its reply to the owner, who then contacts C directly. Only once every count is zero does the raylet free the /dev/shm pages.
Putting it all together
The diagram below maps the five lifecycle steps — allocate, write, signal, read, free — across both approaches side by side. The amber box is where the designs diverge most sharply: a raw pipe signal versus Plasma’s seal primitive.
Summary
| Pipe + pickle | Shared memory | |
|---|---|---|
| Serialization | pickle dumps/loads (CPU cost) | None |
| Data copies | 2 (kernel in + kernel out) | 0 |
| Signaling | Implicit — data is the signal | Explicit — name string or atomic flag |
| N producers | Safe — pipe handles queuing | Needs MPSC design |
| Backpressure | None — pipe buffer absorbs bursts | Explicit — drop or pause producer |
| Real-world example | — | Ray Plasma, PyTorch DataLoader shm mode |
The core insight is that “passing a tensor between processes” is not one problem — it’s three: serialization, copying, and signaling. Pipe + pickle bundles all three into one opaque call. Shared memory forces you to handle each one explicitly, which is exactly why it’s worth understanding.