Skip to content

[TransferEngine] Add NCCL host and device transport backend#2852

Open
akhillanger wants to merge 7 commits into
kvcache-ai:mainfrom
akhillanger:alanger/pr/te_nccl_host_transport
Open

[TransferEngine] Add NCCL host and device transport backend#2852
akhillanger wants to merge 7 commits into
kvcache-ai:mainfrom
akhillanger:alanger/pr/te_nccl_host_transport

Conversation

@akhillanger

@akhillanger akhillanger commented Jul 10, 2026

Copy link
Copy Markdown

Summary

Add an optional NCCL backend to Mooncake Transfer Engine (TE), supporting both:

  • Host-submitted transfers through NCCL ncclPut and ncclGet
  • Device-side communication through Mooncake's DeviceTransport abstraction

The backend is opt-in and does not change the existing P2P, RDMA, IBGDA, CXI, TENT, or EP paths.

RFC: #2630 (This PR adds NCCL backend for both host and device APIs).

Motivation

NCCL now provides host and device APIs for one-sided communication over registered windows. Integrating these APIs
into TE allows applications to use NCCL-managed transport selection while retaining Mooncake's resource ownership,
registration, and submission model.

This also provides a common NCCL foundation for future Mooncake-EP integration without requiring EP kernels to manage NCCL communicators or windows directly.

Implementation

  • Add NCCL communicator initialization and shutdown to TE.
  • Add NCCL-backed buffer allocation and collective window registration.
  • Implement TE write operations with ncclPut.
  • Implement TE read operations with ncclGet.
  • Track host-submitted operation completion using stream-ordered CUDA events.
  • Expose device-visible communication resources through the Mooncake DeviceTransport API.
  • Keep native NCCL communicator and window ownership inside the backend.
  • Define device-operation participation and ordering semantics explicitly.
  • Add capability reporting for the NCCL routes and resources available to kernels.
  • Keep NCCL under its own CUDA-only build guard rather than coupling it to P2P, IBGDA, or CXI.
  • Require the first NCCL version providing the APIs and device-communicator fields used by the implementation.
  • Add an NCCL transport example with run-specific communicator bootstrap state.

Semantics

NCCL communicator creation and window registration are collective operations. All participating ranks must invoke them
in the same order.

Host submissions are asynchronous and stream ordered. A TE write maps to ncclPut, while a TE read maps to ncclGet.
Completion is reported only after the corresponding CUDA event completes.

The lifecycle remains externally serialized, consistent with the existing TE transport implementations.

Compatibility

  • Disabled by default
  • Requires CUDA and a compatible NCCL installation
  • Existing TE backends and their default selection remain unchanged

Validation

  • Build TE with the NCCL backend enabled
  • Exercise NCCL allocation, registration, PUT, GET, completion, deregistration, and shutdown
  • Run the multi-rank NCCL transport example
  • Verify host-to-host-visible data correctness for both read and write operations
  • Verify device-side operations across supported NCCL routes

Out of scope

  • TENT integration
  • Mooncake-EP dispatch/combine integration
  • Making NCCL the default TE backend
  • Automatic fallback between NCCL and existing TE transports

Akhil Langer and others added 3 commits July 6, 2026 14:50
Hide NCCL communicators and windows behind opaque Mooncake contexts, add lane-defined LSA/GIN helpers and capability routing, and provide collective-safe buffer allocation. Align NCCL 2.30.4 CMake discovery, CXI guards, lifecycle semantics, and run-scoped example bootstrap.

