Skip to content

[chore] sync rdma branch with main - #189

Open
Ginray wants to merge 13 commits into
Ascend:rdmafrom
Ginray:rdma-rebased-main-1009
Open

Ginray wants to merge 13 commits into
Ascend:rdmafrom
Ginray:rdma-rebased-main-1009

Conversation

@Ginray

@Ginray Ginray commented Oct 9, 2026 •

Copy link
Copy Markdown
Collaborator

Motivation

Bring the rdma branch up to date with main and verify that the upstream changes remain compatible with the NIXL-UCX payload path.

Summary

  • Merge main changes into rdma through the synced base of this PR.
  • Integrate upstream updates to SimpleStorage, storage request retries, ZMQ socket handling, SSD offload, and Mooncake buffer management.
  • Fix merge regressions in SSD-backed NIXL payloads, ZMQ error handling, and storage arrival metrics.
  • Preserve the existing SimpleStorage APIs and optional NIXL-UCX backend.

Validation

The following checks were run against this merge to verify regression coverage and compatibility with the NIXL-UCX path.

  • Core regression suite: 702 passed, 10 skipped.
  • Focused tests for storage retries, accept probing, ZMQ socket pooling and shared contexts, Mooncake buffer management, and SSD offload passed.
  • ZMQ retry passed with an injected lost response; the retried request returned the expected payload.
  • Two-node NIXL-UCX PUT/GET/CLEAR and payload integrity checks passed over the RDMA transport path.
  • NIXL+SSD public API checks passed for kv_put, kv_batch_get, and kv_clear.
  • NIXL timeout/cancel/retry control-flow check passed with a simulated peer.
  • Verified that SSD-offloaded NIXL payloads no longer retain the shared receive buffer through small Tensor / NumPy views.
  • Verified ZMQ timeout retries emit only retry warnings and log one final error after exhaustion; peer errors are logged and wrapped only once.
  • Added regression coverage for ZMQ send / receive timeouts, key-not-found handling, and ZMQ / NIXL arrival metric semantics.

huniu20 and others added 11 commits September 7, 2026 19:47
## Problem

A storage unit binds its put/get ROUTER to a TCP port. Anything that
dials that
port is forwarded straight to the worker, and the worker then dies
decoding it:
`ZMQMessage.deserialize` sat outside the worker's `try`, so one stray
payload
terminated the request loop. The actor process, its bound socket and the
proxy
thread all stayed up, so requests kept being accepted with nobody left
to answer
them and every client blocked until its own timeout.

## Fix

1. **Filter at the proxy.** `zmq.proxy()` offers no interception point,
so the
proxy now forwards by hand and keeps only the two identity prefixes real
peers
     use (storage managers and the metrics collector).
2. **Survive bad frames anyway.** The filter cannot cover everything: an
allowed
peer can still send frames that fail to decode. Decoding moves inside
the
`try`, mirroring the controller's request loop, and the peer gets an
error
     reply instead of hanging on `recv`.
3. **One source for the prefixes.** They now live in `zmq_utils` and the
identities are built from them, so a rename cannot silently make every
request
     unroutable.

The controller already tolerates undecodable messages, so it needs no
change.

## Tests

`tests/test_simple_storage_unit.py`: 17 passed.

- `test_foreign_payload_dropped_and_unit_stays_usable` — feeds a real
TLS
ClientHello and asserts it draws *no reply at all* (dropped before the
worker,
    unlike a decode failure), then that the unit still serves.
- `test_undecodable_request_answered_and_worker_survives` — an allowed
identity
sending undecodable frames gets `PUT_GET_ERROR` and the worker keeps
serving.

Each was confirmed to fail when its corresponding fix is reverted.

Signed-off-by: huniu20 <huniumail@gmail.com>
As title

Signed-off-by: 0oshowero0 <o0shower0o@outlook.com>
### Problem

Every RPC built a socket from scratch: create DEALER → connect → send →
recv → close. That pays a TCP and ZMTP handshake per request, and makes
the peer's ROUTER accrete a fresh identity on every call.

### Approach

`ZMQSocketPool` lends connected DEALER sockets and takes them back after
a clean send/recv. The decorator `with_zmq_socket` now leases from a
pool instead of building a socket per call.

Four properties carry the correctness argument:

**A lease is exclusive.** Responses do not echo their request's
`request_id` (see `ZMQMessage.create`), so a reply is matched to its
request only by arrival order. Two concurrent users of one socket would
read each other's replies.

**A failed lease discards its socket.** Any exception — including
timeout and `CancelledError`, which `asyncio.gather` raises into
siblings on the first failure — closes the socket rather than returning
it. The request may already be on the wire, so its reply could still
arrive and the next lessee would read it as its own. The notify path
closes its socket explicitly for the same reason, without raising, and
the pool refuses to park an already-closed socket.

**Sockets are keyed by lease owner, then by address.** The owner is the
running event loop, or the thread outside one. pyzmq silently rebinds an
async socket to whatever loop it next sees, and one bound to a *closed*
loop is the `Bad file descriptor` / SIGABRT failure that per-call
context churn used to cause; a finished owner's sockets are swept on the
next access. Keying by address rather than peer id means a peer
restarted under the same id at a new address is never handed a socket
still connected to the old one.

**One pool per role.** Each role owns the pools for the requests it
makes, and sockets are never shared across roles. The pool borrows its
context and never terminates it.

**Concurrency waits rather than opening more.** An async lease takes a
permit per (owner, address) first, so sockets in flight follow
`TQ_SOCKET_POOL_SIZE` instead of peak concurrency — the context's budget
becomes a property of configuration. Waiting also costs less than the
handshake it replaces: locally, 300-way concurrency to one peer ran 48ms
against 71ms, with peak sockets 4 against 300.

### One pool per role

The split is deliberate rather than incidental: a pool is scoped to one
role and one kind of request, so every socket it holds is
interchangeable but for the address it is connected to.

| Role | Pool | Dials | Owner |
|---|---|---|---|
| `CLIENT` | `controller_rpc_pool` | controller `request_handle_socket`
| the loop `TransferQueueClient` builds in `__init__`, or the caller's
for the async client |
| `STORAGE` | `storage_rpc_pool` | storage units' `put_get_socket` | the
caller's loop |
| `STORAGE` | `notify_pool` | controller `request_handle_socket` | its
own dedicated loop and thread |
| `CONTROLLER` | metrics pool | storage units' `put_get_socket` | the
collector thread, with no loop at all |

Sharing one pool across these would reuse nothing in the first place:
they differ by owning loop, socket name, or timeout, and a socket can
only be reused when all three match. A ZMQ socket also cannot safely
cross event loops, so `notify_pool` running on its own loop could never
have shared with another scenario regardless of how the pools were
arranged.

Keeping them separate buys isolation on top of that. One role's timeouts
and cancellations only ever poison its own sockets, a role's pool is
closed with the component that owns it, and the eviction of a finished
loop's sockets cannot reach into another role's buckets. Reuse itself
does not depend on the split — every entry point above sits on a
long-lived owner — but attributing a failure does.

### Sizing

`TQ_SOCKET_POOL_SIZE` (default 4, must be >= 1) is the cap for every
pool: idle sockets per (owner, address) bucket, and with an async lease
the sockets in flight to that address. Concurrency past it waits for one
to come back.

The cap multiplies by peer count while a context's ceiling does not,
which is what decides the default. A storage manager addressing two
thousand units can hold `4 x 2000 = 8004` sockets against the client
context's 8192 — inside the budget, where 8 would have put it at 16000.
That worst case is reachable rather than theoretical: a single peer
under 50-way concurrency does saturate its cap. Raise it if one peer
routinely sees more overlap than that, and lower it if descriptors are
tighter than latency; a single `put` or `get` sends one merged request
per unit, so per-peer concurrency comes only from operations
overlapping.

`AsyncSimpleStorageManager` warns at construction when the product
exceeds its context's `ZMQ_MAX_SOCKETS`, naming both knobs. Past that
ceiling a lease fails outright with `EMFILE`, so it is worth saying
before the first request rather than during one.

Ceilings, for reference: the client context allows 8192
(`TQ_CLIENT_ZMQ_MAX_SOCKETS`), the controller's 4096
(`TQ_CONTROLLER_ZMQ_MAX_SOCKETS`), and a storage unit keeps libzmq's
1023 — it binds three sockets regardless of fleet size. Raising any of
them needs file descriptors to match (`ulimit -n`).

### Fixes found along the way

- `AsyncTransferQueueClient.__init__` leaked the context and its native
I/O threads when it raised after allocating it (an out-of-range
`max_sockets`). Everything checkable without a live context is now
checked first, and the context is destroyed if the remaining check
fails.
- `StorageManager.close()` raised `AttributeError` when a subclass
rejected its config before calling `super().__init__()`, as the KV
managers do — burying the constructor's real error under a teardown
failure. Pre-existing on `main`.
- `AsyncSimpleStorageManager.close()` had the same problem for
`storage_rpc_pool` when the base constructor raised, e.g. on a
controller handshake timeout.
- `_notify_and_wait` decremented its ACK budget by the poll interval, so
unrelated traffic on the socket could stretch the wait. It now uses one
deadline for the whole wait.
- `TQMetricsExporter` lazily minted its own `zmq.Context` with nothing
to close it. It now borrows the controller's.
- Pooling those queries then made the collector hold a socket per
storage unit for the life of the process: buckets are per address and
only swept when their owner ends, and the collector is a permanent
daemon thread. Each query now closes its socket, as the notify path
does, taking resident sockets from one per unit to zero. Collection
walks every unit once per cycle, so a kept socket would be reused only a
cycle later while occupying budget for the whole walk.

### Behaviour and API changes

- `with_zmq_socket(...)` drops `socket_name`, `get_identity`,
`get_context` and `timeout` in favour of `get_pool`; socket name and
timeout live on the pool.
- `TQMetricsExporter(role=..., zmq_context=...)`. Without a context,
querying storage units raises `RuntimeError` instead of silently
building a second context. The controller always passes one.
- New `TQ_SOCKET_POOL_SIZE` (default 4, must be >= 1) sizes every role's
pool. Reuse cannot be disabled: below 1 nothing is ever parked, so every
request would pay a fresh connect while still looking pooled.
- `ZMQSocketPool.alease` is the async entry point and enforces the cap
by waiting; `lease` stays for the synchronous collector, which issues
one request at a time and cannot await. Nesting one lease inside another
raises, including across pools — a permit lives until its body ends, so
nesting can wait on one the task or a sibling already holds, and
separate semaphores do not break that cycle. An RPC's reply is what
frees its permit, so a second RPC belongs after the first returns.
- The controller's context now sets `MAX_SOCKETS` (4096, clamped to
`ZMQ_SOCKET_LIMIT`) instead of keeping libzmq's default 1023. Its own
two ROUTERs are nowhere near that, but the metrics exporter borrows this
context, and a controller that cannot open a socket stops answering
requests at all.
- `StorageManager` gains `notify_pool`; `AsyncSimpleStorageManager`
gains `storage_rpc_pool`. Both are closed before the context they live
on.

