Skip to content

Feat/io rdma retry tunables - #670

Open
amirakb89 wants to merge 5 commits into
ROCm:mainfrom
amirakb89:feat/io-rdma-retry-tunables
Open

amirakb89 wants to merge 5 commits into
ROCm:mainfrom
amirakb89:feat/io-rdma-retry-tunables

Conversation

@amirakb89

@amirakb89 amirakb89 commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

io/rdma: surface QP-error on fatal async events + expose the RC retry budget

Summary

Two related hardening changes for the MoRI-IO RDMA producer path, motivated by
#655 (a single lost KV-write ACK permanently wedging PD-disagg decode).

  1. Make the QP retry budget tunable via MORI_IO_QP_TIMEOUT,
    MORI_IO_QP_RETRY_CNT, and MORI_IO_QP_RNR_RETRY. Previously the RC QP was
    built with a hardcoded timeout=14 / retry_cnt=7 (~0.5 s before teardown),
    which operators could not widen for GPU-loaded receivers that are alive but
    slow to ACK.

  2. Fail in-flight transfers on fatal verbs async events. QP-error CQEs
    already drive TransferStatus to failed, but a dead CQ or device produces no
    CQE at all — leaving every in-flight transfer IN_PROGRESS forever and
    wedging the producer. This forwards the async events that make outstanding
    work uncompletable (QP_FATAL / QP_REQ_ERR / QP_ACCESS_ERR, CQ_ERR,
    DEVICE_FATAL) to a handler that fails the affected ledgers and marks the
    endpoints degraded so later submissions fail fast instead of queueing onto a
    dead QP.

Relationship to #655

Issue #655 requests five fixes. This PR addresses #1 (producer surfaces the
QP-error instead of polling forever) and #4 (expose the retry budget).

Net effect of this PR: the producer degrades a transient QP failure to a failed
transfer (freeing the sender) and lets operators widen the retry window to reduce
trigger frequency — instead of the producer hanging silently on a dead QP.

Details

Retry-budget env vars

Env var Default Range Meaning
MORI_IO_QP_TIMEOUT 14 (~537 ms) 0–31 Local ACK timeout exponent. 0 disables it (an unresponsive peer will stall).
MORI_IO_QP_RETRY_CNT 7 0–7 Transport retries before RETRY_EXC_ERR.
MORI_IO_QP_RNR_RETRY 7 0–7 RNR-NAK retries (7 = infinite, the ibverbs default this path uses).

Defaults are unchanged, so behavior is identical unless an operator opts in.
Out-of-range values fall back to the built-in default. The applied values are
logged at INFO on the application module
(ibverbs attr.timeout:X attr.retry_cnt:Y attr.rnr_retry:Z).

Async fatal-event handling

  • RdmaManager::FailInFlightTransfers matches affected endpoints by qpn, CQ
    handle, or ibv_context (per event scope), fails their ledgers via the new
    SubmissionLedger::FailAll, and marks them degraded.
  • PORT_ERR is deliberately excluded: the link usually recovers and the QP
    survives, so failing transfers on it would be wrong.
  • CqCallbackMeta::status becomes atomic and is claimed with
    exchange(nullptr), since the CQ poller and the monitor thread can now reach
    the same meta concurrently. Claiming on terminal success also fixes a latent
    bug where a late flush CQE could flip an already-successful transfer to failed.
  • Gated by MORI_IO_FAIL_ON_ASYNC_EVENT (default on) under the existing
    MORI_IO_ENABLE_ASYNC_EVENTS. The monitor is reset first in ~RdmaManager so
    its handler cannot run against torn-down endpoints.

Tests

  • New unit test submission_ledger_fail_all (tests/cpp/io/test_engine.cpp)
    covers SubmissionLedger::FailAll: each distinct transfer failed exactly once
    (shared-meta batches counted once), orphaned records drained, SQ depth
    released for every posted WR, failure message propagated, and the
    post-FailAll CQE race (a late CQE must not resurrect a failed transfer).

Performance

No regression on the happy path. The only change on the hot completion path is
CqCallbackMeta::status becoming std::atomic (one relaxed load + one
exchange per completion); everything else runs only on the error/teardown path.

A/B measured with bench_engine, this branch vs. its base (f9697e16), on a
two-node ionic/RoCEv2 setup (MI300-class, ionic_0, single NIC pair). Write,
batch 1, GPU memory, 4 QP/transfer, 500 iters, 50 warmup:

Msg size Base BW (GB/s) Branch BW (GB/s) Δ
1 MiB 27.37 27.40 +0.1%
4 MiB 37.03 37.23 +0.5%
8 MiB 39.50 39.57 +0.2%
16 MiB 40.49 40.56 +0.2%
24 MiB 41.00 41.00 0.0%
32 MiB 41.03 41.03 0.0%

Every point is within ±0.5% (run-to-run RDMA jitter); peak (~41 GB/s), the
latency curve, and the overall shape are identical, and both validate
byte-identical.

Read path not measured on this fabric. RDMA READ fails at the hardware
level on this ionic pair — even raw ib_read_bw returns status 12 / syndrome 0xb (the same responder-resource limitation that blocks RDMA atomics here).
This is environmental, not introduced by this PR: the base commit fails
identically. The atomic status change is exercised by the write completion
path all the same, so write is a sufficient A/B signal.

Verification

Exercised on the same two-node ionic/RoCEv2 setup:

  • Retry tunables take effect end-to-end — with
    MORI_IO_QP_TIMEOUT=20 MORI_IO_QP_RETRY_CNT=5 MORI_IO_QP_RNR_RETRY=4, the
    applied-attr log changed 1:1 from the default 14/7/7; transfers validated
    byte-identical.
  • Async fatal-event path — a synthetic DEVICE_FATAL injected mid-transfer
    drove FailInFlightTransfers, marked all endpoints on the context degraded,
    and the next submission was rejected immediately
    (EP is degraded, rejecting new submissions) rather than hanging.

Refs #655

Akbarzadeh and others added 2 commits September 10, 2026 22:55
MORI-IO always builds its QPs through the ibverbs provider, so every NIC
inherits timeout=14 (~537ms of ACK wait) and retry_cnt=7 regardless of
vendor. A responder that stalls for longer than that budget, or a fabric
that drops ACKs because they come back on a mismarked traffic class,
fails the transfer with IBV_WC_RETRY_EXC_ERR, and there is no way to
widen the window without rebuilding.

Expose timeout, retry_cnt and rnr_retry as MORI_IO_QP_TIMEOUT,
MORI_IO_QP_RETRY_CNT and MORI_IO_QP_RNR_RETRY. Defaults are unchanged.
Each override is range-checked against the field width so a bad value is
warned about and ignored rather than failing ibv_modify_qp during
connection setup, and MORI_IO_QP_TIMEOUT=0 warns that it disables the
ACK timeout altogether. The values actually applied are logged next to
the existing SL/TC line, and the IBV_WC_RETRY_EXC_ERR advice now names
the knobs.

Beyond mitigation this is a diagnostic: widening the budget separates a
responder that is merely slow from ACKs that never arrive at all.

Refs: ROCm#655
QP-error CQEs already drive TransferStatus to failed, but a dead CQ or
device cannot produce a CQE at all. RdmaAsyncEventMonitor logged
IBV_EVENT_CQ_ERR / DEVICE_FATAL and moved on, leaving every in-flight
transfer IN_PROGRESS forever and wedging the producer.

Forward the async events that make outstanding work uncompletable to a
QpErrorHandler, scoped to one QP (QP_FATAL / QP_REQ_ERR / QP_ACCESS_ERR),
one CQ (CQ_ERR), or a whole context (DEVICE_FATAL). PORT_ERR is excluded:
the link usually recovers and the QP survives, so failing transfers on it
would be wrong.

RdmaManager::FailInFlightTransfers matches affected endpoints by qpn, CQ
handle, or ibv_context, fails their ledgers via a new
SubmissionLedger::FailAll, and marks them degraded so later submissions
fail fast instead of queueing onto a dead QP. FailAll leaves the ledger
empty, so the CQE path cannot clear that flag afterwards.

CqCallbackMeta::status becomes atomic and is claimed with
exchange(nullptr), since the CQ poller and the monitor thread can now
reach the same meta concurrently. Claiming on terminal success also fixes
a latent bug where a late flush CQE could flip an already-successful
transfer to failed, because TransferStatus::Update is sticky only against
errors.

Gated by MORI_IO_FAIL_ON_ASYNC_EVENT (default on) under the existing
MORI_IO_ENABLE_ASYNC_EVENTS. The monitor is now reset first in
~RdmaManager so its handler cannot run against torn-down endpoints.

Refs ROCm#655

Co-Authored-By: Claude <noreply@anthropic.com>
…post-close race

FailAll() now marks the ledger closed; Insert()/InsertOrphaned() refuse once
closed (returning kInvalidRecordId). This seals the window where a submitter that
cleared the degraded check just before a fatal async event still reaches Insert()
and posts a signaled WR whose CQE can never arrive. FailInFlightTransfers() now
closes admission before failing the ledger, and the CQE recovery path skips a
closed ledger (terminal degraded, not the recoverable partial-post kind).

Also disambiguate QP-scope async events by ibv context: qp_num is only unique
within one device, so on a multi-NIC node a QP_FATAL on one NIC would otherwise
fail a healthy endpoint on another NIC sharing the same number.

Handle the mid-post rejection: when Insert() returns kInvalidRecordId, release the
signaled EP's depth and orphan the siblings' pending WRs, returning a clean
failure instead of hanging. Extract OrphanPendingWrsOnOtherEps() to share this
between the ibv_post_send-failure and async-retire paths.

Adds unit coverage: submission_ledger_closed_after_fail_all and
async_event_scope_matching.
…an target)

Adds submission_ledger_close_race_stress: N submitter threads calling Insert()
race the CQ poller (ReleaseByCqe) and the async monitor (FailAll) on one ledger,
400 iterations. Asserts the partition invariant — every Insert is either accepted
or refused with kInvalidRecordId, the ledger ends Closed, and post-race Inserts
stay refused. Primarily a ThreadSanitizer target (build with -fsanitize=thread):
proves closed_, records_, and sqDepth are synchronized across the three real
actors. Mirrors the existing TSan-net pattern in the umbp metadata-store tests and
the TransferStatus wait/notify tests in test_transfer_wait.cpp.
…d document the knobs

Follow-up polish on the RC retry-budget tunables:

- Each MORI_IO_QP_* retry knob now falls back to a transport-wide MORI_RDMA_QP_*
  when the IO-specific name is unset, so the shmem/CCO QPs that share the ibverbs
  path can be tuned too; the MORI_IO_QP_* name wins when both are set.
- min_rnr_timer is no longer hardcoded to 12: expose MORI_IO_QP_MIN_RNR_TIMER
  (and its MORI_RDMA_QP_MIN_RNR_TIMER fallback), so a receiver slow to post
  buffers can widen the RNR backoff instead of exhausting rnr_retry. Sits right
  next to the already-tunable rnr_retry.
- Document the retry-budget knobs and MORI_IO_FAIL_ON_ASYNC_EVENT in
  docs/MORI-IO-GUIDE.md, which had no mention of them.

Also folds in a clang-format wrap fix in common.cpp.
@amirakb89
amirakb89 marked this pull request as ready for review September 18, 2026 22:00
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.

1 participant