LINE Solver (C++)
Templated C++ port of the LINE queueing solver
Loading...
Searching...
No Matches
lqn_balance_equations.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_API_LQN_LQN_BALANCE_EQUATIONS_H
6#define LINE_API_LQN_LQN_BALANCE_EQUATIONS_H
7
8/**
9 * Conservation laws of a layered queueing network, enumerated from its structure.
10 *
11 * Port of `matlab/src/api/lqn/lqn_balance_equations.m`, twin of the JAR
12 * `jline.api.lqn.LqnBalanceEquations` and of the Python
13 * `line_solver.api.lqn.balance_equations`.
14 *
15 * A layered model is not free to report any tuple of throughputs, think times and
16 * utilizations: five families of relations tie them together, and every one of them
17 * is fixed by the STRUCTURE of the model alone. This walks an `LqnStruct` and emits
18 * them, one record per relation, with the index sets to aggregate over, the constant
19 * coefficients and a printable form. NOTHING IS SOLVED HERE.
20 *
21 * The families, with `kind` as emitted:
22 *
23 * `little` Little's law on a task's THREAD POOL. The threads of task t form a
24 * closed cycle of one delay stage (the surrogate think time SolverLN
25 * imputes to the task, plus the declared think time of a reference task)
26 * and one service stage (holding a request from above). With B(t,k) the
27 * mean number of threads of t busy serving caller class k -- the
28 * per-class utilization in JOB units --
29 *
30 * X(t)*(Z(t) + z(t)) + sum_k B(t,k) = N(t)
31 *
32 * which is the update `updateThinkTimes` iterates on. In the
33 * [0,1]-normalized utilization LINE reports for a queueing station,
34 * B(t,k) = N(t)*U(t,k), giving X(t)*(Z(t)+z(t)) = N(t)*(1 - sum_k
35 * U(t,k)); at an infinite server the utilization is already a job count,
36 * so B = U. The caller classes k are the CALLS targeting an entry of t --
37 * the in-edges of t in the call graph -- plus each entry of t carrying an
38 * OPEN ARRIVAL, a stream that holds a thread exactly as a call does and
39 * that a task can have alongside its callers. Both are structural
40 * neighbours of t, which makes the relation node-local; `termisentry`
41 * says which of the two a term is.
42 *
43 * `callflow` X(c) = X(src(c))*y(c), with y(c) the mean number of calls and src(c)
44 * the dispatching activity (the dispatching ENTRY for a forwarding call).
45 *
46 * `entryflow` X(e) = sum_c X(c) + lambda(e): the requests an entry serves are the
47 * calls reaching it plus its open-arrival stream.
48 *
49 * `actflow` X(a) = X(e)*v(a): an activity executes v(a) times per invocation of its
50 * entry. An AND-JOIN is the one place where flow does not add up -- its
51 * target executes once per fork, not once per branch -- so the arcs into
52 * a join are scaled by 1/(number of joined branches).
53 *
54 * `hostutil` sum_a X(a)*D(a) = m(h)*U(h), the utilization law at a processor;
55 * m(h)*U(h) is a job count and the factor m(h) drops at an infinite
56 * server.
57 *
58 * Together these close the system: `little` alone is one equation per task and admits
59 * the all-zero solution, so a physics-informed loss built on it should carry the flow
60 * and utilization families as well.
61 *
62 * CONVENTIONS. Rates and populations in a `little` record are PER REPLICA, matching
63 * `updateThinkTimes`: X is tput/repl and N is the multiplicity of one copy. Elsewhere
64 * throughputs and utilizations are as the solver reports them, totalled over
65 * replicas, which is why the server count in `hostutil` is `mult` and NOT
66 * `mult*repl`. N(t) is `lqn.mult`; SolverLN iterates on `njobs`, which carries the
67 * interlocking corrections and may be `maxmult` under replication, so both are
68 * returned per record. S(k) is the entry SERVICE time (phase 1 plus phase 2), the
69 * time a thread is held, not the residence time the caller waits for; the difference
70 * is the phase-2 tail, flagged by `phase2`.
71 *
72 * SATURATION. SolverLN clamps the think time at zero, so the `little` equality is an
73 * INEQUALITY at a saturated task: when sum_k U(t,k) -> 1 the right-hand side reaches
74 * zero and Z can no longer absorb the imbalance. `clamped` marks those once
75 * instantiated, and a loss built on these relations should use a one-sided (hinge)
76 * form there.
77 */
78
79#include <algorithm>
80#include <cmath>
81#include <cstddef>
82#include <limits>
83#include <map>
84#include <sstream>
85#include <string>
86#include <vector>
87
90#include "line/num/number.h"
91#include "line/util/error.h"
92#include "line/util/lu.h"
93#include "line/util/matrix.h"
94
95namespace line {
96namespace lqn {
97
98/** One conservation law, as an aggregation over a node's structural neighbours. */
99template <class T>
101 std::string kind; ///< little | callflow | entryflow | actflow | hostutil
102 std::string branch; ///< the case that produced it: ref/inf/queueing/fwd/arrival, or a call type
103 std::size_t target = 0; ///< absolute index the relation is anchored on
104 std::string targetname;
105 std::vector<std::size_t> terms; ///< absolute indices to aggregate over
106 /// per term, true for an ENTRY class (open arrival, or a reference task's own
107 /// cycle) and false for a CALL class; the two read their rate from different vectors
108 std::vector<bool> termisentry;
109 std::vector<T> coeff; ///< constant coefficient of each term
110 T rhsconst = num_traits<T>::from_int(0); ///< right-hand-side constant
111 double mult = 0.0, maxmult = 0.0, repl = 1.0;
112 bool scaled = false; ///< the per-class utilization needs *mult to reach job units
113 bool phase2 = false, setup = false;
114 bool degenerate = false; ///< unusable as a residual: infinite mult, no terms, zero call count
115 bool clamped = false; ///< instantiated and saturated: the equality is unattainable
116 std::string text;
117 /// NaN until instantiated. `has_residual` is false when no solution was supplied,
118 /// or when this relation needs a datum the solution does not carry.
119 bool has_residual = false;
122 std::vector<T> perclassutil;
123};
124
125/**
126 * The iterates of a solved layered model, indexed by ABSOLUTE element index.
127 *
128 * The five vectors mirror the same-named members of the layered solver. `un` is the
129 * REPORTED utilization, which is not the same quantity as `util`: `util` holds a
130 * task's utilization as a SERVER in its own task layer -- the U that closes the
131 * thread-pool cycle -- and is left at zero on a host. The `hostutil` family needs the
132 * reported one, so it is supplied separately rather than read off the solver:
133 * `getEnsembleAvg` re-enters the fixed point in every codebase, and a diagnostic must
134 * not re-run one as a side effect of being asked a question. A `hostutil` record
135 * whose host has no `un` entry is emitted symbolically with `has_residual` false.
136 */
137template <class T>
139 std::vector<T> tput, util, thinkt, servt, residt, un;
140};
141
142/** The relation set of one layered model. */
143template <class T>
145 std::vector<LqnRelation<T>> eqs;
146 std::vector<T> visits; ///< (nidx+1) executions of each activity per invocation of its entry
147 std::vector<std::string> convention;
148 Matrix<T> A_little; ///< (ntasks+1, ncalls+1) 1 where call c is a caller class of task t
149 Matrix<T> A_flow; ///< (ncalls+1, nidx+1) call-to-source incidence weighted by y(c)
150 Matrix<T> A_host; ///< (nhosts+1, nidx+1) host-to-activity incidence weighted by D(a)
151 bool has_maxresidual = false;
153 std::vector<std::string> text;
154
155 std::string str() const {
156 std::ostringstream os;
157 for (std::size_t i = 0; i < text.size(); ++i) {
158 if (i) os << '\n';
159 os << text[i];
160 }
161 return os.str();
162 }
163};
164
165namespace detail {
166
167/** A number as the reference prints it: an integer bare, Inf as "Inf". */
168template <class T>
169inline std::string lqn_num(const T& x) {
170 const double d = num_traits<T>::to_double(x);
171 if (std::isinf(d)) return d > 0 ? "Inf" : "-Inf";
172 if (std::isnan(d)) return "NaN";
173 if (d == std::floor(d) && std::fabs(d) < 1e15) {
174 std::ostringstream os;
175 os << static_cast<long long>(d);
176 return os.str();
177 }
178 std::ostringstream os;
179 os.precision(6);
180 os << d;
181 return os.str();
182}
183
184inline std::string lqn_num_d(double d) {
185 if (std::isinf(d)) return d > 0 ? "Inf" : "-Inf";
186 if (std::isnan(d)) return "NaN";
187 if (d == std::floor(d) && std::fabs(d) < 1e15) {
188 std::ostringstream os;
189 os << static_cast<long long>(d);
190 return os.str();
191 }
192 std::ostringstream os;
193 os.precision(6);
194 os << d;
195 return os.str();
196}
197
198template <class T>
199inline std::string lqn_elem_name(const LqnStruct<T>& lqn, std::size_t idx) {
200 if (idx < lqn.hashnames.size() && !lqn.hashnames[idx].empty()) return lqn.hashnames[idx];
201 std::ostringstream os;
202 os << '#' << idx;
203 return os.str();
204}
205
206template <class T>
207inline std::string lqn_call_name(const LqnStruct<T>& lqn, std::size_t cidx) {
208 if (cidx < lqn.callhashnames.size() && !lqn.callhashnames[cidx].empty())
209 return lqn.callhashnames[cidx];
210 std::ostringstream os;
211 os << 'C' << cidx;
212 return os.str();
213}
214
215template <class T>
216inline std::string lqn_term_name(const LqnStruct<T>& lqn, bool isentry, std::size_t idx) {
217 return isentry ? lqn_elem_name(lqn, idx) : lqn_call_name(lqn, idx);
218}
219
220/** Element of a solution vector, zero where the vector does not reach. */
221template <class T>
222inline T lqn_sol_at(const std::vector<T>& v, std::size_t i) {
223 if (i < v.size()) {
224 const double d = num_traits<T>::to_double(v[i]);
225 if (!std::isnan(d)) return v[i];
226 }
227 return num_traits<T>::from_int(0);
228}
229
230/** Declared multiplicity, or an out-of-range index as a NaN-like sentinel. */
231template <class T>
232inline double lqn_mult_of(const std::vector<double>& v, std::size_t i) {
233 return i < v.size() ? v[i] : std::numeric_limits<double>::quiet_NaN();
234}
235
236/**
237 * The precedence graph with the arcs into every AND-join target divided by the number
238 * of branches the join waits for.
239 *
240 * An AND-JOIN is the one place where flow does not add up: its target executes ONCE
241 * per fork, not once per branch, so summing the inbound arcs would count it as many
242 * times as there are branches. Dividing recovers the rate of one branch exactly when
243 * the branches carry equal rate, the case for a well-formed fork/join block.
244 * `actpretype` marks the joined PREDECESSORS, so a join target is any successor of one.
245 */
246template <class T>
247inline SparseGraph<T> lqn_join_scaled_graph(const LqnStruct<T>& lqn) {
248 SparseGraph<T> G = lqn.graph;
249 std::vector<std::size_t> andpre;
250 for (std::size_t i = 1; i < lqn.actpretype.size() && i <= G.n; ++i)
251 if (lqn.actpretype[i] == lang::PrecedenceType::PRE_AND) andpre.push_back(i);
252 if (andpre.empty()) return G;
253 const T zero = num_traits<T>::from_int(0);
254 std::map<std::size_t, std::vector<std::size_t>> joined;
255 for (std::size_t i : andpre)
256 for (const std::pair<std::size_t, T>& e : G.row[i])
257 if (e.second != zero) joined[e.first].push_back(i);
258 for (std::map<std::size_t, std::vector<std::size_t>>::const_iterator it = joined.begin();
259 it != joined.end(); ++it) {
260 if (it->second.size() < 2) continue;
261 const T k = num_traits<T>::from_int(static_cast<int>(it->second.size()));
262 for (std::size_t i : it->second) G.set(i, it->first, T(G.get(i, it->first) / k));
263 }
264 return G;
265}
266
267/**
268 * Expected executions of every activity per invocation of its entry.
269 *
270 * The activity precedence arcs of a task are a transient Markov chain whose absorbing
271 * state is the reply, so the visit counts of the block solve v = e0*(I-P)^-1: a loop
272 * back-edge of weight 1-1/count returns count, and an AND-fork row summing above one
273 * returns the branching expectation. Call arcs leave the block and drop out.
274 */
275template <class T>
276inline std::vector<T> lqn_act_visits(const LqnStruct<T>& lqn) {
277 const T zero = num_traits<T>::from_int(0), one = num_traits<T>::from_int(1);
278 std::vector<T> v(lqn.nidx + 1, zero);
279 const SparseGraph<T> G = lqn_join_scaled_graph(lqn);
280 for (std::size_t t = 1; t <= lqn.ntasks; ++t) {
281 const std::size_t tidx = lqn.tshift + t;
282 if (tidx >= lqn.actsof.size()) continue;
283 std::vector<std::size_t> A = lqn.actsof[tidx];
284 std::sort(A.begin(), A.end());
285 A.erase(std::unique(A.begin(), A.end()), A.end());
286 if (A.empty()) continue;
287 std::map<std::size_t, std::size_t> pos;
288 for (std::size_t i = 0; i < A.size(); ++i) pos[A[i]] = i;
289 // (I-P) transposed, so that solving yields the row vector v as a column
290 Matrix<T> M(A.size(), A.size(), zero);
291 for (std::size_t i = 0; i < A.size(); ++i)
292 for (std::size_t j = 0; j < A.size(); ++j)
293 M(j, i) = T((i == j ? one : zero) - (A[i] <= G.n ? G.get(A[i], A[j]) : zero));
294 for (std::size_t eidx : lqn.entriesof[tidx]) {
295 std::vector<T> e0(A.size(), zero);
296 bool any = false;
297 if (eidx <= G.n) {
298 for (const std::pair<std::size_t, T>& e : G.row[eidx]) {
299 std::map<std::size_t, std::size_t>::const_iterator it = pos.find(e.first);
300 if (it != pos.end() && e.second != zero) {
301 e0[it->second] = one;
302 any = true;
303 }
304 }
305 }
306 if (!any) continue;
307 const std::vector<T> x = line::solve(M, e0);
308 for (std::size_t i = 0; i < A.size(); ++i) v[A[i]] = T(v[A[i]] + x[i]);
309 }
310 }
311 return v;
312}
313
314template <class T>
315inline T lqn_arrival_rate(const LqnStruct<T>& lqn, std::size_t eidx) {
316 const T zero = num_traits<T>::from_int(0), one = num_traits<T>::from_int(1);
317 if (eidx >= lqn.has_arrival.size() || !lqn.has_arrival[eidx]) return zero;
318 if (eidx >= lqn.arrival.size() || lqn.arrival[eidx].disabled) return zero;
319 const T m = lqn.arrival[eidx].mean;
320 const double d = num_traits<T>::to_double(m);
321 if (std::isfinite(d) && d > 1e-12) return T(one / m);
322 return zero;
323}
324
325/** Calls targeting each task, i.e. the caller classes of its thread pool. */
326template <class T>
327inline std::map<std::size_t, std::vector<std::size_t>> lqn_calls_into(const LqnStruct<T>& lqn) {
328 std::map<std::size_t, std::vector<std::size_t>> out;
329 for (std::size_t cidx = 1; cidx <= lqn.ncalls; ++cidx) {
330 const std::size_t dst = lqn.callpair_dst[cidx];
331 if (dst < 1 || dst > lqn.nidx) continue;
332 out[lqn.parent[dst]].push_back(cidx);
333 }
334 return out;
335}
336
337template <class T>
338inline std::vector<std::size_t> lqn_incoming_calls(const LqnStruct<T>& lqn, std::size_t eidx) {
339 std::vector<std::size_t> inc;
340 for (std::size_t cidx = 1; cidx <= lqn.ncalls; ++cidx)
341 if (lqn.callpair_dst[cidx] == eidx) inc.push_back(cidx);
342 return inc;
343}
344
345/**
346 * Entry an activity belongs to: the one whose activity block contains it. An activity
347 * shared by two entries is attributed to the first, matching how lqn_act_visits
348 * accumulates its visit counts.
349 */
350template <class T>
351inline std::size_t lqn_entry_of_activity(const LqnStruct<T>& lqn, std::size_t aidx) {
352 const std::size_t tidx = lqn.parent[aidx];
353 if (tidx >= lqn.entriesof.size()) return 0;
354 for (std::size_t eidx : lqn.entriesof[tidx]) {
355 if (eidx >= lqn.actsof.size()) continue;
356 const std::vector<std::size_t>& as = lqn.actsof[eidx];
357 if (std::find(as.begin(), as.end(), aidx) != as.end()) return eidx;
358 }
359 return 0;
360}
361
362template <class T>
363inline bool lqn_has_phase2(const LqnStruct<T>& lqn, std::size_t tidx) {
364 if (tidx >= lqn.entriesof.size()) return false;
365 for (std::size_t eidx : lqn.entriesof[tidx]) {
366 if (eidx >= lqn.actsof.size()) continue;
367 for (std::size_t aidx : lqn.actsof[eidx]) {
368 const std::size_t a = aidx - lqn.ashift;
369 if (a >= 1 && a < lqn.actphase.size() && lqn.actphase[a] > 1) return true;
370 }
371 }
372 return false;
373}
374
375/**
376 * Declared think time of a task as it enters the thread cycle: the value for a
377 * REFERENCE task, zero for any other. Twin of MATLAB lqn_ref_thinktime.
378 */
379template <class T>
380inline T lqn_ref_thinktime(const LqnStruct<T>& lqn, std::size_t tidx) {
381 const T zero = num_traits<T>::from_int(0);
382 if (tidx >= lqn.isref.size() || !lqn.isref[tidx]) return zero;
383 if (tidx >= lqn.think.size() || lqn.think[tidx].disabled) return zero;
384 const double d = num_traits<T>::to_double(lqn.think[tidx].mean);
385 if (!std::isfinite(d) || d < 0) return zero;
386 return lqn.think[tidx].mean;
387}
388
389/** A thread pool's case label and its caller classes. */
390struct LqnPool {
391 std::string branch;
392 std::vector<std::size_t> terms;
393 std::vector<bool> isentry;
394 bool present = false;
395};
396
397/**
398 * Which thread-pool case task TIDX falls in, and the caller classes of its pool.
399 *
400 * The term set is uniform across the cases: every request stream that can hold a
401 * thread of the task contributes one class. That is each CALL targeting one of its
402 * entries, plus each entry carrying an OPEN ARRIVAL, plus, for a reference task, its
403 * own entries, since the cycle of a reference task closes on itself and no layer above
404 * drives it. The branch label follows the case analysis of `updateThinkTimes`, which
405 * is what decides whether the utilization is a job count (infinite server) or is
406 * normalized to [0,1] (every other discipline).
407 */
408template <class T>
409inline LqnPool lqn_thread_pool_branch(const LqnStruct<T>& lqn, std::size_t tidx,
410 const std::vector<std::size_t>& cin) {
411 const T zero = num_traits<T>::from_int(0);
412 LqnPool p;
413 std::vector<std::size_t> ents;
414 if (tidx < lqn.entriesof.size()) ents = lqn.entriesof[tidx];
415 std::vector<std::size_t> arv;
416 for (std::size_t eidx : ents)
417 if (lqn_arrival_rate(lqn, eidx) != zero) arv.push_back(eidx);
418 for (std::size_t c : cin) {
419 p.terms.push_back(c);
420 p.isentry.push_back(false);
421 }
422 for (std::size_t e : arv) {
423 p.terms.push_back(e);
424 p.isentry.push_back(true);
425 }
426 if (tidx < lqn.isref.size() && lqn.isref[tidx]) {
427 p.branch = "ref";
428 for (std::size_t e : ents)
429 if (std::find(arv.begin(), arv.end(), e) == arv.end()) {
430 p.terms.push_back(e);
431 p.isentry.push_back(true);
432 }
433 p.present = true;
434 return p;
435 }
436 bool blocking = false;
437 for (std::size_t c : cin)
438 if (lqn.calltype[c] != lang::CallType::FWD) {
439 blocking = true;
440 break;
441 }
442 if (blocking) {
443 p.branch = (lqn.sched[tidx] == lang::SchedStrategy::INF) ? "inf" : "queueing";
444 } else if (!cin.empty()) {
445 p.branch = "fwd"; // a forwarded request holds a thread too
446 } else if (!arv.empty()) {
447 p.branch = "arrival";
448 } else {
449 return p; // no caller, no arrival: no cycle to close
450 }
451 p.present = true;
452 return p;
453}
454
455} // namespace detail
456
457/**
458 * Enumerate the conservation laws of the layered model `lqn`.
459 *
460 * When `sol` is non-null every relation is also instantiated and its residual
461 * reported. On a converged fixed point they vanish to the solver's own tolerance; a
462 * residual that does not is either a documented convention difference (see `mult`
463 * against `maxmult`) or a defect in the solution.
464 */
465template <class T>
467 const LqnSolution<T>* sol = nullptr) {
468 using namespace detail;
469 const T zero = num_traits<T>::from_int(0), one = num_traits<T>::from_int(1);
470 const std::size_t nidx = lqn.nidx;
471
473 out.visits = lqn_act_visits(lqn);
474 const std::map<std::size_t, std::vector<std::size_t>> callsinto = lqn_calls_into(lqn);
475
476 // A call throughput is not an iterate of the layered solver: a call inherits the
477 // rate of its dispatching element scaled by the mean call count.
478 std::vector<T> calltput(lqn.ncalls + 1, zero);
479 if (sol) {
480 for (std::size_t cidx = 1; cidx <= lqn.ncalls; ++cidx) {
481 const std::size_t src = lqn.callpair_src[cidx];
482 if (src >= 1 && src <= nidx)
483 calltput[cidx] = T(lqn_sol_at(sol->tput, src) * lqn.callproc_mean[cidx]);
484 }
485 }
486
487 // ---------------- Little's law on each thread pool -----------------------
488 for (std::size_t t = 1; t <= lqn.ntasks; ++t) {
489 const std::size_t tidx = lqn.tshift + t;
490 std::vector<std::size_t> cin;
491 std::map<std::size_t, std::vector<std::size_t>>::const_iterator ci = callsinto.find(tidx);
492 if (ci != callsinto.end()) cin = ci->second;
493 const LqnPool pool = lqn_thread_pool_branch(lqn, tidx, cin);
494 if (!pool.present) continue;
495
497 r.kind = "little";
498 r.branch = pool.branch;
499 r.target = tidx;
500 r.targetname = lqn_elem_name(lqn, tidx);
501 r.terms = pool.terms;
502 r.termisentry = pool.isentry;
503 r.coeff.assign(pool.terms.size(), one);
504 r.mult = lqn_mult_of<T>(lqn.mult, tidx);
505 r.maxmult = lqn_mult_of<T>(lqn.maxmult, tidx);
506 r.repl = (tidx < lqn.repl.size() && lqn.repl[tidx] > 1.0) ? lqn.repl[tidx] : 1.0;
508 r.scaled = pool.branch != "inf";
509 r.phase2 = lqn_has_phase2(lqn, tidx);
510 r.setup = tidx < lqn.hassetup.size() && lqn.hassetup[tidx];
511 r.degenerate = !std::isfinite(r.mult) || pool.terms.empty();
512
513 const T z = lqn_ref_thinktime(lqn, tidx);
514 {
515 std::ostringstream lhs;
516 lhs << "X(" << r.targetname << ")*(Z(" << r.targetname << ")";
517 if (num_traits<T>::to_double(z) > 0) lhs << " + " << lqn_num(z);
518 lhs << ")";
519 for (std::size_t i = 0; i < r.terms.size(); ++i)
520 lhs << " + B(" << lqn_term_name(lqn, r.termisentry[i], r.terms[i]) << ")";
521 std::ostringstream txt;
522 txt << lhs.str() << " = " << lqn_num_d(r.mult);
523 if (r.scaled && !r.terms.empty()) {
524 txt << "\n equivalently X(" << r.targetname << ")*Z(" << r.targetname
525 << ") = " << lqn_num_d(r.mult) << "*(1";
526 for (std::size_t i = 0; i < r.terms.size(); ++i)
527 txt << " - U(" << r.targetname << ","
528 << lqn_term_name(lqn, r.termisentry[i], r.terms[i]) << ")";
529 txt << ")";
530 }
531 r.text = txt.str();
532 }
533
534 if (sol) {
535 const T X = T(lqn_sol_at(sol->tput, tidx) / num_traits<T>::from_double(r.repl));
536 T sumB = zero;
537 r.perclassutil.clear();
538 const bool jobunits = lqn.sched[tidx] == lang::SchedStrategy::INF ||
539 !std::isfinite(r.mult) || r.mult <= 0;
540 for (std::size_t i = 0; i < r.terms.size(); ++i) {
541 T b;
542 if (r.termisentry[i]) {
543 b = T(lqn_sol_at(sol->tput, r.terms[i]) * lqn_sol_at(sol->servt, r.terms[i]));
544 } else {
545 b = T(calltput[r.terms[i]] *
546 lqn_sol_at(sol->servt, lqn.callpair_dst[r.terms[i]]));
547 }
548 sumB = T(sumB + b);
549 r.perclassutil.push_back(jobunits ? b : T(b / num_traits<T>::from_double(r.mult)));
550 }
551 const T zt = lqn_sol_at(sol->thinkt, tidx);
552 r.lhs = T(X * T(zt + z) + sumB);
553 r.rhs = r.rhsconst;
554 r.residual = T(r.lhs - r.rhs);
555 const double den = std::max(std::fabs(num_traits<T>::to_double(r.rhs)), 1e-12);
557 r.clamped = std::isfinite(r.mult) &&
558 num_traits<T>::to_double(T(r.rhsconst - sumB - T(X * z))) < 0;
559 r.has_residual = true;
560 }
561 out.eqs.push_back(r);
562 }
563
564 // ---------------- flow conservation --------------------------------------
565 for (std::size_t cidx = 1; cidx <= lqn.ncalls; ++cidx) {
567 r.kind = "callflow";
568 const lang::CallType ct = lqn.calltype[cidx];
569 r.branch = ct == lang::CallType::SYNC ? "sync"
570 : ct == lang::CallType::ASYNC ? "async"
571 : ct == lang::CallType::FWD ? "fwd"
572 : "";
573 r.target = lqn.cshift + cidx;
574 r.targetname = lqn_call_name(lqn, cidx);
575 const std::size_t src = lqn.callpair_src[cidx];
576 const T y = lqn.callproc_mean[cidx];
577 r.terms.push_back(src);
578 r.coeff.push_back(y);
579 r.degenerate = (y == zero);
580 std::ostringstream txt;
581 txt << "X(" << r.targetname << ") = X(" << lqn_elem_name(lqn, src) << ") * " << lqn_num(y);
582 r.text = txt.str();
583 if (sol) {
584 r.lhs = calltput[cidx];
585 r.rhs = T(lqn_sol_at(sol->tput, src) * y);
586 r.residual = T(r.lhs - r.rhs);
587 const double den = std::max(std::fabs(num_traits<T>::to_double(r.rhs)), 1e-12);
589 r.has_residual = true;
590 }
591 out.eqs.push_back(r);
592 }
593
594 for (std::size_t e = 1; e <= lqn.nentries; ++e) {
595 const std::size_t eidx = lqn.eshift + e;
596 const std::vector<std::size_t> inc = lqn_incoming_calls(lqn, eidx);
597 const T lam = lqn_arrival_rate(lqn, eidx);
598 if (inc.empty() && lam == zero) continue; // a reference entry is driven by its own cycle
600 r.kind = "entryflow";
601 r.target = eidx;
602 r.targetname = lqn_elem_name(lqn, eidx);
603 r.terms = inc;
604 r.coeff.assign(inc.size(), one);
605 r.rhsconst = lam;
606 std::ostringstream txt;
607 txt << "X(" << r.targetname << ") =";
608 for (std::size_t i = 0; i < inc.size(); ++i)
609 txt << (i == 0 ? " X(" : " + X(") << lqn_call_name(lqn, inc[i]) << ")";
610 if (lam != zero)
611 txt << (inc.empty() ? " " : " + ") << lqn_num(lam) << " (open arrival)";
612 r.text = txt.str();
613 if (sol) {
614 r.lhs = lqn_sol_at(sol->tput, eidx);
615 T rhs = lam;
616 for (std::size_t c : inc) rhs = T(rhs + calltput[c]);
617 r.rhs = rhs;
618 r.residual = T(r.lhs - r.rhs);
619 const double den = std::max(std::fabs(num_traits<T>::to_double(r.rhs)), 1e-12);
621 r.has_residual = true;
622 }
623 out.eqs.push_back(r);
624 }
625
626 for (std::size_t a = 1; a <= lqn.nacts; ++a) {
627 const std::size_t aidx = lqn.ashift + a;
628 const std::size_t eidx = lqn_entry_of_activity(lqn, aidx);
629 if (eidx == 0) continue;
631 r.kind = "actflow";
632 r.target = aidx;
633 r.targetname = lqn_elem_name(lqn, aidx);
634 r.terms.push_back(eidx);
635 r.coeff.push_back(out.visits[aidx]);
636 r.degenerate = !std::isfinite(num_traits<T>::to_double(out.visits[aidx]));
637 std::ostringstream txt;
638 txt << "X(" << r.targetname << ") = X(" << lqn_elem_name(lqn, eidx) << ") * "
639 << lqn_num(out.visits[aidx]);
640 r.text = txt.str();
641 if (sol) {
642 r.lhs = lqn_sol_at(sol->tput, aidx);
643 r.rhs = T(lqn_sol_at(sol->tput, eidx) * out.visits[aidx]);
644 r.residual = T(r.lhs - r.rhs);
645 const double den = std::max(std::fabs(num_traits<T>::to_double(r.rhs)), 1e-12);
647 r.has_residual = true;
648 }
649 out.eqs.push_back(r);
650 }
651
652 // ---------------- utilization law at each processor ----------------------
653 for (std::size_t h = 1; h <= lqn.nhosts; ++h) {
654 const std::size_t hidx = lqn.hshift + h;
656 r.kind = "hostutil";
657 r.target = hidx;
658 r.targetname = lqn_elem_name(lqn, hidx);
659 r.mult = lqn_mult_of<T>(lqn.mult, hidx);
660 r.repl = (hidx < lqn.repl.size() && lqn.repl[hidx] > 1.0) ? lqn.repl[hidx] : 1.0;
661 r.scaled = lqn.sched[hidx] != lang::SchedStrategy::INF;
662 if (hidx < lqn.tasksof.size()) {
663 for (std::size_t tidx : lqn.tasksof[hidx]) {
664 if (tidx >= lqn.actsof.size()) continue;
665 for (std::size_t aidx : lqn.actsof[tidx]) {
666 if (aidx >= lqn.hostdem.size() || lqn.hostdem[aidx].disabled) continue;
667 const T d = lqn.hostdem[aidx].mean;
668 if (d == zero) continue;
669 r.terms.push_back(aidx);
670 r.coeff.push_back(d);
671 }
672 }
673 }
674 // The server count is the declared multiplicity ALONE, not mult*repl: a
675 // replicated host reports its throughputs and its utilization as TOTALS over
676 // the copies, so the extra factor would double-count the replication.
677 double m = r.mult;
678 if (!r.scaled || !std::isfinite(m)) m = 1.0;
680 r.branch = r.scaled ? "queueing" : "inf";
681 r.degenerate = r.terms.empty();
682 std::ostringstream txt;
683 for (std::size_t i = 0; i < r.terms.size(); ++i) {
684 if (i) txt << " + ";
685 txt << "X(" << lqn_elem_name(lqn, r.terms[i]) << ")*" << lqn_num(r.coeff[i]);
686 }
687 std::string lhs = txt.str();
688 if (lhs.empty()) lhs = "0";
689 std::ostringstream full;
690 full << lhs << " = " << lqn_num_d(m) << "*U(" << r.targetname << ")";
691 r.text = full.str();
692 const bool haveun =
693 sol && hidx < sol->un.size() && !std::isnan(num_traits<T>::to_double(sol->un[hidx]));
694 if (haveun) {
695 T v = zero;
696 for (std::size_t i = 0; i < r.terms.size(); ++i)
697 v = T(v + lqn_sol_at(sol->tput, r.terms[i]) * r.coeff[i]);
698 r.lhs = v;
699 r.rhs = T(r.rhsconst * sol->un[hidx]);
700 r.residual = T(r.lhs - r.rhs);
701 const double den = std::max(std::fabs(num_traits<T>::to_double(r.rhs)), 1e-12);
703 r.has_residual = true;
704 }
705 out.eqs.push_back(r);
706 }
707
708 // ---------------- aggregation incidences ---------------------------------
709 out.A_little = Matrix<T>(lqn.ntasks + 1, lqn.ncalls + 1, zero);
710 out.A_flow = Matrix<T>(lqn.ncalls + 1, nidx + 1, zero);
711 out.A_host = Matrix<T>(lqn.nhosts + 1, nidx + 1, zero);
712 for (const LqnRelation<T>& r : out.eqs) {
713 if (r.kind == "little") {
714 // the call classes only; an entry class (open arrival, or the self-driven
715 // cycle of a reference task) is not a call
716 for (std::size_t i = 0; i < r.terms.size(); ++i)
717 if (!r.termisentry[i]) out.A_little(r.target - lqn.tshift, r.terms[i]) = one;
718 } else if (r.kind == "callflow") {
719 out.A_flow(r.target - lqn.cshift, r.terms[0]) = r.coeff[0];
720 } else if (r.kind == "hostutil") {
721 for (std::size_t i = 0; i < r.terms.size(); ++i)
722 out.A_host(r.target - lqn.hshift, r.terms[i]) = r.coeff[i];
723 }
724 }
725
726 for (const LqnRelation<T>& r : out.eqs) {
727 if (r.degenerate || !r.has_residual) continue;
729 if (!out.has_maxresidual || v > out.maxresidual) {
730 out.maxresidual = v;
731 out.has_maxresidual = true;
732 }
733 }
734
735 out.convention.push_back("Conventions:");
736 out.convention.push_back(
737 " little : rates and populations PER REPLICA (X = tput/repl, N = mult of one copy).");
738 out.convention.push_back(
739 " B(t,k) is the per-class utilization in JOB units; for a queueing task");
740 out.convention.push_back(
741 " B = mult*U with U in [0,1], at an infinite server B = U directly.");
742 out.convention.push_back(
743 " S(k) is the entry SERVICE time (phase 1 + phase 2), the thread hold time.");
744 out.convention.push_back(
745 " other : throughputs and utilizations as the solver reports them, totalled over "
746 "replicas.");
747 out.convention.push_back(
748 " N(t) : lqn.mult. SolverLN iterates on njobs (interlocking corrections, maxmult "
749 "under");
750 out.convention.push_back(" replication), reported per record as mult/maxmult.");
751
752 // ---------------- report --------------------------------------------------
753 {
754 std::ostringstream hdr;
755 hdr << "LQN balance equations: " << lqn.nhosts << " hosts, " << lqn.ntasks << " tasks, "
756 << lqn.nentries << " entries, " << lqn.nacts << " activities, " << lqn.ncalls
757 << " calls";
758 out.text.push_back(hdr.str());
759 }
760 for (const std::string& c : out.convention) out.text.push_back(c);
761 const char* kinds[5] = {"little", "callflow", "entryflow", "actflow", "hostutil"};
762 const char* titles[5] = {"thread-pool Little's law", "call-flow balance", "entry-flow balance",
763 "activity-flow balance", "host utilization law"};
764 for (int k = 0; k < 5; ++k) {
765 std::vector<std::size_t> sel;
766 for (std::size_t i = 0; i < out.eqs.size(); ++i)
767 if (out.eqs[i].kind == kinds[k]) sel.push_back(i);
768 if (sel.empty()) continue;
769 out.text.push_back("");
770 {
771 std::ostringstream os;
772 os << "--- " << titles[k] << " (kind='" << kinds[k] << "', " << sel.size()
773 << " relations) ---";
774 out.text.push_back(os.str());
775 }
776 for (std::size_t i : sel) {
777 const LqnRelation<T>& r = out.eqs[i];
778 std::ostringstream head;
779 head << "[" << (i + 1) << "] " << r.targetname;
780 if (r.kind == "little" || r.kind == "hostutil")
781 head << " " << r.branch << " mult=" << lqn_num_d(r.mult)
782 << " repl=" << lqn_num_d(r.repl);
783 else if (!r.branch.empty())
784 head << " " << r.branch;
785 out.text.push_back(head.str());
786 std::string body = r.text;
787 std::size_t start = 0;
788 while (start <= body.size()) {
789 const std::size_t nl = body.find('\n', start);
790 const std::string line =
791 body.substr(start, nl == std::string::npos ? std::string::npos : nl - start);
792 out.text.push_back(" " + line);
793 if (nl == std::string::npos) break;
794 start = nl + 1;
795 }
796 std::vector<std::string> notes;
797 if (r.phase2) notes.push_back("phase-2 tail on Z");
798 if (r.setup) notes.push_back("setup charge on Z");
799 if (r.degenerate) notes.push_back("DEGENERATE (not usable as a residual)");
800 if (r.clamped) notes.push_back("SATURATED (equality unattainable, use a hinge)");
801 if (sol && !r.degenerate && !r.has_residual)
802 notes.push_back("not instantiated (pass un for the host utilization law)");
803 if (!notes.empty()) {
804 std::ostringstream os;
805 os << " note: ";
806 for (std::size_t j = 0; j < notes.size(); ++j) {
807 if (j) os << ", ";
808 os << notes[j];
809 }
810 out.text.push_back(os.str());
811 }
812 if (r.has_residual && !r.degenerate) {
813 std::ostringstream os;
814 os.precision(6);
815 os << " lhs=" << num_traits<T>::to_double(r.lhs)
816 << " rhs=" << num_traits<T>::to_double(r.rhs)
817 << " residual=" << num_traits<T>::to_double(r.residual)
818 << " rel=" << num_traits<T>::to_double(r.relresidual);
819 out.text.push_back(os.str());
820 }
821 }
822 }
823 if (sol) {
824 std::size_t nd = 0;
825 for (const LqnRelation<T>& r : out.eqs)
826 if (!r.degenerate) ++nd;
827 out.text.push_back("");
828 std::ostringstream os;
829 os.precision(3);
830 os << "max |residual| over " << nd << " non-degenerate relations: "
832 : std::numeric_limits<double>::quiet_NaN());
833 out.text.push_back(os.str());
834 }
835
836 return out;
837}
838
839} // namespace lqn
840} // namespace line
841
842#endif // LINE_API_LQN_LQN_BALANCE_EQUATIONS_H
The exception types the port throws.
Enumerations and the minimal distribution descriptor shared by the model layer of the C++ port.
LayeredNetworkStruct, the flattened description of a layered queueing network.
LU factorization with partial pivoting, templated on the number type.
Dense matrix and non-owning view.
CallType
Call kinds, with the values of MATLAB CallType.
Definition lang_types.h:469
LqnBalanceEquations< T > lqn_balance_equations(const LqnStruct< T > &lqn, const LqnSolution< T > *sol=nullptr)
Enumerate the conservation laws of the layered model lqn.
Conservation laws of a layered queueing network, enumerated from its structure.
Definition aoi_dist2ph.h:52
std::vector< T > solve(const Matrix< T > &A, const std::vector< T > &b)
Convenience: solve Ax = b, leaving A and b untouched.
Definition lu.h:158
Number-type abstraction for the templated API port.
The relation set of one layered model.
std::vector< std::string > convention
Matrix< T > A_host
(nhosts+1, nidx+1) host-to-activity incidence weighted by D(a)
std::vector< T > visits
(nidx+1) executions of each activity per invocation of its entry
std::vector< std::string > text
std::vector< LqnRelation< T > > eqs
Matrix< T > A_flow
(ncalls+1, nidx+1) call-to-source incidence weighted by y(c)
Matrix< T > A_little
(ntasks+1, ncalls+1) 1 where call c is a caller class of task t
One conservation law, as an aggregation over a node's structural neighbours.
bool degenerate
unusable as a residual: infinite mult, no terms, zero call count
std::vector< std::size_t > terms
absolute indices to aggregate over
std::vector< T > coeff
constant coefficient of each term
std::size_t target
absolute index the relation is anchored on
bool clamped
instantiated and saturated: the equality is unattainable
bool has_residual
NaN until instantiated.
std::vector< bool > termisentry
per term, true for an ENTRY class (open arrival, or a reference task's own cycle) and false for a CAL...
std::string kind
little | callflow | entryflow | actflow | hostutil
std::string branch
the case that produced it: ref/inf/queueing/fwd/arrival, or a call type
bool scaled
the per-class utilization needs *mult to reach job units
T rhsconst
right-hand-side constant
The iterates of a solved layered model, indexed by ABSOLUTE element index.