245 lines
7.7 KiB
C++
245 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;
|
|||
|
|
}
|