LINE Solver (C++)
Templated C++ port of the LINE queueing solver
Loading...
Searching...
No Matches
ctmc_memory_gate.h
Go to the documentation of this file.
1#ifndef LINE_API_MC_CTMC_MEMORY_GATE_H
2#define LINE_API_MC_CTMC_MEMORY_GATE_H
3
4/**
5 * @file
6 * @ingroup api_mc
7 * Host-aware memory pre-gate for SolverCTMC.
8 *
9 * Port of `matlab/src/api/mc/ctmc_memory_gate.m`, mirrored by the JAR
10 * (`MemoryGuard.gate`) and native Python (`ctmc_memory_gate`).
11 *
12 * THE POLICY IS REFUSAL, NOT A WARNING. When the predicted peak exceeds the
13 * safe budget the solve is REFUSED with an error naming the two numbers, and
14 * only an explicit `force` downgrades that to a warning. A warning the caller
15 * can walk past is the wrong contract here: the failure mode it precedes is the
16 * OOM killer taking the whole process, which produces no diagnostic at all and,
17 * observed on 2026-08-01, took down five sessions and the terminal hosting
18 * them. Refusing costs the caller a re-run with another solver; not refusing
19 * costs it everything running on the machine.
20 *
21 * MDD IS NEVER GATED. It is served by its own analyzer and returns before any
22 * explicit-generator path, so the gate is not on its route.
23 * That exemption is load-bearing rather than incidental: `mdd` holds the
24 * reachable set in a decision diagram whose size is governed by the diagram's
25 * compression, not by the state count, so it routinely solves models this
26 * estimator scores at exp(200). Gating on explicit size would clamp exactly the
27 * method that exists to beat it. Keep any new non-explicit method on the same
28 * side of the gate.
29 *
30 * The predictor is the power law of the reference, `bytes = alpha * N^beta`,
31 * evaluated in log space. As in the reference, alpha and beta are CALIBRATED
32 * once per host (`ctmc_get_calibration`): the sparse LU of two lattice
33 * generators (40x40 and 80x80) is profiled for its fill-in bytes and its time,
34 * a power law is fitted through the two points, and the fit is cached in a
35 * JSON file in the temporary directory keyed by a machine signature. When the
36 * profile cannot run the reference's FALLBACK coefficients (alpha = 128,
37 * beta = 1.3) are used instead, which is the same branch the reference takes
38 * then. The factorization here is a banded sparse LU in natural order, which
39 * is deterministic across hosts; only the timing coefficients depend on the
40 * machine, and the gate does not read those.
41 */
42
43#include <chrono>
44#include <cmath>
45#include <cstdio>
46#include <cstdlib>
47#include <fstream>
48#include <mutex>
49#include <sstream>
50#include <string>
51#include <thread>
52#include <vector>
53
54#if defined(__unix__) || defined(__APPLE__)
55#include <sys/utsname.h>
56#include <unistd.h>
57#endif
58
59#include "json.hpp"
60#include "line/util/error.h"
61
62namespace line {
63namespace mc {
64
65/// 8 bytes of value plus 8 amortized for the index, per stored nonzero.
66constexpr double CTMC_BYTES_PER_NZ = 16.0;
67/// Fraction of available memory the solver may target.
68constexpr double CTMC_DEFAULT_SAFETY_FRACTION = 0.6;
69/// Fallback power-law coefficients, identical to MATLAB and Python.
70constexpr double CTMC_FALLBACK_ALPHA = CTMC_BYTES_PER_NZ * 8.0;
71constexpr double CTMC_FALLBACK_BETA = 1.3;
72/// Conservative available-memory default when the host probe fails.
73constexpr double CTMC_FALLBACK_AVAIL_BYTES = 1.0 * 1024.0 * 1024.0 * 1024.0;
74/// The two lattice sides the reference profiles (G1, G2 of ctmc_get_calibration).
75constexpr int CTMC_CALIB_G1 = 40;
76constexpr int CTMC_CALIB_G2 = 80;
77
78/** The gate verdict, plus the message the caller reports either way. */
80 bool ok = true;
81 std::string message;
82 double predicted_gb = 0.0;
83 double budget_gb = 0.0;
84};
85
86/** The per-host power-law fit, the reference's `calib` struct. */
88 std::string sig;
91 double alpha_t = 0.0;
92 double beta_t = 1.0;
93 double timestamp = 0.0;
94};
95
96/**
97 * Available physical memory in bytes.
98 *
99 * Never throws: a probe failure returns the conservative fallback, because a
100 * gate that cannot measure the host must not thereby become permissive.
101 */
103#if defined(_SC_AVPHYS_PAGES) && defined(_SC_PAGE_SIZE)
104 const long pages = ::sysconf(_SC_AVPHYS_PAGES);
105 const long page_size = ::sysconf(_SC_PAGE_SIZE);
106 if (pages > 0 && page_size > 0)
107 return static_cast<double>(pages) * static_cast<double>(page_size);
108#endif
110}
111
112namespace gate_detail {
113
114/** The reference's `ctmc_machine_signature`: arch | cores | platform. */
115inline std::string machine_signature() {
116 std::string arch =
117#if defined(__x86_64__) || defined(_M_X64)
118 "x86_64";
119#elif defined(__aarch64__) || defined(_M_ARM64)
120 "arm64";
121#elif defined(__i386__) || defined(_M_IX86)
122 "x86";
123#else
124 "unknown";
125#endif
126 unsigned ncores = std::thread::hardware_concurrency();
127 if (ncores == 0) ncores = 1;
128 std::string platform = "unknown";
129#if defined(__unix__) || defined(__APPLE__)
130 struct utsname u;
131 if (::uname(&u) == 0) platform = std::string(u.sysname) + " " + u.machine;
132#elif defined(_WIN32)
133 platform = "Windows";
134#endif
135 return arch + "|" + std::to_string(ncores) + "|" + platform;
136}
137
138/** The reference's cache file, `fullfile(tempdir, 'line_ctmc_calib_<lang>.json')`. */
139inline std::string cache_file() {
140 const char* base = std::getenv("TMPDIR");
141 std::string dir = (base && *base) ? std::string(base) : std::string("/tmp");
142 if (!dir.empty() && dir.back() == '/') dir.pop_back();
143 return dir + "/line_ctmc_calib_cpp.json";
144}
145
146/**
147 * The reference's `ctmc_profile_point`: sparse LU of the (g^2 - 1)-state
148 * lattice generator, returning n, the fill-in bytes and the elapsed seconds.
149 *
150 * The lattice is the 4-neighbour grid in column-major numbering, so its
151 * bandwidth is g and the no-pivot banded LU keeps every fill-in within the
152 * band; nnz(L) counts the unit diagonal, as MATLAB's `lu` does.
153 */
154inline void profile_point(int g, double bytes_per_nz, std::size_t& n, double& bytes,
155 double& secs) {
156 const std::size_t ntot = static_cast<std::size_t>(g) * static_cast<std::size_t>(g);
157 const std::size_t N = ntot - 1; // drop the last state -> nonsingular
158 n = N;
159 const std::size_t bw = static_cast<std::size_t>(g);
160 const std::size_t w = bw + 1;
161 std::vector<double> U(N * w, 0.0); // U[i*w + (j - i)], j in [i, i + bw]
162 std::vector<double> work(2 * bw + 1, 0.0);
163 std::size_t nnzL = 0, nnzU = 0;
164 const std::chrono::steady_clock::time_point t0 = std::chrono::steady_clock::now();
165 for (std::size_t i = 0; i < N; ++i) {
166 const std::size_t lo = i >= bw ? i - bw : 0;
167 const std::size_t hi = std::min(N - 1, i + bw);
168 std::fill(work.begin(), work.end(), 0.0);
169 // row i of A = Q(1:n-1, 1:n-1): the full-lattice degree on the diagonal,
170 // 1 on each neighbour still inside the kept block
171 const std::size_t r = i % bw, c = i / bw;
172 int deg = 0;
173 if (c > 0) ++deg;
174 if (c + 1 < bw) ++deg;
175 if (r > 0) ++deg;
176 if (r + 1 < bw) ++deg;
177 work[i - lo] = -static_cast<double>(deg);
178 if (c > 0) work[(i - bw) - lo] = 1.0;
179 if (c + 1 < bw && i + bw <= hi) work[(i + bw) - lo] = 1.0;
180 if (r > 0) work[(i - 1) - lo] = 1.0;
181 if (r + 1 < bw && i + 1 <= hi) work[(i + 1) - lo] = 1.0;
182 for (std::size_t k = lo; k < i; ++k) {
183 const double a = work[k - lo];
184 if (a == 0.0) continue;
185 const double ukk = U[k * w];
186 const double lik = a / ukk;
187 ++nnzL;
188 const std::size_t kh = std::min(N - 1, k + bw);
189 for (std::size_t j = k + 1; j <= kh; ++j) work[j - lo] -= lik * U[k * w + (j - k)];
190 }
191 ++nnzL; // the unit diagonal of L
192 for (std::size_t j = i; j <= hi; ++j) {
193 const double v = work[j - lo];
194 U[i * w + (j - i)] = v;
195 if (v != 0.0) ++nnzU;
196 }
197 }
198 const std::chrono::steady_clock::time_point t1 = std::chrono::steady_clock::now();
199 secs = std::chrono::duration<double>(t1 - t0).count();
200 bytes = bytes_per_nz * static_cast<double>(nnzL + nnzU);
201}
202
203/** The reference's `ctmc_fit_power_law`; false on a degenerate pair. */
204inline bool fit_power_law(double x1, double y1, double x2, double y2, double& alpha,
205 double& beta) {
206 if (x1 <= 0 || x2 <= 0 || y1 <= 0 || y2 <= 0 || x1 == x2) return false;
207 beta = std::log(y2 / y1) / std::log(x2 / x1);
208 alpha = y1 / std::pow(x1, beta);
209 return true;
210}
211
212inline double now_posix() {
213 return std::chrono::duration<double>(std::chrono::system_clock::now().time_since_epoch())
214 .count();
215}
216
217} // namespace gate_detail
218
219/**
220 * The reference's `ctmc_get_calibration`: the cached per-host fit, profiled
221 * once when the cache is missing or belongs to another machine, and the
222 * fallback coefficients when the profile fails.
223 */
224inline const CtmcCalibration& ctmc_get_calibration(bool verbose = false) {
225 static CtmcCalibration calib;
226 static bool ready = false;
227 static std::mutex mu;
228 std::lock_guard<std::mutex> lock(mu);
229 if (ready) return calib;
230 const std::string sig = gate_detail::machine_signature();
231 const std::string cachefile = gate_detail::cache_file();
232
233 // the cached fit, when it is this machine's
234 try {
235 std::ifstream in(cachefile);
236 if (in) {
237 nlohmann::json data;
238 in >> data;
239 if (data.contains("sig") && data["sig"].is_string() && data["sig"] == sig &&
240 data.contains("alpha_mem") && data.contains("beta_mem")) {
241 calib.sig = sig;
242 calib.alpha_mem = data["alpha_mem"].get<double>();
243 calib.beta_mem = data["beta_mem"].get<double>();
244 calib.alpha_t = data.value("alpha_t", 0.0);
245 calib.beta_t = data.value("beta_t", 1.0);
246 calib.timestamp = data.value("timestamp", 0.0);
247 ready = true;
248 return calib;
249 }
250 }
251 } catch (...) {
252 }
253
254 bool fitted = false;
255 try {
256 std::size_t n1 = 0, n2 = 0;
257 double b1 = 0, b2 = 0, t1 = 0, t2 = 0, am = 0, bm = 0, at = 0, bt = 0;
260 const bool okm = gate_detail::fit_power_law(static_cast<double>(n1), b1,
261 static_cast<double>(n2), b2, am, bm);
262 const bool okt = gate_detail::fit_power_law(static_cast<double>(n1), std::max(t1, 1e-9),
263 static_cast<double>(n2), std::max(t2, 1e-9),
264 at, bt);
265 if (!okm || !okt) throw NumericError(ErrorKind{"Degenerate"}, "degenerate power-law fit");
266 calib.sig = sig;
267 calib.alpha_mem = am;
268 calib.beta_mem = bm;
269 calib.alpha_t = at;
270 calib.beta_t = bt;
272 fitted = true;
273 try {
274 nlohmann::json data;
275 data["sig"] = calib.sig;
276 data["alpha_mem"] = calib.alpha_mem;
277 data["beta_mem"] = calib.beta_mem;
278 data["alpha_t"] = calib.alpha_t;
279 data["beta_t"] = calib.beta_t;
280 data["timestamp"] = calib.timestamp;
281 std::ofstream out(cachefile);
282 if (out) out << data.dump();
283 } catch (...) {
284 }
285 if (verbose) std::printf("CTMC calibration: bytes ~ %.3g*N^%.3f\n", am, bm);
286 } catch (const std::exception& e) {
287 if (verbose)
288 std::printf("CTMC calibration failed (%s); using fallback model\n", e.what());
289 } catch (...) {
290 if (verbose) std::printf("CTMC calibration failed; using fallback model\n");
291 }
292 if (!fitted) {
293 calib.sig = sig;
296 calib.alpha_t = 0.0;
297 calib.beta_t = 1.0;
299 }
300 ready = true;
301 return calib;
302}
303
304/**
305 * Decide whether a state space of log-size `log_nstates` can be solved here.
306 *
307 * @param log_nstates natural log of the worst-case state count
308 * @param force true downgrades a refusal to a warning
309 * @param safety_fraction fraction of available memory the solve may target
310 * @param verbose print the calibration and the forced / near-budget notes,
311 * the reference's third argument (last here so the
312 * existing three-argument callers keep their meaning)
313 */
314inline CtmcGateResult ctmc_memory_gate(double log_nstates, bool force = false,
315 double safety_fraction = CTMC_DEFAULT_SAFETY_FRACTION,
316 bool verbose = false) {
317 CtmcGateResult res;
318 const double avail = ctmc_available_memory_bytes();
319 const double budget = safety_fraction * avail;
320 const CtmcCalibration& calib = ctmc_get_calibration(verbose);
321
322 // Compared in LOG space: the predicted byte count of an intractable model
323 // overflows a double long before it exceeds the budget, and inf > x is a
324 // comparison that has already lost the margin it was meant to report.
325 const double log_pred = std::log(calib.alpha_mem) + calib.beta_mem * log_nstates;
326 const double log_budget = std::log(budget > 1.0 ? budget : 1.0);
327 const double gb = 1024.0 * 1024.0 * 1024.0;
328
329 res.predicted_gb = std::exp(log_pred < 700.0 ? log_pred : 700.0) / gb;
330 res.budget_gb = budget / gb;
331 if (log_pred > log_budget) {
332 std::ostringstream os;
333 os.setf(std::ios::fixed);
334 os.precision(2);
335 os << "CTMC predicted peak memory ~" << res.predicted_gb
336 << " GB exceeds the safe budget ~" << res.budget_gb << " GB ("
337 << static_cast<long long>(std::floor(100.0 * safety_fraction + 0.5)) << "% of "
338 << avail / gb
339 << " GB available). Reduce the state space (e.g. lower 'cutoff'), use the 'mdd' method, "
340 "use another solver (MVA/NC/FLD), or set force=true to override.";
341 res.message = os.str();
342 if (!force) {
343 res.ok = false;
344 return res;
345 }
346 if (verbose) std::printf("Warning (forced): %s\n", res.message.c_str());
347 } else if (verbose && log_pred > std::log(std::max(0.5 * budget, 1.0))) {
348 std::printf("CTMC predicted peak memory ~%.2f GB (budget ~%.2f GB).\n", res.predicted_gb,
349 res.budget_gb);
350 }
351 return res;
352}
353
354} // namespace mc
355} // namespace line
356
357#endif // LINE_API_MC_CTMC_MEMORY_GATE_H
NumericError(const std::string &what)
Definition error.h:112
The exception types the port throws.
std::string cache_file()
The reference's cache file, fullfile(tempdir, 'line_ctmc_calib_<lang>.json').
std::string machine_signature()
The reference's ctmc_machine_signature: arch | cores | platform.
void profile_point(int g, double bytes_per_nz, std::size_t &n, double &bytes, double &secs)
The reference's ctmc_profile_point: sparse LU of the (g^2 - 1)-state lattice generator,...
bool fit_power_law(double x1, double y1, double x2, double y2, double &alpha, double &beta)
The reference's ctmc_fit_power_law; false on a degenerate pair.
constexpr double CTMC_DEFAULT_SAFETY_FRACTION
Fraction of available memory the solver may target.
constexpr int CTMC_CALIB_G2
constexpr double CTMC_BYTES_PER_NZ
8 bytes of value plus 8 amortized for the index, per stored nonzero.
constexpr double CTMC_FALLBACK_ALPHA
Fallback power-law coefficients, identical to MATLAB and Python.
CtmcGateResult ctmc_memory_gate(double log_nstates, bool force=false, double safety_fraction=CTMC_DEFAULT_SAFETY_FRACTION, bool verbose=false)
Decide whether a state space of log-size log_nstates can be solved here.
const CtmcCalibration & ctmc_get_calibration(bool verbose=false)
The reference's ctmc_get_calibration: the cached per-host fit, profiled once when the cache is missin...
double ctmc_available_memory_bytes()
Available physical memory in bytes.
constexpr double CTMC_FALLBACK_AVAIL_BYTES
Conservative available-memory default when the host probe fails.
constexpr int CTMC_CALIB_G1
The two lattice sides the reference profiles (G1, G2 of ctmc_get_calibration).
constexpr double CTMC_FALLBACK_BETA
Conservation laws of a layered queueing network, enumerated from its structure.
Definition aoi_dist2ph.h:52
The <kind> part of a LINE:<category>:<kind> identifier (see _kb/17-error-vocabulary....
Definition error.h:37
The per-host power-law fit, the reference's calib struct.
The gate verdict, plus the message the caller reports either way.