### Tests

Reuse is asserted from the peer's side rather than from pool state.
Every socket dials with its own ZMQ identity, so one identity across
many requests means the connection was reused and a new one means the
old socket was discarded — the same property observed from outside, with
no access to the pool's internals.

Each role's own reuse is covered where that role is tested, since a pool
belongs to a role. `test_client.py` drives it through the public client:
repeated `get_partition_list()` calls must reach the controller over one
connection, which is the user-visible payoff of pooling.
`test_metrics.py` covers the collector, whose pool keys by thread
because it runs with no event loop at all. Both were checked by breaking
the pool's take path so no socket is ever handed back — each then fails
with one identity per request, so neither passes vacuously.

What remains in `tests/test_zmq_socket_pool.py` needs control over reply
timing and loop lifetime that a caller-level test cannot reach: the
timed-out socket that must not answer the next request; poisoned leases
from both an exception and a cancellation; no reuse across event loops;
no stale socket after a peer re-registers at a new address; and a burst
above the cap.

The cap and its permits are covered there too: 40-way concurrency
against a cap of 4 must peak at 4 sockets and drop nothing; a busy peer
must not stall requests to another, since permits are keyed like the
buckets; nesting raises, both on one pool and across two; and
consecutive leases are not mistaken for nesting.

The rest cover context sharing and pool wiring
(`test_zmq_shared_context.py`), the exporter borrowing its owner's
context (`test_metrics.py`), and quiet teardown after a rejected config
(`test_kv_storage_manager.py`).

### Verification

`python -m compileall -q transfer_queue tutorial tests` — clean. `ruff
check`, `ruff format --check` and `mypy` — clean.

`python -m pytest -q`:

| | passed | skipped | errors |
|---|---|---|---|
| this branch | 607 | 10 | 8 |
| `main` | 584 | 10 | 8 |

The 8 errors are the same on both:
`tests/test_yuanrong_storage_client_e2e.py` cannot import the optional
Yuanrong backend in this environment.

---------

Signed-off-by: OutstanderWang <wangweiyanster@gmail.com>
…scend#171)

## Problem

A storage unit stops answering and every client routed to it fails when
its own recv timeout
expires. Raising the timeout (400s to 1800s in our deployment) changed
nothing.

Evidence from one such failure: the unit's node was alive and serving
other traffic throughout, a
TCP connect to its `put_get_socket` succeeded, and the unit's own
counters showed it fully healthy
(1025 GET_DATA served, 9.6ms p99, 1.33GB RSS). It had served exactly one
GET_DATA fewer than its
cohort. So the unit never saw the request that timed out; it was lost
between the two ends, not
queued behind slow work. Ascend#168 protected the worker thread from dying,
which is a different cause of
the same symptom; here the thread was intact.

That loss is invisible by construction. ZMQ connect is asynchronous and
SNDHWM is 0, so a DEALER
accepts `send()` into an unbounded local queue for a peer it has not
reached yet. The caller only
learns anything when its own RCVTIMEO expires, which is why no timeout
value can distinguish a lost
request from a slow one. Lowering SNDHWM would not help either, it only
trades silent queuing for
silent dropping.

## Fix

1. **Retry on a new connection.** Up to `TQ_SIMPLE_STORAGE_MAX_ATTEMPTS`
(default 3, floored at 1).
A fresh socket and TCP connection is the part that matters. Only a
missing answer is retried:
`zmq.error.Again` now raises `StorageUnitTimeout`, while an error the
unit reported surfaces on
the first attempt. Replaying is safe where it is used: get is read-only
and put overwrites the
same global indexes. A put carrying a `data_parser` is the exception and
is sent once, because a
retry would re-run the parser on the unit and the public API does not
constrain its side
   effects.
2. **Self-diagnosing residual failure.** After the last attempt, ask the
unit for its own counters
over a fresh socket with a short timeout. That probe is served by the
same worker thread as put
and get, so an answer proves the request was lost in flight and silence
means the unit itself
stopped. The failure log carries that verdict plus tcp reachability, the
unit's op counts and RSS,
and the wire size of the request that failed (measured from the
serialized ZMQ frames on put).
3. **Log volume unchanged in the steady state.** A recovered request
logs one line and skips the
diagnosis entirely. Heavy requests are logged only above
`TQ_STORAGE_SLOW_REQUEST_SECONDS` (5s)
or `TQ_STORAGE_LARGE_PAYLOAD_MB` (256MB), via shared
`log_heavy_operation`: puts on the manager
(serialized request frames + end-to-end RTT), gets on the unit
(serialized response frames +
   local handling time). No tensor-`nbytes` estimate remains.

## Tests

`tests/test_storage_request_retry.py`: 14 passed. Covers recovery on
retry, the bounded attempt
count, that a nonpositive count still issues one request, that errors
reported by the unit are not
retried, that a parser-backed put is not replayed while the ordinary put
still is, that retry logs
carry wire-size detail from the failed attempt, shared heavy-request
logging thresholds, and each
diagnosis verdict.

Full non-e2e run: 623 passed, 10 skipped, plus `python -m compileall -q
transfer_queue tutorial tests`.
`test_yuanrong_storage_client_e2e.py` is excluded locally, it needs the
optional
`openyuanrong-datasystem` dependency.

---------

Signed-off-by: jathonzhang <jathonzhang@tencent.com>
Co-authored-by: jathonzhang <jathonzhang@tencent.com>
As title.

---------

Signed-off-by: 0oshowero0 <o0shower0o@outlook.com>
## Summary

A storage-unit request can be lost between the manager and the unit
without either end raising an error. Ascend#171 recovers such a request by
retrying it; this PR closes one of the paths it can be lost on, and adds
the instrumentation needed to tell the loss apart from a slow unit.

It also corrects two defects in the diagnosis that Ascend#171 introduced —
both are on `main` today.

## What's here

### 1. Raise the client-facing ROUTER's accept queue

ZMQ defaults `BACKLOG` to 100. That was simply the unset default: a full
accept queue is drained **without an RST** (`tcp_abort_on_overflow=0`),
so the client stays in `ESTABLISHED` waiting for a reply nobody will
ever send, and the server never learns the request existed. Neither end
raises.

Default is now 4096, tunable via `TQ_STORAGE_ZMQ_BACKLOG` (set it to 100
to restore ZMQ's default for an A/B run). This only raises a queue
ceiling — it cannot reject a connection that previously succeeded.

### 2. Make the loss measurable rather than inferred

- **Arrival counters** (`requests_arrived`, `arrivals_by_op` in
`get_metrics`). The per-op counters advance inside `monitor.measure()`,
so they only move once a request *completes* — a request that arrived
and then stalled is indistinguishable from one that never arrived.
Counting at decode time separates the two, and a shortfall against the
caller's send count localizes the loss to one side of the connection.
- **Accept-queue probe** (`transfer_queue/utils/accept_probe.py`).
Samples `Recv-Q` / `sk_drops` / `ListenOverflows` from the kernel's own
view. **Opt-in and off by default** (`TQ_ACCEPT_PROBE_INTERVAL=0`): it
shells out to `ss` on a timer, and depth must be sampled sub-second
because the queue drains in milliseconds — which is why inspecting a
unit *after* it hung always read zero.

### 3. Report an accept-queue drop once per drop

`sk_drops` is a cumulative kernel counter, but the initial check
compared it against the probe's *first* sample. Once a socket had ever
dropped a connection the condition stayed true for the life of the
process — at a 0.1 s interval that is ten errors per second per storage
unit. The flood also destroyed the signal: repeating the same cumulative
total says nothing about *when* drops happened. Now compares against the
previous sample and reports both deltas.

### 4. Base the timeout diagnosis on evidence (fixes two defects on
`main`)

`_diagnose_storage_unit` reported `verdict=request_lost_in_flight`
**unconditionally** whenever the post-failure probe answered. But a unit
that resumed inside the 10 s diagnostic window answers that probe too,
having merely finished the original request late — so the verdict
asserted *where* the request was lost on evidence that only shows the
unit is serving *now*. It also printed `ops=` from `op_stats`, which
`_handle_get_metrics` populates only when Prometheus is enabled, so the
line read `ops={}` as if the unit had served nothing.

The verdict now follows the unit's own arrival counter for the failed
operation:

| Probe answered, and… | Verdict |
|---|---|
| no arrival recorded for that op | `request_lost_in_flight` |
| an arrival recorded for that op | `arrived_but_unfinished` |
| counters unavailable (e.g. older unit) | `unit_serving_again` |

`op_stats` is now reported as `unavailable(prometheus_disabled)` rather
than as an empty dict.

**The counters could not actually be correlated as intended.** The
worker keyed `arrivals_by_op` by `str(operation)`, which for a `(str,
Enum)` member renders as `"ZMQRequestType.GET_DATA"`, while `op_stats`
is keyed `"GET_DATA"` — the two dicts shared no keys at all. Fixed by
keying on `operation.name`. Note `.value` would *also* miss: the enum
values are short wire tokens (`GET_DATA = "GET"`).

### 5. Stop the accept probe on shutdown

`AcceptQueueProbe.start()` was called but `stop()` had no caller. The
finalizer tore down only the ZMQ resources, so a unit finalized without
its process exiting left the daemon thread spawning `ss` on a timer
indefinitely, and the window summary was never logged. Now stopped in
`_shutdown_resources`, before the ZMQ teardown.

## Behavior and compatibility

- **No public API change.** `transfer_queue/utils/zmq_utils.py` is
untouched.
- **One default changes:** ROUTER `BACKLOG` 100 → 4096.
- **The probe is off by default** and imported lazily, so a run that
does not enable it never loads the module or spawns the thread.
- **Steady-state log volume is unchanged**; the near-full-queue warning
fires once per probe, not per sample.

## New configuration

| Variable | Default | Purpose |
|---|---|---|
| `TQ_STORAGE_ZMQ_BACKLOG` | `4096` | Accept-queue depth for the
client-facing ROUTER |
| `TQ_ACCEPT_PROBE_INTERVAL` | `0` (off) | Accept-queue sampling period,
seconds; sub-second when enabled |

## Note on the rebase

This branch previously carried Ascend#171's commits as its base and included a
per-call socket-timeout change. Since then Ascend#171 merged and Ascend#167 replaced
per-call sockets with `ZMQSocketPool`, which resolves timeouts from the
pool instead of the decorator. That change therefore had nowhere left to
live and has been dropped — `main` already routes the two timeouts
through separate `storage_rpc_pool` / `storage_probe_pool` instances,
which satisfies the same intent. Its test file went with it; pool
timeout behaviour is covered by
`tests/test_zmq_socket_pool.py::test_timed_out_socket_is_not_reused`.

The branch is now four commits on top of `ba8880c`, all mine.

## Testing

- `tests/test_accept_probe.py` — utilization and delta arithmetic, peak
tracking across a drained burst, drop reported once per drop, each new
drop still reported, warn-once behaviour, zero-backlog not dividing by
zero, and the probe being stopped on shutdown.
- `tests/test_storage_request_retry.py` — the diagnosis table above,
plus the empty-`op_stats` case and the arrival-key spelling.
- Full suite: **644 passed, 10 skipped**, against a **623**-passing
baseline measured on `ba8880c` itself — the +21 are the new tests, no
regressions.
- `check_license.py`, `check_docstrings.py`, `ruff check` and `ruff
format --check` all clean.

`tests/test_yuanrong_storage_client_e2e.py` is excluded from those runs:
it errors identically on the unmodified base (optional Yuanrong backend
not installed locally).

---------

Signed-off-by: OutstanderWang <wangweiyanster@gmail.com>
## Motivation

Large training samples can make the long-lived host-memory footprint of
`SimpleStorage` a capacity bottleneck as training scale and sequence
length increase.

## Design

This PR adds an optional hybrid storage layer inside `SimpleStorageUnit`
without changing the public API, controller, or client behavior. Each
`(field, global index)` sample is routed independently according to its
actual stored payload size: samples at or above the internal 1 MiB
threshold are written to node-local SSD, while smaller samples remain in
host memory. The hybrid store preserves existing PUT, GET, CLEAR,
overwrite, capacity, and checkpoint semantics; it reuses decoded
contiguous buffers when possible and falls back to pickle for composite
values. SSD files are temporary, isolated by run and storage unit, and
removed during clear or graceful shutdown. The feature is disabled by
default and configured through `ssd_offload.enabled` and
`ssd_offload.path`.

## E2E Performance

The following results are averages over four iterations with a batch
size of 512 and eight fields. Throughput is reported in Gbit/s.

**Test environment:** 2 × Intel Xeon Platinum 8480C CPUs (224 cores), 10
× NVIDIA/Mellanox ConnectX-7 NDR 400 Gb/s InfiniBand adapters plus an
Intel E810-C 100 GbE adapter, and 8 × KIOXIA CM6-R 3.84 TB NVMe SSDs
(`KCM6DRUL3T84`). 2 Node.

| Per-field sample size | Total payload | Memory PUT | SSD PUT | Memory
GET | SSD GET | Memory E2E | SSD E2E | E2E change |
|---:|---:|---:|---:|---:|---:|---:|---:|---:|
| 1 MiB | 4 GiB | 55.35 | 51.33 | 29.58 | 28.42 | 38.34 | 36.44 | -5.0%
|
| 2 MiB | 8 GiB | 57.17 | 53.53 | 25.37 | 24.65 | 35.04 | 33.73 | -3.7%
|
| 4 MiB | 16 GiB | 58.18 | 50.80 | 27.60 | 26.06 | 37.39 | 34.41 | -8.0%
|

---------

Signed-off-by: pinjie <pinjiex@nvidia.com>
As title

Signed-off-by: 0oshowero0 <o0shower0o@outlook.com>
As title

Signed-off-by: 0oshowero0 <o0shower0o@outlook.com>
…te… (Ascend#179)

## What

Bind the Ascend ACL device context on every thread that calls Mooncake
`register_buffer`/`unregister_buffer`, i.e. the worker threads of the
per-call `ThreadPoolExecutor` in `put()`/`get()`.

## Why

`MooncakeStoreClient.put()`/`get()` create a fresh `ThreadPoolExecutor`
per call and register/unregister transient buffers inside those worker
threads. ACL contexts are thread-local, so the workers had no context:

- `register_buffer` silently fell back to host memory;
- `unregister_buffer` failed with `INVALID_PARAMS`, leaking the
registration and flooding logs with `aclrtGetCurrentContext failed`.

## Changes

`transfer_queue/storage/clients/mooncake_client.py`

- add `_ensure_accelerator_context()`: no-op when `torch.npu` is
unavailable (CUDA/CPU/generic unaffected), otherwise
`npu.set_device(...)` for the calling thread with a one-time context
warmup;
- call it once before `store.setup(...)` (the client thread needs a
context too);
- pass it as `initializer=_ensure_accelerator_context` to both
`ThreadPoolExecutor`s (`put()` and `get()`).

`_register_all_buffers`/`_unregister_all_buffers` run only inside those
pools, so no other call sites are needed.

## Tests

`tests/test_mooncake_client_thread_context.py` (hardware-free):

- `_ensure_accelerator_context` sets the device and warms up only once;
- it is a no-op when no NPU is available;
- `put()` and `get()` build their executor with a non-None `initializer`
that binds the context (executor faked, store faked).

```
python -m pytest -q tests/test_mooncake_client_thread_context.py   # 4 passed
python -m compileall -q transfer_queue tutorial tests
ruff check / ruff format --check                                   # clean
```

## Verification on hardware (Ascend 910B + mooncake-transfer-engine
0.3.11.post1)

Before: `Unregister buffer failed` / `aclrtGetCurrentContext failed`
1500+ times per short run.
After: **0** occurrences; register/unregister succeed; no leak.

