LINE Solver (C++)
Templated C++ port of the LINE queueing solver
Loading...
Searching...
No Matches
solver_env_meanfield.h
Go to the documentation of this file.
1/*
2 * Copyright (c) 2012-2026, QORE Lab, Imperial College London
3 * All rights reserved.
4 */
5#ifndef LINE_SOLVERS_ENV_SOLVER_ENV_MEANFIELD_H
6#define LINE_SOLVERS_ENV_SOLVER_ENV_MEANFIELD_H
7
8/**
9 * @file
10 * @ingroup line_solvers
11 * SolverENV, the default mean-field path: ENVIRONMENT COMPRESSION.
12 *
13 * WHAT THIS FILE IS, and what it deliberately is not. The mean-field coupling
14 * itself -- `solver_env_meanfield_analyzer.m`, the `pre_`/`analyze_`/`post_`/
15 * `finish_`/`converged_` cycle over per-stage transient means -- is ALREADY
16 * ported, in `solver_env.h`. What was missing is the other half of the
17 * mean-field path, the half that lives in `@@SolverENV/SolverENV.m` rather than
18 * in the analyzer file: the environment rate matrix `E0`, its generator
19 * `Eutil`, `ctmc_decompose`, `findBestPartition`, `beamSearchPartition`,
20 * `computeMacroRate` and `applyCompression`. That is what this header adds, and
21 * it composes with `SolverEnv<T>` instead of duplicating it.
22 *
23 * WHAT COMPRESSION BUYS. The mean-field fixed point costs one transient stage
24 * solve per stage per iteration, so an environment with many stages is
25 * expensive in the number of stages and not in their size. When the environment
26 * is NEARLY COMPLETELY DECOMPOSABLE -- stages fall into groups that switch
27 * rapidly among themselves and rarely across groups -- each group behaves, on
28 * the slow time scale the network actually feels, like a single stage running
29 * at the group's conditionally-averaged rates. Aggregating the groups replaces
30 * E stage solves by E' < E of them, and the error is governed by the degree of
31 * coupling eps against the admissible epsMAX that the kernels report.
32 *
33 * THE KERNELS ARE NOT RE-DERIVED HERE. `ctmc_courtois`, `ctmc_kms`,
34 * `ctmc_takahashi` and `ctmc_multi` are already ported under `line/api/mc/`,
35 * with their reference-defect histories recorded in their own headers; this
36 * file is the dispatch `SolverENV.ctmc_decompose` puts in front of them and
37 * nothing more. The same discipline applies downstream: the metrics still come
38 * out of `SolverEnv<T>`, which is the single mean-field implementation, so
39 * there is no second copy of the Util computation to drift -- the failure mode
40 * `_kb/06-solver-catalog.md` records under "ENV state-vector Util had drifted
41 * from the CTMC analyzer".
42 *
43 * ONE DELIBERATE DIVERGENCE FROM THE REFERENCE, and it is a correctness fix
44 * rather than a preference. `applyCompression` replaces `self.ensemble`,
45 * `self.solvers` and `self.sn` with their E' macro versions but leaves
46 * `self.envObj` at its original E stages, so `envObj.proc{e}{h}` and
47 * `envObj.holdTime{e}` still describe MICRO transitions while every consumer
48 * now indexes them as macro ones. The analyzer's `post_` and `finish_` weight
49 * the macro transients by those stale micro CDFs, silently, and only when
50 * E' == E do the two agree. Here `env_compress` builds a genuinely compressed
51 * `Environment<T>`: E' stages, and arcs carrying the aggregated macro rates, so
52 * the holding times the analyzer integrates against are the ones its stages
53 * actually have.
54 *
55 * WHAT IS PORTED, and what is refused by name:
56 * ported `E0`/`Eutil`, `ctmc_decompose` over all four kernels,
57 * `findBestPartition`, `beamSearchPartition`, `computeMacroRate`,
58 * `applyCompression`, and the mean-field solve on top of the result
59 * ported `aggregateCacheMeanfield_` too, as of 2026-08-15: it is a SECOND
60 * mean-field fixed point, nested beside the queue-length one and
61 * carrying each Cache's own occupancy across a switch by prob_orig,
62 * because a cache's state is a per-item occupancy that no marginal
63 * queue length encodes. Its per-sweep transient is
64 * `solver_fld_cacheqn_tran`, whose grid runs in PER-REQUEST time --
65 * it is divided by the cache's total arrival rate before the
66 * holding-time CDF is evaluated on it, or every stage is weighted as
67 * though its cache saw the same request rate
68 * refused a Cache under a NON-FLUID stage solver (only the fluid stage
69 * exposes that RMF transient), compression of a non-exponential
70 * environment, and everything `solver_env.h` already refuses by name
71 */
72
73#include <algorithm>
74#include <cmath>
75#include <cstddef>
76#include <limits>
77#include <memory>
78#include <string>
79#include <vector>
80
88#include "line/num/number.h"
94#include "line/util/error.h"
95#include "line/util/matrix.h"
96
97namespace line {
98namespace env {
99
100/** A partition of the stage indices 0..E-1 into macro-states, MATLAB's `MS`. */
101using MacroPartition = std::vector<std::vector<std::size_t>>;
102
103/** The knobs `applyCompression` reads out of `options.config`. */
105 /** `options.config.da`: courtois (the reference's default), kms, takahashi, multi. */
106 std::string da = "courtois";
107 /** `options.config.da_iter`: sweeps for the two iterative kernels. */
108 std::size_t da_iter = 10;
109 /** `options.config.env_alpha`: the beam search's per-depth merge penalty. */
110 double env_alpha = 0.01;
111 /** Beam width, the reference's hard-coded B = 3. */
112 std::size_t beam_width = 3;
113 /**
114 * Stage count above which the reference switches from the pairwise search
115 * to the beam search. The two are genuinely different searches, not one
116 * search with a budget, so the threshold changes the answer and is exposed.
117 */
118 std::size_t beam_above_stages = 10;
119 /**
120 * A partition supplied by the caller, which SKIPS the search entirely.
121 * Worth having because both searches evaluate a decomposition per candidate
122 * pair, and a caller who already knows the group structure -- a repair model
123 * whose stages are (working, degraded) times (peak, offpeak), say -- should
124 * not pay for rediscovering it.
125 */
127};
128
129/** What `SolverENV.ctmc_decompose` returns: `[p, eps, epsMax, q]`. */
130template <class T>
131struct EnvDecomp {
132 std::vector<T> p; ///< approximate stationary vector of the environment
134};
135
136namespace meanfield_detail {
137
138/** Every kernel is seeded by Courtois, whose epsMAX is an eigenvalue modulus. */
139inline void refuse_inexact(const std::string& da) {
140 throw UnsupportedError(
141 "SolverENV compression: the '" + da +
142 "' decomposition needs transcendental arithmetic, because every one of ctmc_courtois, "
143 "ctmc_kms, ctmc_takahashi and ctmc_multi reports epsMAX, a subdominant eigenvalue "
144 "modulus with no rational closed form; rerun with --arith double or real");
145}
146
147/** MS must be a partition of 0..n-1, which every kernel assumes and none checks. */
148inline void check_partition(const MacroPartition& MS, std::size_t n) {
149 std::vector<bool> seen(n, false);
150 std::size_t total = 0;
151 for (const std::vector<std::size_t>& blk : MS) {
152 if (blk.empty()) throw InputError("SolverENV compression: a macro-state is empty");
153 for (std::size_t s : blk) {
154 if (s >= n)
155 throw InputError("SolverENV compression: a macro-state names stage " +
156 std::to_string(s + 1) + ", which does not exist");
157 if (seen[s])
158 throw InputError("SolverENV compression: stage " + std::to_string(s + 1) +
159 " appears in more than one macro-state");
160 seen[s] = true;
161 ++total;
162 }
163 }
164 if (total != n)
165 throw InputError(
166 "SolverENV compression: the macro-states must cover every stage; the partition "
167 "covers " +
168 std::to_string(total) + " of " + std::to_string(n));
169}
170
171/** Singletons, the starting point of both searches and the no-compression fallback. */
172inline MacroPartition singletons(std::size_t E) {
173 MacroPartition MS(E);
174 for (std::size_t i = 0; i < E; ++i) MS[i] = std::vector<std::size_t>{i};
175 return MS;
176}
177
178/** `trial`: merge blocks i and j of `ms`, the merged block taking i's position. */
179inline MacroPartition merge_blocks(const MacroPartition& ms, std::size_t i, std::size_t j) {
180 MacroPartition out;
181 out.reserve(ms.size() - 1);
182 for (std::size_t k = 0; k < ms.size(); ++k) {
183 if (k == i) {
184 std::vector<std::size_t> m = ms[i];
185 m.insert(m.end(), ms[j].begin(), ms[j].end());
186 out.push_back(m);
187 } else if (k != j) {
188 out.push_back(ms[k]);
189 }
190 }
191 return out;
192}
193
194} // namespace meanfield_detail
195
196/**
197 * Port of `SolverENV.ctmc_decompose`: one NCD decomposition, by whichever
198 * kernel `options.config.da` names.
199 *
200 * The uniformization rate the three iterative kernels report is the reference's
201 * `1.05 * max(max(abs(Q)))` rather than anything the kernel itself derived,
202 * which is why it is recomputed here instead of read back off the result.
203 */
204template <class T>
206 const EnvCompressOptions& opt) {
207 meanfield_detail::check_partition(MS, Q.rows());
208 EnvDecomp<T> out;
209 if constexpr (!num_traits<T>::has_transcendental) {
210 meanfield_detail::refuse_inexact(opt.da);
211 } else {
212 // 1.05 max|Q|, the rate the reference hands back for every kernel that
213 // does not report one of its own.
214 T qmax = num_traits<T>::from_int(0);
215 for (std::size_t i = 0; i < Q.rows(); ++i)
216 for (std::size_t j = 0; j < Q.cols(); ++j) {
217 const T a = num_abs(T(Q(i, j)));
218 if (a > qmax) qmax = a;
219 }
220 const T qdefault = T(num_traits<T>::from_rational(21, 20) * qmax);
221
222 if (opt.da == "courtois") {
224 out.p = r.p;
225 out.eps = r.eps;
226 out.epsMAX = r.epsMAX;
227 out.q = r.q;
228 } else if (opt.da == "kms") {
229 const mc::KmsResult<T> r = mc::ctmc_kms(Q, MS, opt.da_iter);
230 out.p = r.p;
231 out.eps = r.eps;
232 out.epsMAX = r.epsMAX;
233 out.q = qdefault;
234 } else if (opt.da == "takahashi") {
235 const mc::TakahashiResult<T> r = mc::ctmc_takahashi(Q, MS, opt.da_iter);
236 out.p = r.p;
237 out.eps = r.eps;
238 out.epsMAX = r.epsMAX;
239 out.q = qdefault;
240 } else if (opt.da == "multi") {
241 // The coarse partition defaults to singletons over the macro-states,
242 // so the second level decouples nothing: the reference exposes no
243 // way to supply a real macro-macro partition, and a two-level method
244 // whose coarse level is singletons is Courtois plus one extra solve.
245 MacroPartition MSS(MS.size());
246 for (std::size_t i = 0; i < MS.size(); ++i) MSS[i] = std::vector<std::size_t>{i};
247 const mc::MultiResult<T> r = mc::ctmc_multi(Q, MS, MSS);
248 out.p = r.p;
249 out.eps = r.eps;
250 out.epsMAX = r.epsMAX;
251 out.q = qdefault;
252 } else {
253 throw UnsupportedError(
254 "SolverENV compression: unknown decomposition '" + opt.da +
255 "'; options.config.da is one of courtois, kms, takahashi, multi");
256 }
257 }
258 return out;
259}
260
261/**
262 * `E0`, the environment's rate matrix: `E0(e,h) = env{e,h}.getRate()`.
263 *
264 * `getRate()` is the RECIPROCAL MEAN of the transition, so a general Markovian
265 * arc collapses to a single rate here and everything downstream treats the
266 * environment as a CTMC. That is the reference's own reading and it is why
267 * `env_compress` refuses a non-exponential environment by name: the collapse is
268 * harmless for the NCD diagnostics, which only ever look at Eutil, but it is
269 * not harmless once the macro arcs are rebuilt from it.
270 */
271template <class T>
273 const std::size_t E = e.nstages();
275 for (std::size_t a = 0; a < E; ++a)
276 for (std::size_t b = 0; b < E; ++b)
277 if (e.arc(a, b).enabled) E0(a, b) = e.arc(a, b).dist.rate();
278 return E0;
279}
280
281/**
282 * Port of `findBestPartition`, the small-environment search.
283 *
284 * ITS COMMENT CLAIMS AN EXHAUSTIVE SEARCH OVER ALL PARTITIONS AND THE CODE DOES
285 * NOT DO THAT. It evaluates the singletons and then every single pairwise merge
286 * of them, so it explores E(E-1)/2 + 1 partitions out of the Bell number of
287 * them and can never return a macro-state of more than two stages. The port is
288 * literal, because the alternative is a different method wearing the reference's
289 * name; a caller who wants deeper merging has `beam_above_stages` and
290 * `EnvCompressOptions::partition`.
291 */
292template <class T>
294 const std::size_t E = Eutil.rows();
295 MacroPartition best = meanfield_detail::singletons(E);
296 EnvDecomp<T> b = env_ctmc_decompose(Eutil, best, opt);
297 double best_eps = num_traits<T>::to_double(b.eps);
298 if (std::isnan(best_eps)) return best;
299
300 for (std::size_t i = 0; i < E; ++i)
301 for (std::size_t j = i + 1; j < E; ++j) {
302 const MacroPartition trial =
303 meanfield_detail::merge_blocks(meanfield_detail::singletons(E), i, j);
304 const EnvDecomp<T> t = env_ctmc_decompose(Eutil, trial, opt);
305 const double te = num_traits<T>::to_double(t.eps);
306 if (!std::isnan(te) && te < best_eps) {
307 best_eps = te;
308 best = trial;
309 }
310 }
311 return best;
312}
313
314/**
315 * Port of `beamSearchPartition`, the large-environment search: repeatedly merge
316 * two blocks, keeping the `beam_width` cheapest partitions at each depth.
317 *
318 * THE COST AND THE INCUMBENT ARE NOT THE SAME QUANTITY, in the reference. The
319 * incumbent `bestEps` is seeded with the raw eps of the singleton partition,
320 * and thereafter compared against `childEps - childEpsMax + alpha * depth`,
321 * which is a penalized score and not an eps at all. A merge is therefore
322 * adopted partly on the strength of its epsMAX and of how deep it sits, against
323 * a threshold that measured neither. This is ported literally rather than
324 * repaired: the search is a heuristic whose output is checked afterwards
325 * against eps <= epsMAX, so the comparison decides which candidate is tried and
326 * not whether the result is admissible.
327 */
328template <class T>
330 const std::size_t E = Eutil.rows();
331 std::vector<MacroPartition> beam{meanfield_detail::singletons(E)};
332 MacroPartition best = beam[0];
333 double best_cost = num_traits<T>::to_double(env_ctmc_decompose(Eutil, best, opt).eps);
334
335 for (std::size_t depth = 1; depth < E; ++depth) {
336 std::vector<std::pair<double, MacroPartition>> cand;
337 for (const MacroPartition& ms : beam) {
338 for (std::size_t i = 0; i < ms.size(); ++i)
339 for (std::size_t j = i + 1; j < ms.size(); ++j) {
340 const MacroPartition trial = meanfield_detail::merge_blocks(ms, i, j);
341 const EnvDecomp<T> t = env_ctmc_decompose(Eutil, trial, opt);
342 const double te = num_traits<T>::to_double(t.eps);
343 if (std::isnan(te) || !(te > 0.0)) continue;
344 const double cost = te - num_traits<T>::to_double(t.epsMAX) +
345 opt.env_alpha * static_cast<double>(depth);
346 cand.push_back(std::make_pair(cost, trial));
347 if (cost < best_cost) {
348 best_cost = cost;
349 best = trial;
350 }
351 }
352 }
353 if (cand.empty()) break;
354 // Stable, so that ties keep the order the merges were generated in and
355 // the search is reproducible across runs.
356 std::stable_sort(cand.begin(), cand.end(),
357 [](const std::pair<double, MacroPartition>& a,
358 const std::pair<double, MacroPartition>& b) { return a.first < b.first; });
359 beam.clear();
360 for (std::size_t i = 0; i < opt.beam_width && i < cand.size(); ++i)
361 beam.push_back(cand[i].second);
362 }
363 return best;
364}
365
366/** Everything `applyCompression` computes, plus the compressed environment. */
367template <class T>
370 /**
371 * The compressed environment. HELD BY SHARED POINTER because `SolverEnv<T>`
372 * stores a reference to the environment it solves, so the compressed one has
373 * to outlive the solver; returning it by value would make that the caller's
374 * problem to get right, and getting it wrong is a dangling reference rather
375 * than a wrong number.
376 */
377 std::shared_ptr<Environment<T>> env;
379 Matrix<T> macro_rate; ///< `computeMacroRate(i,j)`
380 std::vector<T> p; ///< micro stationary vector from the decomposition
381 std::vector<T> pmicro; ///< within-macro-state conditional probabilities
382 std::vector<T> pmacro; ///< `pMacro`, the macro-state probabilities
383 Matrix<double> prob_orig; ///< the macro embedding weights `newEmbweight`
385 /** eps <= epsMAX: below this the aggregation is meaningful, above it is not. */
386 bool compressible = false;
387};
388
389/**
390 * Port of `applyCompression`: pick a partition, decompose, and build the
391 * macro-state environment.
392 *
393 * WHY THE MACRO SERVICE RATES ARE A pmicro-WEIGHTED AVERAGE. Within a
394 * macro-state the environment switches fast compared with the network, so the
395 * network sees the group's rates averaged over the CONDITIONAL distribution of
396 * being in each micro-stage given the group -- which is exactly pmicro. That
397 * average is over rates and not over distributions, so a phase-type service
398 * collapses to an exponential of the same mean: the compression keeps the first
399 * moment and discards the SCV, as the reference's `Exp(rateSum)` does.
400 */
401template <class T>
403 const std::size_t E = e0.nstages();
404 const T zero = num_traits<T>::from_int(0);
405
406 // A MACRO-STATE IS A NETWORK AT AVERAGED RATES, so every stage merged into
407 // one has to have a station rate table to average. A layered stage does not:
408 // its stations are the layers SolverLN derives from it, and averaging those
409 // would aggregate an artifact of the layering rather than the model.
411 "SolverENV compression",
412 "a macro-state is built as one stage network carrying the pmicro-weighted average of "
413 "its members' station rates, and a layered model has no such rate table -- only the "
414 "layers SolverLN derives from it");
415
416 // The whole construction reads the environment as a CTMC, so a transition
417 // that is not exponential cannot survive it: the macro arc would be built as
418 // an Exp of the aggregated rate, and the analyzer would then integrate the
419 // stage transient against a holding-time CDF the model never had.
420 for (std::size_t a = 0; a < E; ++a)
421 for (std::size_t b = 0; b < E; ++b) {
422 if (!e0.arc(a, b).enabled) continue;
423 if (e0.arc(a, b).dist.type != lang::ProcessType::EXP)
424 throw UnsupportedError(
425 "SolverENV compression: the transition from stage " + std::to_string(a + 1) +
426 " to " + std::to_string(b + 1) +
427 " is not exponential, and the NCD decomposition reads the environment as a "
428 "CTMC through E0 = getRate(); aggregating it would silently replace the "
429 "transition by an exponential of the same mean, so it is refused instead");
430 }
431
433 c.E0 = env_rate_matrix(e0);
435
436 if (!opt.partition.empty()) {
437 meanfield_detail::check_partition(opt.partition, E);
438 c.MS = opt.partition;
439 } else if (E <= opt.beam_above_stages) {
441 } else {
443 }
444 const std::size_t Ec = c.MS.size();
445
446 const EnvDecomp<T> d = env_ctmc_decompose(c.Eutil, c.MS, opt);
447 c.p = d.p;
448 c.eps = d.eps;
449 c.epsMAX = d.epsMAX;
450 c.q = d.q;
451 // The reference warns and continues. The flag is reported rather than
452 // thrown for the same reason: an environment that does not decompose still
453 // has an answer, it is simply the answer to a model the aggregation moved.
455
456 c.pmacro.assign(Ec, zero);
457 for (std::size_t i = 0; i < Ec; ++i)
458 for (std::size_t s : c.MS[i]) c.pmacro[i] += c.p[s];
459 c.pmicro.assign(E, zero);
460 for (std::size_t i = 0; i < Ec; ++i) {
461 if (num_traits<T>::to_double(c.pmacro[i]) <= 0) continue;
462 for (std::size_t s : c.MS[i]) c.pmicro[s] = T(c.p[s] / c.pmacro[i]);
463 }
464
465 // computeMacroRate: the micro rates out of the block, weighted by the
466 // conditional probability of sitting in each of its micro-stages.
467 c.macro_rate = Matrix<T>(Ec, Ec, zero);
468 for (std::size_t i = 0; i < Ec; ++i)
469 for (std::size_t j = 0; j < Ec; ++j)
470 for (std::size_t mi : c.MS[i])
471 for (std::size_t mj : c.MS[j]) c.macro_rate(i, j) += T(c.pmicro[mi] * c.E0(mi, mj));
472
473 // newEmbweight: P(the previous macro-state was k | now entering e).
474 c.prob_orig = Matrix<double>(Ec, Ec, 0.0);
475 for (std::size_t x = 0; x < Ec; ++x) {
476 double tot = 0.0;
477 for (std::size_t h = 0; h < Ec; ++h)
478 if (h != x)
479 tot += num_traits<T>::to_double(c.pmacro[h]) *
481 if (!(tot > 0.0)) continue;
482 for (std::size_t k = 0; k < Ec; ++k) {
483 if (k == x) continue;
486 }
487 }
488
489 // The macro networks: the first micro-stage's structure, its rates replaced
490 // by the pmicro-weighted averages over the block.
491 c.env = std::make_shared<Environment<T>>(e0.name() + "-compressed", Ec);
492 for (std::size_t i = 0; i < Ec; ++i) {
493 const std::size_t first = c.MS[i][0];
494 qn::NetworkStruct<T> sn = e0.stage(first).model;
495 const std::size_t M = sn.nstations, K = sn.nclasses;
496 for (std::size_t s : c.MS[i])
497 if (e0.stage(s).model.nstations != M || e0.stage(s).model.nclasses != K)
498 throw InputError(
499 "SolverENV compression: macro-state " + std::to_string(i + 1) +
500 " merges stages with different stations or classes, whose rates cannot be "
501 "averaged entrywise");
502 for (std::size_t m = 0; m < M; ++m) {
503 const lang::NodeType nt = sn.stations[m].nodetype;
504 // Only a Queue or a Delay has a service rate to average; a Source
505 // carries an arrival process and a Join an infinite rate, and the
506 // reference skips both.
507 if (nt != lang::NodeType::Queue && nt != lang::NodeType::Delay) continue;
508 for (std::size_t k = 0; k < K; ++k) {
509 T acc = zero;
510 for (std::size_t s : c.MS[i])
511 acc += T(c.pmicro[s] * e0.stage(s).model.rates(m, k));
512 if (num_traits<T>::to_double(acc) > 0) sn.service[m][k] = lang::Distrib<T>::exp_rate(acc);
513 }
514 }
515 sn.refresh_struct();
516 c.env->set_stage(i, e0.stage(first).name + "+", e0.stage(first).type, sn);
517 }
518
519 for (std::size_t i = 0; i < Ec; ++i) {
520 bool any_out = false;
521 for (std::size_t j = 0; j < Ec; ++j) {
522 if (i == j) continue; // a self-loop would lengthen the holding time
523 if (!(num_traits<T>::to_double(c.macro_rate(i, j)) > 0)) continue;
524 // The reset policy of the representative micro arc. Two micro arcs
525 // folding into one macro arc may carry DIFFERENT resets and a
526 // std::function cannot be compared, so disagreement is detectable
527 // only in whether a reset is present at all; that much is refused,
528 // and beyond it the representative stands.
529 const std::size_t fi = c.MS[i][0], fj = c.MS[j][0];
530 const bool want = static_cast<bool>(e0.arc(fi, fj).reset);
531 for (std::size_t a : c.MS[i])
532 for (std::size_t b : c.MS[j])
533 if (e0.arc(a, b).enabled && static_cast<bool>(e0.arc(a, b).reset) != want)
534 throw UnsupportedError(
535 "SolverENV compression: the arcs folding into the macro transition " +
536 std::to_string(i + 1) + " -> " + std::to_string(j + 1) +
537 " do not agree on whether a reset policy applies, and one macro arc "
538 "can carry only one; split the partition so that reset policies are "
539 "uniform within it");
540 c.env->add_transition(i, j, lang::Distrib<T>::exp_rate(c.macro_rate(i, j)),
541 e0.arc(fi, fj).reset);
542 any_out = true;
543 }
544 if (!any_out)
545 throw InputError(
546 "SolverENV compression: macro-state " + std::to_string(i + 1) +
547 " has no outgoing transition, so the compressed environment is absorbing; the "
548 "partition merged a whole recurrent class into one block");
549 }
550 return c;
551}
552
553/**
554 * `probEnv = pMacro` and `probOrig = newEmbweight`, the two quantities
555 * `applyCompression` overwrites on the environment.
556 *
557 * ORDER MATTERS: `SolverEnv<T>`'s constructor calls `Environment::init()`,
558 * which recomputes both from the macro arcs, so this must be applied AFTER the
559 * solver is constructed and BEFORE `solve()` is called. `solver_env_meanfield`
560 * below does exactly that, and is the reason to prefer it over wiring the two
561 * calls by hand.
562 *
563 * The two are consistent rather than contradictory: for an exponential
564 * environment `Environment::init()` derives probEnv as the stationary law of
565 * the macro generator, and aggregating a chain by its exact conditional
566 * distributions reproduces the block sums of the original stationary law
567 * exactly. So this overwrite replaces one estimate of the same quantity by
568 * another, and the gap between them is a second reading of the decomposition
569 * error alongside eps.
570 */
571template <class T>
573 const std::size_t Ec = c.MS.size();
574 if (e.nstages() != Ec)
575 throw InputError(
576 "SolverENV compression: the macro probabilities do not match the environment they "
577 "are being applied to");
578 e.prob_env.assign(Ec, 0.0);
579 for (std::size_t i = 0; i < Ec; ++i) e.prob_env[i] = num_traits<T>::to_double(c.pmacro[i]);
580 e.prob_orig = c.prob_orig;
581}
582
583/** A mean-field solve, with the compression that produced it. */
584template <class T>
586 EnvSolution avg; ///< what SolverEnv reported
588 bool compressed = false; ///< false when the solve ran on the original stages
589 /**
590 * `aggregateCacheMeanfield_`: environment-blended hit and miss probabilities
591 * per Cache node. Empty when the model holds no Cache. The reference writes
592 * these onto the stage-one node objects with `setResultHitProb`; this port
593 * has no model-object layer at this level, so they ride in the result.
594 *
595 * KEYED BY NAME, in `CacheMetrics`' one shape rather than an ENV-private
596 * one: the statevec coupling and the closed-form limits report the same
597 * surface, and three shapes for one answer is how a host ends up writing
598 * cache results onto a Sink. A field no sweep produced stays EMPTY, and
599 * empty means not computed rather than zero.
600 */
602};
603
604namespace meanfield_detail {
605
606/** 0-based Cache node indices of a stage, in `find(nodetype == Cache)` order. */
607template <class T>
608std::vector<std::size_t> cache_nodes_of(const qn::NetworkStruct<T>& sn) {
609 std::vector<std::size_t> out;
610 for (std::size_t ind = 0; ind < sn.nodes.size(); ++ind)
611 if (sn.nodes[ind].nodetype == lang::NodeType::Cache) out.push_back(ind);
612 return out;
613}
614
615/**
616 * `aggregateCacheMeanfield_`: the environment-blended hit and miss ratios of
617 * every Cache, by a mean-field fixed point over the caches' OWN occupancies.
618 *
619 * THIS IS A SECOND FIXED POINT, nested beside the queue-length one, and it
620 * exists because the two carry different objects. `SolverEnv`'s coupling hands
621 * each stage the marginal mean queue lengths its predecessors left; a cache's
622 * state is its per-item occupancy, which no queue length encodes. So this sweep
623 * integrates each stage's cache drift over its sojourn from an entry occupancy
624 * that mixes its predecessors' exit occupancies by `prob_orig`, exactly the way
625 * the queue-length handoff mixes means, and iterates the pair to convergence.
626 *
627 * THE RMF DRIFT RUNS IN PER-REQUEST TIME, NOT REAL TIME, which is the trap here.
628 * `solver_fld_cacheqn_tran` returns a grid in units of cache requests, so the
629 * holding-time CDF cannot be evaluated on it directly: the grid is divided by
630 * the cache's total arrival rate first (`treal = t / Lam`). Skipping that
631 * weights every stage as though its cache saw the same request rate, which
632 * silently favours the slow stages.
633 *
634 * THE RATIO IS TAKEN AFTER THE BLEND, as in the state-vector twin: a per-stage
635 * ratio weighted by prob_env averages ratios, which is not the ratio the
636 * environment exhibits unless every stage carries the same total rate.
637 */
638template <class T>
639solvers::CacheMetrics<T> aggregate_cache_meanfield(Environment<T>& e, const EnvOptions& o) {
641 const std::size_t E = e.nstages();
642 if (E == 0) return out;
643 // The blend is over the Cache NODES of the stage networks; a layered
644 // environment has none, and reading an absent NetworkStruct would report an
645 // empty node list as though the model had been examined.
646 if (e.has_lqn_stages()) return out;
647 const qn::NetworkStruct<T>& sn1 = e.stage(0).model;
648 const std::vector<std::size_t> nodes = cache_nodes_of(sn1);
649 if (nodes.empty()) return out;
650 const std::size_t nc = nodes.size(), K = sn1.nclasses;
651
652 // Only a FLUID stage exposes the RMF cache transient this reads; the
653 // reference returns without writing anything when any stage is not one,
654 // rather than blending what it has. Unreachable from the two entry points,
655 // which `refuse_cache_stages` guards by name first, and kept because this
656 // helper is the reference's own guard and not only theirs.
657 if (o.stage_solver != "fluid") return out;
658
659 // A finite window per stage, falling back to a few mean holding times when
660 // the inner solver left the timespan open, as the reference does.
661 std::vector<double> tend(E, 0.0);
662 for (std::size_t s = 0; s < E; ++s) {
663 double t1 = o.timespan_end;
664 if (!std::isfinite(t1) || !(t1 > 0.0)) t1 = 20.0 * mam::map_mean(e.hold_time[s].map());
665 tend[s] = t1;
666 }
667
668 std::vector<std::vector<std::vector<T> > > entry(E, std::vector<std::vector<T> >(nc));
669 std::vector<std::vector<fluid::FluidCacheqnTranCache<T> > > tran(E);
670 std::vector<std::vector<std::vector<T> > > wmass(E, std::vector<std::vector<T> >(nc));
671 std::vector<std::vector<std::vector<T> > > exit_occ(E, std::vector<std::vector<T> >(nc));
672 std::vector<T> prev_flat;
673 const int sweeps = o.iter_max > 0 ? o.iter_max : 1;
674
675 for (int sweep = 0; sweep < sweeps; ++sweep) {
676 for (std::size_t s = 0; s < E; ++s) {
677 fluid::FluidOptions fo;
678 fo.method = "rmf";
679 tran[s] = fluid::solver_fld_cacheqn_tran(e.stage(s).model, fo, 0.0, tend[s], entry[s]);
680 for (std::size_t c = 0; c < nc; ++c) {
681 std::size_t idx = tran[s].size();
682 for (std::size_t q = 0; q < tran[s].size(); ++q)
683 if (tran[s][q].node == nodes[c]) idx = q;
684 if (idx == tran[s].size()) continue;
685 const fluid::FluidCacheqnTranCache<T>& tc = tran[s][idx];
686 double Lam = 0.0;
687 for (std::size_t k = 0; k < tc.arate.size(); ++k)
688 Lam += num_traits<T>::to_double(tc.arate[k]);
689 if (!(Lam > 0.0)) continue;
690
691 // PER-REQUEST TIME -> REAL TIME before the CDF is evaluated.
692 std::vector<double> treal(tc.t.size(), 0.0);
693 for (std::size_t j = 0; j < tc.t.size(); ++j)
694 treal[j] = num_traits<T>::to_double(tc.t[j]) / Lam;
695 const std::vector<double> F = mam::map_cdf(e.hold_time[s].map(), treal);
696 std::vector<T> w(treal.size(), num_traits<T>::from_int(0));
697 for (std::size_t j = 1; j < treal.size(); ++j)
698 w[j] = num_traits<T>::from_double(F[j] - F[j - 1]);
699 wmass[s][c] = w;
700
701 double sw = 0.0;
702 for (std::size_t j = 0; j < w.size(); ++j) sw += num_traits<T>::to_double(w[j]);
703 if (!(sw > 0.0) || tc.xocc.rows() == 0) continue;
704 std::vector<T> xo(tc.xocc.rows(), num_traits<T>::from_int(0));
705 for (std::size_t a = 0; a < tc.xocc.rows(); ++a) {
706 T acc = num_traits<T>::from_int(0);
707 for (std::size_t j = 0; j < w.size() && j < tc.xocc.cols(); ++j)
708 acc = T(acc + T(tc.xocc(a, j) * w[j]));
709 xo[a] = T(acc / num_traits<T>::from_double(sw));
710 }
711 exit_occ[s][c] = xo;
712 }
713 }
714
715 // Each stage's entry occupancy is its predecessors' exits, by prob_orig.
716 std::vector<std::vector<std::vector<T> > > next(E, std::vector<std::vector<T> >(nc));
717 for (std::size_t s = 0; s < E; ++s)
718 for (std::size_t c = 0; c < nc; ++c) {
719 std::vector<T> acc;
720 for (std::size_t h = 0; h < E; ++h) {
721 const double po = e.prob_orig(h, s);
722 if (!(po > 0.0) || exit_occ[h][c].empty()) continue;
723 if (acc.empty()) acc.assign(exit_occ[h][c].size(), num_traits<T>::from_int(0));
724 if (acc.size() != exit_occ[h][c].size()) continue;
725 for (std::size_t a = 0; a < acc.size(); ++a)
726 acc[a] = T(acc[a] + T(num_traits<T>::from_double(po) * exit_occ[h][c][a]));
727 }
728 next[s][c] = acc;
729 }
730
731 std::vector<T> flat;
732 for (std::size_t s = 0; s < E; ++s)
733 for (std::size_t c = 0; c < nc; ++c)
734 flat.insert(flat.end(), next[s][c].begin(), next[s][c].end());
735 entry = next;
736 if (!prev_flat.empty() && prev_flat.size() == flat.size()) {
737 double dmax = 0.0;
738 for (std::size_t a = 0; a < flat.size(); ++a)
739 dmax = std::max(dmax, std::fabs(num_traits<T>::to_double(flat[a]) -
740 num_traits<T>::to_double(prev_flat[a])));
741 prev_flat = flat;
742 if (dmax < o.iter_tol) break;
743 } else {
744 prev_flat = flat;
745 }
746 }
747
748 const T nan = num_traits<T>::from_double(std::numeric_limits<double>::quiet_NaN());
749 std::vector<std::vector<T> > hitprob(nc, std::vector<T>(K, nan));
750 std::vector<std::vector<T> > missprob(nc, std::vector<T>(K, nan));
751 for (std::size_t c = 0; c < nc; ++c) {
752 std::vector<T> hitT(K, num_traits<T>::from_int(0)), missT(K, num_traits<T>::from_int(0));
753 for (std::size_t s = 0; s < E; ++s) {
754 if (wmass[s][c].empty()) continue;
755 double sw = 0.0;
756 bool ok = true;
757 for (std::size_t j = 0; j < wmass[s][c].size(); ++j) {
758 const double v = num_traits<T>::to_double(wmass[s][c][j]);
759 if (!std::isfinite(v)) ok = false;
760 sw += v;
761 }
762 if (!ok || !(sw > 0.0)) continue;
763 std::size_t idx = tran[s].size();
764 for (std::size_t q = 0; q < tran[s].size(); ++q)
765 if (tran[s][q].node == nodes[c]) idx = q;
766 if (idx == tran[s].size()) continue;
767 const fluid::FluidCacheqnTranCache<T>& tc = tran[s][idx];
768 const T pe = num_traits<T>::from_double(e.prob_env[s]);
769 for (std::size_t k = 0; k < K && k < tc.arate.size(); ++k) {
770 if (!(num_traits<T>::to_double(tc.arate[k]) > 0.0)) continue;
771 T hbar = num_traits<T>::from_int(0), mbar = num_traits<T>::from_int(0);
772 for (std::size_t j = 0; j < wmass[s][c].size() && j < tc.hitprob_t.cols(); ++j) {
773 hbar = T(hbar + T(tc.hitprob_t(k, j) * wmass[s][c][j]));
774 mbar = T(mbar + T(tc.missprob_t(k, j) * wmass[s][c][j]));
775 }
776 const T swT = num_traits<T>::from_double(sw);
777 hitT[k] = T(hitT[k] + T(T(pe * tc.arate[k]) * T(hbar / swT)));
778 missT[k] = T(missT[k] + T(T(pe * tc.arate[k]) * T(mbar / swT)));
779 }
780 }
781 for (std::size_t k = 0; k < K; ++k) {
782 const T tot = T(hitT[k] + missT[k]);
783 if (num_traits<T>::to_double(tot) > 0.0) {
784 hitprob[c][k] = T(hitT[k] / tot);
785 missprob[c][k] = T(missT[k] / tot);
786 }
787 }
788 }
789
790 // The model's caches, in the same `find(nodetype == Cache)` order the sweep
791 // used, so entry c is `nodes[c]`; the caps and item counts come from the
792 // struct and the two measured vectors from the blend above. DELAYED-HIT AND
793 // HIT-BY-LIST STAY EMPTY here and that is the answer, not a gap: the RMF
794 // drift models no retrieval sub-system, so there is no delayed fraction to
795 // report, and the per-list occupancy is a per-item law this blend averages
796 // over the sojourn rather than a per-class attribution.
797 out = solvers::cache_metrics_of(sn1, std::vector<T>(), std::vector<T>(), std::vector<T>(),
798 std::vector<T>(), Matrix<T>(), Matrix<T>(), std::vector<T>());
799 for (std::size_t c = 0; c < nc && c < out.caches.size(); ++c) {
800 out.caches[c].hitprob = hitprob[c];
801 out.caches[c].missprob = missprob[c];
802 }
803 return out;
804}
805
806/** A Cache in a stage is only servable by the fluid backend; name the other case. */
807template <class T>
808void refuse_cache_stages(const Environment<T>& e, const EnvOptions& o) {
809 if (o.stage_solver == "fluid") return;
810 for (std::size_t s = 0; s < e.nstages(); ++s) {
811 // A layered stage carries no NetworkStruct at all, so there is no node
812 // table to scan; a Cache inside an LQN is a CacheTask, which lives in
813 // its host's LAYER and is SolverLN's to serve.
814 if (e.is_lqn(s)) continue;
815 const qn::NetworkStruct<T>& sn = e.stage(s).model;
816 for (std::size_t ind = 0; ind < sn.nodes.size(); ++ind)
817 if (sn.nodes[ind].nodetype == lang::NodeType::Cache)
818 throw UnsupportedError(
819 "SolverENV meanfield: stage " + std::to_string(s + 1) +
820 " holds a Cache, whose environment-blended hit and miss ratios come from "
821 "aggregateCacheMeanfield_ and its per-sweep solver_fld_cacheqn_tran RMF "
822 "transient; that transient exists only for a FLUID stage solver, and this "
823 "ensemble runs '" +
824 o.stage_solver + "' stages");
825 }
826}
827
828} // namespace meanfield_detail
829
830/** The mean-field solve on the original stages, with no compression. */
831template <class T>
833 meanfield_detail::refuse_cache_stages(e, o);
835 SolverEnv<T> s(e, o);
836 out.avg = s.solve();
837 out.cache = meanfield_detail::aggregate_cache_meanfield(e, o);
838 return out;
839}
840
841/**
842 * The compressed mean-field solve: aggregate the environment, then run the
843 * mean-field fixed point over the macro-states.
844 *
845 * The compressed environment is kept alive by the returned structure, which is
846 * what `SolverEnv` held a reference to; reading `.avg` out of the result and
847 * discarding the rest is safe, but the compression it came from travels with it
848 * so that eps and epsMAX can be checked against the numbers they produced.
849 */
850template <class T>
852 const EnvCompressOptions& c) {
853 meanfield_detail::refuse_cache_stages(e, o);
855 out.compression = env_compress(e, c);
856 out.compressed = true;
857 SolverEnv<T> s(*out.compression.env, o);
859 out.avg = s.solve();
860 out.cache = meanfield_detail::aggregate_cache_meanfield(*out.compression.env, o);
861 return out;
862}
863
864} // namespace env
865} // namespace line
866
867#endif // LINE_SOLVERS_ENV_SOLVER_ENV_MEANFIELD_H
What a solver observed about the Cache nodes of a model.
InputError(const std::string &what)
Definition error.h:39
std::size_t cols() const
Definition matrix.h:90
std::size_t rows() const
Definition matrix.h:89
bool empty() const
Definition matrix.h:92
UnsupportedError(const std::string &what)
Definition error.h:51
const std::string & name() const
const EnvStage< T > & stage(std::size_t e) const
Matrix< double > prob_orig
probOrig(h, e)
std::size_t nstages() const
void reject_lqn_stages(const std::string &who, const std::string &why) const
Refuse an environment carrying a LayeredNetwork stage, by name.
const EnvArc< T > & arc(std::size_t e, std::size_t h) const
std::vector< double > prob_env
probEnv
The environment solver.
Definition solver_env.h:229
EnvSolution solve()
Definition solver_env.h:233
A network plus its refreshed NetworkStruct.
Courtois decomposition of a nearly completely decomposable (NCD) CTMC.
Koury-McAllister-Stewart aggregation-disaggregation for a nearly completely decomposable CTMC.
Two-level multigrid aggregation-disaggregation for a nearly completely decomposable CTMC.
Steady-state distribution of a continuous-time Markov chain.
Takahashi's aggregation-disaggregation for a nearly completely decomposable CTMC.
A random environment: a port of matlab/src/lang/Environment.m, restricted to what SolverENV reads out...
The exception types the port throws.
The INTEGRATED caching-queueing network under the fluid solver: ports of solver_fld_cacheqn_analyzer....
Cumulative distribution of the inter-arrival time of a MAP.
Markovian arrival process descriptors: stationary vectors, rate, moments, autocorrelation and the ind...
Dense matrix and non-owning view.
std::vector< std::vector< std::size_t > > MacroPartition
A partition of the stage indices 0..E-1 into macro-states, MATLAB's MS.
EnvDecomp< T > env_ctmc_decompose(const Matrix< T > &Q, const MacroPartition &MS, const EnvCompressOptions &opt)
Port of SolverENV.ctmc_decompose: one NCD decomposition, by whichever kernel options....
MacroPartition env_beam_search_partition(const Matrix< T > &Eutil, const EnvCompressOptions &opt)
Port of beamSearchPartition, the large-environment search: repeatedly merge two blocks,...
MacroPartition env_find_best_partition(const Matrix< T > &Eutil, const EnvCompressOptions &opt)
Port of findBestPartition, the small-environment search.
Matrix< T > env_rate_matrix(const Environment< T > &e)
E0, the environment's rate matrix: E0(e,h) = env{e,h}.getRate().
EnvMeanfieldSolution< T > solver_env_meanfield(Environment< T > &e, const EnvOptions &o)
The mean-field solve on the original stages, with no compression.
EnvCompression< T > env_compress(const Environment< T > &e0, const EnvCompressOptions &opt)
Port of applyCompression: pick a partition, decompose, and build the macro-state environment.
void env_apply_macro_probabilities(Environment< T > &e, const EnvCompression< T > &c)
probEnv = pMacro and probOrig = newEmbweight, the two quantities applyCompression overwrites on the e...
std::vector< FluidCacheqnTranCache< T > > solver_fld_cacheqn_tran(const qn::NetworkStruct< T > &sn, const FluidOptions &opt, double t0, double t1, const std::vector< std::vector< T > > &x0cell=std::vector< std::vector< T > >())
Port of solver_fld_cacheqn_tran.m: the transient counterpart of the analyzer above.
NodeType
Node kinds, with the values of MATLAB NodeType.
Definition lang_types.h:326
T map_mean(const Map< T > &m)
Mean inter-arrival time, 1/lambda.
Definition map_moment.h:101
std::vector< T > map_cdf(const Map< T > &m, const std::vector< T > &points)
Cumulative distribution of the inter-arrival time at the given points.
Definition map_cdf.h:63
Matrix< T > ctmc_makeinfgen(const Matrix< T > &Q)
Set the diagonal so that every row sums to zero (ctmc_makeinfgen).
Definition ctmc_solve.h:58
KmsResult< T > ctmc_kms(const Matrix< T > &Q, const std::vector< std::vector< std::size_t > > &MS, std::size_t numSteps)
Koury-McAllister-Stewart aggregation-disaggregation for a nearly completely decomposable CTMC.
Definition ctmc_kms.h:100
TakahashiResult< T > ctmc_takahashi(const Matrix< T > &Q, const std::vector< std::vector< std::size_t > > &MS, std::size_t numSteps, double massTol=1e-14)
Takahashi's aggregation-disaggregation for a nearly completely decomposable CTMC.
MultiResult< T > ctmc_multi(const Matrix< T > &Q, const std::vector< std::vector< std::size_t > > &MS, const std::vector< std::vector< std::size_t > > &MSS, const T &q)
Two-level multigrid aggregation-disaggregation for a nearly completely decomposable CTMC.
Definition ctmc_multi.h:61
CourtoisResult< T > ctmc_courtois(const Matrix< T > &Q, const std::vector< std::vector< std::size_t > > &MS, const T &q)
Courtois decomposition of a nearly completely decomposable (NCD) CTMC.
CacheMetrics< T > cache_metrics_of(const qn::NetworkStruct< T > &sn, const std::vector< T > &hitprob, const std::vector< T > &missprob, const std::vector< T > &delayedprob, const std::vector< T > &latency, const Matrix< T > &hitproblist, const Matrix< T > &itemprob, const std::vector< T > &listcost)
Assemble CacheMetrics from what a cache analyzer returned.
Conservation laws of a layered queueing network, enumerated from its structure.
Definition aoi_dist2ph.h:52
T num_abs(const T &v)
Definition number.h:198
A queueing network and its refreshed NetworkStruct.
Number-type abstraction for the templated API port.
SolverENV: a queueing network in a random environment.
The knobs applyCompression reads out of options.config.
MacroPartition partition
A partition supplied by the caller, which SKIPS the search entirely.
double env_alpha
options.config.env_alpha: the beam search's per-depth merge penalty.
std::size_t beam_above_stages
Stage count above which the reference switches from the pairwise search to the beam search.
std::string da
options.config.da: courtois (the reference's default), kms, takahashi, multi.
std::size_t beam_width
Beam width, the reference's hard-coded B = 3.
std::size_t da_iter
options.config.da_iter: sweeps for the two iterative kernels.
Everything applyCompression computes, plus the compressed environment.
std::vector< T > pmicro
within-macro-state conditional probabilities
std::vector< T > pmacro
pMacro, the macro-state probabilities
bool compressible
eps <= epsMAX: below this the aggregation is meaningful, above it is not.
Matrix< T > macro_rate
computeMacroRate(i,j)
std::vector< T > p
micro stationary vector from the decomposition
Matrix< double > prob_orig
the macro embedding weights newEmbweight
std::shared_ptr< Environment< T > > env
The compressed environment.
What SolverENV.ctmc_decompose returns: [p, eps, epsMax, q].
std::vector< T > p
approximate stationary vector of the environment
A mean-field solve, with the compression that produced it.
solvers::CacheMetrics< T > cache
aggregateCacheMeanfield_: environment-blended hit and miss probabilities per Cache node.
bool compressed
false when the solve ran on the original stages
EnvSolution avg
what SolverEnv reported
Options of SolverENV.
Definition solver_env.h:113
What SolverENV reports.
Definition solver_env.h:170
static Distrib exp_rate(const T &r)
Definition lang_types.h:945
T q
uniformization rate used
T eps
NCD index: largest ROW sum of B, ||B||_inf (MATLAB and the JAR).
std::vector< T > p
approximate stationary vector, ORIGINAL state ordering
T epsMAX
(1 - max subdominant block eigenvalue modulus) / 2
T epsMAX
maximum admissible NCD index
Definition ctmc_kms.h:63
T eps
NCD index, as ctmc_courtois defines it.
Definition ctmc_kms.h:62
std::vector< T > p
estimate after numSteps sweeps, ORIGINAL ordering
Definition ctmc_kms.h:58
std::vector< T > p
approximate stationary vector, ORIGINAL ordering
Definition ctmc_multi.h:44
T eps
NCD index of the fine level.
Definition ctmc_multi.h:47
T epsMAX
maximum admissible NCD index of the fine level
Definition ctmc_multi.h:48
T eps
NCD index, as ctmc_courtois defines it.
T epsMAX
maximum admissible NCD index
std::vector< T > p
estimate after numSteps sweeps
Every Cache node of the model, in node order; empty on a model with none.
std::vector< CacheNodeMetrics< T > > caches