Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -858,8 +858,10 @@ if(BUILD_TESTING)
add_test(NAME moderngekko.cpu_primitives
COMMAND moderngekko_cpu_primitives_test)

if(WIN32)
# Header-only, and Windows-only like the rule it covers.
if(WIN32 OR CMAKE_SYSTEM_NAME STREQUAL "Linux")
# Header-only, and built wherever the rule it covers has an
# implementation. On other platforms the test is a no-op main, so there
# is nothing to run.
add_executable(moderngekko_cache_affinity_test
tests/cache_affinity_test.cpp)
target_include_directories(moderngekko_cache_affinity_test PRIVATE
Expand Down
113 changes: 113 additions & 0 deletions tests/cache_affinity_test.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
// hand and the selection rule is checked against it.
#include "cache_affinity.hpp"

#if defined(_WIN32)

#include <cstring>
#include <vector>

Expand Down Expand Up @@ -194,3 +196,114 @@ int main() {

return 0;
}

#elif defined(__linux__)

// The Linux rule reads sysfs, so the interesting topologies are supplied as a
// fixture tree rather than by the machine running the test -- same reason the
// Windows half is handed a synthetic buffer.
#include <filesystem>
#include <fstream>
#include <string>

namespace {
namespace fs = std::filesystem;
using moderngekko::frontend::CacheDomain;
using moderngekko::frontend::LargestSharedCache;
using moderngekko::frontend::ParseCacheSize;
using moderngekko::frontend::ParseCpuList;

void Write(const fs::path &path, const std::string &text) {
fs::create_directories(path.parent_path());
std::ofstream(path) << text << '\n';
}

// One cache record as the kernel lays it out: per-CPU directories, each listing
// every cache that CPU can see.
void AddCache(const fs::path &root, int cpu, int index, int level,
const std::string &size, const std::string &shared) {
const fs::path dir =
root / ("cpu" + std::to_string(cpu)) / "cache" / ("index" + std::to_string(index));
Write(dir / "level", std::to_string(level));
Write(dir / "size", size);
Write(dir / "shared_cpu_list", shared);
}
} // namespace

int main() {
if (ParseCpuList("0-5,12-17") !=
std::vector<int>{0, 1, 2, 3, 4, 5, 12, 13, 14, 15, 16, 17})
return 1;
if (ParseCpuList("3") != std::vector<int>{3})
return 2;
if (ParseCpuList("0-2,7") != std::vector<int>{0, 1, 2, 7})
return 3;
// Trailing newline is what actually comes back from sysfs.
if (ParseCpuList("0-1\n") != std::vector<int>{0, 1})
return 4;
// Malformed input yields nothing rather than a partial list: pinning to a
// wrong domain is worse than not pinning.
if (!ParseCpuList("").empty() || !ParseCpuList("2-1").empty() ||
!ParseCpuList("0,,3").empty() || !ParseCpuList("a-b").empty() ||
!ParseCpuList("0-").empty())
return 5;

if (ParseCacheSize("32768K") != 32768ull * 1024)
return 6;
if (ParseCacheSize("32M") != 32ull * 1024 * 1024)
return 7;
if (ParseCacheSize("512") != 512)
return 8;
if (ParseCacheSize("") != 0 || ParseCacheSize("K") != 0 || ParseCacheSize("12X") != 0)
return 9;

const fs::path root = fs::temp_directory_path() / "mg_cache_affinity_test";
std::error_code ec;
fs::remove_all(root, ec);

// A 5900X-shaped part: two equal CCDs, 32 MB each. Either is a valid answer;
// what matters is that one whole domain wins rather than a mix.
for (int cpu = 0; cpu < 4; cpu++) {
AddCache(root, cpu, 0, 1, "32K", std::to_string(cpu));
AddCache(root, cpu, 3, 3, "32768K", cpu < 2 ? "0-1" : "2-3");
}
{
const CacheDomain domain = LargestSharedCache(root);
if (!domain || domain.size != 32768ull * 1024)
return 10;
if (domain.cpus != std::vector<int>{0, 1} && domain.cpus != std::vector<int>{2, 3})
return 11;
}

// Now make one die's cache larger, as a V-Cache part does. The rule must pick
// that die specifically, not merely some die.
AddCache(root, 2, 3, 3, "98304K", "2-3");
AddCache(root, 3, 3, 3, "98304K", "2-3");
{
const CacheDomain domain = LargestSharedCache(root);
if (!domain || domain.cpus != std::vector<int>{2, 3})
return 12;
if (domain.size != 98304ull * 1024)
return 13;
}

// No level-3 entries at all: nothing to pin to, and that is not an error.
fs::remove_all(root, ec);
AddCache(root, 0, 0, 1, "32K", "0");
if (LargestSharedCache(root))
return 14;

// A missing tree behaves the same way rather than throwing.
fs::remove_all(root, ec);
if (LargestSharedCache(root / "absent"))
return 15;

fs::remove_all(root, ec);
return 0;
}

#else

int main() { return 0; }

#endif
221 changes: 210 additions & 11 deletions tools/cache_affinity.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,26 @@
// demand by the machine running the tests, and the rule is the part that decides
// whether the pin helps or does nothing.

#include <algorithm>
#include <cstddef>
#include <cstdint>
#include <string>
#include <string_view>

namespace moderngekko::frontend
{
// Process-wide affinity can interfere with Dolphin's worker threads, so require
// an exact, explicit opt-in instead of changing every launch by default.
inline bool AffinityEnabled(const char* value)
{
return value != nullptr && value[0] == '1' && value[1] == '\0';
}

struct PinResult
{
bool affinity_set = false;
};
} // namespace moderngekko::frontend

#if defined(_WIN32)
#ifndef WIN32_LEAN_AND_MEAN
Expand Down Expand Up @@ -62,30 +80,211 @@ inline CacheDomain LargestSharedCache(const void* records, std::size_t bytes, BY
return best;
}

// Process-wide affinity can interfere with Dolphin's worker threads, so require
// an exact, explicit opt-in instead of changing every launch by default.
inline bool AffinityEnabled(const char* value)
// Applies a domain to a process. Separate from choosing one so a test can hand
// it a real process handle and read the result back out of the OS.
//
// An empty domain is not an error: there was nothing to pin to, and the
// process's existing affinity is left alone.
inline PinResult ApplyCacheDomain(HANDLE process, const CacheDomain& domain)
{
return value != nullptr && value[0] == '1' && value[1] == '\0';
PinResult result;
if (!domain)
return result;
result.affinity_set = SetProcessAffinityMask(process, domain.mask) != FALSE;
return result;
}
} // namespace moderngekko::frontend