Signed-off-by: ys2025-AI <yuanshuaien@163.com>
…d path (Ascend#175)

Refs: Ascend#169

## What this changes

Tensor reads registered and unregistered a receive buffer around every
transfer.
`register_buffer` is a kernel operation (page pinning + MR setup) whose
throughput is an
order of magnitude below the RDMA transfer it enables, so it dominated
read time and left
the RDMA backend slower than the TCP one.

This leases the receive buffer from the local buffer that
`store.setup()` already
registered, then copies into the caller's tensors so the returned
tensors keep owning their
memory. `split_by_bytes()` bounds each lease by one reader thread's
share of the pool, so a
read no longer needs `local_buffer_size` to exceed the batch size.

Three cases still register their own receive buffers: builds without
`mooncake.store.BufferPool` (mooncake-transfer-engine < v0.3.12), a
group whose aligned
total exceeds the per-thread share, and a pool that cannot serve the
request.

### How the pool is sized

`max_bytes=local_buffer_size` keeps in-flight leases inside the region
`setup()` registered.
On the default `max_bytes=0` mooncake budgets *twice* the local buffer
and serves the excess
through `allocate_overflow_unlocked()` — `posix_memalign` plus one
`register_buffer` per
lease — which is exactly the cost this path removes, and it would happen
silently.

`max_regions=MAX_BATCH_WORKER_THREADS` bounds concurrent leases, because
each `get()` starts
its own reader pool and nothing else limits how many run at once.

The per-thread share is `local_buffer_size // (2 *
MAX_BATCH_WORKER_THREADS)` — half of an
equal split. The headroom is on our side rather than the pool's because
`BufferPoolNative`
clamps any `max_bytes` below `local_buffer_capacity_` back up to it, so
`max_bytes` can only
bound the overflow arena, never reserve space inside the registered
region. mooncake
allocates from that same region for its own read-side staging: remote
DISK/DFS replica reads
in `batch_get_into_multi_buffers()`, and the host staging
`execute_ranged_read()` needs when
the destination is device memory. Splitting the whole buffer across the
reader threads would
make those allocations fail — and inside one `batch_get_into` call, a
failure returns
`NO_AVAILABLE_HANDLE` while the lease is still held, so the 3 retries
cannot help.

## Benchmarks

Sections 1-3 were measured with the sizing as first submitted (share =
1/4 of the local
buffer, `max_bytes=0`). Section 4 re-measures the sizing change on a
different machine.

### 1. Tuning `BATCH_SIZE_LIMIT` does not substitute for the pool

Register-per-read, threads=4, both reads swept over the same
`BATCH_SIZE_LIMIT` grid (read
time, ms, median; per-key size fixed at 1MB, only the key count
differs):

| BATCH_SIZE_LIMIT | 50 | 100 | 200 | 400 | 800 | 1600 | 3200 |
|---|---|---|---|---|---|---|---|
| 1024×1MB (1GB) | **109** | 138 | 155 | 160 | 253 | 384 | 329 |
| 4096×1MB (4GB) | 528 | 461 | **455** | 496 | 666 | 723 | 1049 |
| _pool @400 (ref)_ | | | | 1GB **103** / 4GB **434** | | | |

The optimum sits at *small* values and grows worse as the batch grows:
`register_buffer` cost
is dominated by per-byte page pinning, not per-call overhead, so a
larger batch does not
reduce the pinning work — it only reduces how many worker threads share
it.

### 2. The pool removes registration entirely

Breakdown at threads=1, 1024×1MB (1GB), segment sums ≈ wall:

| stage | register-per-read | pool |
|---|---|---|
| register_buffer | 279 ms (~79%) | 0 |
| batch_get_into (RDMA) + copy/meta | ~75 ms | 104 ms |
| **total** | **354 ms** | **104 ms (3.4×)** |

<img width="869" height="685" alt="image"
src="https://github.com/user-attachments/assets/1a93e94b-cfff-4371-ba4f-8d6ef898499d"
/>

### 3. Where the win is big, and where it is honestly marginal

baseline vs pool, threads=4, `BATCH_SIZE_LIMIT=400` (read time, ms,
median):

| workload | baseline | pool | speedup |
|---|---|---|---|
| 64×16MB (1GB) | 367 | 94 | **3.9×** |
| 1024×1MB (1GB) | 178 | 109 | **1.6×** |
| 4096×1MB (4GB) | 419 (best bsl) | 387 | 1.08× |
| 16384×64KB (1GB) | 389 | 480 | **0.8× (slower)** |

Two caveats:

- **The advantage shrinks with more threads and larger reads.** At
threads=1 the pool is
~3.4×; at threads=4 the register cost is spread across workers and the
copy-out scales with
bytes, so a 4GB read narrows the gain to ~1.08×. The pool also lowers
step-to-step jitter
  (1024×1MB threads=1 stdev 19.6 → 7.8 ms).
- **Many tiny keys can regress.** For 16384×64KB the mandatory copy-out
does not pay off
against that shape's cheap merged registration. Vectorizing the copy-out
(uniform groups
copied in one strided pass instead of a per-tensor loop) cut this case
from ~2× slower to
~0.8×, but it is still a net loss at extreme fragmentation. A
`num_keys`/per-key-size
heuristic could fall back to register-per-read here; real trajectory
reads (≈1024×1MB) do
  not hit it.

The copy is what keeps returned tensors caller-owned so the buffer
returns immediately. A
zero-copy view mode would restore the large-read win but requires a "no
cross-read retention"
contract, so the copy path is the safe default.

### 4. What the reserved headroom costs

protocol=rdma (mlx5), single node, `local_buffer_size`=1GB, threads=4,
`BATCH_SIZE_LIMIT`=400, median of 10 reads ± stdev. All three modes are
timed round-robin
within each rep so machine drift is shared:

| workload | register-per-read | share = 1/4 | share = 1/8 (this PR) |
|---|---|---|---|
| 64×16MB | 323.0 ± 7.3 | 96.6 ± 13.1 (3.34×) | 92.1 ± 3.7 (**3.51×**) |
| 1024×1MB | 209.5 ± 40.9 | 115.6 ± 5.7 (1.81×) | 118.4 ± 5.0 (1.77×) |
| 4096×1MB | 598.8 ± 56.4 | 417.7 ± 36.8 (1.43×) | 375.7 ± 70.1
(**1.59×**) |
| 16384×64KB | 384.8 ± 39.0 | 466.7 ± 69.1 (0.82×) | 425.2 ± 48.0
(**0.90×**) |

Leases taken per read confirm the extra rounds (4→8, 5→10, 21→41;
16384×64KB stays at 41
because `BATCH_SIZE_LIMIT` already caps each batch below both shares),
but the extra rounds do
not show up as time: three of four shapes are slightly faster with the
smaller share, and
1024×1MB is 0.04× slower — well inside stdev. The headroom is
effectively free.

This run also reproduces two results from section 3 on different
hardware: the large win on
64×16MB (3.9× there, 3.34-3.51× here) and the regression on 16384×64KB
(0.8× there, 0.82×
here, softened to 0.90× by the smaller share).

Caveats: single node, so the transfer itself may be served by a local
copy rather than RDMA —
the eliminated registration and the round counts are real, but absolute
numbers are not
comparable to a cross-node run. Timing calls `MooncakeStoreClient.get`
directly rather than
`scripts/performance_test`, and the machine had residual load. Treat the
three modes against
each other as the result, not the absolute milliseconds.

## Compatibility

The register-per-read path is preserved as a fallback: the client gates
on
`mooncake.store.BufferPool` availability, so builds without it
(mooncake-transfer-engine <
v0.3.12) transparently keep registering their own receive buffers. The
"upgrade" warning is
emitted once, and only where CPU RDMA reads would pay for registration —
TCP and GDR reads
stay quiet. Requests the pool cannot serve also fall back, warning once
rather than per group
per read.

The GDR read/write paths and the non-tensor paths are untouched.

## Verification

`tests/test_mooncake_buffer_pool.py` fakes the store and the pool, so it
needs neither
mooncake nor RDMA. The fake pool keeps the properties this path depends
on: buffers are carved
out of one arena and reused, exhaustion raises (which is what
`acquire(block=False)` actually
does — it never returns `None`), a buffer cannot be returned while an
exported view of it is
alive, and the pool cannot close while a buffer is out on loan.

Covered: pooled reads with mixed dtypes and scalars, the strided
copy-out for uniform groups,
multi-round reads when the batch exceeds the share, register fallback
for an oversized tensor /
an exhausted pool / a build without `BufferPool`, buffer reuse not
disturbing tensors returned
by an earlier read, a failed transfer and a failed copy-out both
returning the buffer and
preserving the original exception, concurrent readers staying within the
budget and the region
cap, warn-once, the warning's scope, and `close()` ordering.

Separately verified against a real `BufferPool` (TCP, in-process): the
pool accepts these
arguments, an over-capacity `acquire` raises `requested buffer size
exceeds pool capacity`,
the region cap raises `buffer pool is exhausted`, and `close()` is
refused while a buffer is
leased.

---------

Signed-off-by: weiziyang <weiziyang@baidu.com>
Co-authored-by: weiziyang <weiziyang@baidu.com>
@Ginray
Ginray marked this pull request as ready for review October 9, 2026 16:22
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 9, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ⚠️ Failed 2026-10-09T16:31:15.190219Z b7dca52 Draft marked ready
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@Ginray
Ginray force-pushed the rdma-rebased-main-1009 branch 2 times, most recently from 1727d70 to d4ca943 Compare October 9, 2026 16:29
@OutstanderWang

Copy link
Copy Markdown
Contributor

NIXL retries only cover a lost control-plane reply, not a failed or stuck WRITE.

Retried today: zmq.error.Again on the control socket becomes PayloadTransferTimeout, then StorageUnitTimeout, so _request_with_retry replays the whole transfer with a fresh transfer_id after _cancel.

Not retried:

  • put: the WRITE runs on the client in NixlRuntime._send_scatter. A timeout (DEFAULT_NIXL_TRANSFER_TIMEOUT_SECONDS, 180s) or a non-DONE status raises NixlError, which the manager surfaces as a hard RuntimeError. The control socket is idle during await asyncio.wrap_future(self.send(...)), so RCVTIMEO cannot turn a stuck WRITE into the retryable timeout.
  • get: the WRITE runs on the storage unit inside _handle_get_commit. A failure comes back as a PUT_GET_ERROR reply, and the client raises RuntimeError.

Mapping NixlError to PayloadTransferTimeout is not enough on its own:

  1. _cancel is best effort. It logs a warning after a 10s timeout, and _pending_puts / _pending_gets have no expiry. A retry that does not wait for an acknowledged cancel can leave a registered receive buffer (put) or the encoded payload (get) on the storage unit forever. The same leak happens when the client dies between PREPARE and COMMIT.
  2. The storage unit has a single worker thread, and _handle_get_commit blocks it on send(...).result() for up to 180s. A retried GET's PREPARE to the same unit queues behind the stuck WRITE, as do any put, clear, metrics or cancel requests.

Related: when the peer answers PREPARE with an explicit error, _cancel still runs, because remote_prepared / remote_may_be_prepared are set before the reply is read. Each _cancel builds a new zmq.asyncio.Context, connects, waits and term()s it. That includes the expected key miss under a concurrent clear, which is normal traffic.

Suggested:

  • Treat a missing reply and a transport failure from send / the WRITE as retryable, but only after the cancel is acknowledged. Keep peer-reported errors (protocol errors, key not found) non-retryable, as on the ZMQ path.
  • Skip _cancel when the peer replied with an error: nothing is pending on its side.
  • Expire pending transfers on the storage unit so a lost cancel or a dead client cannot pin memory.
  • Move the GET WRITE off the worker thread, so one stuck transfer cannot stall the whole unit.
  • Add tests with a fake runtime: send raising NixlError once is retried with a new transfer_id and the old one is cancelled; an error reply is neither retried nor cancelled; a put with data_parser is still sent once.

@OutstanderWang

OutstanderWang commented Oct 10, 2026 •

Copy link
Copy Markdown
Contributor

This brings back the caller-side ERROR log that #160 removed.

After #160, a key that a concurrent kv_clear removes is a normal race. The storage unit marks its GET_ERROR reply with KEY_NOT_FOUND_MARKER and logs at debug. The manager then rebuilds StorageKeyNotFoundError without logging an ERROR.

In this PR, get raises RuntimeError for the marked reply inside the try. This except Exception catches it and logs Unexpected error from storage unit ... at ERROR. Only after that does the manager turn it back into StorageKeyNotFoundError. The exception type is still correct, but every key miss during a concurrent clear now writes one ERROR line on the caller side. The rewritten test_get_error_reply_is_marked_and_logged_at_debug only covers the storage unit's side, so nothing caught this.

Test (runs unchanged on both refs; it could go in tests/test_storage_key_race.py):

import logging

import pytest

import transfer_queue.storage.managers.simple_storage_manager as manager_module
from transfer_queue.storage.managers.simple_storage_manager import AsyncSimpleStorageManager
from transfer_queue.storage.simple_storage import KEY_NOT_FOUND_MARKER, StorageKeyNotFoundError
from transfer_queue.utils.zmq_utils import ZMQMessage, ZMQRequestType


class _GetErrorSocket:
    async def send_multipart(self, *args, **kwargs):
        pass

    async def recv_multipart(self, *args, **kwargs):
        return ZMQMessage.create(
            request_type=ZMQRequestType.GET_ERROR,
            sender_id="su0",
            body={
                "message": "Failed to get data from storage unit id #su0, "
                f"detail error message: {KEY_NOT_FOUND_MARKER}: key 1 not found"
            },
        ).serialize()


@pytest.mark.asyncio
async def test_key_not_found_reply_is_not_logged_as_error_by_caller(caplog):
    # A key cleared mid-read is a normal race: the caller rebuilds the type without an ERROR log.
    mgr = object.__new__(AsyncSimpleStorageManager)
    mgr.storage_manager_id = "TQ_STORAGE_test"
    mgr.storage_unit_infos = {}
    if hasattr(manager_module, "create_payload_transfer"):
        mgr.payload_transfer = manager_module.create_payload_transfer(None)

    with caplog.at_level(logging.DEBUG, logger="transfer_queue"):
        with pytest.raises(StorageKeyNotFoundError):
            await AsyncSimpleStorageManager._get_from_single_storage_unit.__wrapped__(
                mgr, [1], ["log_probs"], target_storage_unit="su0", socket=_GetErrorSocket()
            )

    errors = [r for r in caplog.records if r.name.startswith("transfer_queue") and r.levelno >= logging.ERROR]
    assert errors == [], [r.getMessage() for r in errors]

Results (python -m pytest -q):

  • main 11922e3 (the main merged here): 1 passed
  • PR head d4ca943: 1 failed
    E   AssertionError: ['[TQ_STORAGE_test]: Unexpected error from storage unit su0: RuntimeError: Failed to get data from storage unit su0: Failed to get data from storage unit id #su0, detail error message: TQKeyNotFound: key 1 not found']
    
  • PR head + the suggested fix below: the new test 1 passed; together with tests/test_storage_key_race.py, tests/test_storage_request_retry.py and tests/test_payload_transfer.py, 36 passed.

Suggested fix: raise StorageKeyNotFoundError directly for a marked reply and let it skip the generic handler, as _get_from_single_storage_unit did on main. The manager's existing except StorageKeyNotFoundError: raise then passes it through unchanged. The import is local because simple_storage imports payload_transfer.

@@ -80,6 +80,9 @@ class ZmqPayloadTransfer(PayloadTransfer):
         global_indexes: list[int],
         fields: list[str],
     ) -> dict[str, Any]:
