Content generated by Knowledge-Technology - collect information with the help of Claude - validate my own measurements with sources from the net - find a suitable form for the presentation - instructed Claude to build a SVG with a logarithmic time scale - instructed Claude to summarise the findings as Asciidoc table
244 lines
7.7 KiB
C++
244 lines
7.7 KiB
C++
/*
|
||
* 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;
|
||
}
|