Files
Jungfraujoch/tests/HostMemoryTest.cpp
leonarski_fandClaude Opus 5.5 de4de17abb RESULT-CHANGING: rugnux model stage: waits for the geometry walk's answer, stops on a model of another lattice, holds its memory in one host ledger
Ordering. A canonical pass with a model to validate now waits, before deciding whether it is superseded,
for the question GeometryWalkTakesRound asks: its own post-refinement fit and, for a move of less than a
step, the speculative probe (AwaitGeometryWalkAnswer). On a card too small for the probe beside the merge
the probe waited for the merge to end, so the answer could never be in before the model stage, and
validation/MR/refinement ran on a merge the walk then replaced (measured on a large-cell set: a model
validation on a wrong P1 supercell merge, OOM-killed above the logged host budget). The merge first
re-declares what it still holds of the card (GPUMerge::Holds), and the probe is waited for only where it
can then start (gpu_work_fits_beside_merges); otherwise nothing waits, as before. The cost - the probe's
remaining time - falls only on --model runs whose fit moved the detector; fast_live does not speculate,
so the broker's live mode is unchanged. Final outputs do not change: a superseded pass's files were
rewritten by the walk's pass anyway.

Model-cell check (owner approved). Where no change of basis of CellMappingOperators carries the model's
cell onto the data's (CellsCorrespond tolerance), the unplaced validation stops with cell_mismatch and a
reason naming both cells, instead of refractionalising the model into another lattice's cell. The report
says MODEL_VALIDATION=CELL_MISMATCH and warns MODEL_CELL_MISMATCH (in place of MODEL_NOT_VALIDATED).
Molecular replacement is still tried afterwards, as for any model not placed as given; placed paths
(MR, ExpandIntoSubgroup, RefinableIn) are untouched. --model stays processing-inert.
ModelValidation_WrongModelInAGroupOffTheDataMetric moves to cells within the tolerance so that it keeps
testing what it tested.

One host ledger. HostWorkerPool now knows the host budget and the bytes the run holds itself
(HostWorkerPool::Held): workers may hold the pool's size or what the budget leaves beside the held bytes,
whichever is less. ScaleMergeAndSymmetry holds its reflections, then engine + merge (+ post-refinement),
and at the model stage the reflections and the engine where the ledger merge keeps it; a validation
holds its own CPU grids; the change-of-basis frames reserve their grids per frame instead of all starting
at once. The reserved == 0 escape stays, so a worker larger than what is left still runs, alone.
Only what runs at the same time changes.

Tests: ModelValidation_StopsOnAModelOfAnotherLattice, HostWorkerPool_HeldBytesShareTheBudget.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqN9qRD57vnShzTC37
2026-10-28 15:11:06 +01:00

91 lines
3.5 KiB
C++

// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
// SPDX-License-Identifier: GPL-3.0-only
#include <catch2/catch_all.hpp>
#include <algorithm>
#include <atomic>
#include <chrono>
#include <thread>
#include <tuple>
#include <vector>
#include "../common/HostMemory.h"
TEST_CASE("HostMemory_BudgetFromTheTotal", "[HostMemory]") {
const size_t total = get_host_total_memory();
REQUIRE(total > 0);
CHECK(DefaultHostMemoryBudget() == static_cast<size_t>(HOST_MEMORY_BUDGET_FRACTION * static_cast<double>(total)));
}
// As many reservations at once as the pool holds, and one at a time where a single one is larger than it.
TEST_CASE("HostWorkerPool_BoundsWorkersByBytes", "[HostMemory]") {
HostWorkerPool &pool = HostWorkerPool::Instance();
for (const auto &[size, bytes, at_most] : std::vector<std::tuple<size_t, size_t, int>>{
{100, 40, 2}, {100, 100, 1}, {100, 1000, 1}, {1000, 10, 8}}) {
CAPTURE(size, bytes);
pool.SetSize(size);
std::atomic<int> running{0}, most{0}, done{0};
std::vector<std::thread> workers;
for (int i = 0; i < 8; i++)
workers.emplace_back([&] {
const HostWorkerPool::Reservation reserved(pool, bytes);
const int now = ++running;
int seen = most.load();
while (now > seen && !most.compare_exchange_weak(seen, now)) {}
std::this_thread::sleep_for(std::chrono::milliseconds(20));
--running;
++done;
});
for (auto &w : workers)
w.join();
CHECK(done == 8);
CHECK(most >= 1);
CHECK(most <= at_most);
}
pool.SetSize(0);
CHECK(pool.Size() == static_cast<size_t>(HOST_WORKER_POOL_FRACTION * static_cast<double>(DefaultHostMemoryBudget())));
}
// One ledger: what the run holds (Held) and what the workers reserve together stay within the budget. Workers
// take what the Held bytes leave of it, up to the pool's size, and a worker larger than that runs alone rather
// than never.
TEST_CASE("HostWorkerPool_HeldBytesShareTheBudget", "[HostMemory]") {
HostWorkerPool &pool = HostWorkerPool::Instance();
pool.SetBudget(100);
pool.SetSize(50);
CHECK(pool.Size() == 50);
const auto most_at_once = [&](size_t bytes) {
std::atomic<int> running{0}, most{0}, done{0};
std::vector<std::thread> workers;
for (int i = 0; i < 8; i++)
workers.emplace_back([&] {
const HostWorkerPool::Reservation reserved(pool, bytes);
const int now = ++running;
int seen = most.load();
while (now > seen && !most.compare_exchange_weak(seen, now)) {}
std::this_thread::sleep_for(std::chrono::milliseconds(20));
--running;
++done;
});
for (auto &w : workers)
w.join();
CHECK(done == 8);
return most.load();
};
{
HostWorkerPool::Held run(pool, 70); // leaves 30 of the budget: the pool's 50 does not fit beside it
CHECK(pool.Size() == 30);
CHECK(most_at_once(10) <= 3);
run.Set(20); // leaves 80: the pool's own size bounds again
CHECK(pool.Size() == 50);
CHECK(most_at_once(10) <= 5);
run.Set(100); // nothing left: one at a time, each still runs
CHECK(pool.Size() == 0);
CHECK(most_at_once(10) == 1);
}
CHECK(pool.Size() == 50);
pool.SetBudget(0);
pool.SetSize(0);
}