@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 NCCL-based transport backends to the Mooncake transfer engine, adding support for both device-initiated (USE_NCCL_DEVICE) and host-submitted RMA (USE_NCCL_HOST) communication paths, along with corresponding examples and handshake protocol integration. The code review identified several critical issues in the new host transport implementation: a state desynchronization risk in unregisterMemory if metadata updates fail, a potential GPU memory safety issue if cudaEventRecord fails after a successful transfer submission, and multiple potential crashes or exceptions due to unvalidated JSON parsing during handshake and buffer catalog decoding.

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 +598 to +623
int unregisterMemory(void* addr, bool update_metadata) {
{
std::lock_guard<std::mutex> sessions_lock(sessions_mutex_);
if (!sessions_.empty() || !bootstrap_ids_.empty()) {
LOG(ERROR) << "[Host NCCL] cannot unregister memory while "
"NCCL sessions own collective windows";
return ERR_INVALID_ARGUMENT;
}
}

bool found = false;
{
std::lock_guard<std::mutex> lock(buffers_mutex_);
auto it = std::find_if(
local_buffers_.begin(), local_buffers_.end(),
[addr](const BufferInfo& buffer) {
return buffer.addr == reinterpret_cast<uint64_t>(addr);
});
if (it != local_buffers_.end()) {
local_buffers_.erase(it);
found = true;
}
}
if (!found) return ERR_ADDRESS_NOT_REGISTERED;
return metadata_->removeLocalMemoryBuffer(addr, update_metadata);
}

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

In unregisterMemory, the buffer is erased from local_buffers_ before calling metadata_->removeLocalMemoryBuffer. If removeLocalMemoryBuffer fails, the local state becomes desynchronized from the metadata store. We should only erase the buffer after a successful metadata update, or roll back the erasure on failure.

int unregisterMemory(void* addr, bool update_metadata) {
    {
        std::lock_guard<std::mutex> sessions_lock(sessions_mutex_);
        if (!sessions_.empty() || !bootstrap_ids_.empty()) {
            LOG(ERROR) << "[Host NCCL] cannot unregister memory while "
                          "NCCL sessions own collective windows";
            return ERR_INVALID_ARGUMENT;
        }
    }

    bool found = false;
    BufferInfo removed_info;
    {
        std::lock_guard<std::mutex> lock(buffers_mutex_);
        auto it = std::find_if(
            local_buffers_.begin(), local_buffers_.end(),
            [addr](const BufferInfo& buffer) {
                return buffer.addr == reinterpret_cast<uint64_t>(addr);
            });
        if (it != local_buffers_.end()) {
            removed_info = *it;
            local_buffers_.erase(it);
            found = true;
        }
    }
    if (!found) return ERR_ADDRESS_NOT_REGISTERED;
    int result = metadata_->removeLocalMemoryBuffer(addr, update_metadata);
    if (result != 0) {
        std::lock_guard<std::mutex> lock(buffers_mutex_);
        local_buffers_.push_back(removed_info);
    }
    return result;

@akhillanger akhillanger Jul 10, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Addressed in 9f3cdfb. unregisterMemoryLocked now performs the protocol-scoped metadata removal before erasing the entry from local_buffers_, while the caller holds buffers_mutex_. The metadata side stages a cloned descriptor, publishes it first when requested, and commits the local metadata cache only after publication succeeds, so a lookup or publication failure leaves both catalogs unchanged. Batch unregister also prevalidates every address and uses one bulk transaction, making it all-or-nothing. I added coverage for failed atomic bulk removal, successful bulk removal, and same-address protocol-scoped removal.

Disclosure: This response was AI-generated.

Comment on lines +251 to +257
ncclResult_t result = ncclPutSignal(
source, length, ncclUint8, owner_rank, window->handle,
dest_addr - destination.addr, 0, 0, 0, comm_, stream_);
if (result == ncclSuccess && event) {
cuda_result = cudaEventRecord(event, stream_);
}
if (saved_device >= 0) cudaSetDevice(saved_device);

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

If ncclPutSignal succeeds but cudaEventRecord fails, the transfer is already submitted to the GPU stream, but the function returns an error. The caller might immediately free or overwrite the source buffer, leading to silent data corruption or use-after-free on the GPU. We should synchronously wait for the stream to finish if cudaEventRecord fails to ensure safety.

        ncclResult_t result = ncclPutSignal(
            source, length, ncclUint8, owner_rank, window->handle,
            dest_addr - destination.addr, 0, 0, 0, comm_, stream_);
        if (result == ncclSuccess && event) {
            cuda_result = cudaEventRecord(event, stream_);
            if (cuda_result != cudaSuccess) {
                cudaStreamSynchronize(stream_);
            }
        }
        if (saved_device >= 0) cudaSetDevice(saved_device);

@akhillanger akhillanger Jul 10, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Addressed in 9f3cdfb. After a successful ncclPutSignal, a cudaEventRecord failure now synchronizes stream_ before returning the error. This drains the already-queued put before the caller can reuse or free its source buffer. The original event-record error is preserved; if the fallback synchronization also fails, that error is appended and the session is marked failed. The normal WRITE and reverse-PUT READ paths passed the two-GPU and forced-network GB200 tests.

Disclosure: This response was AI-generated.

Comment on lines +942 to +953
int onHandshake(const HandShakeDesc& peer_desc,
HandShakeDesc& local_desc) {
local_desc.protocol = kHandshakeProtocol;
Json::Value request;
std::string error;
if (!decodeJson(peer_desc.payload, &request, &error)) {
local_desc.reply_msg = "Invalid NCCL handshake payload: " + error;
return 0;
}

const std::string op = request["op"].asString();
Json::Value response;

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

Accessing request["op"].asString() without validating that request is an object and contains a string "op" can cause a crash or throw a Json::LogicError if the peer sends a malformed payload. We should validate the JSON structure before accessing its fields.

    int onHandshake(const HandShakeDesc& peer_desc,
                    HandShakeDesc& local_desc) {
        local_desc.protocol = kHandshakeProtocol;
        Json::Value request;
        std::string error;
        if (!decodeJson(peer_desc.payload, &request, &error)) {
            local_desc.reply_msg = "Invalid NCCL handshake payload: " + error;
            return 0;
        }

        if (!request.isObject() || !request.isMember("op") || !request["op"].isString()) {
            local_desc.reply_msg = "Missing or invalid 'op' field in handshake request";
            return 0;
        }

        const std::string op = request["op"].asString();

@akhillanger akhillanger Jul 10, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Addressed in 9f3cdfb. decodeJson now uses strict parsing and rejects any non-object root. onHandshake then requires op to exist and be a string before calling asString(), and the dispatch is protected by a Json::Exception boundary so malformed peer input is returned as a handshake rejection rather than escaping the daemon callback. The outer transfer-handshake decoder now also rejects wrong-typed present fields.

Disclosure: This response was AI-generated.

Comment on lines +113 to +125
buffers->clear();
for (const auto& item : value) {
BufferInfo buffer;
buffer.addr = item["addr"].asUInt64();
buffer.length = item["length"].asUInt64();
buffer.device_id = item["device_id"].asInt();
if (!buffer.addr || !buffer.length ||
buffer.device_id != expected_device) {
if (error) *error = "Invalid NCCL buffer catalog entry";
return false;
}
buffers->push_back(buffer);
}

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

Calling asUInt64() and asInt() on item fields without verifying their existence and types can cause crashes or exceptions if the peer sends a malformed buffer catalog. We should validate each item's structure.

    buffers->clear();
    for (const auto& item : value) {
        if (!item.isObject() || !item.isMember("addr") || !item["addr"].isUInt64() ||
            !item.isMember("length") || !item["length"].isUInt64() ||
            !item.isMember("device_id") || !item["device_id"].isInt()) {
            if (error) *error = "Malformed buffer catalog entry";
            return false;
        }
        BufferInfo buffer;
        buffer.addr = item["addr"].asUInt64();
        buffer.length = item["length"].asUInt64();
        buffer.device_id = item["device_id"].asInt();
        if (!buffer.addr || !buffer.length ||
            buffer.device_id != expected_device) {
            if (error) *error = "Invalid NCCL buffer catalog entry";
            return false;
        }
        buffers->push_back(buffer);
    }

@akhillanger akhillanger Jul 10, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Addressed in 9f3cdfb. decodeBuffers now validates that the catalog is an array and that every entry is an object with UInt64 address/length fields and an integer device ID before conversion. It also rejects zero values, address-range overflow, the wrong device, empty catalogs, and duplicate or overlapping ranges. Decoding uses a temporary vector and only replaces the output after the entire catalog validates, so a malformed later entry cannot leave a partial result.

Disclosure: This response was AI-generated.

Make buffer catalog freezing and metadata removal atomic, drain queued puts when event recording fails, and strictly validate NCCL handshake payloads.
@xiaofanl-nvidia

Copy link
Copy Markdown

++ @UNIDY2002 @staryxchen from the RFC #2630

Make kernel operations intentionally unchecked, shrink the by-value context, add compile-only NCCL/IBGDA traits coverage, and document the exact NCCL device-code version contract.
@github-actions github-actions Bot added the documentation Improvements or additions to documentation label Jul 10, 2026
Apply the repository-required clang-format 20 style to all C++ and CUDA files changed by this PR.
@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 50.42735% with 116 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
mooncake-transfer-engine/src/transfer_metadata.cpp 40.76% 109 Missing ⚠️
...nsfer-engine/example/transfer_engine_validator.cpp 0.00% 4 Missing ⚠️
...ncake-transfer-engine/src/transfer_engine_impl.cpp 50.00% 3 Missing ⚠️

📢 Thoughts on this report? Let us know!

@UNIDY2002 UNIDY2002 self-requested a review July 11, 2026 15:32

@UNIDY2002 UNIDY2002 left a comment

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.

Thank you for your contribution! I have a few comments that should be addressed before we move on.

Comment on lines +74 to +75
option(USE_NCCL_DEVICE "option for enabling the NCCL DeviceTransport backend" OFF)
option(USE_NCCL_HOST "option for enabling the NCCL host RMA transport" OFF)

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.

We have two options here, but I only see USE_NCCL_DEVICE in the build.md doc.

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.

This seems to be a compile-time probe. I'm wondering whether it's necessary, since the regular build process should already reveal whether these features are supported.

ASSERT_EQ(re, 0);
}

TEST_F(TransferMetadataTest, BulkMemoryRemovalIsAtomic) {

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.

Why do we need this case? It seems irrelevant to NCCL transport.

Comment on lines +101 to +139
if (!root.isObject()) return ERR_INVALID_ARGUMENT;
for (const char *field : {"protocol", "payload", "local_nic_path",
"local_gid", "peer_nic_path", "reply_msg"}) {
if (root.isMember(field) && !root[field].isString()) {
return ERR_INVALID_ARGUMENT;
}
}
if (root.isMember("local_lid") && !root["local_lid"].isUInt()) {
return ERR_INVALID_ARGUMENT;
}
if (root.isMember("qp_num")) {
if (!root["qp_num"].isArray()) return ERR_INVALID_ARGUMENT;
for (const auto &qp : root["qp_num"]) {
if (!qp.isUInt()) return ERR_INVALID_ARGUMENT;
}
}
#ifdef USE_BAREX
if (root.isMember("barex_port") && !root["barex_port"].isUInt()) {
return ERR_INVALID_ARGUMENT;
}
#endif
#ifdef USE_EFA
if (root.isMember("efa_addr") && !root["efa_addr"].isString()) {
return ERR_INVALID_ARGUMENT;
}
#endif
#ifdef USE_CXI
if (root.isMember("cxi_addr") && !root["cxi_addr"].isString()) {
return ERR_INVALID_ARGUMENT;
}
#endif
#ifdef USE_UB
if (root.isMember("jetty_num")) {
if (!root["jetty_num"].isArray()) return ERR_INVALID_ARGUMENT;
for (const auto &jetty : root["jetty_num"]) {
if (!jetty.isUInt()) return ERR_INVALID_ARGUMENT;
}
}
#endif

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.

I'm afraid these checks are too strict, because sometimes we may launch TE of different versions.

Setting fallback values when the new fields (i.e. protocol and payload) do not exist should be more acceptable, just like the handling of local_lid and local_gid below.

}

int TransferMetadata::removeSegmentDesc(const std::string &segment_name) {
std::lock_guard<std::mutex> txn_guard(local_segment_txn_mutex_);

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.

Will introducing a mutex here have side effects? I'm not sure. cc: @alogfans @staryxchen

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.

removeSegmentDesc is less frequently used so I think it doesn't have side effects.

Comment on lines 1214 to 1233
int TransferMetadata::addLocalMemoryBuffer(const BufferDesc &buffer_desc,
bool update_metadata) {
std::lock_guard<std::mutex> txn_guard(local_segment_txn_mutex_);
std::shared_ptr<SegmentDesc> updated;
{
RWSpinlock::WriteGuard guard(segment_lock_);
auto new_segment_desc = std::make_shared<SegmentDesc>();
auto &segment_desc = segment_id_to_desc_map_[LOCAL_SEGMENT_ID];
*new_segment_desc = *segment_desc;
segment_desc = new_segment_desc;
segment_desc->buffers.push_back(buffer_desc);
auto it = segment_id_to_desc_map_.find(LOCAL_SEGMENT_ID);
if (it == segment_id_to_desc_map_.end() || !it->second) {
LOG(ERROR) << "Local segment descriptor not found";
return ERR_METADATA;
}
updated = std::make_shared<SegmentDesc>(*it->second);
updated->buffers.push_back(buffer_desc);
it->second = updated;
}
if (update_metadata) {
return updateSegmentDesc(updated->name, *updated);
}
if (update_metadata) return updateLocalSegmentDesc();
return 0;
}

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.

Why do we have to change the impl of addLocalMemoryBuffer, removeLocalMemoryBuffer, startHandshakeDaemon, etc? They seems irrelevant to this PR.

Comment on lines 139 to +153
if (metadata_conn_string == P2PHANDSHAKE) {
rpc_binding_method = "P2P handshake";
desc.rpc_port = findAvailableTcpPort(desc.sockfd);
if (desc.rpc_port == 0) {
LOG(ERROR) << "P2P: No valid port found for local TCP service.";
return -1;
// Preserve the legacy dynamic-port behavior for callers that pass
// only a host name. The parser supplies the default handshake
// port for those callers, but that port was not explicitly
// requested and cannot be shared by multiple local processes.
// Explicit non-zero ports are honored so independently launched
// peers can advertise predictable endpoints.
if (!hasExplicitPort(local_server_name) || port == 0) {
desc.rpc_port = findAvailableTcpPort(desc.sockfd);
if (desc.rpc_port == 0) {
LOG(ERROR)
<< "P2P: No valid port found for local TCP service.";
return -1;
}

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.

Would you help me review these lines of codes? @alogfans

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.

@UNIDY2002 This is okay.

@@ -1261,14 +1449,55 @@ int TransferMetadata::getRpcMetaEntry(const std::string &server_name,

int TransferMetadata::startHandshakeDaemon(

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.

Does startHandshakeDaemon call only once in normal execution? If not, how to update on_receive_handshake correctly? If necessary, it's better to seperate this feature to updateHandshakeHandler().

}
{
std::lock_guard<std::mutex> lock(handshake_handler_mutex_);
handshake_daemon_started_ = true;

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.

Why need this here? I think it's better to keep it in startHandshakeDaemon () only.

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

Labels

Common documentation Improvements or additions to documentation run-ci Transfer Engine

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants