LINE Solver (C++)
Templated C++ port of the LINE queueing solver
Loading...
Searching...
No Matches
lossn_manjunath.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_LOSSN_LOSSN_MANJUNATH_H
6#define LINE_API_LOSSN_LOSSN_MANJUNATH_H
7
8/**
9 * @file
10 * @ingroup api_lossn
11 * Exact analysis of a loss network by the Manjunath-Sikdar transform.
12 *
13 * Templated port of matlab/src/api/lossn/lossn_manjunath.m. Calls on route r arrive
14 * Poisson at rate nu_r with unit mean holding time and are admitted only while
15 * every constraint holds, sum_r A(j,r) n_r <= C(j). The admissible set is
16 * coordinate convex, so Kelly's truncation theorem gives the truncated product
17 * form p(n) = nu^n / n! / g(C) and every metric is a ratio of normalizing
18 * constants:
19 *
20 * g(C) = sum_{A n <= C} prod_r nu_r^{n_r} / n_r!
21 * E[n_r] = nu_r g(C - A e_r) / g(C)
22 * Loss_r = 1 - g(C - A e_r) / g(C)
23 *
24 * because a class r call is blocked exactly when the state cannot absorb one
25 * more unit of its own requirement vector.
26 *
27 * WHY IT IS A COEFFICIENT COMPUTATION AND NOT A QUADRATURE. Writing each
28 * indicator as a contour integral turns g(C) into a J-fold integral over the
29 * unit circle whose integrand factorizes into the per-route z-transforms. Inside
30 * the circle the only pole in z_j sits at the origin with order C_j+1, so each
31 * integration is a residue, i.e. a Taylor coefficient. The routine therefore
32 * never evaluates an integral: it builds the generating function as a
33 * multivariate power series truncated at degree C_j in z_j, one
34 * shift-and-accumulate convolution per route, and discharges each '<='
35 * constraint by summing the coefficients of degrees 0..C_j along that
36 * dimension. Truncation is exact because A is nonnegative -- a monomial above
37 * degree C_j can never contribute to an extracted coefficient.
38 *
39 * THE ELIMINATION ORDER IS THE MEMORY BOUND. Contour integrations are
40 * interleaved with the product rather than deferred: variable z_j is created
41 * when the first route with A(j,r) != 0 is multiplied in and integrated out
42 * immediately after the last one. Peak memory is therefore the product of
43 * (C_j+1) over the SIMULTANEOUSLY LIVE links, an induced width of the
44 * route-link incidence, not over all J links. That product is bounded by
45 * `LossnManjunathOptions::max_live_states` and a region above it is refused by name
46 * rather than allowed to exhaust the machine: the algorithm is exact but not
47 * unconditionally cheap, and `lossn_mci` answers the same question at any size.
48 *
49 * WHY THIS ONE RUNS AT EXACT ARITHMETIC AND ITS TWO SIBLINGS DO NOT.
50 * `lossn_erlangfp` stops on a tolerance and `lossn_mci` returns a random
51 * variable, so both are transcendental-gated. Here every operation on the series
52 * is an addition or a multiplication of terms nu_r^n / n!, which is rational
53 * whenever nu is, and the reported quantities are RATIOS of series values, so
54 * QLen and Loss come out exact under Arith::Exact. Only `lG` is transcendental,
55 * and it is a double in the result for all three arithmetics anyway, obtained
56 * through `num_traits<T>::log_as_double` (which is defined for Rational via
57 * log_bigint, so no logarithm is ever taken of a value that has to be
58 * representable as a double).
59 *
60 * OVERFLOW, AND WHY THE SCALING IS ARITHMETIC-DEPENDENT. The term nu^n/n! peaks
61 * near n = nu at roughly e^nu / sqrt(2 pi nu), which overflows a double for
62 * loads above ~700. The reference builds the sequence in log space and divides
63 * it by its largest entry, accumulating the discarded logarithm and adding it
64 * back in log g; the port does the same under an inexact T. Under an exact T
65 * there is no overflow to avoid and the scaling would only inflate the
66 * denominators of the rationals, so it is skipped. The scale cancels in every
67 * ratio, so QLen and Loss are unaffected and lG is identical either way.
68 *
69 * A and C must be integer valued, since the residue argument counts whole units
70 * of capacity; a fractional entry is refused rather than rounded, naming
71 * `lossn_mci` which compares in real arithmetic. Each row is divided by the
72 * greatest common divisor of its entries together with its right-hand side,
73 * which is exact and shrinks the truncation degree. Routes appearing in no
74 * constraint never block and contribute a factor exp(nu_r) to g(C).
75 *
76 * Reference: D. Manjunath and B. Sikdar, Integral Expressions for the Numerical
77 * Evaluation of Product Form Expressions Over Irregular Multidimensional
78 * Integer Spaces.
79 */
80
81#include <algorithm>
82#include <cmath>
83#include <cstddef>
84#include <limits>
85#include <numeric>
86#include <string>
87#include <vector>
88
89#include "line/num/number.h"
90#include "line/util/error.h"
91#include "line/util/matrix.h"
92
93namespace line {
94namespace lossn {
95
96/** Controls of lossn_manjunath. The reference has none; both fields are port-local. */
98 /**
99 * Cap on the product of (C_j+1) over the simultaneously live links, i.e. on
100 * the number of series coefficients held at once. The default is 2^26
101 * coefficients, half a gigabyte at double, which no region a finite capacity
102 * region can express reaches by accident. Raise it deliberately.
103 */
104 std::size_t max_live_states = static_cast<std::size_t>(1) << 26;
105};
106
107/** Result of lossn_manjunath. */
108template <class T>
110 std::vector<T> QLen; ///< mean carried load E[n_r] per route
111 std::vector<T> Loss; ///< blocking probability per route
112 double lG = 0.0; ///< log of the EXACT normalizing constant g(C)
113 /** Always 1: the transform is direct, and the field exists for the shared
114 * analyzer contract that the iterative siblings fill in. */
115 std::size_t iterations = 1;
116 /** Peak number of live series coefficients, the realised cost. */
117 std::size_t peak_states = 0;
118};
119
120namespace detail {
121
122/** gcd on nonnegative long, with gcd(0, a) = a as MATLAB's gcd has it. */
123inline long lossn_gcd(long a, long b) {
124 while (b != 0) {
125 const long t = a % b;
126 a = b;
127 b = t;
128 }
129 return a < 0 ? -a : a;
130}
131
132/**
133 * The reduced admission rule: rows that constrain nothing dropped, every
134 * remaining row divided by the gcd of its entries and its right-hand side.
135 *
136 * Both steps are exact. The second is what makes the cost tractable on a memory
137 * budget whose class sizes share a factor -- a row (4, 8) <= 20 becomes
138 * (1, 2) <= 5 and its dimension shrinks from 21 coefficients to 6.
139 */
140struct LossnManjunathRule {
141 std::vector<std::vector<long>> A; ///< (J x R) after dropping and reduction
142 std::vector<long> C; ///< (J)
143};
144
145/**
146 * Integer view of (A, C), refusing a fractional or negative entry by name.
147 *
148 * The tolerance is the reference's 1e-9 on the distance to the nearest integer.
149 * It is applied to the value as a double even under exact arithmetic, where a
150 * fractional entry is exactly representable: the question asked is whether the
151 * caller MEANT an integer, and a rational 3/2 answers no just as 1.5 does.
152 */
153template <class T>
154LossnManjunathRule lossn_manjunath_integralize(const Matrix<T>& A, const std::vector<T>& C) {
155 const std::size_t J = C.size(), R = A.cols();
156 LossnManjunathRule rule;
157 for (std::size_t j = 0; j < J; ++j) {
158 const double c = num_traits<T>::to_double(C[j]);
159 if (c < 0.0 || std::fabs(c - std::round(c)) > 1e-9)
160 throw InputError(
161 "lossn_manjunath: C must contain nonnegative integers -- the residue argument counts "
162 "whole units of capacity. Use lossn_mci, which compares in real arithmetic, or "
163 "lossn_erlangfp");
164 bool any = false;
165 std::vector<long> row(R, 0);
166 for (std::size_t r = 0; r < R; ++r) {
167 const double a = num_traits<T>::to_double(A(j, r));
168 if (a < 0.0 || std::fabs(a - std::round(a)) > 1e-9)
169 throw InputError(
170 "lossn_manjunath: A must contain nonnegative integers -- the residue argument counts "
171 "whole units of capacity. Use lossn_mci, which compares in real arithmetic, or "
172 "lossn_erlangfp");
173 row[r] = std::lround(a);
174 if (row[r] != 0) any = true;
175 }
176 // A row of zeros bounds nothing and is dropped, not carried as a
177 // one-coefficient dimension: keeping it would make `first`/`last`
178 // undefined for that link.
179 if (!any) continue;
180 long g = std::lround(c);
181 for (std::size_t r = 0; r < R; ++r)
182 if (row[r] != 0) g = lossn_gcd(g, row[r]);
183 if (g > 1) {
184 for (std::size_t r = 0; r < R; ++r) row[r] /= g;
185 rule.C.push_back(std::lround(c) / g); // floor, both nonnegative
186 } else {
187 rule.C.push_back(std::lround(c));
188 }
189 rule.A.push_back(row);
190 }
191 return rule;
192}
193
194/**
195 * Coefficient-domain evaluation of the J-fold contour integral for the
196 * right-hand side `C`, which is the full rule for g(C) and the rule shifted by
197 * one class requirement for g(C - A e_r).
198 *
199 * The series lives in a flat vector indexed column-major over the live links,
200 * `stride[k] = prod_{i<k} curdim[i]`, with `curdim[j] == 1` while link j is not
201 * live and `C[j] + 1` while it is. `f[r]` holds the (possibly scaled) terms
202 * nu_r^n / n! for n = 0..nmaxFull[r].
203 */
204template <class T>
205T lossn_manjunath_series(const std::vector<std::vector<T>>& f, const std::vector<std::vector<long>>& A,
206 const std::vector<long>& C, const std::vector<long>& nmaxFull,
207 const LossnManjunathOptions& options, std::size_t& peak) {
208 const std::size_t R = f.size(), J = C.size();
209 const T zero = num_traits<T>::from_int(0);
210
211 // The elimination order: link j is created at its first route and summed
212 // out after its last, so only an induced width of links is ever live.
213 std::vector<std::size_t> first(J, 0), last(J, 0);
214 for (std::size_t j = 0; j < J; ++j) {
215 bool seen = false;
216 for (std::size_t r = 0; r < R; ++r) {
217 if (A[j][r] == 0) continue;
218 if (!seen) {
219 first[j] = r;
220 seen = true;
221 }
222 last[j] = r;
223 }
224 if (!seen)
225 throw NumericError("lossn_manjunath: a constraint row with no nonzero entry reached the "
226 "series; the rule was not reduced");
227 }
228
229 std::vector<std::size_t> curdim(J, 1);
230 std::vector<T> ser(1, num_traits<T>::from_int(1));
231
232 for (std::size_t r = 0; r < R; ++r) {
233 // 1. Create the links whose first route is this one.
234 for (std::size_t j = 0; j < J; ++j) {
235 if (first[j] != r) continue;
236 const std::size_t newdim = static_cast<std::size_t>(C[j]) + 1;
237 std::size_t pre = 1, post = 1;
238 for (std::size_t k = 0; k < j; ++k) pre *= curdim[k];
239 for (std::size_t k = j + 1; k < J; ++k) post *= curdim[k];
240 if (newdim != 0 && pre * post > options.max_live_states / newdim)
241 throw UnsupportedError(
242 "lossn_manjunath: the exact transform would hold more than " +
243 std::to_string(options.max_live_states) +
244 " series coefficients at once. Peak memory is the product of (C_j+1) over the "
245 "links live at the same time, so a wide constraint row with a large capacity "
246 "is what costs; raise LossnManjunathOptions::max_live_states deliberately, or use "
247 "lossn_mci, which is unbiased at any size");
248 std::vector<T> grown(pre * newdim * post, zero);
249 // The existing content keeps its coefficients and enters at degree
250 // zero in the new variable.
251 for (std::size_t q = 0; q < post; ++q)
252 for (std::size_t p = 0; p < pre; ++p)
253 grown[p + q * pre * newdim] = ser[p + q * pre];
254 ser.swap(grown);
255 curdim[j] = newdim;
256 if (ser.size() > peak) peak = ser.size();
257 }
258
259 // 2. Multiply in route r.
260 bool constrained = false;
261 for (std::size_t j = 0; j < J; ++j)
262 if (A[j][r] != 0) constrained = true;
263
264 if (!constrained) {
265 // The route is bounded by no live link, so its z-transform is a
266 // constant: sum the whole truncated sequence into the series. For a
267 // route absent from every row this is the factor exp(nu_r) below,
268 // already folded into `lGfree` by the caller, hence a multiply by 1.
269 T s = zero;
270 for (long n = 0; n <= nmaxFull[r] && static_cast<std::size_t>(n) < f[r].size(); ++n)
271 s += f[r][static_cast<std::size_t>(n)];
272 for (T& v : ser) v *= s;
273 continue;
274 }
275
276 // The degree of route r is capped by every row it appears in, evaluated
277 // at THIS right-hand side: the shifted series for g(C - A e_r) admits
278 // strictly fewer calls than g(C).
279 long nmax = std::min<long>(nmaxFull[r], static_cast<long>(f[r].size()) - 1);
280 for (std::size_t j = 0; j < J; ++j)
281 if (A[j][r] > 0) nmax = std::min<long>(nmax, C[j] / A[j][r]);
282
283 std::vector<std::size_t> stride(J, 1);
284 for (std::size_t k = 1; k < J; ++k) stride[k] = stride[k - 1] * curdim[k - 1];
285 const std::size_t P = ser.size();
286
287 std::vector<T> next(P, zero);
288 std::vector<std::size_t> sub(J, 0);
289 for (long n = 0; n <= nmax; ++n) {
290 const T c = f[r][static_cast<std::size_t>(n)];
291 if (c == zero) continue;
292 if (n == 0) {
293 for (std::size_t i = 0; i < P; ++i) next[i] += T(c * ser[i]);
294 continue;
295 }
296 // Shift by n requirement vectors, dropping the coefficients the
297 // shift would push past the capacity. Those monomials can never
298 // contribute to an extracted coefficient, which is exactly why the
299 // truncation is exact rather than an approximation.
300 std::fill(sub.begin(), sub.end(), static_cast<std::size_t>(0));
301 bool anyok = false;
302 for (std::size_t i = 0; i < P; ++i) {
303 bool ok = true;
304 std::size_t tgt = 0;
305 for (std::size_t j = 0; j < J && ok; ++j) {
306 const std::size_t d = sub[j] + static_cast<std::size_t>(A[j][r] * n);
307 if (static_cast<long>(d) > C[j])
308 ok = false;
309 else
310 tgt += d * stride[j];
311 }
312 if (ok) {
313 next[tgt] += T(c * ser[i]);
314 anyok = true;
315 }
316 // Odometer over the live grid, in the same column-major order
317 // the strides encode.
318 for (std::size_t j = 0; j < J; ++j) {
319 if (++sub[j] < curdim[j]) break;
320 sub[j] = 0;
321 }
322 }
323 // The shift only grows with n, so once nothing fits nothing will.
324 if (!anyok) break;
325 }
326 ser.swap(next);
327
328 // 3. Integrate out the links whose last route was this one. The
329 // multiplier (z^{C+1}-1)/(z-1) of a '<=' constraint turns the residue
330 // into the partial sum of the coefficients of degrees 0..C_j, which is
331 // the sum along that dimension.
332 for (std::size_t j = 0; j < J; ++j) {
333 if (last[j] != r) continue;
334 std::size_t pre = 1, post = 1;
335 for (std::size_t k = 0; k < j; ++k) pre *= curdim[k];
336 for (std::size_t k = j + 1; k < J; ++k) post *= curdim[k];
337 const std::size_t dj = curdim[j];
338 std::vector<T> summed(pre * post, zero);
339 for (std::size_t q = 0; q < post; ++q)
340 for (std::size_t d = 0; d < dj; ++d)
341 for (std::size_t p = 0; p < pre; ++p)
342 summed[p + q * pre] += ser[p + d * pre + q * pre * dj];
343 ser.swap(summed);
344 curdim[j] = 1;
345 }
346 }
347
348 if (ser.size() != 1)
349 throw NumericError("lossn_manjunath: a link was never integrated out; the elimination order is "
350 "inconsistent with the constraint rows");
351 return ser[0];
352}
353
354} // namespace detail
355
356/**
357 * Exact normalizing constant, carried load and blocking of a loss network.
358 *
359 * @param nu offered load of route r, nonnegative (R)
360 * @param A (J x R) nonnegative integer circuit requirements
361 * @param C (J) nonnegative integer capacities
362 * @param options the live-coefficient cap
363 */
364template <class T>
365LossnManjunathResult<T> lossn_manjunath(const std::vector<T>& nu, const Matrix<T>& A, const std::vector<T>& C,
366 const LossnManjunathOptions& options = LossnManjunathOptions()) {
367 const std::size_t R = nu.size();
368 if (A.cols() != R || A.rows() != C.size())
369 throw InputError("lossn_manjunath: A must be J x R, matching C and nu");
370 const T zero = num_traits<T>::from_int(0), one = num_traits<T>::from_int(1);
371 for (std::size_t r = 0; r < R; ++r)
372 if (nu[r] < zero) throw InputError("lossn_manjunath: nu must be nonnegative");
373
375 out.QLen.assign(R, zero);
376 out.Loss.assign(R, zero);
377
378 const detail::LossnManjunathRule rule = detail::lossn_manjunath_integralize(A, C);
379 const std::size_t J = rule.C.size();
380
381 // A route absent from every remaining row never blocks: its marginal is an
382 // untruncated Poisson, so it carries its full offered load and factors
383 // exp(nu_r) out of g(C).
384 std::vector<bool> freeRoute(R, true);
385 for (std::size_t j = 0; j < J; ++j)
386 for (std::size_t r = 0; r < R; ++r)
387 if (rule.A[j][r] != 0) freeRoute[r] = false;
388
389 double lGfree = 0.0;
390 bool allFree = true;
391 for (std::size_t r = 0; r < R; ++r) {
392 if (!freeRoute[r]) {
393 allFree = false;
394 continue;
395 }
396 out.QLen[r] = nu[r];
397 lGfree += num_traits<T>::to_double(nu[r]);
398 }
399 if (J == 0 || allFree) {
400 out.lG = lGfree;
401 return out;
402 }
403
404 // Per-route truncation, and the terms f_r(n) = nu_r^n / n!.
405 std::vector<long> nmax(R, 0);
406 std::vector<std::vector<T>> f(R, std::vector<T>(1, one));
407 double logscale = 0.0;
408 for (std::size_t r = 0; r < R; ++r) {
409 if (freeRoute[r]) continue;
410 long v = std::numeric_limits<long>::max();
411 for (std::size_t j = 0; j < J; ++j)
412 if (rule.A[j][r] > 0) v = std::min<long>(v, rule.C[j] / rule.A[j][r]);
413 nmax[r] = v;
414 const std::size_t len = static_cast<std::size_t>(v) + 1;
415
416 if (nu[r] == zero) {
417 // A route with no offered load contributes only its empty term. The
418 // log-space branch below would take log(0), which is why this case
419 // is separated rather than clamped to a tiny load.
420 f[r].assign(len, zero);
421 f[r][0] = one;
422 continue;
423 }
424 if constexpr (num_traits<T>::is_exact) {
425 // No overflow to guard against, and scaling would only inflate the
426 // denominators; the ratios below are scale free either way.
427 f[r].assign(len, zero);
428 f[r][0] = one;
429 for (std::size_t n = 1; n < len; ++n)
430 f[r][n] = T(f[r][n - 1] * nu[r] / num_traits<T>::from_int(static_cast<long>(n)));
431 } else {
432 using std::exp;
433 using std::log;
434 // Built in log space, then shifted by its own maximum so the peak
435 // term is exactly 1: nu^n/n! peaks at e^nu/sqrt(2 pi nu) and would
436 // overflow a double for a load above ~700.
437 const T lnu = log(nu[r]);
438 std::vector<T> lf(len, zero);
439 T lfact = zero, m = zero;
440 for (std::size_t n = 0; n < len; ++n) {
441 if (n > 0) lfact += log(num_traits<T>::from_int(static_cast<long>(n)));
442 lf[n] = T(num_traits<T>::from_int(static_cast<long>(n)) * lnu - lfact);
443 if (n == 0 || lf[n] > m) m = lf[n];
444 }
445 f[r].assign(len, zero);
446 for (std::size_t n = 0; n < len; ++n) f[r][n] = exp(T(lf[n] - m));
447 logscale += num_traits<T>::to_double(m);
448 }
449 }
450
451 const T G = detail::lossn_manjunath_series(f, rule.A, rule.C, nmax, options, out.peak_states);
452 if (G <= zero)
453 throw NumericError(
454 "lossn_manjunath: the admissible set is empty -- no state satisfies A n <= C, so the loss "
455 "network has no stationary distribution");
456 out.lG = num_traits<T>::log_as_double(G) + logscale + lGfree;
457
458 for (std::size_t r = 0; r < R; ++r) {
459 if (freeRoute[r]) continue;
460 std::vector<long> Cr = rule.C;
461 bool overflows = false;
462 for (std::size_t j = 0; j < J; ++j) {
463 Cr[j] -= rule.A[j][r];
464 if (Cr[j] < 0) overflows = true;
465 }
466 if (overflows) {
467 // A single class r call already exceeds a capacity, so the route is
468 // blocked in every state, including the empty one.
469 out.Loss[r] = one;
470 out.QLen[r] = zero;
471 continue;
472 }
473 const T Gr = detail::lossn_manjunath_series(f, rule.A, Cr, nmax, options, out.peak_states);
474 // The scale cancels here, which is what lets the terms be normalised.
475 const T ratio = T(Gr / G);
476 out.QLen[r] = T(nu[r] * ratio);
477 out.Loss[r] = T(one - ratio);
478 }
479 return out;
480}
481
482} // namespace lossn
483} // namespace line
484
485#endif // LINE_API_LOSSN_LOSSN_MANJUNATH_H
InputError(const std::string &what)
Definition error.h:39
std::size_t cols() const
Definition matrix.h:90
std::size_t rows() const
Definition matrix.h:89
NumericError(const std::string &what)
Definition error.h:45
UnsupportedError(const std::string &what)
Definition error.h:51
The exception types the port throws.
Dense matrix and non-owning view.
LossnManjunathResult< T > lossn_manjunath(const std::vector< T > &nu, const Matrix< T > &A, const std::vector< T > &C, const LossnManjunathOptions &options=LossnManjunathOptions())
Exact normalizing constant, carried load and blocking of a loss network.
Number-type abstraction for the templated API port.
Controls of lossn_manjunath.
std::size_t max_live_states
Cap on the product of (C_j+1) over the simultaneously live links, i.e.
Result of lossn_manjunath.
std::size_t peak_states
Peak number of live series coefficients, the realised cost.
double lG
log of the EXACT normalizing constant g(C)
std::vector< T > QLen
mean carried load E[n_r] per route
std::vector< T > Loss
blocking probability per route
std::size_t iterations
Always 1: the transform is direct, and the field exists for the shared analyzer contract that the ite...