+        # Imported here because simple_storage imports this package.
+        from transfer_queue.storage.simple_storage import StorageKeyNotFoundError
+
         request = ZMQMessage.create(
             request_type=ZMQRequestType.GET_DATA,
             sender_id=sender_id,
@@ -90,12 +93,15 @@ class ZmqPayloadTransfer(PayloadTransfer):
             await control_socket.send_multipart(request.serialize())
             response = ZMQMessage.deserialize(await control_socket.recv_multipart(copy=False))
             if response.request_type != ZMQRequestType.GET_DATA_RESPONSE:
-                raise RuntimeError(
-                    f"Failed to get data from storage unit {target_id}: {response.body.get('message', 'Unknown error')}"
-                )
+                message = response.body.get("message", "Unknown error")
+                error_type = StorageKeyNotFoundError if KEY_NOT_FOUND_MARKER in message else RuntimeError
+                raise error_type(f"Failed to get data from storage unit {target_id}: {message}")
             return response.body["data"]
         except zmq.error.Again as exc:
             self._raise_timeout(sender_id, target_id, "get", exc)
+        except StorageKeyNotFoundError:
+            # A key cleared mid-read is expected; the storage unit already logged it at debug.
+            raise
         except Exception as exc:
             logger.error(f"[{sender_id}]: Unexpected error from storage unit {target_id}: {type(exc).__name__}: {exc}")
             raise RuntimeError(

@OutstanderWang

Copy link
Copy Markdown
Contributor

def _acquire_frame_registration(self, frame: bytes | bytearray | memoryview) -> _FrameRegistration:
view = memoryview(frame)
if view.readonly or not view.c_contiguous:
owner: bytearray | memoryview = bytearray(view)
else:
owner = view.cast("B")
address = _buffer_address(owner)
registrations = self._agent.register_memory(
[(address, memoryview(owner).nbytes, 0, "")], mem_type="DRAM", backends=["UCX"]
)
if registrations is None:
raise NixlError("failed to register NIXL source frame")
return _FrameRegistration(owner, registrations, address, memoryview(owner).nbytes)

NIXL registers and deregisters memory on every transfer. This is the same cost #175 removed from the Mooncake read path, which this PR also merges, but the NIXL path still pays it.

Send side (client for put, storage unit for get): _send_scatter calls _acquire_frame_registration for every non-empty frame, so one transfer does one register_memory per tensor-sized frame. Readonly or non-contiguous frames are first copied into a fresh bytearray. _release_send_resources deregisters them all once the WRITE finishes, so the next transfer starts over. The frames are newly allocated each time, so UCX's registration cache rarely gets a hit. Registration also runs under self._lock, the same lock the completion poll takes.

Receive side: _acquire_receive_buffer keeps a single reusable buffer. It is reused only if no other receive holds it and it is at least as large as the new payload. A concurrent receive, or a larger one, allocates and registers a new bytearray. _return_receive_buffer then deregisters whichever of the two is smaller, and cancel_receive deregisters without returning. The surviving buffer stays pinned at the largest payload size ever seen, for the life of the process.

For reference, #175 measured register_buffer at about 79% of a 1 GiB read (1024 x 1 MiB) on Mooncake, and leasing pre-registered memory made that read 3.4x faster at one thread. NIXL/UCX is a different stack, so the share here needs measuring, but the cost is the same kind: page pinning plus memory-region setup for every byte moved.

Suggested direction, mirroring #175:

  • Register one long-lived arena per NixlRuntime (size configurable) and lease regions from it for both sending and receiving.
  • Send: pack the frames into a leased region (the packed layout from serial_utils already exists) and issue one contiguous WRITE descriptor instead of one per frame.
  • Receive: lease the region instead of allocating and registering a bytearray. receive() already copies the payload out, so the region can go back to the pool right away.
  • Fall back to per-transfer registration when a payload exceeds a lease's share or the arena is exhausted, as [optim] Lease pre-registered receive buffers on the MooncakeStore read path #175 does.
  • With registrations stable, the agent metadata in every token should stop changing too. Today _send_scatter calls remove_remote_agent / add_remote_agent whenever the bytes differ; please check whether that happens per transfer now.

Trade-off: packing into the arena costs one memcpy. #175 saw this lose on many tiny keys (16384 x 64 KiB, 0.8-0.9x), so the fallback threshold matters.

Could you add a register-vs-WRITE time breakdown for the two-node NIXL run (for example 64 x 16 MiB, 1024 x 1 MiB, 16384 x 64 KiB) before and after, so we can see what this buys on RDMA?

@Ginray
Ginray force-pushed the rdma-rebased-main-1009 branch 3 times, most recently from e081fa5 to 2033bd1 Compare October 10, 2026 17:15
Signed-off-by: Ginray <ginray0215@gmail.com>
@Ginray
Ginray force-pushed the rdma-rebased-main-1009 branch from 2033bd1 to 332cc69 Compare October 10, 2026 17:18
@Ginray

Ginray commented Oct 10, 2026

Copy link
Copy Markdown
Collaborator Author

Suggested:

  • Treat a missing reply and a transport failure from send / the WRITE as retryable, but only after the cancel is acknowledged. Keep peer-reported errors (protocol errors, key not found) non-retryable, as on the ZMQ path.
  • Skip _cancel when the peer replied with an error: nothing is pending on its side.
  • Expire pending transfers on the storage unit so a lost cancel or a dead client cannot pin memory.
  • Move the GET WRITE off the worker thread, so one stuck transfer cannot stall the whole unit.
  • Add tests with a fake runtime: send raising NixlError once is retried with a new transfer_id and the old one is cancelled; an error reply is neither retried nor cancelled; a put with data_parser is still sent once.

Thanks a lot for the detailed review. This PR is mainly for syncing/merging main into rdma, so we’d prefer to keep the larger NIXL lifecycle changes in a separate follow-up PR.

Most of these concerns are already addressed in the follow-up PR #176, commit 90ad2c4, including acknowledged cancellation, deferred GET WRITE, receive quarantine / failed-peer isolation, and distinguishing NixlWriteNotStarted from an uncertain WRITE.

One intentional difference is that we take a more conservative approach once a WRITE may have started: we do not automatically retry a generic NixlError, since a cancel ACK does not prove that the previous RDMA WRITE did not happen.

Pending-transfer expiry and skipping the redundant cancel after an explicit PREPARE failure are not included yet. We’ll evaluate whether they are worth adding based on the implementation complexity and whether they can be kept simple without over-designing the lifecycle handling.

@Ginray

Ginray commented Oct 10, 2026

Copy link
Copy Markdown
Collaborator Author

This brings back the caller-side ERROR log that #160 removed.

After #160, a key that a concurrent kv_clear removes is a normal race. The storage unit marks its GET_ERROR reply with KEY_NOT_FOUND_MARKER and logs at debug. The manager then rebuilds StorageKeyNotFoundError without logging an ERROR.

In this PR, get raises RuntimeError for the marked reply inside the try. This except Exception catches it and logs Unexpected error from storage unit ... at ERROR. Only after that does the manager turn it back into StorageKeyNotFoundError. The exception type is still correct, but every key miss during a concurrent clear now writes one ERROR line on the caller side. The rewritten test_get_error_reply_is_marked_and_logged_at_debug only covers the storage unit's side, so nothing caught this.

Test (runs unchanged on both refs; it could go in tests/test_storage_key_race.py):

import logging

import pytest

import transfer_queue.storage.managers.simple_storage_manager as manager_module
from transfer_queue.storage.managers.simple_storage_manager import AsyncSimpleStorageManager
from transfer_queue.storage.simple_storage import KEY_NOT_FOUND_MARKER, StorageKeyNotFoundError
from transfer_queue.utils.zmq_utils import ZMQMessage, ZMQRequestType


class _GetErrorSocket:
    async def send_multipart(self, *args, **kwargs):
        pass

    async def recv_multipart(self, *args, **kwargs):
        return ZMQMessage.create(
            request_type=ZMQRequestType.GET_ERROR,
            sender_id="su0",
            body={
                "message": "Failed to get data from storage unit id #su0, "
                f"detail error message: {KEY_NOT_FOUND_MARKER}: key 1 not found"
            },
        ).serialize()


@pytest.mark.asyncio
async def test_key_not_found_reply_is_not_logged_as_error_by_caller(caplog):
    # A key cleared mid-read is a normal race: the caller rebuilds the type without an ERROR log.
    mgr = object.__new__(AsyncSimpleStorageManager)
    mgr.storage_manager_id = "TQ_STORAGE_test"
    mgr.storage_unit_infos = {}
    if hasattr(manager_module, "create_payload_transfer"):
        mgr.payload_transfer = manager_module.create_payload_transfer(None)

    with caplog.at_level(logging.DEBUG, logger="transfer_queue"):
        with pytest.raises(StorageKeyNotFoundError):
            await AsyncSimpleStorageManager._get_from_single_storage_unit.__wrapped__(
                mgr, [1], ["log_probs"], target_storage_unit="su0", socket=_GetErrorSocket()
            )

    errors = [r for r in caplog.records if r.name.startswith("transfer_queue") and r.levelno >= logging.ERROR]
    assert errors == [], [r.getMessage() for r in errors]

Results (python -m pytest -q):

  • main 11922e3 (the main merged here): 1 passed
  • PR head d4ca943: 1 failed
    E   AssertionError: ['[TQ_STORAGE_test]: Unexpected error from storage unit su0: RuntimeError: Failed to get data from storage unit su0: Failed to get data from storage unit id #su0, detail error message: TQKeyNotFound: key 1 not found']
    
  • PR head + the suggested fix below: the new test 1 passed; together with tests/test_storage_key_race.py, tests/test_storage_request_retry.py and tests/test_payload_transfer.py, 36 passed.

Suggested fix: raise StorageKeyNotFoundError directly for a marked reply and let it skip the generic handler, as _get_from_single_storage_unit did on main. The manager's existing except StorageKeyNotFoundError: raise then passes it through unchanged. The import is local because simple_storage imports payload_transfer.

@@ -80,6 +80,9 @@ class ZmqPayloadTransfer(PayloadTransfer):
         global_indexes: list[int],
         fields: list[str],
     ) -> dict[str, Any]:
+        # Imported here because simple_storage imports this package.
+        from transfer_queue.storage.simple_storage import StorageKeyNotFoundError
+
         request = ZMQMessage.create(
             request_type=ZMQRequestType.GET_DATA,
             sender_id=sender_id,
@@ -90,12 +93,15 @@ class ZmqPayloadTransfer(PayloadTransfer):
             await control_socket.send_multipart(request.serialize())
             response = ZMQMessage.deserialize(await control_socket.recv_multipart(copy=False))
             if response.request_type != ZMQRequestType.GET_DATA_RESPONSE:
-                raise RuntimeError(
-                    f"Failed to get data from storage unit {target_id}: {response.body.get('message', 'Unknown error')}"
-                )
+                message = response.body.get("message", "Unknown error")
+                error_type = StorageKeyNotFoundError if KEY_NOT_FOUND_MARKER in message else RuntimeError
+                raise error_type(f"Failed to get data from storage unit {target_id}: {message}")
             return response.body["data"]
         except zmq.error.Again as exc:
             self._raise_timeout(sender_id, target_id, "get", exc)
+        except StorageKeyNotFoundError:
+            # A key cleared mid-read is expected; the storage unit already logged it at debug.
+            raise
         except Exception as exc:
             logger.error(f"[{sender_id}]: Unexpected error from storage unit {target_id}: {type(exc).__name__}: {exc}")
             raise RuntimeError(

Thanks a lot for catching this. It has been fixed in the current PR, and we also added the caller-side regression test as suggested.

@Ginray

Ginray commented Oct 10, 2026

Copy link
Copy Markdown
Collaborator Author

def _acquire_frame_registration(self, frame: bytes | bytearray | memoryview) -> _FrameRegistration:
view = memoryview(frame)
if view.readonly or not view.c_contiguous:
owner: bytearray | memoryview = bytearray(view)
else:
owner = view.cast("B")
address = _buffer_address(owner)
registrations = self._agent.register_memory(
[(address, memoryview(owner).nbytes, 0, "")], mem_type="DRAM", backends=["UCX"]
)
if registrations is None:
raise NixlError("failed to register NIXL source frame")
return _FrameRegistration(owner, registrations, address, memoryview(owner).nbytes)

NIXL registers and deregisters memory on every transfer. This is the same cost #175 removed from the Mooncake read path, which this PR also merges, but the NIXL path still pays it.

Send side (client for put, storage unit for get): _send_scatter calls _acquire_frame_registration for every non-empty frame, so one transfer does one register_memory per tensor-sized frame. Readonly or non-contiguous frames are first copied into a fresh bytearray. _release_send_resources deregisters them all once the WRITE finishes, so the next transfer starts over. The frames are newly allocated each time, so UCX's registration cache rarely gets a hit. Registration also runs under self._lock, the same lock the completion poll takes.

Receive side: _acquire_receive_buffer keeps a single reusable buffer. It is reused only if no other receive holds it and it is at least as large as the new payload. A concurrent receive, or a larger one, allocates and registers a new bytearray. _return_receive_buffer then deregisters whichever of the two is smaller, and cancel_receive deregisters without returning. The surviving buffer stays pinned at the largest payload size ever seen, for the life of the process.

For reference, #175 measured register_buffer at about 79% of a 1 GiB read (1024 x 1 MiB) on Mooncake, and leasing pre-registered memory made that read 3.4x faster at one thread. NIXL/UCX is a different stack, so the share here needs measuring, but the cost is the same kind: page pinning plus memory-region setup for every byte moved.

Suggested direction, mirroring #175:

  • Register one long-lived arena per NixlRuntime (size configurable) and lease regions from it for both sending and receiving.
  • Send: pack the frames into a leased region (the packed layout from serial_utils already exists) and issue one contiguous WRITE descriptor instead of one per frame.
  • Receive: lease the region instead of allocating and registering a bytearray. receive() already copies the payload out, so the region can go back to the pool right away.
  • Fall back to per-transfer registration when a payload exceeds a lease's share or the arena is exhausted, as [optim] Lease pre-registered receive buffers on the MooncakeStore read path #175 does.
  • With registrations stable, the agent metadata in every token should stop changing too. Today _send_scatter calls remove_remote_agent / add_remote_agent whenever the bytes differ; please check whether that happens per transfer now.

Trade-off: packing into the arena costs one memcpy. #175 saw this lose on many tiny keys (16384 x 64 KiB, 0.8-0.9x), so the fallback threshold matters.

Could you add a register-vs-WRITE time breakdown for the two-node NIXL run (for example 64 x 16 MiB, 1024 x 1 MiB, 16384 x 64 KiB) before and after, so we can see what this buys on RDMA?

Thanks a lot for the detailed analysis. The follow-up PR #176, commit 90ad2c4, introduces a reusable registered receive-buffer pool with leases, and can directly reuse an existing registration when the send source comes from a leased receive MR.

For the send side, we currently prefer to keep frame-native scatter rather than pack every payload into a long-lived registered arena, since that essentially trades registration overhead for an additional payload-sized memcpy.

We also have a previous 1 GiB cold-start profile: for PUT, receive-buffer registration was 6.15 ms, send-source registration was 2.51 ms, while the detach copy was 237.60 ms; for GET, they were 2.45 ms, 5.27 ms, and 245.64 ms respectively. In that profile, the detach-copy cost was roughly 30x higher than the combined receive- and send-side registration cost.

For the RL + TQ workloads we are currently targeting, payloads are more likely to contain relatively large tensor frames rather than being dominated by thousands of very small frames, so avoiding the additional payload-sized copy currently looks more suitable for the workload we are targeting.

The multi-frame benchmarks you suggested are very useful for understanding how registration overhead scales with frame count. I’ll add those experiments, and we can also continue evaluating and adjusting the design based on validation with real RL workloads.

@0oshowero0

Copy link
Copy Markdown
Collaborator

[AI Review]

I reviewed the merge commit itself (git show --remerge-diff 332cc69, 11 files), because the other 11 commits were already reviewed on main. Most of the conflict resolution is right: bootstrap and SimpleStorageUnit init are clean, the socket pool works with NIXL's two round trips (DEALER sockets, discarded on any error), NIXL's cancel identity passes the #168 filter, parser-backed puts are still not replayed, and the KEY_NOT_FOUND log fix from the earlier comment is in. Three problems come from combining main and rdma, and neither side's tests catch them.

Should fix before merge

1. With NIXL + SSD offload, offloaded payloads stay in memory

payload = self.receive(pending.descriptor).result()
with limit_pytorch_auto_parallel_threads(
target_num_threads=TQ_NUM_THREADS, info=f"[{storage_id}] PUT commit"
):
store_data(list(pending.global_indexes), decode(unpack_from(payload)), pending.data_parser)

for position, (value, encoded) in enumerate(zip(logical_samples, encoded_samples, strict=True)):
if encoded is not None and encoded.payload.nbytes >= self._threshold:
ssd_positions.append(position)
ssd_samples.append(encoded)
prepared_values.append(None)
else:
prepared_values.append(value)
if encoded is None:
fallback_values += 1

_handle_put_commit decodes one packed receive buffer, so every decoded tensor is a zero-copy view into that one buffer. HybridStorageUnitData keeps below-threshold samples as they are. One small field kept in memory (a per-sample reward, for example) therefore pins the whole payload, including the bytes that were just written to SSD. On the ZMQ path each frame has its own buffer, so this does not happen.

Repro: 8 x 8 MiB samples are offloaded, plus one tiny field. The output shows the bytes still alive after put_data:

zmq:  ssd_active_values=8 retained=0.0 MiB
nixl: ssd_active_values=8 retained=64.0 MiB

Mixed puts like this are normal in RL. The "NIXL+SSD public API checks" in the description cover behavior, not memory.

Suggested fix: copy the values that stay in memory. They are below the threshold, so the copy is cheap. With this change the NIXL case retains 0.0 MiB, and the SSD tests in test_simple_storage_unit.py still pass. np.ndarray views would need the same copy.

else:
    # Kept values are small; copy so a view cannot pin the whole receive buffer.
    prepared_values.append(value.clone() if isinstance(value, torch.Tensor) else value)
Repro script
import gc
import tempfile
import tracemalloc

import torch

from transfer_queue.storage.simple_storage import HybridStorageUnitData
from transfer_queue.utils.serial_utils import calc_packed_size, decode, encode, pack_into, unpack_from

MIB = 2**20


def zmq_frames(data):  # ZMQ recv: one independent buffer per frame
    return [bytearray(memoryview(f).cast("B")) for f in encode(data)]


def nixl_frames(data):  # NixlRuntime.receive: one detached packed bytearray
    frames = encode(data)
    packed = bytearray(calc_packed_size(frames))
    pack_into(packed, frames)
    return unpack_from(packed[:])


for name, make_frames in [("zmq", zmq_frames), ("nixl", nixl_frames)]:
    with tempfile.TemporaryDirectory() as tmp:
        store = HybridStorageUnitData(None, tmp, "run", "unit", threshold_bytes=MIB)
        data = {"log_probs": torch.randn(8, 2 * MIB), "reward": torch.randn(8)}  # 8 MiB/sample + tiny field
        tracemalloc.start()
        frames = make_frames(data)
        del data
        store.put_data(decode(frames), list(range(8)))
        del frames
        gc.collect()
        retained = tracemalloc.get_traced_memory()[0] / MIB
        tracemalloc.stop()
        print(f"{name}: ssd_active_values={store.ssd_active_values} retained={retained:.1f} MiB")

2. Errors are handled in both ZmqPayloadTransfer and the manager, which brings back two logging regressions

except zmq.error.Again as exc:
self._raise_timeout(sender_id, target_id, "put", exc, wire_bytes=wire_bytes)
except Exception as exc:
logger.error(
f"[{sender_id}]: Unexpected error during put to storage unit {target_id}: {type(exc).__name__}: {exc}"
)
raise RuntimeError(f"Error in put to storage unit {target_id}: {type(exc).__name__}: {exc}") from exc

except PayloadTransferTimeout as e:
raise StorageUnitTimeout(
f"no answer in {TQ_SIMPLE_STORAGE_SEND_RECV_TIMEOUT}s during put to storage unit "
f"{target_storage_unit} at {self._describe_storage_unit(target_storage_unit)}; "
f"serialized_mb={e.wire_bytes / 2**20:.1f}"
) from e
except Exception as e:
logger.error(
f"[{self.storage_manager_id}]: Unexpected error during put to storage unit "
f"{target_storage_unit}: {type(e).__name__}: {e}"
)
raise RuntimeError(f"Error in put to storage unit {target_storage_unit}: {type(e).__name__}: {e}") from e

ZmqPayloadTransfer kept the manager's error handling from before #171: it logs at ERROR, then wraps the error. The merge also put main's newer handling back around it in the manager. Both effects reproduce:

  • A lost reply that is retried successfully now logs at ERROR. The message comes from _raise_timeout: ZMQ recv timeout (200s) ... The storage unit may be overloaded or crashed. [fix] Retry a lost storage-unit request instead of failing the job #171 removed this line on purpose, so that a retry only logs a WARNING. main logs no ERROR; this PR logs one.
  • A PUT_ERROR reply logs two ERROR lines, and the raised message is wrapped twice. The message reads Error in put to storage unit su0: RuntimeError: Error in put to storage unit su0: RuntimeError: Failed to put data .... main logs one ERROR line.

Suggestion: give error handling to one layer.

  • Transfer: turn zmq.error.Again into PayloadTransferTimeout, and raise peer errors unchanged.
  • Manager: log, wrap, and classify key misses for both backends. It already has the KEY_NOT_FOUND_MARKER in str(exc) branch for NIXL.

This deletes _raise_timeout, both except Exception blocks, and the local StorageKeyNotFoundError import from zmq.py; across zmq.py and the manager the change is a net 32 lines shorter. NoReturn and the module-level TQ_SIMPLE_STORAGE_SEND_RECV_TIMEOUT in zmq.py then become unused. I tried it locally: both repros pass, and so do test_storage_key_race.py, test_storage_request_retry.py, test_payload_transfer.py and test_kv_storage_manager.py (48 tests).

# zmq.py put (get has the same shape)
try:
    await control_socket.send_multipart(frames, copy=False)
    response = ZMQMessage.deserialize(await control_socket.recv_multipart(copy=False))
except zmq.error.Again as exc:
    raise PayloadTransferTimeout(f"no answer from storage unit {target_id}", wire_bytes=wire_bytes) from exc
if response.request_type != ZMQRequestType.PUT_DATA_RESPONSE:
    raise RuntimeError(
        f"Failed to put data to storage unit {target_id}: {response.body.get('message', 'Unknown error')}"
    )
return wire_bytes

# simple_storage_manager.py _get_from_single_storage_unit: restore main's log for real errors
except Exception as exc:
    if KEY_NOT_FOUND_MARKER in str(exc):
        # Already logged at debug by the storage unit; propagate for the caller to classify.
        raise StorageKeyNotFoundError(str(exc)) from exc
    logger.error(
        f"[{self.storage_manager_id}]: Unexpected error from storage unit "
        f"{target_storage_unit}: {type(exc).__name__}: {exc}"
    )
    raise RuntimeError(
        f"Error getting data from storage unit {target_storage_unit}: {type(exc).__name__}: {exc}"
    ) from exc
Repro tests (pass on main 11922e3, fail on this PR)
import logging

import pytest
import zmq

import transfer_queue.storage.managers.simple_storage_manager as manager_module
from transfer_queue.storage.managers.simple_storage_manager import AsyncSimpleStorageManager
from transfer_queue.utils.zmq_utils import ZMQMessage, ZMQRequestType


def _manager():
    mgr = object.__new__(AsyncSimpleStorageManager)
    mgr.storage_manager_id = "TQ_STORAGE_test"
    mgr.storage_unit_infos = {}
    if hasattr(manager_module, "create_payload_transfer"):
        mgr.payload_transfer = manager_module.create_payload_transfer(None)
    return mgr


class _Socket:
    def __init__(self, reply=None):
        self.reply = reply  # None simulates a lost reply

    async def send_multipart(self, *args, **kwargs):
        pass

    async def recv_multipart(self, *args, **kwargs):
        if self.reply is None:
            raise zmq.error.Again()
        return self.reply.serialize()


@pytest.mark.asyncio
async def test_recovered_get_retry_logs_no_error(caplog):
    mgr = _manager()
    ok = ZMQMessage.create(request_type=ZMQRequestType.GET_DATA_RESPONSE, sender_id="su0", body={"data": {"f": [1]}})
    sockets = iter([_Socket(), _Socket(ok)])

    async def attempt():
        return await AsyncSimpleStorageManager._get_from_single_storage_unit.__wrapped__(
            mgr, [1], ["f"], target_storage_unit="su0", socket=next(sockets)
        )

    with caplog.at_level(logging.DEBUG, logger="transfer_queue"):
        assert await mgr._request_with_retry("get", "su0", "samples=1", attempt, max_attempts=2) == (["f"], {"f": [1]})
    assert [r.getMessage() for r in caplog.records if r.levelno >= logging.ERROR] == []


@pytest.mark.asyncio
async def test_put_error_reply_logged_once(caplog):
    mgr = _manager()
    err = ZMQMessage.create(request_type=ZMQRequestType.PUT_ERROR, sender_id="su0", body={"message": "disk full"})
    with caplog.at_level(logging.DEBUG, logger="transfer_queue"):
        with pytest.raises(RuntimeError):
            await AsyncSimpleStorageManager._put_to_single_storage_unit.__wrapped__(
                mgr, [1], {"f": [1]}, target_storage_unit="su0", socket=_Socket(err)
            )
    assert len([r for r in caplog.records if r.levelno >= logging.ERROR]) == 1

Worth fixing

3. The merge changes what the arrival metrics mean, and the description does not mention it

def _storage_arrival_op_type(operation: ZMQRequestType) -> str | None:
"""Map a tracked logical storage operation's first message to its metric label."""
return {
ZMQRequestType.PUT_DATA: "PUT_DATA",
ZMQRequestType.GET_DATA: "GET_DATA",
ZMQRequestType.CLEAR_DATA: "CLEAR_DATA",
ZMQRequestType.PUT_DATA_PREPARE: "PUT_DATA",
ZMQRequestType.GET_DATA_PREPARE: "GET_DATA",
}.get(operation)

arrival_op = _storage_arrival_op_type(operation)
if arrival_op is not None:
self._requests_arrived += 1
self._arrivals_by_op[arrival_op] = self._arrivals_by_op.get(arrival_op, 0) + 1
logger.debug(f"[{self.storage_unit_id}]: worker received operation: {operation}")
# The payload transfer owns PUT/GET operations; everything else stays with the unit.
metric_op = {
ZMQRequestType.PUT_DATA: "PUT_DATA",
ZMQRequestType.GET_DATA: "GET_DATA",
ZMQRequestType.PUT_DATA_COMMIT: "PUT_DATA",
ZMQRequestType.GET_DATA_COMMIT: "GET_DATA",
}.get(operation)

Suggestion: keep main's operation.name counting. For NIXL, count the arrival at COMMIT, where completion is measured, and skip PREPARE and CANCEL. Then one table serves both. The trade-off is that a worker stuck inside PREPARE does not show up in the gap, but the probe still reports unit_not_serving.

4. The logic added in the merge has no tests

The retry tests mock _put_to_single_storage_unit to raise StorageUnitTimeout. As a result, the new zmq.error.Again -> PayloadTransferTimeout -> StorageUnitTimeout mapping is never exercised, on either ZMQ or NIXL. Counting arrivals at PREPARE is untested as well. The two repro tests above could be added as they are.

Optional cleanups

  • KEY_NOT_FOUND_MARKER is defined twice, in zmq.py and simple_storage.py. Moving it and StorageKeyNotFoundError to payload_transfer/base.py, which has no dependencies, removes the duplicate and any need for a local import.
  • In the worker dispatch, handle_request(...) is written out in both branches, and response_msg is None and repeats five times. A nullcontext() plus one if response_msg is None: block would cover it.
  • close() nests four try/finally levels. ZMQSocketPool.close() already swallows per-socket errors, so only payload_transfer.close() needs one.
  • The payload_transfer=None default and its is not None check in _shutdown_resources guard a state that cannot happen: the finalizer always passes the argument.

Already discussed and deferred to #176

With this merge, a lost NIXL control reply becomes retryable. That puts the lifecycle issues raised above on a retry path:

  • cancel is best effort, with no ACK;
  • _pending_* entries never expire;
  • a GET WRITE blocks the unit's only worker thread for up to 180s;
  • a cancel opens a new ZMQ context even after an explicit PREPARE error.

It would help to land #176 right after this PR.

Process

What I ran (macOS, no RDMA)

  • compileall and bash -n pass.
  • pytest excluding tests/e2e: 648 passed, 9 skipped. The only errors are in the Yuanrong e2e tests. They come from the missing optional datasystem package and fail the same way on main.
  • I did not run the two-node NIXL tests.

Signed-off-by: Ginray <ginray0215@gmail.com>
@Ginray

Ginray commented Oct 12, 2026

Copy link
Copy Markdown
Collaborator Author

[AI Review]

I reviewed the merge commit itself (git show --remerge-diff 332cc69, 11 files), because the other 11 commits were already reviewed on main. Most of the conflict resolution is right: bootstrap and SimpleStorageUnit init are clean, the socket pool works with NIXL's two round trips (DEALER sockets, discarded on any error), NIXL's cancel identity passes the #168 filter, parser-backed puts are still not replayed, and the KEY_NOT_FOUND log fix from the earlier comment is in. Three problems come from combining main and rdma, and neither side's tests catch them.

Should fix before merge

1. With NIXL + SSD offload, offloaded payloads stay in memory

payload = self.receive(pending.descriptor).result()
with limit_pytorch_auto_parallel_threads(
target_num_threads=TQ_NUM_THREADS, info=f"[{storage_id}] PUT commit"
):
store_data(list(pending.global_indexes), decode(unpack_from(payload)), pending.data_parser)

for position, (value, encoded) in enumerate(zip(logical_samples, encoded_samples, strict=True)):
if encoded is not None and encoded.payload.nbytes >= self._threshold:
ssd_positions.append(position)
ssd_samples.append(encoded)
prepared_values.append(None)
else:
prepared_values.append(value)
if encoded is None:
fallback_values += 1

_handle_put_commit decodes one packed receive buffer, so every decoded tensor is a zero-copy view into that one buffer. HybridStorageUnitData keeps below-threshold samples as they are. One small field kept in memory (a per-sample reward, for example) therefore pins the whole payload, including the bytes that were just written to SSD. On the ZMQ path each frame has its own buffer, so this does not happen.

Repro: 8 x 8 MiB samples are offloaded, plus one tiny field. The output shows the bytes still alive after put_data:

zmq:  ssd_active_values=8 retained=0.0 MiB
nixl: ssd_active_values=8 retained=64.0 MiB

Mixed puts like this are normal in RL. The "NIXL+SSD public API checks" in the description cover behavior, not memory.

Suggested fix: copy the values that stay in memory. They are below the threshold, so the copy is cheap. With this change the NIXL case retains 0.0 MiB, and the SSD tests in test_simple_storage_unit.py still pass. np.ndarray views would need the same copy.

else:
    # Kept values are small; copy so a view cannot pin the whole receive buffer.
    prepared_values.append(value.clone() if isinstance(value, torch.Tensor) else value)

Repro script

2. Errors are handled in both ZmqPayloadTransfer and the manager, which brings back two logging regressions

except zmq.error.Again as exc:
self._raise_timeout(sender_id, target_id, "put", exc, wire_bytes=wire_bytes)
except Exception as exc:
logger.error(
f"[{sender_id}]: Unexpected error during put to storage unit {target_id}: {type(exc).__name__}: {exc}"
)
raise RuntimeError(f"Error in put to storage unit {target_id}: {type(exc).__name__}: {exc}") from exc

except PayloadTransferTimeout as e:
raise StorageUnitTimeout(
f"no answer in {TQ_SIMPLE_STORAGE_SEND_RECV_TIMEOUT}s during put to storage unit "
f"{target_storage_unit} at {self._describe_storage_unit(target_storage_unit)}; "
f"serialized_mb={e.wire_bytes / 2**20:.1f}"
) from e
except Exception as e:
logger.error(
f"[{self.storage_manager_id}]: Unexpected error during put to storage unit "
f"{target_storage_unit}: {type(e).__name__}: {e}"
)
raise RuntimeError(f"Error in put to storage unit {target_storage_unit}: {type(e).__name__}: {e}") from e

ZmqPayloadTransfer kept the manager's error handling from before #171: it logs at ERROR, then wraps the error. The merge also put main's newer handling back around it in the manager. Both effects reproduce:

  • A lost reply that is retried successfully now logs at ERROR. The message comes from _raise_timeout: ZMQ recv timeout (200s) ... The storage unit may be overloaded or crashed. [fix] Retry a lost storage-unit request instead of failing the job #171 removed this line on purpose, so that a retry only logs a WARNING. main logs no ERROR; this PR logs one.
  • A PUT_ERROR reply logs two ERROR lines, and the raised message is wrapped twice. The message reads Error in put to storage unit su0: RuntimeError: Error in put to storage unit su0: RuntimeError: Failed to put data .... main logs one ERROR line.

Suggestion: give error handling to one layer.

  • Transfer: turn zmq.error.Again into PayloadTransferTimeout, and raise peer errors unchanged.
  • Manager: log, wrap, and classify key misses for both backends. It already has the KEY_NOT_FOUND_MARKER in str(exc) branch for NIXL.

This deletes _raise_timeout, both except Exception blocks, and the local StorageKeyNotFoundError import from zmq.py; across zmq.py and the manager the change is a net 32 lines shorter. NoReturn and the module-level TQ_SIMPLE_STORAGE_SEND_RECV_TIMEOUT in zmq.py then become unused. I tried it locally: both repros pass, and so do test_storage_key_race.py, test_storage_request_retry.py, test_payload_transfer.py and test_kv_storage_manager.py (48 tests).

# zmq.py put (get has the same shape)
try:
    await control_socket.send_multipart(frames, copy=False)
    response = ZMQMessage.deserialize(await control_socket.recv_multipart(copy=False))
except zmq.error.Again as exc:
    raise PayloadTransferTimeout(f"no answer from storage unit {target_id}", wire_bytes=wire_bytes) from exc
if response.request_type != ZMQRequestType.PUT_DATA_RESPONSE:
    raise RuntimeError(
        f"Failed to put data to storage unit {target_id}: {response.body.get('message', 'Unknown error')}"
    )
return wire_bytes

# simple_storage_manager.py _get_from_single_storage_unit: restore main's log for real errors
except Exception as exc:
    if KEY_NOT_FOUND_MARKER in str(exc):
        # Already logged at debug by the storage unit; propagate for the caller to classify.
        raise StorageKeyNotFoundError(str(exc)) from exc
    logger.error(
        f"[{self.storage_manager_id}]: Unexpected error from storage unit "
        f"{target_storage_unit}: {type(exc).__name__}: {exc}"
    )
    raise RuntimeError(
        f"Error getting data from storage unit {target_storage_unit}: {type(exc).__name__}: {exc}"
    ) from exc

Repro tests (pass on main 11922e3, fail on this PR)

Worth fixing

3. The merge changes what the arrival metrics mean, and the description does not mention it

def _storage_arrival_op_type(operation: ZMQRequestType) -> str | None:
"""Map a tracked logical storage operation's first message to its metric label."""
return {
ZMQRequestType.PUT_DATA: "PUT_DATA",
ZMQRequestType.GET_DATA: "GET_DATA",
ZMQRequestType.CLEAR_DATA: "CLEAR_DATA",
ZMQRequestType.PUT_DATA_PREPARE: "PUT_DATA",
ZMQRequestType.GET_DATA_PREPARE: "GET_DATA",
}.get(operation)

arrival_op = _storage_arrival_op_type(operation)
if arrival_op is not None:
self._requests_arrived += 1
self._arrivals_by_op[arrival_op] = self._arrivals_by_op.get(arrival_op, 0) + 1
logger.debug(f"[{self.storage_unit_id}]: worker received operation: {operation}")
# The payload transfer owns PUT/GET operations; everything else stays with the unit.
metric_op = {
ZMQRequestType.PUT_DATA: "PUT_DATA",
ZMQRequestType.GET_DATA: "GET_DATA",
ZMQRequestType.PUT_DATA_COMMIT: "PUT_DATA",
ZMQRequestType.GET_DATA_COMMIT: "GET_DATA",
}.get(operation)

Suggestion: keep main's operation.name counting. For NIXL, count the arrival at COMMIT, where completion is measured, and skip PREPARE and CANCEL. Then one table serves both. The trade-off is that a worker stuck inside PREPARE does not show up in the gap, but the probe still reports unit_not_serving.

4. The logic added in the merge has no tests

The retry tests mock _put_to_single_storage_unit to raise StorageUnitTimeout. As a result, the new zmq.error.Again -> PayloadTransferTimeout -> StorageUnitTimeout mapping is never exercised, on either ZMQ or NIXL. Counting arrivals at PREPARE is untested as well. The two repro tests above could be added as they are.

Optional cleanups

  • KEY_NOT_FOUND_MARKER is defined twice, in zmq.py and simple_storage.py. Moving it and StorageKeyNotFoundError to payload_transfer/base.py, which has no dependencies, removes the duplicate and any need for a local import.
  • In the worker dispatch, handle_request(...) is written out in both branches, and response_msg is None and repeats five times. A nullcontext() plus one if response_msg is None: block would cover it.
  • close() nests four try/finally levels. ZMQSocketPool.close() already swallows per-socket errors, so only payload_transfer.close() needs one.
  • The payload_transfer=None default and its is not None check in _shutdown_resources guard a state that cannot happen: the finalizer always passes the argument.

Already discussed and deferred to #176

With this merge, a lost NIXL control reply becomes retryable. That puts the lifecycle issues raised above on a retry path:

  • cancel is best effort, with no ACK;
  • _pending_* entries never expire;
  • a GET WRITE blocks the unit's only worker thread for up to 180s;
  • a cancel opens a new ZMQ context even after an explicit PREPARE error.

It would help to land #176 right after this PR.

Process

What I ran (macOS, no RDMA)

  • compileall and bash -n pass.
  • pytest excluding tests/e2e: 648 passed, 9 skipped. The only errors are in the Yuanrong e2e tests. They come from the missing optional datasystem package and fail the same way on main.
  • I did not run the two-node NIXL tests.

Thanks for the detailed review. We went through the points again and added another round of fixes. The current HEAD has been updated.

In #189, we focused on regressions introduced by the merge:

  • Fixed the NIXL + SSD receive-buffer retention issue where small Tensor / NumPy views could keep the entire shared receive buffer alive after large values had already been offloaded to SSD. Encodable values kept in memory are now detached from the shared backing storage. Regression coverage was added for packed / frame-native layouts, Tensor / NumPy values, and nested values.
  • Simplified ZMQ error handling. The transfer layer no longer logs timeout errors before retry or wraps peer errors a second time. Intermediate retries only emit WARNINGs, while an ERROR is logged only after retries are exhausted. Regression tests now cover PUT / GET send and receive timeouts, peer errors, and exception chains.
  • Preserved KEY_NOT_FOUND as a normal concurrent-clear race, without reintroducing a caller-side ERROR.
  • Restored the original arrival accounting for normal ZMQ operations. For NIXL, PREPARE still counts one logical PUT / GET arrival, while COMMIT / CANCEL do not increment it again. We did not move arrival accounting to COMMIT because the metric currently represents an operation starting, and keeping PREPARE-stage visibility is useful for that definition.

We did not duplicate the NIXL lifecycle / retry work in #189 because that is already handled by the follow-up #176, including:

  • automatic PREPARE retry only after a matching CANCEL acknowledgement;
  • treating a lost PUT COMMIT reply as an unknown outcome and disabling automatic replay;
  • deferred GET WRITE responses so the storage worker is not blocked;
  • reusing the original transaction socket for CANCEL;
  • dedicated lifecycle / retry regression coverage.

Automatic expiry of _pending_puts / _pending_gets is still not implemented. This is tied to potentially active or outcome-uncertain WRITEs and their associated buffers. A simple TTL could reclaim memory that is still being accessed by RDMA, so we are leaving this as follow-up lifecycle work instead of adding a transaction-GC mechanism to this merge PR.

Main has also moved forward since this PR was created. New changes such as UPDATE_DATA will be handled in a later sync rather than expanding the scope of #189 further.

We also left optional cleanups such as simplifying close() and further worker-dispatch refactoring out of this PR, so #189 stays focused on merge regressions and compatibility.

Thanks again for the review. In particular, the SSD receive-buffer retention and ZMQ error-handling findings helped us catch real regressions introduced by the merge.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

8 participants