Files
Strata/tests/core/file_expert_source_test.cpp
T
Niko1221 16a6d77ed4 The expert arena checks the host RAM before it is allocated; the RAM probe reads cgroup v1 too (#633)
The pinned expert arena (the default tier) was allocated without looking at the RAM: on Linux the anonymous mapping
succeeds whatever the host has, and a container whose memory limit is below the arena was killed by the OOM killer
part-way through the load, without a message.

- detail::host_available_memory: the file tier's probe (MemAvailable lowered to the tightest cgroup v2 limit's room),
  now with its files as parameters (tested on fake trees), cgroup v1's memory controller (memory.limit_in_bytes -
  memory.usage_in_bytes), and no cgroup line at all meaning MemAvailable alone (it was "cannot determine", which
  refused a --resident-budget-gib on a v1 host). It also returns the tightest hard limit itself.
- ArenaExpertSource::open, after the size check and before the allocation: a hard cgroup limit below the arena is
  refused with both numbers (a certain OOM kill); less RAM available than the arena is a warning with the numbers
  and what runs with less (--mmap-experts with --resident-budget-gib), and the load goes on. Not for a shared arena.
- file_expert_source_test: v2 limit / max / malformed / missing, v1 limit / unlimited / not mounted, no cgroup.
2026-10-03 18:24:31 +02:00

361 lines
18 KiB
C++

