wegen/FrOSCon26/research/core2core.cpp

245 lines
7.7 KiB
C++
Raw Normal View History

/*
* 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 <atomic>
#include <chrono>
#include <cstdint>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <limits>
#include <string>
#include <thread>
#include <vector>
#include <pthread.h>
#include <sched.h>
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<std::uint64_t> a{0};
std::atomic<std::uint64_t> b{0};
char pad[CACHELINE - 2 * sizeof(std::atomic<std::uint64_t>)]{};
};
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<int> available_cpus()
{
std::vector<int> 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<int> ready {0};
std::atomic<bool> go {false};
std::atomic<bool> 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<std::chrono::nanoseconds> (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<double>::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;
}