Skip to content

[Bugfix][TransferEngine] Fix RDMA handshake thundering herd#2793

Open
dqxlzlz wants to merge 1 commit into
kvcache-ai:mainfrom
dqxlzlz:fix/rdma-handshake-thundering-herd
Open

[Bugfix][TransferEngine] Fix RDMA handshake thundering herd#2793
dqxlzlz wants to merge 1 commit into
kvcache-ai:mainfrom
dqxlzlz:fix/rdma-handshake-thundering-herd

Conversation

@dqxlzlz

@dqxlzlz dqxlzlz commented Jul 8, 2026

Copy link
Copy Markdown

Description

The problem we hit in production

In a large-scale PD-disaggregated deployment, when many nodes start up and connect
at the same time, some QPs get stuck in a permanently degraded state: they report
transport retry counter exceeded and their throughput drops to ~0 and never
recovers.

We reproduced this on a real cluster. With 240 concurrent handshakes hitting a
single target (unpatched build):

  • Handshake setup time ballooned to ~96 seconds (normally < 1s).
  • 53-71% of the connections ended up with near-zero throughput.

This confirms the target's handshake RPC server gets overwhelmed by the connection
storm.

Why this happens (root cause)

The handshake path has no admission control: every node fires all of its
handshake RPCs at once. When 40+ nodes x 8 NICs start simultaneously, hundreds of
handshake requests pile up in the target's RPC queue. Meanwhile the RDMA QP has a
fixed hardware retry window (kTimeout=14 ~67ms x kRetryCount=7 ~= 470ms).

The mismatch is fatal: the RPC queue latency (tens of seconds) far exceeds the QP's
470ms retry window. Before the handshake response arrives, the QP's hardware retry
counter is exhausted, the QP transitions to ERROR, and it never recovers -
throughput stays at 0.

What this PR does

A two-layer defense:

  1. Connection Limiter (per-transport counting semaphore): limits concurrent
    outbound handshakes so a single instance can't flood the target. Configurable via
    the MC_MAX_CONCURRENT_HANDSHAKES environment variable (default: 16).
  2. Exponential Backoff: when a handshake fails, subsequent retries back off
    exponentially (100ms -> 200ms -> ... -> 5s cap) in the worker pool, giving the
    overwhelmed target room to drain its queue and recover.

After the fix, the same 240-concurrent test brings handshake setup time back down
from ~96s to < 1s, keeping it well under the 470ms hardware retry window.

Module

  • Transfer Engine (mooncake-transfer-engine)
  • Mooncake Store (mooncake-store)
  • Mooncake EP (mooncake-ep)
  • Mooncake PG (mooncake-pg)
  • Integration (mooncake-integration)
  • P2P Store (mooncake-p2p-store)
  • Python Wheel (mooncake-wheel)
  • Common (mooncake-common)
  • Mooncake RL (mooncake-rl)
  • CI/CD
  • Docs
  • Other

Type of Change

  • Bug fix
  • New feature
  • Refactor
  • Breaking change
  • Documentation update
  • Performance improvement
  • Other

How Has This Been Tested?

Unit tests

Added connection_limiter_test.cpp (behavioral tests for the semaphore) and
extended config_test.cpp (env-var parsing for MC_MAX_CONCURRENT_HANDSHAKES).

cmake -DBUILD_UNIT_TESTS=ON /path/to/build && cmake --build build \
  --target connection_limiter_test config_test
ctest -R "connection_limiter_test|config_test"
  • ConnectionLimiter never exceeds the configured max concurrent slots under
    32-thread contention.
  • acquire() blocks when full and unblocks on release().
  • MC_MAX_CONCURRENT_HANDSHAKES parsing: default 16, valid override applied,
    zero/negative/non-numeric values rejected.

All unit tests pass.

Cluster stress test (thundering herd)

Tested on a 4-node cluster, 8x mlx5_bond NICs per node (32 NICs total), with a
target node under 240 concurrent handshake connections from 3 initiator nodes.

Test commands:

# Target (with fix, limit=4):
MC_MAX_CONCURRENT_HANDSHAKES=4 transfer_engine_bench --mode=target \
  --metadata_server=http://<target>:8765/metadata \
  --local_server_name=<target>:12345 --device_name=mlx5_bond_0

# 3 remote nodes x 80 initiators = 240 concurrent handshakes

Observed results (directly measured):

Scenario Handshake setup time Near-zero-throughput procs
Before fix (240 concurrent) ~96 seconds 53-71%
After fix (240 concurrent) < 1 second N/A (only NIC bandwidth contention)

Note: the cluster repro directly reproduces the handshake queueing delay
(~96s >> the 470ms QP retry window) and the resulting throughput degradation.
The permanent QP ERROR transition is the downstream production symptom
explained in the root-cause section; it was not separately captured as a log
line in this fresh-connection benchmark.

  • Unit tests pass
  • Integration tests pass (if applicable)
  • Manual testing done (4-node cluster, 240 concurrent connections)

