LINE Solver (C++)
Templated C++ port of the LINE queueing solver
Loading...
Searching...
No Matches
solver_ln.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_LN_SOLVER_LN_H
6#define LINE_SOLVERS_LN_SOLVER_LN_H
7
8/**
9 * @file
10 * @ingroup line_solvers
11 * SolverLN: layered decomposition of a layered queueing network.
12 *
13 * Port of the matlab/src/solvers/LN SolverLN class folder -- construct, buildLayers,
14 * buildLayersRecursive, init, initInterlock, converged, analyze, post,
15 * updateMetrics, updatePopulations, updateThinkTimes, updateLayers,
16 * updateRoutingProbabilities, getEntryServiceMatrix and getEnsembleAvg --
17 * driven by the EnsembleSolver iteration in @@EnsembleSolver/iterate.m.
18 *
19 * The method is a Picard iteration over a fixed point. Each processor and each
20 * software task becomes a closed queueing network (a "layer") in which the
21 * element is the server and its callers are the customers; solving all layers
22 * gives new residence times, which become the service times of the calls one
23 * level up and the think times of the callers one level down, and the layers
24 * are re-solved until the queue lengths stop moving.
25 *
26 * WHAT IS IMPLEMENTED. Every path a plain layered model takes is here:
27 * synchronous calls, entry selection by throughput ratio, multi-entry tasks,
28 * reference tasks with think time, infinite-server tasks and processors,
29 * server replication, AND-forks and AND-joins, and the LQNS V5 interlock
30 * analysis. Forwarding is solved too: construct() runs the
31 * lqn_fwd_rendezvous rewrite of lqn_helpers.h (:306), which flattens every
32 * forwarding chain into caller-side pseudo rendezvous arcs, so nothing below
33 * this point needs a forwarding case. Asynchronous calls and entry open
34 * arrivals are solved by giving the layer its own Source/Sink pair and an
35 * open chain (build_layer's async_here/open_entries construction, roughly
36 * :622-668). Cache tasks and item entries are solved by a Cache node placed
37 * in the host layer, with the hit and miss branches switching class at the
38 * server (build_layer's iscachelayer path, :591-616 and the read walk at
39 * :883-908). Setup tasks with a setup time are solved by charging the cold
40 * start to the ENTRY, at the probability the thread was found powered down
41 * (setup_charge, added to entry_servt and to the caller's think time); no layer
42 * station carries a SetupDelayOffParam. Admission constraints are solved by a Region on
43 * the server station, which forces that layer's fixed point through
44 * SolverCTMC rather than MVA or fluid (:1051-1096, dispatch at :1753-1760).
45 * reject_unsupported() (:433) is kept but empty: every construct the
46 * reference LQN model can carry is now accounted for here. A few narrower
47 * combinations remain unsupported and are refused where they are discovered
48 * instead of up front: a fork sharing a layer with an open stream (:492-496),
49 * a fork's fixed point outside layer_solver 'mva' (:1723-1728), and a fluid
50 * layer or an AND-join's order-statistic fit under a non-double/non-real
51 * arithmetic backend (:120-126, :2198-2201).
52 *
53 * WHAT ELSE THIS CLASS ANSWERS, beyond the mean-value table. `options.method`
54 * selects between three DIFFERENT questions rather than three routes to one:
55 * `default` is the mean-based update; `moment3` is updateMetricsMomentBased,
56 * which fits an APH to each layer's response-time CDF and convolves along the
57 * entry's activity sequence, so it additionally reports a per-entry response
58 * time DISTRIBUTION through get_cdf_respt(); `mwba.upper` / `mwba.lower` report
59 * Majumdar-Woodside robust box bounds and never solve a layer at all.
60 * get_tran_avg() is the layered transient, decoupled (demands frozen at the
61 * fixed point) or coupled (waveform relaxation over time-varying demands
62 * injected through the fluid rate schedule). get_sensitivity_table() delegates
63 * to each layer's own sensitivity table; every derivative in it is a partial
64 * WITHIN its layer, not a total derivative of the layered model.
65 *
66 * THE LAYER ENGINE is `layer_solver`: `mva`, `nc`, `fluid` or `ssa`, the C++
67 * spelling of the reference's solver FACTORY. They converge to DIFFERENT fixed
68 * points, because each feeds different demands back into the next outer sweep.
69 * `ssa` is noisy, so the deterministic convergence test is replaced by
70 * LnStochController (Robbins-Monro relaxation, Polyak-Ruppert reporting);
71 * running it under the deterministic test would simply reach iter_max.
72 *
73 * PHASE-2 ACTIVITIES ARE SOLVED, not refused. `servt` keeps both phases, so the
74 * server's utilization is unchanged, while `residt` carries the CALLER's view:
75 * phase 1 in full plus the phase-2 time the caller is actually overtaken by,
76 * with the overtaking probability from lqn_analyzers.h's
77 * lqn_overtake_prob_markov. Every phase-2 branch below is gated on `has_phase2`
78 * and is inert on a single-phase model.
79 *
80 * ARITHMETIC. All model quantities are T. Iteration counters, populations,
81 * multiplicities and the convergence tolerances are double, matching the
82 * reference: they are properties of the algorithm and of the model's integer
83 * structure, not quantities whose precision is under study.
84 */
85
87#include <algorithm>
88#include <cctype>
89#include <cmath>
90#include <functional>
91#include <limits>
92#include <map>
93#include <memory>
94#include <set>
95#include <string>
96#include <vector>
97
102#include "line/api/mam/aph_fit.h"
114#include "line/api/lqn/lqn_ph.h"
124#include "line/util/error.h"
125#include "line/util/expm.h"
126#include "line/util/linalg.h"
127#include "line/util/matrix.h"
128
129namespace line {
130namespace ln {
131
132using lang::CallType;
133using lang::Distrib;
134using lang::GlobalConstants;
136using lang::LqnElement;
137using lang::NodeType;
141using lqn::LqnCallGroup;
142using lqn::LqnStruct;
143
144/** One (station, class) rate trajectory injected into a layer's closing ODE. */
146
147namespace detail {
148
149/**
150 * Run the fluid analyzer on one layer and write its metrics into the shape the
151 * MVA layer path returns.
152 *
153 * Overloaded rather than gated: LSODA is double-only, so the template below is
154 * what any other backend resolves to, and it refuses by name instead of failing
155 * to compile inside a branch that could never run.
156 */
157inline void ln_fluid_solve(const qn::NetworkStruct<double>& L, const fluid::FluidOptions& fo,
159 // THE RUNNER, NOT THE SWITCH. `solver_fluid` is the ungated port of
160 // solver_fluid_analyzer.m and cannot reach `minnormal`, `rmf`, `refined` or
161 // `kp` at all; `solver_fluid_run_analyzer` is the port of runAnalyzer's resolution,
162 // which is what `LN(model, @(m) Fluid(m))` runs in the reference. Calling
163 // the switch here silently ran the FIRST-ORDER `matrix` method in every
164 // layer: on lqn_basic that reported T3's residence as its bare demand
165 // (0.02 against MATLAB's 0.0206615) because the hard min charges no
166 // queueing below the server count, and the LN table then disagreed with
167 // MATLAB by 3.3% on a metric neither codebase flagged.
169 for (std::size_t i = 0; i < L.nstations; ++i)
170 for (std::size_t r = 0; r < L.nclasses; ++r) {
171 out.Q(i, r) = s.QN(i, r);
172 out.U(i, r) = s.UN(i, r);
173 out.R(i, r) = s.RN(i, r);
174 out.Tp(i, r) = s.TN(i, r);
175 }
176 for (std::size_t r = 0; r < L.nclasses && r < s.XN.size(); ++r) {
177 out.X[r] = s.XN[r];
178 out.C[r] = s.CN[r];
179 }
180 out.method = s.method;
181 out.iter = static_cast<int>(s.iters);
182}
183template <class T>
184void ln_fluid_solve(const qn::NetworkStruct<T>&, const fluid::FluidOptions&,
186 throw UnsupportedError(
187 "SolverLN: a fluid layer integrates its drift with LSODA, whose coefficients assume "
188 "double precision; rerun with --arith double or use layer_solver 'mva'");
189}
190
191/**
192 * The response-time CDF of every (station, class) of one layer, which is what
193 * `SolverFluid(ensemble{e}).getCdfRespT` returns in the reference.
194 *
195 * The layer is integrated once to its fixed point and the converged state is
196 * the marking the passage starts from, so the law reported is the STATIONARY
197 * response time and not the one seen from an arbitrary initial condition. That
198 * is `options.init_sol = odeStateVec` in `@@SolverFLD/getCdfRespT`.
199 *
200 * A pair the class never visits comes back as the degenerate curve at zero,
201 * which `fluid_passage_time` already returns; the moment extraction above reads
202 * it as a mean of zero and the caller drops the term, as MATLAB's
203 * `m1 > CoarseTol` test does.
204 */
205inline std::vector<std::vector<fluid::FluidPassage>> ln_fluid_cdf_respt(
207 fluid::FluidOptions o = fo;
208 // THE SWITCH HERE, DELIBERATELY, unlike ln_fluid_solve above. By the same
209 // argument this should be the runner, but the reference for `moment3` on
210 // this path is the JAR (MATLAB does not finish the method on the model the
211 // golden was taken from), and the JAR's entry response times agree with the
212 // first-order switch to 5e-3 and not with the resolved method, which moves
213 // E:e0 from 2.2748 to 2.4964. Switching it blind would replace a measured
214 // agreement with an unmeasured one; it needs a JAR-side reading first.
216 std::vector<std::vector<fluid::FluidPassage>> out(
217 L.nstations, std::vector<fluid::FluidPassage>(L.nclasses));
218 for (std::size_t i = 1; i <= L.nstations; ++i) {
219 if (L.stations[i - 1].nodetype == qn::NodeType::Source) continue;
220 for (std::size_t r = 1; r <= L.nclasses; ++r) {
221 if (L.disabled[i - 1][r - 1]) continue;
222 out[i - 1][r - 1] = fluid::fluid_passage_time(L, s.xvec, i, r, o.tol, 201, s.closure);
223 }
224 }
225 return out;
226}
227template <class T>
228std::vector<std::vector<fluid::FluidPassage>> ln_fluid_cdf_respt(const qn::NetworkStruct<T>&,
229 const fluid::FluidOptions&) {
230 throw UnsupportedError(
231 "SolverLN: the response-time CDF a layer contributes to the 'moment3' update is a fluid "
232 "passage time, integrated by LSODA in double precision; rerun with --arith double");
233}
234
235/**
236 * One layer's transient trajectory, the `getTranAvg` of a layer.
237 *
238 * `out_grid` REPLACES the uniform grid, and it is not a convenience: SolverENV
239 * sums a layered stage's exit average against the environment's holding-time
240 * CDF, so the trajectory has to be evaluated where that CDF puts its mass
241 * rather than where the horizon happens to spread points. It also makes every
242 * layer of one stage report on the SAME grid, which is what lets the layers be
243 * assembled into one block-diagonal transient with a single time base.
244 */
245inline std::vector<fluid::FluidTranPoint> ln_fluid_transient(
246 const qn::NetworkStruct<double>& L, const fluid::FluidOptions& fo, double t_end,
247 std::size_t points, const std::vector<double>& out_grid = std::vector<double>()) {
248 return fluid::solver_fluid_transient(L, fo, t_end, points, out_grid);
249}
250template <class T>
251std::vector<fluid::FluidTranPoint> ln_fluid_transient(const qn::NetworkStruct<T>&,
252 const fluid::FluidOptions&, double,
253 std::size_t,
254 const std::vector<double>& =
255 std::vector<double>()) {
256 throw UnsupportedError(
257 "SolverLN: the layered transient integrates each layer's drift with LSODA, which is "
258 "double precision by construction; rerun with --arith double");
259}
260
261} // namespace detail
262
263/** Options of SolverLN. Defaults are SolverOptions('LN'). */
264struct LnOptions {
265 int iter_max = 200;
266 double iter_tol = 5e-3;
267 double tol = 1e-4;
268 bool interlocking = true;
269 std::string relax = "fixed";
270 double relax_factor = 0.5;
271 /** Options handed to each layer solver; SolverMVA defaults. */
273 /**
274 * Which solver runs each layer: `mva`, `nc`, `fluid` or `ssa`.
275 *
276 * The reference names this by passing a solver FACTORY,
277 * `LN(model, @(m) Fluid(m, opt))`, so the choice is per-ensemble and not
278 * per-layer; this field is the same choice under a name, because a C++
279 * template cannot take a MATLAB function handle.
280 *
281 * THEY DO NOT CONVERGE TO THE SAME PLACE. Each engine feeds different
282 * service demands back into the next outer iteration, so the ensembles
283 * reach different fixed points rather than the same one by different
284 * routes. `ssa` in particular is a NOISY layer solver: the deterministic
285 * convergence test cannot terminate against its standard error, so it is
286 * driven with `LnStochController` (lqn_analyzers.h) instead.
287 */
288 std::string layer_solver = "mva";
289 /** Options handed to each layer when `layer_solver` is `fluid`. */
291 /** Options handed to each layer when `layer_solver` is `nc`. */
293 /** Options handed to each layer when `layer_solver` is `ssa`. */
295 /**
296 * `options.method`, which selects WHAT is reported and not merely how:
297 *
298 * `default` the mean-based update (updateMetricsDefault)
299 * `moment3` the APH moment-based update (updateMetricsMomentBased),
300 * which additionally produces a per-entry response-time
301 * distribution, `get_cdf_respt()`
302 * `mwba.upper` Majumdar-Woodside robust box bounds INSTEAD of a fixed
303 * `mwba.lower` point: no layer is ever solved, and every metric the
304 * bound does not define is reported as undefined
305 */
306 std::string method = "default";
307 /**
308 * `options.config.ln_transient`: how the per-layer transients are coupled.
309 * `decoupled` freezes the inter-layer demands at the converged fixed point;
310 * `coupled` reconciles them by waveform relaxation. Iteration 0 of the
311 * coupled relaxation IS the decoupled answer.
312 */
313 std::string ln_transient = "coupled";
314 /** `options.config.ln_transient_iter_max` and `..._tol` of the relaxation. */
316 double ln_transient_tol = 1e-2;
317 /**
318 * `options.config.ln_transient_channels`: which inter-layer coupling is
319 * injected, `both`, `thinkt` (client delay only) or `callservt`
320 * (synchronous-call service only). Used to isolate each channel's share of
321 * the coupled transient.
322 */
323 std::string ln_transient_channels = "both";
324 /** `options.timespan(2)`: the transient horizon; infinite means none is set. */
325 double timespan_end = std::numeric_limits<double>::infinity();
326 /** Output points per layer trajectory, and the relaxation's own grid size. */
327 std::size_t tran_points = 101;
328 /**
329 * An EXPLICIT output grid for the layered transient, replacing the uniform
330 * `tran_points` one when it is not empty.
331 *
332 * SolverENV is what asks for it: a layered stage's exit metric is a
333 * Riemann-Stieltjes sum against the environment's holding-time CDF, and a
334 * uniform grid over the horizon resolves the horizon rather than the
335 * sojourn. Interpolating afterwards cannot recover resolution the
336 * trajectory never had, so the points are asked for instead --
337 * `SolverEnv::stage_grid` builds them and `set_tran_grid` installs them.
338 * The grid must be increasing and start at zero; LSODA takes it as given.
339 */
340 std::vector<double> tran_grid;
341};
342
343/** Per-layer results of one iteration, the [QN,UN,RN,TN,AN,WN] of getAvg. */
344template <class T>
347};
348
349/** The LQN-level answer, indexed by element 1..nidx. */
350template <class T>
352 std::vector<T> QN, UN, RN, TN, AN, WN;
354 int iterations = 0;
355 bool converged = false;
356 /**
357 * True when the numbers are a BOUND (`method` = `mwba.upper` / `mwba.lower`)
358 * rather than the fixed point. A bound defines throughput and processor
359 * utilization and nothing else, so the other four measures come back
360 * undefined; reporting zeros there would be a claim.
361 */
362 bool is_bound = false;
363};
364
365/** A CDF sampled on a grid, the [F, t] pair MATLAB's evalCDF returns. */
366struct LnCdf {
367 std::vector<double> t, cdf;
368 bool empty() const { return t.empty(); }
369};
370
371/**
372 * One layer's block of the layered transient.
373 *
374 * The reference assembles the layers into one BLOCK-DIAGONAL cell array whose
375 * off-diagonal blocks are empty, so the blocks themselves carry the whole
376 * answer; keeping them apart also keeps each layer's own time grid, which the
377 * block-diagonal form has nowhere to put.
378 */
380 std::vector<double> t; ///< the output grid, shared by every series below
381 /// [station][class][point]
382 std::vector<std::vector<std::vector<double>>> QN, UN, TN;
383};
384
385/**
386 * `LayeredNetwork.layerBlocks`: where each layer's block sits in the aggregate.
387 *
388 * An LQN has no stations and classes of its own -- SolverLN builds them, one
389 * network per layer -- so the flat (station, class) view a caller like
390 * SolverENV needs is the BLOCK-DIAGONAL UNION of the layer networks, in layer
391 * order. `roff[e]`/`coff[e]` are layer e's 0-based row/column offset in that
392 * union and `msz[e]`/`ksz[e]` its size; `M`/`K` are the totals.
393 *
394 * The off-diagonal blocks pair a station of one layer with a class of another
395 * and stand for nothing at all. The reference leaves them as EMPTY cells; this
396 * port fills them with zeros, which is what the reference's consumers make of
397 * an empty cell (`tranTimeBase_` returns nothing and the metric stays 0).
398 */
400 std::vector<std::size_t> roff, coff, msz, ksz;
401 std::size_t M = 0, K = 0;
402};
403
404/** The layered transient: one block per layer, plus how it was produced. */
406 std::vector<LnTranLayer> layers;
407 std::string mode; ///< "coupled" or "decoupled"
408 long iterations = 0; ///< waveform-relaxation sweeps; 0 when decoupled
409 double gap = 0.0; ///< final sup-norm trajectory change, coupled only
410};
411
412/** getSensitivityTable of the ensemble: the layer tables under a Layer column. */
413template <class T>
415 struct Row {
416 std::string layer, station, jobclass;
418 };
419 std::vector<Row> rows;
420 /** Per layer, the branch that layer took; empty for a layer with no solver. */
421 std::vector<std::string> layer_methods;
422 /** The summary label: the common branch, or "mixed" when they differ. */
423 std::string method;
424 /** Per layer, the analytic Jacobian where that layer produced one. */
425 std::vector<sens::SensTable<T>> layer_tables;
426};
427
428/**
429 * Overtaking probability at a server entry, defined in lqn_analyzers.h.
430 *
431 * DECLARED, not included: lqn_analyzers.h needs LayerResult and SolverLN
432 * complete (LnStochController holds a vector of the first and two refusals take
433 * the second), so it must be parsed AFTER this class. The definition arrives
434 * through the include at the foot of this file, which is why the declaration
435 * has to stand here -- an unqualified call from a member function would
436 * otherwise find nothing, LqnStruct's associated namespace being line::lqn.
437 */
438template <class T>
439T lqn_overtake_prob_markov(const LqnStruct<T>& lqn, const std::vector<T>& servt,
440 const std::vector<T>& callresidt, const std::vector<T>& tput,
441 std::size_t eidx, const T& xj);
442
443/** `options.config.stochiter_*` of SolverOptions.m, with its defaults. */
445 long burnin = 5; ///< Picard iterations before the step decay starts
446 double a0 = 1.0; ///< Robbins-Monro step immediately after burn-in
447 double alpha = 0.6; ///< step decay exponent, in (0.5, 1]
448 long conseq = 3; ///< consecutive sub-tolerance iterations required to stop
449 double iter_tol = 5e-3;
450 /** Relaxation in force during burn-in, i.e. whatever init left in place. */
451 double relax_burnin = 1.0;
452};
453
454/**
455 * The Robbins-Monro / Polyak-Ruppert controller, defined in lqn_analyzers.h.
456 *
457 * DECLARED HERE, not included: same mutual dependence as above. `iterate` holds
458 * one through a shared_ptr rather than by value so that this class stays
459 * complete without it -- a by-value member would need the definition at the
460 * point the class template is instantiated, and the definition arrives at the
461 * foot of this file.
462 *
463 * ITS CONFIG CANNOT BE DEFERRED THE SAME WAY. `iterate` names LnStochConfig by
464 * value, and that name does not depend on T, so it is looked up and required
465 * COMPLETE when the template is parsed rather than when it is instantiated --
466 * the shared_ptr trick only defers the class template beside it.
467 */
468template <class T>
470
471template <class T>
472class SolverLN {
473public:
474 /**
475 * Port of `SolverLN.listValidMethods`.
476 *
477 * Each name states the LAYERING and the ENCODING; `ln_requested_method`
478 * normalises the alias spellings ("ph", "cs", "srvncs", "flatcs",
479 * "squashed", "squashed.ph") onto these, and they are left out here to keep
480 * the list unambiguous, exactly as the reference does.
481 */
482 static std::vector<std::string> list_valid_methods() {
483 return {"srvn", "srvn.ph", "srvn.cs", "flat", "flat.cs", "flat.ph", "moment3", "default"};
484 }
485
486 SolverLN(const LqnStruct<T>& lqn_in, const LnOptions& options) : lqn(lqn_in), opt(options) {
487 // A LAYER SOLVER NEVER NARRATES, the same invariant the MATLAB, JAR and
488 // python ports stamp on each constructed layer solver. The fixed point
489 // runs every layer once per iteration, so a layer left at the caller's
490 // verbosity would print its own banner nlayers*iter_max times and bury
491 // the layered narration the caller actually asked for. `ssa` is the one
492 // layer engine here carrying a verbosity knob of its own; the level is
493 // forced rather than trusted so a library caller cannot set it.
494 // SolverLN's own reporting is unaffected.
495 opt.layer_ssa.verbose = false;
496 construct();
497 }
498
499 /** Port of getEnsembleAvg: run the iteration and aggregate onto LQN elements. */
501 // getAvgTable.m:611-624 answers the two bound requests WITHOUT running
502 // the fixed point at all: a box bound is a statement about the model,
503 // not about an iterate, so solving first and discarding the answer would
504 // only cost time and invite the two to be confused.
505 if (opt.method == "mwba.upper" || opt.method == "mwba.lower") return box_bounds();
506 iterate();
507 // the layers of "srvn.ph" carry one class per caller task, so the
508 // per-element results are rebuilt analytically -- see aggregate_ph
509 return is_ph_encoding() ? aggregate_ph() : aggregate();
510 }
511
512 /**
513 * Port of @@SolverLN/getCdfRespT: the per-entry response-time distribution.
514 *
515 * ONLY THE `moment3` METHOD PRODUCES ONE. The reference reacts to an empty
516 * table by re-running getAvg under method='moment3' and restoring the
517 * caller's method afterwards, and so does this: the distribution is the
518 * whole point of that method, and the mean-based update has no distribution
519 * to report, not a coarser one.
520 *
521 * Indexed by entry NUMBER 1..nentries, as the reference's
522 * `entrycdfrespt{eidx - (nhosts+ntasks)}` is.
523 */
524 std::vector<LnCdf> get_cdf_respt() {
525 if (lqn.nentries == 0) return entrycdfrespt;
526 if (entrycdfrespt.size() <= lqn.nentries || entrycdfrespt[1].empty()) {
527 // The distribution pass reads the ROUTING encoding of the activity
528 // graph, which srvn.ph layers do not carry: re-running the iteration
529 // over them would reconstruct the wrong topology rather than a
530 // coarser answer, and returning the empty table would report no
531 // distribution at all. Refuse by name, as the reference does
532 // (@SolverLN/getCdfRespT).
533 if (is_ph_encoding())
534 throw UnsupportedError(
535 "getCdfRespT needs the routing encoding of the activity graph, which "
536 "method='srvn.ph' does not build. Rebuild the solver with "
537 "method='srvn.cs' or method='moment3'.");
538 // BOTH the option and the RESOLVED method have to move. update_metrics
539 // dispatches on `lnmethod`, which build_layers resolved once, so
540 // flipping `opt.method` alone leaves the mean-based update in place and
541 // the table empty -- which is what this getter did between the alias
542 // landing and 2026-08-11. The routing layers already built serve
543 // moment3 unchanged, so only the update pass changes.
544 const std::string saved = opt.method;
545 const std::string saved_resolved = lnmethod;
546 opt.method = "moment3";
547 lnmethod = "moment3";
548 iterate();
549 aggregate();
550 opt.method = saved;
551 lnmethod = saved_resolved;
552 }
553 return entrycdfrespt;
554 }
555
556 /**
557 * Port of @@SolverLN/getTranAvg: the block-diagonal aggregate transient.
558 *
559 * `opt.ln_transient` selects the coupling; both modes return the same
560 * layout, and iteration 0 of the coupled relaxation IS the decoupled
561 * answer.
562 */
564 if (opt.ln_transient == "decoupled") return tran_avg_decoupled();
565 if (opt.ln_transient == "coupled") return tran_avg_coupled();
566 throw InputError("SolverLN: unknown ln_transient mode '" + opt.ln_transient +
567 "' (use 'coupled' or 'decoupled')");
568 }
569
570 /**
571 * Port of @@SolverLN/getSensitivityTable: solve the ensemble, then
572 * concatenate each layer solver's own table under a leading Layer column.
573 *
574 * WHAT THESE DERIVATIVES MEAN, and the reference is emphatic about it: each
575 * entry is a partial derivative WITHIN ITS LAYER, taken with the layer
576 * parameters the fixed point produced held fixed. Perturbing a host demand
577 * moves the think times, populations and demands of every other layer
578 * through the fixed-point map, and that indirect term is NOT included. The
579 * table attributes a bottleneck inside a layer; it does not predict the
580 * effect of a parameter change on the solved layered model.
581 */
583 if (results.empty()) iterate();
584 LnSensTable<T> out;
585 out.layer_methods.assign(ensemble.size(), std::string());
586 out.layer_tables.resize(ensemble.size());
587 for (std::size_t e = 0; e < ensemble.size(); ++e) {
588 sens::SensOptions so = sopt;
589 so.simulation = opt.layer_solver == "ssa";
590 const bool exact_available =
591 opt.layer_solver == "mva" || opt.layer_solver == "nc";
593 ensemble[e], so, exact_available && !fj_tr[e].active(),
594 [this, e]() { return this->solve_layer(e); });
595 out.layer_methods[e] = t.method;
596 for (const sens::SensRow<T>& r : t.rows) {
597 typename LnSensTable<T>::Row row;
598 row.layer = ensemble[e].name;
599 row.station = r.station;
600 row.jobclass = r.jobclass;
601 row.dTput = r.dTput;
602 row.dRespT = r.dRespT;
603 row.dQLen = r.dQLen;
604 row.dUtil = r.dUtil;
605 out.rows.push_back(row);
606 }
607 out.layer_tables[e] = t;
608 }
609 // One branch label per layer, plus a summary that is `mixed` when the
610 // layers did not all take the same branch.
611 for (const std::string& m : out.layer_methods) {
612 if (m.empty()) continue;
613 if (out.method.empty()) out.method = m;
614 else if (out.method != m) out.method = "mixed";
615 }
616 return out;
617 }
618
619 /**
620 * Majumdar-Woodside robust box bounds, reported in the shape of a solution.
621 *
622 * `mwba.upper` reports the upper bound of both throughput and processor
623 * utilization, `mwba.lower` the lower bound of both. Everything the bound
624 * does not define -- queue length, response time, residence time, arrival
625 * rate -- is left UNDEFINED rather than zeroed.
626 */
628 const bool upper = opt.method != "mwba.lower";
629 // Fully qualified: the member `lqn` shadows the namespace of the same
630 // name inside this class, so an unqualified `lqn::` would not compile.
631 const ::line::lqn::LqnBoxBounds<T> b = ::line::lqn::lqn_boxbounds(lqn);
633 const std::size_t N = lqn.nidx;
634 auto blank = [&](std::vector<T>& v, std::vector<bool>& d) {
635 v.assign(N + 1, Tzero());
636 d.assign(N + 1, false);
637 };
638 blank(s.QN, s.defined_Q);
639 blank(s.UN, s.defined_U);
640 blank(s.RN, s.defined_R);
641 blank(s.TN, s.defined_T);
642 blank(s.AN, s.defined_A);
643 blank(s.WN, s.defined_W);
644 for (std::size_t i = 1; i <= N; ++i) {
645 if (b.defined_T[i]) {
646 s.TN[i] = upper ? b.TN_up[i] : b.TN_lo[i];
647 s.defined_T[i] = true;
648 }
649 if (b.defined_U[i]) {
650 s.UN[i] = upper ? b.UN_up[i] : b.UN_lo[i];
651 s.defined_U[i] = true;
652 }
653 }
654 s.iterations = 0;
655 s.converged = true; // a bound needs no fixed point to have converged to
656 s.is_bound = true;
657 return s;
658 }
659
660 std::size_t nlayers() const { return ensemble.size(); }
661 const std::vector<qn::Layer<T>>& layers() const { return ensemble; }
662
663 /**
664 * Port of `LayeredNetwork.layerBlocks`: the block-diagonal layout of the
665 * layers in the aggregate (station x class) view.
666 *
667 * Available as soon as the solver is constructed -- `build_layers` runs in
668 * the constructor -- which is what lets SolverENV compare the shapes of its
669 * stages before solving any of them.
670 */
673 const std::size_t E = ensemble.size();
674 b.roff.assign(E, 0);
675 b.coff.assign(E, 0);
676 b.msz.assign(E, 0);
677 b.ksz.assign(E, 0);
678 for (std::size_t e = 0; e < E; ++e) {
679 b.roff[e] = b.M;
680 b.coff[e] = b.K;
681 b.msz[e] = ensemble[e].nstations;
682 b.ksz[e] = ensemble[e].nclasses;
683 b.M += b.msz[e];
684 b.K += b.ksz[e];
685 }
686 return b;
687 }
688
689 /**
690 * Port of `LayeredNetwork.initFromMarginal`: split an aggregate (M x K)
691 * mean queue-length matrix into per-layer blocks and warm-start each layer
692 * from its own.
693 *
694 * WHY THIS IS RECORDED AND NOT APPLIED HERE, which is the trap the JAR and
695 * python ports both hit: the layered fixed point RESETS its layers as it
696 * converges, so a warm start installed before the solve does not survive
697 * it. The blocks are therefore kept and replayed by `run_layer_transient`,
698 * i.e. after the fixed point and immediately before each layer's transient
699 * -- the steady solve ignores an initial state, the transient does not.
700 *
701 * THE BLOCK IS NOT ROUNDED. The reference rounds a replayed block onto the
702 * integer lattice for every layer engine EXCEPT the fluid one, and the
703 * layered transient exists only over fluid layers (`require_transient_ready`
704 * refuses the rest), so the branch that would round has no reachable case
705 * here. Rounding a fluid state would quantize the very quantity SolverENV's
706 * fixed point is iterating on.
707 *
708 * An empty matrix clears the warm start, so a caller can put the layers
709 * back on their default initial state without rebuilding the solver.
710 */
712 const std::size_t E = ensemble.size();
713 layer_tran_init.assign(E, std::vector<double>());
714 if (n.empty()) return;
715 const LnLayerBlocks b = layer_blocks();
716 if (n.rows() != b.M || n.cols() != b.K)
717 throw InputError(
718 "SolverLN::init_from_marginal: the marginal is " + std::to_string(n.rows()) + "x" +
719 std::to_string(n.cols()) + " where the block-diagonal union of the layers is " +
720 std::to_string(b.M) + "x" + std::to_string(b.K) +
721 "; the aggregate view is layerBlocks', not the LQN element count");
722 for (std::size_t e = 0; e < E; ++e) {
723 const qn::Layer<T>& L = ensemble[e];
725 std::vector<double> y(lay.nstates, 0.0);
726 bool any = false;
727 for (std::size_t i = 0; i < L.nstations && i < b.msz[e]; ++i)
728 for (std::size_t r = 0; r < L.nclasses && r < b.ksz[e]; ++r) {
729 if (!lay.enabled[i][r]) continue;
730 const double q = std::max(0.0, n(b.roff[e] + i, b.coff[e] + r));
731 y[lay.qidx[i][r]] = q;
732 if (q > 0.0) any = true;
733 }
734 // An all-zero block is NOT a warm start: it is the absence of one,
735 // and installing it would empty a closed layer whose population the
736 // fixed point conserves. The reference reaches the same place by
737 // never calling initFromMarginal before the first stage solve.
738 if (any) layer_tran_init[e] = y;
739 }
740 }
741
742 /** Install the explicit output grid of the layered transient; see `LnOptions::tran_grid`. */
743 void set_tran_grid(const std::vector<double>& g) { opt.tran_grid = g; }
744
745 /** Diagnostic access to the per-iteration layer results, for the regression. */
746 const std::vector<std::vector<LayerResult<T>>>& iteration_results() const { return results; }
747 const std::vector<T>& state_servt() const { return servt; }
748 const std::vector<T>& state_residt() const { return residt; }
749 const std::vector<T>& state_tput() const { return tput; }
750 const std::vector<T>& state_thinkt() const { return thinkt; }
751 const std::vector<T>& state_callservt() const { return callservt; }
752 const std::vector<T>& state_callresidt() const { return callresidt; }
753 const std::vector<Distrib<T>>& state_servtproc() const { return servtproc; }
754 const std::vector<Distrib<T>>& state_thinktproc() const { return thinktproc; }
755 const std::vector<Distrib<T>>& state_callservtproc() const { return callservtproc; }
756 const std::vector<T>& state_util() const { return util; }
757 /** The method the layers were built for: "srvn.ph", "srvn.cs" or "moment3". */
758 const std::string& state_lnmethod() const { return lnmethod; }
759
760private:
761 // state
764
765 std::vector<qn::Layer<T>> ensemble; ///< compacted, layer e is ensemble[e]
766 std::vector<long> idxhash; ///< (nidx+1) element -> layer index, -1 if none
767 std::vector<bool> ignore; ///< (nidx+1) element in a component with no ref task
768
769 // update maps, rows of [layerElement, elementOrCall, node, class]
770 struct UpdRow {
771 std::size_t idx, aidx, node, cls;
772 };
773 std::vector<UpdRow> servt_map, thinkt_map, call_map, actthinkt_map;
774 /**
775 * Source arrival of an async call class, `aidx` holding the call index.
776 *
777 * Kept apart from call_map because the two update different things about
778 * the same class: call_map carries the SERVICE at the server replicas,
779 * this one the arrival RATE at the Source, which is the caller activity's
780 * throughput and so moves with the fixed point. Entry open arrivals have
781 * no row here at all -- theirs is exogenous and never reseeded.
782 */
783 std::vector<UpdRow> arv_call_map;
784 /** [idx, tidx_caller, eidx, nodefrom, nodeto, classfrom, classto] */
785 struct RouteRow {
786 std::size_t idx, tidx_caller, eidx, nodefrom, nodeto, cfrom, cto;
787 };
788 std::vector<RouteRow> route_map;
789 std::vector<std::size_t> unique_route_idx;
790 std::vector<std::size_t> route_reset, svc_reset;
791
792 Matrix<double> njobs; ///< (NT+1 x NT+1) population of caller tidx in layer idx
793
794 // per-element metric state
795 std::vector<T> servt, residt, tput, util, thinkt;
796 std::vector<T> callservt, callresidt;
797 /**
798 * Phase-2 state, the reference's hasPhase2 / servt_ph1 / servt_ph2 /
799 * prOvertake. The split is of `servt` and exists only while `has_phase2`;
800 * `prOvertake` is indexed by entry NUMBER 1..nentries, as the reference
801 * indexes it, not by element index.
802 */
803 bool has_phase2 = false;
804 std::vector<T> servt_ph1, servt_ph2, prOvertake;
805 /** Tasks whose own layer was collapsed to one representative replica. */
806 std::set<std::size_t> single_replica_tasks;
807 std::vector<Distrib<T>> servtproc, thinkproc, thinktproc, tputproc, callservtproc;
808 /**
809 * `moment3` state: the response-time CDF of each activity (by element) and
810 * of each call, the fitted per-entry law and the CDF read off it. All empty
811 * under the default method, which never forms a distribution at all.
812 */
813 std::vector<fluid::FluidPassage> servtcdf, callservtcdf;
814 std::vector<LnCdf> entrycdfrespt;
815 std::vector<mam::AphPair<T>> entryproc;
816 /** Per layer, the response-time CDFs of the converged pass; a lazy cache. */
817 std::vector<std::vector<std::vector<fluid::FluidPassage>>> cdf_repo;
818 std::vector<double> servt_prev, residt_prev, tput_prev, thinkt_prev;
819 std::vector<double> callservt_prev, callresidt_prev;
820 std::vector<T> servt_prev_v, residt_prev_v, tput_prev_v, thinkt_prev_v, callservt_prev_v;
821 Matrix<T> servtmatrix; ///< (nidx+ncalls) x (nidx+ncalls) entry reachability
822
823 // interlock tables
824 Matrix<T> il_all, il_ph1;
825 std::vector<std::vector<std::size_t>> il_common_entries, il_src_all, il_src_ph2;
826 /**
827 * Per-layer interlock matrix of Franks (1999), Eq. (4.7), CLASS-indexed. A host layer
828 * whose engine carries the correction inside its own MVA takes the matrix instead of
829 * having its residence times scaled after the fact; empty for every other layer.
830 */
831 std::vector<std::vector<std::vector<double>>> layer_interlock;
832 std::vector<double> il_num_sources;
833
834 std::vector<std::vector<LayerResult<T>>> results; ///< [iteration][layer]
835 std::vector<Matrix<T>> layer_init_sol;
836 /**
837 * Per layer, the ODE state `init_from_marginal` recorded, replayed by
838 * `run_layer_transient`. Empty for a layer with no warm start.
839 */
840 std::vector<std::vector<double>> layer_tran_init;
841 /**
842 * Per layer, the fork-join transform SolverMVA solves instead of the layer
843 * itself (inactive for a layer with no fork), and the auxiliary arrival
844 * rates its fixed point iterates on. See fj_mmt.h.
845 *
846 * `fj_lambda` is the `self.fjForkLambda` of the reference and is deliberately
847 * NOT reset between outer iterations: the transform is rebuilt cold on every
848 * outer pass but the iterate it converged to last time is a far better
849 * starting point than FineTol, and the reference warm-starts it the same way
850 * (options.config.fj_warmstart, true by default).
851 */
852 std::vector<mva::FjMmt<T>> fj_tr;
853 std::vector<std::vector<T>> fj_lambda;
854 double relax_omega = 1.0;
855 /** Live only while a NOISY layer engine is in force; null otherwise. */
856 std::shared_ptr<LnStochController<T>> stoch_ctl;
857 long averagingstart = -1;
858 bool hasconverged = false;
859 /** method='moment3': true once the distribution pass has formed the entry laws. */
860 bool moment_pass_done = false;
861 std::vector<double> maxitererr;
862 int iterations_done = 0;
863 bool did_converge = false;
864
865 std::size_t NT() const { return lqn.tshift + lqn.ntasks; }
866
867 static T Tzero() { return num_traits<T>::from_int(0); }
868 static T Tone() { return num_traits<T>::from_int(1); }
869 static double dbl(const T& x) { return num_traits<T>::to_double(x); }
870
871 // -----------------------------------------------------------------------
872 // construct
873 // -----------------------------------------------------------------------
874 void construct() {
875 // Forwarding rewrite, SolverLN.m:162-164: flatten every forwarding
876 // chain into caller-side pseudo rendezvous calls BEFORE anything else
877 // reads lqn, so build_layers/detect_phase2/reject_unsupported all see
878 // plain rendezvous arcs. The raw FWD calls survive in the struct but
879 // must not contribute blocking after this point (lqn_helpers.h).
881 reject_unsupported();
882 detect_phase2();
883
884 const std::size_t N = lqn.nidx;
885 ignore.assign(N + 1, false);
886 // weakly connected components of graph + graph'; a component with no
887 // reference task is unreachable and its elements are ignored
888 std::vector<long> comp(N + 1, -1);
889 long ncomp = 0;
890 for (std::size_t v = 1; v <= N; ++v) {
891 if (comp[v] >= 0) continue;
892 std::vector<std::size_t> stack{v};
893 comp[v] = ncomp;
894 while (!stack.empty()) {
895 const std::size_t u = stack.back();
896 stack.pop_back();
897 for (std::size_t w : lqn.graph.succ(u))
898 if (comp[w] < 0) {
899 comp[w] = ncomp;
900 stack.push_back(w);
901 }
902 for (std::size_t w : lqn.graph.pred(u))
903 if (comp[w] < 0) {
904 comp[w] = ncomp;
905 stack.push_back(w);
906 }
907 }
908 ++ncomp;
909 }
910 if (ncomp > 1) {
911 std::vector<bool> has_ref(ncomp, false);
912 for (std::size_t t = 1; t <= lqn.ntasks; ++t) {
913 const std::size_t tidx = lqn.tshift + t;
914 if (lqn.sched[tidx] == SchedStrategy::REF) has_ref[comp[tidx]] = true;
915 }
916 for (std::size_t v = 1; v <= N; ++v)
917 if (!has_ref[comp[v]]) ignore[v] = true;
918 }
919
920 servtproc.assign(N + 1, Distrib<T>::disabled_dist());
921 thinkproc.assign(N + 1, Distrib<T>::disabled_dist());
922 thinktproc.assign(N + 1, Distrib<T>::disabled_dist());
923 tputproc.assign(N + 1, Distrib<T>::disabled_dist());
924 for (std::size_t i = 1; i <= N; ++i) {
925 servtproc[i] = lqn.hostdem[i];
926 thinkproc[i] = lqn.think[i];
927 }
928 callservtproc.assign(lqn.ncalls + 1, Distrib<T>::disabled_dist());
929 for (std::size_t c = 1; c <= lqn.ncalls; ++c)
930 callservtproc[c] = lqn.hostdem[lqn.callpair_dst[c]];
931
932 njobs = Matrix<double>(NT() + 1, NT() + 1, 0.0);
933 build_layers();
934
935 // layers whose routing or service must be re-derived after an update
936 std::vector<bool> rr(NT() + 1, false), sr(NT() + 1, false);
937 for (const RouteRow& r : route_map) rr[r.idx] = true;
938 for (const UpdRow& r : thinkt_map) sr[r.idx] = true;
939 for (const UpdRow& r : call_map) sr[r.idx] = true;
940 // a moved arrival rate reweights the Sink closure, which only
941 // refresh_chains rebuilds, so these layers need the routing reset
942 for (const UpdRow& r : arv_call_map) rr[r.idx] = true;
943 for (std::size_t i = 1; i <= NT(); ++i) {
944 if (rr[i] && idxhash[i] >= 0) route_reset.push_back(std::size_t(idxhash[i]));
945 if (sr[i] && idxhash[i] >= 0) svc_reset.push_back(std::size_t(idxhash[i]));
946 }
947 std::sort(route_reset.begin(), route_reset.end());
948 route_reset.erase(std::unique(route_reset.begin(), route_reset.end()), route_reset.end());
949 std::sort(svc_reset.begin(), svc_reset.end());
950 svc_reset.erase(std::unique(svc_reset.begin(), svc_reset.end()), svc_reset.end());
951 }
952
953 /** Reject, by name, every construct this port does not implement. */
954 // Phase-2 detection, SolverLN.m:165-173. Deliberately NOT inside
955 // reject_unsupported: that method is const because it only refuses, and
956 // detection sets state. Marking the flag mutable would have compiled and
957 // left a validator that silently mutates the solver.
958 void detect_phase2() {
959 has_phase2 = false;
960 for (std::size_t a = 1; a <= lqn.nacts; ++a)
961 if (lqn.actphase[a] > 1) has_phase2 = true;
962 }
963
964 /**
965 * True when `eidx` is the destination of a raw forwarding call.
966 *
967 * buildLayersRecursive.m:186-192 keeps this test even though
968 * lqn_fwd_rendezvous has already run, and so must this port:
969 * the rewrite walks chains out of SYNC calls only, so an entry whose
970 * forwarder is reached asynchronously gets no pseudo arc and would
971 * otherwise be dropped from the layer along with all of its work.
972 */
973 bool is_fwd_target(std::size_t eidx) const {
974 for (std::size_t c = 1; c <= lqn.ncalls; ++c)
975 if (lqn.calltype[c] == CallType::FWD && lqn.callpair_dst[c] == eidx) return true;
976 return false;
977 }
978
979 /**
980 * Total exogenous rate into the entries of task `tidx`, zero unless the arrival is
981 * the ONLY way in.
982 *
983 * A task nobody calls has no task layer, so update_think_times never sets its
984 * surrogate delay and its caller class cycles against an Immediate one. Carrying the
985 * arrival as an open stream ON TOP of that unthrottled chain loads the host twice:
986 * lqn_open_arrival read the processor at 0.68 where lqns, lqsim and LDES all give
987 * 0.32. The chain is the representation that honours the thread pool, so build_layer
988 * drops the stream for these tasks and update_think_times closes the chain on this
989 * rate, as the reference does for a forwarding target. With a caller or a forwarding
990 * source the stream needs a class of its own and this returns 0.
991 */
992 double open_arrival_rate_of(std::size_t tidx) const {
993 if (lqn.isref[tidx]) return 0.0;
994 for (std::size_t e : lqn.entriesof[tidx])
995 if (lqn.issynccaller.any_col(e) || lqn.isasynccaller.any_col(e) || is_fwd_target(e))
996 return 0.0;
997 double rate = 0.0;
998 for (std::size_t e : lqn.entriesof[tidx]) {
999 if (!lqn.has_arrival[e]) continue;
1000 const double m = dbl(lqn.arrival[e].mean);
1001 if (std::isfinite(m) && m > GlobalConstants::FineTol) rate += 1.0 / m;
1002 }
1003 return rate;
1004 }
1005
1006 /** True when layer `e` carries a Cache node. */
1007 bool has_cache_node(std::size_t e) const {
1008 for (std::size_t n = 0; n < ensemble[e].nodes.size(); ++n)
1009 if (ensemble[e].nodes[n].nodetype == NodeType::Cache) return true;
1010 return false;
1011 }
1012
1013 /** True when `aidx` is an activity bound to an entry nobody calls synchronously. */
1014 bool async_only_activity(std::size_t aidx) const {
1015 if (aidx <= lqn.ashift || aidx > lqn.ashift + lqn.nacts) return false;
1016 for (std::size_t e = 1; e <= lqn.nentries; ++e) {
1017 const std::size_t eidx = lqn.eshift + e;
1018 if (lqn.graph.get(eidx, aidx) == Tzero()) continue;
1019 return lqn.isasynccaller.any_col(eidx) && !lqn.issynccaller.any_col(eidx);
1020 }
1021 return false;
1022 }
1023
1024 /**
1025 * Reject, by name, every construct this port does not implement.
1026 *
1027 * NOTHING IS LEFT. Forwarding, asynchronous calls, entry open arrivals,
1028 * admission constraints, cache tasks and setup tasks are each solved.
1029 * The method is kept rather than deleted because it is the place a new
1030 * refusal belongs, and because a layer-build refusal must be raised HERE,
1031 * before construct() has read anything out of the struct, rather than
1032 * halfway through building a layer.
1033 */
1034 void reject_unsupported() const {}
1035
1036 // -----------------------------------------------------------------------
1037 // buildLayers
1038 // -----------------------------------------------------------------------
1039 void build_layers() {
1040 // Method resolution. A method name carries both the LAYERING and the
1041 // ENCODING: "srvn.ph" replaces the routing encoding of the activity graph
1042 // by a composed phase-type server law, "srvn" is the alias that takes it
1043 // where it can serve the model and "srvn.cs" otherwise. The choice is
1044 // made ONCE, here, and every later dispatch reads lnmethod.
1045 // See _kb/06-solver-catalog.md (LN section).
1046 const std::string requested = ln_requested_method(opt.method);
1047 assert_call_groups(requested == "flat.cs");
1048 if (requested == "flat.cs") {
1049 lnmethod = "flat.cs";
1050 build_flat_layer();
1051 return;
1052 }
1053 if (requested == "flat.ph") {
1054 // the squashed layering with the composed law: ONE submodel holding
1055 // every server, and a caller visiting each of them once per
1056 // invocation. The feature gate is the srvn.ph one plus the refusals a
1057 // single submodel carries -- see ph_flat_server_set.
1058 ph_laws_ready = false;
1059 lnmethod = "flat.ph";
1060 build_layers_ph(true);
1061 return;
1062 }
1063 if (requested == "srvn.ph" || requested == "srvn") {
1064 ph_laws_ready = false;
1065 if (requested == "srvn.ph" || probe_srvn_ph()) {
1066 lnmethod = "srvn.ph";
1067 build_layers_ph();
1068 return;
1069 }
1070 }
1071 lnmethod = (requested == "moment3") ? "moment3" : "srvn.cs";
1072 std::vector<qn::Layer<T>> raw(NT() + 1);
1073 std::vector<bool> present(NT() + 1, false);
1074
1075 for (std::size_t hidx = 1; hidx <= lqn.nhosts; ++hidx) {
1076 if (ignore[hidx]) continue;
1077 build_layer(raw[hidx], {hidx}, lqn.tasksof[hidx], true, false);
1078 present[hidx] = true;
1079 }
1080 for (std::size_t t = 1; t <= lqn.ntasks; ++t) {
1081 const std::size_t tidx = lqn.tshift + t;
1082 if (ignore[tidx] || lqn.isref[tidx]) continue;
1083 bool any_caller = lqn.iscaller.any_row(tidx) || lqn.iscaller.any_col(tidx);
1084 if (!any_caller) continue;
1085 // tasks that call some entry of tidx
1086 std::vector<std::size_t> callers;
1087 for (std::size_t ct = 1; ct <= lqn.ntasks; ++ct) {
1088 const std::size_t c = lqn.tshift + ct;
1089 bool calls = false;
1090 for (std::size_t e : lqn.entriesof[tidx])
1091 if (lqn.iscaller.get(c, e)) calls = true;
1092 if (calls) callers.push_back(c);
1093 }
1094 if (callers.empty()) continue;
1095 build_layer(raw[tidx], {tidx}, callers, false, false);
1096 present[tidx] = true;
1097 }
1098
1099 idxhash.assign(lqn.nidx + 1, -1);
1100 long next = 0;
1101 for (std::size_t i = 1; i <= NT(); ++i)
1102 if (present[i]) {
1103 idxhash[i] = next++;
1104 ensemble.push_back(std::move(raw[i]));
1105 }
1106 layer_init_sol.assign(ensemble.size(), Matrix<T>());
1107 build_fork_views();
1108 }
1109
1110 /**
1111 * The processors and called tasks that become stations of the flat layer.
1112 *
1113 * Squashing is refused rather than approximated where an element carries
1114 * state that only a submodel of its own can hold: a REPLICATED element
1115 * would need one station per copy inside a layer whose routing addresses it
1116 * once, a CACHE task needs the Cache node in the host layer its reads queue
1117 * at, and a SETUP task's delay-off belongs to the station that powers down.
1118 */
1119 /**
1120 * Reject a routed call group under any layering or layer solver that cannot
1121 * carry it.
1122 *
1123 * Two conditions, and both are refusals rather than degradations. The
1124 * squashed layering is needed because under `srvn` each target lives in a
1125 * submodel of its own and is replaced, in the caller's submodel, by a
1126 * surrogate delay -- no node ever has arcs to more than one of them, so
1127 * there is nothing to dispatch among. A layer solver that resolves the
1128 * strategy from the STATE is needed because `refresh_routing` expands
1129 * RROBIN and JSQ into a uniform probability split for the matrix solvers,
1130 * and returning that split under a round-robin label misreports a
1131 * deterministic policy as a coin. In this port only `ssa` resolves them.
1132 */
1133 void assert_call_groups(bool flat) const {
1134 if (lqn.callgroups.empty()) return;
1135 if (!flat)
1136 throw UnsupportedError(
1137 "Call groups routed by a routing strategy require the squashed layering; use "
1138 "method='flat'. Under srvn the targets never share a submodel, so the dispatch "
1139 "order cannot be represented.");
1140 if (opt.layer_solver != "ssa")
1141 throw UnsupportedError(
1142 "Routed call groups need a layer solver that resolves the strategy from the "
1143 "state; set layer_solver='ssa'. MVA, NC and FLD read the routing matrix, into "
1144 "which refresh_routing has expanded the strategy as a uniform split, and would "
1145 "return that split under a round-robin or JSQ label.");
1146 }
1147
1148 std::vector<std::size_t> flat_server_set() const {
1149 std::vector<std::size_t> servers;
1150 for (std::size_t i = 1; i <= NT(); ++i) {
1151 if (lqn.repl[i] > 1.0)
1152 throw UnsupportedError(
1153 "Flat layering does not support replicated processors or tasks, use the "
1154 "default 'srvn' layering.");
1155 if (lqn.iscache[i])
1156 throw UnsupportedError(
1157 "Flat layering does not support cache tasks, use the default 'srvn' "
1158 "layering.");
1159 if (lqn.hassetup[i])
1160 throw UnsupportedError(
1161 "Flat layering does not support setup tasks, use the default 'srvn' "
1162 "layering.");
1163 }
1164 for (std::size_t hidx = 1; hidx <= lqn.nhosts; ++hidx)
1165 if (!ignore[hidx] && !lqn.tasksof[hidx].empty()) servers.push_back(hidx);
1166 for (std::size_t t = 1; t <= lqn.ntasks; ++t) {
1167 const std::size_t tidx = lqn.tshift + t;
1168 if (ignore[tidx] || lqn.isref[tidx]) continue;
1169 if (!lqn.iscaller.any_row(tidx) && !lqn.iscaller.any_col(tidx)) continue;
1170 bool has_task_caller = false;
1171 for (std::size_t eidx : lqn.entriesof[tidx])
1172 for (std::size_t c = 1; c <= lqn.ntasks; ++c)
1173 if (lqn.iscaller.get(lqn.tshift + c, eidx)) has_task_caller = true;
1174 if (has_task_caller) servers.push_back(tidx);
1175 }
1176 if (servers.empty())
1177 throw InputError(
1178 "Flat layering found no server: the model has no processor with tasks.");
1179 return servers;
1180 }
1181
1182 /**
1183 * Build the single squashed layer holding every processor and called task.
1184 *
1185 * Every served element resolves to layer 0, which is at once the host layer
1186 * and the task layer, so a consumer that walks `idxhash` finds the same
1187 * network for a processor and for a task and must read the station off
1188 * `server_idx_of` rather than off `serverIdx`.
1189 */
1190 void build_flat_layer() {
1191 const std::vector<std::size_t> flat_servers = flat_server_set();
1192 std::vector<std::size_t> flat_callers;
1193 for (std::size_t t = 1; t <= lqn.ntasks; ++t) {
1194 const std::size_t tidx = lqn.tshift + t;
1195 if (!ignore[tidx]) flat_callers.push_back(tidx);
1196 }
1197 qn::Layer<T> layer;
1198 build_layer(layer, flat_servers, flat_callers, false, true);
1199 ensemble.clear();
1200 ensemble.push_back(std::move(layer));
1201 idxhash.assign(lqn.nidx + 1, -1);
1202 for (std::size_t s : flat_servers) idxhash[s] = 0;
1203 layer_init_sol.assign(ensemble.size(), Matrix<T>());
1204 build_fork_views();
1205 }
1206
1207 /**
1208 * Build, for every layer that has a fork, the transformed model SolverMVA
1209 * actually solves, and seed its auxiliary arrival rates.
1210 */
1211 void build_fork_views() {
1212 fj_tr.assign(ensemble.size(), mva::FjMmt<T>());
1213 fj_lambda.assign(ensemble.size(), {});
1214 for (std::size_t e = 0; e < ensemble.size(); ++e) {
1215 if (!ensemble[e].has_fork()) continue;
1216 // fj_mmt mints its own Source/Sink pair and would silently overwrite
1217 // sourceIdx/sinkNode, detaching the open stream already routed there.
1218 // NOT a parity gap: the reference does not solve this combination
1219 // either, it fails inside the layer solve with "Arrays have
1220 // incompatible sizes" (checked 2026-07-29 on a fork+async model).
1221 // Refusing by name is the better answer, so this stays.
1222 if (ensemble[e].sourceIdx != 0)
1223 throw UnsupportedError(
1224 "SolverLN: layer '" + ensemble[e].name +
1225 "' carries both an AND fork and an open stream (an async call or an entry "
1226 "arrival); the fork-join transform needs a Source of its own");
1227 fj_tr[e] = mva::fj_mmt(ensemble[e]);
1228 fj_lambda[e].assign(fj_tr[e].V.classes.size() + 1,
1229 num_traits<T>::from_double(GlobalConstants::FineTol));
1230 }
1231 }
1232
1233 /**
1234 * Port of matlab/src/lang/layered/lqn_dep_layer_handle.m: lift a
1235 * per-operand rate handle onto the classes of a layer station.
1236 *
1237 * COLS[j] lists the layer classes (0-based) through which operand j occupies
1238 * the station; CHAINCOLS is the same list in the CHAIN index space, filled
1239 * once refresh_chains has run. Solvers evaluate the handle in either space
1240 * -- CTMC and the exact recursions pass a per-class vector, the AMVA and NC
1241 * chain recursions a per-chain one -- so the handle reads the LENGTH of what
1242 * it is given to decide, aggregates the operand populations in that space,
1243 * and answers a vector of the same length, since the caller indexes the
1244 * answer with the index it passed in. An index belonging to no operand keeps
1245 * the neutral scaling 1.
1246 */
1247 static lang::CdScaling<T> layer_dep_handle(
1248 const lang::CdScaling<T>& f, const std::vector<std::vector<std::size_t>>& cols,
1249 const std::shared_ptr<std::vector<std::vector<std::size_t>>>& chaincols, std::size_t R) {
1250 return [f, cols, chaincols, R](const std::vector<T>& n) -> std::vector<T> {
1251 const std::size_t L = n.size();
1252 const std::vector<std::vector<std::size_t>>& use =
1253 (L == R || chaincols->empty()) ? cols : *chaincols;
1254 std::vector<T> nop(use.size(), num_traits<T>::from_int(0));
1255 for (std::size_t j = 0; j < use.size(); ++j)
1256 for (std::size_t k = 0; k < use[j].size(); ++k)
1257 if (use[j][k] < L) nop[j] = T(nop[j] + n[use[j][k]]);
1258 const std::vector<T> w = f(nop);
1259 std::vector<T> v(L, num_traits<T>::from_int(1));
1260 if (w.empty()) return v;
1261 for (std::size_t j = 0; j < use.size(); ++j) {
1262 const T wj = w[j < w.size() ? j : w.size() - 1];
1263 for (std::size_t k = 0; k < use[j].size(); ++k)
1264 if (use[j][k] < L) v[use[j][k]] = wj;
1265 }
1266 return v;
1267 };
1268 }
1269
1270 /** Spread a per-operand peak rate onto the layer classes. Twin of layerPeak. */
1271 static std::vector<T> layer_peak(const std::vector<T>& peakPerOperand,
1272 const std::vector<std::vector<std::size_t>>& cols,
1273 std::size_t R) {
1274 std::vector<T> peak(R, num_traits<T>::from_int(1));
1275 if (peakPerOperand.empty()) return peak;
1276 for (std::size_t j = 0; j < cols.size(); ++j) {
1277 const T pj = peakPerOperand[j < peakPerOperand.size() ? j : peakPerOperand.size() - 1];
1278 for (std::size_t k = 0; k < cols[j].size(); ++k)
1279 if (cols[j][k] < R) peak[cols[j][k]] = pj;
1280 }
1281 return peak;
1282 }
1283
1284 /**
1285 * Port of buildLayersRecursive.
1286 *
1287 * The layer holds a client Delay carrying the callers' think times and the
1288 * time they spend elsewhere, and `nreplicas` copies of the server station.
1289 * A class is created for every task, entry, activity and call that the
1290 * callers' activity graphs reach, and the graph traversal lays down the
1291 * routing that moves a job between those classes.
1292 */
1293 void build_layer(qn::Layer<T>& m, const std::vector<std::size_t>& idxSet,
1294 const std::vector<std::size_t>& callers, bool ishostlayer, bool flat) {
1295 const T one = Tone();
1296 // The layer key: the model name, the ensemble slot and the column every
1297 // update map is written under. Under `srvn` it is the layer's only
1298 // served element; under `flat` it is the first of them.
1299 const std::size_t idx = idxSet[0];
1300 m.name = flat ? lqn.hashnames[idx] + ".Flat" : lqn.hashnames[idx];
1301 m.flat = flat;
1302
1303 const double rawrepl = lqn.repl[idx];
1304 std::size_t nreplicas = 1;
1305 if (!flat && rawrepl > 1.0 && !callers.empty()) {
1306 // A replicated server is pooled into ONE station instead of one per
1307 // replica when every caller already addresses all of its replicas:
1308 // on a host layer that is the callers replicating in step with it,
1309 // on a task layer it is a declared fan-out covering every replica.
1310 // With no fan-out declared the lookup is 0 < rawrepl and the layer
1311 // materialises the replicas, which is the reference's answer too.
1312 bool reduce = true;
1313 if (ishostlayer) {
1314 for (std::size_t c : callers)
1315 if (lqn.repl[c] != rawrepl) reduce = false;
1316 } else {
1317 for (std::size_t c : callers)
1318 if (lqn.fanout_at(c, idx) < rawrepl) reduce = false;
1319 }
1320 nreplicas = reduce ? 1 : static_cast<std::size_t>(std::llround(rawrepl));
1321 if (reduce && !ishostlayer) single_replica_tasks.insert(idx);
1322 }
1323 const bool reduce_fanout = (nreplicas == 1 && rawrepl > 1.0 && !callers.empty());
1324 const std::vector<double>& mult = lqn.maxmult;
1325
1326 // A client Delay exists unless the layer is a task layer reached only
1327 // by asynchronous callers, which this port refuses upstream.
1328 m.clientIdx = m.add_station(qn::Station<T>{"Clients", NodeType::Delay, SchedStrategy::INF,
1329 std::numeric_limits<double>::infinity(), false, 0});
1330 const std::size_t clientNode = m.node_of_station(m.clientIdx);
1331 m.serverIdx = m.clientIdx + 1;
1332 // One station (times its replicas) per SERVED ELEMENT of this layer.
1333 // Under `srvn` that is one element and `srv[idx] == server`; under
1334 // `flat` every processor and called task of the model is here.
1335 std::vector<std::vector<std::size_t>> srv(lqn.nidx + 1);
1336 std::vector<std::vector<std::size_t>> srvnode(lqn.nidx + 1);
1337 m.server_idx_of.assign(lqn.nidx + 1, 0);
1338 for (std::size_t sidx : idxSet) {
1339 const bool sishost = sidx <= lqn.nhosts;
1340 srv[sidx].resize(nreplicas);
1341 for (std::size_t r = 0; r < nreplicas; ++r) {
1342 qn::Station<T> st;
1343 st.name = r == 0 ? lqn.hashnames[sidx]
1344 : lqn.hashnames[sidx] + "." + std::to_string(r + 1);
1345 // inf-scheduled Queue as Delay node rationale: see _kb/06-solver-catalog.md (cpp port notes: solver_ln.h)
1346 st.nodetype =
1347 lqn.sched[sidx] == SchedStrategy::INF ? NodeType::Delay : NodeType::Queue;
1348 st.sched = lqn.sched[sidx];
1349 // setNumberOfServers no-op rationale: see _kb/06-solver-catalog.md (cpp port notes: solver_ln.h)
1350 st.nservers = lqn.sched[sidx] == SchedStrategy::INF
1351 ? std::numeric_limits<double>::infinity()
1352 : mult[sidx];
1353 st.attr_ishost = flat ? sishost : ishostlayer;
1354 st.attr_idx = sidx;
1355 srv[sidx][r] = m.add_station(st);
1356 srvnode[sidx].push_back(m.node_of_station(srv[sidx][r]));
1357 }
1358 m.server_idx_of[sidx] = srv[sidx][0];
1359 if (sishost)
1360 m.host_stations.push_back(srv[sidx][0]);
1361 else
1362 m.task_stations.push_back(srv[sidx][0]);
1363 }
1364 // the layer's own server, the sole server under `srvn`
1365 const std::vector<std::size_t>& server = srv[idx];
1366 const std::vector<std::size_t>& serverNode = srvnode[idx];
1367
1368 /** Stations of ELEM when it is served in this layer, empty otherwise. */
1369 auto servers_for = [&](std::size_t elem) -> const std::vector<std::size_t>& {
1370 static const std::vector<std::size_t> none;
1371 if (elem >= 1 && elem <= lqn.nidx && !srv[elem].empty()) return srv[elem];
1372 return none;
1373 };
1374 auto server_nodes_for = [&](std::size_t elem) -> const std::vector<std::size_t>& {
1375 static const std::vector<std::size_t> none;
1376 if (elem >= 1 && elem <= lqn.nidx && !srvnode[elem].empty()) return srvnode[elem];
1377 return none;
1378 };
1379 /** True when the processor of task TIDX is a server of this layer. */
1380 auto host_is_server = [&](std::size_t tidx_) {
1381 return !servers_for(lqn.parent[tidx_]).empty();
1382 };
1383 /** True when TIDX calls an entry served by this layer. */
1384 auto is_layer_client = [&](std::size_t tidx_) {
1385 for (std::size_t s : idxSet)
1386 for (std::size_t e : lqn.entriesof[s])
1387 if (lqn.issynccaller.get(tidx_, e)) return true;
1388 return false;
1389 };
1390 /** Declare a class at every server station of this layer. */
1391 auto set_all_servers = [&](std::size_t cl, const Distrib<T>& d) {
1392 for (std::size_t s : idxSet)
1393 for (std::size_t st : srv[s]) m.set_service(st, cl, d);
1394 };
1395
1396 // Routed call groups, resolved from target ENTRIES to the call indices
1397 // that reach them: a group is ONE dispatch with n destinations, so its
1398 // members share a call class and a dispatch class that holds the job at
1399 // the client while the target is picked (callGroupsByCidx in
1400 // buildLayersRecursive.m).
1401 std::vector<std::size_t> group_of_call(lqn.ncalls + 1, 0);
1402 std::vector<std::vector<std::size_t>> group_members(lqn.callgroups.size() + 1);
1403 for (std::size_t g = 0; g < lqn.callgroups.size(); ++g) {
1404 const LqnCallGroup& grp = lqn.callgroups[g];
1405 for (std::size_t tgt : grp.targets)
1406 for (std::size_t cidx : lqn.callsof[grp.caller])
1407 if (lqn.callpair_dst[cidx] == tgt && lqn.calltype[cidx] == CallType::SYNC &&
1408 group_of_call[cidx] == 0) {
1409 group_of_call[cidx] = g + 1;
1410 group_members[g + 1].push_back(cidx);
1411 break;
1412 }
1413 }
1414 // per group: the Router node, the dispatch class and the group class
1415 std::vector<std::size_t> grp_router(lqn.callgroups.size() + 1, 0);
1416 std::vector<std::size_t> grp_dispatch(lqn.callgroups.size() + 1, 0);
1417 std::vector<std::size_t> grp_class(lqn.callgroups.size() + 1, 0);
1418 // (router node, dispatch class, group) triples whose strategy is
1419 // installed once the routing is written
1420 std::vector<std::array<std::size_t, 3>> routed_group_sites;
1421
1422 // Fork/Router/Join construction rationale: see _kb/06-solver-catalog.md (cpp port notes: solver_ln.h)
1423 std::vector<std::size_t> acts_in_caller;
1424 for (std::size_t c : callers)
1425 for (std::size_t a : lqn.actsof[c]) acts_in_caller.push_back(a);
1426 bool hasfork = false, hasjoin = false;
1427 std::size_t maxfanout = 1;
1428 for (std::size_t a : acts_in_caller) {
1429 if (lqn.actposttype[a] == PrecedenceType::POST_AND) hasfork = true;
1430 if (lqn.actpretype[a] == PrecedenceType::PRE_AND) hasjoin = true;
1431 std::size_t nand = 0;
1432 for (std::size_t sx : lqn.graph.succ(a))
1433 if (lqn.actposttype[sx] == PrecedenceType::POST_AND) ++nand;
1434 if (nand > maxfanout) maxfanout = nand;
1435 }
1436 std::size_t forkNode = 0, joinNode = 0, joinStation = 0;
1437 std::vector<std::size_t> forkRouter;
1438 if (hasfork) {
1439 forkNode = m.add_node("Fork_PostAnd", NodeType::Fork, false);
1440 for (std::size_t f = 1; f <= maxfanout; ++f)
1441 forkRouter.push_back(
1442 m.add_node("Fork_PostAnd_" + std::to_string(f), NodeType::Router, true));
1443 }
1444 if (hasjoin) {
1445 qn::Station<T> js;
1446 js.name = "Join_PreAnd";
1447 js.nodetype = NodeType::Join;
1448 js.sched = SchedStrategy::INF;
1449 js.nservers = std::numeric_limits<double>::infinity();
1450 js.attr_ishost = false;
1451 js.attr_idx = 0;
1452 joinStation = m.add_station(js);
1453 joinNode = m.node_of_station(joinStation);
1454 if (forkNode) m.fj.emplace_back(forkNode, joinNode);
1455 }
1456 // A CACHE LAYER is a HOST layer all of whose callers are cache tasks,
1457 // buildLayersRecursive.m:88. The Cache node lives here and not in the
1458 // cache task's own layer: the item lookup is what the task DOES on its
1459 // processor, so the hit and miss branches have to queue at the same
1460 // server the read arrived at.
1461 bool iscachelayer = !flat && ishostlayer && !callers.empty();
1462 for (std::size_t c : callers)
1463 if (!lqn.iscache[c]) iscachelayer = false;
1464 // A SETUP NO LONGER CHANGES HOW A LAYER IS BUILT (buildLayersRecursive.m,
1465 // 2026-08-11). Wiring the setup/delay-off onto the server station routed
1466 // the layer through the open M/G/1-with-setup QBD, which reads the idle
1467 // period off the Poisson rate 1/X and so powers the thread down far more
1468 // often than a CLOSED layer does -- 12.67% below LDES on lqn_setup -- and
1469 // it charged the restart to the ACTIVITY, where it is not host demand.
1470 // The cold start is charged to the ENTRY instead, with the probability
1471 // that the thread was really found down: see setup_charge().
1472 std::size_t cacheNode = 0;
1473 qn::CacheParam<T> cachepar;
1474 if (iscachelayer) {
1475 const std::size_t ct = callers[0];
1476 cachepar.nitems = lqn.nitems[ct];
1477 cachepar.itemcap = lqn.itemcap[ct];
1478 cachepar.replacestrat = lqn.replacestrat[ct];
1479 cacheNode = m.add_node(lqn.hashnames[ct], NodeType::Cache, true);
1480 }
1481
1482 // The open streams this layer carries, decided BEFORE the Source is
1483 // added: m.serverIdx is the client index plus one and replica r is
1484 // m.serverIdx + r, so a station inserted between them would silently
1485 // renumber the servers. Source and Sink therefore come last.
1486 // An async call is declared only in the layer whose server owns the
1487 // called entry, which is serversFor(parent(dst)) in the reference
1488 // (buildLayersRecursive.m:327) and the same test route_sync_call uses.
1489 std::vector<std::size_t> async_here;
1490 for (std::size_t c : callers)
1491 for (std::size_t aidx : lqn.actsof[c])
1492 for (std::size_t cidx : lqn.callsof[aidx])
1493 if (lqn.calltype[cidx] == CallType::ASYNC &&
1494 !servers_for(lqn.parent[lqn.callpair_dst[cidx]]).empty())
1495 async_here.push_back(cidx);
1496 std::vector<std::size_t> open_entries;
1497 for (std::size_t c : callers) {
1498 // An arrival that is the only way into the task is carried by the caller
1499 // CHAIN, not by a stream: that task has no task layer, so nothing ever sets
1500 // its surrogate delay, and a chain cycling against an Immediate one plus a
1501 // stream loads the host twice -- lqn_open_arrival's processor read 0.68
1502 // against 0.32 from lqns, lqsim and LDES. update_think_times closes the
1503 // chain on the known rate instead, as it does for a forwarding target.
1504 if (open_arrival_rate_of(c) > GlobalConstants::FineTol) continue;
1505 for (std::size_t eidx : lqn.entriesof[c])
1506 if (lqn.has_arrival[eidx]) open_entries.push_back(eidx);
1507 }
1508
1509 std::size_t sourceStation = 0, sourceNode = 0, sinkNode = 0;
1510 if (!async_here.empty() || !open_entries.empty()) {
1511 qn::Station<T> src;
1512 src.name = "Source";
1513 src.nodetype = NodeType::Source;
1514 // EXT keeps solver_mva out of both infSET and qSET: the Source is lambda, not a queue
1515 src.sched = SchedStrategy::EXT;
1516 src.nservers = 1.0;
1517 src.attr_ishost = false;
1518 src.attr_idx = 0;
1519 sourceStation = m.add_station(src);
1520 sourceNode = m.node_of_station(sourceStation);
1521 sinkNode = m.add_node("Sink", NodeType::Sink, false);
1522 m.sourceIdx = sourceStation;
1523 m.sinkNode = sinkNode;
1524 }
1525
1526 /** The stack of entry classes at the forks currently open. */
1527 std::vector<std::size_t> forkClassStack;
1528
1529 // class index of each LQN element and each call, 0 when absent
1530 std::vector<std::size_t> cls(lqn.nidx + 1, 0);
1531 std::vector<std::size_t> callcls(lqn.ncalls + 1, 0);
1532 // `<call>.Aux`, present only for a call whose mean count differs from the
1533 // replica count; 0 elsewhere. See the routing in route_sync_call.
1534 std::vector<std::size_t> auxcallcls(lqn.ncalls + 1, 0);
1535
1536 // A caller carries a closed class when its own PROCESSOR is served here
1537 // and something drives it (it is a reference task, is called, is a
1538 // forwarding target or takes an arrival), or when it calls an entry
1539 // served here. Under `srvn` the first disjunct is exactly the host
1540 // layer and the second exactly the task layer, so this reduces to the
1541 // two-branch test it replaces (buildLayersRecursive.m:217).
1542 auto caller_needs_class = [&](std::size_t tidx_caller) {
1543 if (host_is_server(tidx_caller)) {
1544 if (lqn.isref[tidx_caller]) return true;
1545 for (std::size_t e : lqn.entriesof[tidx_caller])
1546 if (lqn.issynccaller.any_col(e) || lqn.isasynccaller.any_col(e) ||
1547 is_fwd_target(e) || lqn.has_arrival[e])
1548 return true;
1549 }
1550 return is_layer_client(tidx_caller);
1551 };
1552 auto caller_acts_visible = [&](std::size_t tidx_caller) {
1553 return host_is_server(tidx_caller) || is_layer_client(tidx_caller);
1554 };
1555
1556 // ---- first pass: create the classes --------------------------------
1557 for (std::size_t tidx_caller : callers) {
1558 if (caller_needs_class(tidx_caller)) {
1559 double nj = njobs(tidx_caller, idx);
1560 if (nj == 0.0) {
1561 // representative-replica scaling rationale: see _kb/06-solver-catalog.md (cpp port notes: solver_ln.h)
1562 const bool caller_single_replica =
1563 reduce_fanout || single_replica_tasks.count(tidx_caller) > 0;
1564 nj = caller_single_replica ? mult[tidx_caller]
1565 : mult[tidx_caller] * lqn.repl[tidx_caller];
1566 if (std::isinf(nj)) {
1567 double s = 0.0;
1568 for (std::size_t c = 1; c <= NT(); ++c)
1569 if (lqn.taskgraph.get(c, tidx_caller) != Tzero()) s += mult[c];
1570 nj = s;
1571 if (std::isinf(nj)) {
1572 double s2 = 0.0;
1573 for (std::size_t c = 1; c <= NT(); ++c)
1574 if (std::isfinite(mult[c])) s2 += mult[c] * lqn.repl[c];
1575 nj = std::min(s2, 1000.0);
1576 }
1577 }
1578 njobs(tidx_caller, idx) = nj;
1579 }
1580 qn::JobClass jc;
1581 jc.name = lqn.hashnames[tidx_caller];
1582 jc.type = JobClassType::CLOSED;
1583 jc.population = nj;
1584 jc.refstat = m.clientIdx;
1585 jc.completes = false;
1586 jc.is_ref_class = true;
1587 jc.attr_kind = int(LqnElement::TASK);
1588 jc.attr_idx = tidx_caller;
1589 cls[tidx_caller] = m.add_class(jc);
1590 m.attr_tasks.emplace_back(cls[tidx_caller], tidx_caller);
1591 if (lqn.isref[tidx_caller]) {
1592 m.set_service(m.clientIdx, cls[tidx_caller], thinkproc[tidx_caller]);
1593 } else {
1594 // a served task's declared think time is not a per-request
1595 // delay, so the seed carries none either; update_think_times
1596 // replaces this from the first iteration on
1597 m.set_service(m.clientIdx, cls[tidx_caller], Distrib<T>::immediate());
1598 thinkt_map.push_back({idx, tidx_caller, m.clientIdx, cls[tidx_caller]});
1599 }
1600
1601 for (std::size_t eidx : lqn.entriesof[tidx_caller]) {
1602 qn::JobClass ec;
1603 ec.name = lqn.hashnames[eidx];
1604 ec.type = JobClassType::CLOSED;
1605 ec.population = 0.0;
1606 ec.refstat = m.clientIdx;
1607 ec.completes = false;
1608 ec.attr_kind = int(LqnElement::ENTRY);
1609 ec.attr_idx = eidx;
1610 cls[eidx] = m.add_class(ec);
1611 m.attr_entries.emplace_back(cls[eidx], eidx);
1612 m.set_service(m.clientIdx, cls[eidx], Distrib<T>::immediate());
1613 }
1614 }
1615
1616 for (std::size_t aidx : lqn.actsof[tidx_caller]) {
1617 if (caller_acts_visible(tidx_caller)) {
1618 qn::JobClass ac;
1619 ac.name = lqn.hashnames[aidx];
1620 ac.type = JobClassType::CLOSED;
1621 ac.population = 0.0;
1622 ac.refstat = m.clientIdx;
1623 ac.completes = false;
1624 ac.attr_kind = int(LqnElement::ACTIVITY);
1625 ac.attr_idx = aidx;
1626 cls[aidx] = m.add_class(ac);
1627 m.attr_activities.emplace_back(cls[aidx], aidx);
1628 // The host demand is served at the processor's own station
1629 // when this layer holds it; everywhere else the activity is
1630 // a surrogate delay at the client.
1631 const std::size_t hidx = lqn.parent[lqn.parent[aidx]];
1632 if (servers_for(hidx).empty())
1633 m.set_service(m.clientIdx, cls[aidx], servtproc[aidx]);
1634 }
1635 for (std::size_t cidx : lqn.callsof[aidx]) {
1636 if (lqn.calltype[cidx] == CallType::ASYNC) {
1637 // An async call is a stream, not a visit: the caller does
1638 // not block, so it carries no closed class and instead
1639 // drives an open chain of its own out of the Source.
1640 const std::size_t adst = lqn.parent[lqn.callpair_dst[cidx]];
1641 if (servers_for(adst).empty()) continue;
1642 qn::JobClass oc;
1643 oc.name = lqn.callhashnames[cidx];
1644 oc.type = JobClassType::OPEN;
1645 oc.population = std::numeric_limits<double>::infinity();
1646 oc.refstat = sourceStation;
1647 oc.completes = false;
1648 oc.is_ref_class = false;
1649 oc.attr_kind = int(LqnElement::CALL);
1650 oc.attr_idx = cidx;
1651 callcls[cidx] = m.add_class(oc);
1652 m.attr_calls.push_back({callcls[cidx], cidx, lqn.callpair_src[cidx],
1653 lqn.callpair_dst[cidx]});
1654 // A NEGLIGIBLE seed, not the reference's Immediate. The
1655 // rate is replaced from the caller's throughput on the
1656 // first post(), but apply_sink_closure weights the
1657 // Sink->Source arcs by it in between, and Immediate is
1658 // rate 1/0: the closed classes sharing the closure then
1659 // get NaN visits. fj_mmt seeds its own open classes the
1660 // same way, for the same reason.
1661 m.set_service(sourceStation, callcls[cidx],
1663 num_traits<T>::from_double(GlobalConstants::FineTol)));
1664 T minRespTA = Tzero();
1665 for (std::size_t ta : lqn.actsof[adst]) minRespTA += lqn.hostdem[ta].mean;
1666 for (std::size_t st : servers_for(adst)) {
1667 m.set_service(st, callcls[cidx], Distrib<T>::exp_mean(minRespTA));
1668 call_map.push_back({idx, cidx, st, callcls[cidx]});
1669 }
1670 arv_call_map.push_back({idx, cidx, sourceStation, callcls[cidx]});
1671 continue;
1672 }
1673 if (lqn.calltype[cidx] != CallType::SYNC) continue;
1674 const std::size_t gid = group_of_call[cidx];
1675 if (gid != 0) {
1676 // ONE dispatch with n destinations. The members SHARE the
1677 // dispatch class, which is both the class the strategy
1678 // routes and the class that visits the targets: the hop
1679 // must not switch class, because a state-dependent
1680 // routing function is evaluated at zero off the class
1681 // diagonal. The class switch goes on the return arc
1682 // instead, into a group class the job continues in.
1683 if (grp_dispatch[gid] == 0) {
1684 // The strategy is a property of a NODE and routes
1685 // over that node's links, not over one class's arcs,
1686 // so a dedicated Router whose only links are the
1687 // group's targets is the only place where the choice
1688 // is exactly the group's.
1689 const std::string tag =
1690 lqn.hashnames[aidx] + ".Dispatch" + std::to_string(gid);
1691 grp_router[gid] = m.add_node(tag + ".Router", NodeType::Router, true);
1692 qn::JobClass dc;
1693 dc.name = tag;
1694 dc.type = JobClassType::CLOSED;
1695 dc.population = 0.0;
1696 dc.refstat = m.clientIdx;
1697 dc.completes = false;
1698 dc.attr_kind = int(LqnElement::CALL);
1699 dc.attr_idx = cidx;
1700 grp_dispatch[gid] = m.add_class(dc);
1701 m.set_service(m.clientIdx, grp_dispatch[gid], Distrib<T>::immediate());
1702 qn::JobClass gc;
1703 gc.name = lqn.callhashnames[cidx] + ".Group" + std::to_string(gid);
1704 gc.type = JobClassType::CLOSED;
1705 gc.population = 0.0;
1706 gc.refstat = m.clientIdx;
1707 gc.completes = false;
1708 gc.attr_kind = int(LqnElement::CALL);
1709 gc.attr_idx = cidx;
1710 grp_class[gid] = m.add_class(gc);
1711 m.set_service(m.clientIdx, grp_class[gid], Distrib<T>::immediate());
1712 routed_group_sites.push_back(
1713 {grp_router[gid], grp_dispatch[gid], gid});
1714 }
1715 callcls[cidx] = grp_dispatch[gid];
1716 m.attr_calls.push_back({callcls[cidx], cidx, lqn.callpair_src[cidx],
1717 lqn.callpair_dst[cidx]});
1718 for (std::size_t st2 : servers_for(lqn.parent[lqn.callpair_dst[cidx]]))
1719 m.set_service(st2, callcls[cidx], callservtproc[cidx]);
1720 continue;
1721 }
1722 qn::JobClass cc;
1723 cc.name = lqn.callhashnames[cidx];
1724 cc.type = JobClassType::CLOSED;
1725 cc.population = 0.0;
1726 cc.refstat = m.clientIdx;
1727 cc.completes = false;
1728 cc.attr_kind = int(LqnElement::CALL);
1729 cc.attr_idx = cidx;
1730 callcls[cidx] = m.add_class(cc);
1731 m.attr_calls.push_back({callcls[cidx], cidx, lqn.callpair_src[cidx],
1732 lqn.callpair_dst[cidx]});
1733 // An upper bound on the server's response, replaced at the
1734 // first iteration by the measured one. The station seeded is
1735 // the CALLEE's under `flat` and the layer's own under
1736 // `srvn`, where the latter also seeds calls that leave the
1737 // layer -- a phantom rate on a class with no visits here,
1738 // kept because removing it moves the fixed point (see
1739 // seedCallService in buildLayersRecursive.m).
1740 const std::size_t seedidx = flat ? lqn.parent[lqn.callpair_dst[cidx]] : idx;
1741 T minRespT = Tzero();
1742 for (std::size_t ta : lqn.actsof[seedidx]) minRespT += lqn.hostdem[ta].mean;
1743 for (std::size_t st : servers_for(seedidx))
1744 m.set_service(st, callcls[cidx], Distrib<T>::exp_mean(minRespT));
1745 // .Aux class rationale: see _kb/06-solver-catalog.md (cpp port notes: solver_ln.h)
1746 if (lqn.callproc_mean[cidx] !=
1747 num_traits<T>::from_double(double(nreplicas))) {
1748 qn::JobClass xc;
1749 xc.name = lqn.callhashnames[cidx] + ".Aux";
1750 xc.type = JobClassType::CLOSED;
1751 xc.population = 0.0;
1752 xc.refstat = m.clientIdx;
1753 xc.completes = false;
1754 xc.attr_kind = int(LqnElement::CALL);
1755 xc.attr_idx = cidx;
1756 auxcallcls[cidx] = m.add_class(xc);
1757 m.set_service(m.clientIdx, auxcallcls[cidx], Distrib<T>::immediate());
1758 }
1759 }
1760 }
1761 }
1762
1763 // ---- second pass: routing out of the entries -------------------------
1764 struct Ctx {
1765 std::size_t curclass;
1766 int jobpos; // 1 = at the client, 2 = at a server replica, 3 = at the Cache
1767 // The nodes the job stands at when jobpos is atServer. Under `srvn`
1768 // these are always the layer's own server replicas; under `flat`
1769 // they are whichever element's stations the last hop landed on, so
1770 // the position has to be carried rather than assumed.
1771 std::vector<std::size_t> curnodes;
1772 };
1773 const int atClient = 1, atServer = 2, atCache = 3;
1774 std::vector<int> jobposkey(lqn.nidx + 1, atClient);
1775 std::vector<std::size_t> curclasskey(lqn.nidx + 1, 0);
1776 std::vector<std::vector<std::size_t>> curnodeskey(lqn.nidx + 1);
1777
1778 std::function<Ctx(std::size_t, std::size_t, Ctx)> recur =
1779 [&](std::size_t tidx_caller, std::size_t aidx, Ctx st) -> Ctx {
1780 jobposkey[aidx] = st.jobpos;
1781 curclasskey[aidx] = st.curclass;
1782 curnodeskey[aidx] = st.curnodes;
1783 const std::vector<std::size_t> nexts = lqn.graph.succ(aidx);
1784 std::size_t lastEntryClass = st.curclass;
1785 // fork pre-state save/restore rationale: see _kb/06-solver-catalog.md (cpp port notes: solver_ln.h)
1786 bool next_is_fork = false;
1787 for (std::size_t sx : nexts)
1788 if (lqn.actposttype[sx] == PrecedenceType::POST_AND) next_is_fork = true;
1789 const Ctx preFork = st;
1790 std::vector<std::size_t> andSuccs;
1791 for (std::size_t sx : nexts)
1792 if (lqn.actposttype[sx] == PrecedenceType::POST_AND) andSuccs.push_back(sx);
1793
1794 for (std::size_t k = 0; k < nexts.size(); ++k) {
1795 const std::size_t nextaidx = nexts[k];
1796 if (next_is_fork) st = preFork;
1797 bool isLoop = lqn.graph.get(aidx, nextaidx) != lqn.dag.get(aidx, nextaidx);
1798 if (lqn.parent[aidx] != lqn.parent[nextaidx]) {
1799 // a call to an entry of another task
1800 std::size_t cidx = 0;
1801 for (std::size_t c : lqn.callsof[aidx])
1802 if (lqn.callpair_dst[c] == nextaidx) cidx = c;
1803 if (cidx == 0) continue;
1804 // FWD lays down no routing: its work is already carried by the pseudo SYNC arc
1805 if (lqn.calltype[cidx] != CallType::SYNC) continue;
1806 const std::size_t gid = group_of_call[cidx];
1807 if (gid != 0) {
1808 // The whole group is routed once, at its FIRST member;
1809 // the others are the same dispatch and lay down nothing.
1810 if (group_members[gid].empty() || group_members[gid][0] != cidx) continue;
1811 // The job is switched into the dispatch class while still
1812 // at the client, so (client, dispatch) carries exactly
1813 // the n arcs the strategy chooses among. The 1/n split
1814 // laid down here is the probabilistic reading a solver
1815 // without state-dependent routing would see; the
1816 // declared strategy replaces it below.
1817 std::vector<std::size_t> tnode2, tstat2, tcall2;
1818 for (std::size_t mc : group_members[gid]) {
1819 const std::size_t tt = lqn.parent[lqn.callpair_dst[mc]];
1820 if (servers_for(tt).empty()) continue;
1821 tnode2.push_back(server_nodes_for(tt)[0]);
1822 tstat2.push_back(servers_for(tt)[0]);
1823 tcall2.push_back(mc);
1824 }
1825 if (tnode2.size() < 2) continue; // not enough of it is here
1826 const std::size_t fromNode2 =
1827 st.jobpos == atClient ? clientNode : st.curnodes[0];
1828 const std::size_t dc = grp_dispatch[gid], gc = grp_class[gid];
1829 m.set_route(st.curclass, dc, fromNode2, grp_router[gid], Tone());
1830 const T share2 =
1831 T(Tone() / num_traits<T>::from_int(int(tnode2.size())));
1832 for (std::size_t d = 0; d < tnode2.size(); ++d) {
1833 m.set_route(dc, dc, grp_router[gid], tnode2[d], share2);
1834 m.set_route(dc, gc, tnode2[d], clientNode, Tone());
1835 m.set_service(tstat2[d], dc, callservtproc[tcall2[d]]);
1836 call_map.push_back({idx, tcall2[d], tstat2[d], dc});
1837 }
1838 st.curclass = gc;
1839 st.jobpos = atClient;
1840 st.curnodes.clear();
1841 continue;
1842 }
1843 const std::size_t ctgt = lqn.parent[lqn.callpair_dst[cidx]];
1844 st = route_sync_call(m, idx, cidx, st, server_nodes_for(ctgt),
1845 servers_for(ctgt), callcls, auxcallcls, clientNode,
1846 atClient, atServer, flat);
1847 continue;
1848 }
1849 // a successor inside the same task
1850 bool any_entry_succ = false;
1851 for (std::size_t sx : nexts)
1852 if (sx > lqn.eshift && sx <= lqn.eshift + lqn.nentries) any_entry_succ = true;
1853 if (!any_entry_succ) {
1854 st.jobpos = jobposkey[aidx];
1855 st.curclass = curclasskey[aidx];
1856 st.curnodes = curnodeskey[aidx];
1857 } else {
1858 if (k > 0 && nexts[k - 1] > lqn.eshift && nexts[k - 1] <= lqn.eshift + lqn.nentries)
1859 lastEntryClass = st.curclass;
1860 st.jobpos = atClient;
1861 st.curclass = lastEntryClass;
1862 st.curnodes.clear();
1863 }
1864 const T w = lqn.graph.get(aidx, nextaidx);
1865
1866 // THE CACHE READ, buildLayersRecursive.m:743-765. `aidx` is the
1867 // item entry and `nextaidx` the activity bound to it: the job
1868 // goes from the client to the Cache node, which decides the hit
1869 // or the miss and switches the class accordingly, so the read
1870 // class itself is never served anywhere and the branch classes
1871 // pick the work up at the server.
1872 if (iscachelayer && lqn.nitems[aidx] > 0 && cls[nextaidx] != 0) {
1873 const std::size_t readcls = cls[nextaidx];
1874 m.set_route(st.curclass, readcls, clientNode, cacheNode, w);
1875 if (cachepar.pread.size() < m.classes.size())
1876 cachepar.pread.resize(m.classes.size());
1877 cachepar.pread[readcls - 1] = lqn.itemproc[aidx];
1878 const std::vector<std::size_t> hm = lqn.graph.succ(nextaidx);
1879 if (hm.size() != 2)
1880 throw InputError("SolverLN: the cache read '" + lqn.names[nextaidx] +
1881 "' needs exactly one hit and one miss successor");
1882 if (cachepar.hitclass.size() < m.classes.size()) {
1883 cachepar.hitclass.resize(m.classes.size(), 0);
1884 cachepar.missclass.resize(m.classes.size(), 0);
1885 }
1886 cachepar.hitclass[readcls - 1] = cls[hm[0]];
1887 cachepar.missclass[readcls - 1] = cls[hm[1]];
1888 st.jobpos = atCache;
1889 st.curclass = readcls;
1890 st.curnodes.clear();
1891 st = recur(tidx_caller, nextaidx, st);
1892 continue;
1893 }
1894
1895 const bool is_and_join_tail = lqn.actpretype[aidx] == PrecedenceType::PRE_AND;
1896 // the branch index of this successor among the fork's outputs
1897 std::size_t fbranch = 0;
1898 if (next_is_fork)
1899 for (std::size_t q = 0; q < andSuccs.size(); ++q)
1900 if (andSuccs[q] == nextaidx) fbranch = q + 1;
1901
1902 // Where the successor's HOST DEMAND is served: at the station of
1903 // its own processor when this layer holds it, at the client
1904 // otherwise. Under `srvn` that is the host layer's own server
1905 // and nothing else, so this reduces to the `ishostlayer` test
1906 // it replaces.
1907 const std::size_t hidxOf = lqn.parent[lqn.parent[nextaidx]];
1908 const std::vector<std::size_t>& actStations = servers_for(hidxOf);
1909 const std::vector<std::size_t>& actNodes = server_nodes_for(hidxOf);
1910 const bool actAtServer = !actStations.empty();
1911 const std::size_t from = st.jobpos == atClient ? clientNode
1912 : st.jobpos == atCache ? cacheNode
1913 : st.curnodes[0];
1914 // the node a job continues to after this successor is served
1915 for (std::size_t r = 0; r < nreplicas; ++r) {
1916 const std::size_t fromNode =
1917 st.jobpos == atClient ? clientNode
1918 : st.jobpos == atCache ? cacheNode
1919 : st.curnodes[std::min(r, st.curnodes.size() - 1)];
1920 const std::size_t toNode = actAtServer ? actNodes[r] : clientNode;
1921 if (next_is_fork && fbranch > 0) {
1922 m.set_route(st.curclass, st.curclass, fromNode, forkNode, Tone());
1923 if (r == 0) forkClassStack.push_back(st.curclass);
1924 m.set_route(st.curclass, st.curclass, forkNode, forkRouter[fbranch - 1],
1925 Tone());
1926 m.set_route(st.curclass, cls[nextaidx], forkRouter[fbranch - 1], toNode,
1927 Tone());
1928 } else if (is_and_join_tail) {
1929 // rejoin the class the branch was forked from, then
1930 // leave the Join in the successor's class
1931 if (forkClassStack.empty())
1932 throw InputError("SolverLN: an AND join has no matching fork in '" +
1933 lqn.names[aidx] + "'");
1934 const std::size_t forkClass = forkClassStack.back();
1935 if (r + 1 == nreplicas) forkClassStack.pop_back();
1936 m.set_route(st.curclass, forkClass, fromNode, joinNode, Tone());
1937 m.set_route(forkClass, cls[nextaidx], joinNode, toNode, Tone());
1938 } else {
1939 m.set_route(st.curclass, cls[nextaidx], fromNode, toNode, w);
1940 }
1941 if (actAtServer)
1942 m.set_service(actStations[r], cls[nextaidx], lqn.hostdem[nextaidx]);
1943 }
1944 (void)from;
1945 if (actAtServer) {
1946 st.jobpos = atServer;
1947 st.curclass = cls[nextaidx];
1948 st.curnodes = actNodes;
1949 servt_map.push_back({idx, nextaidx, actStations[0], cls[nextaidx]});
1950 } else {
1951 st.jobpos = atClient;
1952 st.curclass = cls[nextaidx];
1953 st.curnodes.clear();
1954 m.set_service(m.clientIdx, cls[nextaidx], servtproc[nextaidx]);
1955 thinkt_map.push_back({idx, nextaidx, m.clientIdx, cls[nextaidx]});
1956 }
1957 if (aidx != nextaidx && !isLoop) {
1958 st = recur(tidx_caller, nextaidx, st);
1959 // close the branch with a reply back to the caller's class
1960 if (st.jobpos == atClient) {
1961 m.set_route(st.curclass, cls[tidx_caller], clientNode, clientNode, Tone());
1962 } else {
1963 for (std::size_t nd : st.curnodes)
1964 m.set_route(st.curclass, cls[tidx_caller], nd, clientNode, Tone());
1965 }
1966 // .Aux completion-guard rationale: see _kb/06-solver-catalog.md (cpp port notes: solver_ln.h)
1967 if (!is_aux_class(m.classes[st.curclass - 1].name))
1968 m.classes[st.curclass - 1].completes = true;
1969 }
1970 }
1971 return st;
1972 };
1973
1974 for (std::size_t tidx_caller : callers) {
1975 if (!caller_needs_class(tidx_caller)) continue;
1976 const std::vector<std::size_t>& ents = lqn.entriesof[tidx_caller];
1977 const T share = T(Tone() / num_traits<T>::from_int(int(ents.size())));
1978 for (std::size_t eidx : ents) {
1979 m.set_route(cls[tidx_caller], cls[eidx], clientNode, clientNode, share);
1980 if (ents.size() > 1)
1981 route_map.push_back({idx, tidx_caller, eidx, m.clientIdx, m.clientIdx,
1982 cls[tidx_caller], cls[eidx]});
1983 Ctx st{cls[eidx], atClient, {}};
1984 recur(tidx_caller, eidx, st);
1985 }
1986 }
1987
1988 // ---- open streams, laid down AFTER the activity-graph walk ----------
1989 // buildLayersRecursive.m:473 puts the entry-arrival routing here for the
1990 // same reason: the walk above rewrites whole rows of P and would erase
1991 // an arc written before it. Both kinds route Source -> server -> Sink
1992 // within one class, so they never join a closed class in a chain --
1993 // refresh_chains would reject that, the two carrying different refstats.
1994 if (sourceStation != 0) {
1995 const T nrep = num_traits<T>::from_int(int(nreplicas));
1996 for (std::size_t cidx : async_here) {
1997 const std::size_t oc = callcls[cidx];
1998 if (oc == 0) continue;
1999 // the stream enters the CALLEE's station, which under `srvn` is
2000 // this layer's own server and under `flat` one among many
2001 const std::vector<std::size_t>& anode =
2002 server_nodes_for(lqn.parent[lqn.callpair_dst[cidx]]);
2003 const T callmean = lqn.callproc_mean[cidx];
2004 if (callmean < Tone()) {
2005 // fewer than one call per firing: a single Bernoulli pass
2006 m.set_route(oc, oc, sourceNode, sinkNode, T(Tone() - callmean));
2007 for (std::size_t r = 0; r < anode.size(); ++r) {
2008 m.set_route(oc, oc, sourceNode, anode[r], T(callmean / nrep));
2009 m.set_route(oc, oc, anode[r], sinkNode, Tone());
2010 }
2011 } else {
2012 // callmean visits in expectation, as a geometric self-loop
2013 const T p = T(Tone() / callmean);
2014 for (std::size_t r = 0; r < anode.size(); ++r) {
2015 m.set_route(oc, oc, sourceNode, anode[r], T(Tone() / nrep));
2016 for (std::size_t q = 0; q < anode.size(); ++q)
2017 m.set_route(oc, oc, anode[r], anode[q], T((Tone() - p) / nrep));
2018 m.set_route(oc, oc, anode[r], sinkNode, p);
2019 }
2020 }
2021 }
2022 for (std::size_t eidx : open_entries) {
2023 qn::JobClass eo;
2024 eo.name = lqn.hashnames[eidx] + "_Open";
2025 eo.type = JobClassType::OPEN;
2026 eo.population = std::numeric_limits<double>::infinity();
2027 eo.refstat = sourceStation;
2028 eo.completes = false;
2029 eo.is_ref_class = false;
2030 eo.attr_kind = int(LqnElement::ENTRY);
2031 eo.attr_idx = eidx;
2032 const std::size_t ec = m.add_class(eo);
2033 // the arrival is exogenous and fixed, so it is never reseeded
2034 m.set_service(sourceStation, ec, lqn.arrival[eidx]);
2035 // entries are Immediate; the work is the activity bound to them
2036 std::size_t bound = 0;
2037 for (std::size_t sx : lqn.graph.succ(eidx))
2038 if (bound == 0 && sx > lqn.ashift) bound = sx;
2039 const Distrib<T>& svc = bound != 0 ? servtproc[bound] : servtproc[eidx];
2040 // the arrival enters the processor of the entry's task under
2041 // host layering, and the task's own station under `flat`
2042 const std::vector<std::size_t>& ostat =
2043 flat ? servers_for(lqn.parent[eidx]) : server;
2044 const std::vector<std::size_t>& onode =
2045 flat ? server_nodes_for(lqn.parent[eidx]) : serverNode;
2046 for (std::size_t r = 0; r < ostat.size(); ++r) {
2047 m.set_service(ostat[r], ec, svc);
2048 m.set_route(ec, ec, sourceNode, onode[r], T(Tone() / nrep));
2049 m.set_route(ec, ec, onode[r], sinkNode, Tone());
2050 }
2051 }
2052 }
2053
2054 // ---- admission constraint on the server station ---------------------
2055 // buildLayersRecursive.m:596-625. The constraint is stated over LQN
2056 // elements and has to be re-expressed in the layer's own classes: a
2057 // task occupies its host through the classes of its ACTIVITIES, an
2058 // entry is occupied by the classes of the CALLS that target it. The
2059 // columns are summed into the class, never assigned, because several
2060 // classes can stand for one column.
2061 if (idx < lqn.lincon_A.size() && lqn.lincon_A[idx].rows() > 0) {
2062 const Matrix<T>& Aelem = lqn.lincon_A[idx];
2063 Matrix<T> Alayer(Aelem.rows(), m.classes.size(), Tzero());
2064 const std::vector<std::size_t>& constrained =
2065 ishostlayer ? lqn.tasksof[idx] : lqn.entriesof[idx];
2066 bool any = false;
2067 for (std::size_t j = 0; j < constrained.size() && j < Aelem.cols(); ++j) {
2068 std::vector<std::size_t> layerClasses;
2069 if (ishostlayer) {
2070 for (std::size_t a : lqn.actsof[constrained[j]])
2071 if (cls[a] != 0) layerClasses.push_back(cls[a]);
2072 } else {
2073 for (std::size_t c = 1; c <= lqn.ncalls; ++c)
2074 if (lqn.callpair_dst[c] == constrained[j] && callcls[c] != 0)
2075 layerClasses.push_back(callcls[c]);
2076 }
2077 for (std::size_t k = 0; k < layerClasses.size(); ++k)
2078 for (std::size_t rr = 0; rr < Aelem.rows(); ++rr) {
2079 Alayer(rr, layerClasses[k] - 1) =
2080 T(Alayer(rr, layerClasses[k] - 1) + Aelem(rr, j));
2081 if (Aelem(rr, j) != Tzero()) any = true;
2082 }
2083 }
2084 if (any) {
2085 // ONE region over every replica, not one each: the constraint
2086 // models a passive resource of the server as a whole (a
2087 // semaphore, a connection pool), so the replicas share tokens.
2088 typename qn::NetworkStruct<T>::Region rg;
2089 const std::size_t M = m.stations.size(), K = m.classes.size();
2090 rg.cap.assign(M, std::vector<double>(K + 1, -1.0));
2091 rg.maxmem.assign(M, -1.0);
2092 rg.members.assign(M, false);
2093 rg.rule.assign(K, lang::DropStrategy::WAITQ);
2094 rg.weight.assign(K, Tone());
2095 rg.size.assign(K, Tone());
2096 for (std::size_t r = 0; r < nreplicas; ++r) rg.members[server[r] - 1] = true;
2097 rg.lincon_A = Alayer;
2098 rg.lincon_b = lqn.lincon_b[idx];
2099 m.regions.push_back(rg);
2100 }
2101 }
2102
2103 // The Cache node's parameters are only complete once the walk has seen
2104 // every read: pread, hitclass and missclass are all per READ CLASS, and
2105 // the classes are created as the activity graph is traversed.
2106 if (cacheNode != 0) {
2107 cachepar.pread.resize(m.classes.size());
2108 cachepar.hitclass.resize(m.classes.size(), 0);
2109 cachepar.missclass.resize(m.classes.size(), 0);
2110 m.nodeparam[cacheNode] = cachepar;
2111 }
2112
2113 // The declared dispatch replaces the probabilistic split on the router,
2114 // and ONLY on the (router, dispatch class) pair whose arcs are exactly
2115 // the group's targets. The split stays in P underneath, which is what
2116 // `refresh_routing` re-expands for anything that reads a matrix; the SSA
2117 // engine walks the arcs itself and honours the strategy.
2118 for (const std::array<std::size_t, 3>& site : routed_group_sites) {
2119 qn::NodeDef& nd = m.nodes[site[0] - 1];
2120 if (nd.routing.size() < m.classes.size())
2121 nd.routing.resize(m.classes.size(), RoutingStrategy::PROB);
2122 nd.routing[site[1] - 1] = lqn.callgroups[site[2] - 1].strategy;
2123 }
2124
2125 // ---- queue-dependent service rates on the server station ------------
2126 // buildLayersRecursive.m:700-762. Declared over the server's OPERANDS
2127 // (the tasks of a host, the entries of a task) and re-expressed in the
2128 // layer's own classes, on the same mapping the admission constraint
2129 // uses above. The handles are evaluated in two index spaces -- CTMC and
2130 // the exact recursions pass a per-class vector, the AMVA and NC chain
2131 // recursions a per-chain one -- so each carries both column lists and
2132 // picks by the length of what it is handed. The chain list is only known
2133 // after refresh_chains, hence the shared slot filled just below.
2134 std::vector<std::pair<std::shared_ptr<std::vector<std::vector<std::size_t>>>,
2135 std::vector<std::vector<std::size_t>>>> deferred_chaincols;
2136 for (std::size_t sidx : idxSet) {
2137 const bool hasld = sidx < lqn.lldscaling.size() && !lqn.lldscaling[sidx].empty();
2138 const bool hascd = sidx < lqn.cdscaling.size() && bool(lqn.cdscaling[sidx]);
2139 const bool hasjd = sidx < lqn.jdscaling.size() && bool(lqn.jdscaling[sidx]);
2140 const bool haspools = sidx < lqn.pools.size() && !lqn.pools[sidx].empty();
2141 if (!(hasld || hascd || hasjd || haspools)) continue;
2142 const bool sishost = sidx <= lqn.nhosts;
2143 const std::vector<std::size_t>& operandIdx =
2144 sishost ? lqn.tasksof[sidx] : lqn.entriesof[sidx];
2145 std::vector<std::vector<std::size_t>> cols(operandIdx.size());
2146 for (std::size_t j = 0; j < operandIdx.size(); ++j) {
2147 if (sishost) {
2148 for (std::size_t a : lqn.actsof[operandIdx[j]])
2149 if (cls[a] != 0) cols[j].push_back(cls[a] - 1);
2150 } else {
2151 for (std::size_t c = 1; c <= lqn.ncalls; ++c)
2152 if (lqn.callpair_dst[c] == operandIdx[j] && callcls[c] != 0)
2153 cols[j].push_back(callcls[c] - 1);
2154 }
2155 }
2156 const std::size_t R = m.classes.size();
2157 std::shared_ptr<std::vector<std::vector<std::size_t>>> chaincols =
2158 std::make_shared<std::vector<std::vector<std::size_t>>>();
2159 deferred_chaincols.push_back(std::make_pair(chaincols, cols));
2160 for (std::size_t r = 0; r < nreplicas; ++r) {
2161 // The scalings are written straight onto the Station, as the
2162 // region block above writes m.regions: a Layer is a
2163 // NetworkStruct, not the NetworkBuilder that carries the
2164 // set_*_dependence helpers.
2165 qn::Station<T>& stn = m.stations[srv[sidx][r] - 1];
2166 if (hasld) stn.lldscaling = lqn.lldscaling[sidx];
2167 if (hascd) {
2168 // beta_{i,r} is product form only while an operand maps to a
2169 // single class; where it aggregates several, the same
2170 // scaling is emitted as a joint dependence, numerically
2171 // identical but no longer exact.
2172 bool one_class_each = true;
2173 for (std::size_t j = 0; j < cols.size(); ++j)
2174 if (cols[j].size() > 1) one_class_each = false;
2175 const lang::CdScaling<T> h =
2176 layer_dep_handle(lqn.cdscaling[sidx], cols, chaincols, R);
2177 const std::vector<T> pk = layer_peak(lqn.cdscalingpeak[sidx], cols, R);
2178 if (one_class_each) {
2179 stn.cdscaling = h;
2180 stn.cdscalingpeak = pk;
2181 } else {
2182 stn.jdscaling = h;
2183 stn.jdscalingpeak = pk;
2184 }
2185 }
2186 if (hasjd) {
2187 stn.jdscaling = layer_dep_handle(lqn.jdscaling[sidx], cols, chaincols, R);
2188 stn.jdscalingpeak = layer_peak(lqn.jdscalingpeak[sidx], cols, R);
2189 }
2190 if (haspools) {
2191 // A compatibility declaration IS a rate law: the pools clear
2192 // the activated-server rate of api::sn_compat_rate, order
2193 // independent at every integer state. sn_compat_scaling
2194 // normalises it against the rate the SAME population would
2195 // get under full compatibility, so eta isolates the
2196 // compatibility GRAPH and a fully-compatible pool is the
2197 // neutral eta == 1; the low-occupancy loss stays with the
2198 // solver's own multiserver term.
2199 const lqn::ServerPools<T>& pl = lqn.pools[sidx];
2200 const Matrix<T> compat = pl.compat;
2201 const std::vector<double> counts = pl.counts;
2202 const std::vector<T> rates = pl.rates;
2203 lang::CdScaling<T> etaPool = [compat, counts,
2204 rates](const std::vector<T>& nop) {
2205 return std::vector<T>(
2206 1, api::sn_compat_scaling(compat, counts, rates, nop));
2207 };
2208 stn.jdscaling = layer_dep_handle(etaPool, cols, chaincols, R);
2209 stn.jdscalingpeak = layer_peak(
2210 std::vector<T>(cols.size(), num_traits<T>::from_int(1)), cols, R);
2211 }
2212 }
2213 }
2214
2215 m.refresh_chains();
2216 // The chain columns of every handle emitted above, now that the chains
2217 // exist. The slots are shared with the handles, so filling them here
2218 // reaches every replica without rebuilding a single lambda.
2219 for (std::size_t d = 0; d < deferred_chaincols.size(); ++d) {
2220 const std::vector<std::vector<std::size_t>>& cols = deferred_chaincols[d].second;
2221 std::vector<std::vector<std::size_t>>& out = *deferred_chaincols[d].first;
2222 out.assign(cols.size(), std::vector<std::size_t>());
2223 for (std::size_t j = 0; j < cols.size(); ++j) {
2224 for (std::size_t k = 0; k < cols[j].size(); ++k)
2225 for (std::size_t cc = 0; cc < m.chains.size(); ++cc)
2226 if (cols[j][k] < m.chains[cc].size() && m.chains[cc][cols[j][k]]) {
2227 bool seen = false;
2228 for (std::size_t q = 0; q < out[j].size(); ++q)
2229 if (out[j][q] == cc) seen = true;
2230 if (!seen) out[j].push_back(cc);
2231 }
2232 }
2233 }
2234 // A layer holding a POST_AND fork is a fork-join model like any other,
2235 // and the reference builds it as a `Network`, so buildLayers's
2236 // `getStruct()` runs the MMT node-visit pass on it: layer P:P2 of
2237 // lqn_workflows reports a Join visit of 4.3333, not the 1 the routing
2238 // solve leaves. THE LATER `refresh_chains()` CALLS DO NOT REPEAT IT, and
2239 // that is the reference's behaviour rather than an oversight here:
2240 // SolverLN.m re-runs `refreshChains()` alone after a routing reset,
2241 // which recomputes the visits WITHOUT the pass.
2243 }
2244
2245 /**
2246 * Port of routeSynchCall (buildLayersRecursive.m).
2247 *
2248 * A synchronous call is made `callmean` times per visit to the calling
2249 * activity, against `nreplicas` server replicas. The branch is chosen by
2250 * callmean ALONE, against 1 and never against nreplicas: a Bernoulli pass
2251 * carries at most one call, more than one needs the geometric loop through
2252 * the `<call>.Aux` class, and the replicas only SPLIT each probability by
2253 * ntgt -- they never relax that bound (buildLayersRecursive.m:1028-1051).
2254 * Comparing against nreplicas instead agrees with the reference only at
2255 * nreplicas == 1; on lqn_sockshop's replicated P2_1 it put a callmean of 1
2256 * on the < branch and doubled T1's throughput and P2_1's utilization.
2257 *
2258 * A second class is needed because a self-loop on the call class would also
2259 * re-enter its service. Which of the two carries the call time depends on
2260 * the direction:
2261 *
2262 * callmean < 1 the mean count is folded into the DEMAND
2263 * (callservt = callmean * W), so the call class must
2264 * be visited exactly once or the time is discounted
2265 * twice; .Aux absorbs the remaining branch.
2266 * callmean > 1 the call class is re-entered, and .Aux is the
2267 * return path that closes the loop with probability
2268 * 1/callmean, giving a mean of callmean visits.
2269 *
2270 * The four cases below are (job at client / at server) x (call targets an
2271 * entry of THIS layer's server / of some other task).
2272 */
2273 struct CtxPair {
2274 std::size_t curclass;
2275 int jobpos;
2276 };
2277
2278 /** The `<call>.Aux` suffix, which is how the reference identifies them too. */
2279 static bool is_aux_class(const std::string& name) {
2280 return name.size() >= 4 && name.compare(name.size() - 4, 4, ".Aux") == 0;
2281 }
2282 template <class Ctx>
2283 Ctx route_sync_call(qn::Layer<T>& m, std::size_t idx, std::size_t cidx, Ctx st,
2284 const std::vector<std::size_t>& tnode,
2285 const std::vector<std::size_t>& tstat,
2286 const std::vector<std::size_t>& callcls,
2287 const std::vector<std::size_t>& auxcallcls, std::size_t clientNode,
2288 int atClient, int atServer, bool flat) {
2289 const T one = Tone();
2290 // The callee's stations in THIS layer, empty when it is served
2291 // elsewhere: under `srvn` that is the layer's own server or nothing,
2292 // under `flat` it is whichever of the many servers the call targets.
2293 const std::size_t ntgt = tnode.size();
2294 const bool to_this_server = ntgt > 0;
2295 const T nrep = num_traits<T>::from_int(int(ntgt > 0 ? ntgt : 1));
2296 const T share = T(one / nrep);
2297 const T callmean = lqn.callproc_mean[cidx];
2298 const std::size_t cc = callcls[cidx];
2299 const std::size_t ax = auxcallcls[cidx];
2300 const bool below = callmean < one;
2301 const bool above = callmean > one;
2302 if (st.jobpos == atClient) {
2303 if (to_this_server) {
2304 if (below) {
2305 m.set_route(st.curclass, ax, clientNode, clientNode, T(one - callmean));
2306 for (std::size_t r = 0; r < ntgt; ++r) {
2307 m.set_route(st.curclass, cc, clientNode, tnode[r], T(callmean / nrep));
2308 m.set_route(cc, cc, tnode[r], clientNode, one);
2309 }
2310 // keeps .Aux attached to the routing graph; carries no time
2311 m.set_route(ax, cc, clientNode, clientNode, one);
2312 } else if (above) {
2313 for (std::size_t r = 0; r < ntgt; ++r) {
2314 m.set_route(st.curclass, cc, clientNode, tnode[r], share);
2315 m.set_route(cc, ax, tnode[r], clientNode, one);
2316 m.set_route(ax, cc, clientNode, tnode[r], T((one - one / callmean) / nrep));
2317 }
2318 m.set_route(ax, cc, clientNode, clientNode, T(one / callmean));
2319 } else {
2320 for (std::size_t r = 0; r < ntgt; ++r) {
2321 m.set_route(st.curclass, cc, clientNode, tnode[r], share);
2322 m.set_route(cc, cc, tnode[r], clientNode, one);
2323 }
2324 }
2325 for (std::size_t r = 0; r < ntgt; ++r) {
2326 m.set_service(tstat[r], cc, callservtproc[cidx]);
2327 call_map.push_back({idx, cidx, tstat[r], cc});
2328 }
2329 m.set_service(m.clientIdx, cc, Distrib<T>::immediate());
2330 st.jobpos = atClient;
2331 st.curnodes.clear();
2332 st.curclass = cc;
2333 } else {
2334 m.set_route(st.curclass, cc, clientNode, clientNode, one);
2335 if (below || above) {
2336 m.set_route(cc, ax, clientNode, clientNode, one);
2337 st.curclass = ax;
2338 } else {
2339 st.curclass = cc;
2340 }
2341 st.jobpos = atClient;
2342 st.curnodes.clear();
2343 m.set_service(m.clientIdx, cc, callservtproc[cidx]);
2344 call_map.push_back({idx, cidx, m.clientIdx, cc});
2345 }
2346 } else {
2347 // the node the job stands at, one per replica of wherever it landed
2348 auto fromNode = [&](std::size_t r) {
2349 return st.curnodes[std::min(r, st.curnodes.size() - 1)];
2350 };
2351 if (to_this_server) {
2352 if (below) {
2353 // The skip and the call merge back in the CALL class at the
2354 // client, exactly as in the atClient branch above. Routing
2355 // the skip into the call class instead and leaving in .Aux
2356 // gives .Aux no inbound arc at all, so its chain has no
2357 // reference class (buildLayersRecursive.m:1100-1118). This
2358 // port did that until 2026-08-11 and the arm is the same
2359 // under both layerings, as it is in the reference.
2360 for (std::size_t r = 0; r < ntgt; ++r) {
2361 m.set_route(st.curclass, ax, fromNode(r), clientNode, T(one - callmean));
2362 m.set_route(st.curclass, cc, fromNode(r), tnode[r], T(callmean / nrep));
2363 m.set_route(cc, cc, tnode[r], clientNode, one);
2364 }
2365 m.set_route(ax, cc, clientNode, clientNode, one);
2366 m.set_service(m.clientIdx, cc, Distrib<T>::immediate());
2367 st.jobpos = atClient;
2368 st.curnodes.clear();
2369 st.curclass = cc;
2370 } else if (above) {
2371 if (flat) {
2372 // the geometric repeat transits the client between
2373 // visits; a self-loop would merge them into one
2374 for (std::size_t r = 0; r < ntgt; ++r) {
2375 m.set_route(st.curclass, cc, fromNode(r), tnode[r], one);
2376 m.set_route(cc, ax, tnode[r], clientNode, one);
2377 m.set_route(ax, cc, clientNode, tnode[r],
2378 T((one - one / callmean) / nrep));
2379 }
2380 m.set_route(ax, cc, clientNode, clientNode, T(one / callmean));
2381 m.set_service(m.clientIdx, cc, Distrib<T>::immediate());
2382 st.curclass = cc;
2383 } else {
2384 for (std::size_t r = 0; r < ntgt; ++r) {
2385 m.set_route(st.curclass, cc, fromNode(r), tnode[r], one);
2386 m.set_route(cc, cc, tnode[r], tnode[r], T(one - one / callmean));
2387 m.set_route(cc, ax, tnode[r], clientNode, T(one / callmean));
2388 }
2389 st.curclass = ax;
2390 }
2391 st.jobpos = atClient;
2392 st.curnodes.clear();
2393 } else {
2394 for (std::size_t r = 0; r < ntgt; ++r)
2395 m.set_route(st.curclass, cc, fromNode(r), tnode[r], one);
2396 if (flat) {
2397 // the reply returns the job to the client, which is
2398 // where the successor restoration expects it
2399 for (std::size_t r = 0; r < ntgt; ++r)
2400 m.set_route(cc, cc, tnode[r], clientNode, one);
2401 m.set_service(m.clientIdx, cc, Distrib<T>::immediate());
2402 st.jobpos = atClient;
2403 st.curnodes.clear();
2404 } else {
2405 st.jobpos = atServer;
2406 st.curnodes = tnode;
2407 }
2408 st.curclass = cc;
2409 }
2410 for (std::size_t r = 0; r < ntgt; ++r) {
2411 m.set_service(tstat[r], cc, callservtproc[cidx]);
2412 call_map.push_back({idx, cidx, tstat[r], cc});
2413 }
2414 } else {
2415 for (std::size_t nd : st.curnodes)
2416 m.set_route(st.curclass, cc, nd, clientNode, one);
2417 if (below || above) {
2418 m.set_route(cc, ax, clientNode, clientNode, one);
2419 st.curclass = ax;
2420 } else {
2421 st.curclass = cc;
2422 }
2423 st.jobpos = atClient;
2424 st.curnodes.clear();
2425 m.set_service(m.clientIdx, cc, callservtproc[cidx]);
2426 call_map.push_back({idx, cidx, m.clientIdx, cc});
2427 }
2428 }
2429 return st;
2430 }
2431
2432 // -----------------------------------------------------------------------
2433 // getEntryServiceMatrix
2434 // -----------------------------------------------------------------------
2435
2436 /**
2437 * Reachability of activities and calls from each entry, as a 0/1 matrix over
2438 * the combined element-and-call index space. Multiplying it by
2439 * [residt; callresidt] sums an entry's whole service into one number.
2440 */
2441 void build_entry_service_matrix() {
2442 const std::size_t dim = lqn.nidx + lqn.ncalls;
2443 servtmatrix = Matrix<T>(dim + 1, dim + 1, Tzero());
2444 std::function<void(std::size_t, std::size_t)> rec = [&](std::size_t aidx, std::size_t eidx) {
2445 for (std::size_t nextaidx : lqn.graph.succ(aidx)) {
2446 const bool isLoop = lqn.graph.get(aidx, nextaidx) != lqn.dag.get(aidx, nextaidx);
2447 if (lqn.parent[aidx] != lqn.parent[nextaidx]) {
2448 for (std::size_t cidx : lqn.callsof[aidx])
2449 if (lqn.calltype[cidx] == CallType::SYNC)
2450 servtmatrix(eidx, lqn.nidx + cidx) = Tone();
2451 } else if (nextaidx != aidx && !isLoop) {
2452 servtmatrix(eidx, nextaidx) = Tone();
2453 rec(nextaidx, eidx);
2454 }
2455 }
2456 };
2457 for (std::size_t e = 1; e <= lqn.nentries; ++e) {
2458 const std::size_t eidx = lqn.eshift + e;
2459 rec(eidx, eidx);
2460 }
2461 }
2462
2463 // -----------------------------------------------------------------------
2464 // initInterlock
2465 // -----------------------------------------------------------------------
2466
2467 /** Port of initInterlock: the LQNS V5 static interlock analysis. */
2468 void init_interlock() {
2469 const std::size_t NE = lqn.nentries;
2470 il_all = Matrix<T>(NE + 1, NE + 1, Tzero());
2471 il_ph1 = Matrix<T>(NE + 1, NE + 1, Tzero());
2472
2473 std::function<void(std::size_t, std::size_t, T, T, std::vector<bool>&, int)> trace =
2474 [&](std::size_t eidx, std::size_t root_e, T pall, T pph1, std::vector<bool>& visited,
2475 int depth) {
2476 if (eidx <= lqn.eshift || eidx > lqn.eshift + NE) return;
2477 const std::size_t e = eidx - lqn.eshift;
2478 if (visited[e]) return;
2479 visited[e] = true;
2480 il_all(root_e, e) = T(il_all(root_e, e) + pall);
2481 il_ph1(root_e, e) = T(il_ph1(root_e, e) + pph1);
2482 for (std::size_t aidx : lqn.actsof[eidx]) {
2483 if (aidx <= lqn.ashift || aidx > lqn.ashift + lqn.nacts) continue;
2484 const std::size_t a = aidx - lqn.ashift;
2485 if (depth > 0 && lqn.actphase[a] > 1) continue;
2486 const bool is_ph1 = lqn.actphase[a] <= 1;
2487 for (std::size_t cidx : lqn.callsof[aidx]) {
2488 if (lqn.calltype[cidx] != CallType::SYNC) continue;
2489 if (!(lqn.callproc_mean[cidx] > Tzero())) continue;
2490 const std::size_t dst = lqn.callpair_dst[cidx];
2491 if (dst <= lqn.eshift || dst > lqn.eshift + NE) continue;
2492 trace(dst, root_e, T(pall * lqn.callproc_mean[cidx]),
2493 is_ph1 ? T(pph1 * lqn.callproc_mean[cidx]) : Tzero(), visited,
2494 depth + 1);
2495 }
2496 }
2497 visited[e] = false;
2498 };
2499 for (std::size_t e = 1; e <= NE; ++e) {
2500 std::vector<bool> visited(NE + 1, false);
2501 trace(lqn.eshift + e, e, Tone(), Tone(), visited, 0);
2502 }
2503
2504 il_common_entries.assign(NT() + 1, {});
2505 il_src_all.assign(NT() + 1, {});
2506 il_src_ph2.assign(NT() + 1, {});
2507 il_num_sources.assign(NT() + 1, 0.0);
2508
2509 for (std::size_t t = 1; t <= lqn.ntasks; ++t) {
2510 const std::size_t tidx = lqn.tshift + t;
2511 if (lqn.isref[tidx] || lqn.sched[tidx] == SchedStrategy::INF) continue;
2512 interlock_for_server(tidx);
2513 }
2514 for (std::size_t h = 1; h <= lqn.nhosts; ++h) {
2515 if (lqn.sched[h] == SchedStrategy::INF) continue;
2516 interlock_for_server(h);
2517 }
2518 }
2519
2520 std::vector<std::size_t> server_entry_nums(std::size_t serverIdx) const {
2521 std::vector<std::size_t> out;
2522 if (serverIdx <= lqn.nhosts) {
2523 for (std::size_t tidx : lqn.tasksof[serverIdx])
2524 for (std::size_t se : lqn.entriesof[tidx]) out.push_back(se - lqn.eshift);
2525 } else {
2526 for (std::size_t se : lqn.entriesof[serverIdx]) out.push_back(se - lqn.eshift);
2527 }
2528 return out;
2529 }
2530
2531 std::vector<std::size_t> client_tasks(std::size_t serverIdx) const {
2532 std::vector<std::size_t> out;
2533 if (serverIdx <= lqn.nhosts) return lqn.tasksof[serverIdx];
2534 for (std::size_t se : lqn.entriesof[serverIdx])
2535 for (std::size_t ci : lqn.iscaller.col(se))
2536 if (ci > lqn.tshift && ci <= NT()) out.push_back(ci);
2537 std::sort(out.begin(), out.end());
2538 out.erase(std::unique(out.begin(), out.end()), out.end());
2539 return out;
2540 }
2541
2542 std::vector<std::size_t> call_dst_tasks(std::size_t src_eidx, std::size_t target_e) const {
2543 std::vector<std::size_t> out;
2544 for (std::size_t aidx : lqn.actsof[src_eidx]) {
2545 if (aidx <= lqn.ashift || aidx > lqn.ashift + lqn.nacts) continue;
2546 for (std::size_t cidx : lqn.callsof[aidx]) {
2547 if (lqn.calltype[cidx] != CallType::SYNC) continue;
2548 const std::size_t dst = lqn.callpair_dst[cidx];
2549 const std::size_t de = dst - lqn.eshift;
2550 if (de >= 1 && de <= lqn.nentries && il_all(de, target_e) > Tzero())
2551 out.push_back(lqn.parent[dst]);
2552 }
2553 }
2554 std::sort(out.begin(), out.end());
2555 out.erase(std::unique(out.begin(), out.end()), out.end());
2556 return out;
2557 }
2558
2559 bool is_branch_point(std::size_t srcX, std::size_t entryA, std::size_t srcY,
2560 std::size_t entryB) const {
2561 const std::size_t taskA = lqn.parent[entryA], taskB = lqn.parent[entryB];
2562 const std::size_t taskX = lqn.parent[srcX];
2563 if (taskX == taskA && taskX == taskB) return false;
2564 if (srcX == entryA || srcY == entryB) return true;
2565 const std::vector<std::size_t> dx = call_dst_tasks(srcX, entryA - lqn.eshift);
2566 const std::vector<std::size_t> dy = call_dst_tasks(srcY, entryB - lqn.eshift);
2567 for (std::size_t a : dx)
2568 for (std::size_t b : dy)
2569 if (a != b) return true;
2570 return false;
2571 }
2572
2573 void trace_to_server(std::size_t eidx, std::size_t serverIdx, std::vector<bool>& visited,
2574 std::vector<std::size_t>& itasks, bool isHead) const {
2575 if (eidx <= lqn.eshift || eidx > lqn.eshift + lqn.nentries) return;
2576 const std::size_t e = eidx - lqn.eshift;
2577 if (visited[e]) return;
2578 const std::size_t owner = lqn.parent[eidx];
2579 if (owner == serverIdx) return;
2580 if (serverIdx <= lqn.nhosts && lqn.parent[owner] == serverIdx) return;
2581 visited[e] = true;
2582 bool found = false;
2583 const std::vector<std::size_t> sen = server_entry_nums(serverIdx);
2584 for (std::size_t aidx : lqn.actsof[eidx]) {
2585 if (aidx <= lqn.ashift || aidx > lqn.ashift + lqn.nacts) continue;
2586 for (std::size_t cidx : lqn.callsof[aidx]) {
2587 if (lqn.calltype[cidx] != CallType::SYNC) continue;
2588 const std::size_t dst = lqn.callpair_dst[cidx];
2589 const std::size_t dtask = lqn.parent[dst];
2590 bool reaches = dtask == serverIdx ||
2591 (serverIdx <= lqn.nhosts && lqn.parent[dtask] == serverIdx);
2592 if (!reaches) {
2593 const std::size_t de = dst - lqn.eshift;
2594 for (std::size_t sn : sen)
2595 if (il_all(de, sn) > Tzero()) reaches = true;
2596 }
2597 if (reaches) {
2598 trace_to_server(dst, serverIdx, visited, itasks, false);
2599 found = true;
2600 }
2601 }
2602 }
2603 if (found && !isHead) {
2604 itasks.push_back(owner);
2605 std::sort(itasks.begin(), itasks.end());
2606 itasks.erase(std::unique(itasks.begin(), itasks.end()), itasks.end());
2607 }
2608 visited[e] = false;
2609 }
2610
2611 void interlock_for_server(std::size_t serverIdx) {
2612 const std::vector<std::size_t> sen = server_entry_nums(serverIdx);
2613 if (sen.empty()) return;
2614 const std::vector<std::size_t> cts = client_tasks(serverIdx);
2615 if (cts.empty()) return;
2616
2617 std::vector<std::pair<std::size_t, std::size_t>> pairs; // (task, entry number)
2618 for (std::size_t ct : cts)
2619 for (std::size_t ce : lqn.entriesof[ct]) {
2620 const std::size_t cen = ce - lqn.eshift;
2621 if (cen < 1 || cen > lqn.nentries) continue;
2622 for (std::size_t se : sen)
2623 if (il_all(cen, se) > Tzero()) {
2624 pairs.emplace_back(ct, cen);
2625 break;
2626 }
2627 }
2628 if (pairs.size() < 2) return;
2629
2630 std::vector<std::size_t> common;
2631 for (std::size_t i = 0; i < pairs.size(); ++i)
2632 for (std::size_t j = i + 1; j < pairs.size(); ++j) {
2633 if (pairs[i].first == pairs[j].first) continue;
2634 const std::size_t eA = pairs[i].second, eC = pairs[j].second;
2635 for (std::size_t t = 1; t <= lqn.ntasks; ++t) {
2636 const std::size_t tidx = lqn.tshift + t;
2637 for (std::size_t ex : lqn.entriesof[tidx])
2638 for (std::size_t ey : lqn.entriesof[tidx]) {
2639 const std::size_t xn = ex - lqn.eshift, yn = ey - lqn.eshift;
2640 if (xn < 1 || yn < 1 || xn > lqn.nentries || yn > lqn.nentries) continue;
2641 if (il_all(xn, eA) > Tzero() && il_all(yn, eC) > Tzero() &&
2642 is_branch_point(ex, eA + lqn.eshift, ey, eC + lqn.eshift))
2643 common.push_back(ex);
2644 }
2645 }
2646 }
2647 std::sort(common.begin(), common.end());
2648 common.erase(std::unique(common.begin(), common.end()), common.end());
2649 if (common.empty()) return;
2650
2651 std::vector<std::size_t> interlocked;
2652 for (std::size_t ce : common) {
2653 std::vector<bool> visited(lqn.nentries + 1, false);
2654 std::vector<std::size_t> it;
2655 trace_to_server(ce, serverIdx, visited, it, true);
2656 for (std::size_t x : it) interlocked.push_back(x);
2657 }
2658 std::sort(interlocked.begin(), interlocked.end());
2659 interlocked.erase(std::unique(interlocked.begin(), interlocked.end()), interlocked.end());
2660
2661 std::vector<std::size_t> src_all;
2662 for (std::size_t ce : common) src_all.push_back(lqn.parent[ce]);
2663 std::sort(src_all.begin(), src_all.end());
2664 src_all.erase(std::unique(src_all.begin(), src_all.end()), src_all.end());
2665 {
2666 std::vector<std::size_t> diff;
2667 for (std::size_t x : src_all)
2668 if (!std::binary_search(interlocked.begin(), interlocked.end(), x)) diff.push_back(x);
2669 src_all = diff;
2670 }
2671
2672 // Left empty on purpose, and NOT because phase 2 is unsupported: the
2673 // reference computes ph2SrcTasks (initInterlock.m:230-262) and never
2674 // reads it, and its phase-2 source count is behind `if false`
2675 // (initInterlock.m:288-304). Filling it would change no number.
2676 std::vector<std::size_t> src_ph2;
2677 for (std::size_t it : interlocked)
2678 for (std::size_t ie : lqn.entriesof[it])
2679 for (std::size_t ci : lqn.iscaller.col(ie))
2680 if (ci > lqn.tshift && ci <= NT() &&
2681 !std::binary_search(interlocked.begin(), interlocked.end(), ci))
2682 src_all.push_back(ci);
2683 std::sort(src_all.begin(), src_all.end());
2684 src_all.erase(std::unique(src_all.begin(), src_all.end()), src_all.end());
2685
2686 double nsrc = 0.0;
2687 for (std::size_t st : src_all) nsrc += lqn.mult[st];
2688
2689 il_common_entries[serverIdx] = common;
2690 il_src_all[serverIdx] = src_all;
2691 il_src_ph2[serverIdx] = src_ph2;
2692 il_num_sources[serverIdx] = nsrc;
2693 }
2694
2695 // -----------------------------------------------------------------------
2696 // init and the outer iteration
2697 // -----------------------------------------------------------------------
2698 void init() {
2699 const std::size_t N = lqn.nidx;
2700 tput.assign(N + 1, Tzero());
2701 util.assign(N + 1, Tzero());
2702 servt.assign(N + 1, Tzero());
2703 residt.assign(N + 1, Tzero());
2704 thinkt.assign(N + 1, Tzero());
2705 callservt.assign(lqn.ncalls + 1, Tzero());
2706 callresidt.assign(lqn.ncalls + 1, Tzero());
2707 tputproc.assign(N + 1, Distrib<T>::disabled_dist());
2708 servt_ph1.assign(N + 1, Tzero());
2709 servt_ph2.assign(N + 1, Tzero());
2710 prOvertake.assign(lqn.nentries + 1, Tzero());
2711 build_entry_service_matrix();
2712
2713 relax_omega = (opt.relax == "fixed" || opt.relax == "adaptive") ? opt.relax_factor : 1.0;
2714
2715 servt_prev.assign(N + 1, std::numeric_limits<double>::quiet_NaN());
2716 residt_prev.assign(N + 1, std::numeric_limits<double>::quiet_NaN());
2717 tput_prev.assign(N + 1, std::numeric_limits<double>::quiet_NaN());
2718 thinkt_prev.assign(N + 1, std::numeric_limits<double>::quiet_NaN());
2719 callservt_prev.assign(lqn.ncalls + 1, std::numeric_limits<double>::quiet_NaN());
2720 callresidt_prev.assign(lqn.ncalls + 1, std::numeric_limits<double>::quiet_NaN());
2721 servt_prev_v.assign(N + 1, Tzero());
2722 residt_prev_v.assign(N + 1, Tzero());
2723 tput_prev_v.assign(N + 1, Tzero());
2724 thinkt_prev_v.assign(N + 1, Tzero());
2725 callservt_prev_v.assign(lqn.ncalls + 1, Tzero());
2726
2727 unique_route_idx.clear();
2728 for (const RouteRow& r : route_map) unique_route_idx.push_back(r.idx);
2729 std::sort(unique_route_idx.begin(), unique_route_idx.end());
2730 unique_route_idx.erase(std::unique(unique_route_idx.begin(), unique_route_idx.end()),
2731 unique_route_idx.end());
2732
2733 if (opt.interlocking) init_interlock();
2734
2735 maxitererr.assign(opt.iter_max + 2, 0.0);
2736 averagingstart = -1;
2737 hasconverged = false;
2738 moment_pass_done = false;
2739 results.clear();
2740
2741 servtcdf.assign(N + 1, fluid::FluidPassage());
2742 callservtcdf.assign(lqn.ncalls + 1, fluid::FluidPassage());
2743 entrycdfrespt.assign(lqn.nentries + 1, LnCdf());
2744 entryproc.assign(lqn.nentries + 1, mam::AphPair<T>());
2745 cdf_repo.assign(ensemble.size(), std::vector<std::vector<fluid::FluidPassage>>());
2746 }
2747
2748 /**
2749 * The Picard iteration, with the stochastic controller in place of the
2750 * deterministic test when the layer engine is a simulator.
2751 *
2752 * The two tests are NOT interchangeable and cannot both run: `converged`
2753 * folds a moving average into `results` in place, which is exactly what the
2754 * Polyak-Ruppert average would then be taken of a second time. Whichever
2755 * test is in force therefore owns the results.
2756 */
2757 void iterate() {
2758 init();
2759 const bool stoch = opt.layer_solver == "ssa";
2760 if (stoch) {
2761 LnStochConfig cfg;
2762 cfg.iter_tol = opt.iter_tol;
2763 cfg.relax_burnin = relax_omega;
2764 stoch_ctl = std::make_shared<LnStochController<T>>(cfg);
2765 }
2766 int it = 0;
2767 while (it < opt.iter_max) {
2768 if (!stoch && converged(it)) break;
2769 ++it;
2770 results.emplace_back(ensemble.size());
2771 for (std::size_t e = 0; e < ensemble.size(); ++e) analyze(it, e);
2772 post(it);
2773 if (stoch) {
2774 std::vector<double> jobs(ensemble.size(), 0.0);
2775 for (std::size_t e = 0; e < ensemble.size(); ++e)
2776 jobs[e] = ensemble[e].total_jobs();
2777 const bool stop = stoch_ctl->update(it, results.back(), jobs, servt, residt);
2778 // The step this sets is the one the NEXT updateMetrics applies,
2779 // which is why it is read back here and not before the sweep.
2780 relax_omega = stoch_ctl->relax_omega();
2781 if (stop) {
2782 did_converge = true;
2783 hasconverged = true;
2784 break;
2785 }
2786 }
2787 }
2788 iterations_done = it;
2789 // finish(): in Robbins-Monro mode report the averaged iterate, not the
2790 // last (noisy) one.
2791 if (stoch && stoch_ctl && stoch_ctl->averaging_count() > 0) {
2792 const std::vector<LayerResult<T>>& avg = stoch_ctl->averaged_results();
2793 for (std::size_t e = 0; e < results.back().size() && e < avg.size(); ++e)
2794 results.back()[e] = avg[e];
2795 servt = stoch_ctl->averaged_servt();
2796 residt = stoch_ctl->averaged_residt();
2797 }
2798 }
2799
2800 /**
2801 * Port of the filterMetric helper inside @@NetworkSolver/getAvg.m.
2802 *
2803 * This is not cosmetic post-processing: it is where a raw solver matrix
2804 * becomes the result the rest of LINE consumes, and three of its rules
2805 * change numbers that SolverLN then iterates on.
2806 *
2807 * 1. a (station, class) pair the class never visits is zeroed, whether
2808 * because its service is disabled or because its visit ratio is zero;
2809 * 2. anything below FineTol is snapped to zero;
2810 * 3. the caller's zeroMask is applied -- and for the queue length and the
2811 * utilization that mask is `RN < 10*FineTol`, which zeroes both
2812 * wherever the response time is immediate.
2813 *
2814 * Rule 3 is the one that matters most here. Every LQN entry, task and call
2815 * class is served by an Immediate distribution somewhere, whose response
2816 * time is 1e-8, so without it a layer reports the Immediate classes' share
2817 * of the population as real queue length and the LN think-time update reads
2818 * a task utilization that MATLAB reports as zero.
2819 */
2820 Matrix<T> filter_metric(const qn::Layer<T>& L, const Matrix<T>& metric,
2821 const std::vector<std::vector<bool>>* zero_mask) const {
2822 const std::size_t M = L.nstations, K = L.nclasses;
2823 Matrix<T> out(M, K, Tzero());
2824 for (std::size_t i = 0; i < M; ++i)
2825 for (std::size_t k = 0; k < K; ++k)
2826 if (!L.disabled[i][k]) out(i, k) = metric(i, k);
2827 if (zero_mask)
2828 for (std::size_t i = 0; i < M; ++i)
2829 for (std::size_t k = 0; k < K; ++k)
2830 if ((*zero_mask)[i][k]) out(i, k) = Tzero();
2831 for (std::size_t i = 0; i < M; ++i)
2832 for (std::size_t k = 0; k < K; ++k)
2833 if (dbl(out(i, k)) < GlobalConstants::FineTol) out(i, k) = Tzero();
2834 for (std::size_t k = 0; k < K; ++k) {
2835 std::size_t c = L.nchains;
2836 for (std::size_t cc = 0; cc < L.nchains; ++cc)
2837 if (L.chains[cc][k]) c = cc;
2838 if (c == L.nchains) continue;
2839 for (std::size_t i = 0; i < M; ++i)
2840 if (L.visits[c](L.stateful_of_station(i + 1) - 1, k) == Tzero())
2841 out(i, k) = Tzero();
2842 }
2843 return out;
2844 }
2845
2846 /**
2847 * Solve one layer, running the fork-join fixed point when it has a fork.
2848 *
2849 * The driver itself is shared (fj_driver.h); the layer path supplies
2850 * solver_mva_analyzer as the inner solve. The auxiliary arrival rates
2851 * fj_lambda are warm-started across outer iterations, per the reference.
2852 *
2853 * `default` becomes `amva` on a fork layer, which is what mvaDispatch.m does
2854 * for any model whose BASE has a fork: the transformed layer is mixed with
2855 * auxiliary near-zero-rate open classes, and the exact mixed MVA the default
2856 * ladder would otherwise pick degenerates on them.
2857 */
2858 /**
2859 * Run one layer through the fluid analyzer instead of MVA.
2860 *
2861 * WHAT MAKES THIS SOUND AT ALL. The layer handed to a solver is an ordinary
2862 * closed queueing network -- the layering has already replaced every call
2863 * by a class with a service demand -- so any NetworkSolver can solve it,
2864 * and the reference says exactly that by taking a solver factory. The outer
2865 * Picard iteration only ever reads [QN, UN, RN, TN] back, so the fluid
2866 * result maps onto the same shape MVA returns.
2867 *
2868 * WHAT CHANGES, and it is not nothing: the fluid limit is exact only as the
2869 * populations grow, so on a layer of a few jobs it is a genuinely different
2870 * approximation, not a slower route to the same fixed point. It also feeds
2871 * DIFFERENT service demands back into the next outer iteration, so the two
2872 * ensembles converge to different fixed points rather than to the same one
2873 * by different paths.
2874 *
2875 * REFUSED BY NAME: a non-double backend (LSODA is double-only, see
2876 * solver_fluid.h) and a fork layer (`fj_fixed_point` supplies MVA as its
2877 * inner solve, and the auxiliary near-zero-rate open classes it introduces
2878 * are not something the fluid drift represents).
2879 */
2880 /**
2881 * Solve one layer with CTMC and report it in the shape the MVA path returns.
2882 *
2883 * Only reached for a layer carrying a Region. `qn::Layer<T>` derives from
2884 * NetworkStruct, so the layer goes to the analyzer as it stands; the
2885 * generator needs the routing matrix, which the layer does not build until
2886 * asked (see solve_layer_fluid).
2887 */
2888 mva::MvaSolution<T> solve_layer_ctmc(std::size_t e) {
2889 qn::Layer<T>& L = ensemble[e];
2890 L.refresh_rt();
2891 // The capacities the WAITQ generator reads to bound each station's
2892 // marginal are filled by solve_layer, for every engine; the routing
2893 // matrix is not, because only this path and the fluid one need it.
2894 ctmc::CtmcOptions co;
2895 // _run_any, not _run: the plain generator implements DROP only, and a
2896 // region built from an LQN admission constraint carries the WAITQ
2897 // default, whose per-region token FIFO lives in the waitq generator.
2898 const mva::AvgResult<T> a = ctmc::solver_ctmc_run_analyzer_any(L, co);
2899 mva::MvaSolution<T> out;
2900 out.method = "ctmc";
2901 out.iter = 1;
2902 out.Q = a.QN;
2903 out.U = a.UN;
2904 out.R = a.RN;
2905 out.Tp = a.TN;
2906 out.C = a.CN;
2907 out.X = a.XN;
2908 return out;
2909 }
2910
2911 /**
2912 * Solve one layer with SolverMAM's `dec.poisson`, for a setup/delay-off.
2913 *
2914 * The method matters: `dec.poisson` caps the arrival superposition at one
2915 * phase AND skips the fork-join / retrial / ldqbd diversions, so the station
2916 * reaches the setup branch of solver_mam_basic rather than being taken by a
2917 * shape-matching special case. It is what SolverLN.m:183 asks for.
2918 */
2919 mva::MvaSolution<T> solve_layer_mam(std::size_t e) {
2920 qn::Layer<T>& L = ensemble[e];
2921 L.refresh_rt();
2922 L.refresh_capacity();
2923 mam::MamOptions mo;
2924 mo.method = "dec.poisson";
2925 mva::MvaSolution<T> out = mam::mam_dispatch(L, mo).sol;
2926 out.method = "mam";
2927 return out;
2928 }
2929
2930 /**
2931 * Solve one layer with SolverNC, the reference's `LN(model, @(m) NC(m))`.
2932 *
2933 * This is the layer engine the Java CLI's bare `ln` token selects, against
2934 * `ln.mva` for the MVA one. NC evaluates the normalizing constant rather
2935 * than the MVA recursion, so on a product-form layer it is the SAME answer
2936 * reached exactly instead of through the AMVA approximation -- and on a
2937 * layer that is not product form the two differ, which is the whole reason
2938 * the token exists.
2939 */
2940 mva::MvaSolution<T> solve_layer_nc(std::size_t e) {
2941 if (fj_tr[e].active())
2942 throw UnsupportedError(
2943 "SolverLN: layer '" + ensemble[e].name +
2944 "' carries a fork, whose fixed point is driven by MVA in this port; solve this "
2945 "model with layer_solver 'mva'");
2946 qn::Layer<T>& L = ensemble[e];
2947 L.refresh_rt();
2948 // `solver_nc_solve`, NOT `nc_dispatch`: the latter is the INNER network
2949 // solve, the one the caching-queueing decomposition itself calls as its
2950 // `netfun`, and it carries no cache branch at all. The three cache gates
2951 // live in `solver_nc_solve` (solver_nc_runner.h), which is the true
2952 // counterpart of `mva_dispatch` on the branch below. Routed here, a
2953 // cache layer never reached `solver_nc_cacheqn_analyzer` and its Cache
2954 // node was solved as ordinary routing, so the hit/miss split came back
2955 // as the probability `link()` offered -- an even 1/2, independent of
2956 // capacity, item count and replacement strategy, with no warning.
2957 // `.sol` alone is taken, exactly as the MVA branch takes it: the layer
2958 // reads the split off its own station throughputs, and the auxiliary
2959 // `refreshed_struct` both dispatchers also return is unused on either.
2960 mva::MvaSolution<T> out = nc::solver_nc_solve(L, opt.layer_nc).sol;
2961 out.method = "nc";
2962 return out;
2963 }
2964
2965 /**
2966 * Solve one layer by simulation, the reference's `LN(model, @(m) SSA(m))`.
2967 *
2968 * THE LAYER RESULTS ARE THEN NOISY, and the deterministic convergence test
2969 * cannot terminate against noise: its successive-difference error is
2970 * bounded below by the standard error of the estimates. `iterate` therefore
2971 * switches to `LnStochController` whenever this engine is selected, so the
2972 * relaxation decays as Robbins-Monro and the reported iterate is the
2973 * Polyak-Ruppert average. Selecting this engine and keeping the
2974 * deterministic test would simply run to iter_max.
2975 */
2976 mva::MvaSolution<T> solve_layer_ssa(std::size_t e) {
2977 if (fj_tr[e].active())
2978 throw UnsupportedError(
2979 "SolverLN: layer '" + ensemble[e].name +
2980 "' carries a fork, whose fixed point is driven by MVA in this port; solve this "
2981 "model with layer_solver 'mva'");
2982 qn::Layer<T>& L = ensemble[e];
2983 L.refresh_rt();
2984 const ssa::SsaSolution s = ssa::solver_ssa(L, opt.layer_ssa);
2985 mva::MvaSolution<T> out;
2986 out.method = "ssa";
2987 out.iter = 1;
2988 out.Q = Matrix<T>(L.nstations, L.nclasses, Tzero());
2989 out.U = Matrix<T>(L.nstations, L.nclasses, Tzero());
2990 out.R = Matrix<T>(L.nstations, L.nclasses, Tzero());
2991 out.Tp = Matrix<T>(L.nstations, L.nclasses, Tzero());
2992 for (std::size_t i = 0; i < L.nstations; ++i)
2993 for (std::size_t r = 0; r < L.nclasses; ++r) {
2994 out.Q(i, r) = num_traits<T>::from_double(s.QN(i, r));
2995 out.U(i, r) = num_traits<T>::from_double(s.UN(i, r));
2996 out.R(i, r) = num_traits<T>::from_double(s.RN(i, r));
2997 out.Tp(i, r) = num_traits<T>::from_double(s.TN(i, r));
2998 }
2999 out.C.assign(L.nclasses, Tzero());
3000 out.X.assign(L.nclasses, Tzero());
3001 for (std::size_t r = 0; r < L.nclasses && r < s.XN.size(); ++r) {
3002 out.X[r] = num_traits<T>::from_double(s.XN[r]);
3003 out.C[r] = num_traits<T>::from_double(s.CN[r]);
3004 }
3005 return out;
3006 }
3007
3008 mva::MvaSolution<T> solve_layer_fluid(std::size_t e) {
3009 if (fj_tr[e].active())
3010 throw UnsupportedError(
3011 "SolverLN: layer '" + std::to_string(e) +
3012 "' carries a fork, whose fixed point is driven by MVA in this port; solve this "
3013 "model with layer_solver 'mva'");
3014 qn::Layer<T>& L = ensemble[e];
3015 // THE LAYER HAS NO ROUTING MATRIX UNTIL IT IS ASKED FOR ONE. `buildLayers`
3016 // records the wiring with set_route and then calls refresh_chains, which
3017 // reads P directly and computes the VISITS; MVA needs nothing else, so
3018 // `rt` stays empty. Both fluid methods route through `sn.rt` instead, and
3019 // an empty one is not an error anywhere -- it is read as "no transition
3020 // exists", the drift decays to zero and every metric comes back 0. It is
3021 // rebuilt on every solve because update_routing_probabilities can change
3022 // the wiring between outer iterations.
3023 L.refresh_rt();
3024 mva::MvaSolution<T> out;
3025 out.method = "fluid";
3026 out.iter = 1;
3027 out.Q = Matrix<T>(L.nstations, L.nclasses, Tzero());
3028 out.U = Matrix<T>(L.nstations, L.nclasses, Tzero());
3029 out.R = Matrix<T>(L.nstations, L.nclasses, Tzero());
3030 out.Tp = Matrix<T>(L.nstations, L.nclasses, Tzero());
3031 out.C.assign(L.nclasses, Tzero());
3032 out.X.assign(L.nclasses, Tzero());
3033 detail::ln_fluid_solve(L, opt.layer_fluid, out);
3034 return out;
3035 }
3036
3037 mva::MvaSolution<T> solve_layer(std::size_t e) {
3038 // BEFORE ANY ENGINE, AND FOR EVERY ONE OF THEM. `build_layer` stops at
3039 // refresh_chains, so a layer reaches this point with `cap` and
3040 // `classcap` still EMPTY, while the reference hands SolverMVA a struct
3041 // that refreshCapacity has already filled. Every consumer of Kendall's
3042 // K -- `buffer_size`, and through it `has_blocking` and the BCMP gate
3043 // `has_product_form` -- indexes those vectors by station, so an empty
3044 // one is an out-of-range read and not a permissive default. It is
3045 // recomputed on each solve because the layer's class populations move
3046 // between outer iterations, and the buffers are derived from them.
3047 ensemble[e].refresh_capacity();
3048 // A layer carrying an admission constraint goes to CTMC whatever the
3049 // layer solver is, as adaptiveSolverFactory does (SolverLN.m:1047-1089):
3050 // MVA and the fluid path both refuse a Region by name, so leaving the
3051 // layer on them would refuse a model the reference solves. The
3052 // reference then falls back to LDES and SSA; neither is reachable here
3053 // (there is no cpp LDES, and cpp SSA refuses regions), so CTMC is the
3054 // only path and its state-space bound is this port's real limit.
3055 if (!ensemble[e].regions.empty()) return solve_layer_ctmc(e);
3056 // A setup no longer forces the MAM decomposition on the layer
3057 // (SolverLN.m, 2026-08-11): the cold start is charged to the entry by
3058 // setup_charge(), not wired into the station, so the layer is an
3059 // ordinary one and the user's own layer solver serves it.
3060 // A cache layer is dispatched to the integrated caching-queueing
3061 // analyzer, which reads the NODE routing to find what lies downstream
3062 // of the cache. The layer carries none until asked, and it has to be
3063 // rebuilt every pass because entry selection can move it. EVERY layer
3064 // engine below must reach that analyzer, not just the MVA one: this
3065 // refresh runs for all of them, and a branch that then hands the
3066 // rebuilt routing to a solver with no cache gate reports the offered
3067 // hit/miss split instead of the converged one.
3068 if (has_cache_node(e)) ensemble[e].refresh_rt();
3069 if (opt.layer_solver == "fluid") return solve_layer_fluid(e);
3070 if (opt.layer_solver == "nc") return solve_layer_nc(e);
3071 if (opt.layer_solver == "ssa") return solve_layer_ssa(e);
3072 if (opt.layer_solver != "mva")
3073 throw UnsupportedError("SolverLN: layer_solver '" + opt.layer_solver +
3074 "' is not available; use 'mva', 'nc', 'fluid' or 'ssa'");
3075 // Route each layer through the full mva_dispatch ladder, as MATLAB's
3076 // SolverLN does by handing every layer to SolverMVA.runAnalyzer: a plain
3077 // closed layer still lands on branch 13 (solver_mva_analyzer), but a
3078 // layer carrying load- or class-dependent scaling now reaches
3079 // solver_mvald_analyzer, matching the reference rather than silently
3080 // running the flat AMVA.
3081 mva::MvaOptions eopt = opt.layer;
3082 if (e < layer_interlock.size()) eopt.interlock = layer_interlock[e];
3083 if (!fj_tr[e].active())
3084 return mva::mva_dispatch(ensemble[e], eopt, layer_init_sol[e]).sol;
3085 mva::MvaOptions lopt = eopt;
3086 if (lopt.method == "default") lopt.method = "amva";
3087 return mva::fj_fixed_point(
3088 ensemble[e], fj_tr[e], fj_lambda[e], eopt,
3089 [&lopt](qn::NetworkStruct<T>& V) {
3090 return mva::mva_dispatch(V, lopt, Matrix<T>()).sol;
3091 });
3092 }
3093
3094 void analyze(int it, std::size_t e) {
3095 qn::Layer<T>& L = ensemble[e];
3096 const mva::MvaSolution<T> s = solve_layer(e);
3097 LayerResult<T>& r = results[it - 1][e];
3098 r.RN = filter_metric(L, s.R, nullptr);
3099 std::vector<std::vector<bool>> zmask(L.nstations, std::vector<bool>(L.nclasses, false));
3100 for (std::size_t i = 0; i < L.nstations; ++i)
3101 for (std::size_t k = 0; k < L.nclasses; ++k)
3102 zmask[i][k] = dbl(r.RN(i, k)) < 10.0 * GlobalConstants::FineTol;
3103 r.QN = filter_metric(L, s.Q, &zmask);
3104 r.UN = filter_metric(L, s.U, &zmask);
3105 r.TN = filter_metric(L, s.Tp, nullptr);
3106 r.WN = residence_from_response(L, r.RN);
3107 // warm start the next solve of this layer from the chain-aggregated
3108 // queue lengths, as the reference does for SolverMVA layers
3109 // A fork layer is exempt: the reference guards this on the result having
3110 // the LAYER's station count, and the transformed model reports one more
3111 // row (its Source), so the hint is never installed there. A fluid layer
3112 // is exempt too, and for the reference's own reason: `analyze` guards
3113 // the warm start on `strcmp(self.solvers{e}.name, 'SolverMVA')`, because
3114 // init_sol means a queue-length vector to AMVA and a full ODE state
3115 // vector to the fluid solver -- the two are not interchangeable.
3116 if (!fj_tr[e].active() && opt.layer_solver == "mva") {
3117 Matrix<T> Qch(L.nstations, L.nchains, Tzero());
3118 for (std::size_t c = 0; c < L.nchains; ++c)
3119 for (std::size_t i = 0; i < L.nstations; ++i) {
3120 T s2 = Tzero();
3121 for (std::size_t k : L.inchain[c]) s2 += s.Q(i, k - 1);
3122 Qch(i, c) = s2;
3123 }
3124 layer_init_sol[e] = Qch;
3125 }
3126 }
3127
3128 /** Port of sn_get_residt_from_respt: response time scaled by the visit ratio. */
3129 Matrix<T> residence_from_response(const qn::Layer<T>& L, const Matrix<T>& RN) const {
3130 Matrix<T> V(L.nstations, L.nclasses, Tzero());
3131 for (std::size_t c = 0; c < L.nchains; ++c)
3132 for (std::size_t i = 0; i < L.nstations; ++i) {
3133 const std::size_t sf = L.stateful_of_station(i + 1) - 1;
3134 for (std::size_t k = 0; k < L.nclasses; ++k)
3135 V(i, k) = T(V(i, k) + L.visits[c](sf, k));
3136 }
3137 Matrix<T> WN(L.nstations, L.nclasses, Tzero());
3138 for (std::size_t i = 0; i < L.nstations; ++i)
3139 for (std::size_t k = 0; k < L.nclasses; ++k) {
3140 if (L.disabled[i][k]) continue;
3141 if (!(RN(i, k) > Tzero())) continue;
3142 if (dbl(RN(i, k)) < GlobalConstants::FineTol) {
3143 WN(i, k) = RN(i, k);
3144 continue;
3145 }
3146 std::size_t c = 0;
3147 for (std::size_t cc = 0; cc < L.nchains; ++cc)
3148 if (L.chains[cc][k]) c = cc;
3149 const std::size_t rstat = L.classes[k].refstat;
3150 T den = Tzero();
3151 if (L.refclass[c] > 0) {
3152 den = V(rstat - 1, L.refclass[c] - 1);
3153 } else {
3154 for (std::size_t kk : L.inchain[c]) den += V(rstat - 1, kk - 1);
3155 }
3156 if (den == Tzero()) continue;
3157 WN(i, k) = T(RN(i, k) * V(i, k) / den);
3158 }
3159 for (std::size_t i = 0; i < L.nstations; ++i)
3160 for (std::size_t k = 0; k < L.nclasses; ++k)
3161 if (dbl(WN(i, k)) < 10.0 * GlobalConstants::FineTol) WN(i, k) = Tzero();
3162 return WN;
3163 }
3164
3165 void post(int it) {
3166 update_metrics(it);
3167 if (opt.interlocking) update_populations(it);
3168 update_think_times(it);
3169 update_layers(it);
3170 update_routing_probabilities(it);
3171 for (std::size_t e : route_reset) {
3172 ensemble[e].refresh_chains();
3173 layer_init_sol[e] = Matrix<T>();
3174 }
3175 // moment3 needs no refreshProcesses here: this port keeps the service
3176 // DISTRIBUTION on the layer and refresh_rates re-reads it, so there is no
3177 // second representation to fall out of step, unlike sn.proc in MATLAB.
3178 for (std::size_t e : svc_reset) ensemble[e].refresh_rates();
3179 }
3180
3181 /** Port of converged.m: moving average of the layer results plus the test. */
3182 bool converged(int it) {
3183 const std::size_t E = ensemble.size();
3184 const int iter_min = std::max<int>(2 * int(E), int(std::ceil(opt.iter_max / 4.0)));
3185 const int wnd_size = std::max(5, int(std::ceil(iter_min / 5.0)));
3186
3187 if (it >= iter_min && int(results.size()) >= wnd_size) {
3188 const T w = T(Tone() / num_traits<T>::from_int(wnd_size));
3189 for (std::size_t e = 0; e < E; ++e) {
3190 LayerResult<T>& cur = results[results.size() - 1][e];
3191 auto scale = [&](Matrix<T>& A) {
3192 for (std::size_t i = 0; i < A.rows(); ++i)
3193 for (std::size_t j = 0; j < A.cols(); ++j) A(i, j) = T(A(i, j) * w);
3194 };
3195 Matrix<T> Q = cur.QN, U = cur.UN, R = cur.RN, Tp = cur.TN, W = cur.WN;
3196 scale(Q); scale(U); scale(R); scale(Tp); scale(W);
3197 for (int k = 1; k < wnd_size; ++k) {
3198 const LayerResult<T>& old = results[results.size() - 1 - k][e];
3199 auto add = [&](Matrix<T>& A, const Matrix<T>& B) {
3200 for (std::size_t i = 0; i < A.rows(); ++i)
3201 for (std::size_t j = 0; j < A.cols(); ++j) A(i, j) = T(A(i, j) + B(i, j) * w);
3202 };
3203 add(Q, old.QN); add(U, old.UN); add(R, old.RN); add(Tp, old.TN); add(W, old.WN);
3204 }
3205 cur.QN = Q; cur.UN = U; cur.RN = R; cur.TN = Tp; cur.WN = W;
3206 }
3207 }
3208
3209 if (it > 1) {
3210 double err = 0.0;
3211 for (std::size_t e = 0; e < E; ++e) {
3212 const Matrix<T>& Q = results[results.size() - 1][e].QN;
3213 const Matrix<T>& Q1 = results[results.size() - 2][e].QN;
3214 const double Njobs = ensemble[e].total_jobs();
3215 if (!(Njobs > 0.0)) continue;
3216 double mx = 0.0;
3217 for (std::size_t i = 0; i < Q.rows(); ++i)
3218 for (std::size_t j = 0; j < Q.cols(); ++j)
3219 mx = std::max(mx, std::fabs(dbl(Q(i, j)) - dbl(Q1(i, j))));
3220 err += mx / Njobs;
3221 }
3222 maxitererr[it] = err;
3224 static_cast<long>(it),
3225 "layer iteration %zu: max queue-length change %.3e (tolerance %.3e)",
3226 static_cast<std::size_t>(it), err, opt.iter_tol);
3227 if (it == iter_min) {
3228 line::util::LineConsole::step("started averaging the iterates to aid convergence");
3229 averagingstart = it;
3230 }
3231 }
3232
3233 if (it > iter_min && maxitererr[it] < opt.iter_tol && maxitererr[it - 1] < opt.iter_tol &&
3234 maxitererr[it - 2] < opt.iter_tol) {
3235 if (!hasconverged) {
3236 hasconverged = true;
3237 } else {
3238 did_converge = true;
3239 return true;
3240 }
3241 } else {
3242 hasconverged = false;
3243 }
3244 return false;
3245 }
3246
3247 // -----------------------------------------------------------------------
3248 // updateMetrics
3249 // -----------------------------------------------------------------------
3250 /** Port of updateMetrics.m: the method selects which update runs. */
3251 // -----------------------------------------------------------------------
3252 // Method "srvn.ph": the activity graph of an entry as a phase-type server law
3253 //
3254 // Each layer is a two-station cycle, a client Delay plus the server, with
3255 // one closed class per caller task. The sequencing the default method
3256 // encodes as routing -- a class per entry, per activity and per call, plus
3257 // Fork, Join, Router and ClassSwitch nodes -- is composed instead into a
3258 // single phase-type service law per (layer, caller), by the exact
3259 // series-parallel reduction of Workflow. The layer therefore carries only
3260 // the client/server back-and-forth, and the activity graph survives as a
3261 // distribution.
3262 //
3263 // Port of the MATLAB @SolverLN/buildLayersPH.m, updateLayersPH.m,
3264 // updateMetricsPH.m, updateThinkTimesPH.m, phComposeEntryLaws.m and
3265 // getEnsembleAvgPH.m, of the JAR jline.solvers.ln.SolverLNPH and of the
3266 // Python line_solver.solvers.solver_ln.solver_ln_ph. See
3267 // _kb/06-solver-catalog.md (LN section) for the layering taxonomy.
3268 // -----------------------------------------------------------------------
3269
3270 /**
3271 * The method the layers were actually BUILT for: "srvn.ph", "srvn.cs",
3272 * "flat.cs", "flat.ph" or "moment3". Resolved once in build_layers, because
3273 * the alias "srvn" may fall back; every dispatch reads this and not
3274 * opt.method, so a reconstruction can never disagree with the layers it is
3275 * reading.
3276 */
3277 std::string lnmethod;
3278 /** True once ph_init_laws has composed the per-entry workflows. */
3279 bool ph_laws_ready = false;
3280
3281 /**
3282 * Normalise a method name onto one the solver dispatches on. A method name
3283 * carries TWO decisions: the LAYERING, which fixes what a submodel is, and
3284 * the ENCODING, which fixes how an activity graph is written into it.
3285 *
3286 * "srvn.cs" encodes the activity graph as ROUTING, "srvn.ph" as a composed
3287 * phase-type server law, "srvn" is the alias that takes "srvn.ph" where it
3288 * can serve the model and "srvn.cs" otherwise, "flat.cs" squashes every
3289 * server into one submodel with the routing encoding, "flat.ph" squashes
3290 * them with the composed one ("flat" is the alias of "flat.cs" and resolves
3291 * unconditionally rather than probing "flat.ph", because a model is squashed
3292 * in order to express what only the routing encoding carries), and "moment3"
3293 * is the three-moment distribution pass over the routing layers. "default"
3294 * is the srvn alias; an unrecognised method name takes "srvn.cs".
3295 */
3296 static std::string ln_requested_method(const std::string& method) {
3297 std::string m;
3298 for (char c : method) m += static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
3299 if (m.empty() || m == "srvn" || m == "default" || m == "auto") return "srvn";
3300 if (m == "srvn.ph" || m == "ph") return "srvn.ph";
3301 if (m == "srvn.cs" || m == "srvncs" || m == "cs") return "srvn.cs";
3302 if (m == "flat.cs" || m == "flatcs" || m == "flat" || m == "squashed") return "flat.cs";
3303 if (m == "flat.ph" || m == "flatph" || m == "squashed.ph") return "flat.ph";
3304 if (m == "moment3") return "moment3";
3305 // An unrecognised token takes the routing encoding, which is what every
3306 // name other than "moment3" resolved to before the alias existed.
3307 return "srvn.cs";
3308 }
3309
3310 /** True when the layers are the collapsed phase-type ones of "srvn.ph". */
3311 bool is_srvn_ph() const { return lnmethod == "srvn.ph"; }
3312
3313 /**
3314 * True when the layers carry the COMPOSED phase-type server law rather than
3315 * the routing encoding of the activity graph, under either layering. The
3316 * encoding, not the layering, decides which update and reconstruction passes
3317 * run, so every such dispatch asks this and not for one method name.
3318 */
3319 bool is_ph_encoding() const { return lnmethod == "srvn.ph" || lnmethod == "flat.ph"; }
3320
3321 /**
3322 * Station of layer L that stands for LQN element ELEM, falling back to the
3323 * layer's own server when ELEM is not a server there. Under "srvn" the
3324 * fallback is the answer for every element; under "flat.cs" it is the map
3325 * that tells the many servers of one layer apart.
3326 */
3327 static std::size_t station_idx_of(const qn::Layer<T>& L, std::size_t elem) {
3328 if (elem >= 1 && elem < L.server_idx_of.size() && L.server_idx_of[elem] != 0)
3329 return L.server_idx_of[elem];
3330 return L.serverIdx;
3331 }
3332
3333 /**
3334 * Station of layer L that class K (0-based) is served at: the PROCESSOR of
3335 * an activity, the CALLED TASK of a call, the layer's own server otherwise.
3336 */
3337 std::size_t station_idx_of_class(const qn::Layer<T>& L, std::size_t k) const {
3338 const int kind = L.classes[k].attr_kind;
3339 const std::size_t a = L.classes[k].attr_idx;
3340 if (kind == int(LqnElement::ACTIVITY))
3341 return station_idx_of(L, lqn.parent[lqn.parent[a]]);
3342 if (kind == int(LqnElement::CALL))
3343 return station_idx_of(L, lqn.parent[lqn.callpair_dst[a]]);
3344 return L.serverIdx;
3345 }
3346
3347 /**
3348 * Answer whether "srvn.ph" can serve this model, without disturbing the
3349 * solver.
3350 *
3351 * Both the feature gate and the series-parallel reduction can refuse, and
3352 * the second only finds out by composing the per-entry workflows -- work the
3353 * build then reuses, since those laws do not depend on the iterate.
3354 */
3355 bool probe_srvn_ph() {
3356 try {
3357 ph_assert_supported();
3358 ph_init_laws();
3359 ph_laws_ready = true;
3360 return true;
3361 } catch (const std::exception&) {
3362 ph_laws_ready = false;
3363 return false;
3364 }
3365 }
3366
3367 /** One two-station layer, and the caller classes that cycle through it. */
3368 struct PHLayer {
3369 std::size_t idx = 0;
3370 bool ishost = false;
3371 std::vector<std::size_t> callers;
3372 /** 1-based class index of each caller task, 0 when absent. */
3373 std::vector<std::size_t> class_of_caller;
3374 std::size_t nreplicas = 1;
3375 /** 1-based station indices of the server replicas. */
3376 std::vector<std::size_t> qstations;
3377 /** Mean of the law each class is currently served with, by class index. */
3378 std::vector<T> svcmean_by_class;
3379 /** (class index, entry index) or (class index, -call index). */
3380 std::vector<std::pair<std::size_t, long>> open_arrivals;
3381 /**
3382 * Closed population of the MODEL this server sits in. Under "flat.ph"
3383 * that is every caller of the single network, not only the callers of
3384 * this one station, so it is recorded here rather than recomputed.
3385 */
3386 double npop = 0.0;
3387 };
3388
3389 // per-entry workflows and their composed laws
3390 std::vector<workflow::Workflow<T>> ph_wf, ph_wfhost;
3391 std::vector<std::unordered_map<std::size_t, T>> ph_execs;
3392 std::vector<bool> ph_has_wf;
3393 std::vector<workflow::PhLaw<T>> ph_hostlaw, ph_entrylaw;
3394 std::vector<T> ph_hostmean, ph_entrymean, ph_entryscv;
3395 std::vector<T> ph_share, ph_overlap, ph_setupshare, ph_xdemand;
3396 Matrix<T> ph_ncalls, ph_calltime;
3397 std::vector<T> ph_procresid, ph_actthinkt, ph_calltotal;
3398 std::vector<PHLayer> ph_layers; ///< by element index, empty where absent
3399 std::vector<bool> ph_has_layer;
3400
3401 /** Mean of an activity's own think time, 0 when it declares none. */
3402 T act_think_time(std::size_t aidx) const {
3403 if (aidx >= lqn.actthink.size() || lqn.actthink[aidx].disabled) return Tzero();
3404 const double m = dbl(lqn.actthink[aidx].mean);
3405 if (!std::isfinite(m) || m <= GlobalConstants::FineTol) return Tzero();
3406 return lqn.actthink[aidx].mean;
3407 }
3408
3409 /**
3410 * Mean cold start one request of task TIDX pays, 0 when it declares none.
3411 *
3412 * A SetupTask powers a thread down when it goes idle and pays a setup before
3413 * it can serve again. The thread is released at a reply and starts a
3414 * delay-off countdown D of mean d; it powers off only if D expires before
3415 * the next request arrives, and a request arriving first cancels the
3416 * countdown and pays nothing. With the idle interval I seen by one thread
3417 * and exponential D, p = P(D < I) = E[I]/(E[I]+d) and the charge is p*s.
3418 *
3419 * Admission takes an ACTIVE idle thread before it wakes a sleeping one, so
3420 * the pool that actually cycles is only as large as the load needs: with
3421 * offered load b = X*S = rho*mult threads, about max(1,b) stay hot, giving
3422 * E[I] = (max(1,b) - b)/X. Exact at mult = 1; above it the exact answer is
3423 * matrix-analytic (Gandhi, Harchol-Balter and Adan, Performance Evaluation
3424 * 67(11), 2010). Twin of MATLAB lqn_setup_charge.m, the JAR
3425 * SolverLN.setupCharge and the Python SolverLN._setup_charge.
3426 */
3427 double setup_charge(std::size_t tidx) const {
3428 if (tidx >= lqn.hassetup.size() || !lqn.hassetup[tidx]) return 0.0;
3429 const double s = tidx < lqn.setuptime.size() && !lqn.setuptime[tidx].disabled
3430 ? dbl(lqn.setuptime[tidx].mean) : 0.0;
3431 const double d = tidx < lqn.delayofftime.size() && !lqn.delayofftime[tidx].disabled
3432 ? dbl(lqn.delayofftime[tidx].mean) : 0.0;
3433 if (!(s > GlobalConstants::FineTol) || !(d > GlobalConstants::FineTol)) return 0.0;
3434 const double mult = lqn.mult[tidx];
3435 if (!std::isfinite(mult) || mult <= 0.0) return 0.0;
3436 if (tidx >= tput.size() || tidx >= util.size()) return s;
3437 const double X = dbl(tput[tidx]);
3438 if (!std::isfinite(X) || X <= GlobalConstants::FineTol) return s;
3439 double rho = dbl(util[tidx]);
3440 if (!std::isfinite(rho) || rho < 0.0) rho = 0.0;
3441 rho = std::min(rho, 1.0 - GlobalConstants::FineTol);
3442 const double b = rho * mult; // offered load, in threads
3443 const double EI = (std::max(1.0, b) - b) / X; // idle interval of a hot thread
3444 return s * EI / (EI + d);
3445 }
3446
3447 /**
3448 * Probability that a request for entry EIDX finds its task's thread off.
3449 * ONE closure for both methods: setup_charge returns p*s, so p is that over
3450 * s. It also answers p = 1 during construction, before the first solve has
3451 * sized tput or util.
3452 */
3453 double ph_setup_prob(std::size_t eidx) const {
3454 const std::size_t tidx = lqn.parent[eidx];
3455 if (tidx >= lqn.hassetup.size() || !lqn.hassetup[tidx]) return 0.0;
3456 const double s = tidx < lqn.setuptime.size() && !lqn.setuptime[tidx].disabled
3457 ? dbl(lqn.setuptime[tidx].mean) : 0.0;
3458 const double d = tidx < lqn.delayofftime.size() && !lqn.delayofftime[tidx].disabled
3459 ? dbl(lqn.delayofftime[tidx].mean) : 0.0;
3460 if (!(s > GlobalConstants::FineTol) || !(d > GlobalConstants::FineTol)) return 0.0;
3461 return std::min(1.0, std::max(0.0, setup_charge(tidx) / s));
3462 }
3463
3464 /** Divisor that scales a processor utilization into [0,1]. */
3465 double ph_host_servers(std::size_t hidx) const {
3466 if (lqn.sched[hidx] == SchedStrategy::INF) return 1.0;
3467 const double m = lqn.maxmult[hidx];
3468 return (std::isfinite(m) && m > 0.0) ? m : 1.0;
3469 }
3470
3471 /** True when any task calls entry EIDX, synchronously or not. */
3472 bool ph_any_caller_of(std::size_t eidx) const {
3473 return lqn.issynccaller.any_col(eidx) || lqn.isasynccaller.any_col(eidx);
3474 }
3475
3476 /**
3477 * True when an entry arrival is the ONLY way requests reach task TIDX.
3478 * "srvn.ph" refuses forwarding calls outright, so sync/async callers are the
3479 * whole test.
3480 */
3481 bool ph_open_arrival_only(std::size_t tidx) const {
3482 if (lqn.isref[tidx]) return false;
3483 for (std::size_t eidx : lqn.entriesof[tidx])
3484 if (ph_any_caller_of(eidx)) return false;
3485 for (std::size_t eidx : lqn.entriesof[tidx])
3486 if (lqn.has_arrival[eidx]) return true;
3487 return false;
3488 }
3489
3490 /** Asynchronous calls whose target entry belongs to TIDX. */
3491 std::vector<std::size_t> ph_async_calls_into(std::size_t tidx) const {
3492 std::vector<std::size_t> out;
3493 for (std::size_t cidx = 1; cidx <= lqn.ncalls; ++cidx) {
3494 if (lqn.calltype[cidx] != CallType::ASYNC) continue;
3495 for (std::size_t e : lqn.entriesof[tidx])
3496 if (lqn.callpair_dst[cidx] == e) { out.push_back(cidx); break; }
3497 }
3498 return out;
3499 }
3500
3501 /**
3502 * Features the collapsed layer cannot represent are refused by name rather
3503 * than silently degraded.
3504 */
3505 void ph_assert_supported(bool flat = false) const {
3506 // The list is a property of the ENCODING, so it is the same under either
3507 // layering; what the squashing adds on top is refused in ph_flat_server_set.
3508 const std::string mname = flat ? "flat.ph" : "srvn.ph";
3509 // PHASE 2 IS ASKED HERE AND NOWHERE ELSE. `has_phase2` is built during
3510 // layering rather than being a property of the model, so the report twin
3511 // below cannot ask it; every other rule is shared with it.
3512 if (has_phase2)
3513 throw UnsupportedError(
3514 "method='" + mname + "' does not support second-phase activities: the composed "
3515 "entry law has no reply point. Use method='default'.");
3516 // ONE RULE LIST, TWO CALLERS: this throws what `ph_method_refusal`
3517 // returns, in the same order, so the run and a report cannot say
3518 // different things about one model. The squashing rules it also carries
3519 // for `flat.ph` are the ones `ph_flat_server_set` raises, which runs
3520 // before this on that path, so the message a caller sees is unchanged.
3521 const std::string why = ph_method_refusal(mname);
3522 if (!why.empty()) throw UnsupportedError(why);
3523 }
3524
3525 /**
3526 * The same rules as a SENTENCE, so a report can withdraw a method it cannot run.
3527 *
3528 * ONE RULE LIST, TWO CALLERS. `ph_assert_supported` above answers the run
3529 * path by throwing; nothing answered the report, and `list_valid_methods`
3530 * returns the same eight names for every model, so every layered model was
3531 * offered every encoding and `srvn.ph`/`flat.ph` then threw on contact.
3532 *
3533 * PHASE 2 IS DELIBERATELY ABSENT. `has_phase2` is built during layering
3534 * rather than being a property of the model, so a gate cannot ask it without
3535 * doing the layering it is meant to precede; the run path still refuses it.
3536 *
3537 * @param method the concrete method name
3538 * @return empty string when the method can encode this model, else the reason
3539 */
3540 std::string ph_method_refusal(const std::string& method) const {
3541 if (method != "srvn.ph" && method != "flat.ph") return std::string();
3542 // The squashing refusals, `flat.ph` only: each carries PER-LAYER state
3543 // that one submodel cannot hold (ph_flat_server_set).
3544 if (method == "flat.ph") {
3545 for (std::size_t i = 1; i <= NT(); ++i) {
3546 if (lqn.repl[i] > 1.0)
3547 return "method='flat.ph' does not support replicated processors or tasks, "
3548 "whose replicas need a submodel each. Use method='srvn.ph'.";
3549 if (lqn.hassetup[i])
3550 return "method='flat.ph' does not support setup tasks, whose powered-down "
3551 "threads are per-layer state. Use method='srvn.ph'.";
3552 }
3553 }
3554 for (std::size_t cidx = 1; cidx <= lqn.ncalls; ++cidx)
3555 if (lqn.calltype[cidx] == CallType::FWD)
3556 return "method='" + method +
3557 "' does not support forwarding calls, whose target is not part of the "
3558 "caller's activity graph. Use method='default'.";
3559 for (std::size_t i = 1; i < lqn.iscache.size(); ++i)
3560 if (lqn.iscache[i])
3561 return "method='" + method +
3562 "' does not support cache tasks. Use method='default'.";
3563 for (std::size_t i = 1; i < lqn.hassetup.size(); ++i) {
3564 if (!lqn.hassetup[i]) continue;
3565 if (lqn.sched[i] == SchedStrategy::INF || !std::isfinite(lqn.mult[i]))
3566 return "method='" + method + "': task '" + lqn.names[i] +
3567 "' declares a setup time on an infinite-server task, which holds no "
3568 "thread to power down; give it a finite multiplicity.";
3569 }
3570 for (std::size_t i = 0; i < lqn.lincon_A.size(); ++i)
3571 if (lqn.lincon_A[i].rows() > 0)
3572 return "method='" + method +
3573 "' does not support admission constraints on a layer station. "
3574 "Use method='default'.";
3575 for (std::size_t i = 1; i < lqn.lldscaling.size(); ++i) {
3576 const char* fname = nullptr;
3577 if (!lqn.lldscaling[i].empty()) fname = "a load dependence";
3578 else if (lqn.cdscaling[i]) fname = "a class dependence";
3579 else if (lqn.jdscaling[i]) fname = "a joint dependence";
3580 else if (!lqn.pools[i].empty()) fname = "server pools";
3581 if (fname != nullptr)
3582 return "method='" + method +
3583 "' does not support queue-dependent service rates on a layer station ('" +
3584 lqn.names[i] + "' declares " + fname + "). Use method='srvn.cs'.";
3585 }
3586 return std::string();
3587 }
3588
3589 /** Build the per-entry workflows and the iteration-invariant processor law. */
3590 void ph_init_laws() {
3591 const std::size_t N = lqn.nidx;
3592 ph_wf.assign(N + 1, workflow::Workflow<T>("empty"));
3593 ph_wfhost.assign(N + 1, workflow::Workflow<T>("empty"));
3594 ph_execs.assign(N + 1, {});
3595 ph_has_wf.assign(N + 1, false);
3596 ph_hostlaw.assign(N + 1, workflow::PhLaw<T>());
3597 ph_entrylaw.assign(N + 1, workflow::PhLaw<T>());
3598 ph_hostmean.assign(N + 1, Tzero());
3599 ph_entrymean.assign(N + 1, Tzero());
3600 ph_entryscv.assign(N + 1, Tone());
3601 ph_share.assign(N + 1, Tzero());
3602 ph_overlap.assign(N + 1, Tone());
3603 ph_setupshare.assign(N + 1, Tzero());
3604 ph_xdemand.assign(N + 1, Tzero());
3605 ph_ncalls = Matrix<T>(N + 1, N + 1, Tzero());
3606 ph_calltime = Matrix<T>(N + 1, N + 1, Tzero());
3607 ph_procresid.assign(N + 1, Tzero());
3608 ph_actthinkt.assign(N + 1, Tzero());
3609 ph_calltotal.assign(N + 1, Tzero());
3610 ph_layers.assign(NT() + 1, PHLayer());
3611 ph_has_layer.assign(NT() + 1, false);
3612
3613 for (std::size_t e = 1; e <= lqn.nentries; ++e) {
3614 const std::size_t eidx = lqn.eshift + e;
3615 const std::size_t tidx = lqn.parent[eidx];
3616 if (ignore[tidx]) continue;
3617 api::lqn::EntryWorkflow<T> ew = api::lqn::entry_workflow(lqn, eidx, true);
3618 ph_wf[eidx] = std::move(ew.wf);
3619 ph_execs[eidx] = ew.execs;
3620 ph_has_wf[eidx] = true;
3621 api::lqn::EntryWorkflow<T> eh = api::lqn::entry_workflow(lqn, eidx, false);
3622 ph_wfhost[eidx] = std::move(eh.wf);
3623 // the processor sees the WORK of concurrent branches, not their
3624 // elapsed time, so the host law serialises an AND fork
3625 ph_hostlaw[eidx] = api::lqn::serial_law(ph_wfhost[eidx]);
3626 ph_hostmean[eidx] =
3627 api::lqn::ph_moments(ph_hostlaw[eidx].alpha, ph_hostlaw[eidx].S).first;
3628 }
3629
3630 // until the first iteration reports throughputs, a task splits its
3631 // requests evenly over its entries
3632 for (std::size_t t = 1; t <= lqn.ntasks; ++t) {
3633 const std::size_t tidx = lqn.tshift + t;
3634 const std::size_t n = lqn.entriesof[tidx].size();
3635 if (n == 0) continue;
3636 for (std::size_t eidx : lqn.entriesof[tidx])
3637 ph_share[eidx] = num_traits<T>::from_double(1.0 / double(n));
3638 }
3639 }
3640
3641 /**
3642 * Law of the total time one execution of the issuing activity spends in call
3643 * CIDX: the geometric compound, of mean callproc_mean, of the response law
3644 * of the called entry. The response law is fitted to the response time
3645 * reported by the callee's layer and to the SCV of the callee's own composed
3646 * law, so no extra solver output is needed.
3647 */
3648 Distrib<T> ph_call_burst_law(std::size_t cidx) const {
3649 const double m = dbl(lqn.callproc_mean[cidx]);
3650 const std::size_t eidx = lqn.callpair_dst[cidx];
3652 const T R = T(callservt[cidx] / lqn.callproc_mean[cidx]);
3653 double scvd = dbl(ph_entryscv[eidx]);
3654 if (!std::isfinite(scvd) || scvd <= GlobalConstants::FineTol) scvd = 1.0;
3655 const T Rf = dbl(R) > GlobalConstants::FineTol
3656 ? R : num_traits<T>::from_double(GlobalConstants::FineTol);
3657 const Distrib<T> base = lang::aph_fit_mean_scv(Rf, num_traits<T>::from_double(scvd));
3658 const workflow::PhLaw<T> body = api::lqn::ph_law_of(base);
3659 const workflow::PhLaw<T> loop =
3660 workflow::Workflow<T>::compose_loop_geometric(body, lqn.callproc_mean[cidx]);
3661 return Distrib<T>::phase_type(loop.alpha, loop.S,
3663 }
3664
3665 /**
3666 * Station law of a composed workflow. A geometric loop over a body of two or
3667 * more phases closes a cycle in the phase graph, and a cyclic generator is a
3668 * PH and not an APH: no layer solver declares PH, so such a law is reduced
3669 * to the APH with the SAME first two moments. AMVA and NC read exactly those
3670 * two, so the reduction is lossless for them and is a two-moment fit for the
3671 * phase-aware layer solvers.
3672 */
3673 Distrib<T> ph_station_law(const workflow::PhLaw<T>& law) const {
3675 return Distrib<T>::phase_type(law.alpha, law.S, true);
3676 const std::pair<T, T> mm = api::lqn::ph_moments(law.alpha, law.S);
3677 return lang::aph_fit_mean_scv(mm.first, mm.second);
3678 }
3679
3680 /** The law of a single immediate phase, the empty-composition answer. */
3681 static workflow::PhLaw<T> ph_immediate_law() {
3682 workflow::PhLaw<T> out;
3683 out.alpha.assign(1, Tone());
3684 out.S = Matrix<T>(1, 1, num_traits<T>::from_double(-GlobalConstants::Immediate));
3685 return out;
3686 }
3687
3688 /**
3689 * Recompose the entry service laws from the current fixed-point iterate.
3690 *
3691 * The composed mean is NOT the sum of the leaf means when the graph forks:
3692 * the branches of an AND fork overlap, and the entry finishes with the last
3693 * of them. The ratio of the two, the overlap factor, is what the caller-side
3694 * aggregates are scaled by, so that the pieces of a cycle still add up to
3695 * the cycle.
3696 */
3697 void ph_compose_entry_laws() {
3698 const std::size_t N = lqn.nidx;
3699 std::vector<T> entry_setup_share(N + 1, Tzero());
3700 ph_overlap.assign(N + 1, Tone());
3701
3702 for (std::size_t e = 1; e <= lqn.nentries; ++e) {
3703 const std::size_t eidx = lqn.eshift + e;
3704 if (!ph_has_wf[eidx]) continue;
3705 workflow::Workflow<T>& w = ph_wf[eidx];
3706 const std::unordered_map<std::size_t, T>& ex = ph_execs[eidx];
3707 T entrysum = Tzero(), procsum = Tzero();
3708 for (std::size_t aidx : lqn.actsof[eidx]) {
3709 T m = T(residt[aidx] + act_think_time(aidx));
3710 const T xa = ex.at(aidx);
3711 procsum += T(xa * m);
3712 const double md = dbl(m);
3713 w.set_activity_demand_mean(
3714 lqn.names[aidx],
3716 ? m : num_traits<T>::from_double(GlobalConstants::FineTol));
3717 for (std::size_t cidx : lqn.callsof[aidx]) {
3718 if (lqn.calltype[cidx] != CallType::SYNC) continue;
3719 w.set_activity_demand(lqn.callhashnames[cidx], ph_call_burst_law(cidx));
3720 m = T(m + callservt[cidx]);
3721 }
3722 entrysum += T(xa * m);
3723 }
3724 workflow::PhLaw<T> law = api::lqn::ph_law_of(w.refresh_ph());
3725 std::pair<T, T> mm = api::lqn::ph_moments(law.alpha, law.S);
3726 T m1 = mm.first, scv = mm.second;
3727 // All activities of an entry run on ONE processor, so the branches of
3728 // an AND fork cannot overlap the processor residence they request:
3729 // the composed maximum is a lower bound on the entry service time
3730 // only above that total. Where it falls below, the law is rescaled in
3731 // time to it, which keeps its shape, its SCV and its order.
3732 if (dbl(procsum) > dbl(m1) + GlobalConstants::FineTol) {
3733 const T f = T(m1 / procsum);
3734 for (std::size_t i = 0; i < law.S.rows(); ++i)
3735 for (std::size_t j = 0; j < law.S.cols(); ++j) law.S(i, j) = T(law.S(i, j) * f);
3736 m1 = procsum;
3737 }
3738 // A SetupTask powers a thread down when it goes idle, so a request may
3739 // find it off and pay a cold start before the entry runs at all. The
3740 // setup is not part of the activity graph and never enters the
3741 // series-parallel reduction: it is prefixed to the composed law
3742 // afterwards as the mixture p*(setup THEN entry) + (1-p)*entry, which
3743 // is again phase-type.
3744 const double p = ph_setup_prob(eidx);
3745 if (p > GlobalConstants::FineTol) {
3746 const std::size_t tidx = lqn.parent[eidx];
3747 const double sm = dbl(lqn.setuptime[tidx].mean);
3748 double sscv = dbl(lqn.setuptime[tidx].scv);
3749 if (!std::isfinite(sscv) || sscv <= GlobalConstants::FineTol) sscv = 1.0;
3750 if (std::isfinite(sm) && sm > GlobalConstants::FineTol) {
3751 const workflow::PhLaw<T> sl = api::lqn::ph_law_of(lang::aph_fit_mean_scv(
3752 num_traits<T>::from_double(sm), num_traits<T>::from_double(sscv)));
3753 std::vector<workflow::PhLaw<T>> mix;
3754 mix.push_back(workflow::Workflow<T>::compose_serial(sl, law));
3755 mix.push_back(law);
3756 std::vector<T> probs;
3757 probs.push_back(num_traits<T>::from_double(p));
3758 probs.push_back(num_traits<T>::from_double(1.0 - p));
3760 mm = api::lqn::ph_moments(law.alpha, law.S);
3761 m1 = mm.first;
3762 scv = mm.second;
3763 // The share of the entry law that is cold start and not work.
3764 // The surrogate-delay closure measures a thread's cycle in
3765 // WORK, so it must not read a station utilization this has
3766 // inflated -- see update_think_times_ph.
3767 const double denom = std::max(dbl(m1), GlobalConstants::FineTol);
3768 entry_setup_share[eidx] = num_traits<T>::from_double(p * sm / denom);
3769 }
3770 }
3771 ph_entrylaw[eidx] = law;
3772 ph_entrymean[eidx] = m1;
3773 ph_entryscv[eidx] = scv;
3774 if (dbl(entrysum) > GlobalConstants::FineTol) {
3775 const double r = std::min(1.0, dbl(m1) / dbl(entrysum));
3776 ph_overlap[eidx] = num_traits<T>::from_double(r);
3777 }
3778 }
3779
3780 // Per task, the share-weighted fraction of its station service that is
3781 // cold start rather than work.
3782 ph_setupshare.assign(N + 1, Tzero());
3783 for (std::size_t t = 1; t <= lqn.ntasks; ++t) {
3784 const std::size_t tidx = lqn.tshift + t;
3785 if (ignore[tidx]) continue;
3786 for (std::size_t eidx : lqn.entriesof[tidx])
3787 ph_setupshare[tidx] = T(ph_setupshare[tidx] + ph_share[eidx] * entry_setup_share[eidx]);
3788 }
3789
3790 // Expected number of calls per invocation, and the caller-side aggregates
3791 ph_ncalls = Matrix<T>(N + 1, N + 1, Tzero());
3792 ph_calltime = Matrix<T>(N + 1, N + 1, Tzero());
3793 ph_procresid.assign(N + 1, Tzero());
3794 ph_actthinkt.assign(N + 1, Tzero());
3795 ph_calltotal.assign(N + 1, Tzero());
3796 for (std::size_t t = 1; t <= lqn.ntasks; ++t) {
3797 const std::size_t tidx = lqn.tshift + t;
3798 if (ignore[tidx]) continue;
3799 for (std::size_t eidx : lqn.entriesof[tidx]) {
3800 if (!ph_has_wf[eidx]) continue;
3801 const T w = ph_share[eidx];
3802 if (!(dbl(w) > 0.0)) continue;
3803 const std::unordered_map<std::size_t, T>& ex = ph_execs[eidx];
3804 const T r = ph_overlap[eidx];
3805 for (std::size_t aidx : lqn.actsof[eidx]) {
3806 const T xa = ex.at(aidx);
3807 ph_procresid[tidx] = T(ph_procresid[tidx] + w * r * xa * residt[aidx]);
3808 ph_actthinkt[tidx] = T(ph_actthinkt[tidx] + w * r * xa * act_think_time(aidx));
3809 for (std::size_t cidx : lqn.callsof[aidx]) {
3810 if (lqn.calltype[cidx] != CallType::SYNC) continue;
3811 const std::size_t tgte = lqn.callpair_dst[cidx];
3812 const std::size_t tgtt = lqn.parent[tgte];
3813 // the COUNT of calls does not change with the overlap,
3814 // only the time the caller is held by them
3815 ph_ncalls(tidx, tgte) =
3816 T(ph_ncalls(tidx, tgte) + w * xa * lqn.callproc_mean[cidx]);
3817 ph_calltime(tidx, tgtt) =
3818 T(ph_calltime(tidx, tgtt) + w * r * xa * callservt[cidx]);
3819 ph_calltotal[tidx] = T(ph_calltotal[tidx] + w * r * xa * callservt[cidx]);
3820 }
3821 }
3822 }
3823 }
3824 }
3825
3826 /** Build the ensemble under method "srvn.ph". */
3827 void build_layers_ph(bool flat = false) {
3828 if (!ph_laws_ready) ph_assert_supported(flat);
3829 // The interlock correction rewrites the populations of the call classes,
3830 // which this method does not create: its callers reach the server in one
3831 // class each
3832 opt.interlocking = false;
3833
3834 // A preceding probe has already composed the per-entry workflows; they do
3835 // not depend on the iterate, so they are not rebuilt here.
3836 if (!ph_laws_ready) ph_init_laws();
3837
3838 // Seed the fixed point with the static demands, then compose the laws
3839 const std::size_t N = lqn.nidx;
3840 residt.assign(N + 1, Tzero());
3841 servt.assign(N + 1, Tzero());
3842 callservt.assign(lqn.ncalls + 1, Tzero());
3843 callresidt.assign(lqn.ncalls + 1, Tzero());
3844 for (std::size_t aidx = lqn.ashift + 1; aidx <= lqn.ashift + lqn.nacts; ++aidx)
3845 residt[aidx] = lqn.hostdem[aidx].disabled ? Tzero() : lqn.hostdem[aidx].mean;
3846 for (std::size_t cidx = 1; cidx <= lqn.ncalls; ++cidx) {
3847 if (lqn.calltype[cidx] != CallType::SYNC && lqn.calltype[cidx] != CallType::ASYNC)
3848 continue;
3849 const std::size_t eidx = lqn.callpair_dst[cidx];
3850 callservt[cidx] = T(lqn.callproc_mean[cidx] * ph_hostmean[eidx]);
3851 callresidt[cidx] = callservt[cidx];
3852 }
3853 ph_compose_entry_laws();
3854
3855 if (flat) {
3856 // ONE subnetwork holding every processor and every called task
3857 build_ph_flat_layer();
3858 tput.assign(N + 1, Tzero());
3859 util.assign(N + 1, Tzero());
3860 thinkt.assign(N + 1, Tzero());
3861 update_layers_ph(0);
3862 return;
3863 }
3864
3865 std::vector<qn::Layer<T>> raw(NT() + 1);
3866 std::vector<bool> present(NT() + 1, false);
3867
3868 for (std::size_t hidx = 1; hidx <= lqn.nhosts; ++hidx) {
3869 if (ignore[hidx]) continue;
3870 std::vector<std::size_t> callers;
3871 for (std::size_t tidx : lqn.tasksof[hidx]) {
3872 if (ignore[tidx]) continue;
3873 if (lqn.isref[tidx]) { callers.push_back(tidx); continue; }
3874 for (std::size_t eidx : lqn.entriesof[tidx])
3875 if (ph_any_caller_of(eidx) || lqn.has_arrival[eidx]) {
3876 callers.push_back(tidx);
3877 break;
3878 }
3879 }
3880 if (callers.empty()) continue;
3881 build_layer_ph(raw[hidx], hidx, callers, true);
3882 present[hidx] = true;
3883 }
3884 for (std::size_t t = 1; t <= lqn.ntasks; ++t) {
3885 const std::size_t tidx = lqn.tshift + t;
3886 if (ignore[tidx] || lqn.isref[tidx]) continue;
3887 std::vector<std::size_t> callers;
3888 for (std::size_t ct = 1; ct <= lqn.ntasks; ++ct) {
3889 const std::size_t c = lqn.tshift + ct;
3890 if (c == tidx || ignore[c]) continue;
3891 for (std::size_t e : lqn.entriesof[tidx])
3892 if (lqn.issynccaller.get(c, e)) { callers.push_back(c); break; }
3893 }
3894 if (callers.empty() && ph_async_calls_into(tidx).empty()) continue;
3895 build_layer_ph(raw[tidx], tidx, callers, false);
3896 present[tidx] = true;
3897 }
3898
3899 idxhash.assign(lqn.nidx + 1, -1);
3900 long next = 0;
3901 for (std::size_t i = 1; i <= NT(); ++i)
3902 if (present[i]) {
3903 idxhash[i] = next++;
3904 ensemble.push_back(std::move(raw[i]));
3905 }
3906 layer_init_sol.assign(ensemble.size(), Matrix<T>());
3907 build_fork_views();
3908
3909 // install the initial laws, so that iteration 1 sees the seeded demands
3910 // rather than the placeholders the stations were created with
3911 tput.assign(N + 1, Tzero());
3912 util.assign(N + 1, Tzero());
3913 thinkt.assign(N + 1, Tzero());
3914 update_layers_ph(0);
3915 }
3916
3917 /**
3918 * Build the ONE layer of method "flat.ph": a client delay plus a station for
3919 * every processor and every called task.
3920 *
3921 * A caller task is one closed class, and it visits each server it uses ONCE
3922 * per invocation, carrying there the composed law of the demand it places on
3923 * that server -- the same law method "srvn.ph" installs in the server's own
3924 * layer. What changes is that the servers now contend inside one network
3925 * instead of seeing each other through surrogate delays, so the client delay
3926 * keeps only the think times and whatever of the cycle this model does not
3927 * hold. That is the whole difference between the two encodings of the PH
3928 * composition, and it is why the reconstruction passes are shared verbatim.
3929 */
3930 void build_ph_flat_layer() {
3931 const std::vector<std::size_t> servers = ph_flat_server_set();
3932 const std::size_t nsrv = servers.size();
3933
3934 qn::Layer<T> m;
3935 m.name = "FlatPH";
3936 m.clientIdx = m.add_station(qn::Station<T>{
3937 "Clients", NodeType::Delay, SchedStrategy::INF,
3938 std::numeric_limits<double>::infinity(), false, 0});
3939 const std::size_t clientNode = m.node_of_station(m.clientIdx);
3940 m.server_idx_of.assign(lqn.nidx + 1, 0);
3941
3942 std::vector<std::size_t> station_of(lqn.nidx + 1, 0);
3943 std::vector<std::size_t> serverNode(nsrv, 0);
3944 std::vector<std::size_t> srvStation(nsrv, 0);
3945 for (std::size_t si = 0; si < nsrv; ++si) {
3946 const std::size_t idx = servers[si];
3947 const bool ishost = idx <= lqn.nhosts;
3948 qn::Station<T> st;
3949 st.name = lqn.hashnames[idx];
3950 st.nodetype = lqn.sched[idx] == SchedStrategy::INF ? NodeType::Delay : NodeType::Queue;
3951 st.sched = lqn.sched[idx];
3952 st.nservers = lqn.sched[idx] == SchedStrategy::INF
3953 ? std::numeric_limits<double>::infinity()
3954 : lqn.maxmult[idx];
3955 st.attr_ishost = ishost;
3956 st.attr_idx = idx;
3957 const std::size_t s = m.add_station(st);
3958 srvStation[si] = s;
3959 station_of[idx] = s;
3960 serverNode[si] = m.node_of_station(s);
3961 m.server_idx_of[idx] = s;
3962 if (ishost)
3963 m.host_stations.push_back(s);
3964 else
3965 m.task_stations.push_back(s);
3966 }
3967 // the scalar fallback of the station lookup, which no served element reaches
3968 m.serverIdx = station_of[servers[0]];
3969 m.flat = true;
3970
3971 // Callers of each server, and the union of them, which becomes the class set
3972 std::vector<std::vector<std::size_t>> callers_of(lqn.nidx + 1);
3973 std::vector<std::size_t> all_callers;
3974 for (std::size_t si = 0; si < nsrv; ++si) {
3975 const std::size_t idx = servers[si];
3976 std::vector<std::size_t> cs;
3977 if (idx <= lqn.nhosts) {
3978 for (std::size_t tidx : lqn.tasksof[idx]) {
3979 if (ignore[tidx]) continue;
3980 if (lqn.isref[tidx]) { cs.push_back(tidx); continue; }
3981 for (std::size_t eidx : lqn.entriesof[tidx])
3982 if (ph_any_caller_of(eidx) || lqn.has_arrival[eidx]) {
3983 cs.push_back(tidx);
3984 break;
3985 }
3986 }
3987 } else {
3988 for (std::size_t ct = 1; ct <= lqn.ntasks; ++ct) {
3989 const std::size_t c = lqn.tshift + ct;
3990 if (c == idx || ignore[c]) continue;
3991 for (std::size_t e : lqn.entriesof[idx])
3992 if (lqn.issynccaller.get(c, e)) { cs.push_back(c); break; }
3993 }
3994 }
3995 callers_of[idx] = cs;
3996 for (std::size_t c : cs)
3997 if (std::find(all_callers.begin(), all_callers.end(), c) == all_callers.end())
3998 all_callers.push_back(c);
3999 }
4000 std::sort(all_callers.begin(), all_callers.end());
4001
4002 // One closed class per caller task
4003 std::vector<std::size_t> class_of_caller(lqn.nidx + 1, 0);
4004 double npop = 0.0;
4005 for (std::size_t c : all_callers) {
4006 // ph_flat_server_set has refused every replicated element, so the
4007 // per-replica reduction the srvn builder makes is the identity here
4008 double nj = lqn.maxmult[c];
4009 if (std::isinf(nj)) {
4010 double sacc = 0.0;
4011 for (std::size_t k = 1; k <= NT(); ++k)
4012 if (lqn.taskgraph.get(k, c) != Tzero()) sacc += lqn.maxmult[k];
4013 nj = sacc;
4014 if (std::isinf(nj) || nj == 0.0) {
4015 double s2 = 0.0;
4016 for (std::size_t k = 1; k <= NT(); ++k)
4017 if (std::isfinite(lqn.maxmult[k])) s2 += lqn.maxmult[k];
4018 nj = std::min(s2, 1000.0);
4019 }
4020 }
4021 qn::JobClass jc;
4022 jc.name = lqn.hashnames[c];
4023 jc.type = JobClassType::CLOSED;
4024 jc.population = nj;
4025 jc.refstat = m.clientIdx;
4026 jc.is_ref_class = true;
4027 jc.attr_kind = int(LqnElement::TASK);
4028 jc.attr_idx = c;
4029 const std::size_t k = m.add_class(jc);
4030 class_of_caller[c] = k;
4031 m.attr_tasks.emplace_back(k, c);
4032 npop += nj;
4033 const double zt = dbl(ref_think_time(c));
4034 m.set_service(m.clientIdx, k,
4035 Distrib<T>::exp_mean(num_traits<T>::from_double(
4036 std::max(zt, GlobalConstants::FineTol))));
4037 // A station this caller never reaches must say so with a DISABLED law,
4038 // not with a tiny placeholder. An FCFS station carries ONE service law
4039 // across its classes, so a placeholder is not inert there: it is mixed
4040 // into the multiserver correction and invents waiting where there is
4041 // none. Under "srvn.ph" the question never arises, since every class of
4042 // a layer visits that layer's single server.
4043 for (std::size_t si = 0; si < nsrv; ++si)
4044 m.set_service(srvStation[si], k, Distrib<T>::disabled_dist());
4045 for (std::size_t si = 0; si < nsrv; ++si) {
4046 const std::size_t idx = servers[si];
4047 const std::vector<std::size_t>& cs = callers_of[idx];
4048 if (std::find(cs.begin(), cs.end(), c) == cs.end()) continue;
4049 m.set_service(srvStation[si], k,
4051 num_traits<T>::from_double(GlobalConstants::FineTol)));
4052 njobs(c, idx) = nj;
4053 thinkt_map.push_back({idx, c, m.clientIdx, k});
4054 servt_map.push_back({idx, c, station_of[idx], k});
4055 }
4056 }
4057
4058 // Open classes: entry arrivals on a processor station, async calls on a task one
4059 std::vector<std::vector<std::pair<std::size_t, long>>> open_of(lqn.nidx + 1);
4060 std::size_t sourceStation = 0, sinkNode = 0;
4061 for (std::size_t si = 0; si < nsrv; ++si) {
4062 const std::size_t hidx = servers[si];
4063 if (hidx > lqn.nhosts) continue;
4064 for (std::size_t c : callers_of[hidx]) {
4065 // A task no other task calls has no task station, so the think-time
4066 // closure never gives its caller class a surrogate delay: the class
4067 // cycles against an Immediate one and an open stream on top of it
4068 // doubles the load. The chain is the representation that honours the
4069 // thread pool, so it is kept and closed on the arrival rate instead.
4070 if (ph_open_arrival_only(c)) continue;
4071 for (std::size_t eidx : lqn.entriesof[c]) {
4072 if (!lqn.has_arrival[eidx]) continue;
4073 if (sourceStation == 0) {
4074 sourceStation = m.add_station(qn::Station<T>{
4075 "Source", NodeType::Source, SchedStrategy::EXT,
4076 std::numeric_limits<double>::infinity(), false, 0});
4077 m.sourceIdx = sourceStation;
4078 sinkNode = m.add_node("Sink", NodeType::Sink, false);
4079 m.sinkNode = sinkNode;
4080 }
4081 qn::JobClass oc;
4082 oc.name = lqn.hashnames[eidx] + ".Open";
4083 oc.type = JobClassType::OPEN;
4084 oc.population = std::numeric_limits<double>::infinity();
4085 oc.refstat = sourceStation;
4086 oc.attr_kind = int(LqnElement::ENTRY);
4087 oc.attr_idx = eidx;
4088 const std::size_t k = m.add_class(oc);
4089 m.set_service(sourceStation, k, lqn.arrival[eidx]);
4090 // disabled, not a placeholder, at every station this stream misses
4091 for (std::size_t s2 = 0; s2 < nsrv; ++s2)
4092 m.set_service(srvStation[s2], k, Distrib<T>::disabled_dist());
4093 const double hm = std::max(dbl(ph_hostmean[eidx]), GlobalConstants::FineTol);
4094 m.set_service(srvStation[si], k,
4095 Distrib<T>::exp_mean(num_traits<T>::from_double(hm)));
4096 open_of[hidx].emplace_back(k, long(eidx));
4097 m.attr_entries.emplace_back(k, eidx);
4098 }
4099 }
4100 }
4101 for (std::size_t si = 0; si < nsrv; ++si) {
4102 const std::size_t tidx = servers[si];
4103 if (tidx <= lqn.nhosts) continue;
4104 for (std::size_t cidx : ph_async_calls_into(tidx)) {
4105 if (sourceStation == 0) {
4106 sourceStation = m.add_station(qn::Station<T>{
4107 "Source", NodeType::Source, SchedStrategy::EXT,
4108 std::numeric_limits<double>::infinity(), false, 0});
4109 m.sourceIdx = sourceStation;
4110 sinkNode = m.add_node("Sink", NodeType::Sink, false);
4111 m.sinkNode = sinkNode;
4112 }
4113 const std::size_t eidx = lqn.callpair_dst[cidx];
4114 qn::JobClass oc;
4115 oc.name = lqn.callhashnames[cidx];
4116 oc.type = JobClassType::OPEN;
4117 oc.population = std::numeric_limits<double>::infinity();
4118 oc.refstat = sourceStation;
4119 oc.attr_kind = int(LqnElement::CALL);
4120 oc.attr_idx = cidx;
4121 const std::size_t k = m.add_class(oc);
4122 m.set_service(sourceStation, k, Distrib<T>::immediate());
4123 // disabled, not a placeholder, at every station this stream misses
4124 for (std::size_t s2 = 0; s2 < nsrv; ++s2)
4125 m.set_service(srvStation[s2], k, Distrib<T>::disabled_dist());
4126 const double em = std::max(dbl(ph_entrymean[eidx]), GlobalConstants::FineTol);
4127 m.set_service(srvStation[si], k,
4128 Distrib<T>::exp_mean(num_traits<T>::from_double(em)));
4129 open_of[tidx].emplace_back(k, -long(cidx));
4130 m.attr_calls.push_back({k, cidx, lqn.callpair_src[cidx], eidx});
4131 arv_call_map.push_back({tidx, cidx, sourceStation, k});
4132 call_map.push_back({tidx, cidx, station_of[tidx], k});
4133 }
4134 }
4135
4136 // Routing: one visit per server the caller uses, in server order. The
4137 // number of calls is carried by the service law, not by a visit ratio, so
4138 // no arc ever moves.
4139 for (std::size_t c : all_callers) {
4140 const std::size_t k = class_of_caller[c];
4141 std::size_t prev = clientNode;
4142 bool visited = false;
4143 for (std::size_t si = 0; si < nsrv; ++si) {
4144 const std::vector<std::size_t>& cs = callers_of[servers[si]];
4145 if (std::find(cs.begin(), cs.end(), c) == cs.end()) continue;
4146 m.set_route(k, k, prev, serverNode[si], Tone());
4147 prev = serverNode[si];
4148 visited = true;
4149 }
4150 if (visited) m.set_route(k, k, prev, clientNode, Tone());
4151 }
4152 if (sourceStation != 0) {
4153 const std::size_t srcNode = m.node_of_station(sourceStation);
4154 for (std::size_t si = 0; si < nsrv; ++si)
4155 for (const auto& oa : open_of[servers[si]]) {
4156 m.set_route(oa.first, oa.first, srcNode, serverNode[si], Tone());
4157 m.set_route(oa.first, oa.first, serverNode[si], sinkNode, Tone());
4158 }
4159 }
4160 m.refresh_chains();
4161
4162 idxhash.assign(lqn.nidx + 1, -1);
4163 for (std::size_t idx : servers) idxhash[idx] = 0;
4164 ensemble.clear();
4165 ensemble.push_back(std::move(m));
4166 layer_init_sol.assign(ensemble.size(), Matrix<T>());
4167 build_fork_views();
4168
4169 const std::size_t nclasses = ensemble[0].classes.size();
4170 for (std::size_t idx : servers) {
4171 PHLayer L;
4172 L.idx = idx;
4173 L.ishost = idx <= lqn.nhosts;
4174 L.callers = callers_of[idx];
4175 L.class_of_caller = class_of_caller;
4176 L.nreplicas = 1;
4177 L.qstations.assign(1, station_of[idx]);
4178 L.svcmean_by_class.assign(nclasses + 1, Tzero());
4179 L.open_arrivals = open_of[idx];
4180 L.npop = npop < 1.0 ? 1.0 : npop;
4181 ph_layers[idx] = L;
4182 ph_has_layer[idx] = true;
4183 }
4184 }
4185
4186 /**
4187 * Processors and called tasks that become stations of the flat layer.
4188 *
4189 * The set is the elements the srvn builder would have given a layer of their
4190 * own, so "flat.ph" and "srvn.ph" place the SAME stations and differ only in
4191 * how many networks hold them. The refusals are those of flat_server_set,
4192 * since they are properties of the squashing and not of the encoding: each of
4193 * these carries per-layer state that one submodel cannot hold.
4194 */
4195 std::vector<std::size_t> ph_flat_server_set() const {
4196 for (std::size_t i = 1; i <= NT(); ++i) {
4197 if (lqn.repl[i] > 1.0)
4198 throw UnsupportedError(
4199 "method='flat.ph' does not support replicated processors or tasks, whose "
4200 "replicas need a submodel each. Use method='srvn.ph'.");
4201 if (lqn.iscache[i])
4202 throw UnsupportedError(
4203 "method='flat.ph' does not support cache tasks. Use method='default'.");
4204 if (lqn.hassetup[i])
4205 throw UnsupportedError(
4206 "method='flat.ph' does not support setup tasks, whose powered-down threads "
4207 "are per-layer state. Use method='srvn.ph'.");
4208 }
4209 std::vector<std::size_t> servers;
4210 for (std::size_t hidx = 1; hidx <= lqn.nhosts; ++hidx) {
4211 if (ignore[hidx] || lqn.tasksof[hidx].empty()) continue;
4212 bool any = false;
4213 for (std::size_t tidx : lqn.tasksof[hidx]) {
4214 if (ignore[tidx]) continue;
4215 if (lqn.isref[tidx]) { any = true; break; }
4216 for (std::size_t eidx : lqn.entriesof[tidx])
4217 if (ph_any_caller_of(eidx) || lqn.has_arrival[eidx]) { any = true; break; }
4218 if (any) break;
4219 }
4220 if (any) servers.push_back(hidx);
4221 }
4222 for (std::size_t t = 1; t <= lqn.ntasks; ++t) {
4223 const std::size_t tidx = lqn.tshift + t;
4224 if (ignore[tidx] || lqn.isref[tidx]) continue;
4225 bool has_caller = false;
4226 for (std::size_t ct = 1; ct <= lqn.ntasks && !has_caller; ++ct) {
4227 const std::size_t c = lqn.tshift + ct;
4228 if (c == tidx || ignore[c]) continue;
4229 for (std::size_t e : lqn.entriesof[tidx])
4230 if (lqn.issynccaller.get(c, e)) { has_caller = true; break; }
4231 }
4232 if (!has_caller && ph_async_calls_into(tidx).empty()) continue;
4233 servers.push_back(tidx);
4234 }
4235 if (servers.empty())
4236 throw InputError(
4237 "method='flat.ph' found no server: the model has no processor with tasks.");
4238 return servers;
4239 }
4240
4241 /** Build the two-station layer of server element IDX. */
4242 void build_layer_ph(qn::Layer<T>& m, std::size_t idx,
4243 const std::vector<std::size_t>& callers, bool ishost) {
4244 m.name = lqn.hashnames[idx];
4245
4246 // Replicas of the server station, with the same fan-out reduction as the
4247 // default builder: a caller that reaches every replica sees one
4248 // representative
4249 const double rawrepl = lqn.repl[idx];
4250 std::size_t nreplicas = 1;
4251 if (rawrepl > 1.0 && !callers.empty()) {
4252 bool reduce = true;
4253 if (ishost) {
4254 for (std::size_t c : callers)
4255 if (lqn.repl[c] != rawrepl) reduce = false;
4256 } else {
4257 for (std::size_t c : callers)
4258 if (lqn.fanout_at(c, idx) < rawrepl) reduce = false;
4259 }
4260 nreplicas = reduce ? 1 : static_cast<std::size_t>(std::llround(rawrepl));
4261 if (reduce && !ishost) single_replica_tasks.insert(idx);
4262 }
4263 const bool reduce_fanout = (nreplicas == 1 && rawrepl > 1.0 && !callers.empty());
4264
4265 m.clientIdx = m.add_station(qn::Station<T>{
4266 "Clients", NodeType::Delay, SchedStrategy::INF,
4267 std::numeric_limits<double>::infinity(), false, 0});
4268 const std::size_t clientNode = m.node_of_station(m.clientIdx);
4269 m.serverIdx = m.clientIdx + 1;
4270 PHLayer L;
4271 L.idx = idx;
4272 L.ishost = ishost;
4273 L.callers = callers;
4274 L.nreplicas = nreplicas;
4275 L.class_of_caller.assign(lqn.nidx + 1, 0);
4276 std::vector<std::size_t> serverNode(nreplicas);
4277 for (std::size_t r = 0; r < nreplicas; ++r) {
4278 qn::Station<T> st;
4279 st.name = r == 0 ? lqn.hashnames[idx] : lqn.hashnames[idx] + "." + std::to_string(r + 1);
4280 st.nodetype = lqn.sched[idx] == SchedStrategy::INF ? NodeType::Delay : NodeType::Queue;
4281 st.sched = lqn.sched[idx];
4282 st.nservers = lqn.sched[idx] == SchedStrategy::INF
4283 ? std::numeric_limits<double>::infinity()
4284 : lqn.maxmult[idx];
4285 st.attr_ishost = ishost;
4286 st.attr_idx = idx;
4287 const std::size_t s = m.add_station(st);
4288 L.qstations.push_back(s);
4289 serverNode[r] = m.node_of_station(s);
4290 }
4291
4292 // --- closed class per caller task
4293 for (std::size_t c : callers) {
4294 double nj = njobs(c, idx);
4295 if (nj == 0.0) {
4296 const bool caller_single_replica =
4297 reduce_fanout || single_replica_tasks.count(c) > 0;
4298 nj = caller_single_replica ? lqn.maxmult[c] : lqn.maxmult[c] * lqn.repl[c];
4299 if (std::isinf(nj)) {
4300 double s = 0.0;
4301 for (std::size_t k = 1; k <= NT(); ++k)
4302 if (lqn.taskgraph.get(k, c) != Tzero()) s += lqn.maxmult[k];
4303 nj = s;
4304 if (std::isinf(nj) || nj == 0.0) {
4305 double s2 = 0.0;
4306 for (std::size_t k = 1; k <= NT(); ++k)
4307 if (std::isfinite(lqn.maxmult[k])) s2 += lqn.maxmult[k] * lqn.repl[k];
4308 nj = std::min(s2, 1000.0);
4309 }
4310 }
4311 njobs(c, idx) = nj;
4312 }
4313 qn::JobClass jc;
4314 jc.name = lqn.hashnames[c];
4315 jc.type = JobClassType::CLOSED;
4316 jc.population = nj;
4317 jc.refstat = m.clientIdx;
4318 jc.is_ref_class = true;
4319 jc.attr_kind = int(LqnElement::TASK);
4320 jc.attr_idx = c;
4321 const std::size_t k = m.add_class(jc);
4322 L.class_of_caller[c] = k;
4323 m.attr_tasks.emplace_back(k, c);
4324 const double zt = dbl(ref_think_time(c));
4325 m.set_service(m.clientIdx, k,
4326 Distrib<T>::exp_mean(num_traits<T>::from_double(
4327 std::max(zt, GlobalConstants::FineTol))));
4328 for (std::size_t s : L.qstations)
4329 m.set_service(s, k, Distrib<T>::exp_mean(
4330 num_traits<T>::from_double(GlobalConstants::FineTol)));
4331 // every layer must be refreshed after a law change: post() resets the
4332 // layers named by the think-time map
4333 thinkt_map.push_back({idx, c, m.clientIdx, k});
4334 servt_map.push_back({idx, c, L.qstations[0], k});
4335 }
4336
4337 // --- open classes: entry arrivals on a host layer, async calls on a task layer
4338 std::size_t sourceStation = 0, sinkNode = 0;
4339 if (ishost) {
4340 for (std::size_t c : callers) {
4341 // A task no other task calls has no task layer, so
4342 // update_think_times_ph never gives its caller class a surrogate
4343 // delay: the class cycles against an Immediate one and an open
4344 // stream on top of it doubles the load. The chain is the
4345 // representation that honours the thread pool, so it is kept and
4346 // closed on the arrival rate instead.
4347 if (ph_open_arrival_only(c)) continue;
4348 for (std::size_t eidx : lqn.entriesof[c]) {
4349 if (!lqn.has_arrival[eidx]) continue;
4350 if (sourceStation == 0) {
4351 sourceStation = m.add_station(qn::Station<T>{
4352 "Source", NodeType::Source, SchedStrategy::EXT,
4353 std::numeric_limits<double>::infinity(), false, 0});
4354 m.sourceIdx = sourceStation;
4355 sinkNode = m.add_node("Sink", NodeType::Sink, false);
4356 m.sinkNode = sinkNode;
4357 }
4358 qn::JobClass oc;
4359 oc.name = lqn.hashnames[eidx] + ".Open";
4360 oc.type = JobClassType::OPEN;
4361 oc.population = std::numeric_limits<double>::infinity();
4362 oc.refstat = sourceStation;
4363 oc.attr_kind = int(LqnElement::ENTRY);
4364 oc.attr_idx = eidx;
4365 const std::size_t k = m.add_class(oc);
4366 m.set_service(sourceStation, k, lqn.arrival[eidx]);
4367 for (std::size_t s : L.qstations) {
4368 const double hm = std::max(dbl(ph_hostmean[eidx]), GlobalConstants::FineTol);
4369 m.set_service(s, k, Distrib<T>::exp_mean(num_traits<T>::from_double(hm)));
4370 }
4371 L.open_arrivals.emplace_back(k, long(eidx));
4372 m.attr_entries.emplace_back(k, eidx);
4373 }
4374 }
4375 } else {
4376 for (std::size_t cidx : ph_async_calls_into(idx)) {
4377 if (sourceStation == 0) {
4378 sourceStation = m.add_station(qn::Station<T>{
4379 "Source", NodeType::Source, SchedStrategy::EXT,
4380 std::numeric_limits<double>::infinity(), false, 0});
4381 m.sourceIdx = sourceStation;
4382 sinkNode = m.add_node("Sink", NodeType::Sink, false);
4383 m.sinkNode = sinkNode;
4384 }
4385 const std::size_t eidx = lqn.callpair_dst[cidx];
4386 qn::JobClass oc;
4387 oc.name = lqn.callhashnames[cidx];
4388 oc.type = JobClassType::OPEN;
4389 oc.population = std::numeric_limits<double>::infinity();
4390 oc.refstat = sourceStation;
4391 oc.attr_kind = int(LqnElement::CALL);
4392 oc.attr_idx = cidx;
4393 const std::size_t k = m.add_class(oc);
4394 m.set_service(sourceStation, k, Distrib<T>::immediate());
4395 for (std::size_t s : L.qstations) {
4396 const double em = std::max(dbl(ph_entrymean[eidx]), GlobalConstants::FineTol);
4397 m.set_service(s, k, Distrib<T>::exp_mean(num_traits<T>::from_double(em)));
4398 }
4399 L.open_arrivals.emplace_back(k, -long(cidx));
4400 m.attr_calls.push_back({k, cidx, lqn.callpair_src[cidx], eidx});
4401 arv_call_map.push_back({idx, cidx, sourceStation, k});
4402 call_map.push_back({idx, cidx, L.qstations[0], k});
4403 }
4404 }
4405
4406 // Routing: one visit to the server per client cycle. The number of calls
4407 // is carried by the service law, not by a visit ratio, so no arc changes
4408 const T share = num_traits<T>::from_double(1.0 / double(nreplicas));
4409 for (std::size_t c : callers) {
4410 const std::size_t k = L.class_of_caller[c];
4411 for (std::size_t r = 0; r < nreplicas; ++r) {
4412 m.set_route(k, k, clientNode, serverNode[r], share);
4413 m.set_route(k, k, serverNode[r], clientNode, Tone());
4414 }
4415 }
4416 for (const auto& oa : L.open_arrivals) {
4417 const std::size_t k = oa.first;
4418 const std::size_t srcNode = m.node_of_station(sourceStation);
4419 for (std::size_t r = 0; r < nreplicas; ++r) {
4420 m.set_route(k, k, srcNode, serverNode[r], share);
4421 m.set_route(k, k, serverNode[r], sinkNode, Tone());
4422 }
4423 }
4424 L.svcmean_by_class.assign(m.classes.size() + 1, Tzero());
4425 double np = 0.0;
4426 for (std::size_t c : callers) {
4427 const double v = njobs(c, idx);
4428 if (std::isfinite(v) && v > 0.0) np += v;
4429 }
4430 L.npop = np < 1.0 ? 1.0 : np;
4431 m.refresh_chains();
4432 ph_layers[idx] = L;
4433 ph_has_layer[idx] = true;
4434 }
4435
4436 /** Law of the demand caller C places on the server of layer IDX per invocation. */
4437 workflow::PhLaw<T> ph_service_law(std::size_t idx, bool ishost, std::size_t c) const {
4438 if (ishost) {
4439 // mixture over the entries of C, weighted by their share of its requests
4440 std::vector<workflow::PhLaw<T>> laws;
4441 std::vector<T> probs;
4442 double tot = 0.0;
4443 for (std::size_t eidx : lqn.entriesof[c]) {
4444 if (ph_hostlaw[eidx].S.rows() == 0 || !(dbl(ph_share[eidx]) > 0.0)) continue;
4445 laws.push_back(ph_hostlaw[eidx]);
4446 probs.push_back(ph_share[eidx]);
4447 tot += dbl(ph_share[eidx]);
4448 }
4449 if (laws.empty()) return ph_immediate_law();
4450 for (T& p : probs) p = T(p / num_traits<T>::from_double(tot));
4451 return workflow::Workflow<T>::compose_mixture(laws, probs);
4452 }
4453 // task layer: the total demand is the sum, over the entries of the
4454 // server, of a geometric compound of the entry law of mean equal to the
4455 // number of calls
4456 bool started = false;
4457 workflow::PhLaw<T> out;
4458 for (std::size_t eidx : lqn.entriesof[idx]) {
4459 const T n = ph_ncalls(c, eidx);
4460 if (dbl(n) <= GlobalConstants::FineTol || ph_entrylaw[eidx].S.rows() == 0) continue;
4461 const workflow::PhLaw<T> lp =
4463 out = started ? workflow::Workflow<T>::compose_serial(out, lp) : lp;
4464 started = true;
4465 }
4466 if (!started) return ph_immediate_law();
4467 return out;
4468 }
4469
4470 /**
4471 * Mean time a thread of caller C spends away from the server of layer IDX per
4472 * invocation: idle, plus whatever of its cycle the layer does not hold.
4473 */
4474 T ph_delay_mean(std::size_t idx, std::size_t c) const {
4475 // ONE closure for both layerings. Under "srvn.ph" the model holds a single
4476 // server, so a host layer charges the whole call burst to the delay and a
4477 // task layer charges the caller's processor plus every other callee. Under
4478 // "flat.ph" the model holds every server, and only the think times are
4479 // left. Every term is SUMMED in rather than obtained by subtracting from a
4480 // total: that subtraction cancels catastrophically once a call time is
4481 // large, the think time falls below the ULP of the call time, and the layer
4482 // then sees a client delay of zero, saturates, and the fixed point runs
4483 // away.
4484 T z = T(thinkt[c] + ref_think_time(c));
4485 if (!std::isfinite(dbl(z)) || dbl(z) < 0.0) z = Tzero();
4486 z = T(z + ph_actthinkt[c]);
4487 const std::size_t hidx = lqn.parent[c];
4488 if (!ph_served_here(idx, hidx)) z = T(z + ph_procresid[c]);
4489 for (std::size_t t = 1; t <= lqn.ntasks; ++t) {
4490 const std::size_t tidx = lqn.tshift + t;
4491 if (!ph_served_here(idx, tidx)) z = T(z + ph_calltime(c, tidx));
4492 }
4493 if (!std::isfinite(dbl(z)) || dbl(z) < 0.0)
4494 z = num_traits<T>::from_double(GlobalConstants::FineTol);
4495 return z;
4496 }
4497
4498 /**
4499 * True when LQN element ELEM is a station of the same model that holds server
4500 * IDX. Under "srvn.ph" that is ELEM == IDX, since each server has a layer of
4501 * its own; under "flat.ph" it is every server of the one network.
4502 */
4503 bool ph_served_here(std::size_t idx, std::size_t elem) const {
4504 if (elem < 1 || elem >= idxhash.size() || idx < 1 || idx >= idxhash.size())
4505 return false;
4506 return idxhash[elem] >= 0 && idxhash[elem] == idxhash[idx];
4507 }
4508
4509 /**
4510 * Push the composed laws into the layers.
4511 *
4512 * A layer of this method carries no routing that depends on the iterate: the
4513 * number of calls a caller makes is folded into its service law rather than
4514 * into a visit ratio, so only two laws move per (layer, class) -- the
4515 * phase-type service law at the server and the mean of the surrogate delay
4516 * at the client.
4517 */
4518 void update_layers_ph(int) {
4519 for (std::size_t idx = 1; idx <= NT(); ++idx) {
4520 if (idxhash[idx] < 0 || !ph_has_layer[idx]) continue;
4521 PHLayer& L = ph_layers[idx];
4522 qn::Layer<T>& m = ensemble[std::size_t(idxhash[idx])];
4523 for (std::size_t c : L.callers) {
4524 const std::size_t k = L.class_of_caller[c];
4525 const workflow::PhLaw<T> sl = ph_service_law(idx, L.ishost, c);
4526 L.svcmean_by_class[k] = api::lqn::ph_moments(sl.alpha, sl.S).first;
4527 const Distrib<T> law = ph_station_law(sl);
4528 for (std::size_t s : L.qstations) m.set_service(s, k, law);
4529 const double zd = std::max(dbl(ph_delay_mean(idx, c)),
4531 m.set_service(m.clientIdx, k,
4532 Distrib<T>::exp_mean(num_traits<T>::from_double(zd)));
4533 }
4534 for (const auto& oa : L.open_arrivals) {
4535 const std::size_t k = oa.first;
4536 if (oa.second > 0) {
4537 // entry arrival: the processor demand law of the entry is static
4538 L.svcmean_by_class[k] = ph_hostmean[std::size_t(oa.second)];
4539 continue;
4540 }
4541 const std::size_t cidx = std::size_t(-oa.second);
4542 const std::size_t eidx = lqn.callpair_dst[cidx];
4543 L.svcmean_by_class[k] = ph_entrymean[eidx];
4544 const Distrib<T> law = ph_station_law(ph_entrylaw[eidx]);
4545 for (std::size_t s : L.qstations) m.set_service(s, k, law);
4546 const std::size_t aidx = lqn.callpair_src[cidx];
4547 double rate = dbl(tput[aidx]) * dbl(lqn.callproc_mean[cidx]);
4548 if (!std::isfinite(rate) || rate <= GlobalConstants::FineTol)
4550 m.set_service(m.sourceIdx, k,
4551 Distrib<T>::exp_rate(num_traits<T>::from_double(rate)));
4552 }
4553 m.refresh_rt();
4554 }
4555 }
4556
4557 /**
4558 * Residence time per visit, by Little from the queue length rather than from
4559 * the reported RN. A layer that saturates can come back from AMVA with an RN
4560 * no closed model can produce, and a reconstruction that trusts it feeds the
4561 * impossible value straight back into the call response times.
4562 */
4563 static T ph_residence(const T& Q, const T& X, const T& RN) {
4564 const double q = num_traits<T>::to_double(Q), x = num_traits<T>::to_double(X);
4565 if (std::isfinite(q) && q >= 0.0 && std::isfinite(x) && x > GlobalConstants::FineTol)
4566 return T(Q / X);
4567 return RN;
4568 }
4569
4570 /**
4571 * Ratio of a residence time to the mean of the law it was measured against,
4572 * bounded above by the layer population: a job can wait behind at most every
4573 * other job in a closed layer.
4574 */
4575 static T ph_inflation_of(const T& R, const T& S, double npop) {
4576 double f = 1.0;
4577 const double s = num_traits<T>::to_double(S), r = num_traits<T>::to_double(R);
4578 if (s > GlobalConstants::FineTol && std::isfinite(r) && r > 0.0) f = r / s;
4579 if (!std::isfinite(f) || f < 1.0) f = 1.0;
4580 if (std::isfinite(npop) && npop >= 1.0 && f > npop) f = npop;
4581 return num_traits<T>::from_double(f);
4582 }
4583
4584 /** Total closed population of a layer, i.e. how many jobs a job can queue behind. */
4585 double ph_layer_pop(const PHLayer& L, std::size_t idx) const {
4586 // Under "flat.ph" this is every caller of the single network and not only
4587 // the callers of this one station, so it is taken from the layer record.
4588 if (L.npop >= 1.0) return L.npop;
4589 double n = 0.0;
4590 for (std::size_t c : L.callers) {
4591 const double v = njobs(c, idx);
4592 if (std::isfinite(v) && v > 0.0) n += v;
4593 }
4594 return n < 1.0 ? 1.0 : n;
4595 }
4596
4597 /**
4598 * Reconstruct the LQN metrics.
4599 *
4600 * A layer of this method reports one row per caller task, not one per entry,
4601 * activity and call, so the per-element quantities the rest of SolverLN reads
4602 * -- servt, residt, callservt, callresidt, tput -- are recovered analytically
4603 * from the series-parallel weights of the entry workflows.
4604 *
4605 * The split is conservative by construction. A station reports a residence
4606 * time R per visit against a service law of mean S, so the queueing inflation
4607 * R/S is attributed to every leaf of that visit in proportion to its own
4608 * mean: the pieces sum back to R exactly.
4609 */
4610 void update_metrics_ph(int it) {
4611 const std::size_t N = lqn.nidx;
4612 servt.assign(N + 1, Tzero());
4613 residt.assign(N + 1, Tzero());
4614 callservt.assign(lqn.ncalls + 1, Tzero());
4615 callresidt.assign(lqn.ncalls + 1, Tzero());
4616
4617 std::vector<T> inflNum(N + 1, Tzero()), inflDen(N + 1, Tzero());
4618 std::vector<T> taskTput(N + 1, Tzero()), openTput(N + 1, Tzero());
4619
4620 // Host layers: the queueing inflation of the processor demand
4621 for (std::size_t hidx = 1; hidx <= lqn.nhosts; ++hidx) {
4622 if (idxhash[hidx] < 0 || !ph_has_layer[hidx]) continue;
4623 const PHLayer& L = ph_layers[hidx];
4624 const LayerResult<T>& res = results.back()[std::size_t(idxhash[hidx])];
4625 const double npop = ph_layer_pop(L, hidx);
4626 for (std::size_t c : L.callers) {
4627 const std::size_t k = L.class_of_caller[c];
4628 T X = Tzero(), Q = Tzero();
4629 for (std::size_t s : L.qstations) {
4630 X = T(X + res.TN(s - 1, k - 1));
4631 Q = T(Q + res.QN(s - 1, k - 1));
4632 }
4633 const T R = ph_residence(Q, X, res.RN(L.qstations[0] - 1, k - 1));
4634 const T f = ph_inflation_of(R, L.svcmean_by_class[k], npop);
4635 if (!std::isfinite(dbl(X)) || dbl(X) < 0.0) X = Tzero();
4636 // TOTAL over the replicas. The processor layer of a replicated
4637 // element models ONE representative replica, so X is one replica's
4638 // rate and the element's own rate is REPL times it. The matching
4639 // per-replica quantity is ph_xdemand, which the think-time closure
4640 // divides down for the same reason.
4641 taskTput[c] = T(taskTput[c] + num_traits<T>::from_double(lqn.repl[c]) * X);
4642 for (std::size_t eidx : lqn.entriesof[c]) {
4643 const T sh = dbl(ph_share[eidx]) > 0.0 ? ph_share[eidx] : Tzero();
4644 const T w = T(sh * X);
4645 inflNum[eidx] = T(inflNum[eidx] + w * f);
4646 inflDen[eidx] = T(inflDen[eidx] + w);
4647 }
4648 }
4649 for (const auto& oa : L.open_arrivals) {
4650 if (oa.second <= 0) continue; // an async call is served in the task layer
4651 const std::size_t k = oa.first;
4652 const std::size_t eidx = std::size_t(oa.second);
4653 T X = Tzero(), Q = Tzero();
4654 for (std::size_t s : L.qstations) {
4655 X = T(X + res.TN(s - 1, k - 1));
4656 Q = T(Q + res.QN(s - 1, k - 1));
4657 }
4658 if (!std::isfinite(dbl(X)) || dbl(X) <= 0.0) continue;
4659 const T f = ph_inflation_of(ph_residence(Q, X, res.RN(L.qstations[0] - 1, k - 1)),
4660 L.svcmean_by_class[k], npop);
4661 inflNum[eidx] = T(inflNum[eidx] + X * f);
4662 inflDen[eidx] = T(inflDen[eidx] + X);
4663 openTput[eidx] = T(openTput[eidx] + X);
4664 taskTput[lqn.parent[eidx]] = T(taskTput[lqn.parent[eidx]] + X);
4665 }
4666 }
4667
4668 for (std::size_t e = 1; e <= lqn.nentries; ++e) {
4669 const std::size_t eidx = lqn.eshift + e;
4670 double f = 1.0;
4671 if (dbl(inflDen[eidx]) > GlobalConstants::FineTol)
4672 f = dbl(inflNum[eidx]) / dbl(inflDen[eidx]);
4673 if (!std::isfinite(f) || f < 1.0) f = 1.0; // a residence cannot fall below its demand
4674 const T fv = num_traits<T>::from_double(f);
4675 for (std::size_t aidx : lqn.actsof[eidx])
4676 residt[aidx] = T(fv * (lqn.hostdem[aidx].disabled ? Tzero()
4677 : lqn.hostdem[aidx].mean));
4678 }
4679
4680 // Task layers: the response time of every call
4681 std::vector<T> relw(N + 1, Tzero());
4682 for (std::size_t t = 1; t <= lqn.ntasks; ++t) {
4683 const std::size_t tidx = lqn.tshift + t;
4684 if (idxhash[tidx] < 0 || !ph_has_layer[tidx]) continue;
4685 const PHLayer& L = ph_layers[tidx];
4686 const LayerResult<T>& res = results.back()[std::size_t(idxhash[tidx])];
4687 const double npop = ph_layer_pop(L, tidx);
4688 for (std::size_t c : L.callers) {
4689 const std::size_t k = L.class_of_caller[c];
4690 T X = Tzero(), Q = Tzero();
4691 for (std::size_t s : L.qstations) {
4692 X = T(X + res.TN(s - 1, k - 1));
4693 Q = T(Q + res.QN(s - 1, k - 1));
4694 }
4695 if (!std::isfinite(dbl(X)) || dbl(X) < 0.0) X = Tzero();
4696 const T g = ph_inflation_of(ph_residence(Q, X, res.RN(L.qstations[0] - 1, k - 1)),
4697 L.svcmean_by_class[k], npop);
4698 for (std::size_t cidx = 1; cidx <= lqn.ncalls; ++cidx) {
4699 if (lqn.calltype[cidx] != CallType::SYNC) continue;
4700 if (lqn.parent[lqn.callpair_src[cidx]] != c) continue;
4701 if (lqn.parent[lqn.callpair_dst[cidx]] != tidx) continue;
4702 const std::size_t eidx = lqn.callpair_dst[cidx];
4703 callservt[cidx] = T(lqn.callproc_mean[cidx] * g * ph_entrymean[eidx]);
4704 callresidt[cidx] = callservt[cidx];
4705 }
4706 for (std::size_t eidx : lqn.entriesof[tidx])
4707 relw[eidx] = T(relw[eidx] + X * ph_ncalls(c, eidx));
4708 }
4709 for (const auto& oa : L.open_arrivals) {
4710 if (oa.second >= 0) continue;
4711 const std::size_t k = oa.first;
4712 const std::size_t cidx = std::size_t(-oa.second);
4713 const std::size_t eidx = lqn.callpair_dst[cidx];
4714 T X = Tzero();
4715 for (std::size_t s : L.qstations) X = T(X + res.TN(s - 1, k - 1));
4716 const T R = res.RN(L.qstations[0] - 1, k - 1);
4717 if (std::isfinite(dbl(R)) && dbl(R) > 0.0) {
4718 callservt[cidx] = T(R * lqn.callproc_mean[cidx]);
4719 callresidt[cidx] = callservt[cidx];
4720 }
4721 if (std::isfinite(dbl(X)) && dbl(X) > 0.0) relw[eidx] = T(relw[eidx] + X);
4722 }
4723 }
4724
4725 // Entry shares and throughputs
4726 for (std::size_t t = 1; t <= lqn.ntasks; ++t) {
4727 const std::size_t tidx = lqn.tshift + t;
4728 const std::vector<std::size_t>& entries = lqn.entriesof[tidx];
4729 if (entries.empty()) continue;
4730 // How the requests SPLIT over the entries is a flow-balance question,
4731 // and is answered at the task layer: a caller class reaches that server
4732 // once per invocation of the caller, carrying its whole call burst in
4733 // its service law, so the station rate counts caller cycles and the
4734 // per-entry rate is that rate times the calls the caller makes.
4735 T tot = Tzero();
4736 for (std::size_t eidx : entries) tot = T(tot + relw[eidx] + openTput[eidx]);
4737 if (dbl(tot) > GlobalConstants::FineTol) {
4738 for (std::size_t eidx : entries)
4739 ph_share[eidx] = T((relw[eidx] + openTput[eidx]) / tot);
4740 } else {
4741 for (std::size_t eidx : entries)
4742 ph_share[eidx] = num_traits<T>::from_double(1.0 / double(entries.size()));
4743 }
4744 // HOW MANY requests the task completes is a different question, and the
4745 // flow-balance total does not answer it: that total is what the callers
4746 // DEMAND, not what the task's threads can deliver. A thread cycles
4747 // through its host demand AND then through the task think time, and only
4748 // the processor layer of the task carries both, so the rate is read there.
4749 tput[tidx] = dbl(taskTput[tidx]) > GlobalConstants::FineTol ? taskTput[tidx] : tot;
4750 for (std::size_t eidx : entries) tput[eidx] = T(tput[tidx] * ph_share[eidx]);
4751 // The DEMAND is kept apart because it, and not the rate just reported,
4752 // is what closes the surrogate delay: normalising the think time by a
4753 // rate the same think time produced makes the processor layer
4754 // self-referential. PER REPLICA, because the thread count it is paired
4755 // with there is per replica.
4756 const T nrep = num_traits<T>::from_double(std::max(1.0, lqn.repl[tidx]));
4757 ph_xdemand[tidx] = dbl(tot) > GlobalConstants::FineTol ? T(tot / nrep)
4758 : T(tput[tidx] / nrep);
4759 }
4760
4761 // Recovery, under-relaxation, and the derived per-element quantities
4762 for (std::size_t aidx = lqn.ashift + 1; aidx <= lqn.ashift + lqn.nacts; ++aidx) {
4763 T v = residt[aidx];
4764 if (!std::isfinite(dbl(v)) && it > 1 && !std::isnan(residt_prev[aidx]))
4765 v = residt_prev_v[aidx];
4766 if (relax_omega < 1.0 && it > 1 && !std::isnan(residt_prev[aidx])) {
4767 const T om = num_traits<T>::from_double(relax_omega);
4768 const T om1 = num_traits<T>::from_double(1.0 - relax_omega);
4769 v = T(om * v + om1 * residt_prev_v[aidx]);
4770 }
4771 residt[aidx] = v;
4772 residt_prev[aidx] = dbl(v);
4773 residt_prev_v[aidx] = v;
4774 }
4775 for (std::size_t cidx = 1; cidx <= lqn.ncalls; ++cidx) {
4776 T v = callservt[cidx];
4777 if (!std::isfinite(dbl(v)))
4778 v = (it > 1 && std::isfinite(callservt_prev[cidx])) ? callservt_prev_v[cidx]
4779 : Tzero();
4780 if (relax_omega < 1.0 && it > 1 && !std::isnan(callservt_prev[cidx])) {
4781 const T om = num_traits<T>::from_double(relax_omega);
4782 const T om1 = num_traits<T>::from_double(1.0 - relax_omega);
4783 v = T(om * v + om1 * callservt_prev_v[cidx]);
4784 }
4785 callservt[cidx] = v;
4786 callresidt[cidx] = v;
4787 callservt_prev[cidx] = dbl(v);
4788 callresidt_prev[cidx] = dbl(v);
4789 callservt_prev_v[cidx] = v;
4790 if (dbl(v) > 0.0) callservtproc[cidx] = Distrib<T>::exp_mean(v);
4791 }
4792
4793 // Recompose the entry laws from the iterate just computed. The entry
4794 // service time is then the mean of the COMPOSED law and not the sum of the
4795 // parts: the branches of an AND fork overlap, so an entry that forks
4796 // finishes with the last of its branches and is not charged their sum.
4797 ph_compose_entry_laws();
4798
4799 for (std::size_t e = 1; e <= lqn.nentries; ++e) {
4800 const std::size_t eidx = lqn.eshift + e;
4801 if (!ph_has_wf[eidx]) continue;
4802 const std::unordered_map<std::size_t, T>& ex = ph_execs[eidx];
4803 for (std::size_t aidx : lqn.actsof[eidx]) {
4804 T sa = T(residt[aidx] + act_think_time(aidx));
4805 for (std::size_t cidx : lqn.callsof[aidx])
4806 if (lqn.calltype[cidx] == CallType::SYNC) sa = T(sa + callservt[cidx]);
4807 servt[aidx] = sa;
4808 servt_prev[aidx] = dbl(sa);
4809 servt_prev_v[aidx] = sa;
4810 tput[aidx] = T(tput[eidx] * ex.at(aidx));
4811 tput_prev[aidx] = dbl(tput[aidx]);
4812 tput_prev_v[aidx] = tput[aidx];
4813 // exp_rate admits a null rate, but a never-called activity has no arrivals: disabled says so, as the python twin does.
4814 tputproc[aidx] = dbl(tput[aidx]) > 0.0 ? Distrib<T>::exp_rate(tput[aidx])
4815 : Distrib<T>::disabled_dist();
4816 if (dbl(sa) > 0.0) servtproc[aidx] = Distrib<T>::exp_mean(sa);
4817 }
4818 servt[eidx] = ph_entrymean[eidx];
4819 residt[eidx] = ph_entrymean[eidx];
4820 if (dbl(servt[eidx]) > 0.0) servtproc[eidx] = Distrib<T>::exp_mean(servt[eidx]);
4821 }
4822 }
4823
4824 /**
4825 * Surrogate delay of every caller.
4826 *
4827 * Same closure as update_think_times -- a thread of the task is idle for
4828 * whatever of its cycle the task's own station does not hold -- but the rate
4829 * it is normalised by is the INVOCATION rate of the task and not the
4830 * throughput of its station. Under this method a caller class reaches the
4831 * server once per invocation of the caller, carrying its whole call burst in
4832 * its service law, so the station rate counts caller cycles rather than calls
4833 * and the two differ by the mean number of calls.
4834 */
4835 void update_think_times_ph(int it) {
4836 thinktproc.assign(lqn.nidx + 1, Distrib<T>::disabled_dist());
4837 const T floorv = num_traits<T>::from_double(GlobalConstants::Zero);
4838 for (std::size_t t = 1; t <= lqn.ntasks; ++t) {
4839 const std::size_t tidx = lqn.tshift + t;
4840 if (ignore[tidx]) continue;
4841 const T ztask = ref_think_time(tidx);
4842 if (idxhash[tidx] < 0) {
4843 // A task no other task calls but whose entries carry an arrival
4844 // still has a cycle: its threads are driven by the stream.
4845 // build_layers_ph drops the open class for it precisely so this
4846 // closure can set the rate.
4847 const double arvrate = open_arrival_rate_of(tidx);
4848 if (arvrate > GlobalConstants::FineTol) {
4849 double nja = lqn.maxmult[tidx];
4850 if (!std::isfinite(nja) || nja <= 0.0) {
4851 nja = 0.0;
4852 for (std::size_t c = 1; c <= NT(); ++c) nja = std::max(nja, njobs(tidx, c));
4853 }
4854 T hres = Tzero();
4855 const std::size_t hidx = lqn.parent[tidx];
4856 if (hidx >= 1 && hidx < idxhash.size() && idxhash[hidx] >= 0 &&
4857 ph_has_layer[hidx] && ph_layers[hidx].class_of_caller[tidx] > 0) {
4858 const PHLayer& HL = ph_layers[hidx];
4859 const LayerResult<T>& hr = results.back()[std::size_t(idxhash[hidx])];
4860 const T rr = hr.RN(HL.qstations[0] - 1, HL.class_of_caller[tidx] - 1);
4861 if (!std::isnan(dbl(rr))) hres = rr;
4862 }
4863 T za = T(num_traits<T>::from_double(nja / arvrate) - hres - ztask);
4864 if (za < floorv) za = floorv;
4865 if (relax_omega < 1.0 && it > 1 && !std::isnan(thinkt_prev[tidx])) {
4866 const T om = num_traits<T>::from_double(relax_omega);
4867 const T om1 = num_traits<T>::from_double(1.0 - relax_omega);
4868 za = T(om * za + om1 * thinkt_prev_v[tidx]);
4869 }
4870 tput[tidx] = num_traits<T>::from_double(arvrate);
4871 thinkt[tidx] = za;
4872 thinkt_prev[tidx] = dbl(za);
4873 thinkt_prev_v[tidx] = za;
4874 thinktproc[tidx] = Distrib<T>::exp_mean(T(za + ztask));
4875 continue;
4876 }
4877 // a reference task, or one no other task calls: it has no station
4878 // of its own, so its only delay is the think time the user declared
4879 thinkt[tidx] = num_traits<T>::from_double(GlobalConstants::FineTol);
4880 thinktproc[tidx] = Distrib<T>::immediate();
4881 continue;
4882 }
4883 const PHLayer& L = ph_layers[tidx];
4884 const qn::Layer<T>& m = ensemble[std::size_t(idxhash[tidx])];
4885 const LayerResult<T>& r = results.back()[std::size_t(idxhash[tidx])];
4886 T U = Tzero();
4887 for (std::size_t k = 0; k < m.nclasses; ++k) {
4888 const T v = r.UN(L.qstations[0] - 1, k);
4889 if (!std::isnan(dbl(v))) U = T(U + v);
4890 }
4891 util[tidx] = U;
4892 // The closure below measures a thread's cycle in WORK: it is idle for
4893 // whatever of the cycle its station does not hold it working. A
4894 // SetupTask's station service also carries a cold start, which is time
4895 // the thread is unavailable but is not work, so it is taken back out of
4896 // U before the closure reads it. Zero for every task without a setup.
4897 if (dbl(ph_setupshare[tidx]) > 0.0)
4898 U = T(U * (Tone() - ph_setupshare[tidx]));
4899 // the rate the CALLERS ask of the task, not the rate its processor
4900 // layer reported: the latter is itself a function of this think time
4901 T X = ph_xdemand[tidx];
4902 if (!(dbl(X) > GlobalConstants::FineTol)) X = tput[tidx];
4903 // The thread pool of ONE replica, the convention ph_xdemand is kept in
4904 double nj = lqn.maxmult[tidx];
4905 if (!std::isfinite(nj) || nj <= 0.0) {
4906 nj = 0.0;
4907 for (std::size_t c = 1; c <= NT(); ++c) nj = std::max(nj, njobs(tidx, c));
4908 }
4909 T z;
4910 if (dbl(X) > GlobalConstants::FineTol) {
4911 if (lqn.sched[tidx] == SchedStrategy::INF) {
4912 // an infinite server reports a mean number of busy threads
4913 z = T((num_traits<T>::from_double(nj) - U) / X - ztask);
4914 } else {
4915 const T om = U > Tone() ? T(U - Tone()) : T(Tone() - U);
4916 z = T(num_traits<T>::from_double(nj) * om / X - ztask);
4917 }
4918 } else {
4919 z = thinkt[tidx];
4920 }
4921 if (z < floorv) z = floorv;
4922 if (it > 1 && !std::isnan(thinkt_prev[tidx]) && !std::isfinite(dbl(z)))
4923 z = thinkt_prev_v[tidx];
4924 if (relax_omega < 1.0 && it > 1 && !std::isnan(thinkt_prev[tidx])) {
4925 const T om = num_traits<T>::from_double(relax_omega);
4926 const T om1 = num_traits<T>::from_double(1.0 - relax_omega);
4927 z = T(om * z + om1 * thinkt_prev_v[tidx]);
4928 }
4929 thinkt[tidx] = z;
4930 thinkt_prev[tidx] = dbl(z);
4931 thinkt_prev_v[tidx] = z;
4932 thinktproc[tidx] = Distrib<T>::exp_mean(T(z + ztask));
4933 }
4934 }
4935
4936 /**
4937 * LQN-level results of method "srvn.ph".
4938 *
4939 * The layers report per caller task, so every entry, activity and call figure
4940 * is rebuilt from the converged fixed point rather than read off a class row,
4941 * in the layout aggregate() returns: QN carries the entry and task
4942 * utilizations, UN the processor utilizations, RN the response times and WN
4943 * the residence times.
4944 */
4945 LnSolution<T> aggregate_ph() {
4946 const std::size_t N = lqn.nidx;
4947 LnSolution<T> out;
4948 out.QN.assign(N + 1, Tzero());
4949 out.UN.assign(N + 1, Tzero());
4950 out.RN.assign(N + 1, Tzero());
4951 out.TN.assign(N + 1, Tzero());
4952 out.AN.assign(N + 1, Tzero());
4953 out.WN.assign(N + 1, Tzero());
4954 out.defined_Q.assign(N + 1, false);
4955 out.defined_U.assign(N + 1, false);
4956 out.defined_R.assign(N + 1, false);
4957 out.defined_T.assign(N + 1, false);
4958 out.defined_A.assign(N + 1, false);
4959 out.defined_W.assign(N + 1, false);
4960 out.iterations = iterations_done;
4961 out.converged = did_converge;
4962
4963 std::vector<T> PN(N + 1, Tzero()), UT(N + 1, Tzero());
4964 std::vector<bool> hasPN(N + 1, false), hasUT(N + 1, false);
4965
4966 for (std::size_t a = 1; a <= lqn.nacts; ++a) {
4967 const std::size_t aidx = lqn.ashift + a;
4968 const std::size_t tidx = lqn.parent[aidx];
4969 if (ignore[tidx]) continue;
4970 const std::size_t hidx = lqn.parent[tidx];
4971 out.TN[aidx] = tput[aidx];
4972 out.defined_T[aidx] = true;
4973 out.RN[aidx] = servt[aidx];
4974 out.defined_R[aidx] = true;
4975 UT[aidx] = T(tput[aidx] * servt[aidx]);
4976 hasUT[aidx] = true;
4977 // LINE scales the utilization of a queueing station into [0,1] whatever
4978 // its multiplicity, and reports a mean number of busy servers at an
4979 // infinite server: the processor share of an activity follows the same
4980 // convention
4981 const T hd = lqn.hostdem[aidx].disabled ? Tzero() : lqn.hostdem[aidx].mean;
4982 PN[aidx] = T(tput[aidx] * hd / num_traits<T>::from_double(ph_host_servers(hidx)));
4983 hasPN[aidx] = true;
4984 PN[hidx] = T(PN[hidx] + PN[aidx]);
4985 hasPN[hidx] = true;
4986 }
4987
4988 for (std::size_t e = 1; e <= lqn.nentries; ++e) {
4989 const std::size_t eidx = lqn.eshift + e;
4990 const std::size_t tidx = lqn.parent[eidx];
4991 if (ignore[tidx]) continue;
4992 out.TN[eidx] = tput[eidx];
4993 out.defined_T[eidx] = true;
4994 out.RN[eidx] = servt[eidx];
4995 out.defined_R[eidx] = true;
4996 UT[eidx] = T(tput[eidx] * servt[eidx]);
4997 hasUT[eidx] = true;
4998 for (std::size_t aidx : lqn.actsof[eidx]) {
4999 PN[eidx] = T(PN[eidx] + PN[aidx]);
5000 hasPN[eidx] = true;
5001 }
5002 // ResidT is reported per visit to the TASK, not per execution of the
5003 // activity: an activity of this entry runs EXECS times per invocation,
5004 // and the entry takes SHARE of the task's invocations. RespT stays per
5005 // execution.
5006 if (ph_has_wf[eidx]) {
5007 const std::unordered_map<std::size_t, T>& ex = ph_execs[eidx];
5008 for (std::size_t aidx : lqn.actsof[eidx]) {
5009 out.WN[aidx] = T(ph_share[eidx] * ex.at(aidx) * residt[aidx]);
5010 out.defined_W[aidx] = true;
5011 }
5012 }
5013 UT[tidx] = T(UT[tidx] + UT[eidx]);
5014 hasUT[tidx] = true;
5015 }
5016
5017 for (std::size_t t = 1; t <= lqn.ntasks; ++t) {
5018 const std::size_t tidx = lqn.tshift + t;
5019 if (ignore[tidx]) continue;
5020 out.TN[tidx] = tput[tidx];
5021 out.defined_T[tidx] = true;
5022 T w = Tzero();
5023 bool anyw = false;
5024 for (std::size_t aidx : lqn.actsof[tidx]) {
5025 PN[tidx] = T(PN[tidx] + PN[aidx]);
5026 hasPN[tidx] = true;
5027 if (out.defined_W[aidx]) { w = T(w + out.WN[aidx]); anyw = true; }
5028 }
5029 if (anyw) { out.WN[tidx] = w; out.defined_W[tidx] = true; }
5030 }
5031
5032 for (std::size_t hidx = 1; hidx <= lqn.nhosts; ++hidx)
5033 out.defined_T[hidx] = false; // kept undefined for consistency with LQNS
5034
5035 for (std::size_t idx = 1; idx <= N; ++idx) {
5036 out.QN[idx] = UT[idx];
5037 out.defined_Q[idx] = hasUT[idx];
5038 out.UN[idx] = PN[idx];
5039 out.defined_U[idx] = hasPN[idx];
5040 // Idle, not undefined -- the same rule aggregate() applies, and for the
5041 // same reason: an unreachable element reports zero for the measures its
5042 // kind HAS and leaves the ones it never has undefined, so that the
5043 // table's NaN mask survives a disconnected component.
5044 if (ignore[idx]) {
5045 out.UN[idx] = Tzero(); // every kind reports a utilization
5046 out.defined_U[idx] = true;
5047 out.defined_A[idx] = false; // nothing reports an arrival rate on an LQN
5048 const bool host = lqn.type[idx] == LqnElement::HOST;
5049 const bool task = lqn.type[idx] == LqnElement::TASK;
5050 const bool entry = lqn.type[idx] == LqnElement::ENTRY;
5051 out.QN[idx] = Tzero();
5052 out.defined_Q[idx] = !host;
5053 out.RN[idx] = Tzero();
5054 out.defined_R[idx] = !host && !task;
5055 out.WN[idx] = Tzero();
5056 out.defined_W[idx] = !host && !entry;
5057 out.TN[idx] = Tzero();
5058 out.defined_T[idx] = !host;
5059 }
5060 }
5061 return out;
5062 }
5063
5064 void update_metrics(int it) {
5065 if (is_ph_encoding()) {
5066 update_metrics_ph(it);
5067 return;
5068 }
5069 if (lnmethod == "moment3") {
5070 update_metrics_moment_based(it);
5071 return;
5072 }
5073 update_metrics_default(it);
5074 }
5075
5076 void update_metrics_default(int it) {
5077 const std::size_t N = lqn.nidx;
5078 servt.assign(N + 1, Tzero());
5079 residt.assign(N + 1, Tzero());
5080 const int iter_min = std::min(30, int(std::ceil(opt.iter_max / 4.0)));
5081 const bool averaging = averagingstart >= 0 && it >= iter_min;
5082 const int wnd = averaging ? int(it - averagingstart + 1) : 1;
5083
5084 for (const UpdRow& row : servt_map) {
5085 const std::size_t e = std::size_t(idxhash[row.idx]);
5086 const qn::Layer<T>& L = ensemble[e];
5087 const std::size_t k = row.cls - 1;
5088 std::size_t c = 0;
5089 for (std::size_t cc = 0; cc < L.nchains; ++cc)
5090 if (L.chains[cc][k]) c = cc;
5091 const std::size_t refclass_c = L.refclass[c];
5092 const std::size_t refstat_k = L.classes[k].refstat;
5093
5094 T sv = Tzero(), rs = Tzero(), tp = Tzero();
5095 const T wT = T(Tone() / num_traits<T>::from_int(wnd));
5096 for (int w = 0; w < wnd; ++w) {
5097 const LayerResult<T>& r = results[results.size() - 1 - w][e];
5098 sv += r.RN(row.node - 1, k) * wT;
5099 const T TN_ref = (refclass_c > 0 && refstat_k > 0) ? r.TN(refstat_k - 1, refclass_c - 1) : Tzero();
5100 if (dbl(TN_ref) > GlobalConstants::FineTol)
5101 rs += r.QN(row.node - 1, k) / TN_ref * wT;
5102 else
5103 rs += r.WN(row.node - 1, k) * wT;
5104 tp += r.TN(row.node - 1, k) * wT;
5105 }
5106 servt[row.aidx] = sv;
5107 residt[row.aidx] = rs;
5108 tput[row.aidx] = tp;
5109
5110 // an activity think time is in series with the host demand
5111 const Distrib<T>& at = lqn.actthink[row.aidx];
5112 if (!at.disabled && dbl(at.mean) > GlobalConstants::FineTol) {
5113 servt[row.aidx] = T(servt[row.aidx] + at.mean);
5114 residt[row.aidx] = T(residt[row.aidx] + at.mean);
5115 }
5116
5117 // An activity of an async-only entry takes RN, the response per
5118 // visit, and not the visit-weighted residence: entry selection
5119 // routes the task to each of its entries with a share, and there is
5120 // no caller-side visit ratio here to divide that share back out
5121 // (the sync branch below does exactly that). updateMetricsDefault.m:63-77.
5122 if (async_only_activity(row.aidx)) residt[row.aidx] = servt[row.aidx];
5123
5124 if (relax_omega < 1.0 && it > 1) {
5125 const T om = num_traits<T>::from_double(relax_omega);
5126 const T om1 = num_traits<T>::from_double(1.0 - relax_omega);
5127 if (!std::isnan(servt_prev[row.aidx]))
5128 servt[row.aidx] = T(om * servt[row.aidx] + om1 * servt_prev_v[row.aidx]);
5129 if (!std::isnan(residt_prev[row.aidx]))
5130 residt[row.aidx] = T(om * residt[row.aidx] + om1 * residt_prev_v[row.aidx]);
5131 if (!std::isnan(tput_prev[row.aidx]))
5132 tput[row.aidx] = T(om * tput[row.aidx] + om1 * tput_prev_v[row.aidx]);
5133 }
5134 servt_prev[row.aidx] = dbl(servt[row.aidx]);
5135 residt_prev[row.aidx] = dbl(residt[row.aidx]);
5136 tput_prev[row.aidx] = dbl(tput[row.aidx]);
5137 servt_prev_v[row.aidx] = servt[row.aidx];
5138 residt_prev_v[row.aidx] = residt[row.aidx];
5139 tput_prev_v[row.aidx] = tput[row.aidx];
5140
5141 if (servt[row.aidx] > Tzero() && dbl(servt[row.aidx]) <= 1e10)
5142 servtproc[row.aidx] = Distrib<T>::exp_mean(servt[row.aidx]);
5143 tputproc[row.aidx] = Distrib<T>::exp_rate(tput[row.aidx]);
5144 }
5145
5146 // The phase split of servt, updateMetricsDefault.m:120-151. It is
5147 // recomputed from scratch each iteration because servt is; the
5148 // overtaking probability it feeds is computed later, once the entry
5149 // throughputs exist.
5150 if (has_phase2) {
5151 servt_ph1.assign(N + 1, Tzero());
5152 servt_ph2.assign(N + 1, Tzero());
5153 for (std::size_t a = 1; a <= lqn.nacts; ++a) {
5154 const std::size_t aidx = lqn.ashift + a;
5155 if (lqn.actphase[a] == 1)
5156 servt_ph1[aidx] = servt[aidx];
5157 else
5158 servt_ph2[aidx] = servt[aidx];
5159 }
5160 for (std::size_t e = 1; e <= lqn.nentries; ++e) {
5161 const std::size_t eidx = lqn.eshift + e;
5162 for (std::size_t aidx : lqn.actsof[eidx]) {
5163 if (aidx <= lqn.ashift) continue;
5164 const std::size_t a = aidx - lqn.ashift;
5165 if (a > lqn.nacts) continue;
5166 if (lqn.actphase[a] == 1)
5167 servt_ph1[eidx] = T(servt_ph1[eidx] + servt_ph1[aidx]);
5168 else
5169 servt_ph2[eidx] = T(servt_ph2[eidx] + servt_ph2[aidx]);
5170 }
5171 }
5172 }
5173
5174 // throughput of the activities that appear only as client-side classes
5175 for (const UpdRow& row : thinkt_map) {
5176 if (!tputproc[row.aidx].disabled) continue;
5177 const std::size_t e = std::size_t(idxhash[row.idx]);
5178 T tp = Tzero();
5179 const T wT = T(Tone() / num_traits<T>::from_int(wnd));
5180 for (int w = 0; w < wnd; ++w)
5181 tp += results[results.size() - 1 - w][e].TN(row.node - 1, row.cls - 1) * wT;
5182 tput[row.aidx] = tp;
5183 tputproc[row.aidx] = Distrib<T>::exp_rate(tp);
5184 }
5185
5186 // call service and residence times
5187 callservt.assign(lqn.ncalls + 1, Tzero());
5188 callresidt.assign(lqn.ncalls + 1, Tzero());
5189 for (const UpdRow& row : call_map) {
5190 if (row.node <= 1) continue; // a client-side call class contributes none
5191 const std::size_t e = std::size_t(idxhash[row.idx]);
5192 const LayerResult<T>& r = results.back()[e];
5193 callservt[row.aidx] =
5194 T(r.RN(row.node - 1, row.cls - 1) * lqn.callproc_mean[row.aidx]);
5195 // Normalise per chain-reference visit, as residt does. WN divides by the
5196 // class's own reference rate when the layer is open (an INF client task),
5197 // which is per-ENTRY visit, and the entry rescaling in
5198 // resolve_entry_service would then charge the call once per entry.
5199 {
5200 const qn::Layer<T>& L = ensemble[e];
5201 const std::size_t k = row.cls - 1;
5202 std::size_t c = 0;
5203 for (std::size_t cc = 0; cc < L.nchains; ++cc)
5204 if (L.chains[cc][k]) c = cc;
5205 const std::size_t refclass_c = L.refclass[c];
5206 const std::size_t refstat_k = L.classes[k].refstat;
5207 const T TN_ref = (refclass_c > 0 && refstat_k > 0) ? r.TN(refstat_k - 1, refclass_c - 1) : Tzero();
5208 callresidt[row.aidx] = dbl(TN_ref) > GlobalConstants::FineTol
5209 ? T(r.QN(row.node - 1, k) / TN_ref)
5210 : r.WN(row.node - 1, k);
5211 }
5212 const T rw = region_wait(e, row.cls);
5213 if (rw > Tzero()) {
5214 callservt[row.aidx] = T(callservt[row.aidx] + rw);
5215 callresidt[row.aidx] = T(callresidt[row.aidx] + rw);
5216 }
5217 if (relax_omega < 1.0 && it > 1 && !std::isnan(callservt_prev[row.aidx])) {
5218 const T om = num_traits<T>::from_double(relax_omega);
5219 const T om1 = num_traits<T>::from_double(1.0 - relax_omega);
5220 callservt[row.aidx] =
5221 T(om * callservt[row.aidx] + om1 * callservt_prev_v[row.aidx]);
5222 }
5223 callservt_prev[row.aidx] = dbl(callservt[row.aidx]);
5224 callservt_prev_v[row.aidx] = callservt[row.aidx];
5225 callresidt_prev[row.aidx] = dbl(callresidt[row.aidx]);
5226 }
5227
5228 resolve_entry_service();
5229
5230 // The overtaking correction, updateMetricsDefault.m:313-351. It runs
5231 // HERE and not with the split above because it needs the entry
5232 // throughput resolve_entry_service has just produced.
5233 //
5234 // servt keeps both phases -- the server IS busy through phase 2, so the
5235 // utilization is unchanged -- while residt becomes the CALLER's view:
5236 // phase 1 in full, phase 2 only when the caller is actually overtaken.
5237 if (has_phase2) {
5238 for (std::size_t e = 1; e <= lqn.nentries; ++e) {
5239 const std::size_t eidx = lqn.eshift + e;
5240 const std::size_t tidx = lqn.parent[eidx];
5241 if (!(dbl(servt_ph2[eidx]) > GlobalConstants::FineTol)) continue;
5242 if (lqn.isref[tidx] || !lqn.issynccaller.any_col(eidx)) {
5243 residt[eidx] = servt[eidx];
5244 continue;
5245 }
5246 T entry_tput = Tzero();
5247 if (dbl(tput[eidx]) > GlobalConstants::FineTol)
5248 entry_tput = tput[eidx];
5249 else if (dbl(tput[tidx]) > GlobalConstants::FineTol)
5250 entry_tput = tput[tidx];
5251 prOvertake[e] =
5252 dbl(entry_tput) > GlobalConstants::FineTol
5253 ? lqn_overtake_prob_markov(lqn, servt, callresidt, tput, eidx,
5254 servt_ph2[eidx])
5255 : Tzero();
5256 residt[eidx] = T(servt_ph1[eidx] + prOvertake[e] * servt_ph2[eidx]);
5257 }
5258 }
5259
5260 for (const UpdRow& row : call_map) {
5261 if (row.node <= 1) continue;
5262 const std::size_t eidx = lqn.callpair_dst[row.aidx];
5263 if (servt[eidx] > Tzero()) servtproc[eidx] = Distrib<T>::exp_mean(servt[eidx]);
5264 }
5265 for (const UpdRow& row : call_map) {
5266 if (row.node <= 1) continue;
5267 const std::size_t eidx = lqn.callpair_dst[row.aidx];
5268 if (it == 1) {
5269 callservt[row.aidx] = servt[eidx];
5270 callservtproc[row.aidx] = servtproc[eidx];
5271 } else if (callservt[row.aidx] > Tzero()) {
5272 callservtproc[row.aidx] = Distrib<T>::exp_mean(callservt[row.aidx]);
5273 }
5274 }
5275
5276 // What a synchronous CALLER waits for at a phase-2 target is residt,
5277 // not servt (updateMetricsDefault.m:384-400): the loop just above set it
5278 // from the target's full service, which would charge the caller for
5279 // phase 2 it never waits through.
5280 if (has_phase2) {
5281 for (std::size_t cidx = 1; cidx <= lqn.ncalls; ++cidx) {
5282 if (lqn.calltype[cidx] != CallType::SYNC) continue;
5283 const std::size_t target = lqn.callpair_dst[cidx];
5284 if (target <= lqn.eshift || target > lqn.eshift + lqn.nentries) continue;
5285 if (!(dbl(servt_ph2[target]) > GlobalConstants::FineTol)) continue;
5286 const T eff = residt[target]; // servt_ph1 + prOvertake * servt_ph2
5287 if (!(eff > Tzero())) continue;
5288 const T w = T(eff * lqn.callproc_mean[cidx]);
5289 callservt[cidx] = w;
5290 callresidt[cidx] = w;
5291 callservtproc[cidx] = Distrib<T>::exp_mean(w);
5292 }
5293 }
5294 }
5295
5296 // -----------------------------------------------------------------------
5297 // updateMetricsMomentBased, the `moment3` method
5298 // -----------------------------------------------------------------------
5299
5300 /**
5301 * The moments of an empirical CDF, MATLAB's `EmpiricalCDF.getMoments`.
5302 *
5303 * READ EXACTLY AS THE REFERENCE READS IT, and the reading is not the
5304 * obvious one: the weight of a bin is the CDF INCREMENT and its abscissa is
5305 * the MIDPOINT of the two t values, so this is a midpoint quadrature of
5306 * integral x^k dF and not a sum over grid points. Reading the grid as a pmf
5307 * at the right endpoint instead inflates every moment; that exact mistake
5308 * cost a wrong entry mean in the Python port (see _kb/06-solver-catalog.md).
5309 */
5310 static void cdf_moments(const fluid::FluidPassage& c, double& m1, double& m2, double& m3) {
5311 m1 = m2 = m3 = 0.0;
5312 for (std::size_t i = 0; i + 1 < c.t.size(); ++i) {
5313 const double x = 0.5 * (c.t[i + 1] + c.t[i]);
5314 const double w = c.cdf[i + 1] - c.cdf[i];
5315 m1 += x * w;
5316 m2 += x * x * w;
5317 m3 += x * x * x * w;
5318 }
5319 }
5320
5321 /** The per-layer response-time CDFs, computed once and cached. */
5322 const std::vector<std::vector<fluid::FluidPassage>>& layer_cdf(std::size_t e) {
5323 if (cdf_repo[e].empty()) {
5324 // THE LAYER HAS NO ROUTING MATRIX UNTIL IT IS ASKED FOR ONE, and the
5325 // fluid drift is built from `sn.rt` alone: without this the ODE has
5326 // no transitions, the state decays to zero, every passage reports the
5327 // degenerate curve at the origin and every entry ends up with an
5328 // empty convolution and a service time of zero. Same reason as in
5329 // solve_layer_fluid, and the failure is silent in both.
5330 ensemble[e].refresh_rt();
5331 try {
5332 cdf_repo[e] = detail::ln_fluid_cdf_respt(ensemble[e], opt.layer_fluid);
5333 } catch (const std::exception&) {
5334 // The reference falls back to the LAYER's own solver when the
5335 // fluid passage fails, and for the MVA-family layers that is
5336 // the base-class exponential law with the layer's mean -- so
5337 // the fallback here is that law over the last solved averages,
5338 // rather than an empty repo that silently collapses every
5339 // entry law to its bare host demand.
5340 cdf_repo[e] = layer_cdf_exp_fallback(e);
5341 }
5342 }
5343 return cdf_repo[e];
5344 }
5345
5346 /** The base-class exponential CDF over layer e's last solved mean response
5347 * times, the reference's fallback route when the fluid passage fails. */
5348 std::vector<std::vector<fluid::FluidPassage>> layer_cdf_exp_fallback(std::size_t e) {
5349 const qn::NetworkStruct<T>& L = ensemble[e];
5350 std::vector<std::vector<fluid::FluidPassage>> out(
5351 L.nstations, std::vector<fluid::FluidPassage>(L.nclasses));
5352 if (results.empty() || e >= results.back().size()) return out;
5353 const Matrix<T>& RN = results.back()[e].RN;
5354 const std::size_t npts = 100;
5355 for (std::size_t i = 0; i < L.nstations; ++i) {
5356 if (L.stations[i].nodetype == qn::NodeType::Source) continue;
5357 for (std::size_t r = 0; r < L.nclasses; ++r) {
5358 if (L.disabled[i][r]) continue;
5359 const double rn = (i < static_cast<std::size_t>(RN.rows()) &&
5360 r < static_cast<std::size_t>(RN.cols()))
5361 ? dbl(RN(i, r))
5362 : 0.0;
5363 if (!(std::isfinite(rn) && rn > 0.0)) continue;
5364 fluid::FluidPassage& cell = out[i][r];
5365 cell.t.reserve(npts);
5366 cell.cdf.reserve(npts);
5367 for (std::size_t j = 0; j < npts; ++j) {
5368 const double q =
5369 0.001 + (0.999 - 0.001) * static_cast<double>(j) / (npts - 1);
5370 cell.cdf.push_back(q);
5371 cell.t.push_back(-std::log(1.0 - q) * rn);
5372 }
5373 }
5374 }
5375 return out;
5376 }
5377
5378 /**
5379 * task_tput / entry_tput at the host layer of `eidx`.
5380 *
5381 * This is what renormalises a residence time from "one visit to the TASK",
5382 * which is how the layer reports it, to "one visit to the ENTRY", which is
5383 * what an entry metric means. It exists as a helper because applying it
5384 * TWICE is a real and silent failure mode: `moment3` once summed per-visit
5385 * response times and then applied this ratio on top, inflating every entry
5386 * service time by the entries-per-task ratio.
5387 *
5388 * `state` is 0 when the entry's layers are ignored (nothing is assigned at
5389 * all), 1 when it has no synchronous caller (no ratio exists) and 2 when
5390 * `ratio` is set.
5391 */
5392 int entry_visit_ratio(std::size_t eidx, T& ratio) const {
5393 const std::size_t tidx = lqn.parent[eidx];
5394 const std::size_t hidx = lqn.parent[tidx];
5395 if (ignore[tidx] || ignore[hidx] || idxhash[hidx] < 0) return 0;
5396 if (!lqn.issynccaller.any_col(eidx)) return 1;
5397 const std::size_t hl = std::size_t(idxhash[hidx]);
5398 const qn::Layer<T>& L = ensemble[hl];
5399 const LayerResult<T>& r = results.back()[hl];
5400 T task_tput = Tzero(), entry_tput = Tzero();
5401 for (const auto& kv : L.attr_tasks)
5402 if (kv.second == tidx) task_tput += r.TN(L.clientIdx - 1, kv.first - 1);
5403 for (const auto& kv : L.attr_entries)
5404 if (kv.second == eidx) entry_tput += r.TN(L.clientIdx - 1, kv.first - 1);
5405 const T floor = num_traits<T>::from_double(GlobalConstants::Zero);
5406 ratio = T(task_tput / (entry_tput > floor ? entry_tput : floor));
5407 return 2;
5408 }
5409
5410 /** (I - servtmatrix)^-1, the reference's `inv(eye - servtmatrix)`. */
5411 Matrix<T> entry_service_resolvent() const {
5412 const std::size_t dim = lqn.nidx + lqn.ncalls;
5413 Matrix<T> A(dim + 1, dim + 1, Tzero());
5414 for (std::size_t i = 0; i <= dim; ++i) {
5415 A(i, i) = Tone();
5416 for (std::size_t j = 0; j <= dim; ++j) A(i, j) = T(A(i, j) - servtmatrix(i, j));
5417 }
5418 return ::line::inverse(A);
5419 }
5420
5421 /**
5422 * Port of @@SolverLN/updateMetricsMomentBased.m, the `moment3` method.
5423 *
5424 * WHAT IT DOES DIFFERENTLY from the default update. The default feeds MEANS
5425 * between the layers. This fits an APH to the response-time CDF of every
5426 * activity and every call a layer reports, convolves those fits along the
5427 * entry's activity sequence, and reads the entry's law off the convolution:
5428 * a mean AND a distribution, which is what `get_cdf_respt` returns.
5429 *
5430 * TWO PASSES, not one. While the ensemble is still moving (`!hasconverged`)
5431 * the update is mean-based -- forming a CDF per layer per iteration would
5432 * cost a fluid integration per layer per iteration and would be fitting
5433 * noise anyway -- and the distribution is formed ONCE, on the converged
5434 * ensemble. The reference splits it exactly here.
5435 *
5436 * THE NORMALISATION TRAP. Both passes build the entry service from
5437 * RESIDENCE times, which are normalised to one visit to the TASK, and then
5438 * apply the task/entry throughput ratio, which converts that to one visit
5439 * to the ENTRY. Summing per-visit RESPONSE times and applying the ratio as
5440 * well applies the normalisation twice and inflates every multi-entry task
5441 * by its entries-per-task ratio. That was a live defect in all three
5442 * reference codebases until 2026-07-31.
5443 */
5444 void update_metrics_moment_based(int it) {
5445 const std::size_t N = lqn.nidx;
5446 servt.assign(N + 1, Tzero());
5447 residt.assign(N + 1, Tzero());
5448 callservt.assign(lqn.ncalls + 1, Tzero());
5449 callresidt.assign(lqn.ncalls + 1, Tzero());
5450
5451 // ---- what every activity's layer reports, common to both passes ----
5452 for (const UpdRow& row : servt_map) {
5453 const std::size_t e = std::size_t(idxhash[row.idx]);
5454 const qn::Layer<T>& L = ensemble[e];
5455 const LayerResult<T>& r = results.back()[e];
5456 const std::size_t k = row.cls - 1;
5457 std::size_t c = 0;
5458 for (std::size_t cc = 0; cc < L.nchains; ++cc)
5459 if (L.chains[cc][k]) c = cc;
5460 const std::size_t refclass_c = L.refclass[c];
5461 const std::size_t refstat_k = L.classes[k].refstat;
5462 const T TN_ref = (refclass_c > 0 && refstat_k > 0) ? r.TN(refstat_k - 1, refclass_c - 1) : Tzero();
5463
5464 tput[row.aidx] = r.TN(row.node - 1, k);
5465 residt[row.aidx] = dbl(TN_ref) > GlobalConstants::FineTol
5466 ? T(r.QN(row.node - 1, k) / TN_ref)
5467 : r.WN(row.node - 1, k);
5468 if (!hasconverged) {
5469 servt[row.aidx] = r.RN(row.node - 1, k);
5470 servtproc[row.aidx] = Distrib<T>::exp_mean(servt[row.aidx]);
5471 const Distrib<T>& at = lqn.actthink[row.aidx];
5472 if (!at.disabled && dbl(at.mean) > GlobalConstants::FineTol) {
5473 servt[row.aidx] = T(servt[row.aidx] + at.mean);
5474 residt[row.aidx] = T(residt[row.aidx] + at.mean);
5475 servtproc[row.aidx] = Distrib<T>::exp_mean(servt[row.aidx]);
5476 }
5477 // An activity of an async-only entry takes the per-visit
5478 // response, for the same reason as in the default update.
5479 if (async_only_activity(row.aidx)) residt[row.aidx] = servt[row.aidx];
5480 } else {
5481 servtcdf[row.aidx] = layer_cdf(e)[row.node - 1][k];
5482 }
5483 }
5484
5485 for (const UpdRow& row : call_map) {
5486 if (row.node <= 1) continue; // a client-side call class contributes none
5487 const std::size_t e = std::size_t(idxhash[row.idx]);
5488 const LayerResult<T>& r = results.back()[e];
5489 callresidt[row.aidx] = r.WN(row.node - 1, row.cls - 1);
5490 if (!hasconverged)
5491 callservt[row.aidx] =
5492 T(r.RN(row.node - 1, row.cls - 1) * lqn.callproc_mean[row.aidx]);
5493 else
5494 callservtcdf[row.aidx] = layer_cdf(e)[row.node - 1][row.cls - 1];
5495 }
5496
5497 if (!hasconverged)
5498 moment3_means_pass(it);
5499 else
5500 moment3_distribution_pass(it);
5501 }
5502
5503 /** The mean-based pass of moment3, run while the ensemble is still moving. */
5504 void moment3_means_pass(int it) {
5505 const std::size_t dim = lqn.nidx + lqn.ncalls;
5506 std::vector<T> x(dim + 1, Tzero());
5507 for (std::size_t i = 1; i <= lqn.nidx; ++i) x[i] = residt[i];
5508 for (std::size_t c = 1; c <= lqn.ncalls; ++c) x[lqn.nidx + c] = callresidt[c];
5509
5510 // entry_servt = (I - servtmatrix) \ [residt; callresidt]
5511 const Matrix<T> Rinv = entry_service_resolvent();
5512 std::vector<T> entry_servt(dim + 1, Tzero());
5513 for (std::size_t i = 1; i <= dim; ++i) {
5514 T s = Tzero();
5515 for (std::size_t j = 1; j <= dim; ++j)
5516 if (Rinv(i, j) != Tzero()) s += Rinv(i, j) * x[j];
5517 entry_servt[i] = s;
5518 }
5519 for (std::size_t i = 1; i <= lqn.eshift; ++i) entry_servt[i] = Tzero();
5520
5521 // NO forwarding propagation here. `lqn_fwd_rendezvous` has already
5522 // reconnected every forwarding chain reachable from a synchronous call to
5523 // the client that issued the rendezvous (Franks 1999, Sec. 3.3.1), so the
5524 // forwarded service is in the caller's chain before this runs; charging it
5525 // again inflated the caller by exactly the forwarded entry's mean. An
5526 // asynchronous call into a chain is left untouched there by design -- a
5527 // send-no-reply does not block -- so it must not accumulate the forwarded
5528 // service either. See BUGS.md BUG-91.
5529
5530 for (std::size_t e = 1; e <= lqn.nentries; ++e)
5531 servt[lqn.eshift + e] = entry_servt[lqn.eshift + e];
5532
5533 // entry_residt = servtmatrix * [residt; callresidt]
5534 std::vector<T> entry_residt(dim + 1, Tzero());
5535 for (std::size_t i = lqn.eshift + 1; i <= lqn.eshift + lqn.nentries; ++i) {
5536 T s = Tzero();
5537 for (std::size_t j = 1; j <= dim; ++j)
5538 if (servtmatrix(i, j) != Tzero()) s += servtmatrix(i, j) * x[j];
5539 entry_residt[i] = s;
5540 }
5541
5542 for (std::size_t e = 1; e <= lqn.nentries; ++e) {
5543 const std::size_t eidx = lqn.eshift + e;
5544 T ratio = Tone();
5545 const int state = entry_visit_ratio(eidx, ratio);
5546 if (state == 0) continue;
5547 if (state == 1) {
5548 residt[eidx] = entry_residt[eidx];
5549 continue;
5550 }
5551 servt[eidx] = T(entry_servt[eidx] * ratio);
5552 residt[eidx] = T(entry_residt[eidx] * ratio);
5553 }
5554
5555 for (const UpdRow& row : call_map) {
5556 if (row.node <= 1) continue;
5557 const std::size_t eidx = lqn.callpair_dst[row.aidx];
5558 if (servt[eidx] > Tzero()) servtproc[eidx] = Distrib<T>::exp_mean(servt[eidx]);
5559 }
5560 for (const UpdRow& row : call_map) {
5561 if (row.node <= 1) continue;
5562 const std::size_t eidx = lqn.callpair_dst[row.aidx];
5563 if (it == 1) {
5564 // A response time is per visit, so the number of calls is 1 here.
5565 callservt[row.aidx] = servt[eidx];
5566 callservtproc[row.aidx] = servtproc[eidx];
5567 } else if (callservt[row.aidx] > Tzero()) {
5568 callservtproc[row.aidx] = Distrib<T>::exp_mean(callservt[row.aidx]);
5569 }
5570 }
5571 }
5572
5573 /**
5574 * The distribution pass of moment3, run once on the converged ensemble.
5575 *
5576 * Every term reachable from an entry is fitted to an APH from the first
5577 * three moments of its response-time CDF, repeated as many times as the
5578 * entry-service matrix says it is visited, and the whole sequence is
5579 * convolved. A FRACTIONAL repetition count -- a call made 1.5 times on
5580 * average -- is realised as a branch between the fitted law and a point
5581 * mass at zero, which is the reference's `aph_simplify(..., pattern 3)`.
5582 */
5583 void moment3_distribution_pass(int it) {
5584 if constexpr (!num_traits<T>::has_transcendental) {
5585 throw UnsupportedError(
5586 "SolverLN: the 'moment3' method fits an APH to a response-time CDF, which needs "
5587 "square roots and a matrix exponential; rerun with --arith double or real");
5588 } else {
5589 const std::size_t dim = lqn.nidx + lqn.ncalls;
5590 const Matrix<T> Rinv = entry_service_resolvent();
5591
5592 // The point mass at zero the fractional branch mixes against.
5593 mam::AphPair<T> zero_law;
5594 zero_law.alpha.push_back(Tone());
5595 zero_law.S = Matrix<T>(1, 1, num_traits<T>::from_double(-GlobalConstants::Immediate));
5596
5597 for (std::size_t en = 1; en <= lqn.nentries; ++en) {
5598 const std::size_t eidx = lqn.eshift + en;
5599 std::vector<mam::AphPair<T>> seq;
5600 for (std::size_t fitidx = 1; fitidx <= dim; ++fitidx) {
5601 if (!(Rinv(eidx, fitidx) > Tzero())) continue;
5602 // Entries themselves are dropped: the entry's own service is
5603 // what is being assembled, and a host or task index carries
5604 // no response-time law of its own.
5605 if (fitidx <= lqn.eshift + lqn.nentries) continue;
5606 const bool is_call = fitidx > lqn.nidx;
5607 const fluid::FluidPassage& curve =
5608 is_call ? callservtcdf[fitidx - lqn.nidx] : servtcdf[fitidx];
5609 double m1 = 0.0, m2 = 0.0, m3 = 0.0;
5610 cdf_moments(curve, m1, m2, m3);
5611
5612 // An activity think time is in series with the host demand,
5613 // so its raw moments convolve with the measured ones.
5614 if (!is_call && !lqn.actthink[fitidx].disabled &&
5615 dbl(lqn.actthink[fitidx].mean) > GlobalConstants::FineTol) {
5616 const Distrib<T>& zt = lqn.actthink[fitidx];
5617 const double t1 = dbl(lang::dist_moment(zt, 1));
5618 const double t2 = dbl(lang::dist_moment(zt, 2));
5619 const double t3 = dbl(lang::dist_moment(zt, 3));
5620 m3 = m3 + 3.0 * m2 * t1 + 3.0 * m1 * t2 + t3;
5621 m2 = m2 + 2.0 * m1 * t1 + t2;
5622 m1 = m1 + t1;
5623 }
5624
5625 // CoarseTol and not FineTol: an Immediate activity has a
5626 // near-zero mean whose APH fit has rates of order 1e8, and
5627 // the matrix exponential of the convolution then does not
5628 // terminate. The reference skips those terms outright.
5629 if (!(m1 > GlobalConstants::CoarseTol)) continue;
5630
5631 const mam::AphFitResult<T> fit = mam::aph_fit(
5632 num_traits<T>::from_double(m1), num_traits<T>::from_double(m2),
5633 num_traits<T>::from_double(m3), 10u,
5634 num_traits<T>::from_double(GlobalConstants::FineTol));
5635 const mam::AphPair<T> law = aph_pair_of_map(fit.aph);
5636
5637 double reps = dbl(Rinv(eidx, fitidx));
5638 // servtmatrix carries 1.0 for a call; the mean NUMBER of
5639 // calls is what says how many times its law is convolved.
5640 if (is_call) reps *= dbl(lqn.callproc_mean[fitidx - lqn.nidx]);
5641 const long whole = static_cast<long>(std::floor(reps));
5642 const double frac = reps - static_cast<double>(whole);
5643 for (long q = 0; q < whole; ++q) seq.push_back(law);
5644 if (frac > 0.0)
5645 seq.push_back(mam::aph_simplify(law, zero_law,
5646 num_traits<T>::from_double(frac),
5647 num_traits<T>::from_double(1.0 - frac),
5649
5650 if (is_call) {
5651 const std::size_t cidx = fitidx - lqn.nidx;
5652 callservt[cidx] = num_traits<T>::from_double(m1);
5653 callservtproc[cidx] = Distrib<T>::exp_mean(callservt[cidx]);
5654 } else {
5655 servt[fitidx] = num_traits<T>::from_double(m1);
5656 servtproc[fitidx] = Distrib<T>::exp_mean(servt[fitidx]);
5657 }
5658 }
5659
5660 if (seq.empty()) {
5661 servt[eidx] = Tzero();
5662 continue;
5663 }
5664 const mam::AphPair<T> entry_law = mam::aph_convseq(seq);
5665 entryproc[en] = entry_law;
5666 servt[eidx] = Distrib<T>::ph_moment(entry_law.alpha, entry_law.S, 1);
5667 servtproc[eidx] = Distrib<T>::exp_mean(servt[eidx]);
5668 entrycdfrespt[en] = aph_eval_cdf(entry_law);
5669 }
5670
5671 // NO forwarding propagation here, for the reason given at the
5672 // entry_servt assembly above. See BUGS.md BUG-91.
5673
5674 // entry_residt = servtmatrix * [residt; callresidt], then the same
5675 // task/entry renormalisation the mean pass applies.
5676 std::vector<T> x(dim + 1, Tzero());
5677 for (std::size_t i = 1; i <= lqn.nidx; ++i) x[i] = residt[i];
5678 for (std::size_t c = 1; c <= lqn.ncalls; ++c) x[lqn.nidx + c] = callresidt[c];
5679 for (std::size_t en = 1; en <= lqn.nentries; ++en) {
5680 const std::size_t eidx = lqn.eshift + en;
5681 T s = Tzero();
5682 for (std::size_t j = 1; j <= dim; ++j)
5683 if (servtmatrix(eidx, j) != Tzero()) s += servtmatrix(eidx, j) * x[j];
5684 T ratio = Tone();
5685 const int state = entry_visit_ratio(eidx, ratio);
5686 if (state == 0) continue;
5687 residt[eidx] = state == 2 ? T(s * ratio) : s;
5688 }
5689
5690 if (it == 1)
5691 for (const UpdRow& row : call_map) {
5692 if (row.node <= 1) continue;
5693 const std::size_t eidx = lqn.callpair_dst[row.aidx];
5694 callservt[row.aidx] = servt[eidx];
5695 callservtproc[row.aidx] = Distrib<T>::exp_mean(servt[eidx]);
5696 }
5697
5698 // The entry servt is now the MEAN OF AN APH CONVOLUTION, not a sum
5699 // of residence times, which the interlock rescale in
5700 // `update_populations` has to know. See BUGS.md BUG-97.
5701 moment_pass_done = true;
5702 }
5703 }
5704
5705 /**
5706 * (alpha, S) of an APH handed back by aph_fit as a (D0, D1) pair.
5707 *
5708 * D1(i,j) = (-D0 e)_i alpha_j by construction, so alpha is any row of D1
5709 * divided by that row's exit rate; the first row with a positive exit rate
5710 * is taken. It is not recovered from the stationary phase distribution,
5711 * which is a different vector and would silently refit the law.
5712 */
5713 static mam::AphPair<T> aph_pair_of_map(const mam::Map<T>& m) {
5714 mam::AphPair<T> out;
5715 const std::size_t n = m.D0.rows();
5716 out.S = m.D0;
5717 out.alpha.assign(n, Tzero());
5718 for (std::size_t i = 0; i < n; ++i) {
5719 T rowsum = Tzero();
5720 for (std::size_t j = 0; j < n; ++j) rowsum += m.D1(i, j);
5721 if (!(dbl(rowsum) > GlobalConstants::Zero)) continue;
5722 for (std::size_t j = 0; j < n; ++j) out.alpha[j] = T(m.D1(i, j) / rowsum);
5723 return out;
5724 }
5725 // No phase can absorb: the law is degenerate, so it enters phase one and
5726 // stays there, which is what a zero alpha would NOT say.
5727 out.alpha[0] = Tone();
5728 return out;
5729 }
5730
5731 /**
5732 * F(t) = 1 - alpha exp(S t) e on the reference's grid, `APH.evalCDF` with
5733 * no argument: 500 points over [0, mean + 10 sigma].
5734 */
5735 static LnCdf aph_eval_cdf(const mam::AphPair<T>& law) {
5736 LnCdf out;
5737 const double m1 = dbl(Distrib<T>::ph_moment(law.alpha, law.S, 1));
5738 const double m2 = dbl(Distrib<T>::ph_moment(law.alpha, law.S, 2));
5739 const double var = m2 - m1 * m1;
5740 const double sigma = var > 0.0 ? std::sqrt(var) : 0.0;
5741 const double tmax = m1 + 10.0 * sigma;
5742 const std::size_t P = 500;
5743 out.t.resize(P);
5744 out.cdf.resize(P);
5745 for (std::size_t k = 0; k < P; ++k) {
5746 const double t = tmax * static_cast<double>(k) / static_cast<double>(P - 1);
5747 out.t[k] = t;
5748 Matrix<T> St(law.S.rows(), law.S.cols(), Tzero());
5749 for (std::size_t i = 0; i < law.S.rows(); ++i)
5750 for (std::size_t j = 0; j < law.S.cols(); ++j)
5751 St(i, j) = T(law.S(i, j) * num_traits<T>::from_double(t));
5752 const Matrix<T> E = ::line::expm(St);
5753 T surv = Tzero();
5754 for (std::size_t i = 0; i < E.rows(); ++i)
5755 for (std::size_t j = 0; j < E.cols(); ++j) surv += law.alpha[i] * E(i, j);
5756 out.cdf[k] = 1.0 - dbl(surv);
5757 }
5758 return out;
5759 }
5760
5761 /**
5762 * The AND-join completion times, and how much they undercut the serial sum.
5763 *
5764 * The branches of an AND fork run concurrently, so the time to clear the
5765 * join is the k-th smallest of the branch completion times, k being the
5766 * quorum. The entry-service reachability matrix cannot express that -- it
5767 * charges every activity of every branch to the entry, i.e. it serialises
5768 * them -- so the difference is recorded per join and applied as a
5769 * correction to any entry that reaches it.
5770 *
5771 * Branch times are taken as exponential, so the variance is the square of
5772 * the mean; that is the reference's assumption, not an approximation added
5773 * here.
5774 */
5775 std::vector<T> join_excess() const {
5776 std::vector<T> excess(lqn.nidx + 1, Tzero());
5777 std::vector<std::size_t> joined;
5778 for (std::size_t tail = 1; tail <= lqn.nidx; ++tail) {
5779 if (lqn.actpretype[tail] != PrecedenceType::PRE_AND) continue;
5780 for (std::size_t sx : lqn.graph.succ(tail))
5781 if (sx > lqn.ashift && sx <= lqn.ashift + lqn.nacts) joined.push_back(sx);
5782 }
5783 std::sort(joined.begin(), joined.end());
5784 joined.erase(std::unique(joined.begin(), joined.end()), joined.end());
5785 if (joined.empty()) return excess;
5786
5787 fj::LqnBranchView<T> view;
5788 view.graph = Matrix<T>(lqn.nidx, lqn.nidx, Tzero());
5789 for (std::size_t i = 1; i <= lqn.nidx; ++i)
5790 for (const auto& e : lqn.graph.row[i]) view.graph(i - 1, e.first - 1) = e.second;
5791 view.ashift = lqn.ashift;
5792 view.nacts = lqn.nacts;
5793 view.actposttype.assign(lqn.nidx, 0);
5794 for (std::size_t i = 1; i <= lqn.nidx; ++i)
5795 view.actposttype[i - 1] = static_cast<int>(lqn.actposttype[i]);
5796
5797 for (std::size_t aidx : joined) {
5798 const std::vector<std::vector<std::size_t>> branches =
5799 fj::fj_branch_members(view, aidx);
5800 if (branches.empty()) continue;
5801 std::vector<T> means;
5802 for (const auto& br : branches) {
5803 T s = Tzero();
5804 for (std::size_t a : br) {
5805 s += residt[a];
5806 // A branch activity with an Immediate host demand does all its work
5807 // in a rendezvous, so residt alone leaves this correction inert.
5808 if (a < lqn.callsof.size())
5809 for (std::size_t cidx : lqn.callsof[a])
5810 if (cidx < lqn.calltype.size() && lqn.calltype[cidx] == CallType::SYNC
5811 && cidx < callresidt.size())
5812 s += callresidt[cidx];
5813 }
5814 means.push_back(s);
5815 }
5816 if (means.size() == 1) continue; // a single branch cannot overlap
5817 std::size_t quorum = means.size();
5818 if (lqn.actquorum[aidx] >= 1 && lqn.actquorum[aidx] <= means.size())
5819 quorum = lqn.actquorum[aidx];
5820 std::vector<T> vars;
5821 for (const T& m2 : means) vars.push_back(T(m2 * m2));
5822 T serial = Tzero();
5823 for (const T& m2 : means) serial += m2;
5824 if constexpr (num_traits<T>::has_transcendental) {
5825 const fj::FJQuorumMomentsResult<T> q = fj::fj_quorum_moments(means, vars, quorum);
5826 excess[aidx] = T(q.m - serial);
5827 } else {
5828 throw UnsupportedError(
5829 "SolverLN: the AND-join completion time is a k-th order statistic fitted "
5830 "through a three-point distribution, which needs a square root; use the "
5831 "double or real backend for a model with an AND join");
5832 }
5833 }
5834 return excess;
5835 }
5836
5837 /** The block that turns activity residence times into entry service times. */
5838 void resolve_entry_service() {
5839 const std::size_t dim = lqn.nidx + lqn.ncalls;
5840 std::vector<T> x(dim + 1, Tzero());
5841 for (std::size_t i = 1; i <= lqn.nidx; ++i) x[i] = residt[i];
5842 for (std::size_t c = 1; c <= lqn.ncalls; ++c) x[lqn.nidx + c] = callresidt[c];
5843 std::vector<T> entry_servt(dim + 1, Tzero());
5844 for (std::size_t i = 1; i <= dim; ++i) {
5845 T s = Tzero();
5846 for (std::size_t j = 1; j <= dim; ++j)
5847 if (servtmatrix(i, j) != Tzero()) s += servtmatrix(i, j) * x[j];
5848 entry_servt[i] = s;
5849 }
5850
5851 // replace each reachable join's serialised branch sum by its concurrent
5852 // completion time
5853 const std::vector<T> excess = join_excess();
5854 for (std::size_t e = 1; e <= lqn.nentries; ++e) {
5855 const std::size_t eidx = lqn.eshift + e;
5856 for (std::size_t aidx = 1; aidx <= lqn.nidx; ++aidx) {
5857 if (excess[aidx] == Tzero()) continue;
5858 if (servtmatrix(eidx, aidx) > Tzero())
5859 entry_servt[eidx] = T(entry_servt[eidx] + excess[aidx]);
5860 }
5861 if (entry_servt[eidx] < Tzero()) entry_servt[eidx] = Tzero();
5862 }
5863
5864 // A SetupTask's cold start is charged HERE, to the entry, and with the
5865 // probability that the thread was actually found powered down. It is not
5866 // host demand, so it belongs to no activity's residence -- reporting it
5867 // there put RespT(A2) at 1.29479 against 0.333178 from LDES on lqn_setup,
5868 // the bare demand. The probability is the one 'srvn.ph' uses through
5869 // ph_setup_prob, so the two encodings charge the same thing
5870 // (updateMetricsDefault.m, lqn_setup_charge.m).
5871 for (std::size_t e = 1; e <= lqn.nentries; ++e) {
5872 const std::size_t eidx = lqn.eshift + e;
5873 const double c = setup_charge(lqn.parent[eidx]);
5874 if (c != 0.0)
5875 entry_servt[eidx] = T(entry_servt[eidx] + num_traits<T>::from_double(c));
5876 }
5877
5878 // ResidT is normalised so the TASK has one visit; the entries need one
5879 // visit each, hence the throughput ratio below
5880 for (std::size_t e = 1; e <= lqn.nentries; ++e) {
5881 const std::size_t eidx = lqn.eshift + e;
5882 const std::size_t tidx = lqn.parent[eidx];
5883 const std::size_t hidx = lqn.parent[tidx];
5884 if (ignore[tidx] || ignore[hidx]) continue;
5885 if (idxhash[hidx] < 0) continue;
5886 const bool has_sync = lqn.issynccaller.any_col(eidx);
5887 if (!has_sync) {
5888 servt[eidx] = entry_servt[eidx];
5889 residt[eidx] = entry_servt[eidx];
5890 continue;
5891 }
5892 const std::size_t hl = std::size_t(idxhash[hidx]);
5893 const qn::Layer<T>& L = ensemble[hl];
5894 const LayerResult<T>& r = results.back()[hl];
5895 T task_tput = Tzero(), entry_tput = Tzero();
5896 for (const auto& kv : L.attr_tasks)
5897 if (kv.second == tidx) task_tput += r.TN(L.clientIdx - 1, kv.first - 1);
5898 for (const auto& kv : L.attr_entries)
5899 if (kv.second == eidx) entry_tput += r.TN(L.clientIdx - 1, kv.first - 1);
5900 if (dbl(entry_tput) > GlobalConstants::Zero) {
5901 servt[eidx] = T(entry_servt[eidx] * task_tput / entry_tput);
5902 residt[eidx] = servt[eidx];
5903 } else {
5904 servt[eidx] = entry_servt[eidx];
5905 residt[eidx] = entry_servt[eidx];
5906 }
5907 }
5908 }
5909
5910 // -----------------------------------------------------------------------
5911 // updatePopulations (interlock correction)
5912 // -----------------------------------------------------------------------
5913 /**
5914 * True when the layer engine applies Eq. (4.7) inside its own MVA. A layer whose MVA path
5915 * has no interlock term would be moved to another algorithm by the matrix alone: exact
5916 * multiserver MVA would become AMVA, the product-form kernels would become the
5917 * load-dependent forward step. That swap is worth far more than the correction it carries,
5918 * and on a layer sitting near a bifurcation it turns the LN iteration into a limit cycle.
5919 * Such a layer keeps the residt scaling instead.
5920 */
5921 bool layer_takes_interlock(std::size_t e) const {
5922 return opt.layer_solver == "mva" && e < ensemble.size() &&
5923 mva::mva_carries_interlock(ensemble[e], opt.layer);
5924 }
5925
5926 /**
5927 * Class-level interlock matrix of one host layer. IL[r][s] is the share of the class-s
5928 * queue that a class-r arrival must not see at the host. It is kept CLASS-indexed, not
5929 * chain-indexed, so that a later chain refresh cannot leave it stale: the analyzer
5930 * aggregates it to chains against the struct it is about to solve. Two classes are
5931 * interlocked only if BOTH their tasks are, which is the 0/1 relation ir_mkj of Eq. (5);
5932 * the diagonal stays zero, since a request always sees its own class in full. The entry
5933 * is the Eq. (5) product Pr(IL_ms)*IR_ms*IR_mr, asymmetric in (r,s) because Pr(IL) is
5934 * taken from the QUEUED class s, so that the layer's ILw(r,s) = 1-IL(r,s) is the
5935 * lower-level adjustment rate r_lower. An empty result keeps the layer on the plain MVA
5936 * path.
5937 */
5938 std::vector<std::vector<double>> build_layer_interlock(std::size_t e,
5939 const std::vector<std::size_t>& hts,
5940 const std::vector<T>& prIL,
5941 const std::vector<T>& PrIL) const {
5942 const qn::Layer<T>& L = ensemble[e];
5943 const std::size_t R = L.nclasses;
5944 std::vector<double> cls_ir(R, 0.0); // IR
5945 std::vector<double> cls_pr(R, 0.0); // Pr(IL)
5946 auto stamp = [&](std::size_t classIdx, std::size_t tidx) {
5947 if (classIdx < 1 || classIdx > R) return;
5948 for (std::size_t i = 0; i < hts.size(); ++i)
5949 if (hts[i] == tidx) {
5950 cls_ir[classIdx - 1] = dbl(prIL[i]);
5951 cls_pr[classIdx - 1] = dbl(PrIL[i]);
5952 }
5953 };
5954 for (const auto& a : L.attr_tasks) stamp(a.first, a.second);
5955 for (const auto& a : L.attr_entries) stamp(a.first, lqn.parent[a.second]);
5956 for (const auto& a : L.attr_activities) stamp(a.first, lqn.parent[a.second]);
5957 for (const auto& a : L.attr_calls) stamp(a[0], lqn.parent[a[2]]);
5958
5959 std::vector<std::vector<double>> IL(R, std::vector<double>(R, 0.0));
5960 bool any = false;
5961 for (std::size_t r = 0; r < R; ++r) {
5962 if (!(cls_ir[r] > GlobalConstants::FineTol)) continue;
5963 for (std::size_t sIl = 0; sIl < R; ++sIl) {
5964 if (sIl == r || !(cls_ir[sIl] > GlobalConstants::FineTol)) continue;
5965 IL[r][sIl] = cls_pr[sIl] * cls_ir[sIl] * cls_ir[r];
5966 if (IL[r][sIl] > GlobalConstants::FineTol) any = true;
5967 }
5968 }
5969 return any ? IL : std::vector<std::vector<double>>();
5970 }
5971
5972 /**
5973 * Interlock probability for one (client, server) pair, as {IR, Pr(IL)} of Li and Franks,
5974 * "An improved interlocking correction for decomposition of layered queueing networks",
5975 * CCECE 2015, Eqs. (3) and (4). isProcessorHost selects the m' rule of lqns
5976 * Interlock::ilrate_pril_flow: at a PROCESSOR the common-source population is doubled
5977 * above 3 customers and squared at or below it, which is what turns m = 4 into the
5978 * pril = 1/8 its trace reports. The two factors are multiplied into the Eq. (5) rate by
5979 * build_layer_interlock, so neither carries the source count on its own -- that lives in
5980 * m'. This replaces the superseded (n_s-1)/n_s discount of Franks (1999), Eq. (4.7).
5981 */
5982 std::pair<T, T> interlock_prob(std::size_t client_tidx, std::size_t server_idx,
5983 bool isProcessorHost) const {
5984 const std::vector<std::size_t>& common = il_common_entries[server_idx];
5985 const double nsrc = il_num_sources[server_idx];
5986 if (nsrc == 0.0 || common.empty()) return std::make_pair(Tzero(), Tzero());
5987 const std::vector<std::size_t>& allsrc = il_src_all[server_idx];
5988 T sum_flow = Tzero();
5989 T sum_pril = Tzero();
5990 for (std::size_t ce : common) {
5991 const std::size_t srcTask = lqn.parent[ce];
5992 const std::size_t cen = ce - lqn.eshift;
5993 // population of this common source, in customer copies
5994 double m_src = lqn.mult[srcTask];
5995 if (!std::isfinite(m_src) || m_src < 1.0) m_src = 1.0;
5996 double m_eff = m_src;
5997 if (isProcessorHost) m_eff = (m_src > 3.0) ? (m_src + m_src) : (m_src * m_src);
5998 for (std::size_t dst : lqn.entriesof[client_tidx]) {
5999 const std::size_t dn = dst - lqn.eshift;
6000 if (dn < 1 || dn > lqn.nentries) continue;
6001 if (!(il_all(cen, dn) > Tzero())) continue;
6002 T ce_tput = tput[ce];
6003 if (!(dbl(ce_tput) > GlobalConstants::FineTol) && !lqn.actsof[ce].empty())
6004 ce_tput = tput[lqn.actsof[ce][0]];
6005 if (!(dbl(ce_tput) > GlobalConstants::FineTol)) ce_tput = tput[srcTask];
6006 if (!(dbl(ce_tput) > GlobalConstants::FineTol)) continue;
6007 // phase-2 entries are refused upstream, so every source is all-phase
6008 if (std::find(allsrc.begin(), allsrc.end(), srcTask) != allsrc.end()) {
6009 const T contrib = T(ce_tput * il_all(cen, dn));
6010 sum_flow += contrib;
6011 sum_pril += T(contrib / num_traits<T>::from_double(m_eff));
6012 }
6013 }
6014 }
6015 T client_tput = tput[client_tidx];
6016 if (!(dbl(client_tput) > GlobalConstants::FineTol)) {
6017 for (std::size_t e : lqn.entriesof[client_tidx]) {
6018 T et = tput[e];
6019 if (!(dbl(et) > GlobalConstants::FineTol) && !lqn.actsof[e].empty())
6020 et = tput[lqn.actsof[e][0]];
6021 client_tput = T(client_tput + et);
6022 }
6023 }
6024 if (!(dbl(client_tput) > GlobalConstants::FineTol))
6025 return std::make_pair(Tzero(), Tzero());
6026 const T flow = sum_flow < client_tput ? sum_flow : client_tput;
6027 T IR = T(flow / client_tput);
6028 if (IR > Tone()) IR = Tone();
6029 if (dbl(IR) < 0.0) IR = Tzero();
6030 T pr = Tzero();
6031 if (dbl(sum_flow) > GlobalConstants::FineTol) pr = T(sum_pril / sum_flow);
6032 if (pr > Tone()) pr = Tone();
6033 if (dbl(pr) < 0.0) pr = Tzero();
6034 return std::make_pair(IR, pr);
6035 }
6036
6037 void update_populations(int) {
6038 const std::vector<T> residt_orig = residt;
6039 const std::vector<T> callresidt_orig = callresidt;
6040 bool adjusted = false;
6041
6042 for (std::size_t cidx = 1; cidx <= lqn.ncalls; ++cidx) {
6043 if (lqn.calltype[cidx] != CallType::SYNC) continue;
6044 const std::size_t dst = lqn.callpair_dst[cidx];
6045 const std::size_t server_tidx = lqn.parent[dst];
6046 std::size_t server = 0;
6047 if (server_tidx <= NT() && !il_common_entries[server_tidx].empty()) {
6048 server = server_tidx;
6049 } else if (server_tidx > lqn.tshift) {
6050 const std::size_t h = lqn.parent[server_tidx];
6051 if (h >= 1 && h <= NT() && !il_common_entries[h].empty()) server = h;
6052 }
6053 if (server == 0) continue;
6054 const std::size_t client_tidx = lqn.parent[lqn.callpair_src[cidx]];
6055 // This path serves a TASK, not a processor, so the m' rule of Li/lqns leaves the
6056 // source population alone; the product IR*Pr(IL) reproduces the scalar this
6057 // branch used before.
6058 const std::pair<T, T> ilp = interlock_prob(client_tidx, server, false);
6059 const T prIL = T(ilp.first * ilp.second);
6060 if (!(dbl(prIL) > GlobalConstants::FineTol)) continue;
6061 const T S = servt[dst];
6062 const T cm = lqn.callproc_mean[cidx];
6063 if (!(cm > Tzero()) || !(callservt[cidx] > Tzero())) continue;
6064 const T RN = T(callservt[cidx] / cm);
6065 const T W = RN > S ? T(RN - S) : Tzero();
6066 if (!(dbl(W) > GlobalConstants::FineTol)) continue;
6067 const T RN_adj = T(S + (Tone() - prIL) * W);
6068 const T scale = T(RN_adj / RN);
6069 callservt[cidx] = T(callservt[cidx] * scale);
6070 callresidt[cidx] = T(callresidt[cidx] * scale);
6071 if (callservt[cidx] > Tzero())
6072 callservtproc[cidx] = Distrib<T>::exp_mean(callservt[cidx]);
6073 adjusted = true;
6074 }
6075
6076 // Every layer starts the pass without a matrix, so a host that stops being
6077 // interlocked does not keep the previous iteration's correction alive.
6078 layer_interlock.assign(ensemble.size(), {});
6079 for (std::size_t h = 1; h <= lqn.nhosts; ++h) {
6080 if (il_common_entries[h].empty()) continue;
6081 const std::vector<std::size_t>& hts = lqn.tasksof[h];
6082 std::vector<T> prIL(hts.size(), Tzero()), PrIL(hts.size(), Tzero()),
6083 tutil(hts.size(), Tzero());
6084 for (std::size_t i = 0; i < hts.size(); ++i) {
6085 // The host of a task layer is a PROCESSOR, which is what selects the m' rule.
6086 const std::pair<T, T> ilp = interlock_prob(hts[i], h, true);
6087 prIL[i] = ilp.first;
6088 PrIL[i] = ilp.second;
6089 for (std::size_t e : lqn.entriesof[hts[i]])
6090 for (std::size_t a : lqn.actsof[e]) tutil[i] += tput[a] * lqn.hostdem[a].mean;
6091 }
6092 T Utot = Tzero(), Uil = Tzero();
6093 for (std::size_t i = 0; i < hts.size(); ++i) {
6094 Utot += tutil[i];
6095 if (dbl(prIL[i]) > GlobalConstants::FineTol) Uil += tutil[i];
6096 }
6097 if (!(dbl(Utot) > GlobalConstants::FineTol) || !(dbl(Uil) > GlobalConstants::FineTol))
6098 continue;
6099 const T frac = T(Uil / Utot);
6100
6101 // When the layer engine carries Eq. (4.7) inside its own MVA, the interlock goes
6102 // to the layer as a class-level matrix and the residence times are left
6103 // untouched. Scaling them here as well would remove the same waiting twice, and
6104 // would still leave the layer's own THROUGHPUT uncorrected, which is what breaks
6105 // flow balance across a call: the reported task rate then comes from a cycle time
6106 // the correction has already shortened elsewhere.
6107 if (idxhash[h] >= 0 && layer_takes_interlock(std::size_t(idxhash[h]))) {
6108 const std::size_t e = std::size_t(idxhash[h]);
6109 layer_interlock[e] = build_layer_interlock(e, hts, prIL, PrIL);
6110 continue;
6111 }
6112
6113 for (std::size_t i = 0; i < hts.size(); ++i) {
6114 if (!(dbl(prIL[i]) > GlobalConstants::FineTol)) continue;
6115 // Weight by the share of host utilization that is interlocked. The rate
6116 // is the SAME Eq. (5) product IR*Pr(IL) that pass 1 applies to a call and
6117 // that build_layer_interlock puts in the layer matrix -- IR alone is a
6118 // flow SHARE, ~1 whenever a layer has a single common source, and using
6119 // it here removed the whole processor queueing rather than the
6120 // interlocked part of it, which broke flow balance across a call.
6121 const T eff = T(prIL[i] * PrIL[i] * frac);
6122 for (std::size_t e : lqn.entriesof[hts[i]])
6123 for (std::size_t a : lqn.actsof[e]) {
6124 const T D = lqn.hostdem[a].mean;
6125 if (D > Tzero() && dbl(residt[a] - D) > GlobalConstants::FineTol) {
6126 residt[a] = T(D + (Tone() - eff) * (residt[a] - D));
6127 adjusted = true;
6128 }
6129 }
6130 }
6131 }
6132
6133 if (!adjusted) return;
6134 const std::size_t dim = lqn.nidx + lqn.ncalls;
6135 auto esum = [&](const std::vector<T>& rs, const std::vector<T>& cr, std::size_t i) {
6136 T s = Tzero();
6137 for (std::size_t j = 1; j <= dim; ++j) {
6138 if (servtmatrix(i, j) == Tzero()) continue;
6139 s += servtmatrix(i, j) * (j <= lqn.nidx ? rs[j] : cr[j - lqn.nidx]);
6140 }
6141 return s;
6142 };
6143 for (std::size_t e = 1; e <= lqn.nentries; ++e) {
6144 const std::size_t eidx = lqn.eshift + e;
6145 const T oldv = esum(residt_orig, callresidt_orig, eidx);
6146 if (!(dbl(oldv) > GlobalConstants::FineTol)) continue;
6147 const T ratio = T(esum(residt, callresidt, eidx) / oldv);
6148 // The entry servt is rescaled only when it was itself assembled from
6149 // these residence times, which is the mean-based path. After the
6150 // moment3 distribution pass it is the mean of an APH convolution of
6151 // the activities' own response laws, and a ratio of residence-time
6152 // sums is not a correction to it: applying it multiplied the entry
6153 // law by the entry's visit ratio and reported a service time BELOW
6154 // that of the single activity the entry contains. The residence
6155 // times keep their correction either way. See BUGS.md BUG-97.
6156 if (!moment_pass_done) {
6157 servt[eidx] = T(servt[eidx] * ratio);
6158 if (servt[eidx] > Tzero()) servtproc[eidx] = Distrib<T>::exp_mean(servt[eidx]);
6159 }
6160 residt[eidx] = T(residt[eidx] * ratio);
6161 }
6162 }
6163
6164 // -----------------------------------------------------------------------
6165 // Declared think time of a task as it enters the thread cycle: the value for
6166 // a REFERENCE task, zero for any other.
6167 //
6168 // A think time is an attribute of the closed customer population a reference
6169 // task stands for, and it is what separates one request of that population
6170 // from the next. On a served task it has no such meaning, and charging it
6171 // per request throttles the task: lqn_basic's T3 has 25 threads and a
6172 // declared think time of 4, and reading it as a per-request delay caps it at
6173 // 25/(4+0.02) = 6.219 completions per second. Three independent oracles put
6174 // the rate at five calls per caller request instead -- lqsim 66.5, LDES
6175 // 66.955, lqns 75.6. See _kb/06-solver-catalog.md (LN section).
6176 T ref_think_time(std::size_t tidx) const {
6177 if (tidx >= lqn.isref.size() || !lqn.isref[tidx]) return Tzero();
6178 if (lqn.think[tidx].disabled) return Tzero();
6179 return lqn.think[tidx].mean;
6180 }
6181
6182 // updateThinkTimes
6183 // -----------------------------------------------------------------------
6184 void update_think_times(int it) {
6185 // Under "srvn.ph" a caller reaches the server once per invocation, so the
6186 // station rate is not the task's invocation rate
6187 if (is_ph_encoding()) {
6188 update_think_times_ph(it);
6189 return;
6190 }
6191 thinktproc.assign(lqn.nidx + 1, Distrib<T>::disabled_dist());
6192 for (std::size_t t = 1; t <= lqn.ntasks; ++t) {
6193 const std::size_t tidx = lqn.tshift + t;
6194 // only a REFERENCE task's think time is a per-request delay
6195 const T ztask = ref_think_time(tidx);
6196 if (idxhash[tidx] < 0) {
6197 // A task reached only by an entry arrival still has a cycle: its threads
6198 // are driven by the stream. build_layer drops the open class for it so
6199 // the chain can be closed on the known rate here.
6200 const double arvrate = open_arrival_rate_of(tidx);
6201 if (arvrate > GlobalConstants::FineTol) {
6202 double nja = 0.0;
6203 for (std::size_t c = 1; c <= NT(); ++c) nja = std::max(nja, njobs(tidx, c));
6204 if (!(nja > 0.0)) nja = lqn.maxmult[tidx];
6205 T hres = Tzero();
6206 for (std::size_t eidx : lqn.entriesof[tidx])
6207 for (std::size_t aidx : lqn.actsof[eidx])
6208 if (!std::isnan(dbl(residt[aidx]))) hres += residt[aidx];
6209 const T floora = num_traits<T>::from_double(GlobalConstants::Zero);
6210 T za = T(num_traits<T>::from_double(nja / arvrate) - hres - ztask);
6211 if (za < floora) za = floora;
6212 if (relax_omega < 1.0 && it > 1 && !std::isnan(thinkt_prev[tidx])) {
6213 const T om = num_traits<T>::from_double(relax_omega);
6214 const T om1 = num_traits<T>::from_double(1.0 - relax_omega);
6215 za = T(om * za + om1 * thinkt_prev_v[tidx]);
6216 }
6217 tput[tidx] = num_traits<T>::from_double(arvrate);
6218 thinkt[tidx] = za;
6219 thinkt_prev[tidx] = dbl(za);
6220 thinkt_prev_v[tidx] = za;
6221 thinktproc[tidx] = Distrib<T>::exp_mean(T(za + ztask));
6222 continue;
6223 }
6224 thinkt[tidx] = num_traits<T>::from_double(GlobalConstants::FineTol);
6225 thinktproc[tidx] = Distrib<T>::immediate();
6226 continue;
6227 }
6228 const std::size_t e = std::size_t(idxhash[tidx]);
6229 const qn::Layer<T>& L = ensemble[e];
6230 const LayerResult<T>& r = results.back()[e];
6231 double nj = 0.0;
6232 for (std::size_t c = 1; c <= NT(); ++c) nj = std::max(nj, njobs(tidx, c));
6233 // the task's OWN station, which under `flat.cs` is one of many
6234 const std::size_t ts = station_idx_of(L, tidx);
6235 T tp = Tzero(), ut = Tzero();
6236 for (std::size_t k = 0; k < L.nclasses; ++k) {
6237 tp += r.TN(ts - 1, k);
6238 ut += r.UN(ts - 1, k);
6239 }
6240 tput[tidx] = T(num_traits<T>::from_double(lqn.repl[tidx]) * tp);
6241 util[tidx] = ut;
6242 T raw;
6243 if (lqn.sched[tidx] == SchedStrategy::INF) {
6244 // an infinite server reports utilization as a mean job count
6245 raw = tput[tidx] == Tzero()
6246 ? Tzero()
6247 : T((num_traits<T>::from_double(nj) - util[tidx]) / tput[tidx] - ztask);
6248 } else {
6249 const T om = util[tidx] > Tone() ? T(util[tidx] - Tone()) : T(Tone() - util[tidx]);
6250 raw = tput[tidx] == Tzero()
6251 ? Tzero()
6252 : T(num_traits<T>::from_double(nj) * om / tput[tidx] - ztask);
6253 }
6254 const T floorv = num_traits<T>::from_double(GlobalConstants::Zero);
6255 thinkt[tidx] = raw < floorv ? floorv : raw;
6256
6257 // The cold start goes the OTHER way from the phase-2 tail. A caller
6258 // class cycles as delay plus station service, and the station serves
6259 // only the host demand: the charge is on the ENTRY, not on any
6260 // activity's demand, so the station never sees it and the delay has
6261 // to carry it. Without this the callee layer cycles faster than its
6262 // callers drive it -- 0.529412 against 0.5 on lqn_setup, with the
6263 // caller conserved and the callee not. Zero for a task with no setup
6264 // (updateThinkTimes.m).
6265 {
6266 const double c = setup_charge(tidx);
6267 if (c != 0.0) {
6268 const T withc = T(thinkt[tidx] + num_traits<T>::from_double(c));
6269 thinkt[tidx] = withc < floorv ? floorv : withc;
6270 }
6271 }
6272
6273 if (relax_omega < 1.0 && it > 1 && !std::isnan(thinkt_prev[tidx])) {
6274 const double rawd = dbl(thinkt[tidx]);
6275 if (thinkt_prev[tidx] > 10.0 * rawd && rawd > GlobalConstants::FineTol) {
6276 thinkt_prev[tidx] = rawd;
6277 thinkt_prev_v[tidx] = thinkt[tidx];
6278 }
6279 const T om = num_traits<T>::from_double(relax_omega);
6280 const T om1 = num_traits<T>::from_double(1.0 - relax_omega);
6281 thinkt[tidx] = T(om * thinkt[tidx] + om1 * thinkt_prev_v[tidx]);
6282 }
6283 thinkt_prev[tidx] = dbl(thinkt[tidx]);
6284 thinkt_prev_v[tidx] = thinkt[tidx];
6285 thinktproc[tidx] = Distrib<T>::exp_mean(T(thinkt[tidx] + ztask));
6286 }
6287 }
6288
6289 // -----------------------------------------------------------------------
6290 // updateLayers and updateRoutingProbabilities
6291 // -----------------------------------------------------------------------
6292 void update_layers(int it) {
6293 // Under "srvn.ph" the layer classes are one per caller task and their laws
6294 // are composed, not read off the update maps
6295 if (is_ph_encoding()) {
6296 update_layers_ph(it);
6297 return;
6298 }
6299 const bool elevator = (it % 2) == 1;
6300 const std::size_t nt = thinkt_map.size();
6301 for (std::size_t r = 0; r < nt; ++r) {
6302 const UpdRow& row = thinkt_map[elevator ? nt - 1 - r : r];
6303 qn::Layer<T>& L = ensemble[std::size_t(idxhash[row.idx])];
6304 if (row.aidx <= NT() && opt.interlocking &&
6305 L.classes[row.cls - 1].type == JobClassType::CLOSED)
6306 L.classes[row.cls - 1].population = njobs(row.aidx, row.idx);
6307 if (row.node == L.clientIdx) {
6308 if (lqn.type[row.aidx] == LqnElement::TASK) {
6309 if (lqn.sched[row.aidx] != SchedStrategy::REF) {
6310 if (!thinktproc[row.aidx].disabled)
6311 L.set_service(row.node, row.cls, thinktproc[row.aidx]);
6312 } else {
6313 L.set_service(row.node, row.cls, servtproc[row.aidx]);
6314 }
6315 } else {
6316 L.set_service(row.node, row.cls, servtproc[row.aidx]);
6317 }
6318 } else {
6319 L.set_service(row.node, row.cls, servtproc[row.aidx]);
6320 }
6321 }
6322 const std::size_t nc = call_map.size();
6323 for (std::size_t r = 0; r < nc; ++r) {
6324 const UpdRow& row = call_map[elevator ? nc - 1 - r : r];
6325 qn::Layer<T>& L = ensemble[std::size_t(idxhash[row.idx])];
6326 if (row.node == L.clientIdx)
6327 L.set_service(row.node, row.cls, callservtproc[row.aidx]);
6328 else
6329 L.set_service(row.node, row.cls, servtproc[lqn.callpair_dst[row.aidx]]);
6330 }
6331 // Async arrival rate: the caller activity fires at its own throughput,
6332 // and every firing releases callmean jobs into this layer's Source.
6333 // updateLayers.m:63-73 replays the same map; the geometric self-loop in
6334 // build_layer already accounts for callmean, so the rate is the bare
6335 // activity throughput, not scaled by it.
6336 for (const UpdRow& row : arv_call_map) {
6337 qn::Layer<T>& L = ensemble[std::size_t(idxhash[row.idx])];
6338 const Distrib<T>& d = tputproc[lqn.callpair_src[row.aidx]];
6339 if (!d.disabled) L.set_service(row.node, row.cls, d);
6340 }
6341 }
6342
6343 /**
6344 * Time a job of `cls`'s CHAIN spends blocked at this layer's region.
6345 *
6346 * A job held by an admission constraint is at no station at all, so its
6347 * wait is structurally absent from the RN and WN the layer reports back and
6348 * the caller and the callee end up disagreeing on throughput -- the fixed
6349 * point still converges, it just converges to the wrong place.
6350 *
6351 * Recovered by Little over the CHAIN, never per class: a job switches class
6352 * along the activity graph, so a call class carries population 0 and a
6353 * per-class deficit comes out negative and silently does nothing.
6354 * Zero for a layer with no region, which is every layer in a plain model.
6355 */
6356 T region_wait(std::size_t e, std::size_t cls) const {
6357 const qn::Layer<T>& L = ensemble[e];
6358 if (L.regions.empty() || results.empty()) return Tzero();
6359 const LayerResult<T>& r = results.back()[e];
6360 const std::size_t k = cls - 1;
6361 std::size_t c = L.nchains;
6362 for (std::size_t cc = 0; cc < L.nchains; ++cc)
6363 if (L.chains[cc][k]) c = cc;
6364 if (c == L.nchains) return Tzero();
6365
6366 // the station the region is stated over, which under `flat.cs` is one
6367 // among many and under `srvn` is the layer's own server
6368 std::size_t rs = L.serverIdx;
6369 for (std::size_t i = 0; i < L.regions[0].members.size(); ++i)
6370 if (L.regions[0].members[i]) { rs = i + 1; break; }
6371
6372 double pop = 0.0;
6373 T inside = Tzero(), tput_srv = Tzero();
6374 for (std::size_t j = 0; j < L.nclasses; ++j) {
6375 if (!L.chains[c][j]) continue;
6376 const double p = L.classes[j].population;
6377 if (!std::isfinite(p)) return Tzero(); // an open chain has no population to close on
6378 pop += p;
6379 for (std::size_t i = 0; i < L.nstations; ++i) inside += r.QN(i, j);
6380 tput_srv += r.TN(rs - 1, j);
6381 }
6382 const T deficit = T(num_traits<T>::from_double(pop) - inside);
6383 if (!(deficit > Tzero()) || !(tput_srv > Tzero())) return Tzero();
6384 return T(deficit / tput_srv);
6385 }
6386
6387 /** Port of updateRoutingProbabilities: entry selection by throughput ratio. */
6388 void update_routing_probabilities(int) {
6389 for (std::size_t u = 0; u < unique_route_idx.size(); ++u) {
6390 // the reference always takes the reversed order here: its `mod(it,0)`
6391 // guard is NaN, which MATLAB reads as false
6392 const std::size_t idx = unique_route_idx[unique_route_idx.size() - 1 - u];
6393 qn::Layer<T>& L = ensemble[std::size_t(idxhash[idx])];
6394 bool updated = false;
6395 for (const RouteRow& r : route_map) {
6396 if (r.idx != idx) continue;
6397 if (idxhash[r.tidx_caller] < 0) continue;
6398 const std::size_t cl = std::size_t(idxhash[r.tidx_caller]);
6399 const qn::Layer<T>& CL = ensemble[cl];
6400 const LayerResult<T>& cr = results.back()[cl];
6401 // the CALLER's own station, and for a call class the station of
6402 // the entry it targets; the two coincide under `srvn`
6403 const std::size_t cs = station_idx_of(CL, r.tidx_caller);
6404 T Xtot = Tzero();
6405 for (std::size_t k = 0; k < CL.nclasses; ++k) Xtot += cr.TN(cs - 1, k);
6406 if (!(Xtot > Tzero())) continue;
6407 T entry_tput = Tzero();
6408 for (const auto& a : CL.attr_calls)
6409 if (a[3] == r.eidx)
6410 entry_tput += cr.TN(station_idx_of(CL, lqn.parent[a[3]]) - 1, a[0] - 1);
6411 L.set_route(r.cfrom, r.cto, r.nodefrom, r.nodeto, T(entry_tput / Xtot));
6412 updated = true;
6413 }
6414 if (updated) L.refresh_chains();
6415 }
6416 }
6417
6418 // -----------------------------------------------------------------------
6419 // getTranAvg: getTranAvgDecoupled and getTranAvgCoupled
6420 // -----------------------------------------------------------------------
6421
6422 /**
6423 * What a layered transient needs before it can mean anything.
6424 *
6425 * A FLUID ENSEMBLE IS REQUIRED, and not as an implementation shortcut: the
6426 * reference reaches the layer transient through `self.solvers{e}.getTranAvg`,
6427 * which only a transient-capable layer solver has, and the coupled mode
6428 * injects its time-varying demands through the fluid rate schedule
6429 * specifically. An MVA layer has no trajectory to report and no place to
6430 * receive an injection, so an ensemble built on one is refused here rather
6431 * than silently answered with its steady state repeated over a grid.
6432 */
6433 void require_transient_ready() const {
6434 if (opt.layer_solver != "fluid")
6435 throw UnsupportedError(
6436 "SolverLN: the layered transient is the transient OF EACH LAYER, which only the "
6437 "fluid layer solver has; rebuild the ensemble with layer_solver 'fluid'");
6438 }
6439
6440 /** True when the caller named a horizon; otherwise each layer picks its own. */
6441 bool has_finite_horizon() const {
6442 return std::isfinite(opt.timespan_end) && opt.timespan_end > 0.0;
6443 }
6444
6445 /** One layer's trajectory, resampled onto the shared grid. */
6446 struct TranTraj {
6447 std::vector<std::vector<std::vector<double>>> Q, U, Tp, R; ///< [station][class][point]
6448 };
6449
6450 /**
6451 * Run one layer's transient, optionally with an injected rate schedule, and
6452 * return both the reportable block and the trajectory the relaxation reads.
6453 */
6454 void run_layer_transient(std::size_t e, const std::vector<FluidRateSched>& sched,
6455 LnTranLayer& block, TranTraj& traj) {
6456 qn::Layer<T>& L = ensemble[e];
6457 L.refresh_rt();
6458 fluid::FluidOptions fo = opt.layer_fluid;
6459 fo.rate_sched = sched;
6460 // THE WARM START IS REPLAYED HERE, and this is the only place it can be:
6461 // `converged` resets every layer the first time the fixed point settles,
6462 // so a state installed before the solve is gone by now. The steady solve
6463 // ignores an initial state and the transient does not, which is why
6464 // replaying it after the one and before the other loses nothing.
6465 if (e < layer_tran_init.size() && !layer_tran_init[e].empty())
6466 fo.init_sol = layer_tran_init[e];
6467 // No horizon named: this layer picks its own, by the analyzer's own rule
6468 // (thirty mean events of its slowest transition). That is what the
6469 // reference's decoupled path does -- each layer's SolverFluid chooses --
6470 // and it is why an unset timespan is a valid request rather than an error.
6471 const double t_end =
6472 has_finite_horizon() ? opt.timespan_end : fluid::fluid_default_horizon(L, fo);
6473 const std::vector<fluid::FluidTranPoint> pts =
6474 detail::ln_fluid_transient(L, fo, t_end, opt.tran_points, opt.tran_grid);
6475 const std::size_t M = L.nstations, K = L.nclasses, P = pts.size();
6476 block.t.resize(P);
6477 auto alloc = [&](std::vector<std::vector<std::vector<double>>>& A) {
6478 A.assign(M, std::vector<std::vector<double>>(K, std::vector<double>(P, 0.0)));
6479 };
6480 alloc(block.QN);
6481 alloc(block.UN);
6482 alloc(block.TN);
6483 alloc(traj.Q);
6484 alloc(traj.U);
6485 alloc(traj.Tp);
6486 alloc(traj.R);
6487 for (std::size_t p = 0; p < P; ++p) {
6488 block.t[p] = pts[p].t;
6489 for (std::size_t i = 0; i < M; ++i)
6490 for (std::size_t r = 0; r < K; ++r) {
6491 const double q = pts[p].QN(i, r), u = pts[p].UN(i, r), x = pts[p].TN(i, r);
6492 block.QN[i][r][p] = q;
6493 block.UN[i][r][p] = u;
6494 block.TN[i][r][p] = x;
6495 traj.Q[i][r][p] = q;
6496 traj.U[i][r][p] = u;
6497 traj.Tp[i][r][p] = x;
6498 // Residence by Little, which is how the relaxation reads a
6499 // callee's response time off a layer trajectory.
6500 traj.R[i][r][p] = q / std::max(x, GlobalConstants::FineTol);
6501 }
6502 }
6503 }
6504
6505 /**
6506 * Port of getTranAvgDecoupled: freeze the inter-layer demands at the
6507 * converged fixed point and run each layer's transient in isolation.
6508 */
6509 LnTranSolution tran_avg_decoupled() {
6510 require_transient_ready();
6511 if (results.empty()) iterate();
6512 LnTranSolution out;
6513 out.mode = "decoupled";
6514 out.layers.resize(ensemble.size());
6515 for (std::size_t e = 0; e < ensemble.size(); ++e) {
6516 TranTraj tj;
6517 run_layer_transient(e, std::vector<FluidRateSched>(), out.layers[e], tj);
6518 }
6519 return out;
6520 }
6521
6522 /**
6523 * Port of getTranAvgCoupled: reconcile the per-layer transients by waveform
6524 * relaxation, so the layer populations and the inter-layer demands co-evolve
6525 * in model time.
6526 *
6527 * Each layer's fluid transient is driven by TIME-VARYING inter-layer demand
6528 * trajectories taken from the other layers' latest transients, and the loop
6529 * repeats until the trajectories stop moving in sup-norm. Iteration 0 uses
6530 * the frozen equilibrium demands, so it reproduces the decoupled answer
6531 * exactly; at convergence every layer relaxes to its own fixed point, so the
6532 * endpoint equals getAvg.
6533 *
6534 * TWO CHANNELS ARE COUPLED, the task think times (the client delay) and the
6535 * synchronous-call service demands (the caller's client station). Both are
6536 * dominant inter-layer couplings; the intra-layer host service stays at its
6537 * equilibrium value, as in the reference.
6538 */
6539 LnTranSolution tran_avg_coupled() {
6540 require_transient_ready();
6541 // Waveform relaxation reconciles the layers on ONE shared grid, so it
6542 // needs a horizon the layers agree on. With none named there is nothing
6543 // to co-evolve over, and the reference defers to the decoupled path
6544 // rather than inventing one; so does this.
6545 if (!has_finite_horizon()) return tran_avg_decoupled();
6546 if (results.empty()) iterate();
6547 const std::size_t E = ensemble.size();
6548
6549 LnTranSolution out;
6550 out.mode = "coupled";
6551 out.layers.resize(E);
6552 std::vector<TranTraj> traj(E), prev(E);
6553 for (std::size_t e = 0; e < E; ++e)
6554 run_layer_transient(e, std::vector<FluidRateSched>(), out.layers[e], traj[e]);
6555 const std::vector<double> tgrid = out.layers.empty() ? std::vector<double>() : out.layers[0].t;
6556
6557 for (long iter = 1; iter <= opt.ln_transient_iter_max; ++iter) {
6558 prev = traj;
6559 const std::vector<std::vector<FluidRateSched>> sched =
6560 build_rate_sched(recompute_demand(traj, tgrid), tgrid);
6561 for (std::size_t e = 0; e < E; ++e)
6562 run_layer_transient(e, sched[e], out.layers[e], traj[e]);
6563 double gap = 0.0;
6564 for (std::size_t e = 0; e < E; ++e)
6565 for (std::size_t i = 0; i < traj[e].Q.size(); ++i)
6566 for (std::size_t r = 0; r < traj[e].Q[i].size(); ++r)
6567 for (std::size_t p = 0; p < traj[e].Q[i][r].size(); ++p)
6568 gap = std::max(gap, std::fabs(traj[e].Q[i][r][p] - prev[e].Q[i][r][p]));
6569 out.iterations = iter;
6570 out.gap = gap;
6571 if (gap < opt.ln_transient_tol) break;
6572 }
6573 return out;
6574 }
6575
6576 /** The two coupled demand channels, as trajectories on the shared grid. */
6577 struct TranDemand {
6578 std::map<std::size_t, std::vector<double>> thinkt; ///< by task element index
6579 std::map<std::size_t, std::vector<double>> callservt; ///< by call index
6580 };
6581
6582 /**
6583 * Recompute the inter-layer demands pointwise in t, mirroring the SCALAR
6584 * updateThinkTimes and updateMetricsDefault formulas.
6585 *
6586 * They are the same formulas, evaluated at each point of the grid instead of
6587 * at the fixed point: that is what makes the endpoint of the relaxation the
6588 * steady-state answer rather than something near it.
6589 */
6590 TranDemand recompute_demand(const std::vector<TranTraj>& traj,
6591 const std::vector<double>& tgrid) const {
6592 TranDemand out;
6593 const std::size_t ng = tgrid.size();
6594
6595 for (std::size_t t = 1; t <= lqn.ntasks; ++t) {
6596 const std::size_t tidx = lqn.tshift + t;
6597 if (idxhash[tidx] < 0 || lqn.isref[tidx]) continue;
6598 const std::size_t e = std::size_t(idxhash[tidx]);
6599 const qn::Layer<T>& L = ensemble[e];
6600 const std::size_t s = station_idx_of(L, tidx) - 1;
6601 double nj = 0.0;
6602 for (std::size_t c = 1; c <= NT(); ++c) nj = std::max(nj, njobs(tidx, c));
6603 // same closure as update_think_times, so the same gate
6604 const double userthink = dbl(ref_think_time(tidx));
6605 std::vector<double> tk(ng, 0.0);
6606 for (std::size_t p = 0; p < ng; ++p) {
6607 double U = 0.0, X = 0.0;
6608 for (std::size_t r = 0; r < L.nclasses; ++r) {
6609 U += traj[e].U[s][r][p];
6610 X += traj[e].Tp[s][r][p];
6611 }
6612 const double Xs = std::max(X, GlobalConstants::FineTol);
6613 double v = lqn.sched[tidx] == SchedStrategy::INF
6614 ? (nj - U) / Xs - userthink
6615 : nj * std::fabs(1.0 - U) / Xs - userthink;
6617 tk[p] = v + userthink; // total mean, user think included
6618 }
6619 out.thinkt[tidx] = tk;
6620 }
6621
6622 for (std::size_t cidx = 1; cidx <= lqn.ncalls; ++cidx) {
6623 if (lqn.calltype[cidx] != CallType::SYNC) continue;
6624 const std::size_t eidx = lqn.callpair_dst[cidx];
6625 const std::size_t tidx = lqn.parent[eidx];
6626 if (tidx > NT() || idxhash[tidx] < 0) continue;
6627 const std::size_t e = std::size_t(idxhash[tidx]);
6628 const qn::Layer<T>& L = ensemble[e];
6629 const std::size_t s = station_idx_of(L, tidx) - 1;
6630 std::vector<double> Rc(ng, 0.0);
6631 bool any = false;
6632 for (std::size_t r = 0; r < L.nclasses; ++r) {
6633 if (L.classes[r].attr_kind != int(LqnElement::ENTRY)) continue;
6634 if (L.classes[r].attr_idx != eidx) continue;
6635 any = true;
6636 for (std::size_t p = 0; p < ng; ++p) Rc[p] += traj[e].R[s][r][p];
6637 }
6638 if (!any) {
6639 // No entry class of its own in this layer: the entry's work is
6640 // carried by its activities, so their residence is the answer.
6641 for (std::size_t r = 0; r < L.nclasses; ++r)
6642 for (std::size_t p = 0; p < ng; ++p) Rc[p] += traj[e].R[s][r][p];
6643 }
6644 const double cm = dbl(lqn.callproc_mean[cidx]);
6645 for (std::size_t p = 0; p < ng; ++p) Rc[p] *= cm;
6646 out.callservt[cidx] = Rc;
6647 }
6648 return out;
6649 }
6650
6651 /**
6652 * Map the demand trajectories onto per-layer rate schedules, through the
6653 * SAME update maps that place the scalar setService calls.
6654 *
6655 * The injected schedule MODULATES the layer's equilibrium rate by the ratio
6656 * of the transient demand to its steady-state value: passing rate = 1/d(t)
6657 * with nominal = 1/d(end) makes solver_fluid_ratemult's multiplier
6658 * d(end)/d(t), which is exactly 1 at the horizon end. That is what makes the
6659 * layer relax to its UNMODIFIED fixed point regardless of any small mismatch
6660 * between the fluid residence Q/T and the scalar equilibrium demand.
6661 */
6662 std::vector<std::vector<FluidRateSched>> build_rate_sched(
6663 const TranDemand& demand, const std::vector<double>& tgrid) const {
6664 std::vector<std::vector<FluidRateSched>> out(ensemble.size());
6665 const bool want_think =
6666 opt.ln_transient_channels == "both" || opt.ln_transient_channels == "thinkt";
6667 const bool want_call =
6668 opt.ln_transient_channels == "both" || opt.ln_transient_channels == "callservt";
6669
6670 auto add = [&](std::size_t e, std::size_t station, std::size_t cls,
6671 const std::vector<double>& d) {
6672 if (d.empty()) return;
6673 const double dend = d.back();
6674 if (!(dend > GlobalConstants::FineTol)) return; // degenerate steady state
6675 // Bound the transient demand to a physical band around its
6676 // steady-state value: an early-transient throughput near zero sends
6677 // the reciprocal to infinity and the integrator with it.
6678 const double cap = 20.0;
6680 s.station = station;
6681 s.cls = cls;
6682 s.tgrid = tgrid;
6683 s.rates.resize(d.size());
6684 for (std::size_t p = 0; p < d.size(); ++p) {
6685 const double v = std::min(std::max(d[p], dend / cap), dend * cap);
6686 s.rates[p] = 1.0 / v;
6687 }
6688 s.nominal = 1.0 / dend;
6689 out[e].push_back(s);
6690 };
6691
6692 if (want_think)
6693 for (const UpdRow& row : thinkt_map) {
6694 if (idxhash[row.idx] < 0) continue;
6695 const std::size_t e = std::size_t(idxhash[row.idx]);
6696 if (row.node != ensemble[e].clientIdx) continue;
6697 if (lqn.type[row.aidx] != LqnElement::TASK) continue;
6698 if (lqn.sched[row.aidx] == SchedStrategy::REF) continue;
6699 const auto it = demand.thinkt.find(row.aidx);
6700 if (it == demand.thinkt.end()) continue;
6701 add(e, row.node, row.cls, it->second);
6702 }
6703 if (want_call)
6704 for (const UpdRow& row : call_map) {
6705 if (idxhash[row.idx] < 0) continue;
6706 const std::size_t e = std::size_t(idxhash[row.idx]);
6707 if (row.node != ensemble[e].clientIdx) continue;
6708 const auto it = demand.callservt.find(row.aidx);
6709 if (it == demand.callservt.end()) continue;
6710 add(e, row.node, row.cls, it->second);
6711 }
6712 return out;
6713 }
6714
6715 // -----------------------------------------------------------------------
6716 // getEnsembleAvg
6717 // -----------------------------------------------------------------------
6718 LnSolution<T> aggregate() {
6719 const std::size_t N = lqn.nidx;
6720 LnSolution<T> s;
6721 auto mk = [&](std::vector<T>& v, std::vector<bool>& d) {
6722 v.assign(N + 1, Tzero());
6723 d.assign(N + 1, false);
6724 };
6725 std::vector<T> QN, UN, RN, TN, PN, SN, WN, AN;
6726 std::vector<bool> dQ, dU, dR, dT, dP, dS, dW, dA;
6727 mk(QN, dQ); mk(UN, dU); mk(RN, dR); mk(TN, dT);
6728 mk(PN, dP); mk(SN, dS); mk(WN, dW); mk(AN, dA);
6729 std::vector<bool> wn_done(N + 1, false);
6730
6731 for (std::size_t e = 0; e < ensemble.size(); ++e) {
6732 const qn::Layer<T>& L = ensemble[e];
6733 const LayerResult<T>& r = results.back()[e];
6734 const std::size_t clientIdx = L.clientIdx;
6735 // The processors this layer serves: one under `srvn` (and only when
6736 // it is a host layer), every one of them under `flat.cs`. Each is
6737 // charged with the activities that actually run on it, which is
6738 // vacuous under `srvn` because a host layer holds no others.
6739 const bool has_host_server = !L.host_stations.empty();
6740 for (std::size_t hs : L.host_stations) {
6741 const std::size_t hidx = L.stations[hs - 1].attr_idx;
6742 dT[hidx] = true;
6743 TN[hidx] = Tzero();
6744 dP[hidx] = true;
6745 PN[hidx] = Tzero();
6746 for (std::size_t k = 0; k < L.nclasses; ++k) {
6747 if (L.classes[k].completes) {
6748 T t = clientIdx > 0 ? r.TN(clientIdx - 1, k) : Tzero();
6749 const T ts = r.TN(hs - 1, k);
6750 TN[hidx] = T(TN[hidx] + (t > ts ? t : ts));
6751 }
6752 if (L.classes[k].attr_kind == int(LqnElement::ACTIVITY)) {
6753 // the activity does not run on this processor
6754 if (station_idx_of_class(L, k) != hs) continue;
6755 const std::size_t aidx = L.classes[k].attr_idx;
6756 const std::size_t tidx = lqn.parent[aidx];
6757 dP[aidx] = true;
6758 dP[tidx] = true;
6759 PN[aidx] = T(PN[aidx] + r.UN(hs - 1, k));
6760 PN[tidx] = T(PN[tidx] + r.UN(hs - 1, k));
6761 PN[hidx] = T(PN[hidx] + r.UN(hs - 1, k));
6762 }
6763 }
6764 dT[hidx] = false; // NaN in the reference, for consistency with LQNS
6765 }
6766
6767 for (std::size_t k = 0; k < L.nclasses; ++k) {
6768 const int kind = L.classes[k].attr_kind;
6769 // the station this class is actually served at, which under
6770 // `flat.cs` is the processor of an activity or the called task
6771 // of a call rather than the one server the layer used to have
6772 const std::size_t serverIdx = station_idx_of_class(L, k);
6773 if (kind == int(LqnElement::TASK)) {
6774 const std::size_t tidx = L.classes[k].attr_idx;
6775 if (has_host_server && !dT[tidx]) {
6776 dT[tidx] = true;
6777 TN[tidx] = r.TN(clientIdx - 1, k);
6778 }
6779 } else if (kind == int(LqnElement::ENTRY)) {
6780 const std::size_t eidx = L.classes[k].attr_idx;
6781 dS[eidx] = true;
6782 // getEnsembleAvg.m:84-92: a phase-2 entry reports the
6783 // CALLER's view, which is what residt now holds.
6784 SN[eidx] = (has_phase2 && dbl(servt_ph2[eidx]) > GlobalConstants::FineTol)
6785 ? residt[eidx]
6786 : servt[eidx];
6787 if (has_host_server && !dT[eidx]) {
6788 dT[eidx] = true;
6789 TN[eidx] = r.TN(clientIdx - 1, k);
6790 }
6791 } else if (kind == int(LqnElement::CALL)) {
6792 const std::size_t cidx = L.classes[k].attr_idx;
6793 const std::size_t aidx = lqn.callpair_src[cidx];
6794 if (lqn.calltype[cidx] == CallType::SYNC) {
6795 dS[aidx] = true;
6796 SN[aidx] = T(SN[aidx] + r.RN(serverIdx - 1, k) * lqn.callproc_mean[cidx]);
6797 }
6798 dQ[aidx] = true;
6799 QN[aidx] = T(QN[aidx] + r.QN(serverIdx - 1, k));
6800 } else if (kind == int(LqnElement::ACTIVITY)) {
6801 const std::size_t aidx = L.classes[k].attr_idx;
6802 const std::size_t tidx = lqn.parent[aidx];
6803 dQ[tidx] = true;
6804 QN[tidx] = T(QN[tidx] + r.QN(serverIdx - 1, k));
6805 dT[aidx] = true;
6806 dQ[aidx] = true;
6807 TN[aidx] = T(TN[aidx] + r.TN(serverIdx - 1, k));
6808 dS[aidx] = true;
6809 SN[aidx] = T(SN[aidx] + r.RN(serverIdx - 1, k));
6810 dR[aidx] = true;
6811 RN[aidx] = T(RN[aidx] + r.RN(serverIdx - 1, k));
6812 dW[aidx] = true;
6813 dW[tidx] = true;
6814 WN[aidx] = residt[aidx];
6815 if (!wn_done[aidx]) {
6816 WN[tidx] = T(WN[tidx] + residt[aidx]);
6817 wn_done[aidx] = true;
6818 }
6819 QN[aidx] = T(QN[aidx] + r.QN(serverIdx - 1, k));
6820 }
6821 }
6822 }
6823
6824 for (std::size_t e = 1; e <= lqn.nentries; ++e) {
6825 const std::size_t eidx = lqn.eshift + e;
6826 const std::size_t tidx = lqn.parent[eidx];
6827 dU[tidx] = true;
6828 dU[eidx] = true;
6829 // getEnsembleAvg.m:186-198: the server is busy through BOTH phases,
6830 // so the utilization is the full service time and not SN, which for
6831 // a phase-2 entry has been cut down to the caller's view above.
6832 UN[eidx] = (has_phase2 && dbl(servt_ph2[eidx]) > GlobalConstants::FineTol)
6833 ? T(TN[eidx] * (servt_ph1[eidx] + servt_ph2[eidx]))
6834 : T(TN[eidx] * SN[eidx]);
6835 T ps = Tzero();
6836 bool any = false;
6837 for (std::size_t a : lqn.actsof[eidx])
6838 if (dP[a]) {
6839 ps += PN[a];
6840 any = true;
6841 }
6842 if (!lqn.actsof[eidx].empty()) {
6843 dP[eidx] = true;
6844 PN[eidx] = any ? ps : Tzero();
6845 }
6846 for (std::size_t a : lqn.actsof[tidx]) {
6847 dU[a] = true;
6848 UN[a] = T(TN[a] * SN[a]);
6849 }
6850 UN[tidx] = T(UN[tidx] + UN[eidx]);
6851 }
6852
6853 // AN IGNORED ELEMENT IS IDLE, NOT UNDEFINED, and the two are different cells.
6854 // Its component holds no reference task, so nothing reaches it and every
6855 // measure it HAS is zero -- but the measures its kind never has stay
6856 // undefined, exactly as they do for a reachable element. A flat zero over
6857 // all six columns broke the table's NaN mask (a processor with a queue
6858 // length of 0, an arrival rate reported where no solver reports one), and
6859 // the mask is part of the answer: see _kb/06-solver-catalog.md. The
6860 // relabelling below reports Q from U, U from P and R from S, so QN and RN
6861 // are dead here and are not written.
6862 for (std::size_t i = 1; i <= N; ++i)
6863 if (ignore[i]) {
6864 PN[i] = Tzero(); // every kind reports a utilization
6865 dP[i] = true;
6866 dA[i] = false; // nothing reports an arrival rate on an LQN
6867 const bool host = lqn.type[i] == LqnElement::HOST;
6868 const bool task = lqn.type[i] == LqnElement::TASK;
6869 const bool entry = lqn.type[i] == LqnElement::ENTRY;
6870 UN[i] = Tzero();
6871 dU[i] = !host;
6872 SN[i] = Tzero();
6873 dS[i] = !host && !task;
6874 WN[i] = Tzero();
6875 dW[i] = !host && !entry;
6876 TN[i] = Tzero();
6877 dT[i] = !host;
6878 }
6879
6880 // the reference's final relabelling: Q <- U, U <- P, R <- S
6881 s.QN = UN; s.defined_Q = dU;
6882 s.UN = PN; s.defined_U = dP;
6883 s.RN = SN; s.defined_R = dS;
6884 s.TN = TN; s.defined_T = dT;
6885 s.AN = AN; s.defined_A = dA;
6886 s.WN = WN; s.defined_W = dW;
6887 s.iterations = iterations_done;
6888 s.converged = did_converge;
6889 return s;
6890 }
6891};
6892
6893} // namespace ln
6894} // namespace line
6895
6896// Deliberately at the FOOT of the file: lqn_analyzers.h needs LayerResult and
6897// SolverLN complete, and this file needs its lqn_overtake_prob_markov, so the
6898// two are mutually dependent. Either include order works -- whichever header a
6899// translation unit names first, the other is fully parsed before the templates
6900// above are instantiated -- and the forward declaration near the top of this
6901// file is what makes the call inside update_metrics resolve.
6903
6904#endif // LINE_SOLVERS_LN_SOLVER_LN_H
Convolution of a sequence of matrix-exponential laws.
Minimal-order acyclic phase-type fit of the first three moments (matlab/lib/kpctoolbox/aph/aph_fit....
Composition of two matrix-exponential distributions given in (alpha, T) form.
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
Convergence controller for an ensemble whose layers are solved by a NOISY method (simulation,...
LnSolution< T > box_bounds()
Majumdar-Woodside robust box bounds, reported in the shape of a solution.
Definition solver_ln.h:627
LnLayerBlocks layer_blocks() const
Port of LayeredNetwork.layerBlocks: the block-diagonal layout of the layers in the aggregate (station...
Definition solver_ln.h:671
const std::vector< std::vector< LayerResult< T > > > & iteration_results() const
Diagnostic access to the per-iteration layer results, for the regression.
Definition solver_ln.h:746
const std::vector< T > & state_util() const
Definition solver_ln.h:756
SolverLN(const LqnStruct< T > &lqn_in, const LnOptions &options)
Definition solver_ln.h:486
LnSensTable< T > get_sensitivity_table(const sens::SensOptions &sopt)
Port of @SolverLN/getSensitivityTable: solve the ensemble, then concatenate each layer solver's own t...
Definition solver_ln.h:582
LnTranSolution get_tran_avg()
Port of @SolverLN/getTranAvg: the block-diagonal aggregate transient.
Definition solver_ln.h:563
const std::vector< T > & state_thinkt() const
Definition solver_ln.h:750
std::size_t nlayers() const
Definition solver_ln.h:660
const std::vector< Distrib< T > > & state_callservtproc() const
Definition solver_ln.h:755
std::vector< LnCdf > get_cdf_respt()
Port of @SolverLN/getCdfRespT: the per-entry response-time distribution.
Definition solver_ln.h:524
const std::vector< T > & state_callresidt() const
Definition solver_ln.h:752
const std::string & state_lnmethod() const
The method the layers were built for: "srvn.ph", "srvn.cs" or "moment3".
Definition solver_ln.h:758
const std::vector< T > & state_tput() const
Definition solver_ln.h:749
void set_tran_grid(const std::vector< double > &g)
Install the explicit output grid of the layered transient; see LnOptions::tran_grid.
Definition solver_ln.h:743
const std::vector< T > & state_servt() const
Definition solver_ln.h:747
static std::vector< std::string > list_valid_methods()
Port of SolverLN.listValidMethods.
Definition solver_ln.h:482
const std::vector< Distrib< T > > & state_servtproc() const
Definition solver_ln.h:753
const std::vector< Distrib< T > > & state_thinktproc() const
Definition solver_ln.h:754
LnSolution< T > get_ensemble_avg()
Port of getEnsembleAvg: run the iteration and aggregate onto LQN elements.
Definition solver_ln.h:500
const std::vector< T > & state_callservt() const
Definition solver_ln.h:751
const std::vector< qn::Layer< T > > & layers() const
Definition solver_ln.h:661
const std::vector< T > & state_residt() const
Definition solver_ln.h:748
void init_from_marginal(const Matrix< double > &n)
Port of LayeredNetwork.initFromMarginal: split an aggregate (M x K) mean queue-length matrix into per...
Definition solver_ln.h:711
A layer network: everything a NetworkStruct holds, plus the LQN back-mapping.
Definition qn_layer.h:52
A network plus its refreshed NetworkStruct.
std::vector< std::vector< bool > > disabled
std::vector< Station< T > > stations
stations[k-1] is the k-th station
static void iter(long k, const char *fmt,...)
Report iteration k of the current loop.
static void step(const char *fmt,...)
Write one progress line.
static bool is_acyclic_generator(const Matrix< T > &S)
True when the phase graph of S has no cycle.
Definition workflow.h:657
static PhLaw< T > compose_loop_geometric(const PhLaw< T > &body, const T &count)
Geometric repetition of a phase-type law, the POST_LOOP semantics.
Definition workflow.h:585
static PhLaw< T > compose_serial(const PhLaw< T > &a, const PhLaw< T > &b)
Serial composition: the second law starts when the first absorbs.
Definition workflow.h:463
static PhLaw< T > compose_mixture(const std::vector< PhLaw< T > > &laws, const std::vector< T > &probs)
Probabilistic mixture: a block-diagonal generator whose initial vector picks branch i with probabilit...
Definition workflow.h:548
What refreshProcessRepresentations and refreshLST compute FROM a distribution: the (D0,...
The exception types the port throws.
Matrix exponential by scaling and squaring with a diagonal Pade approximant.
Activities belonging to each branch of an AND-join.
The fork-join fixed point that drives one inner MVA solve.
The fork-join transform SolverMVA applies before solving a layer that contains a Fork.
Mean and variance of a k-of-n (quorum) join completion time, from the mean and variance of each branc...
Response-time distribution by tagged fluid: a port of solver_fluid_passage_time.m,...
The fluid solver's outermost entry point: @@SolverFLD/runAnalyzer.m's method resolution over solver_f...
Dense linear algebra over the templated number type: products, identity, inverse, and powers.
Running progress log of a LINE solver run (the "solver console").
The @SolverLN methods that solver_ln.h does not carry.
Majumdar-Woodside robust box bounds on the throughput of a layered network.
Standalone LQN routines that SolverLN needs but does not contain.
Phase-type composition of an LQN activity graph, the machinery behind SolverLN method 'srvn....
LayeredNetworkStruct, the flattened description of a layered queueing network.
Port of solver_mam_analyzer.m: one inner solve, choosing the analyzer that fits the model and the req...
Dense matrix and non-owning view.
Port of @@SolverMVA/mvaDispatch.m: one inner solve, choosing the analyzer that fits the model.
EntryWorkflow< T > entry_workflow(const ::line::lqn::LqnStruct< T > &lqn, std::size_t eidx, bool with_calls)
Activity graph of LQN entry EIDX as a Workflow.
Definition lqn_ph.h:104
PhLaw< T > ph_law_of(const Distrib< T > &d)
The (alpha, S) pair of a phase-type Distrib, the form the composition rules take.
Definition lqn_ph.h:52
PhLaw< T > serial_law(Workflow< T > &wf)
Composed law of a workflow in which the branches of an AND fork are SERIAL rather than concurrent,...
Definition lqn_ph.h:241
std::pair< T, T > ph_moments(const std::vector< T > &alpha, const Matrix< T > &S)
First two moments of a phase-type law without building a Distrib, which is what the layered fixed poi...
Definition lqn_ph.h:258
void sn_fj_nodevisits_mmt(qn::NetworkStruct< T > &sn)
Rewrite sn.nodevisits with the MMT correction.
T sn_compat_scaling(const Matrix< T > &compat, const std::vector< double > &counts, const std::vector< T > &rates, const std::vector< T > &n)
Rate scaling eta(n) a compatibility declaration imposes on its station.
mva::AvgResult< T > solver_ctmc_run_analyzer_any(const NetworkStruct< T > &sn, const CtmcOptions &opt)
Solve on whichever path applies and format, mirroring solver_ctmc_run_analyzer.
std::vector< std::vector< std::size_t > > fj_branch_members(const LqnBranchView< T > &lqn, std::size_t joinaidx)
Branch membership of an AND-join.
FJQuorumMomentsResult< T > fj_quorum_moments(const std::vector< T > &branchMeans, const std::vector< T > &branchVars, std::size_t k)
Mean and variance of a k-of-n (quorum) join completion time, from the mean and variance of each branc...
double fluid_default_horizon(const qn::NetworkStruct< T > &sn, const FluidOptions &opt)
The horizon a transient runs to when the caller gives none.
FluidLayout fluid_layout(const qn::NetworkStruct< T > &sn)
Port of the layout half of solver_fluid_odes.m.
Definition fluid_odes.h:282
std::vector< FluidTranPoint > solver_fluid_transient(const qn::NetworkStruct< T > &sn, const FluidOptions &opt, double t_end, std::size_t points=101, const std::vector< double > &out_grid=std::vector< double >())
Port of @@SolverFLD/getTranAvg: the metrics along the trajectory, not just at the fixed point.
FluidSolution solver_fluid(const qn::NetworkStruct< T > &sn_in, const FluidOptions &opt, qn::NetworkStruct< T > *sn_out=nullptr)
Port of solver_fluid_analyzer.m: dispatch on the method, refit the non-exponential FCFS stations the ...
FluidPassage fluid_passage_time(const qn::NetworkStruct< T > &sn, const std::vector< double > &x_steady, std::size_t ist, std::size_t cls, double tol=1e-4, std::size_t points=201, const FluidClosure &closure=FluidClosure())
Response-time CDF at station ist for class cls, both 1-based.
FluidSolution solver_fluid_run_analyzer(const qn::NetworkStruct< T > &sn, const FluidOptions &opt, qn::NetworkStruct< T > *sn_out=nullptr, qn::NetworkStruct< T > *refreshed_out=nullptr, solvers::CacheMetrics< T > *cache_out=nullptr)
Port of @@SolverFLD/runAnalyzer.m: resolve the method, route to the function the reference routes to,...
SchedStrategy
Scheduling disciplines, with the values of MATLAB SchedStrategy.
Definition lang_types.h:181
PrecedenceType
Activity precedence kinds, with the values of MATLAB ActivityPrecedenceType.
Definition lang_types.h:470
LqnElement
LQN element kinds, with the values of MATLAB LayeredNetworkElement.
Definition lang_types.h:464
RoutingStrategy
Routing strategies, with the values of MATLAB RoutingStrategy.
Definition lang_types.h:389
CallType
Call kinds, with the values of MATLAB CallType.
Definition lang_types.h:467
Distrib< T > aph_fit_mean_scv(const T &mean, const T &scv)
APH.fitMeanAndSCV(MEAN, SCV), through mam::aph_fit_mean_scv.
JobClassType
Job class kinds, with the values of MATLAB JobClassType.
Definition lang_types.h:367
std::function< std::vector< T >(const std::vector< T > &)> CdScaling
A class-dependent scaling map, sn.cdscaling.
Definition lang_types.h:639
NodeType
Node kinds, with the values of MATLAB NodeType.
Definition lang_types.h:324
T dist_moment(const Distrib< T > &d, unsigned k)
The k-th raw moment.
void lqn_fwd_rendezvous(LqnStruct< T > &lqn)
Replace every forwarding chain reachable from a synchronous call by caller-side pseudo rendezvous cal...
Definition lqn_helpers.h:76
T lqn_overtake_prob_markov(const LqnStruct< T > &lqn, const std::vector< T > &servt, const std::vector< T > &callresidt, const std::vector< T > &tput, std::size_t eidx, const T &xj)
Overtaking probability at a server entry, through the LQNS phased-server chain rather than the reduce...
fluid::FluidOptions::RateSched FluidRateSched
One (station, class) rate trajectory injected into a layer's closing ODE.
Definition solver_ln.h:145
LqnBoxBounds< T > lqn_boxbounds(const LqnStruct< T > &lqn)
Evaluate the box bounds of lqn.
MamSolution< T > mam_dispatch(const qn::NetworkStruct< T > &L, const MamOptions &opt_in)
The ladder.
AphPair< T > aph_convseq(const std::vector< AphPair< T > > &seq)
Convolve the sequence, i.e.
Definition aph_convseq.h:37
AphPair< T > aph_simplify(const AphPair< T > &d1, const AphPair< T > &d2, const T &p1, const T &p2, AphPattern pattern)
Compose two matrix-exponential laws, as aph_simplify.m does.
AphFitResult< T > aph_fit(const T &e1, const T &e2, const T &e3, unsigned nmax, const T &tol)
Fit an APH(n) with n <= nmax to the raw moments e1, e2, e3.
Definition aph_fit.h:176
FjMmt< T > fj_mmt(const qn::NetworkStruct< T > &L)
Build the transformed layer.
Definition fj_mmt.h:444
bool mva_carries_interlock(const qn::NetworkStruct< T > &L, const MvaOptions &opt)
True when the MVA path this model already dispatches to carries a class-level interlock matrix (Frank...
MvaSolution< T > fj_fixed_point(const qn::NetworkStruct< T > &L, FjMmt< T > &tr, std::vector< T > &lam, const MvaOptions &opt, InnerSolve inner)
Drive the fork-join fixed point of a transformed model to convergence.
Definition fj_driver.h:401
DispatchResult< T > mva_dispatch(const qn::NetworkStruct< T > &L, const MvaOptions &opt, const Matrix< T > &init_sol)
The ladder itself.
NcSolution< T > solver_nc_solve(const qn::NetworkStruct< T > &L_in, const NcSolverOptions &opt_in)
The gates, the multiserver conversion and the dispatch of @@SolverNC/runAnalyzer.m,...
SensTable< T > solver_sensitivity_table(qn::NetworkStruct< T > &sn, const SensOptions &opt, bool exact_available, const std::function< mva::MvaSolution< T >()> &solve)
Build the sensitivity table of sn under solve.
SsaSolution solver_ssa(const qn::NetworkStruct< T > &sn, const SsaOptions &opt, std::vector< SsaCacheRatio > *cache=nullptr)
solver_ssa_analyzer.m: choose the method.
Matrix< T > expm(const Matrix< T > &A)
Matrix exponential exp(A).
Definition expm.h:141
Port of solver_nc_analyzer.m, solver_ncld_analyzer.m and @@SolverNC/ncDispatch.m: one inner solve,...
One SolverLN layer: a NetworkStruct plus the LQN annotations that say which element of the layered mo...
Total service rate of a station served by heterogeneous server pools with a class-compatibility graph...
Post-MMT node visits of a fork-join model.
Port of solver_ctmc_fcr_waitq.m: the reachability-built generator of a model whose finite capacity re...
SolverFluid: the closing method, a port of solver_fluid.m, solver_fluid_iteration....
SolverMVA over a SolverLN layer.
The SolverNC class surface: @@SolverNC/runAnalyzer.m and the gates around it.
Performance sensitivities with respect to service rates.
The SolverSSA entry surface: a port of @@SolverSSA/runAnalyzer.m's method whitelist,...
Where each (station, class) block sits in the state vector.
Definition fluid_odes.h:86
std::vector< std::vector< std::size_t > > qidx
0-based first index of (i,r)
Definition fluid_odes.h:88
std::size_t nstates
length of the state vector
Definition fluid_odes.h:87
std::vector< std::vector< bool > > enabled
whether (i,r) is served at all
Definition fluid_odes.h:90
options.config.rate_sched: explicit per-(station, class) rate trajectories, the third source solver_f...
Controls, defaulting to SolverOptions('Fluid') in the reference.
double tol
absolute and relative tolerance handed to the integrator
What the analyzer returns, in the same shape as the MVA solver's result.
std::vector< double > XN
std::vector< double > xvec
the converged fluid state
std::vector< double > CN
static Distrib exp_rate(const T &r)
Definition lang_types.h:814
static Distrib phase_type(const std::vector< T > &alpha, const Matrix< T > &A, bool acyclic)
PH / APH given by (alpha, A): D0 = A and D1 = (-A e) alpha.
static Distrib disabled_dist()
Definition lang_types.h:857
static T ph_moment(const std::vector< T > &alpha, const Matrix< T > &A, unsigned k)
The k-th raw moment of a phase-type (alpha, A): k!
static Distrib immediate()
The Immediate singleton.
Definition lang_types.h:846
static Distrib exp_mean(const T &m)
Definition lang_types.h:799
static constexpr double Immediate
Rate of an Immediate distribution; its mean is 1/Immediate = 1e-8.
Definition lang_types.h:674
static constexpr double FineTol
Definition lang_types.h:668
static constexpr double Zero
Definition lang_types.h:670
static constexpr double CoarseTol
Definition lang_types.h:669
Per-layer results of one iteration, the [QN,UN,RN,TN,AN,WN] of getAvg.
Definition solver_ln.h:345
A CDF sampled on a grid, the [F, t] pair MATLAB's evalCDF returns.
Definition solver_ln.h:366
bool empty() const
Definition solver_ln.h:368
std::vector< double > t
Definition solver_ln.h:367
std::vector< double > cdf
Definition solver_ln.h:367
LayeredNetwork.layerBlocks: where each layer's block sits in the aggregate.
Definition solver_ln.h:399
std::vector< std::size_t > msz
Definition solver_ln.h:400
std::vector< std::size_t > roff
Definition solver_ln.h:400
std::vector< std::size_t > coff
Definition solver_ln.h:400
std::vector< std::size_t > ksz
Definition solver_ln.h:400
Options of SolverLN.
Definition solver_ln.h:264
std::size_t tran_points
Output points per layer trajectory, and the relaxation's own grid size.
Definition solver_ln.h:327
std::vector< double > tran_grid
An EXPLICIT output grid for the layered transient, replacing the uniform tran_points one when it is n...
Definition solver_ln.h:340
std::string relax
Definition solver_ln.h:269
fluid::FluidOptions layer_fluid
Options handed to each layer when layer_solver is fluid.
Definition solver_ln.h:290
std::string layer_solver
Which solver runs each layer: mva, nc, fluid or ssa.
Definition solver_ln.h:288
mva::MvaOptions layer
Options handed to each layer solver; SolverMVA defaults.
Definition solver_ln.h:272
std::string method
options.method, which selects WHAT is reported and not merely how:
Definition solver_ln.h:306
ssa::SsaOptions layer_ssa
Options handed to each layer when layer_solver is ssa.
Definition solver_ln.h:294
std::string ln_transient_channels
options.config.ln_transient_channels: which inter-layer coupling is injected, both,...
Definition solver_ln.h:323
std::string ln_transient
options.config.ln_transient: how the per-layer transients are coupled.
Definition solver_ln.h:313
nc::NcSolverOptions layer_nc
Options handed to each layer when layer_solver is nc.
Definition solver_ln.h:292
double timespan_end
options.timespan(2): the transient horizon; infinite means none is set.
Definition solver_ln.h:325
long ln_transient_iter_max
options.config.ln_transient_iter_max and ..._tol of the relaxation.
Definition solver_ln.h:315
getSensitivityTable of the ensemble: the layer tables under a Layer column.
Definition solver_ln.h:414
std::vector< std::string > layer_methods
Per layer, the branch that layer took; empty for a layer with no solver.
Definition solver_ln.h:421
std::vector< sens::SensTable< T > > layer_tables
Per layer, the analytic Jacobian where that layer produced one.
Definition solver_ln.h:425
std::vector< Row > rows
Definition solver_ln.h:419
std::string method
The summary label: the common branch, or "mixed" when they differ.
Definition solver_ln.h:423
The LQN-level answer, indexed by element 1..nidx.
Definition solver_ln.h:351
std::vector< T > WN
Definition solver_ln.h:352
std::vector< bool > defined_W
Definition solver_ln.h:353
std::vector< bool > defined_U
Definition solver_ln.h:353
std::vector< T > QN
Definition solver_ln.h:352
std::vector< bool > defined_R
Definition solver_ln.h:353
std::vector< T > RN
Definition solver_ln.h:352
std::vector< T > AN
Definition solver_ln.h:352
std::vector< bool > defined_Q
Definition solver_ln.h:353
bool is_bound
True when the numbers are a BOUND (method = mwba.upper / mwba.lower) rather than the fixed point.
Definition solver_ln.h:362
std::vector< bool > defined_A
Definition solver_ln.h:353
std::vector< T > UN
Definition solver_ln.h:352
std::vector< bool > defined_T
Definition solver_ln.h:353
std::vector< T > TN
Definition solver_ln.h:352
options.config.stochiter_* of SolverOptions.m, with its defaults.
Definition solver_ln.h:444
double a0
Robbins-Monro step immediately after burn-in.
Definition solver_ln.h:446
long conseq
consecutive sub-tolerance iterations required to stop
Definition solver_ln.h:448
long burnin
Picard iterations before the step decay starts.
Definition solver_ln.h:445
double alpha
step decay exponent, in (0.5, 1]
Definition solver_ln.h:447
double relax_burnin
Relaxation in force during burn-in, i.e.
Definition solver_ln.h:451
One layer's block of the layered transient.
Definition solver_ln.h:379
std::vector< std::vector< std::vector< double > > > QN
[station][class][point]
Definition solver_ln.h:382
std::vector< double > t
the output grid, shared by every series below
Definition solver_ln.h:380
std::vector< std::vector< std::vector< double > > > UN
Definition solver_ln.h:382
std::vector< std::vector< std::vector< double > > > TN
Definition solver_ln.h:382
The layered transient: one block per layer, plus how it was produced.
Definition solver_ln.h:405
std::string mode
"coupled" or "decoupled"
Definition solver_ln.h:407
long iterations
waveform-relaxation sweeps; 0 when decoupled
Definition solver_ln.h:408
double gap
final sup-norm trajectory change, coupled only
Definition solver_ln.h:409
std::vector< LnTranLayer > layers
Definition solver_ln.h:406
std::vector< std::size_t > targets
absolute entry indices, in declaration order
Definition lqn_struct.h:204
std::size_t caller
absolute index of the dispatching activity
Definition lqn_struct.h:202
The options SolverMVA reads.
Definition mva_types.h:31
Class-level results, the [Q,U,R,T,C,X] of the MATLAB analyzers.
Definition mva_types.h:96
std::vector< T > X
Definition mva_types.h:98
std::vector< T > C
Definition mva_types.h:98
Controls, defaulting to SolverOptions('NC') in the reference.
Definition nc_types.h:33
The name-value contract of getSensitivityTable.
bool simulation
True when the callback is a simulator, which widens the default step.
One (station, class) row of the table.
What the table carries, plus the branch that produced it.
std::vector< SensRow< T > > rows
std::string method
"exact" or "fd", the branch actually taken
Controls, defaulting to SolverOptions('SSA') in the reference.
Definition ssa_types.h:69