Skip to content

MORI-IO nixl-style cpp bench script - #504

Open
amirakb89 wants to merge 7 commits into
ROCm:mainfrom
amirakb89:io-nixlstyle-cpp-bench
Open

MORI-IO nixl-style cpp bench script#504
amirakb89 wants to merge 7 commits into
ROCm:mainfrom
amirakb89:io-nixlstyle-cpp-bench

Conversation

@amirakb89

Copy link
Copy Markdown
Contributor

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's xferbench) 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

What "nixl-matching" means here

  • One whole-loop timer around the entire iteration loop (identical to
    nixlbench's total_timer), not the per-iteration time.time() sum the Python
    bench uses — the per-iter sum silently excludes the inter-iteration gap.
  • Latency reported per single transfer: total_us / (iters × batch), matching
    nixl's avg_latency = total_duration / (per_thread_iter × batch_size).
  • Bandwidth = msg × batch × iters / 1e9 / (total_us / 1e6) (GB = 10⁹), same
    units as nixl.
  • Completion is always an inline busy spin-poll in the benchmark thread
    (mirroring nixl's getXferStatus scan); no cv-blocking wait that would add
    ~5–10 µs wakeup latency and make numbers non-comparable.
  • Strict stop-and-wait (one request outstanding at a time, == nixl
    --pipeline_depth 1).
  • Warmup iterations excluded from timing.
  • Memory is registered once in setup (buffer sized to the largest request in
    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_re RoCE, 2 nodes.

Benchmark Avg BW (GB/s) Avg Lat (µs)
C++ bench_engine 27.88 37.61
Python benchmark.py 25.61 (max 26.34) 40.95

The 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.

Note: at batch = 1 both tools are latency-bound and far below line rate; batching
or pipelining fills the pipe and recovers full bandwidth. This point is chosen to
highlight the measurement-methodology difference, not peak throughput.

Reproduce

Build

cd /path/to/mori
cmake -B build -DBUILD_IO=ON -DBUILD_TESTS=ON -DBUILD_EXAMPLES=OFF
cmake --build build --target bench_engine -j
# binary: build/tests/cpp/bench_engine
# runtime libs: build/src/io/libmori_io.so, build/src/application/libmori_application.so
export LD_LIBRARY_PATH=$PWD/build/src/io:$PWD/build/src/application:$LD_LIBRARY_PATH

C++ bench_engine — TCP rendezvous, start rank 1 (target) FIRST

# TARGET (rank 1) — start FIRST:
MORI_DISABLE_AUTO_XGMI=1 build/tests/cpp/bench_engine \
    --rank 1 --master-ip <RANK0_IP> --self-ip <RANK1_IP> --port 18527 \
    --mem-type gpu --op-type write --enable-sess \
    --num-qp-per-transfer 1 --num-worker-threads 1 \
    --transfer-batch-size 1 --all --sweep-start-size 1048576 --sweep-max-size 1048576 \
    --iters 500 --warmup-iters 50

# INITIATOR (rank 0) — start SECOND (prints the results table):
MORI_DISABLE_AUTO_XGMI=1 build/tests/cpp/bench_engine \
    --rank 0 --master-ip <RANK0_IP> --self-ip <RANK0_IP> --port 18527 \
    --mem-type gpu --op-type write --enable-sess \
    --num-qp-per-transfer 1 --num-worker-threads 1 \
    --transfer-batch-size 1 --all --sweep-start-size 1048576 --sweep-max-size 1048576 \
    --iters 500 --warmup-iters 50

Python benchmark.py — torchrun/gloo, start rank 0 (master) FIRST

# MASTER / INITIATOR (rank 0) — start FIRST (prints the results table):
MORI_DISABLE_AUTO_XGMI=1 bash tools/run_internode_io_benchmark.sh \
    --rank 0 --master-addr <RANK0_IP> --master-port 1234 --ifname <CTRL_NIC> \
    -- --mem-type gpu --op-type write --num-initiator-dev 1 --num-target-dev 1 \
       --num-qp-per-transfer 1 --num-worker-threads 1 --enable-sess \
       --transfer-batch-size 1 --all --sweep-start-size 1048576 --sweep-max-size 1048576 \
       --iters 500

# rank 1 — start SECOND (same args, --rank 1):
MORI_DISABLE_AUTO_XGMI=1 bash tools/run_internode_io_benchmark.sh \
    --rank 1 --master-addr <RANK0_IP> --master-port 1234 --ifname <CTRL_NIC> \
    -- --mem-type gpu --op-type write --num-initiator-dev 1 --num-target-dev 1 \
       --num-qp-per-transfer 1 --num-worker-threads 1 --enable-sess \
       --transfer-batch-size 1 --all --sweep-start-size 1048576 --sweep-max-size 1048576 \
       --iters 500

Flag differences between the two tools (they cannot take an identical command
string): C++ uses its own TCP rendezvous (--master-ip/--self-ip/--port,
rank 1 first) while Python uses torchrun/gloo (--master-addr/--ifname,
rank 0 first); the C++ tool is single-device so it has no
--num-initiator-dev/--num-target-dev. All shared benchmark values above are
identical.

Test plan

  • C++ bench_engine builds (-DBUILD_EXAMPLES=OFF to skip the MPI-only
    examples target) and links libmori_io + libmori_application.
  • 2-node VRAM WRITE run, batch 1 and batched (64), read back sane BW/latency.
  • C++ vs Python agree to <1% at large batched sizes; the batch-1 gap is
    explained by the timer/busy-wait methodology difference.
  • Reviewer sanity-check on a second fabric (CX7/mlx5).

25337 and others added 7 commits July 23, 2026 03:44
…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>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 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));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge 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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge 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 👍 / 👎.

Comment on lines +78 to +94
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;
}

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.

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.

Comment on lines +423 to +424
TransferStatusPtrVec sp{&base[0]};
TransferUniqueIdVec ids{id};

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.

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.

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.

3 participants