/* * core2core.cpp — measure core-to-core latency by cache-line ping-pong. * * Code generated 8/2026 by Claude -- prompted by Ichthyostega * Context: FrOSCon-26 talk on IO_URING * * Two threads are pinned to two specific logical CPUs and share a single * cache line holding two atomic counters. Thread A stores i, then spins * until thread B has answered with i; thread B does the mirror image. * Each round trip therefore forces the line to migrate twice: A -> B and * back. The one-way latency reported is half the measured round trip. * * What this actually measures is the cost of a *contended* cache line — * the coherence traffic, not the atomic instruction. An uncontended * atomic on a line already held in M state costs a couple of nanoseconds; * the numbers here are one to two orders of magnitude larger, and that * difference is the whole point. * * build: g++ -O2 -std=c++17 -pthread -o core2core core2core.cpp * run: ./core2core # full matrix over all allowed CPUs * ./core2core 3 9 # just the pair (3,9) * * Linux only (uses pthread_setaffinity_np / sched_getaffinity). * * Reading the results * ------------------- * * SMT siblings of one physical core: lowest of all, often 5–20 ns. * They share L1/L2, so almost nothing migrates. Cross-check which * CPUs are siblings with lscpu -e or lscpu -p=CPU,CORE,SOCKET. * * Cores on the same die / CCX / cluster: typically 25–50 ns. * * Across an AMD CCX or CCD boundary, or across sockets: 70–150 ns * and upwards. On a multi-socket machine the cross-socket figure * can exceed local DRAM latency. * * Getting numbers you can trust * ----------------------------- * * Set the CPU governor to performance; on an idle laptop the cores * will otherwise be clocked down and the figures inflate. * sudo cpupower frequency-set -g performance * * Run on an otherwise idle machine. The tool reports the minimum * over several repetitions, which filters most interference, but it * cannot filter a busy system. * * Do not read more than two significant digits into the result. */ #include #include #include #include #include #include #include #include #include #include #include #include namespace { constexpr std::size_t CACHELINE = 64; // deliberately hard-coded; see note below /** the two counters live in one cache line, so a store by either side * invalidates the other side's copy. `alignas` keeps neighbouring data * out of that line, so we measure our own ping-pong and nothing else. */ struct alignas(CACHELINE) PingPong { std::atomic a{0}; std::atomic b{0}; char pad[CACHELINE - 2 * sizeof(std::atomic)]{}; }; static_assert (sizeof(PingPong) == CACHELINE, "PingPong must occupy exactly one line"); bool pin_to (int cpu) { cpu_set_t set; CPU_ZERO (&set); CPU_SET (cpu, &set); return 0 == pthread_setaffinity_np (pthread_self(), sizeof(set), &set); } std::vector available_cpus() { std::vector cpus; cpu_set_t set; CPU_ZERO (&set); if (0 == sched_getaffinity (0, sizeof(set), &set)) for (int i = 0; i < CPU_SETSIZE; ++i) if (CPU_ISSET (i, &set)) cpus.push_back (i); return cpus; } /** one ping-pong run between two pinned threads. * @return round-trip time per iteration in nanoseconds, or -1 on failure */ double one_run (PingPong& pp, int cpuA, int cpuB, std::uint64_t warmup, std::uint64_t iters) { using Clock = std::chrono::steady_clock; pp.a.store (0, std::memory_order_relaxed); pp.b.store (0, std::memory_order_relaxed); std::atomic ready {0}; std::atomic go {false}; std::atomic ok {true}; const std::uint64_t total = warmup + iters; std::thread responder{[&] { if (not pin_to (cpuB)) { ok.store (false); } ready.fetch_add (1, std::memory_order_release); while (not go.load (std::memory_order_acquire)) ; for (std::uint64_t i = 1; i <= total; ++i) { while (pp.a.load (std::memory_order_acquire) != i) ; pp.b.store (i, std::memory_order_release); } }}; Clock::time_point t0, t1; std::thread initiator{[&] { if (not pin_to (cpuA)) { ok.store (false); } ready.fetch_add (1, std::memory_order_release); while (not go.load (std::memory_order_acquire)) ; for (std::uint64_t i = 1; i <= total; ++i) { if (i == warmup + 1) // caches and predictors are warm now t0 = Clock::now(); pp.a.store (i, std::memory_order_release); while (pp.b.load (std::memory_order_acquire) != i) ; } t1 = Clock::now(); }}; while (ready.load (std::memory_order_acquire) < 2) ; go.store (true, std::memory_order_release); initiator.join(); responder.join(); if (not ok.load()) return -1.0; auto elapsed = std::chrono::duration_cast (t1 - t0); return double(elapsed.count()) / double(iters); } /** @return best (minimum) one-way latency in ns over @p reps repetitions */ double measure_pair (int cpuA, int cpuB, int reps, std::uint64_t warmup, std::uint64_t iters) { PingPong pp; double best = std::numeric_limits::infinity(); for (int r = 0; r < reps; ++r) { double rtt = one_run (pp, cpuA, cpuB, warmup, iters); if (rtt < 0) return -1.0; if (rtt < best) best = rtt; } return best / 2.0; // round trip covers two line migrations } } // namespace (anon) int main (int argc, char** argv) { constexpr int REPS = 5; constexpr std::uint64_t WARMUP = 2000; constexpr std::uint64_t ITERS = 20000; if (argc == 3) { // single pair int a = std::atoi (argv[1]); int b = std::atoi (argv[2]); if (a == b) { std::fprintf (stderr, "the two CPUs must differ " "(both threads spin, they cannot share one core)\n"); return 1; } double ns = measure_pair (a, b, REPS, WARMUP, ITERS); if (ns < 0) { std::fprintf (stderr, "could not pin to CPU %d / %d\n", a, b); return 1; } std::printf ("CPU %d <-> CPU %d : %.1f ns one-way (%.1f ns round trip)\n", a, b, ns, 2 * ns); return 0; } auto cpus = available_cpus(); if (cpus.size() < 2) { std::fprintf (stderr, "need at least two available CPUs, found %zu.\n" "(inside a restricted container, check the cpuset)\n", cpus.size()); return 1; } std::printf ("core-to-core latency, one-way, nanoseconds\n"); std::printf ("minimum of %d runs x %llu iterations; %zu CPUs available\n\n", REPS, (unsigned long long)ITERS, cpus.size()); // header std::printf (" "); for (std::size_t j = 0; j + 1 < cpus.size(); ++j) std::printf ("%6d", cpus[j]); std::printf ("\n"); for (std::size_t i = 1; i < cpus.size(); ++i) { std::printf ("%5d ", cpus[i]); for (std::size_t j = 0; j < i; ++j) { double ns = measure_pair (cpus[i], cpus[j], REPS, WARMUP, ITERS); if (ns < 0) std::printf (" -"); else std::printf ("%6.0f", ns); std::fflush (stdout); } std::printf ("\n"); } std::printf ("\nlowest values are usually SMT siblings; check with lscpu -e\n"); return 0; }