MORI-IO nixl-style cpp bench script - #504
Conversation
…haling WaitBusy() spins on the completion flag instead of blocking on the condition variable, trading a CPU core for lower wakeup latency. BatchRead/BatchWrite now accept numpy arrays for offsets/sizes, avoiding per-element Python-list conversion. Benchmark gains --busy-wait, --post-batch-size, --warmup-iters flags and separate launch/transfer latency reporting. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…_once actually uses _validate_rdma() unconditionally assumed the strided (buffer_size+1) offset layout whenever --batch-contiguous was off, but that layout is only ever used by run_batch_once() (--enable-batch-transfer). The default path, run_single_once(), always issues transfers at contiguous offsets (buffer_size * i) regardless of --batch-contiguous, causing spurious AssertionErrors on batch_contiguous=False runs with batch > 1 even though the RDMA transfer itself succeeded. Gate the strided comparison on self.enable_batch_transfer and add a branch for the non-batched, non-contiguous-flag case that compares against the contiguous prefix that was actually transferred.
bench_engine is a pure-C++ cross-node MORI-IO benchmark whose measurement loop matches nixlbench (NVIDIA NIXL xferbench): a single whole-loop timer, inline spin-poll completion in the bench thread, GB=1e9 throughput, warmup excluded, and pipeline depth (--inflight) equivalent to nixl --pipeline_depth. This makes MORI-IO and NIXL RDMA numbers directly comparable on the same fabric. Ports the MORI Python benchmark feature set: batching (strided default + --batch-contiguous), --enable-batch-transfer, chunking (--chunk-bytes/--max-chunks), session fast-path (--enable-sess), --busy-wait, --poll_cq_mode, --mem-type gpu|cpu, --post-batch-size, and the max-send-wr/cqe/msg-sge QP knobs. Flag defaults match the Python bench (enable-sess and busy-wait off by default). Rendezvous is a tiny length-prefixed TCP socket exchanging msgpack-packed EngineDesc/MemoryDesc; rank 0 initiator drives one-sided WRITE/READ, rank 1 target only holds memory registered. Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
Match benchmark.py: size/batch sweeps (--all, --all-batch, --buffer-size, --sweep-start-size/--sweep-max-size/--sweep-step), per-side memory type (--initiator-mem-type/--target-mem-type), --target-dev-offset for cross-rail transfers, and --log-level.
- latency is now per single transfer (total / (iters*batch)), matching nixl's avg_latency = total_duration/(per_thread_iter*batch_size); BW unchanged (msg*batch*iters over one whole-loop timer). - completion is always an inline spin (like nixl's getXferStatus scan); drop the --busy-wait flag, which only affected untimed warmup and would otherwise make latency non-comparable to nixl. Warmup now spins too. - batching self-activates on --transfer-batch-size > 1 (one N-descriptor batch request); drop the redundant --enable-batch-transfer gate that silently made batch sweeps a no-op by default. - simplify dead cfg.host ternary; document that --inflight > 1 reuses the same buffer window (fine for an unvalidated perf bench, matching nixl).
Restore the Python bench's batch/single submission toggle in the C++
nixl-style engine bench, and add C++ run docs to MORI-IO-BENCHMARK.md.
- --enable-batch-transfer / --disable-batch-transfer (default ON):
- ON => one N-descriptor batch request (BatchWrite/BatchRead), the
nixl-equivalent path (nixl always batches).
- OFF => N individual single-transfer submissions per iteration at
contiguous offsets i*msg, matching Python's run_single_once.
Default is ON (vs Python's OFF) so the out-of-the-box run and the
--all-batch sweep stay nixl-comparable; this also fixes the old sweep
no-op (OFF now scales with N instead of doing one msg-byte write).
- Strict stop-and-wait (nixl --pipeline_depth 1): one request outstanding
at a time. perSlot statuses live in a flat status array (TransferStatus
is non-copyable); a request completes only when all its sub-transfers
finish, mirroring Python waiting on the whole status_list.
- Metrics unchanged/consistent across modes: both move curBatch transfers
of msg per iter, so total_bytes = msg*batch*iters and per-transfer
latency = total_us/(iters*batch).
- docs/MORI-IO-BENCHMARK.md: new "C++ Benchmark (nixlbench-matching)"
section (build, 2-node run, sweep modes, Python->C++ flag mapping) and
updated the --enable-batch-transfer row to describe ON vs OFF.
Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: c755fd822e
ℹ️ 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".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| // Target: nothing to drive. RDMA one-sided ops complete without target CPU. | ||
| // Just hold memory registered until the initiator says it's done. | ||
| Barrier(sock); // wait for initiator to finish the whole sweep | ||
| if (cpuMem) HIP_CHECK(hipHostFree(buf)); else HIP_CHECK(hipFree(buf)); |
There was a problem hiding this comment.
Deregister memory before freeing its backing buffer
Both this target exit and the initiator exit free buf while localMem remains registered with the RDMA backend; the initiator may also still have a session referencing it. Once a transfer has caused the region to be registered with a NIC, freeing the HIP allocation first leaves the backend with a registration for invalid storage and defers cleanup until engine destruction, which can cause teardown failures or stale DMA mappings. Destroy the session where applicable and call engine.DeregisterMemory(localMem) before hipHostFree/hipFree on both paths.
Useful? React with 👍 / 👎.
| # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE | ||
| # SOFTWARE. | ||
| from tests.python.utils import TorchDistContext | ||
| import numpy as np |
There was a problem hiding this comment.
Declare or remove the new NumPy dependency
In an environment that previously had the documented benchmark prerequisites but not NumPy, the script now fails at startup on this import before it can parse any arguments. NumPy is not declared in pyproject.toml or requirements-build.txt, so users following the existing installation flow are not guaranteed to have it; add it to the applicable runtime/setup requirements or retain an implementation using the previously supported list inputs.
Useful? React with 👍 / 👎.
| static int TcpListenAccept(uint16_t port) { | ||
| int lfd = socket(AF_INET, SOCK_STREAM, 0); | ||
| int opt = 1; | ||
| setsockopt(lfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)); | ||
| sockaddr_in addr{}; | ||
| addr.sin_family = AF_INET; | ||
| addr.sin_addr.s_addr = INADDR_ANY; | ||
| addr.sin_port = htons(port); | ||
| if (bind(lfd, reinterpret_cast<sockaddr*>(&addr), sizeof(addr)) != 0) { | ||
| perror("bind"); | ||
| std::exit(1); | ||
| } | ||
| listen(lfd, 1); | ||
| int fd = accept(lfd, nullptr, nullptr); | ||
| close(lfd); | ||
| return fd; | ||
| } |
There was a problem hiding this comment.
Could we reuse MORI's existing networking abstractions instead of introducing another raw-socket rendezvous implementation here?
mori::application::TCPContext and SocketBootstrapNetwork already provide connection lifecycle management, send/receive primitives, barriers, timeouts, and cleanup. This benchmark also links mori_application, so duplicating these responsibilities with raw file descriptors creates a second implementation with different retry, timeout, and error-handling behavior.
Please consider using the existing bootstrap layer, or extracting a small reusable rendezvous helper from it. That would also avoid manual close()/std::exit() paths and make resource ownership explicit.
| TransferStatusPtrVec sp{&base[0]}; | ||
| TransferUniqueIdVec ids{id}; |
There was a problem hiding this comment.
Both are ordinary std::vector types, so this normally introduces two heap allocations and two deallocations per iteration. This harness overhead is included in the reported latency and can be significant for small messages.
Could these vectors be allocated once outside the timed loop and reused by updating sp[0] and ids[0] before each submission? The mode dispatch could likewise be selected before timing, although the allocations are the more significant concern.
bench(io): nixlbench-matching C++ engine benchmark
Summary
This PR extends #444 (the busy-wait numpy-batch-marshaling benchmark work) with a native C++ MORI-IO benchmark,
tests/cpp/io/bench_engine.cpp, whose measurement loop is written to match nixlbench (NVIDIA NIXL'sxferbench) exactly. The branch descends directly from #444's head (1bd5dcd3); everything here is on top of that.Where #444 gave the Python benchmark a busy-wait completion mode and explicit warmup so its small-message numbers stop being dominated by condition-variable wakeup latency, this PR takes the next step: a benchmark with zero Python-interpreter overhead and a measurement methodology identical to nixl, so MORI-IO and NIXL RDMA numbers are truly apples-to-apples on the same fabric.
Relationship to #444
--busy-wait,--post-batch-size,--warmup-iters,--sweep-stepfrom perf(io): add busy-wait completion mode and numpy batch-transfer mars… #444) is unchanged.excluded warmup — but bakes them in as the only path (busy-wait is
unconditional; there is no cv-blocking
Wait()here) and adds the nixl-paritytimer/metric definitions the Python bench structurally cannot match.
What "nixl-matching" means here
nixlbench's
total_timer), not the per-iterationtime.time()sum the Pythonbench uses — the per-iter sum silently excludes the inter-iteration gap.
total_us / (iters × batch), matchingnixl's
avg_latency = total_duration / (per_thread_iter × batch_size).= msg × batch × iters / 1e9 / (total_us / 1e6)(GB = 10⁹), sameunits as nixl.
(mirroring nixl's
getXferStatusscan); no cv-blocking wait that would add~5–10 µs wakeup latency and make numbers non-comparable.
--pipeline_depth 1).the whole sweep) and reused across every size point and iteration — no
registration or allocation inside the timed loop, matching the Python bench.
Results (batch = 1, msg = 1 MiB)
VRAM→VRAM, WRITE, single device, 1 QP, 1 worker thread, session enabled,
500 timed iters. Broadcom Thor2
bnxt_reRoCE, 2 nodes.bench_enginebenchmark.pyThe C++ tool reads ~9% higher at this latency-bound point precisely because of the
nixl-parity changes: the whole-loop timer and unconditional busy-wait remove the
inter-iteration gap and the cv-wakeup latency that the Python per-iter timing
still includes. At large batched sizes (e.g. batch 64 × 1 MiB) the two converge to
within <1% (~48 GB/s), since those overheads become negligible next to the
~22 µs/transfer of a 64 MiB request.
Reproduce
Build
C++
bench_engine— TCP rendezvous, start rank 1 (target) FIRSTPython
benchmark.py— torchrun/gloo, start rank 0 (master) FIRSTTest plan
bench_enginebuilds (-DBUILD_EXAMPLES=OFFto skip the MPI-onlyexamples target) and links
libmori_io+libmori_application.explained by the timer/busy-wait methodology difference.