Skip to main content

compare/
compare.rs

1// Ring transport against plain MPI point-to-point, over a payload sweep.
2//
3//   mpirun -n <2+> compare [messages=20000] [depth=32] [noise_kib=0] [repeats=5]
4//
5// Ranks 0 and 1 are the measured pair; ranks 2+ generate background ping-pong
6// when `noise_kib > 0`, which requires at least four ranks. Each transport is
7// measured two ways:
8//
9//   stream   rank 0 pushes a fixed message count as hard as it can, rank 1
10//            drains. The count is capped by total bytes, so a large payload does
11//            not have to move 20k of them.
12//
13//            Two rates come out of this and a lossy transport separates them.
14//            `inject` is what the sender achieved, measured until its last send
15//            returned; `goodput` is what the receiver actually took delivery of.
16//            For safe mode and p2p the two agree and the sender pays in stall
17//            time (`wait_s`). For raw mode the sender never stalls and pays in
18//            messages that never arrive. Reporting only one of the two would
19//            flatter whichever mode is being looked at.
20//   rtt      one message out, one back, repeated. Half the round trip is the
21//            one-way latency of an unloaded lane.
22//
23// Transports:
24//
25//   ring-safe   Ring::safe, acknowledged, no loss
26//   ring-raw    Ring::raw, no acknowledgement, overwrites unread slots
27//   p2p-send    MPI_Send / MPI_Recv
28//   p2p-bsend   MPI_Bsend / MPI_Recv, the closest p2p analogue to a ring send
29//               in that the sender does not wait for the receiver
30//
31// One TSV row per (transport, payload, measurement, repeat) on stdout, from rank 0.
32// Repeats are emitted rather than averaged: a microbenchmark on a shared machine
33// has a long right tail, and the aggregation belongs to whatever reads the file.
34// The rtt rows carry `us_per_msg`, the mean one-way latency, and
35// `max_us_per_msg`, the worst single one-way hop of the run.
36
37use std::sync::Arc;
38use std::sync::atomic::{AtomicBool, Ordering};
39use std::time::Instant;
40
41use mpi::Threading;
42use mpi::collective::CommunicatorCollectives;
43use mpi::point_to_point::{Destination, Source};
44use mpi::topology::{Communicator, Group, Rank, SimpleCommunicator};
45
46use mpi_rma::Ring;
47
48const TAG_DATA: i32 = 1;
49const TAG_STOP: i32 = 2;
50const TAG_NOISE: i32 = 11;
51const TAG_QUIT: i32 = 12;
52
53const SIZES: [usize; 5] = [64, 512, 4096, 32768, 262_144];
54/// Ceiling on bytes moved by one stream row.
55const STREAM_BYTES: u64 = 64 << 20;
56/// Attached buffer for `MPI_Bsend`, comfortably past `STREAM_BYTES`.
57const BSEND_BUF: usize = 512 << 20;
58
59/// Carries a communicator reference across a thread spawn. rsmpi wraps a raw
60/// handle, so its communicators are neither `Send` nor `Sync`; under
61/// `MPI_THREAD_MULTIPLE` the concurrent use here is legal.
62struct Shared<'a>(&'a SimpleCommunicator);
63unsafe impl Send for Shared<'_> {}
64
65const HEADER: &str = "transport\tmeasure\tranks\tdepth\tnoise_kib\trep\tpayload\
66\tsent\tdelivered\tinject_s\ttotal_s\twait_s\tinject_per_s\tgoodput_per_s\
67\tgoodput_MiB_per_s\tus_per_msg\tlost\tmax_us_per_msg";
68
69/// One measurement, as it lands in the TSV.
70struct Row {
71    transport: &'static str,
72    measure: &'static str,
73    payload: usize,
74    /// What the sender put on the wire.
75    sent: u64,
76    /// What the receiver took delivery of. Below `sent` only in raw mode.
77    delivered: u64,
78    /// Seconds until the sender's last send returned.
79    inject: f64,
80    /// Seconds until everything had drained at the receiver.
81    total: f64,
82    /// Seconds the sender spent blocked on acknowledgements.
83    wait: f64,
84    /// Worst single one-way hop, microseconds (rtt rows only).
85    max_us: f64,
86}
87
88impl Row {
89    fn print(&self, ranks: i32, depth: usize, noise_kib: usize, rep: u32) {
90        let inject = self.sent as f64 / self.inject;
91        let goodput = self.delivered as f64 / self.total;
92        println!(
93            "{}\t{}\t{ranks}\t{depth}\t{noise_kib}\t{rep}\t{}\t{}\t{}\
94             \t{:.6}\t{:.6}\t{:.6}\t{inject:.1}\t{goodput:.1}\t{:.4}\t{:.3}\t{}\t{:.3}",
95            self.transport,
96            self.measure,
97            self.payload,
98            self.sent,
99            self.delivered,
100            self.inject,
101            self.total,
102            self.wait,
103            goodput * self.payload as f64 / (1 << 20) as f64,
104            self.total * 1e6 / self.delivered.max(1) as f64,
105            self.sent - self.delivered,
106            self.max_us,
107        );
108    }
109}
110
111/// Deterministic per-repeat payload: a xorshift64 Fisher-Yates walk over a
112/// fixed fill. Consecutive repeats never move the same byte pattern, so L3
113/// and prefetcher effects do not alias across repeats, and the walk is a
114/// function of `(rep, payload)` alone, so the measurement is reproducible.
115fn shuffled(rep: u32, len: usize) -> Vec<u8> {
116    let mut buf = vec![0xC3u8; len];
117    let mut x = (rep as u64 + 1) | 1;
118    let mut rnd = || {
119        x ^= x << 13;
120        x ^= x >> 7;
121        x ^= x << 17;
122        x
123    };
124    for i in (1..buf.len()).rev() {
125        buf.swap(i, (rnd() % (i as u64 + 1)) as usize);
126    }
127    buf
128}
129
130// ── Ring ────────────────────────────────────────────────────────────────────
131
132/// Rank 0 pushes `n` messages flat out; rank 1 drains and reports arrivals.
133///
134/// Returns `(sent, delivered, inject, total, wait)`. `inject` stops when the
135/// last send returns and `total` when the receiver confirms it has drained, so
136/// a mode that lets its sender run ahead is visible as a gap between them.
137fn ring_stream(
138    comm: &SimpleCommunicator,
139    ring: &Ring,
140    rank: Rank,
141    rep: u32,
142    payload: usize,
143    n: u64,
144) -> (u64, u64, f64, f64, f64) {
145    comm.barrier();
146    let stalled = ring.wait_ns();
147    let t0 = Instant::now();
148    if rank == 0 {
149        let buf = shuffled(rep, payload);
150        let mut sent = 0u64;
151        while sent < n {
152            ring.send(1, &buf).unwrap();
153            sent += 1;
154        }
155        let inject = t0.elapsed().as_secs_f64();
156        let wait = (ring.wait_ns() - stalled) as f64 * 1e-9;
157        // Tell the drain side to stop, then collect what it actually saw.
158        comm.process_at_rank(1).send_with_tag(&[0u8], TAG_STOP);
159        let (reply, _) = comm
160            .process_at_rank(1)
161            .receive_vec_with_tag::<u64>(TAG_STOP);
162        (sent, reply[0], inject, t0.elapsed().as_secs_f64(), wait)
163    } else {
164        let mut got = 0u64;
165        let mut stopping = false;
166        loop {
167            let batch = ring.poll().unwrap();
168            let empty = batch.is_empty();
169            let mut furthest = 0u64;
170            for m in &batch {
171                got += 1;
172                furthest = furthest.max(m.sequence);
173            }
174            if furthest > 0 {
175                ring.ack(0, furthest).unwrap();
176            }
177            if stopping && empty {
178                break;
179            }
180            if !stopping
181                && comm
182                    .any_process()
183                    .immediate_probe_with_tag(TAG_STOP)
184                    .is_some()
185            {
186                comm.any_process().receive_vec_with_tag::<u8>(TAG_STOP);
187                stopping = true;
188            }
189            if empty {
190                std::thread::yield_now();
191            }
192        }
193        comm.process_at_rank(0).send_with_tag(&[got][..], TAG_STOP);
194        let dt = t0.elapsed().as_secs_f64();
195        (0, got, dt, dt, 0.0)
196    }
197}
198
199/// One message each way, `iters` times, on the ring.
200///
201/// Returns `(total, max_us)`: the run wall time and the worst single
202/// round trip, halved to a one-way latency so it shares the `us_per_msg`
203/// unit of the mean.
204fn ring_rtt(ring: &Ring, rank: Rank, rep: u32, payload: usize, iters: u64) -> (f64, f64) {
205    let buf = shuffled(rep, payload);
206    let other: Rank = if rank == 0 { 1 } else { 0 };
207    let mut seen = 0u64;
208    let await_one = |ring: &Ring, seen: &mut u64| {
209        loop {
210            let batch = ring.poll().unwrap();
211            if let Some(last) = batch.last() {
212                *seen = last.sequence;
213                ring.ack(other, *seen).unwrap();
214                return;
215            }
216            std::hint::spin_loop();
217        }
218    };
219    let mut max_us = 0.0f64;
220    let t0 = Instant::now();
221    for _ in 0..iters {
222        let start = Instant::now();
223        if rank == 0 {
224            ring.send(1, &buf).unwrap();
225            await_one(ring, &mut seen);
226        } else {
227            await_one(ring, &mut seen);
228            ring.send(0, &buf).unwrap();
229        }
230        max_us = max_us.max(start.elapsed().as_secs_f64() * 5e5);
231    }
232    (t0.elapsed().as_secs_f64(), max_us)
233}
234
235// ── Point to point ──────────────────────────────────────────────────────────
236
237fn p2p_stream(
238    comm: &SimpleCommunicator,
239    rank: Rank,
240    rep: u32,
241    payload: usize,
242    n: u64,
243    buffered: bool,
244) -> (u64, u64, f64, f64) {
245    comm.barrier();
246    let t0 = Instant::now();
247    if rank == 0 {
248        let buf = shuffled(rep, payload);
249        let peer = comm.process_at_rank(1);
250        let mut sent = 0u64;
251        while sent < n {
252            if buffered {
253                peer.buffered_send_with_tag(&buf[..], TAG_DATA);
254            } else {
255                peer.send_with_tag(&buf[..], TAG_DATA);
256            }
257            sent += 1;
258        }
259        let inject = t0.elapsed().as_secs_f64();
260        peer.send_with_tag(&[0u8], TAG_STOP);
261        // The receiver's count is authoritative and equals the sender's here:
262        // p2p does not drop. Waiting for it also drains the queue before the
263        // next measurement starts.
264        let (reply, _) = comm
265            .process_at_rank(1)
266            .receive_vec_with_tag::<u64>(TAG_STOP);
267        (sent, reply[0], inject, t0.elapsed().as_secs_f64())
268    } else {
269        let mut got = 0u64;
270        let source = comm.process_at_rank(0);
271        loop {
272            let (_, status) = source.receive_vec::<u8>();
273            if status.tag() == TAG_STOP {
274                break;
275            }
276            got += 1;
277        }
278        comm.process_at_rank(0).send_with_tag(&[got][..], TAG_STOP);
279        let dt = t0.elapsed().as_secs_f64();
280        (got, got, dt, dt)
281    }
282}
283
284fn p2p_rtt(
285    comm: &SimpleCommunicator,
286    rank: Rank,
287    rep: u32,
288    payload: usize,
289    iters: u64,
290) -> (f64, f64) {
291    let buf = shuffled(rep, payload);
292    let peer = comm.process_at_rank(if rank == 0 { 1 } else { 0 });
293    let mut max_us = 0.0f64;
294    let t0 = Instant::now();
295    for _ in 0..iters {
296        let start = Instant::now();
297        if rank == 0 {
298            peer.send_with_tag(&buf[..], TAG_DATA);
299            peer.receive_vec_with_tag::<u8>(TAG_DATA);
300        } else {
301            peer.receive_vec_with_tag::<u8>(TAG_DATA);
302            peer.send_with_tag(&buf[..], TAG_DATA);
303        }
304        max_us = max_us.max(start.elapsed().as_secs_f64() * 5e5);
305    }
306    (t0.elapsed().as_secs_f64(), max_us)
307}
308
309/// Background ping-pong among ranks 2+, in pairs, until `stop`.
310fn noise(world: &SimpleCommunicator, kib: usize, stop: &AtomicBool) {
311    let rank = world.rank();
312    let base = rank - 2;
313    let partner = if base % 2 == 0 { rank + 1 } else { rank - 1 };
314    if partner >= world.size() {
315        return;
316    }
317    let buf = vec![0xA5u8; kib * 1024];
318    let peer = world.process_at_rank(partner);
319    loop {
320        if rank < partner {
321            if stop.load(Ordering::Relaxed) {
322                peer.send_with_tag(&[0u8], TAG_QUIT);
323                return;
324            }
325            peer.send_with_tag(&buf[..], TAG_NOISE);
326            peer.receive_vec_with_tag::<u8>(TAG_NOISE);
327        } else {
328            let (_, status) = peer.receive_vec::<u8>();
329            if status.tag() == TAG_QUIT {
330                return;
331            }
332            peer.send_with_tag(&buf[..], TAG_NOISE);
333        }
334    }
335}
336
337fn main() {
338    let mut args = std::env::args().skip(1);
339    let messages: u64 = args.next().and_then(|s| s.parse().ok()).unwrap_or(20_000);
340    let depth: usize = args.next().and_then(|s| s.parse().ok()).unwrap_or(32);
341    let noise_kib: usize = args.next().and_then(|s| s.parse().ok()).unwrap_or(0);
342    let repeats: u32 = args.next().and_then(|s| s.parse().ok()).unwrap_or(5);
343
344    let (mut universe, provided) =
345        mpi::initialize_with_threading(Threading::Multiple).expect("MPI must initialize once");
346    assert_eq!(provided, Threading::Multiple);
347    let world = universe.world();
348    let rank = world.rank();
349    let size = world.size();
350    assert!(size >= 2, "compare needs at least 2 ranks");
351    assert!(noise_kib == 0 || size >= 4, "noise needs at least 4 ranks");
352    // `p2p-bsend` needs somewhere to copy to. Only rank 0 sends buffered, so
353    // only it attaches the buffer; every rank allocating 512 MiB would need
354    // tens of GiB at scale. Sized past the largest amount a stream can leave
355    // in flight, so the comparison measures the transport and not a buffer
356    // running out.
357    if rank == 0 {
358        universe.set_buffer_size(BSEND_BUF);
359    }
360
361    let stop = Arc::new(AtomicBool::new(false));
362    let noisy = noise_kib > 0;
363
364    std::thread::scope(|scope| {
365        if noisy && rank >= 2 {
366            let stop = Arc::clone(&stop);
367            let shared = Shared(&world);
368            scope.spawn(move || {
369                let shared = shared;
370                noise(shared.0, noise_kib, &stop);
371            });
372        }
373        if rank > 1 {
374            // Noise ranks take no part in the measurement. They wait here for
375            // rank 0 to call time, then let their noise thread finish.
376            world
377                .process_at_rank(0)
378                .receive_vec_with_tag::<u8>(TAG_QUIT);
379            stop.store(true, Ordering::Relaxed);
380            return;
381        }
382
383        if rank == 0 {
384            println!("{HEADER}");
385        }
386
387        // The measured pair only. A world barrier here would deadlock against
388        // ranks sitting in the noise loop.
389        let pair: Vec<Rank> = vec![0, 1];
390        let group = world.group().include(&pair);
391        let duo = world
392            .split_by_subgroup(&group)
393            .expect("ranks 0 and 1 form the measured pair");
394
395        for rep in 0..repeats {
396            for payload in SIZES {
397                // Both ring modes share the lane shape, so the only difference
398                // between the two rows is the acknowledgement gate.
399                let lanes = vec![
400                    (0 as Rank, 1 as Rank, depth, payload),
401                    (1, 0, depth, payload),
402                ];
403                // Cap the stream by bytes as well as count: 256 KiB x 20k would be
404                // 5 GiB through the wire for one row.
405                let n = messages.min((STREAM_BYTES / payload as u64).max(64));
406                let iters = if payload >= 32768 { 500 } else { 2000 };
407
408                for (name, safe) in [("ring-safe", true), ("ring-raw", false)] {
409                    let ring = if safe {
410                        Ring::safe(&duo, &lanes).unwrap()
411                    } else {
412                        Ring::raw(&duo, &lanes).unwrap()
413                    };
414                    let (sent, got, inject, total, wait) =
415                        ring_stream(&duo, &ring, rank, rep, payload, n);
416                    if rank == 0 {
417                        Row {
418                            transport: name,
419                            measure: "stream",
420                            payload,
421                            sent,
422                            delivered: got,
423                            inject,
424                            total,
425                            wait,
426                            max_us: 0.0,
427                        }
428                        .print(size, depth, noise_kib, rep);
429                    }
430                    duo.barrier();
431                    let stalled = ring.wait_ns();
432                    let (dt, max_us) = ring_rtt(&ring, rank, rep, payload, iters);
433                    if rank == 0 {
434                        Row {
435                            transport: name,
436                            measure: "rtt",
437                            payload,
438                            sent: iters * 2,
439                            delivered: iters * 2,
440                            inject: dt,
441                            total: dt,
442                            wait: (ring.wait_ns() - stalled) as f64 * 1e-9,
443                            max_us,
444                        }
445                        .print(size, depth, noise_kib, rep);
446                    }
447                    duo.barrier();
448                    ring.close().unwrap();
449                }
450
451                for (name, buffered) in [("p2p-send", false), ("p2p-bsend", true)] {
452                    let (sent, got, inject, total) =
453                        p2p_stream(&duo, rank, rep, payload, n, buffered);
454                    if rank == 0 {
455                        Row {
456                            transport: name,
457                            measure: "stream",
458                            payload,
459                            sent,
460                            delivered: got,
461                            inject,
462                            total,
463                            wait: 0.0,
464                            max_us: 0.0,
465                        }
466                        .print(size, depth, noise_kib, rep);
467                    }
468                    duo.barrier();
469                }
470                let (dt, max_us) = p2p_rtt(&duo, rank, rep, payload, iters);
471                if rank == 0 {
472                    Row {
473                        transport: "p2p-send",
474                        measure: "rtt",
475                        payload,
476                        sent: iters * 2,
477                        delivered: iters * 2,
478                        inject: dt,
479                        total: dt,
480                        wait: 0.0,
481                        max_us,
482                    }
483                    .print(size, depth, noise_kib, rep);
484                }
485                duo.barrier();
486            }
487        }
488
489        if rank == 0 {
490            for r in 2..size {
491                world.process_at_rank(r).send_with_tag(&[0u8], TAG_QUIT);
492            }
493        }
494    });
495
496    // Buffered sends must be drained before MPI tears the buffer down.
497    world.barrier();
498}