LINE Solver (C++)
Templated C++ port of the LINE queueing solver
Loading...
Searching...
No Matches
solver_ssa_serial.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_SSA_SOLVER_SSA_SERIAL_H
6#define LINE_SOLVERS_SSA_SOLVER_SSA_SERIAL_H
7
8/**
9 * @file
10 * @ingroup line_solvers
11 * SolverSSA, the `serial` method: a port of `solver_ssa_reachability.m`, of the
12 * run loop of `solver_ssa.m`, and of `solver_ssa_analyzer_serial.m`.
13 *
14 * WHAT THE METHOD IS, AND HOW IT DIFFERS FROM THE NRM. The NRM rewrites the
15 * network as a reaction grid over (node, class, phase) counts and never touches
16 * the state ENCODING. The serial engine simulates the network in its own
17 * encoding instead: at every step it applies the SAME event handlers the CTMC
18 * generator applies (`after_event`, `after_global_event`) to the current network
19 * state, collects every enabled synchronization with its rate, and takes one
20 * Gillespie direct-method step. That is why it reaches models the NRM refuses --
21 * anything the encoding can express, the handlers can move -- and why it is
22 * slower: the enabled set is rebuilt from scratch at every firing, as the
23 * reference's own inlined `solver_ssa_findenabled` does.
24 *
25 * TWO ENGINES, TWO STREAMS. A seed-fixed result from the serial engine cannot be
26 * reproduced by the NRM and vice versa: they consume different draws in a
27 * different order. Neither can be reproduced by MATLAB, the JAR or native
28 * Python, for the reason `ssa_types.h` states at length. A cross-codebase check
29 * against this engine is STATISTICAL. A simulated number without its sample
30 * count and its seed is not a measurement, which is why `SsaSerialSolution`
31 * carries both and why the tests compare against the exact CTMC only inside a
32 * stated Monte Carlo band.
33 *
34 * THE JUMP CHAIN IS DRAWN IN ONE STEP, NOT TWO. MATLAB calls `State.afterEvent`
35 * with `isSimulation = true`, which SAMPLES one successor row and returns its
36 * probability; the direct method then picks among the sampled rows. The C++
37 * `after_event` is the enumeration-mode handler and returns EVERY successor with
38 * its probability, so this port flattens the (synchronization, active row,
39 * passive row) triples into one weighted list and draws from it once. The two
40 * are the same jump chain -- P(triple) = rate * p_active * p_route * p_passive,
41 * normalized -- reached with a different number of uniforms, which is exactly
42 * the stream difference above and not a modelling difference.
43 *
44 * ZERO-RATE ROWS ARE DROPPED, NOT FLOORED. The reference rewrites a zero or NaN
45 * rate to 1e-38 "so that it is never selected", which leaves it in the enabled
46 * list and in the arrival/departure rate statistics. Dropping it is the same
47 * sample path to within 1e-38 and keeps `depRates` exactly the rate the CTMC
48 * generator would accumulate for the same state, which is what makes the
49 * throughput comparable between the two solvers.
50 *
51 * THE STATE ROW DOES NOT GROW HERE, SO IT STARTS WIDE. MATLAB's simulation-mode
52 * handlers widen a buffer when a job arrives and no slot is free, and
53 * `solver_ssa_reachability` left-pads the stored spaces to match. The C++
54 * handlers derive the buffer width from the row they are handed, so a path
55 * seeded at the natural width of the EMPTY marginal would silently saturate at
56 * one waiting job per station. The initial state is therefore padded to the
57 * WIDEST row each stateful node's local encoding admits (`serial_detail::
58 * max_row_width`), which is the width `space_generator` gives every state of
59 * that node, and the path then lives inside the enumerated encoding for free.
60 *
61 * DOUBLE ONLY, for the reason `solver_ssa_nrm.h` gives: the sample path is
62 * generated from exponential clocks drawn as `-log(u)`, there is no exact value
63 * to compute, and the answer's error is the Monte Carlo error rather than the
64 * rounding. A non-`double` backend is refused BY NAME.
65 */
66
67#include <algorithm>
68#include <cmath>
69#include <cstddef>
70#include <limits>
71#include <map>
72#include <memory>
73#include <string>
74#include <type_traits>
75#include <vector>
76
78#include "line/lang/qn/fj_tag.h"
81#include "line/lang/qn/state.h"
88#include "line/util/error.h"
89#include "line/util/matrix.h"
90
91namespace line {
92namespace ssa {
93
94/**
95 * The serial engine's knobs: `SsaOptions` plus the three the serial path reads
96 * and the NRM has no use for.
97 *
98 * `cutoff` (declared on the base, see `SsaOptions::cutoff`) bounds an open class exactly as SolverCTMC's does, and for the same
99 * reason: it fixes how wide a station's buffer encoding is, so it decides where
100 * the truncation sits. A refused arrival at the truncation boundary is a LOSS
101 * (`arrival_is_lost` on an open class), which is what the CTMC truncation does
102 * too, so the two solvers truncate the same model the same way.
103 */
105 std::size_t state_max = 3000000; ///< refuse a reachable space larger than this
106};
107
108/**
109 * Port of `solver_ssa_reachability.m`'s return: `[SSq, SSh, sn.space]`.
110 *
111 * `node_space[i]` is the reference's `space{i}`, the distinct local rows node
112 * `i` was seen in; `hash` is `SSh`, one 1-BASED index into `node_space[i]` per
113 * stateful node per state; `ssq` is `SSq`, the same states with their local rows
114 * concatenated. The three are redundant by construction and the reference
115 * returns all three because its callers index states by node (`SSh`) and read
116 * them flat (`SSq`).
117 *
118 * THE ORDER IS THE WALK'S, NOT THE REFERENCE'S. The reference pushes and pops a
119 * stack of its own; this reuses `reachable_space_generator`, whose stack order
120 * differs. The SET is the same and nothing downstream indexes it positionally
121 * across codebases, so the difference is not observable in a metric.
122 */
123template <class T>
125 std::vector<qn::NetState<T>> space; ///< the reachable states
126 std::vector<std::vector<std::vector<T>>> node_space; ///< `sn.space`, per stateful node
127 std::vector<std::vector<std::size_t>> hash; ///< `SSh`, 1-based per node
128 Matrix<T> ssq; ///< `SSq`, states x concatenated width
129};
130
131/** One sample path, in the shape `solver_ssa.m` returns it. */
132template <class T>
134 /** The DISTINCT states visited, in first-visit order (the reference's `u`). */
135 std::vector<qn::NetState<T>> space;
136 /**
137 * The region token FIFOs of each of those states, the reference's `fcrBuf`.
138 *
139 * Empty (one empty vector per region, or no vectors at all) on every model
140 * without a WAITQ region. A parked job is in NO station, so it appears in no
141 * queue length and is visible only here -- the JMT report convention, which
142 * `ctmc_waitq_parked` states for the exact solver and
143 * `SsaSerialSolution::parked` for this one.
144 */
145 std::vector<std::vector<std::vector<std::size_t>>> buf;
146 /** `pi`: the fraction of simulated time spent in each of them. */
147 std::vector<double> pi;
148 /** `SSq`: the per-(station, class) job counts of each distinct state. */
150 /**
151 * `arvRates` / `depRates`, indexed [distinct state][stateful-1][class-1].
152 *
153 * They are a deterministic function of the state, so one sample per state is
154 * the exact value and not an estimate -- which is what lets the analyzer
155 * multiply them by `pi` and get a throughput rather than a sample mean. The
156 * reference says as much where it keeps `arvRatesSamples(ui(s),...)`.
157 */
158 std::vector<std::vector<std::vector<double>>> arv_rates, dep_rates;
159 /**
160 * The DERIVED rates per state, laid out like `arv_rates`: how fast the
161 * transitions enabled in that state START a class-r service at a stateful
162 * node, and how fast they PUSH a class-r job in service back into the
163 * buffer there. Annotations on the arcs the engine already walks, so no
164 * rate, probability or state depends on them.
165 */
166 std::vector<std::vector<std::vector<double>>> start_rates, preempt_rates;
167 /**
168 * The rate of the cache MERGE transitions, i.e. of the delayed hits, in the
169 * same [state][stateful-1][class-1] shape and by the same argument.
170 *
171 * A delayed hit is invisible in `dep_rates`: the merged request is released
172 * later, in the HIT class, and is indistinguishable there from a true hit.
173 * The merge itself is the only cache transition that EMPTIES the node -- it
174 * decrements the read class and adds nothing -- which is what identifies it.
175 */
176 std::vector<std::vector<std::vector<double>>> dly_rates;
177 /** `tranSysState{1}`: the cumulative time at each firing. */
178 std::vector<double> tran_time;
179 /** `tranSync`: which synchronization fired, `sync.size() + g` for a global one. */
180 std::vector<std::size_t> tran_sync;
181 /**
182 * The row of `space` the path OCCUPIED over `[t-dt, t]`, one per firing.
183 *
184 * The trace and the distinct-state table are two views of the same path and
185 * the samplers need both: `sampleSys` prints the state at each event and
186 * `getProb` sums the time spent in one state, so keeping only `pi` would
187 * lose the order and keeping only the rows would lose the aggregation. It
188 * indexes the state BEFORE the firing, exactly as `pi` weights it.
189 */
190 std::vector<std::size_t> tran_state;
191 double simulated_time = 0.0;
192 std::size_t samples = 0; ///< firings actually performed
193 std::size_t warmup = 0; ///< leading firings excluded from `pi`
194 unsigned long seed = 0; ///< the stream this path came from
195};
196
197/** The serial analyzer's return: the metric table, the path, and the stream. */
198template <class T>
200 SsaSolution avg; ///< QN, UN, RN, TN, XN, CN; `method` = "serial"
202 unsigned long seed = 0; ///< carried beside the numbers, never implied
203 std::vector<SsaCacheRatio> cache;
204 /**
205 * `fjclassmap` of the tag augmentation, empty on a model with no Fork.
206 *
207 * The PATH inside `run` is the augmented one -- its classes are the sibling
208 * classes `fj_tag` invented -- while `avg` has been folded back onto the
209 * classes the caller declared. The map is what relates the two, and it is
210 * returned rather than discarded for the reason SolverCTMC returns it: a
211 * caller reading the trajectory needs to know which class a sibling came
212 * from.
213 */
214 std::vector<std::size_t> fjclassmap;
215 /**
216 * Mean number of jobs parked in a region FIFO, per class of the struct that
217 * ran. Zero everywhere without a WAITQ region.
218 *
219 * IT IS IN NO QLen, so a population check on a closed model has to add it
220 * back by hand. That is the JMT convention `ctmc_waitq_parked` reports for
221 * the exact solver and not an omission here.
222 */
223 std::vector<double> parked;
224 /**
225 * The struct the path was simulated on when it differs from the caller's (`ctmc_mapc` widens
226 * a multiserver MAP server block into pairs), else null. The rows of `run` are in ITS layout.
227 */
228 std::shared_ptr<const qn::NetworkStruct<T>> solved_sn;
229};
230
231namespace serial_detail {
232
233using lang::NodeType;
235
236/**
237 * `solver_ssa.m`'s own guards, plus what this port cannot represent.
238 *
239 * Reneging and QUEUE_LENGTH balking are simulated: `refresh_sync` emits a RENEGE
240 * action from the station's own patience and the arrival handler applies the
241 * balking band. What the state machinery cannot serve (non-exponential patience,
242 * wait-based balking) is refused through `state_impatience_unsupported`, the
243 * CTMC's own list. Heterogeneous servers (`nodeparam.nservertypes`) have no
244 * field in the C++ `NetworkStruct`, so that guard would be dead code. The same
245 * holds for the class-switch mask violation the reference
246 * raises inside its scan: `sn.csmask` is not carried, and the state-dependent
247 * routing that can violate it is refused when the struct is built.
248 */
249template <class T>
250bool serial_check(const qn::NetworkStruct<T>& sn, bool raise = true, bool skip_fork = false) {
251 const std::string impatience = qn::state_impatience_unsupported(sn, "SolverSSA(method='serial')");
252 if (!impatience.empty()) {
253 if (!raise) return false;
254 throw UnsupportedError(ErrorKind{"Impatience"}, impatience);
255 }
256 // A WAITQ region parks refused jobs in a FIFO the engine carries beside the
257 // network state, and the combinations that FIFO has no meaning against are
258 // the CTMC's own: one list, so the two solvers refuse the same models with
259 // the same sentence and neither can silently accept what the other rejects.
260 if (!sn.regions.empty()) {
261 // The rule decides which machinery runs, so the gate is the matching
262 // one: `ctmc_check_waitq_support` for the token FIFO, and the DROP-only
263 // checker otherwise, which is what refuses BAS/BBS/RSRD by name.
264 try {
267 else
269 } catch (const UnsupportedError&) {
270 if (!raise) return false;
271 throw;
272 }
273 }
274 // A Fork fires through `fjsync`, which only the TAG-AUGMENTED struct carries
275 // (`fj_tag`). A raw fork-join struct reaching the engine would find no
276 // firing at all: the fork would never emit, the branches would stay empty
277 // and the path would report a network that transparently swallows every
278 // forked task. The analyzer augments before it runs, so this refusal is
279 // reachable only by a caller who drove the engine directly.
280 if (!skip_fork && sn.has_fork() && !sn.isfjaugmented) {
281 if (!raise) return false;
282 throw UnsupportedError(ErrorKind{"Fork"},
283 "SolverSSA(method='serial'): the model contains a Fork node and has not been "
284 "tag-augmented. A fork fires through `sn.fjsync` / `State.afterFJEvent`, which only "
285 "`fj_tag` builds; call `solver_ssa_serial_analyzer`, which augments and folds the "
286 "sibling classes back, rather than the engine directly");
287 }
288 for (std::size_t i = 0; i < sn.nstations; ++i) {
289 const typename std::map<std::size_t, qn::RetrialParam<T>>::const_iterator rit =
290 sn.retrialparam.find(i + 1);
291 if (rit == sn.retrialparam.end()) continue;
292 bool any = false;
293 std::size_t served = 0;
294 for (std::size_t r = 0; r < rit->second.retrial_proc.size(); ++r) {
295 if (rit->second.retrial_proc[r].disabled) continue;
296 any = true;
297 if (rit->second.retrial_proc[r].type != lang::ProcessType::EXP) {
298 if (!raise) return false;
299 throw UnsupportedError(ErrorKind{"Retrial"},
300 "SolverSSA(method='serial'): station '" + sn.stations[i].name +
301 "' retries with non-exponential patience. SOLVER_SSA supports only "
302 "exponential (memoryless) retrial delay in every codebase, because the orbit "
303 "carries no remaining-delay phase");
304 }
305 if (r < rit->second.max_attempts.size() && rit->second.max_attempts[r] > 0) {
306 if (!raise) return false;
307 throw UnsupportedError(ErrorKind{"Retrial"},
308 "SolverSSA(method='serial'): station '" + sn.stations[i].name +
309 "' declares a finite retrial max-attempts count. SOLVER_SSA supports only "
310 "unlimited retrials in every codebase, because the attempt counter is not "
311 "part of the state");
312 }
313 if (r < sn.stations[i].orbit_impatience.size() &&
314 !sn.stations[i].orbit_impatience[r].disabled) {
315 if (!raise) return false;
316 throw UnsupportedError(ErrorKind{"Retrial"},
317 "SolverSSA(method='serial'): station '" + sn.stations[i].name +
318 "' declares abandonment from the retrial orbit. SOLVER_SSA does not model "
319 "it in any codebase; use SolverMAM or SolverLDES");
320 }
321 }
322 if (!any) continue;
323 for (std::size_t r = 0; r < sn.nclasses; ++r)
324 if (!sn.disabled[i][r] && sn.service[i][r].D0.rows() > 0) ++served;
325 if (served > 1) {
326 if (!raise) return false;
327 throw UnsupportedError(ErrorKind{"Retrial"},
328 "SolverSSA(method='serial'): station '" + sn.stations[i].name +
329 "' is a multi-class retrial station. SOLVER_SSA supports retrial only for "
330 "single-class stations in every codebase");
331 }
332 }
333 return true;
334}
335
336/**
337 * Time-averaged servers held for a pending REPLY signal, per (station, calling class).
338 *
339 * Port of `ssa_reply_held.m`. A job of a class with a synchronous call leaves its caller
340 * but keeps the server there until the reply returns; the hold lives in the reply block
341 * that trails the caller's local row (`reply_block_info`), where `to_marginal` does not
342 * see it. All zero for a model without synchronous calls.
343 */
344template <class T>
345Matrix<double> reply_held(const qn::NetworkStruct<T>& sn, const SsaSerialRun<T>& r) {
346 const std::size_t M = sn.nstations, K = sn.nclasses;
347 Matrix<double> held(M, K, 0.0);
348 if (sn.replyblock.empty()) return held;
349 for (std::size_t ist = 1; ist <= M; ++ist) {
350 const std::size_t ind = sn.node_of_station(ist);
351 const std::size_t isf = sn.stateful_of_station(ist);
352 if (isf == 0 || sn.replyblock.size() < ind) continue;
353 bool any = false;
354 for (std::size_t k = 0; k < sn.replyblock[ind - 1].size(); ++k)
355 any = any || sn.replyblock[ind - 1][k];
356 if (!any) continue;
357 const qn::ReplyBlockInfo info = qn::reply_block_info(sn, ind);
358 if (info.width == 0) continue;
359 for (std::size_t s = 0; s < r.space.size() && s < r.pi.size(); ++s) {
360 if (r.pi[s] == 0) continue;
361 const std::vector<T>& row = r.space[s].local[isf - 1];
362 // the block trails the row; a buffer grows on the left only
363 for (std::size_t pos = 0; pos < info.classes.size(); ++pos) {
364 const std::size_t col = row.size() - info.width + pos;
365 held(ist - 1, info.classes[pos] - 1) += r.pi[s] * num_traits<T>::to_double(row[col]);
366 }
367 }
368 }
369 return held;
370}
371
372/**
373 * Can `solver_ssa_serial_analyzer` run this model at all?
374 *
375 * The dispatcher's fallback test, and it is the SAME body as the refusal above
376 * so the two cannot drift. The fork guard is skipped because the analyzer
377 * augments before the engine sees the struct, so a raw fork-join model IS one
378 * the serial path runs -- answering otherwise would send it back to the NRM,
379 * which excludes fork-join in every codebase.
380 */
381template <class T>
382bool serial_can_run(const qn::NetworkStruct<T>& sn) {
383 return serial_check(sn, false, true);
384}
385
386/**
387 * The widest local row stateful node `ind` admits, which is the width
388 * `space_generator` gives EVERY state of that node.
389 *
390 * WHY IT IS COMPUTED RATHER THAN ENUMERATED. Taking the width from
391 * `space_generator` would mean building the whole cartesian product across
392 * nodes, which is the cost simulation exists to avoid. The width of one node's
393 * row is a property of that node alone: for an ordered (class-tag) buffer it
394 * grows with the TOTAL jobs held and not with how they split across classes, and
395 * for every other encoding it is constant. So one call to `from_marginal_node`
396 * at a maximal admissible marginal settles it, and the split is chosen to fill
397 * the classes in order precisely because a lopsided multiset has the fewest
398 * permutations for `from_marginal` to enumerate.
399 *
400 * The descent to a smaller total is not defensive padding: `from_marginal_node`
401 * returns NO rows for a marginal the station cannot hold, and the joint bound
402 * (`cap` against the sum of the per-class bounds) can be met by a marginal that
403 * some other constraint inside the handler still rejects.
404 */
405template <class T>
406std::size_t max_row_width(const qn::NetworkStruct<T>& sn, std::size_t ind,
407 const std::vector<std::size_t>& cutoff) {
408 const std::size_t R = sn.nclasses;
409 const std::size_t ist = sn.nodes[ind - 1].station;
410 std::vector<std::size_t> ph(R, 1);
411 if (ist != 0)
412 for (std::size_t r = 0; r < R; ++r) ph[r] = sn.phasessz_of(ist, r + 1);
413
414 // A stateful non-station (a Cache, a Join, a Transition) holds a per-class
415 // count and its local variables, both of fixed width, so the empty marginal
416 // already gives the final width.
417 if (ist == 0 || sn.stations[ist - 1].nodetype == NodeType::Source) {
418 std::vector<T> row;
419 if (!qn::from_marginal_node_first(sn, ind, std::vector<std::size_t>(R, 0), ph, row))
420 throw UnsupportedError(ErrorKind{"Model"}, "SolverSSA(method='serial'): node '" + sn.nodes[ind - 1].name +
421 "' admits no state at all, so no sample path can start");
422 return row.size();
423 }
424
425 // The per-class bound: the class population when closed, the cutoff when
426 // open, never above the station's own per-class capacity.
427 std::vector<std::size_t> bound(R, 0);
428 for (std::size_t r = 0; r < R; ++r) {
429 const double nj = sn.njobs()[r];
430 double b = std::isfinite(nj) ? nj : static_cast<double>(cutoff[r]);
431 const double cc = sn.classcap[ist - 1][r];
432 if (cc < b) b = cc;
433 if (!(b > 0)) b = 0;
434 bound[r] = static_cast<std::size_t>(b);
435 }
436 double tcapd = sn.cap[ist - 1];
437 std::size_t total = 0;
438 for (std::size_t r = 0; r < R; ++r) total += bound[r];
439 if (std::isfinite(tcapd) && tcapd < static_cast<double>(total))
440 total = static_cast<std::size_t>(tcapd);
441
442 // A PAS / OI station is the one encoding `from_marginal_node` cannot size:
443 // its row is the ORDERED JOB LIST, one position per job, and the function
444 // builds the ordinary [buffer | server] split instead -- which for a
445 // single-server station returns a ONE-COLUMN row however many jobs the
446 // marginal holds. A path seeded at that width would hold one job and block
447 // every further arrival, so the width is taken from the job bound directly.
448 if (sn.stations[ist - 1].sched == SchedStrategy::PAS ||
449 sn.stations[ist - 1].sched == SchedStrategy::OI)
450 return total + sn.nvars_of(ind);
451
452 for (std::size_t t = total + 1; t-- > 0;) {
453 std::vector<std::size_t> n(R, 0);
454 std::size_t left = t;
455 for (std::size_t r = 0; r < R && left > 0; ++r) {
456 n[r] = std::min(left, bound[r]);
457 left -= n[r];
458 }
459 if (left > 0) continue; // this total does not fit the per-class bounds
460 const std::vector<std::vector<T>> rows = qn::from_marginal_node(sn, ind, n, ph);
461 if (!rows.empty()) return rows[0].size();
462 }
463 throw UnsupportedError(ErrorKind{"Model"}, "SolverSSA(method='serial'): station '" + sn.stations[ist - 1].name +
464 "' admits no state at all, so no sample path can start");
465}
466
467/**
468 * The initial network state, padded to the encoding width of every node.
469 *
470 * The marginal is `Network.initDefault`'s -- every closed class's jobs at its
471 * reference station -- taken from the CTMC analyzer so the two solvers start the
472 * same model in the same state. The LEFT pad is not a convention chosen here: it
473 * is what `space_generator` does to the narrow rows, and every slicer in
474 * `state_events.h` measures its blocks from the RIGHT-hand end, so a right pad
475 * would shift the server block and silently decode the wrong queue.
476 */
477template <class T>
478qn::NetState<T> wide_init_state(const qn::NetworkStruct<T>& sn,
479 const std::vector<std::size_t>& cutoff) {
480 qn::NetState<T> init;
481 if (!ctmc::analyzer_detail::default_init_state(sn, init))
482 throw UnsupportedError(ErrorKind{"Model"},
483 "SolverSSA(method='serial'): the model's initial state admits no state; check the "
484 "class populations against their reference stations");
485 for (std::size_t f = 0; f < sn.stateful_nodes.size(); ++f) {
486 const std::size_t w = max_row_width(sn, sn.stateful_nodes[f], cutoff);
487 if (init.local[f].size() > w) continue; // already at or beyond the encoding width
488 init.local[f].insert(init.local[f].begin(), w - init.local[f].size(),
489 num_traits<T>::from_int(0));
490 }
491 return init;
492}
493
494} // namespace serial_detail
495
496/**
497 * Port of `solver_ssa_reachability.m`: the states the DYNAMICS can occupy,
498 * decomposed per stateful node.
499 *
500 * The walk itself is `reachable_space_generator`, which applies the same
501 * handlers to the same synchronization list and additionally walks `gsync`. The
502 * reference's reachability walks only `sync`, so an SPN's firing states reach it
503 * through `solver_ssa`'s own global scan instead of through this function; here
504 * they are in the space from the start, which is strictly more of the reachable
505 * set and never less.
506 *
507 * WHAT THIS ADDS OVER THE WALK is the decomposition the reference returns and
508 * the CTMC does not need: the per-node list of distinct local rows, and the
509 * per-state index into it. That is the reference's `space{i}` / `SSh` pair, and
510 * it exists so a caller can address a state by node without carrying the rows.
511 */
512template <class T>
515 serial_detail::serial_check(sn);
517 copt.cutoff = opt.cutoff;
518 copt.state_max = opt.state_max;
519 const std::vector<std::size_t> cutoff = ctmc::analyzer_detail::resolve_cutoff(sn, copt);
520 const std::vector<qn::Sync<T>> sync = qn::refresh_sync(sn);
521 const std::vector<qn::GlobalSync<T>> gsync = qn::refresh_global_sync(sn);
522 const qn::NetState<T> init = serial_detail::wide_init_state(sn, cutoff);
523
525 // A WAITQ region's states are AUGMENTED with the token FIFO, which a
526 // `NetState` cannot hold, so this decomposition has no row to report them
527 // in and refuses rather than returning the region-free walk under the
528 // region's name. The metrics do not need it: the engine carries the FIFO
529 // itself and `SsaSerialRun::buf` reports it.
531 throw UnsupportedError(ErrorKind{"Region"},
532 "solver_ssa_reachability: the model declares a WAITQ finite capacity region, whose "
533 "states carry a per-region token FIFO outside every node; this decomposition is "
534 "per-node and cannot represent it. Use `solver_ssa_serial_analyzer`, whose run carries "
535 "the FIFO, or `solver_ctmc_waitq` for the exact augmented space");
536 // The cutoff resolved above bounds the walk, as it bounds SolverCTMC's:
537 // without it an open model's Source produces forever and the walk runs to
538 // `state_max` rather than answering.
539 out.space = ctmc::reachable_space_generator(sn, init, sync, gsync, opt.state_max,
540 std::vector<qn::FjSync<T>>(), cutoff);
541 // A DROP region censors the space exactly as it censors SolverCTMC's: the
542 // forbidden states are never occupied, so leaving them in would report a
543 // reachable set the dynamics cannot reach.
545
546 const std::size_t NF = sn.stateful_nodes.size();
547 out.node_space.assign(NF, std::vector<std::vector<T>>());
548 out.hash.assign(out.space.size(), std::vector<std::size_t>(NF, 0));
549 // One map per node, keyed on the row itself: the reference's `matchrow`,
550 // which is a linear scan and turns the decomposition quadratic on a space
551 // large enough to be worth walking.
552 std::vector<std::map<std::vector<double>, std::size_t>> seen(NF);
553 for (std::size_t s = 0; s < out.space.size(); ++s)
554 for (std::size_t f = 0; f < NF; ++f) {
555 std::vector<double> key(out.space[s].local[f].size(), 0.0);
556 for (std::size_t j = 0; j < key.size(); ++j)
557 key[j] = num_traits<T>::to_double(out.space[s].local[f][j]);
558 const typename std::map<std::vector<double>, std::size_t>::const_iterator it =
559 seen[f].find(key);
560 if (it != seen[f].end()) {
561 out.hash[s][f] = it->second;
562 } else {
563 out.node_space[f].push_back(out.space[s].local[f]);
564 seen[f][key] = out.node_space[f].size();
565 out.hash[s][f] = out.node_space[f].size();
566 }
567 }
568
569 std::size_t width = 0;
570 for (std::size_t f = 0; f < NF; ++f)
571 width += out.space.empty() ? 0 : out.space[0].local[f].size();
572 out.ssq = Matrix<T>(out.space.size(), width, num_traits<T>::from_int(0));
573 for (std::size_t s = 0; s < out.space.size(); ++s) {
574 std::size_t col = 0;
575 for (std::size_t f = 0; f < NF; ++f)
576 for (std::size_t j = 0; j < out.space[s].local[f].size(); ++j)
577 out.ssq(s, col++) = out.space[s].local[f][j];
578 }
579 return out;
580}
581
582/**
583 * The serial engine: the sample path of `solver_ssa.m`'s main loop.
584 *
585 * It is a class for the reason `NrmEngine` is: the loop threads the current
586 * state, the visited-state table, the two rate tables and the trace through
587 * every step, and the reference threads the same through one long function.
588 */
589template <class T>
591public:
592 /**
593 * `fjsync` is the fork firing list of the TAG-AUGMENTED struct, empty for a
594 * model with no Fork. It is passed in rather than derived because the
595 * augmentation produces the struct and the firing list together and `sn`
596 * must be the augmented one: deriving it here would leave the two able to
597 * disagree about which classes the branches carry.
598 */
600 const std::vector<qn::FjSync<T>>& fjsync = std::vector<qn::FjSync<T>>())
601 : sn_(sn), opt_(opt), rng_(opt.seed), fjsync_(fjsync) {
602 serial_detail::serial_check(sn);
604 copt.cutoff = opt.cutoff;
605 copt.state_max = opt.state_max;
606 const std::vector<std::size_t> cutoff = ctmc::analyzer_detail::resolve_cutoff(sn, copt);
607 sync_ = qn::refresh_sync(sn);
608 gsync_ = qn::refresh_global_sync(sn);
609 sdr_ = sn.has_rtfun_routing();
610 // WHICH REGION MACHINERY, decided exactly as SolverCTMC decides it: a
611 // model whose every region class applies DROP is CENSORED, because a
612 // refused job is destroyed and the chain simply never occupies the
613 // forbidden states; one WAITQ class anywhere puts the whole model on the
614 // token-FIFO relation, which handles its DROP classes inline. Choosing
615 // differently from the CTMC here would make the simulator and the exact
616 // solver answer different models under one model file.
618 if (!sn.regions.empty()) {
619 if (waitq_) {
620 caps_ = ctmc::waitq_detail::extract_caps(sn);
621 ctmc::waitq_detail::resolve_lmax(sn, cutoff, caps_);
622 } // the DROP-only rule set is checked by `serial_check` above
623 }
624 init_.net = serial_detail::wide_init_state(sn, cutoff);
625 init_.buf.assign(caps_.size(), std::vector<std::size_t>());
626 if (!sn.regions.empty() && !region_admissible(init_.net))
627 throw InputError(
628 "SolverSSA(method='serial'): the model's initial state violates a finite capacity "
629 "region; the region cannot hold the model's initial population, so no sample path "
630 "can start");
631 }
632
633 /** Run `opt.samples` firings and return the path with its statistics. */
635
636 /** The synchronization list the trace's `tran_sync` indexes. */
637 const std::vector<qn::Sync<T>>& sync() const { return sync_; }
638 /** The state the path starts from, at full encoding width. */
639 const qn::NetState<T>& init_state() const { return init_.net; }
640
641private:
642 /** One enabled transition: where it goes and what it contributes. */
643 struct Move {
644 std::size_t sync = 0; ///< index into `sync_`, or `sync_.size() + g`
645 double weight = 0.0; ///< rate * p_active * p_route * p_passive
647 };
648
649 const qn::NetworkStruct<T>& sn_;
650 SsaSerialOptions opt_;
651 SsaRng rng_;
652 std::vector<qn::Sync<T>> sync_;
653 std::vector<qn::GlobalSync<T>> gsync_;
654 std::vector<qn::FjSync<T>> fjsync_;
655 std::vector<ctmc::waitq_detail::RegionCaps<T>> caps_;
656 bool waitq_ = false;
657 /** The routing must be re-evaluated at every state visited (Krzesinski SDR). */
658 bool sdr_ = false;
660
661 /** `ctmc_region_admissible` on one network state, the DROP censoring test. */
662 bool region_admissible(const qn::NetState<T>& st) const {
663 if (sn_.regions.empty()) return true;
664 std::vector<qn::NetState<T>> one(1, st);
665 const Matrix<T> A = ctmc::ctmc_state_space_aggr(sn_, one);
666 std::vector<T> nir(A.cols());
667 for (std::size_t c = 0; c < A.cols(); ++c) nir[c] = A(0, c);
668 return ctmc::ctmc_region_admissible(sn_, nir);
669 }
670
671 /**
672 * The reference's inlined `solver_ssa_findenabled`: every synchronization
673 * that can fire in `st`, with the arrival and departure rates it carries.
674 */
675 void enabled(const ctmc::WaitqState<T>& st, std::vector<Move>& moves,
676 std::vector<std::vector<double>>& arv, std::vector<std::vector<double>>& dep,
677 std::vector<std::vector<double>>& dly,
678 std::vector<std::vector<double>>& start,
679 std::vector<std::vector<double>>& preempt) const;
680
681 /**
682 * `phi(n)` on the CURRENT sample-path state, as an (nstations*nclasses)
683 * row-major vector of scalings. The twin of `ctmc_gd_factor`'s single row.
684 */
685 std::vector<T> gd_factor_now(const qn::NetState<T>& ns) const {
686 const std::size_t M = sn_.stations.size(), K = sn_.nclasses;
687 const T zero = num_traits<T>::from_int(0);
688 std::vector<T> npop(M * K, zero);
689 for (std::size_t ist = 1; ist <= M; ++ist) {
690 const std::size_t isf = sn_.stateful_of_station(ist);
691 if (isf == 0) continue;
692 if (sn_.stations[ist - 1].nodetype == lang::NodeType::Source) continue;
693 const std::size_t ind = sn_.node_of_station(ist);
694 std::vector<std::size_t> ph(K, 1), shift(K, 0);
695 std::size_t w = 0;
696 for (std::size_t k = 0; k < K; ++k) {
697 ph[k] = sn_.phasessz_of(ist, k + 1);
698 shift[k] = w;
699 w += ph[k];
700 }
701 const qn::Marginal<T> m =
702 qn::to_marginal(sn_, ist, ns.local[isf - 1], ph, shift, sn_.nvars_of(ind));
703 for (std::size_t k = 0; k < K; ++k) npop[(ist - 1) * K + k] = m.nir[k];
704 }
705 const std::vector<T> v = sn_.gdscaling(npop);
706 std::vector<T> out(M * K, num_traits<T>::from_int(1));
707 for (std::size_t i = 0; i < M; ++i)
708 for (std::size_t r = 0; r < K; ++r) {
709 const T f = v.size() == 1 ? v[0] : (v.size() == M ? v[i] : v[i * K + r]);
710 if (!(num_traits<T>::to_double(f) >= 0))
711 throw InputError(
712 "the global dependence handle returned a non-finite or negative scaling");
713 out[i * K + r] = f;
714 }
715 return out;
716 }
717};
718
719template <class T>
720void SsaSerialEngine<T>::enabled(const ctmc::WaitqState<T>& st, std::vector<Move>& moves,
721 std::vector<std::vector<double>>& arv,
722 std::vector<std::vector<double>>& dep,
723 std::vector<std::vector<double>>& dly,
724 std::vector<std::vector<double>>& start,
725 std::vector<std::vector<double>>& preempt) const {
726 const std::size_t local = sn_.nodes.size() + 1; // the dummy passive node
727 const std::size_t R = sn_.nclasses;
728 moves.clear();
729 for (std::size_t f = 0; f < arv.size(); ++f)
730 for (std::size_t r = 0; r < R; ++r) {
731 arv[f][r] = 0.0;
732 dep[f][r] = 0.0;
733 dly[f][r] = 0.0;
734 start[f][r] = 0.0;
735 preempt[f][r] = 0.0;
736 }
737
738 // A WAITQ REGION REPLACES THE WHOLE ENUMERATION rather than filtering it.
739 // The token FIFO is state the network encoding cannot hold, a refused job
740 // parks instead of being lost, and every firing runs a release cascade to a
741 // fixed point, so there is no per-move filter that turns the ordinary
742 // relation into this one. `waitq_successors` IS that relation, shared with
743 // the CTMC generator. Its own support gate has already refused an SPN and a
744 // fork-join model beside a WAITQ region, so no global or fork firing can
745 // reach here on this branch.
746 if (waitq_) {
747 std::vector<ctmc::waitq_detail::Successor<T>> succ;
748 ctmc::waitq_detail::waitq_successors(sn_, sync_, caps_, st, succ);
749 for (std::size_t i = 0; i < succ.size(); ++i) {
750 const ctmc::waitq_detail::Successor<T>& su = succ[i];
751 const double w = num_traits<T>::to_double(su.w);
752 if (!(w > 0)) continue;
753 Move m;
754 m.sync = su.sync;
755 m.weight = w;
756 m.next = su.next;
757 moves.push_back(m);
758 if (su.dep_isf != 0) dep[su.dep_isf - 1][su.dep_cls - 1] += w;
759 for (std::size_t q = 0; q < su.arv.size(); ++q)
760 arv[su.arv[q].first - 1][su.arv[q].second - 1] += w;
761 }
762 return;
763 }
764
765 const qn::NetState<T>& base = st.net;
766 // Global (Whittle) rate scaling declared through `set_global_dependence`. It
767 // reads the FULL population matrix, so it is a CONSTANT within one state and
768 // factors out of the per-transition rates, exactly as in `solver_ctmc`. The
769 // CTMC tabulates it once per state of the enumerated space; a simulator has
770 // one state at a time, so the table collapses to this single row.
771 std::vector<T> gd_now;
772 const bool has_gd = static_cast<bool>(sn_.gdscaling);
773 if (has_gd) gd_now = gd_factor_now(base);
774 // The state-dependent routing table, for the same reason and at the same
775 // scope: one evaluation of eq. (10) per state, not one per synchronization.
776 // The CTMC tabulates it over the enumerated space; a sample path holds one
777 // state at a time, so the table collapses to this single matrix.
778 Matrix<T> rt_now;
779 if (sdr_) rt_now = qn::rt_state(sn_, base.local);
780 for (std::size_t a = 0; a < sync_.size(); ++a) {
781 const qn::Sync<T>& sy = sync_[a];
782 const std::size_t isf_a = sn_.stateful_index(sy.active.node);
783 if (isf_a == 0) continue; // a stateless node schedules nothing
784 const std::size_t isf_p =
785 sy.passive.node == local ? 0 : sn_.stateful_index(sy.passive.node);
786 if (sy.passive.node != local && isf_p == 0) continue;
787
788 // Immediate feedback (sn.immfeed): the departure half of a self-loop must
789 // not promote a waiting job, so the fed-back arrival seizes the server it
790 // just left. See qn::immfeed_self_loop; this is the reference's
791 // solver_ssa.m immfeed_selfloop.
792 const qn::EventOutcome<T> oa =
793 qn::after_event(sn_, sy.active.node, base.local[isf_a - 1], sy.active.event,
794 sy.active.cls, qn::immfeed_self_loop(sn_, sy));
795 // PHASE is scaled too, or phase-type service would advance unscaled
796 const bool gd_here = has_gd && sn_.nodes[sy.active.node - 1].station != 0 &&
797 (sy.active.event == lang::EventType::DEP ||
798 sy.active.event == lang::EventType::PHASE);
799 const double gd_f =
800 gd_here ? num_traits<T>::to_double(
801 gd_now[(sn_.nodes[sy.active.node - 1].station - 1) * R +
802 (sy.active.cls - 1)])
803 : 1.0;
804 // A cache READ whose successor holds one job FEWER is the delayed-hit
805 // merge: the request joined an in-flight fetch and is held in block B, so
806 // it leaves no departure to count and is released later in the hit class.
807 // Every other cache READ keeps the server block flat.
808 const bool cache_read = sy.active.event == lang::EventType::READ &&
809 sn_.nodes[sy.active.node - 1].nodetype == lang::NodeType::Cache;
810 double srv_pre = 0.0;
811 if (cache_read)
812 for (std::size_t r = 0; r < R && r < base.local[isf_a - 1].size(); ++r)
813 srv_pre += num_traits<T>::to_double(base.local[isf_a - 1][r]);
814
815 double fired = 0.0; // what this synchronization contributes from here
816 for (std::size_t ia = 0; ia < oa.space.size(); ++ia) {
817 const double rate = num_traits<T>::to_double(oa.rate[ia]) * gd_f;
818 const double pa = num_traits<T>::to_double(oa.prob[ia]);
819 if (!(rate > 0) || !(pa > 0)) continue;
820 bool merged = false;
821 if (cache_read) {
822 double srv_post = 0.0;
823 for (std::size_t r = 0; r < R && r < oa.space[ia].size(); ++r)
824 srv_post += num_traits<T>::to_double(oa.space[ia][r]);
825 merged = srv_post - srv_pre == -1.0;
826 }
827
828 if (sy.passive.node == local) {
829 Move m;
830 m.sync = a;
831 m.weight = rate * pa;
832 m.next = st;
833 m.next.net.local[isf_a - 1] = oa.space[ia];
834 // A DROP region CENSORS the chain: a transition into a state the
835 // region forbids is not taken at all, which is what deleting the
836 // state from the CTMC's space and re-closing its rows amounts
837 // to. The move is dropped whole, so it contributes neither a
838 // departure nor an arrival, exactly as the deleted column does.
839 if (!region_admissible(m.next.net)) continue;
840 fired += m.weight;
841 if (merged) dly[isf_a - 1][sy.active.cls - 1] += m.weight;
842 // The START/PREEMPT tags of this arc, weighted like the rate it
843 // carries: they annotate the transition itself. Written for
844 // EVERY action, not only for departures -- a retrial or a
845 // polling switchover starts service without being a DEP.
846 ssa_detail::add_tag_rates(start, preempt, sn_, sy.active.node, oa, ia, m.weight);
847 moves.push_back(m);
848 continue;
849 }
850 // A self-loop synchronization reads the passive node's state AFTER
851 // the active half has been applied, since they are the same node.
852 const std::vector<T>& src =
853 sy.passive.node == sy.active.node ? oa.space[ia] : base.local[isf_p - 1];
854 const qn::EventOutcome<T> op = qn::after_event(sn_, sy.passive.node, src,
855 sy.passive.event, sy.passive.cls);
856 // NO ROWS is a BLOCK, not a loss: the destination has no room and
857 // cannot take the job, so the upstream departure is disabled and the
858 // synchronization simply does not appear in the enabled list. This
859 // is the reference's `prob_sync_p = 0`.
860 // The routing probability, read at the state the job LEAVES from,
861 // which is what `sub_sdr` reads.
862 double proute =
863 sy.passive.statedep
864 ? num_traits<T>::to_double(rt_now(sy.passive.rt_row, sy.passive.rt_col))
865 : num_traits<T>::to_double(sy.passive.prob);
866 // ROUND-ROBIN reads the pointer the ACTIVE node carries once its own
867 // departure has advanced it, exactly as the CTMC generator does; the
868 // uniform expansion in `sy.passive.prob` would make the dispatcher a
869 // coin. The pointer lives in the state, so the serial engine needs
870 // no cursor of its own -- unlike the NRM, which walks the arcs.
871 if (sy.active.event == lang::EventType::DEP &&
872 sn_.rr_var_slot(sy.active.node, sy.active.cls) != 0) {
873 const std::size_t w = sn_.nvars_of(sy.active.node);
874 const std::vector<T>& arow = oa.space[ia];
875 std::size_t dest = 0;
876 if (arow.size() >= w) {
877 const std::vector<T> var(arow.end() - w, arow.end());
878 dest = sn_.rr_dest(sy.active.node, sy.active.cls, var);
879 }
880 proute = (dest == sy.passive.node && sy.passive.cls == sy.active.cls) ? 1.0 : 0.0;
881 }
882 for (std::size_t ip = 0; ip < op.space.size(); ++ip) {
883 const double pp = num_traits<T>::to_double(op.prob[ip]);
884 if (!(pp > 0)) continue;
885 Move m;
886 m.sync = a;
887 m.weight = rate * pa * proute * pp;
888 if (!(m.weight > 0)) continue;
889 m.next = st;
890 m.next.net.local[isf_a - 1] = oa.space[ia];
891 m.next.net.local[isf_p - 1] = op.space[ip];
892 if (!region_admissible(m.next.net)) continue;
893 fired += m.weight;
894 if (merged) dly[isf_a - 1][sy.active.cls - 1] += m.weight;
895 // both halves are tagged: the arrival half is where most
896 // service starts happen
897 ssa_detail::add_tag_rates(start, preempt, sn_, sy.active.node, oa, ia, m.weight);
898 ssa_detail::add_tag_rates(start, preempt, sn_, sy.passive.node, op, ip, m.weight);
899 moves.push_back(m);
900 }
901 }
902 // A DEP synchronization is one job LEAVING the active node and ENTERING
903 // the passive one, so the same rate is a departure there and an arrival
904 // here. The passive half of a LOCAL action is the dummy node, which is
905 // nobody's arrival.
906 if (sy.active.event == lang::EventType::DEP && fired > 0) {
907 dep[isf_a - 1][sy.active.cls - 1] += fired;
908 if (isf_p != 0) arv[isf_p - 1][sy.passive.cls - 1] += fired;
909 }
910 }
911
912 for (std::size_t g = 0; g < gsync_.size(); ++g) {
913 const qn::GlobalOutcome<T> go = qn::after_global_event(sn_, base, gsync_[g]);
914 for (std::size_t io = 0; io < go.space.size(); ++io) {
915 const double w = num_traits<T>::to_double(go.rate[io]) *
916 num_traits<T>::to_double(go.prob[io]);
917 if (!(w > 0)) continue;
918 Move m;
919 m.sync = sync_.size() + g;
920 m.weight = w;
921 m.next = st;
922 m.next.net = go.space[io];
923 if (!region_admissible(m.next.net)) continue;
924 moves.push_back(m);
925 // A FIRE consumes from its PRE places and produces to its POST
926 // places, which is a departure and an arrival respectively; an
927 // ENABLE only reads the markings and moves nothing.
928 if (gsync_[g].active.event != lang::EventType::FIRE) continue;
929 for (std::size_t j = 0; j < gsync_[g].passive.size(); ++j) {
930 const qn::ModeEvent<T>& pev = gsync_[g].passive[j];
931 const std::size_t pisf = sn_.stateful_index(pev.node);
932 if (pisf == 0 || pev.cls == 0 || pev.cls > R) continue;
933 if (pev.event == lang::EventType::PRE) dep[pisf - 1][pev.cls - 1] += w;
934 else if (pev.event == lang::EventType::POST) arv[pisf - 1][pev.cls - 1] += w;
935 }
936 }
937 }
938
939 // FORK FIRINGS, atomic across the fork and every branch head, so like an SPN
940 // firing they take the whole network state and cannot be decomposed into
941 // sync halves. `refresh_sync` emits no DEP for a Fork, which is why nothing
942 // above has already counted them.
943 //
944 // ONE DEPARTURE, B ARRIVALS. The parent leaves the fork in its own class and
945 // one sibling enters each branch head in the tag's auxiliary class, so the
946 // rate statistics are accumulated by hand exactly as `solver_ctmc` does:
947 // an ordinary synchronization has no way to express a one-to-many emission.
948 for (std::size_t k = 0; k < fjsync_.size(); ++k) {
949 const qn::FjSync<T>& e = fjsync_[k];
950 const std::size_t isf_f = sn_.stateful_index(e.fork);
951 if (isf_f == 0) continue;
952 const qn::GlobalOutcome<T> fo = qn::after_fj_event(sn_, e, base);
953 double fired = 0.0;
954 for (std::size_t io = 0; io < fo.space.size(); ++io) {
955 const double w = num_traits<T>::to_double(fo.rate[io]) *
956 num_traits<T>::to_double(fo.prob[io]);
957 if (!(w > 0)) continue;
958 Move m;
959 m.sync = sync_.size() + gsync_.size() + k;
960 m.weight = w;
961 m.next = st;
962 m.next.net = fo.space[io];
963 if (!region_admissible(m.next.net)) continue;
964 fired += w;
965 moves.push_back(m);
966 }
967 if (!(fired > 0)) continue;
968 dep[isf_f - 1][e.cls - 1] += fired;
969 for (std::size_t b = 0; b < e.branchheads.size(); ++b) {
970 const std::size_t isf_b = sn_.stateful_index(e.branchheads[b]);
971 if (isf_b != 0) arv[isf_b - 1][e.auxclasses[b] - 1] += fired;
972 }
973 }
974}
975
976template <class T>
978 // The exponential holding time is drawn as -log(u)/lambda, so the backend
979 // must have a logarithm at all. The analyzer gates on `double` before it
980 // instantiates this, so a caller reaching the assert is one that reached
981 // past the gate and would otherwise get a compile error deep inside the
982 // uniform draw instead of a sentence naming the reason.
984 "solver_ssa_serial: an SSA sample path is generated from exponential clocks "
985 "drawn as -log(u)/rate, which needs transcendental arithmetic");
986
987 const std::size_t NF = sn_.stateful_nodes.size();
988 const std::size_t R = sn_.nclasses;
989 SsaSerialRun<T> out;
990 out.seed = opt_.seed;
991 out.warmup = static_cast<std::size_t>(
992 std::floor(std::max(0.0, std::min(0.99, opt_.warmupfrac)) *
993 static_cast<double>(opt_.samples)));
994
995 ctmc::WaitqState<T> cur = init_;
996 std::vector<Move> moves;
997 std::vector<std::vector<double>> arv(NF, std::vector<double>(R, 0.0));
998 std::vector<std::vector<double>> dep(NF, std::vector<double>(R, 0.0));
999 std::vector<std::vector<double>> dly(NF, std::vector<double>(R, 0.0));
1000 // Derived START/PREEMPT rates, sampled exactly like the three above: the
1001 // rate at which the transitions enabled in the current state start a
1002 // class-r service, or push a class-r job in service back into the buffer.
1003 std::vector<std::vector<double>> start(NF, std::vector<double>(R, 0.0));
1004 std::vector<std::vector<double>> preempt(NF, std::vector<double>(R, 0.0));
1005 std::vector<double> weights;
1006 std::map<std::vector<double>, std::size_t> index; // state key -> row of `space`
1007
1008 double cur_time = 0.0;
1009 out.tran_time.reserve(opt_.samples);
1010 out.tran_sync.reserve(opt_.samples);
1011 for (std::size_t n = 0; n < opt_.samples; ++n) {
1012 enabled(cur, moves, arv, dep, dly, start, preempt);
1013 if (moves.empty())
1014 throw NumericError(ErrorKind{"Deadlock"},
1015 "solver_ssa_serial: the sample path entered a deadlock before collecting all "
1016 "samples, no synchronization is enabled");
1017
1018 weights.resize(moves.size());
1019 double tot = 0.0;
1020 for (std::size_t i = 0; i < moves.size(); ++i) {
1021 weights[i] = moves[i].weight;
1022 tot += weights[i];
1023 }
1024 const std::size_t sel = rng_.draw(weights);
1025 // The transition is drawn BEFORE the holding time, as the reference
1026 // draws them: the two are independent, so the order changes only which
1027 // stream this engine is, and being a nameable stream is the point.
1028 const double dt = -std::log(rng_.uniform()) / tot;
1029
1030 // The state is recorded with the time spent IN it, so the pair belongs
1031 // to the state before the firing, not after.
1032 //
1033 // THE KEY IS THE AUGMENTED ONE and the stored row is the network half.
1034 // Two states that agree on every node but differ in a region FIFO are
1035 // DIFFERENT states -- their enabled sets differ -- so merging them would
1036 // put two rate rows on one entry and report whichever was seen first.
1037 // `space` therefore may hold the same network row twice, once per FIFO
1038 // content, which every consumer here handles: each row carries its own
1039 // `pi` and the metrics are sums over rows, never lookups by row.
1040 const std::vector<double> key = ctmc::waitq_detail::waitq_key(cur);
1041 std::size_t si;
1042 const typename std::map<std::vector<double>, std::size_t>::const_iterator it =
1043 index.find(key);
1044 if (it != index.end()) {
1045 si = it->second;
1046 } else {
1047 si = out.space.size();
1048 index[key] = si;
1049 out.space.push_back(cur.net);
1050 out.buf.push_back(cur.buf);
1051 out.pi.push_back(0.0);
1052 out.arv_rates.push_back(arv);
1053 out.dep_rates.push_back(dep);
1054 out.dly_rates.push_back(dly);
1055 out.start_rates.push_back(start);
1056 out.preempt_rates.push_back(preempt);
1057 }
1058 // The warmup discard drops the transient from the TIME AVERAGE only: the
1059 // states themselves stay in the table, so a state visited only during
1060 // the transient keeps its (exact) rate row and contributes zero weight.
1061 if (n >= out.warmup) {
1062 out.pi[si] += dt;
1063 out.simulated_time += dt;
1064 }
1065 cur_time += dt;
1066 out.tran_time.push_back(cur_time);
1067 out.tran_sync.push_back(moves[sel].sync);
1068 out.tran_state.push_back(si);
1069
1070 cur = moves[sel].next;
1071 out.samples = n + 1;
1072 }
1073
1074 double tot_pi = 0.0;
1075 for (std::size_t s = 0; s < out.pi.size(); ++s) tot_pi += out.pi[s];
1076 if (tot_pi > 0)
1077 for (std::size_t s = 0; s < out.pi.size(); ++s) out.pi[s] /= tot_pi;
1078 out.ssq = ctmc::ctmc_state_space_aggr(sn_, out.space);
1079 return out;
1080}
1081
1082namespace serial_detail {
1083
1084/** `map_mean(PH{ist}{k})`, or a negative sentinel when the pair has no process. */
1085template <class T>
1086double service_mean(const qn::NetworkStruct<T>& sn, std::size_t ist, std::size_t k) {
1087 const lang::Distrib<T>& d = sn.service[ist - 1][k - 1];
1088 if (d.disabled || d.D0.rows() == 0) return -1.0;
1089 mam::Map<T> m;
1090 m.D0 = d.D0;
1091 m.D1 = d.D1;
1092 try {
1094 } catch (const Error&) {
1095 return -1.0; // a zero-rate process has no mean; the reference skips it too
1096 }
1097}
1098
1099} // namespace serial_detail
1100
1101/**
1102 * Port of `solver_ssa_analyzer_serial.m`: run the serial engine and reduce its
1103 * path to the metric table.
1104 *
1105 * THE UTILIZATION ESTIMATOR IS THE REFERENCE'S, discipline by discipline. An
1106 * INF station is utilized by every job it holds; a PS-family station takes the
1107 * ARRIVAL rate over rate*servers, because the offered load is what a processor
1108 * sharing server carries; every other discipline takes the arrival rate times
1109 * the mean service time over the servers. A class that can be DROPPED -- an open
1110 * class at a station with a finite capacity -- is measured on the CARRIED rate
1111 * instead, because the offered rate counts arrivals that never entered service.
1112 *
1113 * ONE DIVERGENCE FROM THE REFERENCE, stated rather than hidden:
1114 *
1115 * THE CACHE LOOP IS INDEXED BY NODE, NOT BY STATEFUL INDEX. The reference
1116 * writes `sn.nodetype(isf) == NodeType.Cache` with `isf` running over the
1117 * STATEFUL nodes, so on any model whose stateful indices differ from its node
1118 * indices -- one with a Source, a Router or a ClassSwitch, which is most of
1119 * them -- it tests the type of the wrong node. Reproducing that would report
1120 * hit ratios for a node that is not the cache.
1121 *
1122 * A PAS STATION takes the reference's `otherwise` branch, T*E[S]/c, and NOT the
1123 * in-service occupancy `solver_ctmc_avg_from_pi` computes for the same station.
1124 * The two disagree because a pass-and-swap job does not engage a single server;
1125 * the reference serial analyzer is what is ported here, and the disagreement is
1126 * named so it is not mistaken for a defect in either.
1127 */
1128template <class T>
1130 const SsaSerialOptions& opt,
1131 const std::vector<qn::FjSync<T>>& fjsync) {
1132 // `if constexpr`, not a run-time test: the engine reaches `map_mean` and the
1133 // logarithm of a uniform, so a Rational instantiation would fail to COMPILE
1134 // rather than refuse. The gate has to keep the body from being instantiated.
1135 if constexpr (!std::is_same<T, double>::value) {
1136 (void)sn_arg;
1137 (void)opt;
1138 throw UnsupportedError(ErrorKind{"Arithmetic"},
1139 "solver_ssa_serial: an SSA sample path is generated from exponential clocks, which "
1140 "are logarithms of uniform draws; there is no exact value to compute and a wider "
1141 "float carries no information the Monte Carlo error does not swamp. Rerun with "
1142 "--arith double");
1143 } else {
1144 using lang::SchedStrategy;
1145 // A MAP service at an FCFS station runs in the pair form SolverCTMC uses (`ctmc_mapc`):
1146 // one sampler per (station, class) chained in start order, as JMT and LDES draw it.
1147 qn::NetworkStruct<T> sn_mapc;
1148 const bool mapc = ctmc::ctmc_mapc(sn_arg, sn_mapc);
1149 const qn::NetworkStruct<T>& sn = mapc ? sn_mapc : sn_arg;
1150 const std::size_t M = sn.nstations, K = sn.nclasses;
1152 out.seed = opt.seed;
1153 if (mapc) out.solved_sn = std::make_shared<const qn::NetworkStruct<T>>(sn);
1154
1155 SsaSerialEngine<T> eng(sn, opt, fjsync);
1156 out.run = eng.run();
1157 const SsaSerialRun<T>& r = out.run;
1158
1159 // The parked population, per class: a token carries the class its job
1160 // will enter in, so the mean is the time average of the FIFO contents.
1161 out.parked.assign(K, 0.0);
1162 for (std::size_t s = 0; s < r.buf.size() && s < r.pi.size(); ++s)
1163 for (std::size_t f = 0; f < r.buf[s].size(); ++f)
1164 for (std::size_t j = 0; j < r.buf[s][f].size(); ++j) {
1165 const std::size_t cls = (r.buf[s][f][j] - 1) % K + 1;
1166 out.parked[cls - 1] += r.pi[s];
1167 }
1168
1169 SsaSolution& a = out.avg;
1170 a.method = "serial";
1171 a.samples = r.samples;
1173 a.QN = Matrix<double>(M, K, 0.0);
1174 a.UN = Matrix<double>(M, K, 0.0);
1175 a.RN = Matrix<double>(M, K, 0.0);
1176 a.TN = Matrix<double>(M, K, 0.0);
1177 a.XN.assign(K, 0.0);
1178 a.CN.assign(K, 0.0);
1179 a.StartN = Matrix<double>(M, K, 0.0);
1180 a.PreemptN = Matrix<double>(M, K, 0.0);
1181
1182 // System throughput is the DEPARTURE rate at each class's reference
1183 // station, which is what makes X a per-class quantity rather than a sum.
1184 for (std::size_t k = 1; k <= K; ++k) {
1185 const std::size_t refsf = sn.stateful_of_station(sn.classes[k - 1].refstat);
1186 if (refsf == 0) continue;
1187 for (std::size_t s = 0; s < r.space.size(); ++s)
1188 a.XN[k - 1] += r.pi[s] * r.dep_rates[s][refsf - 1][k - 1];
1189 }
1190
1191 // The reference's `isempty(sn.lldscaling) && isempty(sn.cdscaling)` test
1192 // is over the WHOLE matrix, so one load-dependent station puts every
1193 // station on the scaling branch. That is kept: at a station with no
1194 // scaling the branch degenerates to T*E[S]/c, which differs from the
1195 // unscaled branch only in using the carried rather than the offered
1196 // rate, and reproducing the reference's table means reproducing that.
1197 bool scaled = static_cast<bool>(sn.gdscaling);
1198 for (std::size_t i = 0; i < M; ++i)
1199 if (!sn.stations[i].lldscaling.empty() || sn.stations[i].cdscaling) scaled = true;
1200
1201 for (std::size_t ist = 1; ist <= M; ++ist) {
1202 const std::size_t isf = sn.stateful_of_station(ist);
1203 if (isf == 0) continue;
1204 const SchedStrategy sched = sn.stations[ist - 1].sched;
1205 const double S = sn.stations[ist - 1].nservers;
1206 for (std::size_t k = 1; k <= K; ++k) {
1207 for (std::size_t s = 0; s < r.space.size(); ++s) {
1208 a.TN(ist - 1, k - 1) += r.pi[s] * r.dep_rates[s][isf - 1][k - 1];
1209 a.QN(ist - 1, k - 1) +=
1210 r.pi[s] * num_traits<T>::to_double(r.ssq(s, (ist - 1) * K + k - 1));
1211 // same time average as TN, over the derived tag rates
1212 if (s < r.start_rates.size()) {
1213 a.StartN(ist - 1, k - 1) += r.pi[s] * r.start_rates[s][isf - 1][k - 1];
1214 a.PreemptN(ist - 1, k - 1) += r.pi[s] * r.preempt_rates[s][isf - 1][k - 1];
1215 }
1216 }
1217 }
1218
1219 const bool is_ps = sched == SchedStrategy::PS || sched == SchedStrategy::DPS ||
1220 sched == SchedStrategy::GPS || sched == SchedStrategy::LPS;
1221 // A SOURCE holds no jobs, so QLen, Util and hence RespT are zero
1222 // there BY DEFINITION and only its throughput is a quantity -- the
1223 // rule `solver_ssa_nrm.h` states at length and `solver_ctmc_avg_from_pi`
1224 // applies by the same `continue`. The reference serial analyzer has
1225 // no such branch and lets a Source fall into `otherwise`, where it
1226 // divides the arrival rate by a server count `solver_ssa.m` has
1227 // meanwhile overwritten with the station's capacity; that product is
1228 // not a utilization of anything.
1229 if (sn.stations[ist - 1].nodetype == lang::NodeType::Source ||
1230 sched == SchedStrategy::EXT)
1231 continue;
1232 if (sched == SchedStrategy::INF) {
1233 for (std::size_t k = 1; k <= K; ++k) {
1234 a.UN(ist - 1, k - 1) = a.QN(ist - 1, k - 1);
1235 // a global dependence re-times the think time too, so its declared peak normalizes the
1236 // delay row as well (T*E[S]/peak, as SolverCTMC and LDES)
1237 if (sn.gdscaling && sn.gdscalingpeak.size() >= ist * K) {
1238 const double pk = num_traits<T>::to_double(sn.gdscalingpeak[(ist - 1) * K + (k - 1)]);
1239 const double mean = serial_detail::service_mean(sn, ist, k);
1240 if (pk > 0.0 && mean >= 0.0) a.UN(ist - 1, k - 1) = a.TN(ist - 1, k - 1) * mean / pk;
1241 }
1242 }
1243 continue;
1244 }
1245 if (scaled) {
1246 // The EFFECTIVE server count a load-dependent station can
1247 // deliver: `max(c, max_n lld(n))`. A class-dependent station
1248 // normalizes by its DECLARED peak instead, which is the only
1249 // thing the utilization can be a fraction of.
1250 double ceff = S;
1251 const std::vector<T>& lld = sn.stations[ist - 1].lldscaling;
1252 for (std::size_t j = 0; j < lld.size(); ++j)
1253 ceff = std::max(ceff, num_traits<T>::to_double(lld[j]));
1254 const bool is_cd = static_cast<bool>(sn.stations[ist - 1].cdscaling);
1255 const bool is_jd = static_cast<bool>(sn.stations[ist - 1].jdscaling);
1256 // A global (Whittle) dependence rescales the service rate the
1257 // same way, so the peak IT declares normalizes Util too.
1258 const bool is_gd = static_cast<bool>(sn.gdscaling);
1259 std::vector<T> gdpk;
1260 if (is_gd)
1261 gdpk.assign(sn.gdscalingpeak.begin() + (ist - 1) * K,
1262 sn.gdscalingpeak.begin() + ist * K);
1263 for (std::size_t k = 1; k <= K; ++k) {
1264 const double mean = serial_detail::service_mean(sn, ist, k);
1265 if (mean < 0) continue;
1266 // The divisor is the PRODUCT of the declared peaks when either
1267 // dependence is present, and the effective server count only
1268 // otherwise: a station carrying both scales its rate by both,
1269 // so normalizing by one of them alone leaves the other's factor
1270 // in the reported utilization.
1271 double cdiv = ceff;
1272 if (is_cd || is_jd || is_gd) {
1273 cdiv = 1.0;
1274 const std::vector<T>* pks[3] = {&sn.stations[ist - 1].cdscalingpeak,
1275 &sn.stations[ist - 1].jdscalingpeak,
1276 &gdpk};
1277 const char* names[3] = {"setClassDependence", "setJointDependence",
1278 "setGlobalDependence"};
1279 const bool on[3] = {is_cd, is_jd, is_gd};
1280 for (std::size_t h = 0; h < 3; ++h) {
1281 if (!on[h]) continue;
1282 const std::vector<T>& pk = *pks[h];
1283 if (pk.size() < k || !(num_traits<T>::to_double(pk[k - 1]) > 0))
1284 throw InputError(
1285 "SolverSSA(method='serial'): station '" +
1286 sn.stations[ist - 1].name +
1287 "' declares a dependent scaling with no declared peak rate. "
1288 "Utilization there is T*E[S]/peak, so pass the peak to " +
1289 names[h]);
1290 cdiv *= num_traits<T>::to_double(pk[k - 1]);
1291 }
1292 }
1293 a.UN(ist - 1, k - 1) = cdiv > 0 ? a.TN(ist - 1, k - 1) * mean / cdiv : 0.0;
1294 }
1295 continue;
1296 }
1297
1298 // A class whose jobs can be lost here is measured on the carried
1299 // rate; everything else on the offered rate, which is exact in
1300 // steady state and is what the reference reports.
1301 for (std::size_t k = 1; k <= K; ++k) {
1302 const double mean = serial_detail::service_mean(sn, ist, k);
1303 if (mean < 0) continue;
1304 // A job that reneges or balks leaves without service, as a dropped one does. RENEGING counts for a
1305 // closed class too, as in the CTMC analyzer (solver_ctmc.h, MATLAB solver_ctmc_analyzer.m).
1306 const qn::Station<T>& stk = sn.stations[ist - 1];
1307 const bool reneging = num_traits<T>::to_double(qn::station_renege_mu(sn, ist, k)) > 0;
1308 const bool balking = k <= stk.balking.size() &&
1309 stk.balking[k - 1].strategy != lang::BalkingStrategy::NONE;
1310 const bool can_drop = (!std::isfinite(sn.njobs()[k - 1]) &&
1311 (std::isfinite(sn.cap[ist - 1]) ||
1312 std::isfinite(sn.classcap[ist - 1][k - 1]) || balking)) ||
1313 reneging;
1314 double arv = 0.0;
1315 if (!can_drop)
1316 for (std::size_t s = 0; s < r.space.size(); ++s)
1317 arv += r.pi[s] * r.arv_rates[s][isf - 1][k - 1];
1318 if (is_ps) {
1319 const double mu = num_traits<T>::to_double(sn.rates(ist - 1, k - 1));
1320 if (!(mu > 0)) continue;
1321 a.UN(ist - 1, k - 1) =
1322 (can_drop ? a.TN(ist - 1, k - 1) / mu : arv / mu) / S;
1323 } else {
1324 a.UN(ist - 1, k - 1) =
1325 (can_drop ? a.TN(ist - 1, k - 1) * mean : arv * mean) / S;
1326 }
1327 }
1328 }
1329
1330 // SYNCHRONOUS CALLS (REPLY signals), as `solver_ctmc_avg_from_pi` and the reference's
1331 // `ssa_reply_held`: a caller waiting for its reply still HOLDS its server, counted in the
1332 // reply block at the tail of the local row. The hold is added to QLen and Util but not to
1333 // RespT, since the waiting job is at its callee and not here.
1334 const Matrix<double> held = serial_detail::reply_held(sn, r);
1335 for (std::size_t ist = 1; ist <= M; ++ist) {
1336 const double S = sn.stations[ist - 1].nservers;
1337 for (std::size_t k = 1; k <= K; ++k) {
1338 if (held(ist - 1, k - 1) == 0) continue;
1339 a.QN(ist - 1, k - 1) += held(ist - 1, k - 1);
1340 a.UN(ist - 1, k - 1) += held(ist - 1, k - 1) / S;
1341 }
1342 }
1343
1344 // Little's law per station, then the per-class system response time.
1345 for (std::size_t k = 1; k <= K; ++k) {
1346 for (std::size_t ist = 1; ist <= M; ++ist)
1347 a.RN(ist - 1, k - 1) =
1348 a.TN(ist - 1, k - 1) > 0
1349 ? (a.QN(ist - 1, k - 1) - held(ist - 1, k - 1)) / a.TN(ist - 1, k - 1)
1350 : 0.0;
1351 // The reference's `CN(k) = NK(k)/XN(k)` with NK the class population:
1352 // infinite for an open class, which its NaN sweep does NOT clear and
1353 // which the NRM engine reports identically.
1354 if (a.XN[k - 1] > 0) a.CN[k - 1] = sn.classes[k - 1].population / a.XN[k - 1];
1355 }
1356
1357 // The cache write-back: every read leaves as exactly one of hit or miss,
1358 // so the two departure streams divide the read rate between them and
1359 // their ratio is the realized hit probability. The reference stores it
1360 // into `sn.nodeparam{ind}.actualhitprob`; the struct is const here, so it
1361 // is returned beside the table instead.
1362 const double nan = std::numeric_limits<double>::quiet_NaN();
1363 for (typename std::map<std::size_t, qn::CacheParam<T>>::const_iterator ci =
1364 sn.nodeparam.begin();
1365 ci != sn.nodeparam.end(); ++ci) {
1366 const std::size_t ind = ci->first;
1367 if (ind == 0 || ind > sn.nodes.size()) continue;
1368 if (sn.nodes[ind - 1].nodetype != lang::NodeType::Cache) continue;
1369 const std::size_t isf = sn.stateful_index(ind);
1370 if (isf == 0) continue;
1371 SsaCacheRatio cr;
1372 cr.node = ind;
1373 cr.hitprob.assign(K, nan);
1374 cr.missprob.assign(K, nan);
1375 cr.residt.assign(K, nan);
1376 std::vector<double> dly(K, 0.0);
1377 bool any_delayed = false;
1378 for (std::size_t k = 1; k <= K; ++k) {
1379 if (ci->second.hitclass.size() < k || ci->second.missclass.size() < k) continue;
1380 const std::size_t h = ci->second.hitclass[k - 1];
1381 const std::size_t mi = ci->second.missclass[k - 1];
1382 if (h == 0 || mi == 0 || h > K || mi > K) continue;
1383 double th = 0.0, tm = 0.0, td = 0.0;
1384 for (std::size_t s = 0; s < r.space.size(); ++s) {
1385 th += r.pi[s] * r.dep_rates[s][isf - 1][h - 1];
1386 tm += r.pi[s] * r.dep_rates[s][isf - 1][mi - 1];
1387 if (s < r.dly_rates.size()) td += r.pi[s] * r.dly_rates[s][isf - 1][k - 1];
1388 }
1389 if (th + tm > 0) {
1390 // `th` already carries the released delayed hits, so the
1391 // delayed share is CARVED OUT of it rather than added as a
1392 // fourth share.
1393 cr.hitprob[k - 1] = std::max(th - td, 0.0) / (th + tm);
1394 cr.missprob[k - 1] = tm / (th + tm);
1395 dly[k - 1] = td / (th + tm);
1396 if (td > 0) any_delayed = true;
1397 }
1398 }
1399 if (any_delayed) cr.delayedprob = dly;
1400 out.cache.push_back(cr);
1401 }
1402 return out;
1403 }
1404}
1405
1406
1407/**
1408 * Port of `solver_ssa_analyzer_serial.m` plus the fork-join wrapper
1409 * `@@SolverSSA/runAnalyzer.m` puts in front of it.
1410 *
1411 * A FORK-JOIN MODEL IS SIMULATED ON THE TAG-AUGMENTED COPY, exactly as
1412 * SolverCTMC solves it there: the fork emits one sibling per branch in a class
1413 * of its own, the tag is what lets the Join recognize which siblings belong to
1414 * the same parent, and `fj_tag` is the only thing that builds the `fjsync`
1415 * firing list the engine fires. The sample path in the returned run is
1416 * therefore indexed by the AUGMENTED classes; only the metric table is folded
1417 * back, which is why `fjclassmap` travels with it.
1418 */
1419template <class T>
1421 const SsaSerialOptions& opt) {
1422 // The augmentation is skipped entirely on a non-`double` backend so the
1423 // refusal a caller reads is the arithmetic one, from inside the engine,
1424 // rather than a fork-join message about a model whose real problem is that
1425 // an exponential clock has no exact value.
1426 if constexpr (!std::is_same<T, double>::value) {
1427 return solver_ssa_serial_on_struct(sn, opt, std::vector<qn::FjSync<T>>());
1428 } else {
1429 if (!tr::has_fork_join(sn))
1430 return solver_ssa_serial_on_struct(sn, opt, std::vector<qn::FjSync<T>>());
1431 const qn::FjTagged<T> fjt = qn::fj_tag(sn);
1433 tr::fj_foldback(sn, out.avg, fjt.fjclassmap, fjt.korig);
1434 out.fjclassmap = fjt.fjclassmap;
1435 out.parked.resize(fjt.korig);
1436 return out;
1437 }
1438}
1439
1440/**
1441 * The `serial` entry of `solver_ssa_analyzer.m`.
1442 *
1443 * The reference reaches it from `default` (when the NRM eligibility gate fails),
1444 * from `ssa`, from `serial` and from `para`/`parallel` without the Parallel
1445 * Computing Toolbox. `para`/`parallel` is NOT that: it replicates the SAME
1446 * engine across workers and averages, so answering it with one replica would
1447 * report a number at a different variance from the one asked for, and it refuses
1448 * by name here.
1449 */
1450template <class T>
1452 const SsaSerialOptions& opt) {
1453 const std::string& m = opt.method;
1454 if (m == "default" || m == "ssa" || m == "serial") return solver_ssa_serial_analyzer(sn, opt);
1455 if (m == "para" || m == "pana" || m == "parallel")
1456 throw UnsupportedError(ErrorKind{"Method"},
1457 "SolverSSA: the '" + m +
1458 "' method runs the serial engine on several workers and averages the replicas "
1459 "(solver_ssa_analyzer_parallel.m). The engine is ported; the replication is not, and "
1460 "one replica has a different variance from the average of many. Use 'serial'");
1461 throw UnsupportedError(ErrorKind{"Method"}, "SolverSSA(serial): '" + m +
1462 "' is not a method this entry accepts; it implements 'serial' and the "
1463 "'default' and 'ssa' aliases that reach it");
1464}
1465
1466} // namespace ssa
1467} // namespace line
1468
1469#endif // LINE_SOLVERS_SSA_SOLVER_SSA_SERIAL_H
Base error for the multiprecision C++ port.
Definition error.h:73
InputError(const std::string &what)
Definition error.h:105
std::size_t cols() const
Definition matrix.h:90
NumericError(const std::string &what)
Definition error.h:112
A refusal: the feature, method or analysis is not covered: LINE:unsupported:<kind>.
Definition error.h:117
UnsupportedError(const std::string &what)
Definition error.h:119
A network plus its refreshed NetworkStruct.
std::size_t stateful_index(std::size_t ind) const
1-based stateful index of node ind, 0 when the node is not stateful.
std::size_t stateful_of_station(std::size_t st) const
GdScaling< T > gdscaling
sn.gdscaling: the network-level globally state-dependent (Whittle) rate scaling phi(n).
std::size_t rr_var_slot(std::size_t ind, std::size_t r) const
1-BASED index of the pointer of (ind, r) INSIDE the node's local-variable block, or 0 when that pair ...
std::vector< Station< T > > stations
stations[k-1] is the k-th station
std::size_t node_of_station(std::size_t st) const
1-based node index of station st, 0 when the map has no entry.
std::vector< NodeDef > nodes
every node, in creation order
std::size_t phasessz_of(std::size_t ist, std::size_t r) const
sn.phasessz(i,r) = max(sn.phases(i,r),1): THE WIDTH of class r's phase block in a state row,...
std::vector< Region > regions
The serial engine: the sample path of solver_ssa.m's main loop.
const std::vector< qn::Sync< T > > & sync() const
The synchronization list the trace's tran_sync indexes.
SsaSerialEngine(const qn::NetworkStruct< T > &sn, const SsaSerialOptions &opt, const std::vector< qn::FjSync< T > > &fjsync=std::vector< qn::FjSync< T > >())
fjsync is the fork firing list of the TAG-AUGMENTED struct, empty for a model with no Fork.
const qn::NetState< T > & init_state() const
The state the path starts from, at full encoding width.
SsaSerialRun< T > run()
Run opt.samples firings and return the path with its statistics.
The exception types the port throws.
Port of matlab/src/api/fj/sn_fj_validate.m and matlab/src/io/@@ModelAdapter/fjtag....
Fork-join TAG AUGMENTATION: the fold-back half of the transform/lift pair that CTMC and SSA share.
Markovian arrival process descriptors: stationary vectors, rate, moments, autocorrelation and the ind...
Dense matrix and non-owning view.
bool ctmc_region_admissible(const NetworkStruct< T > &sn, const std::vector< T > &nir)
True when nir – the per-(station, class) counts of one state, in (ist-1)*K + k order – satisfies ever...
bool ctmc_mapc(const qn::NetworkStruct< T > &in, qn::NetworkStruct< T > &out)
Rewrite a copy of in into out when it has a MAP service at an FCFS station; false,...
std::vector< NetState< T > > ctmc_filter_regions(const NetworkStruct< T > &sn, const std::vector< NetState< T > > &space)
The states of space a DROP region admits, in their original order.
void ctmc_check_region_rules(const NetworkStruct< T > &sn)
Refuse the region rules this port does not implement.
Matrix< T > ctmc_state_space_aggr(const NetworkStruct< T > &sn, const std::vector< NetState< T > > &space)
Port of StateSpaceAggr: the per-(station, class) job counts of every state, as an (nstates x nstation...
bool ctmc_has_waitq_region(const NetworkStruct< T > &sn)
True when the model declares a region that applies anything other than DROP.
std::vector< NetState< T > > reachable_space_generator(const NetworkStruct< T > &sn, const NetState< T > &init, const std::vector< Sync< T > > &sync, const std::vector< qn::GlobalSync< T > > &gsync=std::vector< qn::GlobalSync< T > >(), std::size_t maxst=3000000, const std::vector< qn::FjSync< T > > &fjsync=std::vector< qn::FjSync< T > >(), const std::vector< std::size_t > &cutoff=std::vector< std::size_t >(), const std::vector< std::vector< std::size_t > > &cutoff_mat=std::vector< std::vector< std::size_t > >())
Port of State.reachableSpaceGenerator: the states reachable from init.
void ctmc_check_waitq_support(const NetworkStruct< T > &sn)
The combinations the reference gates, plus the two this port cannot represent.
SchedStrategy
Scheduling disciplines, with the values of MATLAB SchedStrategy.
Definition lang_types.h:185
@ PHASE
service advances a phase WITHOUT departing
Definition lang_types.h:120
@ READ
a cache item is read
Definition lang_types.h:121
@ POST
produce to a place or queue buffer
Definition lang_types.h:126
@ DEP
a job departs
Definition lang_types.h:119
@ FIRE
an SPN mode fires
Definition lang_types.h:124
@ PRE
consume from a place or queue buffer, no server effect
Definition lang_types.h:125
NodeType
Node kinds, with the values of MATLAB NodeType.
Definition lang_types.h:331
double to_double(const std::string &s)
T map_mean(const Map< T > &m)
Mean inter-arrival time, 1/lambda.
Definition map_moment.h:101
std::string state_impatience_unsupported(const NetworkStruct< T > &sn, const std::string &solver)
Port of solver_ctmc_state_supports.m: the impatience laws the state machinery cannot serve,...
ReplyBlockInfo reply_block_info(const NetworkStruct< T > &sn, std::size_t ind)
Defined below; the departure branch records a server held for a reply.
Marginal< T > to_marginal(const NetworkStruct< T > &sn, std::size_t ist, const std::vector< T > &state_i, const std::vector< std::size_t > &phasesz, const std::vector< std::size_t > &phaseshift, std::size_t nvar=0)
Port of State.toMarginal for a STATION, one state row at a time.
Definition state.h:131
std::vector< std::vector< T > > from_marginal_node(const NetworkStruct< T > &sn, std::size_t ind, const std::vector< std::size_t > &n, const std::vector< std::size_t > &phases)
Port of State.fromMarginal at its OWN signature: the reference indexes by NODE, not by station,...
Definition state.h:2044
bool from_marginal_node_first(const NetworkStruct< T > &sn, std::size_t ind, const std::vector< std::size_t > &n, const std::vector< std::size_t > &phases, std::vector< T > &out)
The FIRST row from_marginal_node emits, BUILT rather than enumerated.
Definition state.h:2599
bool immfeed_self_loop(const NetworkStruct< T > &sn, const Sync< T > &sy)
True when a synchronization is an IMMEDIATE-FEEDBACK SELF-LOOP: a departure whose passive half is an ...
T station_renege_mu(const NetworkStruct< T > &sn, std::size_t ist, std::size_t cls)
sn.impatienceMu(ist,cls): the rate of an exponential reneging patience, 0 when the class declares non...
GlobalOutcome< T > after_global_event(const NetworkStruct< T > &sn, const NetState< T > &glspace, const GlobalSync< T > &gl)
Port of State.afterGlobalEvent: an SPN mode ENABLEs or FIREs.
std::vector< Sync< T > > refresh_sync(const NetworkStruct< T > &sn, const std::vector< std::vector< bool > > &impatience_classes=std::vector< std::vector< bool > >(), const std::vector< std::size_t > &breakdown_nodes=std::vector< std::size_t >())
Port of MNetwork.refreshSync: the synchronization list.
EventOutcome< T > after_event(const NetworkStruct< T > &sn, std::size_t ind, const std::vector< T > &inspace, EventType event, std::size_t cls, bool no_promote=false, const T &aux_rate=num_traits< T >::from_int(0))
Port of State.afterEvent: the successors of one event at one NODE.
std::vector< GlobalSync< T > > refresh_global_sync(const NetworkStruct< T > &sn)
Port of MNetwork.refreshGlobalSync: the ENABLE and FIRE synchronizations.
FjTagged< T > fj_tag(const NetworkStruct< T > &sn)
Port of ModelAdapter.fjtag.
Definition fj_tag.h:302
GlobalOutcome< T > after_fj_event(const NetworkStruct< T > &sn, const FjSync< T > &e, const NetState< T > &gl)
Port of State.afterFJEvent: fire ONE entry of the fork firing list.
Matrix< T > rt_state(const NetworkStruct< T > &sn, const std::vector< std::vector< T > > &local)
Port of sn.rtfun: the routing over the stateful nodes AT ONE STATE.
Definition state.h:383
SsaSerialSolution< T > solver_ssa_serial_analyzer(const qn::NetworkStruct< T > &sn, const SsaSerialOptions &opt)
Port of solver_ssa_analyzer_serial.m plus the fork-join wrapper @@SolverSSA/runAnalyzer....
SsaSerialSolution< T > solver_ssa_serial(const qn::NetworkStruct< T > &sn, const SsaSerialOptions &opt)
The serial entry of solver_ssa_analyzer.m.
SsaReachability< T > solver_ssa_reachability(const qn::NetworkStruct< T > &sn, const SsaSerialOptions &opt=SsaSerialOptions())
Port of solver_ssa_reachability.m: the states the DYNAMICS can occupy, decomposed per stateful node.
SsaSerialSolution< T > solver_ssa_serial_on_struct(const qn::NetworkStruct< T > &sn_arg, const SsaSerialOptions &opt, const std::vector< qn::FjSync< T > > &fjsync)
Port of solver_ssa_analyzer_serial.m: run the serial engine and reduce its path to the metric table.
void fj_foldback(const qn::NetworkStruct< T > &sn, Avg &a, const std::vector< std::size_t > &fjclassmap, std::size_t korig)
Reduce the augmented metrics onto the original classes.
bool has_fork_join(const qn::NetworkStruct< T > &sn)
Whether the model needs the tag augmentation at all.
Conservation laws of a layered queueing network, enumerated from its structure.
Definition aoi_dist2ph.h:52
A queueing network and its refreshed NetworkStruct.
Port of solver_ctmc.m: the infinitesimal generator of a queueing network, assembled from the enumerat...
Port of solver_ctmc_analyzer.m and the parts of @@SolverCTMC/runAnalyzer.m that surround one solve: t...
Finite Capacity Regions in SolverCTMC: the DROP rule, as a filter on the enumerated state space,...
Port of solver_ctmc_fcr_waitq.m: the reachability-built generator of a model whose finite capacity re...
Controls, results and the random source of SolverSSA.
Port of the MATLAB +State package: the encoding that turns a station's state row into marginal job co...
Port of the event half of MATLAB's +State package: the successor states an event produces at one node...
The <kind> part of a LINE:<category>:<kind> identifier (see _kb/17-error-vocabulary....
Definition error.h:37
The SolverCTMC knobs this port honours.
std::size_t state_max
refuse a space larger than this
double cutoff
< 0 = not given
One augmented state: the network state, plus the token FIFO of every region.
std::vector< std::vector< std::size_t > > buf
Matrix< T > D0
The (D0,D1) pair when the type carries one directly.
Definition lang_types.h:863
A MAP as the pair of matrices (D0, D1).
Definition map_moment.h:53
Matrix< T > D1
Definition map_moment.h:55
Matrix< T > D0
Definition map_moment.h:54
The parameters of a Cache node, MATLAB's sn.nodeparam{ind} for a Cache.
One fork firing synchronization: sn.fjsync{k}.
The augmented struct and everything needed to read its results back.
Definition fj_tag.h:71
std::vector< FjSync< T > > fjsync
Definition fj_tag.h:76
NetworkStruct< T > V
Definition fj_tag.h:72
std::size_t korig
Definition fj_tag.h:78
std::vector< std::size_t > fjclassmap
fjclassmap[a-1] is the ORIGINAL class of auxiliary class a, 0 for originals.
Definition fj_tag.h:74
One network state: the per-stateful-node local rows it is composed of.
Definition state.h:2734
Where node ind keeps its reply-block counters inside the local vars.
std::vector< std::size_t > classes
1-based calling classes holding a block
One station of the network.
std::vector< BalkingParam > balking
What the cache write-back of solver_ssa_analyzer_serial.m produces (and the NRM's).
Definition ssa_types.h:167
std::size_t node
1-based Cache node index
Definition ssa_types.h:168
std::vector< double > delayedprob
The delayed-hit share, EMPTY off a retrieval system.
Definition ssa_types.h:177
std::vector< double > missprob
per class, NaN where undefined
Definition ssa_types.h:169
std::vector< double > hitprob
Definition ssa_types.h:169
std::vector< double > residt
actualresidt: NaN, and NOT a port gap.
Definition ssa_types.h:183
Controls, defaulting to SolverOptions('SSA') in the reference.
Definition ssa_types.h:71
Port of solver_ssa_reachability.m's return: [SSq, SSh, sn.space].
std::vector< std::vector< std::vector< T > > > node_space
sn.space, per stateful node
Matrix< T > ssq
SSq, states x concatenated width
std::vector< qn::NetState< T > > space
the reachable states
std::vector< std::vector< std::size_t > > hash
SSh, 1-based per node
The serial engine's knobs: SsaOptions plus the three the serial path reads and the NRM has no use for...
std::size_t state_max
refuse a reachable space larger than this
One sample path, in the shape solver_ssa.m returns it.
std::vector< qn::NetState< T > > space
The DISTINCT states visited, in first-visit order (the reference's u).
std::vector< double > tran_time
tranSysState{1}: the cumulative time at each firing.
std::vector< std::size_t > tran_state
The row of space the path OCCUPIED over [t-dt, t], one per firing.
Matrix< T > ssq
SSq: the per-(station, class) job counts of each distinct state.
std::vector< std::vector< std::vector< double > > > start_rates
The DERIVED rates per state, laid out like arv_rates: how fast the transitions enabled in that state ...
std::size_t warmup
leading firings excluded from pi
unsigned long seed
the stream this path came from
std::vector< std::vector< std::vector< double > > > arv_rates
arvRates / depRates, indexed [distinct state][stateful-1][class-1].
std::vector< std::vector< std::vector< std::size_t > > > buf
The region token FIFOs of each of those states, the reference's fcrBuf.
std::size_t samples
firings actually performed
std::vector< std::vector< std::vector< double > > > dly_rates
The rate of the cache MERGE transitions, i.e.
std::vector< std::vector< std::vector< double > > > dep_rates
std::vector< std::vector< std::vector< double > > > preempt_rates
std::vector< double > pi
pi: the fraction of simulated time spent in each of them.
std::vector< std::size_t > tran_sync
tranSync: which synchronization fired, sync.size() + g for a global one.
The serial analyzer's return: the metric table, the path, and the stream.
std::vector< SsaCacheRatio > cache
std::vector< double > parked
Mean number of jobs parked in a region FIFO, per class of the struct that ran.
SsaSolution avg
QN, UN, RN, TN, XN, CN; method = "serial".
std::shared_ptr< const qn::NetworkStruct< T > > solved_sn
The struct the path was simulated on when it differs from the caller's (ctmc_mapc widens a multiserve...
unsigned long seed
carried beside the numbers, never implied
std::vector< std::size_t > fjclassmap
fjclassmap of the tag augmentation, empty on a model with no Fork.
What the analyzer returns, in the same shape as the MVA and fluid results.
Definition ssa_types.h:147
std::vector< double > XN
Definition ssa_types.h:149
std::vector< double > CN
Definition ssa_types.h:149
Matrix< double > UN
Definition ssa_types.h:148
Matrix< double > RN
Definition ssa_types.h:148
Matrix< double > StartN
The DERIVED rates, (nstations x nclasses): how often per unit time a class-r service STARTS at statio...
Definition ssa_types.h:157
double simulated_time
Simulated time the metrics are averaged over; the reference's totalTime.
Definition ssa_types.h:161
Matrix< double > TN
Definition ssa_types.h:148
std::size_t samples
Reaction firings actually performed.
Definition ssa_types.h:163
Matrix< double > QN
Definition ssa_types.h:148
std::string method
The concrete algorithm, as the reference's method.
Definition ssa_types.h:159
Matrix< double > PreemptN
Definition ssa_types.h:157