Adaptive P2P Communication#
This component provides point-to-point (P2P) data transfer between workers with strict ordering and async handles, on top of PyTorch torch.distributed.
It consists of two public-facing classes:
Collective: per-worker singleton that creates/caches communication groups.
CollectiveGroup: a two-rank communication group that implements P2P send/recv for tensors, lists/dicts of tensors, and picklable Python objects.
Group Creation & Caching#
The Collective class is instantiated on each worker (as a singleton per worker) and is responsible for creating and caching CollectiveGroup instances.
When two workers or a set of workers need to communicate, a collective group must be established that includes all participants.
The typical usage in this framework is to form groups for point-to-point communication by
Collective.create_collective_group(worker_addresses, group_name=None),
which either retrieves an existing CollectiveGroup for the given set of worker addresses or creates a new one.
P2P Communication#
A CollectiveGroup is the core abstraction in RLinf for managing point-to-point communication between two workers.
It determines the local rank (0 or 1) from group_info and lazily initializes communication process groups on first use.
Internally, separate send and receive process groups are created for both GPU (NCCL) and CPU (Gloo), forming dedicated one-way channels; in a two-worker setup, a carefully configured broadcast is equivalent to a send/receive.
Initialization uses a TCP rendezvous to coordinate port allocation and synchronization, ensuring both sides are ready.
Each direction maintains a work queue backed by a dedicated CUDA stream, strictly preserving the order of send/recv operations and preventing message interleaving.
With the process groups in place, CollectiveGroup can perform communications. The main APIs are:
Send:
send(obj, async_op=False)sends an object (tensor, list of tensors, dict of tensors, or arbitrary picklable object) to the single other peer in the group. This method first sends a small header indicating the object type so that the receiver can interpret the payload.Recv:
recv(async_op=False)receives an object from the peer. It first receives the type code (CPU/Gloo), then dispatches to the appropriate receiver to reconstruct the object.Direct Tensor Send/Recv:
send_tensor(tensor, async_op=False)andrecv_tensor(tensor, async_op=False)are optimized for the case where only one tensor is being transferred and the receiver already has an allocated tensor buffer. These avoid the extra round-trip of sending metadata.
Note
All tensors must be contiguous; non-contiguity raises a helpful error. CPU and accelerator tensors may coexist in one supported tensor container and are partitioned onto their respective communication paths.
Warning
send_tensor must be paired with recv_tensor (and vice versa). Do not mix them with the generic send/recv for the same message.
Tensor Compression#
Enable lossless CPU tensor compression when network transfer time outweighs the extra CPU work. Compression is optional and job-wide: the driver validates one configuration and propagates it to every Worker.
Scope#
Compression applies to CPU tensors carried by the optimized generic
Worker.send/Worker.recv paths: a tensor, a tensor list or tuple, a
tensor-valued dictionary, or tensor fields extracted from a dataclass. For a
mixed CPU/accelerator container, only its CPU tensors are candidates. A Channel
that communicates through Worker send/recv inherits the same behavior.
Arbitrary pickled Python objects, accelerator tensors, broadcast, and direct
send_tensor/recv_tensor calls are not compressed. These boundaries are
deliberate:
The arbitrary-object path is intended for control data and metadata. Its serialized payloads are usually small, so another codec pass, buffer, and compression header would normally cost more than the bytes saved. Large tensor-bearing data should use the tensor list, dictionary, or dataclass paths so tensor storage does not pass through pickle and eligible CPU tensors can be compressed directly.
Accelerator tensors normally use high-throughput device communication such as NCCL-like collectives or same-device IPC. CPU compression would require device synchronization and device-to-host and host-to-device copies, disrupting the accelerator communication pipeline; common floating-point model tensors may also compress poorly. The extra work can therefore produce a net slowdown.
Direct
send_tensor/recv_tensordeliberately avoids metadata exchange because the receiver already owns the destination. Compression would require adding a variable wire size and framing metadata, which would break that fast path’s contract.broadcasthas separate multi-receiver and topology-aware transfer paths. This change is limited to point-to-point payload ownership and fallback, so it does not alter broadcast scheduling or buffer lifetime.
Tensor-list elements also remain separate wire payloads; this feature reduces their byte counts but does not coalesce them.
Choose a Codec#
Both codecs are lossless, operate on the raw bytes of a contiguous CPU tensor,
and restore the original dtype and shape byte-for-byte. They write directly
between tensors and preallocated torch.uint8 buffers without creating an
intermediate Python bytes object.
Codec |
Characteristics |
Codec parameters |
Choose it when |
|---|---|---|---|
|
Prioritizes compression and decompression speed with relatively low CPU cost, usually at a lower compression ratio than Zstd. |
|
CPU time matters or the link is only moderately bandwidth-bound. Start here. |
|
Usually reduces wire bytes more than LZ4, with higher compression and decompression cost. |
|
The network is slow enough that reducing bytes dominates codec time. |
Measure with representative payloads before choosing. Already compressed or high-entropy tensors may not shrink, while dense tensors containing many zeros or repeated values can benefit substantially. LZ4 also has a per-tensor input limit; unsupported tensor sizes automatically follow the raw path.
Configure Compression#
cluster:
collective:
tensor_buffer_pool:
max_bytes: 2147483648
tensor_compression:
enabled: true
codec: lz4
min_bytes: 16384
excluded_dtypes: [float32]
acceleration: 1
For Zstd, select it with codec and pass only Zstd parameters:
cluster:
collective:
tensor_compression:
enabled: true
codec: zstd
min_bytes: 16384
excluded_dtypes: [float32]
level: 1
max_inflight: 4
Omit tensor_compression or set enabled: false to use the original wire
path. The compression options and defaults are:
Option |
Default |
Meaning |
|---|---|---|
|
|
Enables compression when the |
|
required |
Selects the codec config that owns the rest of the block: |
|
|
Skips tensors smaller than this raw byte count. |
|
|
Skips codec attempts for tensors whose dtype is listed. Set it to |
codec parameters |
codec-specific |
Declared by the selected codec and given in the same block. LZ4 accepts
|
codec selects a codec configuration class, and only that class’s parameters
are accepted alongside it. The contract is exact:
Codec |
Parameter |
Default |
Validation and behavior |
|---|---|---|---|
|
|
|
Must be at least |
|
|
|
Must be at least |
|
|
|
Must be at least |
A codec only accepts its own parameters: LZ4 rejects max_inflight while
Zstd rejects acceleration, and both are reported like any other unknown
cluster key. The driver validates the block once as part of the cluster
configuration, which then reaches every Worker; wire metadata therefore only
has to identify the codec, since each receiver already holds the same job-wide
parameters.
tensor_buffer_pool is independent of tensor_compression. Its
max_bytes option limits the combined active and cached CPU buffer capacity
per Worker and defaults to 2 GiB. Configuring compression without this block
automatically supplies the default pool.
Runtime and Fallback#
Each Worker probes the configured system codec library while loading its
job-wide configuration, so a missing dependency fails during Worker startup.
Native codec contexts and buffers remain lazy: each Worker creates an empty
TensorBufferPool while loading its configuration, then creates one
TensorCodecProvider when an eligible tensor first needs compression. Both
resources are shared across all of the Worker’s CollectiveGroup instances.
LZ4 shares one stateless codec without a lock or slot limit. Zstd leases an
exclusive codec from one bounded LIFO queue shared by compression and
decompression. A send uses them as follows:
It obtains the provider’s codec. LZ4 access cannot be saturated. Zstd does not wait for a busy codec queue; a saturated queue keeps the transfer raw.
It visits CPU tensors in their original order, filters them by
min_bytesandexcluded_dtypes, and tries to lease a buffer for each eligible tensor. A tensor stays raw when its codec bound is unsupported or no buffer fits within the budget.It keeps the compressed result only when it is smaller than the original. Otherwise, the tensor stays raw and that buffer is discarded rather than cached.
It sends per-tensor compression sizes in the existing metadata. The receiver restores compressed tensors directly into their preallocated destinations.
It releases the codec immediately after compression. Compressed payload buffers remain leased until their synchronous payload sends finish, then return to the Worker buffer pool. The receiver acquires a decoder only after the compressed wire payload has arrived and releases it after decompression.
When cluster.net_emulation is also enabled, bandwidth is charged against the
actual compressed CPU tensor bytes while preserving the original payload’s
metadata estimate. Raw and ineligible tensors keep their original accounting.
The buffer pool indexes idle buffers by capacity and finds the smallest cached
size that fits. It reuses that buffer when its capacity is at most twice the
requested size, or when allocating an exact-size buffer would exceed the budget;
otherwise it allocates an exact-size buffer. It maintains separate lists for
repeated sizes and tracks active plus cached capacity against max_bytes. When
a new allocation needs room, it evicts buffers starting with the largest idle
size bucket. Buffer acquisition never waits; an unavailable buffer therefore
preserves baseline behavior for that tensor.
The common dependency installation installs the LZ4 and Zstandard system libraries required by these codecs. Ensure the same RLinf version and its compression dependencies are available on all worker nodes.
Asynchronous API#
All P2P APIs support asynchronous operation and return awaitable work handles when async_op=True. Internally, we expose a small hierarchy:
AsyncWork: abstract base withwait(),async_wait(),then(func, *args, **kwargs),done(), and chaining helpers (get_next_work(),get_last_work()).AsyncFuncWork: executes a Python callback when its predecessor completes, records a CUDA event, and can be chained viathen. If the callback returns anotherAsyncWork, completion is deferred until the last work in that chain finishes.AsyncCollWork: wraps atorch.distributedwork (e.g., broadcast) into our awaitable interface. It also supportsthen(single underlying work).AsyncChannelWork: wraps aray.ObjectRefas an awaitable (for channel RPCs).
Key properties:
Waiting:
wait()is blocking ;async_wait()isasyncio-friendly. Both ensure the recorded CUDA event has completed before returning.Chaining:
thenschedules a follow-up callback.Completion:
done()is a non-blocking query to check whether the underlying work finished.
Minimal examples:
# Async object send/recv with await
send_work = group.send(obj, async_op=True) # AsyncWork
await send_work.async_wait() # non-blocking await
recv_work = group.recv(async_op=True) # AsyncWork
obj = recv_work.wait() # blocking wait; returns received object
# Chaining a post-processing step
def postprocess(buf):
# e.g., move to CPU, cast, or notify another subsystem
return None
w = group.recv_tensor(tensor, async_op=True) # receiver-side preallocated tensor
w2 = w.then(postprocess) # AsyncFuncWork
w2.wait() # ensure postprocess finished
Summary#
In summary, the collective component provides the engine for P2P data transfer between workers. It abstracts away the details of using PyTorch’s distributed backends, managing multiple process groups to simulate send/receive, and optimizing for GPU transfers.
Users of the framework typically invoke these via the Worker.send/recv or channel operations, rather than calling CollectiveGroup directly.