1function [QN,UN,RN,TN,CN,XN,totiter] = solver_mam_basic(sn, options)
2% [Q,U,R,T,C,X] = SOLVER_MAM_BASIC(QN, PH, OPTIONS)
4% This solver uses MAM to solve queues in isolation, but simplifies
the
5% traffic equations by
using visits to rescale
the flows into
the queue
8% Copyright (c) 2012-2026, Imperial College London
11config = options.config;
15%% generate local state spaces
21V = cellsum(sn.visits);
23Strue = S; % service times as declared, used to report utilization below
24Lchain = sn_get_demands_chain(sn);
26% Interference of self-looping classes on the classes they share a station
27% with. A self-looping class holds njobs customers at its reference station in
28% perpetuity, so a job of any other class arriving there always finds those
29% njobs customers resident and, under any work-conserving discipline, must
30% share the server with them: its residence time is multiplied by
31% (1 + njobs_slc). Modelling this as an inflated service time keeps the SLC out
32% of the decomposition (it has no surrogate arrival stream; see below) while
33% still charging the other classes for the capacity it holds. On the sanity SLC
34% models this reproduces the CTMC solution exactly. Delay stations are exempt:
35% with infinite servers there is no contention to share.
39 ist_k = sn.refstat(k);
40 if isfinite(sn.nservers(ist_k))
41 slcjobs(ist_k) = slcjobs(ist_k) + sn.njobs(k);
49 S(ist,k) = S(ist,k)*(1+slcjobs(ist));
52 % The chain demands drive the throughput fixed point and must be
53 % consistent with the inflated service times.
54 Lchain(ist,:) = Lchain(ist,:)*(1+slcjobs(ist));
65% Track stations using exact MAP/D/c solver (skip post-processing for these)
66mapdcStations = false(M, 1);
72chainSysArrivals = cell(1,C);
77% open queueing system (one node is the external world)
78% first build the joint arrival process
81 case {SchedStrategy.FCFS, SchedStrategy.HOL, SchedStrategy.FCFSPRPRIO, SchedStrategy.PS}
83 % Skip Det processes - they will be handled by qsys_mapdc
84 if sn.procid(ist, k) == ProcessType.DET
85 % For Det, we don't need MAP representation since we use exact solver
90 % divide service time by number of servers and put
91 % later a surrogate delay server in tandem to compensate.
92 % map_scale normalizes through map_normalize, which clips
93 % negative entries to zero; on a matrix-exponential or
94 % rational representation that
is not a repair of numerical
95 % noise but a replacement of
the process by a different,
96 % Markovian one, so those are rescaled by
the rate alone.
97 if sn.procid(ist, k) == ProcessType.ME || sn.procid(ist, k) == ProcessType.RAP
98 ratio = map_mean(PH{ist}{k}) / (S(ist,k)/sn.nservers(ist));
99 PH{ist}{k} = {PH{ist}{k}{1}*ratio, PH{ist}{k}{2}*ratio};
101 PH{ist}{k} = map_scale(PH{ist}{k}, S(ist,k)/sn.nservers(ist));
103 pie{ist}{k} = map_pie(PH{ist}{k});
104 D0{ist,k} = PH{ist}{k}{1};
105 if any(isnan(D0{ist,k}))
106 D0{ist,k} = -GlobalConstants.Immediate;
108 PH{ist}{k} = map_exponential(GlobalConstants.Immediate);
116if any(isinf(sn.njobs))
119if any(isfinite(sn.njobs))
122isMixed = isOpen & isClosed;
124% Self-looping classes never leave their reference station, so a chain made
125% up of them admits no surrogate arrival stream:
the flow-equivalent rate
126% that would reproduce a permanently resident customer does not exist, and
127%
the regula falsi below would drive it upwards without bound. Such chains
128% are excluded from
the fixed point and pinned by
the SLC clamp at
the end
129% of this function, mirroring solver_mna_closed and
130% solver_mam_basic_mmap_closed.
131isslcchain = false(1,C);
133 isslcchain(c) = all(sn.isslc(sn.inchain{c}));
136lambdas_inchain = cell(1,C);
138 inchain = sn.inchain{c};
139 lambdas_inchain{c} = sn.rates(sn.refstat(inchain(1)),inchain);
140 %lambdas_inchain{c} = lambdas_inchain{c}(isfinite(lambdas_inchain{c}));
141 lambda(c) = sum(lambdas_inchain{c}(isfinite(lambdas_inchain{c})));
144 lambdas_inchain{c} = zeros(1,length(inchain));
146 ist = sn.refstat(inchain(1)); % identical
for all classes in
the chain
147 if isinf(sum(sn.njobs(inchain))) %
if open chain
148 % ist here
is the source
149 % assemble a
MMAP for the arrival process from all classes
151 if isnan(PH{ist}{k}{1})
152 PH{ist}{k} = map_exponential(Inf); % no arrivals from
this class
155 inchain = sn.inchain{c};
158 for ki=2:length(inchain)
160 if isnan(PH{ist}{k}{1})
161 PH{ist}{k} = map_exponential(Inf); % no arrivals from
this class
165 TN(ist,inchain
') = lambdas_inchain{c};
169sd = isfinite(sn.nservers);
171isclosedchain = false(1,C);
172isopenchain = false(1,C);
174 isopenchain(c) = isinf(sum(sn.njobs(sn.inchain{c})));
175 isclosedchain(c) = ~isopenchain(c) && ~isslcchain(c);
177% A mixed network is the only case in which the uniform 1/Umax backoff below is
178% unsafe, because there it also scales the open chains, whose arrival rate is
179% exogenous data fixed by their source rather than an unknown of the
180% decomposition. Purely open and purely closed networks keep the original
181% iteration untouched: with a single chain kind the uniform backoff is exactly
182% the closed-only backoff, and a closed chain is expected to drive its
183% bottleneck to saturation as its population grows.
184ismixed = any(isclosedchain) && any(isopenchain);
185% In a mixed network the closed chains may claim no more than the capacity the
186% open traffic leaves free, and the iterate must stay strictly inside the
187% stability region: the queue-length formulas below are of M/G/1 type and
188% diverge at total utilization 1.
189Ulim = 1 - GlobalConstants.CoarseTol;
191% Stations already flagged as carrying a non-phase-type service process, scoped
192% to this solve so the fixed-point iteration reports each station once while
193% every new model is still warned about.
194meWarned = false(1, M);
196while max(max(abs(TN-TN_1))) > tol && it <= options.iter_max %#ok<max>
199 Umax = max(sum(UN(sd,:),2));
200 if ismixed || Umax < 1
202 inchain = sn.inchain{c};
204 Nc = sum(sn.njobs(inchain)); % closed population
205 QNc = max(tol, sum(sum(QN(:,inchain),2,"omitnan"))); %#ok<NANSUM>
206 TNlb = Nc./sum(Lchain(:,c));
208 lambda(c) = TNlb; % lower bound
210 lambda(c) = lambda(c) * it/options.iter_max + (Nc / QNc) * lambda(c) * (options.iter_max-it)/options.iter_max; % iteration-averaged regula falsi;
216 % Back the closed chains alone onto the residual capacity of the
217 % busiest finite-server station. Scaling every chain by 1/Umax instead
218 % drags the open chains down too, so the open classes report a
219 % throughput below their own source rate, and it parks the iterate
220 % exactly on U==1, where the queue lengths diverge and, since the
221 % backoff then becomes a no-op, the iteration stops moving.
222 % Summed rather than multiplied out: with no open chain the empty
223 % product would collapse to 1x0 instead of a zero column.
224 Uchain = Lchain(sd,:) .* lambda;
225 Uopen = sum(Uchain(:,~isclosedchain), 2);
226 Uclosed = sum(Uchain(:, isclosedchain), 2);
227 binding = Uclosed > tol;
229 theta = min((Ulim - Uopen(binding)) ./ Uclosed(binding));
231 lambda(isclosedchain) = lambda(isclosedchain) * max(0, theta);
235 lambda = lambda * 1/Umax;
239 inchain = sn.inchain{c};
241 % Vanishing-rate surrogate, as in solver_mam_basic_mmap_inner: a
242 % self-looping chain must not inject arrivals into the shared-queue
243 % decomposition. An exactly zero rate would give a singular MMAP.
244 chainSysArrivals{c} = mmap_exponential(GlobalConstants.Zero);
245 elseif ~isinf(sum(sn.njobs(inchain)))
246 % Closed chain: Poisson surrogate at the current throughput
247 % iterate. Open chains keep the source arrival MMAP assembled
248 % before the loop (its rate is re-imposed per station by
249 % mmap_scale), so the external arrival variability is not
250 % silently discarded.
251 chainSysArrivals{c} = mmap_exponential(lambda(c));
254 TN(m,inchain) = V(m,inchain) .* lambda(c);
260 ist = sn.nodeToStation(ind);
261 switch sn.nodetype(ind)
264 inchain = sn.inchain{c};
266 fanin = nnz(sn.rtnodes(:, (ind-1)*K+k));
267 TN(ist,k) = lambda(c)*V(ist,k)/fanin;
275 case SchedStrategy.INF
277 inchain = sn.inchain{c};
280 % Class k does not visit this station, so
281 % its service rate is undefined (NaN) and
282 % all of its metrics here are null. Guard
283 % explicitly: S(ist,k)*TN(ist,k) would be
284 % NaN*0 = NaN, which then propagates into
285 % the Umax saturation test below (NaN>=1 is
286 % false, so the guard would be bypassed
287 % silently). Mirrors the V(ist,r)>0 test in
288 % solver_mam_basic_mmap_inner.
295 TN(ist,k) = lambda(c)*V(ist,k);
296 % Utilization Law: a c-server station holds TN*S/c of
297 % its capacity. An INF station has nservers = Inf, so
298 % dividing would annihilate it (S*TN/Inf = 0); an
299 % infinite server reports U = QLen = TN*S instead, the
300 % convention every other solver follows.
301 UN(ist,k) = S(ist,k)*TN(ist,k);
302 % TN already carries the visits, so Little's law reads
303 % QN = TN*S; a second V
factor undercounts
the delay
304 % jobs whenever
the chain switches
class (V<1), which
305 % skews
the closed-chain regula falsi target QNc and,
306 % through
the post-loop rescale, deflates
the response
307 % time of
the queueing stations in
the same chain. The
308 % PS and FCFS branches already use
the single-V
form.
309 QN(ist,k) = TN(ist,k).*S(ist,k);
310 RN(ist,k) = QN(ist,k)/TN(ist,k);
313 case SchedStrategy.PS
315 inchain = sn.inchain{c};
318 % See
the INF branch: a non-visiting
class
319 % has an undefined rate, and NaN*0 = NaN
320 % would poison
the Uden sum below.
325 TN(ist,k) = lambda(c)*V(ist,k);
326 % Utilization Law: a c-server station holds TN*S/c of its capacity.
327 UN(ist,k) = S(ist,k)*TN(ist,k)/sn.nservers(ist);
329 %Nc = sum(sn.njobs(inchain)); % closed population
330 Uden = min([1-GlobalConstants.FineTol,sum(UN(ist,:))]);
337 %QN(ist,k) = (UN(ist,k)-UN(ist,k)^(Nc+1))/(1-Uden); % geometric bound type approximation
338 QN(ist,k) = UN(ist,k)/(1-Uden);
339 RN(ist,k) = QN(ist,k)/TN(ist,k);
342 case {SchedStrategy.FCFS, SchedStrategy.HOL, SchedStrategy.FCFSPRPRIO}
343 chainArrivalAtNode = cell(1,C);
345 mapdcUsed =
false; % Flag
for exact MAP/D/c solver
346 finiteCapUsed =
false; % Flag
for finite-capacity truncate-and-renormalize
347 finiteCapLossPerClass = []; % per-
class loss, exact
MMAP/G/1/K branch only
348 for c=1:C %
for each chain
349 rates{ist,c} = V(ist,:) .* lambda(c); %
visits of classes within
the chain
350 inchain = find(sn.chains(c,:))
';
351 markProb = rates{ist,c}(inchain) / sum(rates{ist,c}(inchain));
352 markProb(isnan(markProb)) = 0;
353 chainArrivalAtNode{c} = mmap_mark(chainSysArrivals{c}, markProb);
354 % mmap_normalize clips negative entries to zero and
355 % re-derives the diagonal. On a genuine MAP that
356 % repairs roundoff; on a matrix-exponential or
357 % rational arrival it substitutes a different,
358 % Markovian process, which changes the
359 % autocorrelation that the downstream MMAPPH1FCFS
360 % is there to account for. Same reasoning as the
361 % ME/RAP branch of the service rescaling above.
362 if mam_chain_arrival_is_markovian(sn, c)
363 chainArrivalAtNode{c} = mmap_normalize(chainArrivalAtNode{c});
365 % Target the per-class mean inter-arrival times of
366 % THIS chain's classes. mmap_scale reads M(1:C)
368 % length(inchain) here, so passing
the full 1:K
369 % vector silently targets classes 1..length(inchain)
370 % instead of
the chain
's own classes. The two
371 % coincide only when the chain occupies the leading
372 % class indices; otherwise every chain but the first
373 % is scaled to another chain's visit rate.
374 % A chain that offers no arrivals here (a self-looping
375 % chain, whose lambda
is pinned to zero) has an
376 % infinite target mean: mmap_scale would then scale
the
377 %
MMAP by mean/Inf = 0 and return an all-zero, singular
378 % MAP. It
is already a vanishing-rate arrival process,
379 % so leave it untouched.
380 tgtrates = rates{ist,c}(inchain);
382 chainArrivalAtNode{c} = mmap_scale(chainArrivalAtNode{c}, 1./tgtrates, 0); % non-iterative approximation
384 chainIsMarkovian = mam_chain_arrival_is_markovian(sn, c);
387 aggrArrivalAtNode = mmap_super_safe({chainArrivalAtNode{c}, mmap_exponential(0,1)}, config.space_max,
'default');
388 aggrArrivalAtNode = {aggrArrivalAtNode{1} aggrArrivalAtNode{2} aggrArrivalAtNode{2}};
390 % Superposing with a zero-rate exponential
is the
391 % identity on (D0,D1) --
the Kronecker sum with an
392 % order-1 null adds nothing -- and
is used here only
393 % to reach
the {D0,D1,D1} marked shape. What it also
394 % does
is run mmap_normalize inside mmap_super, which
395 % clips
the negative off-diagonal entries a
396 % matrix-exponential or rational arrival legitimately
397 % carries and substitutes a Markovian process with a
398 % different autocorrelation. Build
the shape directly
399 % instead, so
the arrival reaching MMAPPH1FCFS
is the
400 % process
the user declared.
401 aggrArrivalAtNode = {chainArrivalAtNode{c}{1}, chainArrivalAtNode{c}{2}, chainArrivalAtNode{c}{2}};
403 lc = map_lambda(chainArrivalAtNode{c});
405 aggrArrivalAtNode = mmap_scale(aggrArrivalAtNode, 1/lc, 0); % non-iterative approximation
409 % mmap_super_safe normalizes, so
the ME/RAP component
410 % of a multi-chain superposition
is replaced by a
411 % Markovian one and its correlation
is only
412 % approximated. Say so rather than report
the result
413 % as
if it carried
the declared process.
414 line_warning_always(mfilename, ...
415 'Chain %d has a matrix-exponential or rational arrival process, which the superposition of several chains at station %s cannot represent exactly. Its autocorrelation is approximated by the closest Markovian arrival process.', ...
416 c, sn.nodenames{sn.stationToNode(ist)});
418 aggrArrivalAtNode = mmap_super_safe({aggrArrivalAtNode, chainArrivalAtNode{c}}, config.space_max,
'default');
422 if (sn.sched(ist)==SchedStrategy.HOL && any(sn.classprio ~= sn.classprio(1))) %
if priorities are not identical; sched holds numeric ids so == must be used (strcmp on numerics
is always
false)
423 [uK,iK] = unique(sn.classprio);
424 % BUTools convention: D1=lowest priority, DK=highest priority
425 % LINE convention: lower value = higher priority
426 % unique() returns ascending order, so we need to reverse for BUTools
428 if length(uK) == length(sn.classprio) % if all priorities are different
429 [Qret{iK
'}] = MMAPPH1NPPR({aggrArrivalAtNode{[1;2+iK]}}, {pie{ist}{iK}}, {D0{ist,iK}}, 'ncMoms
', 1);
431 line_error(mfilename,'Solver MAM
requires either identical priorities or all distinct priorities
');
433 elseif (sn.sched(ist)==SchedStrategy.FCFSPRPRIO && any(sn.classprio ~= sn.classprio(1))) % FCFS preemptive resume priority
434 [uK,iK] = unique(sn.classprio);
435 % BUTools convention: D1=lowest priority, DK=highest priority
436 % LINE convention: lower value = higher priority
437 % unique() returns ascending order, so we need to reverse for BUTools
439 if length(uK) == length(sn.classprio) % if all priorities are different
440 [Qret{iK'}] = MMAPPH1PRPR({aggrArrivalAtNode{[1;2+iK]}}, {pie{ist}{iK}}, {D0{ist,iK}},
'ncMoms', 1);
442 line_error(mfilename,
'Solver MAM requires either identical priorities or all distinct priorities');
445 aggrUtil = sum(mmap_lambda(aggrArrivalAtNode)./(GlobalConstants.FineTol+sn.rates(ist,1:K)*sn.nservers(ist)),
'omitnan'); %% to debug
446 aggrLambda = mmap_lambda(aggrArrivalAtNode);
447 if aggrUtil < 1-GlobalConstants.FineTol
449 % Check
for MAP/D/c: single-
class open model with deterministic service
450 isMapDc = (K == 1) && (sn.procid(ist, 1) == ProcessType.DET);
451 % Check
for D/M/c: single-
class open, exp service at this queue,
452 % deterministic source elsewhere.
455 if K == 1 && ~isMapDc && sn.procid(ist, 1) == ProcessType.EXP
456 for jst = 1:sn.nstations
457 if jst ~= ist && sn.procid(jst, 1) == ProcessType.DET
464 % Check
for PH/M/c: single-
class open, exp service at this queue,
465 % source has PH inter-arrivals (Erlang/Coxian/MAP/PH/...).
466 % Covers both c=1 (PH/M/1) and c>1 (PH/M/c via matrix-geometric).
469 if K == 1 && ~isMapDc && ~isDMc && sn.procid(ist, 1) == ProcessType.EXP ...
470 && isfinite(sn.nservers(ist)) && sn.nservers(ist) >= 1
471 for jst = 1:sn.nstations
475 srcProc = sn.procid(jst, 1);
476 if srcProc ~= ProcessType.EXP && srcProc ~= ProcessType.DET ...
477 && srcProc ~= ProcessType.IMMEDIATE && srcProc ~= ProcessType.DISABLED
478 % qsys_phmc reads
the arrival only through
479 % its (pie, D0) phase-type pair, i.e. its
480 % renewal marginal. That
is exact
for a
481 % renewal arrival and discards
the
482 % autocorrelation of any other, so
the gate
483 %
is RENEWAL, not merely non-exponential.
484 % Being non-exponential was
the old test and
485 % it let a correlated MAP, RAP or ME arrival
487 % out near
the Kingman value implied by
the
488 % arrival SCV alone. An ME arrival
is renewal
489 % and stays on
this fast path.
490 if ~mam_srcproc_is_renewal(sn, jst)
496 elseif srcProc == ProcessType.EXP && sn.nservers(ist) > 1 ...
497 && sn.nstations == 2 ...
498 && sn.nodetype(sn.stationToNode(jst)) == NodeType.Source
499 % M/M/c:
the single-fast-server surrogate used by
the
500 %
generic path
is inexact
for c>1; dispatch to
the exact
501 % PH/M/c matrix-geometric solver (Erlang-C result)
508 % Check
for finite-buffer FCFS. qsys_phmc, qsys_dmc
509 % and qsys_mapdc are all infinite-buffer closed
510 % forms, so letting them win here would silently
511 % discard sn.cap and report a lossless queue. A
512 % finite buffer therefore takes precedence over
513 % them rather than being disabled by them.
514 isFiniteCap = isfinite(sn.cap(ist));
521 muQ = 1.0 / S(ist, 1);
523 phPair = sn.proc{phM1SourceIdx}{1};
526 pie_src = map_pie({D0_ph, D1_ph});
527 phm1Result = qsys_phmc(pie_src, D0_ph, muQ, sn.nservers(ist));
528 Qret{1} = phm1Result.meanQueueLength;
530 mapdcStations(ist) =
true;
531 line_debug(options,
'Using exact PH/M/%d solver: Q=%.4f, W=%.4f', ...
532 sn.nservers(ist), phm1Result.meanQueueLength, phm1Result.meanWaitingTime);
538 muQ = 1.0 / S(ist, 1);
539 lamD = sn.rates(dmcSourceIdx, 1);
541 dmcResult = qsys_dmc(lamD, muQ, sn.nservers(ist));
542 Qret{1} = dmcResult.meanQueueLength;
543 mapdcUsed =
true; % skip surrogate delay correction
544 mapdcStations(ist) =
true;
545 line_debug(options,
'Using exact D/M/%d solver: Q=%.4f, W=%.4f', ...
546 sn.nservers(ist), dmcResult.meanQueueLength, dmcResult.meanWaitingTime);
552 % handled above; skip
the MAP/D/c, finite-cap, and MMAPPH1FCFS branches
554 % Use exact MAP/D/c solver from Q-MAM
555 D0_arr = aggrArrivalAtNode{1};
556 D1_arr = aggrArrivalAtNode{2};
557 detServiceTime = S(ist, 1); % 1/rate = service time
558 numServers = sn.nservers(ist);
560 mapdcResult = qsys_mapdc(D0_arr, D1_arr, detServiceTime, numServers);
561 Qret{1} = mapdcResult.meanQueueLength;
562 % Store result
for later use to skip surrogate delay adjustment
564 mapdcStations(ist) =
true; % Mark
for skipping post-processing
565 line_debug(
'Using exact MAP/D/%d solver: Q=%.4f, W=%.4f', ...
566 numServers, mapdcResult.meanQueueLength, mapdcResult.meanWaitingTime);
568 % Finite-buffer FCFS. Detect exact M/M/c/K
569 % (Poisson arrivals + same exp service across
570 % classes);
else use
MMAP[K]/PH[K]/1/FCFS
571 % truncate-and-renormalize approximation.
574 finiteCapLossPerClass = [];
575 [isMmck, muMmck] = mam_detect_mmck(sn, ist, K, aggrArrivalAtNode);
577 aggrLambdaTotal = sum(aggrLambda,
'omitnan');
578 exactRes = qsys_mmck(aggrLambdaTotal, muMmck, sn.nservers(ist), capK);
579 finiteCapMeanQ = exactRes.meanQueueLength;
580 finiteCapLossProb = exactRes.lossProbability;
581 finiteCapP0 = exactRes.queueLengthDist(1);
582 line_debug(
'Using exact M/M/%d/%d: Q=%.4f, ploss=%.4f', ...
583 sn.nservers(ist), capK, finiteCapMeanQ, finiteCapLossProb);
584 elseif sn.nservers(ist) == 1
585 % Exact
MMAP[K]/G/1/K. The embedded chain at
586 % departure epochs resolves
the buffer level
587 % jointly with
the arrival phase, so
class k,
588 % which arrives out of phase i at rate
589 % (D1k*e)_i, gets its own loss ratio. The
590 % truncate-renorm fallback below can only
591 %
return one blocking probability
for every
592 %
class, making loss rate-proportional and
593 % blind to interarrival variability.
594 svcMix = mam_svc_mixture({aggrArrivalAtNode{[1,3:end]}}, ...
595 {pie{ist}{:}}, {D0{ist,:}});
596 exRes = qsys_mmapg1k(aggrArrivalAtNode{1}, ...
597 aggrArrivalAtNode(3:end), svcMix, capK);
598 finiteCapMeanQ = exRes.meanQueueLength;
599 finiteCapLossProb = exRes.lossAggregate;
600 finiteCapLossPerClass = exRes.lossRatio;
601 finiteCapP0 = exRes.p0;
602 line_debug(
'Using exact MMAP/G/1/%d: Q=%.4f, ploss=%.4f, per-class ploss=%s', ...
603 capK, finiteCapMeanQ, finiteCapLossProb, mat2str(finiteCapLossPerClass, 4));
605 [meanQ_fc, lossProb_fc, p_norm_fc] = mam_truncate_renorm( ...
606 {aggrArrivalAtNode{[1,3:end]}}, {pie{ist}{:}}, {D0{ist,:}}, capK);
607 finiteCapMeanQ = meanQ_fc;
608 finiteCapLossProb = lossProb_fc;
609 finiteCapP0 = p_norm_fc(1);
610 line_debug(
'Using MMAP/PH/%d/%d truncate-renorm: Q=%.4f, ploss=%.4f', ...
611 sn.nservers(ist), capK, meanQ_fc, lossProb_fc);
613 finiteCapUsed =
true;
615 Qret{k} = NaN; % filled per
class in result loop
617 mapdcStations(ist) =
true; % skip surrogate delay 2nd pass
618 elseif sn.isfunction(ist)
619 % Open setup/delay-off station. qbd_setupdelayoff
620 % races
the delay-off against
the aggregate
621 % interarrival Exp(lambda), which
is exactly
the
622 % idle seen by a Poisson stream, so it
is the right
623 % surrogate here (unlike
the closed
case below,
624 % where
the idle
is per instance). Without
this the
625 % station fell through to
the plain MMAPPH1FCFS
626 % below and
the setup was silently ignored: an
627 % M/M/1 with setup returned
the setup-free
630 %
nodeparam is NODE-indexed (refreshLocalVars),
while
631 % ist
is a STATION index;
the two coincide only when
632 % no non-station node precedes
the queue. Reading
633 %
nodeparam{ist} directly errored out on any model
634 % with e.g. a Router or ClassSwitch ahead of
the
636 alpharate = map_lambda(sn.nodeparam{sn.stationToNode(ist)}{end}.setupTime);
637 alphascv = map_scv(sn.nodeparam{sn.stationToNode(ist)}{end}.setupTime);
638 betarate = map_lambda(sn.nodeparam{sn.stationToNode(ist)}{end}.delayoffTime);
639 betascv = map_scv(sn.nodeparam{sn.stationToNode(ist)}{end}.delayoffTime);
641 lambda_k = zeros(1, K);
642 active_k =
false(1, K);
645 if ~isempty(pie_k) && ~isnan(pie_k(1))
646 mu_k(k) = 1 / (pie_k * inv(-D0{ist,k}) * ones(size(pie_k))
');
647 c = find(sn.chains(:,k), 1);
648 lambda_k(k) = rates{ist,c}(k);
652 rho_k = lambda_k ./ mu_k;
653 rho_k(~active_k) = 0;
654 rho_total = sum(rho_k);
656 % Aggregate service rate consistent with the
657 % aggregate load, then split the aggregate queue
658 % across classes in proportion to their load.
659 % aggrLambda is per class; the QBD takes the
660 % scalar total arrival rate seen by the server.
661 aggrLambdaTotal = sum(aggrLambda, 'omitnan
');
662 aggrRate = aggrLambdaTotal / rho_total;
663 Q_total = qbd_setupdelayoff(aggrLambdaTotal, aggrRate, alpharate, alphascv, betarate, betascv);
666 Qret{k} = Q_total * rho_k(k) / rho_total;
678 % MMAPPH1FCFS treats service as a renewal
679 % phase-type (marginal), discarding service
680 % autocorrelation. For the single-class
681 % single-server M/MAP/1 case with a genuinely
682 % correlated service, use the exact MAP/MAP/1
683 % queue (Q_CT_MAP_MAP_1), which carries the
684 % service phase across departures.
685 % A matrix-exponential or rational service
686 % process is not phase-type, so the (pie,D0)
687 % pair MMAPPH1FCFS reads does not describe it
688 % and the PH path would silently return the
689 % wrong numbers. The RAP/RAP/1 QBD applies
690 % whenever the SERVICE is ME or RAP, whatever
691 % the arrival type: the aggregate arrival
692 % stream is handed over as a (D0,D1) pair, and
693 % a Poisson stream, a MAP and a PH renewal
694 % stream are all RAPs.
695 isMEorRAPsvc = any(sn.procid(ist,:) == ProcessType.ME | ...
696 sn.procid(ist,:) == ProcessType.RAP);
699 if (K == 1) && (sn.nservers(ist) == 1)
702 % qbd_raprap1 is a single-class
703 % single-server QBD. Say so rather than
704 % let an ME model be answered by the
705 % phase-type approximation without the
707 % line_warning_always, not
708 % line_warning: this reports that
709 % the returned numbers are not
710 % exact, so it must be raised for
711 % every model it applies to rather
712 % than be hidden as a repeat of the
713 % previous model's warning.
715 meWarned(ist) =
true;
716 line_warning_always(mfilename, ...
717 'Station %s has a matrix-exponential or rational service process, which the RAP/RAP/1 analysis supports only with a single class at a single server (here %d classes, %g servers). Falling back to the phase-type approximation MMAPPH1FCFS, which is not exact for this service process.', ...
718 sn.nodenames{sn.stationToNode(ist)}, K, sn.nservers(ist));
722 useMapMap1 = ~useRapRap1 && (K == 1) && (sn.nservers(ist) == 1) && ...
723 (abs(map_acf(PH{ist}{1}, 1)) > GlobalConstants.CoarseTol);
725 % qbd_raprap1 carries no sojourn-time
726 % information, so only
the queue-length
727 % mean
is taken here;
the response time
is
728 % recovered downstream by Little
's law.
729 [~, QNrap] = qbd_raprap1({aggrArrivalAtNode{1}, aggrArrivalAtNode{3}}, ...
730 {PH{ist}{1}{1}, PH{ist}{1}{2}});
733 ql = Q_CT_MAP_MAP_1(aggrArrivalAtNode{1}, aggrArrivalAtNode{3}, ...
734 PH{ist}{1}{1}, PH{ist}{1}{2}, 'MaxNumComp
', 100000);
736 Qret{1} = sum((0:numel(ql)-1)' .* ql);
738 [Qret{1:K}] = MMAPPH1FCFS({aggrArrivalAtNode{[1,3:end]}}, {pie{ist}{:}}, {D0{ist,:}},
'ncMoms', 1);
741 else % all closed classes
742 maxLevel = sum(N(isfinite(N)))+1;
743 D = {aggrArrivalAtNode{[1,3:end]}};
744 pdistr_k = cell(1,K);
745 if map_lambda(D)< GlobalConstants.FineTol
747 pdistr_k = [1-GlobalConstants.FineTol, GlobalConstants.FineTol];
748 Qret{k} = GlobalConstants.FineTol / sn.rates(ist);
751 if sn.isfunction(ist)
752 alpharate = map_lambda(sn.nodeparam{sn.stationToNode(ist)}{end}.setupTime);
753 betarate = map_lambda(sn.nodeparam{sn.stationToNode(ist)}{end}.delayoffTime);
754 betascv = map_scv(sn.nodeparam{sn.stationToNode(ist)}{end}.delayoffTime);
755 lambda_k = zeros(1, K);
756 active_k =
false(1, K);
760 c = find(sn.chains(:,k), 1);
761 lambda_k(k) = rates{ist,c}(k);
766 % Per-instance cold-start race. In a closed layer
the
767 % function instances are
the chain
's own tokens: each
768 % token's idle period between two consecutive requests
769 %
is the client think time ZT of its chain (
the
770 % exponential surrogate at
the infinite-server client
771 % delay), so an arriving request pays
the setup iff
the
772 % delay-off timer expires within Exp(1/ZT):
773 % p_cold =
P(delayoff < Exp(1/ZT)) = LST_delayoff(1/ZT).
774 % The response time then carries
the full setup latency,
775 % R_k = p_cold*E[setup] + S_k,
776 % and QN follows by Little
's law, which keeps the
777 % regula falsi above linear in lambda. The open-arrival
778 % surrogate qbd_setupdelayoff(aggrLambda,...) is not
779 % used here: it races the delay-off against the
780 % aggregate interarrival Exp(lambda) instead of the
781 % per-instance idle, and its Poisson clumping creates
782 % queueing that cannot occur among closed tokens.
783 setupMean = 1/alpharate;
785 % rate taken directly, as in qbd_setupdelayoff
786 betaProc = {-betarate, betarate};
788 betaProc = APH.fitMeanAndSCV(1/betarate, betascv).getProcess;
791 pie_b = map_pie(betaProc);
792 infstat = isinf(sn.nservers);
794 if active_k(k) && lambda_k(k) > 0 && isfinite(S(ist,k))
795 c = find(sn.chains(:,k), 1);
796 inchain_k = find(sn.chains(c,:));
797 % Lchain and V are per chain cycle; the token
798 % visits this station sum(V(ist,inchain)) times
799 % per cycle, so the idle between two consecutive
800 % requests to an instance is the chain think
802 Vtot = sum(V(ist, inchain_k));
803 ZT = sum(Lchain(infstat, c), 'omitnan
') / max(Vtot, GlobalConstants.FineTol);
804 nu = 1 / max(ZT, GlobalConstants.FineTol);
805 pcold = pie_b * ((nu*eye(size(Tb,1)) - Tb) \ (-Tb*ones(size(Tb,1),1)));
806 % service part uses S/nservers: the surrogate
807 % delay correction below adds the remaining
808 % S*(nservers-1)/nservers, restoring R_k =
809 % p_cold*E[setup] + S_k.
810 Qret{k} = lambda_k(k) * (pcold*setupMean + S(ist,k)/sn.nservers(ist));
812 % A class that offers no load here (zero visits,
813 % or no service process: the chain's client-delay
814 % token class) holds no jobs; 0*NaN would stamp
815 % NaN into QN,
void the unguarded chain sum QNc
816 % of
the second pass, and
the NaN-ignoring max()
817 % in
the response-time fallback would silently
818 % report
the bare service time
for the served
822 % An inactive
class holds no jobs here. NaN
823 % would propagate into
the chain sum used by
824 %
the closed-population renormalization below
825 % (QNc = sum(sum(QN(:,inchain)))), turning
the
826 % whole row NaN and discarding
the queue length;
827 %
the NaN-ignoring max() in
the response-time
828 % fallback would then silently report
the bare
829 % service time. The MMAPPH1FCFS path likewise
830 % yields 0 for a zero-population class.
840 % MMAPPH1FCFS returns one queue-length
841 % distribution per
class (see
the for k=1:K loop
842 % around its
'ncDistr' branch), so all K outputs
843 % must be captured. Requesting a single output
844 % yields
class 1's distribution alone, which was
845 % then reused
for every
class: Qret depended on
846 %
the class only through N(k), so two classes
847 % with equal populations but different visit
848 % ratios were handed
the same queue length, and
849 %
the throughput fixed point resolved that by
850 % driving every chain to
the same lambda.
851 pdistr_all = cell(1,K);
852 [pdistr_all{1:K}] = MMAPPH1FCFS(D, {pie{ist}{:}}, {D0{ist,:}},
'ncDistr', maxLevel);
854 pdistr = pdistr_all{k};
855 pdistr_k = abs(pdistr(1:(N(k)+1)));
856 % Truncating at
the class population folds
857 %
the tail beyond level N(k) into that level,
858 % so
the mean below
is E[min(Q_k,N(k))]. The
859 % complement must therefore be taken over
the
860 % truncated vector: reading it from
the full
861 % distribution instead assigns only
the mass
862 % above N(k), i.e.
P(Q>N(k)) rather than
863 %
P(Q>=N(k)), losing
the levels in between.
864 pdistr_k(end) = abs(1-sum(pdistr_k(1:end-1)));
865 pdistr_k = pdistr_k / sum(pdistr_k(1:(N(k)+1)));
866 Qret{k} = max(0,min(N(k),(0:N(k))*pdistr_k(1:(N(k)+1))
'));
873 Qret{k} = sn.njobs(k);
878 % Per-class decomposition under FCFS: wait time in queue is
879 % uniform across classes, so per-class response time is
880 % R_k = W_q + S_k. From E[N_total] (truncate-renorm) and
881 % per-class effective rates lambda_k_eff = lambda_k*(1-ploss):
882 % W_q = E[N_total]/lambda_total_eff - sum(lambda_k_eff*S_k)/lambda_total_eff
883 % N_k = lambda_k_eff * R_k
884 lambdaInflow = zeros(1, K);
886 cidx = find(sn.chains(:,k),1);
887 lambdaInflow(k) = rates{ist,cidx}(k);
889 lambdaInflow(isnan(lambdaInflow)) = 0;
890 if ~isempty(finiteCapLossPerClass)
891 % Exact MMAP/G/1/K branch: loss differs by class
892 TN_eff = lambdaInflow .* (1 - finiteCapLossPerClass(1:K));
894 TN_eff = lambdaInflow * (1 - finiteCapLossProb);
898 Savg_eff = sum(TN_eff .* S(ist,1:K), 'omitnan
') / sumTN;
899 Wq = max(0, finiteCapMeanQ / sumTN - Savg_eff);
904 TN(ist,k) = TN_eff(k);
905 UN(ist,k) = TN(ist,k) * S(ist,k) / sn.nservers(ist);
907 RN(ist,k) = Wq + S(ist,k);
908 QN(ist,k) = TN(ist,k) * RN(ist,k);
915 QN(ist,:) = cell2mat(Qret);
917 c = find(sn.chains(:,k),1);
918 TN(ist,k) = rates{ist,c}(k);
919 UN(ist,k) = TN(ist,k) * S(ist,k) / sn.nservers(ist);
920 if sn.isfunction(ist) && ~isfinite(UN(ist,k))
921 % A class with no service process here (the chain's
922 % client-delay token class) has S = NaN, and NaN*0
923 % poisons
the Umax saturation gate above, freezing
924 %
the closed-chain regula falsi at
the no-contention
925 % bound TNlb from it=2. At a setup-bearing function
926 % station
the iteration must keep moving so lambda
927 % reaches QNc = Nc with
the cold-start queue length
928 % included;
the class contributes no utilization.
929 % Elsewhere
the NaN freeze
is load-bearing (it pins
930 % LN
class-switching host layers) and
is preserved.
935 % For MAP/D/c, D/M/c, or PH/M/1, use exact results
936 % (no surrogate delay adjustment).
938 RN(ist,k) = phm1Result.meanSojournTime;
940 RN(ist,k) = dmcResult.meanSojournTime;
942 RN(ist,k) = mapdcResult.meanSojournTime;
945 % add number of jobs at
the surrogate delay server.
946 % A
class with no service process here has S=NaN, and
947 %
the correction
is nil
for it (TN=0) and for a single
948 % server ((nservers-1)/nservers=0), but IEEE makes
949 % NaN*0 = NaN, which would propagate into
the chain sum
950 % QNc below,
void the whole row and, via
the NaN-ignoring
951 % max() in
the response-time fallback, silently degrade
952 %
the result to
the bare service time.
953 if isfinite(S(ist,k))
954 QN(ist,k) = QN(ist,k) + TN(ist,k)*S(ist,k) * (sn.nservers(ist)-1)/sn.nservers(ist);
956 RN(ist,k) = QN(ist,k) ./ TN(ist,k);
963 switch sn.nodetype(ind)
965 % line_error(mfilename,'Fork
nodes not supported yet by MAM solver.');
970 %max(max(abs(TN-TN_1)))
975for it=1:2 % second pass to rescale again QN based on RN correction
977 inchain = sn.inchain{c};
978 Nc = sum(sn.njobs(inchain));
980 QNc = sum(sum(QN(:,inchain)));
981 QN(:,inchain) = QN(:,inchain) * (Nc / QNc);
986 ist = sn.nodeToStation(ind);
987 % Skip stations
using exact MAP/D/c solver (already have exact values)
988 if mapdcStations(ist)
992 if isinf(sn.nservers(ist))
993 RN(ist,k) = S(ist,k);
995 RN(ist,k) = max([S(ist,k), QN(ist,k) ./ TN(ist,k)]);
1000 QN(ist,k) = RN(ist,k) .* TN(ist,k);
1004 Nc = sum(sn.njobs(inchain)); % closed population
1005 if Nc == 0 %
if closed chain
1016% SLC clamp: a self-looping
class holds its njobs customers at its reference
1017% station in perpetuity, so its metrics follow in closed
form and supersede
1018% whatever
the decomposition produced. Applied last, after
the queue-length
1019% rescaling above, so that it
is not undone.
1021% At a queueing station
the class is permanently backlogged, hence a
1022% work-conserving server never idles and
the class takes exactly
the capacity
1023%
the other classes leave free: U_slc = 1 - sum_j U_j. This
is exact whenever
1024%
the other classes
' utilizations are, and it reproduces the CTMC solution of
1025% the sanity SLC models to 5 significant digits. Note that solver_mna_closed
1026% and solver_mam_basic_mmap_closed instead pin U_slc = S*njobs*rate, which
1027% assumes each of the njobs customers owns a server and returns U>1 as soon as
1028% njobs exceeds nservers; that form is deliberately not reproduced here.
1029slcAll = find(sn.isslc);
1031 % Utilization is the fraction of server time a class actually consumes, so
1032 % it follows from the declared service time. The inflation applied above
1033 % models the interference the self-looping customers cause in the queueing
1034 % delay, not extra work performed by the server, and must not be reported as
1035 % utilization (nor fed to the leftover-capacity identity below).
1040 UN(ist,k) = Strue(ist,k)*TN(ist,k);
1050 slck = slcAll(sn.refstat(slcAll) == ist);
1054 if isinf(sn.nservers(ist))
1055 % Delay station: no contention, every customer is always in
1056 % service, so the class completes at its full aggregate rate.
1058 QN(ist,k) = sn.njobs(k);
1059 TN(ist,k) = sn.njobs(k)*sn.rates(ist,k);
1060 RN(ist,k) = Strue(ist,k);
1061 UN(ist,k) = Strue(ist,k)*TN(ist,k);
1064 nsrv = sn.nservers(ist);
1065 Uleft = max(0, 1 - sum(UN(ist,~sn.isslc)));
1066 % Several self-looping classes at one station share
the free
1067 % capacity in proportion to
the service rate they offer.
1068 w = sn.njobs(slck(:)
').*sn.rates(ist,slck(:)');
1074 UN(ist,k) = min(Uleft*w(i)/sum(w), min(sn.njobs(k),nsrv)/nsrv);
1075 TN(ist,k) = sn.rates(ist,k)*UN(ist,k)*nsrv;
1076 QN(ist,k) = sn.njobs(k);
1078 RN(ist,k) = QN(ist,k) ./ TN(ist,k);