Checklist

  • I have performed a self-review of my own code
  • I have formatted my code using ./scripts/code_format.sh
  • I have run pre-commit run --all-files and all hooks pass
  • I have updated the documentation (if applicable)
  • I have added tests to prove my changes are effective
  • For changes >500 LOC: I have filed an RFC issue (N/A: ~140 LOC)

AI Assistance Disclosure

  • AI tools were used (specify below)

An AI coding assistant helped implement the fix and orchestrate the multi-node
stress tests across the 4-node cluster. The human submitter has reviewed every
changed line and can defend the change end-to-end.

When many nodes connect simultaneously during large-scale startup, the target's RPC server is overwhelmed with concurrent handshake requests, causing handshake latency to exceed the QP hardware retry timeout (~470ms). This leads to permanent 'transport retry counter exceeded' errors and zero throughput.

Add a two-layer defense:
1. ConnectionLimiter: a per-transport counting semaphore limiting concurrent
   outbound handshakes. Configurable via MC_MAX_CONCURRENT_HANDSHAKES (default 16).
2. Exponential backoff on handshake failure (100ms -> 5s cap) in the worker pool.

Validated on a 4-node cluster (32 NICs, 240 concurrent handshakes): handshake
setup time dropped from ~96s to <1s.

Co-authored-by: Tony Lu <tonylu@linux.alibaba.com>
Copilot AI review requested due to automatic review settings July 8, 2026 06:00

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a ConnectionLimiter semaphore and an exponential handshake backoff mechanism to mitigate thundering-herd connection failures during large-scale startups. The review feedback highlights three key issues: a critical bug where slices in handshake backoff are immediately failed and cause CPU busy-looping, a potential deadlock risk if sendHandshake() throws an exception before releasing its handshake slot, and a need for defensive initialization of ConnectionLimiter to prevent deadlocks when configured with non-positive values.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +360 to +366
// Check handshake backoff before attempting connection
if (isInHandshakeBackoff(entry.first)) {
for (auto &slice : entry.second)
failed_slice_list.push_back(slice);
entry.second.clear();
continue;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

Critical Bug: Handshake Backoff Causes Immediate Transfer Failure & Busy-Looping

In the current implementation, when an endpoint is in handshake backoff, its slices are added to failed_slice_list and cleared from entry.second.

However, this triggers a severe issue:

  1. Slices in failed_slice_list have their retry count (slice->rdma.retry_cnt) incremented via shouldRetrySlice() and are immediately redispatched back to the queue.
  2. Because the worker thread busy-loops very quickly, these slices will be picked up again almost instantly, find the endpoint still in backoff, increment their retry count again, and redispatch.
  3. Within microseconds, the slices will exhaust their maximum retries (max_retry_cnt) and fail permanently, completely defeating the purpose of the backoff.
  4. This also causes 100% CPU busy-looping on the worker thread during the backoff period.

Solution

We should collect these slices in a local list, redispatch them directly without incrementing their retry count, and introduce a small sleep (e.g., 10ms) to prevent high CPU usage.

            // Check handshake backoff before attempting connection
            if (isInHandshakeBackoff(entry.first)) {
                SliceList backoff_slice_list;
                for (auto &slice : entry.second)
                    backoff_slice_list.push_back(slice);
                entry.second.clear();
                redispatch(backoff_slice_list, thread_id);
                std::this_thread::sleep_for(std::chrono::milliseconds(10));
                continue;
            }

Comment on lines +400 to +403
context_.engine().acquireHandshakeSlot();
int rc = context_.engine().sendHandshake(peer_server_name, local_desc,
peer_desc);
context_.engine().releaseHandshakeSlot();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Exception Safety & Deadlock Risk

Directly calling acquireHandshakeSlot() and releaseHandshakeSlot() around sendHandshake() is risky. If sendHandshake() (or any internal metadata/RPC operation) throws an exception, or if an early return is added in the future, releaseHandshakeSlot() will be bypassed. This will permanently leak a slot in the ConnectionLimiter semaphore, eventually causing all future handshakes to block indefinitely (deadlock).

We should use a simple RAII guard to ensure the handshake slot is always released safely.

        struct HandshakeSlotGuard {
            RdmaTransport& engine;
            explicit HandshakeSlotGuard(RdmaTransport& eng) : engine(eng) {
                engine.acquireHandshakeSlot();
            }
            ~HandshakeSlotGuard() {
                engine.releaseHandshakeSlot();
            }
        };
        HandshakeSlotGuard guard(context_.engine());
        int rc = context_.engine().sendHandshake(peer_server_name, local_desc,
                                                 peer_desc);

Comment on lines +47 to +48
explicit ConnectionLimiter(int max_concurrent)
: max_concurrent_(max_concurrent), current_(0) {}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Defensive Programming: Guard Against Invalid/Zero Max Concurrent Slots

If max_concurrent is configured to 0 or a negative value (e.g., via programmatic configuration or unexpected environment parsing), current_ < max_concurrent_ will always be false, causing acquire() to block permanently and deadlock the system.

We should defensively ensure max_concurrent_ is at least 1.

Suggested change
explicit ConnectionLimiter(int max_concurrent)
: max_concurrent_(max_concurrent), current_(0) {}
explicit ConnectionLimiter(int max_concurrent)
: max_concurrent_(max_concurrent > 0 ? max_concurrent : 1), current_(0) {}

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Mitigates RDMA handshake “thundering herd” failures in the Transfer Engine by adding per-transport admission control for outbound handshakes and introducing retry backoff behavior in the worker pool to avoid overwhelming the target handshake RPC server.

Changes:

  • Add a per-transport ConnectionLimiter (counting semaphore) configured via MC_MAX_CONCURRENT_HANDSHAKES (default 16).
  • Introduce handshake failure tracking and exponential backoff state in WorkerPool when connection setup fails.
  • Add unit tests and CMake wiring for the new limiter + config parsing.

Reviewed changes

Copilot reviewed 10 out of 10 changed files in this pull request and generated 6 comments.

Show a summary per file
File Description
mooncake-transfer-engine/tests/connection_limiter_test.cpp Adds behavioral tests for ConnectionLimiter under contention and blocking semantics.
mooncake-transfer-engine/tests/config_test.cpp Adds env-var parsing tests for MC_MAX_CONCURRENT_HANDSHAKES.
mooncake-transfer-engine/tests/CMakeLists.txt Builds and registers connection_limiter_test.
mooncake-transfer-engine/src/transport/rdma_transport/worker_pool.cpp Adds handshake backoff logic and rail state tracking for connection setup failures.
mooncake-transfer-engine/src/transport/rdma_transport/rdma_transport.cpp Instantiates the per-transport limiter from global config.
mooncake-transfer-engine/src/transport/rdma_transport/rdma_endpoint.cpp Wraps outbound handshake RPC with limiter acquire/release calls.
mooncake-transfer-engine/src/config.cpp Parses MC_MAX_CONCURRENT_HANDSHAKES into GlobalConfig.
mooncake-transfer-engine/include/transport/rdma_transport/worker_pool.h Adds handshake backoff fields and constants to WorkerPool state.
mooncake-transfer-engine/include/transport/rdma_transport/rdma_transport.h Introduces ConnectionLimiter and transport methods to acquire/release handshake slots.
mooncake-transfer-engine/include/config.h Adds GlobalConfig::max_concurrent_handshakes with default 16.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +361 to +366
if (isInHandshakeBackoff(entry.first)) {
for (auto &slice : entry.second)
failed_slice_list.push_back(slice);
entry.second.clear();
continue;
}
Comment on lines +761 to +762
state.handshake_failures =
std::min(state.handshake_failures + 1, kHandshakeBackoffMaxFailures);
Comment on lines +47 to +48
explicit ConnectionLimiter(int max_concurrent)
: max_concurrent_(max_concurrent), current_(0) {}
Comment on lines +56 to +60
void release() {
std::unique_lock<std::mutex> lock(mu_);
--current_;
cv_.notify_one();
}
Comment on lines +399 to +403
// Acquire a handshake slot to limit concurrent connection storms.
context_.engine().acquireHandshakeSlot();
int rc = context_.engine().sendHandshake(peer_server_name, local_desc,
peer_desc);
context_.engine().releaseHandshakeSlot();
EXPECT_EQ(config.max_concurrent_handshakes, 8);
}

TEST_F(PkeyIndexEnvTest, MaxConcurrentHandshakesNonNumericKeepsDefault) {
@codecov-commenter

Copy link
Copy Markdown

⚠️ Please install the 'codecov app svg image' to ensure uploads and comments are reliably processed by Codecov.

Codecov Report

❌ Patch coverage is 65.27778% with 50 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
...ngine/src/transport/rdma_transport/worker_pool.cpp 0.00% 44 Missing ⚠️
.../include/transport/rdma_transport/rdma_transport.h 71.42% 4 Missing ⚠️
...ine/src/transport/rdma_transport/rdma_endpoint.cpp 0.00% 2 Missing ⚠️

📢 Thoughts on this report? Let us know!

Comment on lines +361 to +366
if (isInHandshakeBackoff(entry.first)) {
for (auto &slice : entry.second)
failed_slice_list.push_back(slice);
entry.second.clear();
continue;
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The current backoff branch may incorrectly consume the slice retry budget and cause busy-waiting.

context_.engine().acquireHandshakeSlot();
int rc = context_.engine().sendHandshake(peer_server_name, local_desc,
peer_desc);
context_.engine().releaseHandshakeSlot();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A RAII guard is better.

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants