wegen/FrOSCon26/research/core2core.cpp
Ichthyostega f46f117eda Graph: fabricate overview of timing scales
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
2026-08-04 19:20:45 +02:00

244 lines
7.7 KiB
C++
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/*
* 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;
}