LINE Solver (C++)
Templated C++ port of the LINE queueing solver
Loading...
Searching...
No Matches
solver_ssa_parallel.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_PARALLEL_H
6#define LINE_SOLVERS_SSA_SOLVER_SSA_PARALLEL_H
7
8/**
9 * @file
10 * @ingroup line_solvers
11 * SolverSSA, the `para` / `parallel` method: a port of
12 * `solver_ssa_analyzer_parallel.m`.
13 *
14 * REPLICATION IS NOT A LONGER RUN. This is the whole point of the method and
15 * the one thing a reader must take from this file. The budget `opt.samples` is
16 * SPLIT across R independent replicas of `ceil(samples/R)` firings each, not
17 * multiplied by R; each replica is a complete run of the serial engine from the
18 * same initial state on its own random stream, and each produces its own time
19 * average. What is returned is the MEAN OF THE R REPLICA ESTIMATES, and the
20 * quantity that says how good it is, is the spread BETWEEN those R numbers --
21 * not the number of firings behind them. Concatenating the R sample paths and
22 * time-averaging the whole would give a similar point estimate and an error bar
23 * too small by a factor that grows with the autocorrelation of the path, which
24 * is precisely the mistake replication exists to avoid. So `avg` is the replica
25 * mean, `*_sem` is the standard error OF THAT MEAN computed across replicas
26 * with R-1 degrees of freedom, and both are reported together.
27 *
28 * WORKER-COUNT INVARIANCE. Replica r (0-based) is seeded `base_seed + r` and
29 * given `ceil(samples/R)` firings, so its result is a function of
30 * `(base_seed, samples, R)` alone. The reference's header dwells on this
31 * because its earlier `spmd` implementation both divided the budget by, and
32 * seeded from, the RUNTIME number of workers, so the same script answered
33 * differently on two machines. Nothing here can depend on a worker count for a
34 * second reason, below.
35 *
36 * THERE IS NO THREADING, AND THAT IS A DELIBERATE CHOICE, NOT A GAP. The
37 * replicas are run one after another in the loop below. Three reasons, in order
38 * of weight: (i) the parallelism is over REPLICAS and each replica's answer is
39 * pinned by its seed, so the returned numbers are bit-for-bit the same whether
40 * the loop is serial or spread over cores -- the concurrency is an
41 * implementation detail of how long the call takes and is not part of the
42 * answer, which is exactly the property the reference had to work to recover;
43 * (ii) nothing else in this port starts a thread and the build declares no
44 * threading dependency, so introducing one here would be a build-system change
45 * made for a wall-clock gain in a solver whose cost is already the user's
46 * chosen sample budget; (iii) `SsaSerialEngine` holds `sn` by const reference
47 * and mutates nothing shared, so the loop is trivially parallelizable later by
48 * whoever wants to pay for the dependency -- the estimator does not change.
49 *
50 * WHAT THE CACHE WRITE-BACK AVERAGES. The reference averages each Cache node's
51 * realized hit and miss probabilities over the replicas with a fixed 1/R
52 * weight. That is kept, NaN and all: a replica in which a cache saw no reads
53 * reports 0/0 there, and the reference propagates it into the average rather
54 * than dropping the replica, which is the honest outcome -- the estimate really
55 * is undefined when a replica contributes no reads.
56 *
57 * DOUBLE ONLY, for the reason `solver_ssa_serial.h` and `solver_ssa_nrm.h` both
58 * give: the sample path comes out of exponential clocks drawn as `-log(u)/rate`
59 * and the answer's error is Monte Carlo error, not rounding.
60 */
61
62#include <cmath>
63#include <cstddef>
64#include <limits>
65#include <map>
66#include <string>
67#include <type_traits>
68#include <vector>
69
71#include "line/num/number.h"
75#include "line/util/error.h"
76#include "line/util/matrix.h"
77
78namespace line {
79namespace ssa {
80
81/**
82 * The replicated engine's knobs: the serial engine's, plus the two the
83 * reference reads from `options.config` on this path.
84 *
85 * `nreplicas` defaults to 8, which is `SolverOptions('SSA')`'s own default and
86 * not a number chosen here; `eventcache` defaults to false, which is what the
87 * reference sets for every `options.lang` except `java`.
88 */
90 /** `options.config.nreplicas`: R, the FIXED number of independent replicas. */
91 std::size_t nreplicas = 8;
92 /** `options.config.eventcache`: memoize `after_event` inside each replica. */
93 bool eventcache = false;
94};
95
96/**
97 * What the replicated analyzer returns.
98 *
99 * The per-replica tables are kept, not just their mean, because the mean alone
100 * cannot be audited: a caller who wants a different confidence level, a
101 * different combination rule or a look at whether one replica is an outlier
102 * needs the R numbers. What is NOT kept is the R sample paths -- each is
103 * `samples_per_replica` long and the estimator never looks at them again.
104 */
105template <class T>
107 /** The replica MEAN; `method` = "parallel". */
109 /** The R per-replica estimates, in replica order. */
110 std::vector<SsaSolution> replica;
111 /** The stream each replica ran on: `base_seed + r`. */
112 std::vector<unsigned long> seed;
113
114 std::size_t nreplicas = 0;
115 /** `ceil(samples / R)`: the firings EACH replica performed. */
116 std::size_t samples_per_replica = 0;
117 /** `opt.samples`: the budget asked for, which `R * samples_per_replica` rounds up. */
118 std::size_t samples_requested = 0;
119 unsigned long base_seed = 0;
120
121 /**
122 * Standard error of the replica mean, per metric: `s / sqrt(R)` with `s`
123 * the sample standard deviation ACROSS the R replica estimates.
124 *
125 * NOT in the reference, which returns the mean alone. It is here because a
126 * replicated estimate whose between-replica spread is discarded is
127 * indistinguishable from a single long run, and the whole reason to
128 * replicate is that the two have different error bars. NaN when R = 1,
129 * where no spread is observable -- which is the correct report, not zero.
130 */
132 std::vector<double> XN_sem, CN_sem;
133
134 /** The Cache write-back of the reference's final loop, averaged over replicas. */
135 std::vector<SsaCacheRatio> cache;
136};
137
138namespace parallel_detail {
139
140/** Sample mean and standard error of the mean of `x`, both NaN when it is short. */
141inline void mean_sem(const std::vector<double>& x, double& mean, double& sem) {
142 const std::size_t R = x.size();
143 const double nan = std::numeric_limits<double>::quiet_NaN();
144 if (R == 0) {
145 mean = nan;
146 sem = nan;
147 return;
148 }
149 double s = 0.0;
150 for (std::size_t r = 0; r < R; ++r) s += x[r];
151 mean = s / static_cast<double>(R);
152 if (R < 2) {
153 // One replica has no observable spread. Reporting 0 would claim the
154 // estimate is exact, which is the failure mode this field exists to
155 // prevent, so it reports "unknown" instead.
156 sem = nan;
157 return;
158 }
159 double ss = 0.0;
160 for (std::size_t r = 0; r < R; ++r) ss += (x[r] - mean) * (x[r] - mean);
161 sem = std::sqrt(ss / static_cast<double>(R - 1)) / std::sqrt(static_cast<double>(R));
162}
163
164/** Elementwise `mean_sem` over the same cell of every replica's matrix. */
165inline void reduce_matrix(const std::vector<SsaSolution>& rep, Matrix<double> SsaSolution::*m,
166 Matrix<double>& mean, Matrix<double>& sem) {
167 const std::size_t R = rep.size();
168 const std::size_t nr = R ? (rep[0].*m).rows() : 0;
169 const std::size_t nc = R ? (rep[0].*m).cols() : 0;
170 mean = Matrix<double>(nr, nc, 0.0);
171 sem = Matrix<double>(nr, nc, 0.0);
172 std::vector<double> col(R, 0.0);
173 for (std::size_t i = 0; i < nr; ++i)
174 for (std::size_t j = 0; j < nc; ++j) {
175 for (std::size_t r = 0; r < R; ++r) col[r] = (rep[r].*m)(i, j);
176 double mu = 0.0, se = 0.0;
177 mean_sem(col, mu, se);
178 mean(i, j) = mu;
179 sem(i, j) = se;
180 }
181}
182
183/** Elementwise `mean_sem` over the same entry of every replica's vector. */
184inline void reduce_vector(const std::vector<SsaSolution>& rep, std::vector<double> SsaSolution::*v,
185 std::vector<double>& mean, std::vector<double>& sem) {
186 const std::size_t R = rep.size();
187 const std::size_t n = R ? (rep[0].*v).size() : 0;
188 mean.assign(n, 0.0);
189 sem.assign(n, 0.0);
190 std::vector<double> col(R, 0.0);
191 for (std::size_t i = 0; i < n; ++i) {
192 for (std::size_t r = 0; r < R; ++r) col[r] = (rep[r].*v)[i];
193 mean_sem(col, mean[i], sem[i]);
194 }
195}
196
197} // namespace parallel_detail
198
199/**
200 * `solver_ssa_analyzer_parallel.m`: run R replicas of the serial engine and
201 * combine their estimates.
202 *
203 * The reference's `run_replica` subfunction is `solver_ssa_serial_analyzer`
204 * here: the two compute the same per-station table from one sample path, and
205 * factoring the replication away from the estimator is what makes it obvious
206 * that the combination below touches only the R finished numbers.
207 */
208template <class T>
210 const SsaParallelOptions& opt) {
211 // `if constexpr`, not a run-time test: the replica reaches `map_mean` and
212 // the logarithm of a uniform, so a Rational instantiation would fail to
213 // COMPILE rather than refuse. The gate keeps the body uninstantiated.
214 if constexpr (!std::is_same<T, double>::value) {
215 (void)sn;
216 (void)opt;
217 throw UnsupportedError(
218 "solver_ssa_parallel: an SSA sample path is generated from exponential clocks, which "
219 "are logarithms of uniform draws; there is no exact value to compute and a wider "
220 "float carries no information the Monte Carlo error does not swamp. Rerun with "
221 "--arith double");
222 } else {
223 // The replica holding time is drawn as -mean*log(u), so the backend must
224 // have a logarithm at all. The gate above keeps a backend without one
225 // from reaching here; the assert names the reason rather than letting
226 // the failure surface inside the uniform draw.
228 "solver_ssa_parallel: each replica is an SSA sample path generated from "
229 "exponential clocks drawn as -mean*log(u), which needs transcendental "
230 "arithmetic");
231
232 if (opt.eventcache)
233 throw UnsupportedError(
234 "SolverSSA(method='parallel'): options.config.eventcache is set. The memo itself "
235 "is ported (ssa::SsaEventCache, which the reference builds per replica via "
236 "EventCache.create) and is verified against recomputation, but SsaSerialEngine "
237 "still calls the free qn::after_event and takes no cache argument, so setting the "
238 "flag here would advertise a memo the replicas never consult. Wire the engine's "
239 "enabled() through SsaEventCache::after_event first");
240
241 const std::size_t R = opt.nreplicas > 0 ? opt.nreplicas : 1;
242 // ceil(samples/R): the budget is SPLIT, so the total firings performed
243 // is R*ceil(samples/R), which rounds the request UP and never down --
244 // a replica of zero firings would have no estimate to contribute.
245 std::size_t per = (opt.samples + R - 1) / R;
246 if (per == 0) per = 1; // a replica of zero firings has no estimate to contribute
247
249 out.nreplicas = R;
250 out.samples_per_replica = per;
251 out.samples_requested = opt.samples;
252 out.base_seed = opt.seed;
253 out.replica.reserve(R);
254 out.seed.reserve(R);
255
256 // Only the finished ESTIMATE of each replica is kept, never its sample
257 // path: the combination below looks at the R tables and at nothing
258 // else, and holding R paths at once would make the peak memory grow
259 // with a budget the estimator does not read.
260 std::vector<std::vector<SsaCacheRatio> > rep_cache;
261 rep_cache.reserve(R);
262 for (std::size_t r = 0; r < R; ++r) {
263 // Sliced to the base on purpose: `nreplicas` and `eventcache` are
264 // the replicATION's knobs and mean nothing inside a replica, while
265 // everything the serial engine reads (cutoff, warmupfrac,
266 // state_max) is carried through unchanged, as the reference's
267 // `repoptions = laboptions` carries it.
268 SsaSerialOptions ropt = opt;
269 ropt.method = "serial";
270 ropt.samples = per;
271 ropt.verbose = false; // the reference's VerboseLevel.SILENT per replica
272 // Replica r's stream is fixed by r alone, which is what makes the
273 // combined answer independent of the order the replicas run in and
274 // of how many run at once.
275 ropt.seed = opt.seed + static_cast<unsigned long>(r);
277 out.replica.push_back(one.avg);
278 rep_cache.push_back(one.cache);
279 out.seed.push_back(ropt.seed);
280 }
281
282 SsaSolution& a = out.avg;
283 parallel_detail::reduce_matrix(out.replica, &SsaSolution::QN, a.QN, out.QN_sem);
284 parallel_detail::reduce_matrix(out.replica, &SsaSolution::UN, a.UN, out.UN_sem);
285 parallel_detail::reduce_matrix(out.replica, &SsaSolution::RN, a.RN, out.RN_sem);
286 parallel_detail::reduce_matrix(out.replica, &SsaSolution::TN, a.TN, out.TN_sem);
287 parallel_detail::reduce_vector(out.replica, &SsaSolution::XN, a.XN, out.XN_sem);
288 parallel_detail::reduce_vector(out.replica, &SsaSolution::CN, a.CN, out.CN_sem);
289
290 // The reference averages RN and CN with the same 1/R weight it uses for
291 // the rest rather than recomputing them from the averaged QN and XN.
292 // The two differ, because a ratio of means is not a mean of ratios, and
293 // it is the reference's estimator that is reported here.
294
295 a.method = "parallel";
296 // The FIRINGS SPENT, which is not the effective sample size of the
297 // estimate: that is R, the number of independent numbers averaged.
298 // Anyone reading this field as a precision must read `*_sem` instead.
299 a.samples = 0;
300 a.simulated_time = 0.0;
301 for (std::size_t r = 0; r < R; ++r) {
302 a.samples += out.replica[r].samples;
303 a.simulated_time += out.replica[r].simulated_time;
304 }
305
306 // The Cache write-back. `hitclass` is a property of the struct, not of a
307 // replica, so the reference's `length(...hitclass) >= k` test is
308 // replica-independent and is taken once from `sn`: a class outside it
309 // keeps the zero the reference initializes and never adds to.
310 const std::size_t K = sn.nclasses;
311 for (typename std::map<std::size_t, qn::CacheParam<T> >::const_iterator ci =
312 sn.nodeparam.begin();
313 ci != sn.nodeparam.end(); ++ci) {
314 const std::size_t ind = ci->first;
315 if (ind == 0 || ind > sn.nodes.size()) continue;
316 if (sn.nodes[ind - 1].nodetype != lang::NodeType::Cache) continue;
317 SsaCacheRatio cr;
318 cr.node = ind;
319 cr.hitprob.assign(K, 0.0);
320 cr.missprob.assign(K, 0.0);
321 cr.residt.assign(K, std::numeric_limits<double>::quiet_NaN());
322 std::vector<double> dly(K, 0.0);
323 bool any_delayed = false;
324 for (std::size_t k = 1; k <= K; ++k) {
325 if (ci->second.hitclass.size() < k || ci->second.missclass.size() < k) continue;
326 double h = 0.0, m = 0.0, d = 0.0;
327 for (std::size_t r = 0; r < R; ++r)
328 for (std::size_t c = 0; c < rep_cache[r].size(); ++c) {
329 if (rep_cache[r][c].node != ind) continue;
330 h += rep_cache[r][c].hitprob[k - 1] / static_cast<double>(R);
331 m += rep_cache[r][c].missprob[k - 1] / static_cast<double>(R);
332 // A replica that saw no merge leaves the field empty
333 // rather than reporting a zero share it never measured.
334 if (!rep_cache[r][c].delayedprob.empty()) {
335 d += rep_cache[r][c].delayedprob[k - 1] / static_cast<double>(R);
336 any_delayed = true;
337 }
338 }
339 cr.hitprob[k - 1] = h;
340 cr.missprob[k - 1] = m;
341 dly[k - 1] = d;
342 }
343 if (any_delayed) cr.delayedprob = dly;
344 out.cache.push_back(cr);
345 }
346 return out;
347 }
348}
349
350/**
351 * The `para` / `parallel` entry of `solver_ssa_analyzer.m`.
352 *
353 * The reference reaches this only after its NRM eligibility gate has declined
354 * the model (`solver_ssa_analyzer.m` lines 143-157 run the NRM instead when it
355 * is eligible, because one fast exact-enough run beats replicated simulation).
356 * That preference belongs to the dispatcher and is not duplicated here, so this
357 * entry always replicates the serial engine; asking it for `serial` says so by
358 * name rather than quietly answering with one replica, whose error bar is a
359 * factor sqrt(R) wider than the one requested.
360 */
361template <class T>
363 const SsaParallelOptions& opt) {
364 const std::string& m = opt.method;
365 if (m == "para" || m == "parallel" || m == "default")
367 if (m == "serial" || m == "ssa")
368 throw UnsupportedError(
369 "SolverSSA(parallel): the '" + m +
370 "' method is ONE run of the serial engine, not the mean of " +
371 std::to_string(opt.nreplicas) +
372 " independent replicas, and the two report the same quantity at different variances. "
373 "Call solver_ssa_serial for it");
374 throw UnsupportedError("SolverSSA(parallel): '" + m +
375 "' is not a method this entry accepts; it implements 'para' and "
376 "'parallel' and the 'default' alias that reaches them");
377}
378
379} // namespace ssa
380} // namespace line
381
382#endif // LINE_SOLVERS_SSA_SOLVER_SSA_PARALLEL_H
UnsupportedError(const std::string &what)
Definition error.h:51
A network plus its refreshed NetworkStruct.
The exception types the port throws.
Dense matrix and non-owning view.
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....
SsaParallelSolution< T > solver_ssa_parallel_analyzer(const qn::NetworkStruct< T > &sn, const SsaParallelOptions &opt)
solver_ssa_analyzer_parallel.m: run R replicas of the serial engine and combine their estimates.
SsaParallelSolution< T > solver_ssa_parallel(const qn::NetworkStruct< T > &sn, const SsaParallelOptions &opt)
The para / parallel entry of solver_ssa_analyzer.m.
A queueing network and its refreshed NetworkStruct.
Number-type abstraction for the templated API port.
SolverSSA, the serial method: a port of solver_ssa_reachability.m, of the run loop of solver_ssa....
Port of EventCache.m and of the lookup that State.afterEvent performs against it (afterEvent....
Controls, results and the random source of SolverSSA.
What the cache write-back of solver_ssa_analyzer_serial.m produces.
std::size_t node
1-based Cache node index
std::vector< double > delayedprob
The delayed-hit share, EMPTY off a retrieval system.
std::vector< double > missprob
per class, NaN where undefined
std::vector< double > hitprob
std::vector< double > residt
actualresidt: NaN, and NOT a port gap.
std::size_t samples
Reaction firings to simulate; options.samples in the reference.
Definition ssa_types.h:73
std::string method
default and nrm both select the Next Reaction Method here.
Definition ssa_types.h:71
unsigned long seed
options.seed; LINE's own default is 23000.
Definition ssa_types.h:75
The replicated engine's knobs: the serial engine's, plus the two the reference reads from options....
bool eventcache
options.config.eventcache: memoize after_event inside each replica.
std::size_t nreplicas
options.config.nreplicas: R, the FIXED number of independent replicas.
What the replicated analyzer returns.
Matrix< double > QN_sem
Standard error of the replica mean, per metric: s / sqrt(R) with s the sample standard deviation ACRO...
SsaSolution avg
The replica MEAN; method = "parallel".
std::vector< unsigned long > seed
The stream each replica ran on: base_seed + r.
std::vector< SsaCacheRatio > cache
The Cache write-back of the reference's final loop, averaged over replicas.
std::size_t samples_requested
opt.samples: the budget asked for, which R * samples_per_replica rounds up.
std::size_t samples_per_replica
ceil(samples / R): the firings EACH replica performed.
std::vector< SsaSolution > replica
The R per-replica estimates, in replica order.
The serial engine's knobs: SsaOptions plus the three the serial path reads and the NRM has no use for...
The serial analyzer's return: the metric table, the path, and the stream.
std::vector< SsaCacheRatio > cache
SsaSolution avg
QN, UN, RN, TN, XN, CN; method = "serial".
What the analyzer returns, in the same shape as the MVA and fluid results.
Definition ssa_types.h:101
std::vector< double > XN
Definition ssa_types.h:103
std::vector< double > CN
Definition ssa_types.h:103
Matrix< double > UN
Definition ssa_types.h:102
Matrix< double > RN
Definition ssa_types.h:102
double simulated_time
Simulated time the metrics are averaged over; the reference's totalTime.
Definition ssa_types.h:115
Matrix< double > TN
Definition ssa_types.h:102
std::size_t samples
Reaction firings actually performed.
Definition ssa_types.h:117
Matrix< double > QN
Definition ssa_types.h:102
std::string method
The concrete algorithm, as the reference's method.
Definition ssa_types.h:113