Skip to content

Commit 7324a92

Browse files
committed
feat: persist the coherence probe across processes
The first pool in a process runs a one-time inter-core coherence probe to cluster CPUs for the scheduler, cached only for that process. A fresh process cannot reuse it, so short-lived and single-fit callers repay the full calibration every time. Add exportCoherenceProbe and importCoherenceProbe so a host can serialize the probe and seed the process cache from it, letting a later pool with a matching cpuset skip the calibration. citor performs no file I/O; the host owns where the bytes live.
1 parent 77bd994 commit 7324a92

6 files changed

Lines changed: 2471 additions & 1654 deletions

File tree

include/citor/coherence_cache.h

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
#pragma once
2+
3+
#include <cstddef>
4+
#include <span>
5+
#include <vector>
6+
7+
#include "citor/detail/coherence_probe.h"
8+
9+
namespace citor {
10+
11+
class ThreadPool;
12+
13+
/// Serialise a pool's one-time coherence probe to a portable blob. The blob
14+
/// embeds the probe's worker cpuset, which is the key `importCoherenceProbe`
15+
/// seeds under, so a short-lived process can persist it and replay it on the
16+
/// next run to let a matching pool skip the live probe. Returns an empty
17+
/// vector when the pool has no valid probe (single-worker and arena pools
18+
/// never run it).
19+
std::vector<std::byte> exportCoherenceProbe(const ThreadPool &pool);
20+
21+
/// Seed the process-wide probe cache from a blob produced by
22+
/// @ref citor::exportCoherenceProbe. The next `ThreadPool` whose worker cpuset
23+
/// matches the blob's embedded key returns the seeded probe instead of running
24+
/// the live calibration; a cpuset that does not match is a harmless miss that
25+
/// re-probes. Returns false, with no effect and without throwing, on a magic
26+
/// or version mismatch, truncation, or a structural inconsistency.
27+
inline bool importCoherenceProbe(std::span<const std::byte> bytes) {
28+
detail::CoherenceProbe probe;
29+
if (!detail::deserializeCoherenceProbe(bytes, probe)) {
30+
return false;
31+
}
32+
detail::seedCoherenceProbeCache(probe.matrix.cpus, probe);
33+
return true;
34+
}
35+
36+
} // namespace citor

include/citor/detail/coherence_probe.h

Lines changed: 292 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,17 @@
11
#pragma once
22

33
#include <algorithm>
4+
#include <array>
45
#include <atomic>
56
#include <chrono>
67
#include <cmath>
78
#include <cstddef>
89
#include <cstdint>
10+
#include <cstring>
911
#include <map>
1012
#include <mutex>
1113
#include <numeric>
14+
#include <span>
1215
#include <thread>
1316
#include <utility>
1417
#include <vector>
@@ -626,6 +629,34 @@ runCoherenceProbe(const std::vector<std::uint32_t> &cpus,
626629
return out;
627630
}
628631

632+
/// Mutex plus map backing the process-wide coherence-probe cache, keyed on
633+
/// the sorted-unique `cpus` vector. A single owner so `cachedCoherenceProbe`
634+
/// (lookup then insert) and `seedCoherenceProbeCache` (external insert) share
635+
/// one map instead of two function-local statics.
636+
struct CoherenceProbeCache {
637+
/// Guards every read and write of `entries`.
638+
std::mutex mutex;
639+
/// Cached probe per normalised cpuset key.
640+
std::map<std::vector<std::uint32_t>, CoherenceProbe> entries;
641+
};
642+
643+
/// Accessor for the single process-wide `CoherenceProbeCache`.
644+
inline CoherenceProbeCache &coherenceProbeCache() noexcept {
645+
static CoherenceProbeCache cache;
646+
return cache;
647+
}
648+
649+
/// Normalise a CPU list into the cache key: sorted and deduplicated so a
650+
/// pool's `probeCpus` and a seeded probe's embedded `cpus` map to the same
651+
/// entry regardless of input order.
652+
inline std::vector<std::uint32_t>
653+
coherenceProbeCacheKey(const std::vector<std::uint32_t> &cpus) {
654+
std::vector<std::uint32_t> key = cpus;
655+
std::sort(key.begin(), key.end());
656+
key.erase(std::unique(key.begin(), key.end()), key.end());
657+
return key;
658+
}
659+
629660
/// Process-wide cache for `runCoherenceProbe`, keyed on the
630661
/// sorted-unique `cpus` vector. The latency matrix depends only on the
631662
/// host hardware, so repeated probes for the same set are duplicate
@@ -635,17 +666,13 @@ inline CoherenceProbe
635666
cachedCoherenceProbe(const std::vector<std::uint32_t> &cpus,
636667
const std::vector<std::vector<std::uint32_t>> &sysfsPrior,
637668
std::uint32_t roundTrips = 1024U) {
638-
std::vector<std::uint32_t> key = cpus;
639-
std::sort(key.begin(), key.end());
640-
key.erase(std::unique(key.begin(), key.end()), key.end());
641-
642-
static std::mutex cacheMutex;
643-
static std::map<std::vector<std::uint32_t>, CoherenceProbe> cache;
669+
std::vector<std::uint32_t> key = coherenceProbeCacheKey(cpus);
670+
CoherenceProbeCache &cache = coherenceProbeCache();
644671

645672
{
646-
const std::scoped_lock guard(cacheMutex);
647-
const auto hit = cache.find(key);
648-
if (hit != cache.end()) {
673+
const std::scoped_lock guard(cache.mutex);
674+
const auto hit = cache.entries.find(key);
675+
if (hit != cache.entries.end()) {
649676
return hit->second;
650677
}
651678
}
@@ -656,8 +683,262 @@ cachedCoherenceProbe(const std::vector<std::uint32_t> &cpus,
656683
// first inserter's copy so identical pools see identical numbers.
657684
CoherenceProbe fresh = runCoherenceProbe(cpus, sysfsPrior, roundTrips);
658685

659-
const std::scoped_lock guard(cacheMutex);
660-
return cache.emplace(std::move(key), std::move(fresh)).first->second;
686+
const std::scoped_lock guard(cache.mutex);
687+
return cache.entries.emplace(std::move(key), std::move(fresh)).first->second;
688+
}
689+
690+
/// Insert `probe` into the process-wide cache under the normalised `cpus`
691+
/// key so the next pool whose worker cpuset matches that key skips the live
692+
/// probe. Replaces any existing entry for the key: a seed is an explicit
693+
/// caller decision to use this probe for that cpuset, and import is meant to
694+
/// run before the first pool is built, where the cache is empty.
695+
inline void seedCoherenceProbeCache(const std::vector<std::uint32_t> &cpus,
696+
const CoherenceProbe &probe) {
697+
std::vector<std::uint32_t> key = coherenceProbeCacheKey(cpus);
698+
CoherenceProbeCache &cache = coherenceProbeCache();
699+
const std::scoped_lock guard(cache.mutex);
700+
cache.entries.insert_or_assign(std::move(key), probe);
701+
}
702+
703+
/// Magic prefix on the self-describing probe blob. Spells 'COHP'. A blob
704+
/// whose first word is not this magic is rejected by `importCoherenceProbe`.
705+
inline constexpr std::uint32_t kCoherenceProbeMagic = 0x434F4850U;
706+
/// Blob layout version. Bumped when the field order or encoding changes so a
707+
/// blob from an incompatible citor is rejected rather than misread.
708+
inline constexpr std::uint32_t kCoherenceProbeFormatVersion = 1U;
709+
710+
/// Append the raw bytes of a trivially-copyable scalar to `bytes` in native
711+
/// byte order. The cache is same-machine, so native endianness is fine and
712+
/// the magic plus version guard against cross-build corruption.
713+
template <typename T>
714+
inline void coherenceProbeWritePod(std::vector<std::byte> &bytes,
715+
const T &value) {
716+
std::array<std::byte, sizeof(T)> buf{};
717+
std::memcpy(buf.data(), &value, sizeof(T));
718+
bytes.insert(bytes.end(), buf.begin(), buf.end());
719+
}
720+
721+
/// Write a `std::uint32_t` vector as a `std::uint64_t` length prefix followed
722+
/// by the elements.
723+
inline void coherenceProbeWriteU32Vector(std::vector<std::byte> &bytes,
724+
const std::vector<std::uint32_t> &v) {
725+
coherenceProbeWritePod(bytes, static_cast<std::uint64_t>(v.size()));
726+
for (const std::uint32_t x : v) {
727+
coherenceProbeWritePod(bytes, x);
728+
}
729+
}
730+
731+
/// Write a `double` vector as a `std::uint64_t` length prefix followed by the
732+
/// elements.
733+
inline void coherenceProbeWriteF64Vector(std::vector<std::byte> &bytes,
734+
const std::vector<double> &v) {
735+
coherenceProbeWritePod(bytes, static_cast<std::uint64_t>(v.size()));
736+
for (const double x : v) {
737+
coherenceProbeWritePod(bytes, x);
738+
}
739+
}
740+
741+
/// Write a row-major matrix as a row count followed by each row through
742+
/// `coherenceProbeWriteF64Vector`.
743+
inline void
744+
coherenceProbeWriteF64Matrix(std::vector<std::byte> &bytes,
745+
const std::vector<std::vector<double>> &m) {
746+
coherenceProbeWritePod(bytes, static_cast<std::uint64_t>(m.size()));
747+
for (const auto &row : m) {
748+
coherenceProbeWriteF64Vector(bytes, row);
749+
}
750+
}
751+
752+
/// Serialise a `CoherenceProbe` to a self-describing binary blob: magic,
753+
/// format version, then every field with length-prefixed vectors and
754+
/// native-endian doubles.
755+
inline std::vector<std::byte>
756+
serializeCoherenceProbe(const CoherenceProbe &probe) {
757+
std::vector<std::byte> bytes;
758+
coherenceProbeWritePod(bytes, kCoherenceProbeMagic);
759+
coherenceProbeWritePod(bytes, kCoherenceProbeFormatVersion);
760+
coherenceProbeWritePod(bytes,
761+
static_cast<std::uint8_t>(probe.valid ? 1U : 0U));
762+
coherenceProbeWritePod(bytes, probe.maxCrossOverIntraRatio);
763+
coherenceProbeWritePod(
764+
bytes, static_cast<std::uint8_t>(probe.matrix.valid ? 1U : 0U));
765+
coherenceProbeWriteU32Vector(bytes, probe.matrix.cpus);
766+
coherenceProbeWriteF64Matrix(bytes, probe.matrix.matrix);
767+
coherenceProbeWriteU32Vector(bytes, probe.clusters.clusterIdOfCpuIndex);
768+
coherenceProbeWritePod(bytes, probe.clusters.numClusters);
769+
coherenceProbeWriteF64Matrix(bytes, probe.clusters.clusterDistanceNs);
770+
return bytes;
771+
}
772+
773+
/// Bounds-checked cursor over a byte blob. Every read first confirms the
774+
/// blob holds enough bytes; a short read latches `ok` to false and leaves
775+
/// the output untouched so a malformed blob can never read out of range.
776+
struct CoherenceProbeReader {
777+
/// Next byte to read.
778+
const std::byte *cur = nullptr;
779+
/// One past the last readable byte.
780+
const std::byte *end = nullptr;
781+
/// False once any read ran short; latches and is never cleared.
782+
bool ok = true;
783+
784+
/// Bytes left between `cur` and `end`.
785+
[[nodiscard]] std::size_t remainingBytes() const noexcept {
786+
return static_cast<std::size_t>(end - cur);
787+
}
788+
789+
/// Copy one `T` out of the blob and advance. Returns false and latches
790+
/// `ok` when fewer than `sizeof(T)` bytes remain; `out` is left untouched.
791+
template <typename T>
792+
bool readPod(T &out) noexcept {
793+
if (!ok || remainingBytes() < sizeof(T)) {
794+
ok = false;
795+
return false;
796+
}
797+
std::memcpy(&out, cur, sizeof(T));
798+
cur += sizeof(T);
799+
return true;
800+
}
801+
};
802+
803+
/// Read a length-prefixed `std::uint32_t` vector. Rejects a length that
804+
/// exceeds the remaining bytes before resizing, so a hostile prefix cannot
805+
/// force an oversized allocation.
806+
inline bool coherenceProbeReadU32Vector(CoherenceProbeReader &r,
807+
std::vector<std::uint32_t> &out) {
808+
std::uint64_t count = 0;
809+
if (!r.readPod(count)) {
810+
return false;
811+
}
812+
// Divide before multiply so a hostile count cannot overflow the size
813+
// check; each element occupies a fixed four bytes.
814+
if (count > r.remainingBytes() / sizeof(std::uint32_t)) {
815+
r.ok = false;
816+
return false;
817+
}
818+
out.resize(static_cast<std::size_t>(count));
819+
for (std::uint32_t &x : out) {
820+
if (!r.readPod(x)) {
821+
return false;
822+
}
823+
}
824+
return true;
825+
}
826+
827+
/// Read a length-prefixed `double` vector, with the same oversized-length
828+
/// guard as `coherenceProbeReadU32Vector`.
829+
inline bool coherenceProbeReadF64Vector(CoherenceProbeReader &r,
830+
std::vector<double> &out) {
831+
std::uint64_t count = 0;
832+
if (!r.readPod(count)) {
833+
return false;
834+
}
835+
if (count > r.remainingBytes() / sizeof(double)) {
836+
r.ok = false;
837+
return false;
838+
}
839+
out.resize(static_cast<std::size_t>(count));
840+
for (double &x : out) {
841+
if (!r.readPod(x)) {
842+
return false;
843+
}
844+
}
845+
return true;
846+
}
847+
848+
/// Read a row count followed by that many `double` rows through
849+
/// `coherenceProbeReadF64Vector`. Bounds the row count by the remaining
850+
/// bytes since every row carries at least its own length prefix.
851+
inline bool coherenceProbeReadF64Matrix(CoherenceProbeReader &r,
852+
std::vector<std::vector<double>> &out) {
853+
std::uint64_t rows = 0;
854+
if (!r.readPod(rows)) {
855+
return false;
856+
}
857+
// Each row carries at least its own eight-byte length prefix, so the row
858+
// count cannot exceed the remaining bytes divided by that minimum.
859+
if (rows > r.remainingBytes() / sizeof(std::uint64_t)) {
860+
r.ok = false;
861+
return false;
862+
}
863+
out.resize(static_cast<std::size_t>(rows));
864+
for (auto &row : out) {
865+
if (!coherenceProbeReadF64Vector(r, row)) {
866+
return false;
867+
}
868+
}
869+
return true;
870+
}
871+
872+
/// Parse a blob produced by `serializeCoherenceProbe` into `out`. Returns
873+
/// false on magic or version mismatch, truncation, or a structural
874+
/// inconsistency (latency matrix not NxN over the embedded cpus, cluster
875+
/// fields not sized to `numClusters`). `out` is written only on success.
876+
inline bool deserializeCoherenceProbe(std::span<const std::byte> bytes,
877+
CoherenceProbe &out) {
878+
CoherenceProbeReader r{bytes.data(), bytes.data() + bytes.size(), true};
879+
880+
std::uint32_t magic = 0;
881+
std::uint32_t version = 0;
882+
if (!r.readPod(magic) || !r.readPod(version)) {
883+
return false;
884+
}
885+
if (magic != kCoherenceProbeMagic ||
886+
version != kCoherenceProbeFormatVersion) {
887+
return false;
888+
}
889+
890+
CoherenceProbe probe;
891+
std::uint8_t probeValid = 0;
892+
if (!r.readPod(probeValid)) {
893+
return false;
894+
}
895+
probe.valid = probeValid != 0U;
896+
if (!r.readPod(probe.maxCrossOverIntraRatio)) {
897+
return false;
898+
}
899+
std::uint8_t matrixValid = 0;
900+
if (!r.readPod(matrixValid)) {
901+
return false;
902+
}
903+
probe.matrix.valid = matrixValid != 0U;
904+
if (!coherenceProbeReadU32Vector(r, probe.matrix.cpus) ||
905+
!coherenceProbeReadF64Matrix(r, probe.matrix.matrix) ||
906+
!coherenceProbeReadU32Vector(r, probe.clusters.clusterIdOfCpuIndex) ||
907+
!r.readPod(probe.clusters.numClusters) ||
908+
!coherenceProbeReadF64Matrix(r, probe.clusters.clusterDistanceNs)) {
909+
return false;
910+
}
911+
if (!r.ok) {
912+
return false;
913+
}
914+
915+
// Structural consistency: the matrix must be NxN over the embedded cpu
916+
// list, the per-cpu cluster ids must cover every cpu, and the cluster
917+
// distance matrix must be numClusters x numClusters.
918+
const std::size_t n = probe.matrix.cpus.size();
919+
if (probe.matrix.matrix.size() != n) {
920+
return false;
921+
}
922+
for (const auto &row : probe.matrix.matrix) {
923+
if (row.size() != n) {
924+
return false;
925+
}
926+
}
927+
if (probe.clusters.clusterIdOfCpuIndex.size() != n) {
928+
return false;
929+
}
930+
const std::size_t k = probe.clusters.numClusters;
931+
if (probe.clusters.clusterDistanceNs.size() != k) {
932+
return false;
933+
}
934+
for (const auto &row : probe.clusters.clusterDistanceNs) {
935+
if (row.size() != k) {
936+
return false;
937+
}
938+
}
939+
940+
out = std::move(probe);
941+
return true;
661942
}
662943

663944
} // namespace citor::detail

0 commit comments

Comments
 (0)