#include "strata/core/expert_source.hpp"
#include "strata/kernels/cpu/expert.hpp"
#include "strata/kernels/cpu/expert_layout.hpp"
#if defined(STRATA_NATIVE_EXPERTS)
#include "strata/kernels/cpu/native_expert.hpp"
#include "ggml.h"
#endif
#include <algorithm>
#include <chrono>
#include <cstddef>
#include <cstdint>
#include <filesystem>
#include <fstream>
#include <iostream>
#include <limits>
#include <stdexcept>
#include <string>
#include <system_error>
#include <utility>
#include <vector>
namespace fs = std::filesystem;
namespace {
void require(bool ok, const std::string& message) {
if (!ok) throw std::runtime_error(message);
}
struct TempDirectory {
fs::path path;
TempDirectory() {
const auto stamp = std::chrono::steady_clock::now().time_since_epoch().count();
path = fs::temp_directory_path() / ("strata-file-source-test-" + std::to_string(stamp));
fs::create_directories(path);
}
~TempDirectory() {
std::error_code ignored;
fs::remove_all(path, ignored);
}
};
void create_pack(const fs::path& dir, uint64_t bytes, const std::vector<std::pair<uint64_t, char>>& markers = {}) {
const fs::path file = dir / "experts.bin";
{
std::ofstream out(file, std::ios::binary | std::ios::trunc);
require((bool) out, "could not create synthetic experts.bin");
if (bytes > 0) {
out.seekp((std::streamoff) (bytes - 1));
out.put('\0');
}
require((bool) out, "could not size synthetic experts.bin");
}
for (const auto& [offset, marker] : markers) {
require(offset < bytes, "synthetic marker lies beyond experts.bin");
std::fstream file_out(file, std::ios::binary | std::ios::in | std::ios::out);
require((bool) file_out, "could not open synthetic experts.bin for markers");
file_out.seekp((std::streamoff) offset);
file_out.put(marker);
require((bool) file_out, "could not write synthetic expert marker");
}
}
void check_file_size_rejection(strata::core::FileExpertSource& source, const fs::path& dir, int64_t layers,
int64_t experts, uint64_t expected_bytes) {
std::string err;
source.close();
fs::resize_file(dir / "experts.bin", expected_bytes - 1);
require(!source.open(dir.string(), layers, experts, err), "a truncated expert file was accepted");
require(!source.mapped() && !err.empty(), "truncated-file rejection left the source mapped or unreported");
fs::resize_file(dir / "experts.bin", expected_bytes + 1);
err.clear();
require(!source.open(dir.string(), layers, experts, err), "an oversized expert file was accepted");
require(!source.mapped() && !err.empty(), "oversized-file rejection left the source mapped or unreported");
}
void test_canonical_layout() {
using namespace strata::core;
using namespace strata::kernels::cpu;
constexpr int64_t layers = 2;
constexpr int64_t experts = 3;
const uint64_t layer_bytes = (uint64_t) experts * BLOB;
const uint64_t total = (uint64_t) layers * layer_bytes;
TempDirectory dir;
std::string err;
const bool layout_ok = expert_layout_load(dir.path.string(), layers, experts, err);
require(layout_ok, "could not load canonical layout: " + err);
create_pack(dir.path, total, {{0, 'a'}, {(uint64_t) BLOB, 'b'}, {layer_bytes, 'c'},
{layer_bytes + (uint64_t) BLOB, 'd'}});
FileExpertSource source;
bool opened = source.open(dir.path.string(), layers, experts, err);
require(opened, "could not map canonical pack: " + err);
require(source.blobs() == layers * experts, "canonical blob count is wrong");
const uint8_t* first = source.blob(0, 0);
const uint8_t* second = source.blob(0, 1);
const uint8_t* next_layer = source.blob(1, 0);
require(first && second && next_layer, "valid canonical blob lookup failed");
require(second - first == (ptrdiff_t) BLOB && next_layer - first == (ptrdiff_t) layer_bytes,
"canonical expert or layer stride is wrong");
require(first[0] == 'a' && second[0] == 'b' && next_layer[0] == 'c',
"canonical blob lookup returned bytes from the wrong expert");
require(source.blob(-1, 0) == nullptr && source.blob(0, -1) == nullptr &&
source.blob(layers, 0) == nullptr && source.blob(0, experts) == nullptr,
"canonical bounds check accepted an invalid layer or expert");
require(source.reads() == 3, "invalid canonical lookups changed the read count");
check_file_size_rejection(source, dir.path, layers, experts, total);
create_pack(dir.path, total, {{layer_bytes, 'c'}});
err.clear();
opened = source.open(dir.path.string(), layers, experts, err);
require(opened, "source failed to reopen after size errors: " + err);
const uint8_t* reopened = source.blob(1, 0);
require(reopened && source.reads() == 1 && reopened[0] == 'c', "reopened canonical source kept stale state");
source.close();
}
#if defined(STRATA_NATIVE_EXPERTS)
void test_native_variable_layout() {
using namespace strata::core;
using namespace strata::kernels::cpu;
constexpr int64_t layers = 2;
constexpr int64_t experts = 3;
constexpr int64_t embedding = 2560;
constexpr int64_t feed_forward = 640;
TempDirectory dir;
NativeFmt first_fmt, second_fmt;
std::string err;
const bool first_ok = native_fmt(GGML_TYPE_IQ3_XXS, GGML_TYPE_IQ4_NL, embedding, feed_forward, first_fmt, err);
require(first_ok, "IQ3_XXS/IQ4_NL synthetic format is unavailable: " + err);
err.clear();
const bool second_ok = native_fmt(GGML_TYPE_IQ2_XS, GGML_TYPE_IQ4_NL, embedding, feed_forward, second_fmt, err);
require(second_ok, "IQ2_XS/IQ4_NL synthetic format is unavailable: " + err);
require(first_fmt.bytes != second_fmt.bytes, "chosen native formats do not exercise variable layer sizes");
const uint64_t layer0_bytes = (uint64_t) first_fmt.bytes * experts;
const uint64_t total = layer0_bytes + (uint64_t) second_fmt.bytes * experts;
{
std::ofstream metadata(dir.path / "native_experts.txt");
require((bool) metadata, "could not create synthetic native_experts.txt");
metadata << "0 " << GGML_TYPE_IQ3_XXS << ' ' << GGML_TYPE_IQ4_NL << " 0 " << first_fmt.bytes << '\n';
metadata << "1 " << GGML_TYPE_IQ2_XS << ' ' << GGML_TYPE_IQ4_NL << ' ' << layer0_bytes << ' '
<< second_fmt.bytes << '\n';
require((bool) metadata, "could not write synthetic native_experts.txt");
}
create_pack(dir.path, total, {{0, 'a'}, {(uint64_t) first_fmt.bytes, 'b'},
{layer0_bytes, 'c'}, {layer0_bytes + (uint64_t) second_fmt.bytes, 'd'}});
const bool layout_ok = expert_layout_load(dir.path.string(), layers, experts, err);
require(layout_ok, "could not load synthetic native layout: " + err);
FileExpertSource source;
bool opened = source.open(dir.path.string(), layers, experts, err);
require(opened, "could not map native pack: " + err);
require(source.blobs() == layers * experts, "native blob count is wrong");
const uint8_t* first = source.blob(0, 0);
const uint8_t* second = source.blob(0, 1);
const uint8_t* next_layer = source.blob(1, 0);
const uint8_t* next_layer_second = source.blob(1, 1);
require(first && second && next_layer && next_layer_second, "valid native blob lookup failed");
require(second - first == (ptrdiff_t) first_fmt.bytes && next_layer - first == (ptrdiff_t) layer0_bytes &&
next_layer_second - next_layer == (ptrdiff_t) second_fmt.bytes,
"native per-layer expert stride is wrong");
require(first[0] == 'a' && second[0] == 'b' && next_layer[0] == 'c' && next_layer_second[0] == 'd',
"native blob lookup returned bytes from the wrong expert");
require(source.blob(-1, 0) == nullptr && source.blob(0, -1) == nullptr &&
source.blob(layers, 0) == nullptr && source.blob(0, experts) == nullptr,
"native bounds check accepted an invalid layer or expert");
require(source.reads() == 4, "invalid native lookups changed the read count");
check_file_size_rejection(source, dir.path, layers, experts, total);
create_pack(dir.path, total, {{layer0_bytes, 'c'}});
err.clear();
opened = source.open(dir.path.string(), layers, experts, err);
require(opened, "source failed to reopen after size errors: " + err);
const uint8_t* reopened = source.blob(1, 0);
require(reopened && source.reads() == 1 && reopened[0] == 'c', "reopened native source kept stale state");
source.close();
}
#endif
void test_complement_plan() {
using namespace strata::core::detail;
std::vector<uint64_t> offsets;
uint64_t bytes = 0;
std::string error;
require(make_cache_complement_plan(2, 3, {3, 5}, {{0, 1}, {1, 2}}, {}, offsets, bytes, error), error);
require(bytes == 16 && offsets == std::vector<uint64_t>{0, kNoCacheComplement, 3, 6, 11, kNoCacheComplement},
"wrong compact offsets for variable native layer sizes");
const uint8_t resident[16] = {}, mapped[5] = {};
require(cache_complement_blob_or_fallback(1, offsets, resident, mapped) == mapped,
"GPU-resident expert lost mmap fallback");
require(cache_complement_blob_or_fallback(4, offsets, resident, mapped) == resident + 11,
"CPU miss did not use resident complement");
require(!make_cache_complement_plan(2, 3, {3, 5}, {{0, 1}, {0, 1}}, {}, offsets, bytes, error)
&& offsets.empty() && bytes == 0, "duplicate pair accepted");
require(!make_cache_complement_plan(2, 3, {3, 5}, {{0, 1}}, {{0, 1}}, offsets, bytes, error),
"overlapping tiers accepted");
require(!make_cache_complement_plan(2, 3, {3, 5}, {{2, 0}}, {}, offsets, bytes, error),
"out-of-range pair accepted");
require(!make_cache_complement_plan(2, 3, {0, 5}, {}, {}, offsets, bytes, error), "zero-size layer accepted");
}
void test_resident_lend_region() {
using namespace strata::core::detail;
// four slots (sizes 5, 3, 3, 5); the experts no slot holds take 10 bytes
const std::vector<uint64_t> slots{5, 3, 3, 5};
require(choose_resident_keep_from(slots, 10, 9, 1) == -1, "a base larger than the budget was accepted");
require(choose_resident_keep_from(slots, 10, 10, 1) == 4, "no room: the lend region must stay on the file");
require(choose_resident_keep_from(slots, 10, 15, 1) == 3, "the last slot fits and must be kept first");
require(choose_resident_keep_from(slots, 10, 17, 1) == 3, "a slot that does not fit ends the lend region's walk");
require(choose_resident_keep_from(slots, 10, 18, 1) == 2, "two slots fit");
require(choose_resident_keep_from(slots, 10, 100, 1) == 1, "the walk must stop at the lend region's first slot");
require(choose_resident_keep_from(slots, 10, 100, -1) == 4, "no lend region kept slots in RAM");
require(choose_resident_keep_from(slots, 10, 100, 9) == 4, "an out-of-range lend region kept slots in RAM");
require(choose_resident_keep_from({}, 0, 0, 0) == 0, "an empty cache");
}
void test_resident_exchange() {
using namespace strata::core;
using namespace strata::core::detail;
// 2 layers x 3 experts; the GPU holds (0,1) and (1,2): the compact copy holds the other four
std::vector<uint64_t> offsets;
uint64_t bytes = 0;
std::string error;
require(make_cache_complement_plan(2, 3, {3, 5}, {{0, 1}, {1, 2}}, {}, offsets, bytes, error), error);
const std::vector<uint64_t> before = offsets;
// (1,0) moves into the GPU, (1,2) leaves it: (1,2) takes (1,0)'s bytes' place
require(exchange_cache_complement(offsets, 3, 5), "a valid exchange was refused");
require(offsets[5] == before[3] && offsets[3] == kNoCacheComplement, "the exchange did not move the place");
for (size_t i : {0u, 1u, 2u, 4u}) require(offsets[i] == before[i], "an exchange touched another expert");
// the copy's size and its set of places are unchanged
std::vector<uint64_t> a, b;
for (uint64_t o : before) if (o != kNoCacheComplement) a.push_back(o);
for (uint64_t o : offsets) if (o != kNoCacheComplement) b.push_back(o);
std::sort(a.begin(), a.end());
std::sort(b.begin(), b.end());
require(a == b, "an exchange changed the compact copy's places");
const std::vector<uint64_t> after = offsets;
require(!exchange_cache_complement(offsets, 3, 0), "an `in` the copy does not hold was accepted");
require(!exchange_cache_complement(offsets, 0, 2), "an `out` the copy holds already was accepted");
require(!exchange_cache_complement(offsets, 0, 0), "a self-exchange was accepted");
require(!exchange_cache_complement(offsets, 0, 6), "an out-of-range `out` was accepted");
require(offsets == after, "a refused exchange changed the offsets");
// and back: the copy is again what the plan makes for the original placement
require(exchange_cache_complement(offsets, 5, 3), "the reverse exchange was refused");
require(offsets == before, "exchanging back did not restore the plan");
}
void test_cgroup_memory_budget() {
using namespace strata::core::detail;
constexpr uint64_t GiB = 1ull << 30;
uint64_t bytes = 0;
CgroupMemoryStat clean_cache{40 * GiB, 32 * GiB, 0, 0, true};
require(cgroup_available_bytes(56 * GiB, clean_cache, bytes), "valid cgroup memory.stat was rejected");
require(bytes == 48 * GiB, "clean inactive file cache was not credited against the cgroup cap");
CgroupMemoryStat dirty_cache{40 * GiB, 32 * GiB, 4 * GiB, 2 * GiB, true};
require(cgroup_available_bytes(56 * GiB, dirty_cache, bytes), "valid dirty-cache counters were rejected");
require(bytes == 42 * GiB, "dirty/writeback pages were incorrectly counted as reclaimable");
CgroupMemoryStat oversized_inactive{10 * GiB, 20 * GiB, 0, 0, true};
require(cgroup_available_bytes(12 * GiB, oversized_inactive, bytes),
"valid oversized inactive-file counter was rejected");
require(bytes == 12 * GiB, "inactive-file accounting exceeded charged current usage");
const uint64_t max = std::numeric_limits<uint64_t>::max();
CgroupMemoryStat large_counters{max, max, max - 1, max, true};
require(cgroup_available_bytes(max, large_counters, bytes),
"large memory.stat counters were rejected");
require(bytes == 0, "dirty/writeback subtraction overflowed or escaped the usage cap");
CgroupMemoryStat usage_over_limit{60 * GiB, 0, 0, 0, true};
require(cgroup_available_bytes(56 * GiB, usage_over_limit, bytes),
"valid over-limit memory counters were rejected");
require(bytes == 0, "over-limit cgroup usage produced a positive budget");
CgroupMemoryStat missing_stat{};
bytes = 123;
require(!cgroup_available_bytes(56 * GiB, missing_stat, bytes) && bytes == 0,
"missing memory.stat counters did not fail closed");
}
// #633: the host RAM probe with fake /proc and cgroup trees: v2 (a limit, "max", a missing or malformed limit), v1
// (a limit, unlimited), and no cgroup line at all. Elsewhere than Linux it reads the machine's RAM.
void test_host_memory() {
using namespace strata::core::detail;
constexpr uint64_t GiB = 1ull << 30;
HostMemory m;
#if defined(__linux__)
TempDirectory t;
auto put = [&](const fs::path& rel, const std::string& text) {
fs::create_directories((t.path / rel).parent_path());
std::ofstream(t.path / rel) << text;
};
put("meminfo", "MemTotal: 134217728 kB\nMemAvailable: 104857600 kB\n"); // 100 GiB available
const std::string mi = (t.path / "meminfo").string(), cg = (t.path / "cgroup").string(),
root = (t.path / "fs").string();
auto probe = [&](const std::string& self) {
put("cgroup", self);
return host_available_memory(m, mi, cg, root);
};
// no cgroup line at all: MemAvailable alone (before #633: "cannot determine")
require(probe("") && m.available == 100 * GiB && m.cgroup_limit == ~0ull, "no cgroup: MemAvailable alone");
// v2, a 48 GiB limit with 16 GiB charged, 4 GiB of it clean cache
put("fs/cgroup.controllers", "memory\n");
put("fs/box/memory.max", std::to_string(48 * GiB) + "\n");
put("fs/box/memory.current", std::to_string(16 * GiB) + "\n");
put("fs/box/memory.stat", "inactive_file " + std::to_string(4 * GiB) + "\nfile_dirty 0\nfile_writeback 0\n");
require(probe("0::/box\n") && m.available == 36 * GiB && m.cgroup_limit == 48 * GiB, "v2 limit");
put("fs/box/memory.max", "max\n");
require(probe("0::/box\n") && m.available == 100 * GiB && m.cgroup_limit == ~0ull, "v2 max");
put("fs/box/memory.max", "48G\n");
require(!probe("0::/box\n"), "v2 malformed limit accepted");
fs::remove(t.path / "fs/box/memory.max");
require(!probe("0::/box\n"), "v2 group without memory.max accepted");
// v1: the memory controller's group (a hybrid line list), a 32 GiB limit with 10 GiB used
put("fs/memory/docker/abc/memory.limit_in_bytes", std::to_string(32 * GiB) + "\n");
put("fs/memory/docker/abc/memory.usage_in_bytes", std::to_string(10 * GiB) + "\n");
put("fs/memory/memory.limit_in_bytes", "9223372036854771712\n"); // the root: unlimited
put("fs/memory/memory.usage_in_bytes", std::to_string(50 * GiB) + "\n");
require(probe("12:cpu,cpuacct:/docker/abc\n4:memory:/docker/abc\n1:name=systemd:/docker/abc\n") &&
m.available == 22 * GiB && m.cgroup_limit == 32 * GiB, "v1 limit");
put("fs/memory/docker/abc/memory.limit_in_bytes", "9223372036854771712\n");
require(probe("4:memory:/docker/abc\n") && m.available == 100 * GiB && m.cgroup_limit == ~0ull, "v1 unlimited");
// a v1 group not visible here (another namespace): MemAvailable alone
require(probe("4:memory:/elsewhere\n") && m.available == 100 * GiB, "v1 group not mounted");
#else
require(host_available_memory(m) && m.available > 0 && m.cgroup_limit == ~0ull, "this PC's RAM");
(void) GiB;
#endif
}
} // namespace
int main() {
try {
test_complement_plan();
test_resident_lend_region();
test_resident_exchange();
test_cgroup_memory_budget();
test_host_memory();
test_canonical_layout();
#if defined(STRATA_NATIVE_EXPERTS)
test_native_variable_layout();
#endif
std::cout << "file_expert_source_test: PASS\n";
return 0;
} catch (const std::exception& error) {
std::cerr << "file_expert_source_test: " << error.what() << '\n';
return 1;
}
}