struct PinResult
#elif defined(__linux__)

#include <sched.h>
#include <unistd.h>

#include <filesystem>
#include <fstream>
#include <system_error>
#include <vector>

// Linux exposes the same topology through sysfs. Each logical CPU has one
// directory per cache it sees; a level-3 entry lists every CPU sharing that
// cache in `shared_cpu_list`, so the distinct lists across all CPUs are the
// domains, and `size` picks between them.
//
// The sysfs root is a parameter so a test can point it at a fixture tree. The
// layouts that matter -- one die with a larger victim cache, one uniform cache
// spanning every core -- are not reproducible on demand on the test machine,
// which is the same reason the Windows rule takes a buffer rather than calling
// the OS itself.
namespace moderngekko::frontend
{
bool affinity_set = false;
struct CacheDomain
{
std::vector<int> cpus;
unsigned long long size = 0;

// No CPUs means nothing matched, so there is nothing to pin to.
explicit operator bool() const { return !cpus.empty(); }
};

// Applies a domain to a process. Separate from choosing one so a test can hand
// it a real process handle and read the result back out of the OS.
// "0-5,12-17" -> 0 1 2 3 4 5 12 13 14 15 16 17
//
// Malformed input yields an empty list rather than a partial one: a domain that
// is wrong is worse than a domain that is missing, because the caller pins to it.
inline std::vector<int> ParseCpuList(std::string_view text)
{
std::vector<int> cpus;
while (!text.empty() && (text.back() == '\n' || text.back() == ' '))
text.remove_suffix(1);
while (!text.empty())
{
const std::size_t comma = text.find(',');
std::string_view item = text.substr(0, comma);
text = comma == std::string_view::npos ? std::string_view() : text.substr(comma + 1);
if (item.empty())
return {};

const std::size_t dash = item.find('-');
const auto number = [](std::string_view s, int* out) {
if (s.empty())
return false;
int value = 0;
for (const char c : s)
{
if (c < '0' || c > '9')
return false;
// A CPU index this large is not a topology, it is a malformed file.
if (value > 100000)
return false;
value = value * 10 + (c - '0');
}
*out = value;
return true;
};

int first = 0;
int last = 0;
if (dash == std::string_view::npos)
{
if (!number(item, &first))
return {};
last = first;
}
else if (!number(item.substr(0, dash), &first) ||
!number(item.substr(dash + 1), &last) || last < first)
{
return {};
}
for (int cpu = first; cpu <= last; cpu++)
cpus.push_back(cpu);
}
return cpus;
}

// "32768K" / "32M" / "512" -> bytes. Returns 0 for anything unrecognised.
inline unsigned long long ParseCacheSize(std::string_view text)
{
unsigned long long value = 0;
std::size_t i = 0;
for (; i < text.size() && text[i] >= '0' && text[i] <= '9'; i++)
value = value * 10 + static_cast<unsigned>(text[i] - '0');
if (i == 0)
return 0;
if (i < text.size())
{
switch (text[i])
{
case 'K':
case 'k':
value *= 1024ull;
break;
case 'M':
case 'm':
value *= 1024ull * 1024ull;
break;
case 'G':
case 'g':
value *= 1024ull * 1024ull * 1024ull;
break;
case '\n':
case '\0':
break;
default:
return 0;
}
}
return value;
}

inline std::string ReadSysfsFile(const std::filesystem::path& path)
{
std::ifstream input(path);
if (!input)
return {};
std::string text;
std::getline(input, text);
return text;
}

// Ties keep the first domain seen in CPU order, so the answer does not depend on
// directory iteration order. On a part whose dies are identical that choice is
// arbitrary and it does not matter which one wins -- what matters is that the
// process stops migrating between them.
inline CacheDomain LargestSharedCache(
const std::filesystem::path& sysfs_root = "/sys/devices/system/cpu", int level = 3)
{
CacheDomain best;
std::error_code ec;
std::vector<std::vector<int>> seen;
for (int cpu = 0;; cpu++)
{
const std::filesystem::path cache_dir =
sysfs_root / ("cpu" + std::to_string(cpu)) / "cache";
if (!std::filesystem::exists(cache_dir, ec))
break;
for (int index = 0; index < 16; index++)
{
const std::filesystem::path entry = cache_dir / ("index" + std::to_string(index));
if (!std::filesystem::exists(entry, ec))
continue;
if (ReadSysfsFile(entry / "level") != std::to_string(level))
continue;
std::vector<int> cpus = ParseCpuList(ReadSysfsFile(entry / "shared_cpu_list"));
if (cpus.empty())
continue;
// The same domain is reported once per CPU in it; count it once.
if (std::find(seen.begin(), seen.end(), cpus) != seen.end())
continue;
seen.push_back(cpus);
const unsigned long long size = ParseCacheSize(ReadSysfsFile(entry / "size"));
if (size > best.size)
best = CacheDomain{std::move(cpus), size};
}
}
return best;
}

// An empty domain is not an error: there was nothing to pin to, and the
// process's existing affinity is left alone.
inline PinResult ApplyCacheDomain(HANDLE process, const CacheDomain& domain)
//
// This pins the calling thread and every thread it later creates, which on Linux
// means passing 0 rather than a pid: sched_setaffinity on the process id moves
// only the main thread on some kernels, and Dolphin's workers are spawned after
// this runs.
inline PinResult ApplyCacheDomain(pid_t pid, const CacheDomain& domain)
{
PinResult result;
if (!domain)
return result;
result.affinity_set = SetProcessAffinityMask(process, domain.mask) != FALSE;
cpu_set_t set;
CPU_ZERO(&set);
for (const int cpu : domain.cpus)
{
if (cpu < 0 || cpu >= CPU_SETSIZE)
return result;
CPU_SET(cpu, &set);
}
result.affinity_set = sched_setaffinity(pid, sizeof(set), &set) == 0;
return result;
}
} // namespace moderngekko::frontend
#endif // _WIN32
#endif // _WIN32 / __linux__
Loading
Loading