1function [QN, UN, RN, TN, CN, XN, lG, sn] = solver_ssa_nrm(sn, options)
2% SOLVER_SSA_NRM Steady‑state analysis via
the Next‑Reaction Method (SSA)
4% [QN, UN, RN, TN, CN, XN, LG, SN] = SOLVER_SSA_NRM(SN, OPTIONS)
5% runs a stochastic simulation of
the queueing network described in SN
6%
for OPTIONS.samples reaction firings
using Gibson & Bruck
's
7% Next‑Reaction Method. During the run it:
8% • computes performance metrics directly during simulation;
9% • returns standard queueing performance measures.
12% QN – M×K matrix of mean queue lengths
13% UN – M×K matrix of utilizations
14% RN – M×K matrix of response times
15% TN – M×K matrix of throughputs
16% CN – 1×K vector of cycle times
17% XN – 1×K vector of system throughputs
18% LG – Logarithm of normalizing constant (not computed)
19% SN – (Possibly updated) network structure.
21% See also SOLVER_SSA_NRM_SPACE, NEXT_REACTION_METHOD_DIRECT.
23% ---------------------------------------------------------------------
24% Parameters & shorthands
25% ---------------------------------------------------------------------
26samples = options.samples;
33% ---------------------------------------------------------------------
34% Phase slot map --------------------------------------------------------
35% ---------------------------------------------------------------------
36% The state vector counts jobs per (node, class, PHASE) rather than per
37% (node, class), so that phase-type service is represented exactly instead of
38% being collapsed onto its mean rate. Phase counts differ per (station, class)
39% via sn.phasessz, hence the explicit offset map rather than arithmetic on R.
41% The layout is chosen so that a single-phase model is bit-for-bit the old one:
42% with nph == 1 everywhere, phOff(ind,r) = (ind-1)*R + (r-1) and therefore
43% slot(ind,r,1) = (ind-1)*R + r, exactly the flat class index the engine used
44% before. Every exponential model must reproduce its previous results, which is
45% the self-check for this generalization.
49 ist = sn.nodeToStation(ind);
51 nph(ind,r) = max(1, sn.phasessz(ist,r));
63isPhaseExpanded = NS > I*R; % false for a purely exponential model
64% Reverse map. Every consumer that used to decode a state index arithmetically
65% (floor((slot-1)/R)+1, mod(slot-1,R)+1) must go through this instead: with
66% unequal phase counts the flat arithmetic no longer identifies the node.
67smap = struct(); % layout descriptor threaded into every slot consumer
68slotNode = zeros(NS,1);
69slotClass = zeros(NS,1);
70slotPhase = zeros(NS,1);
74 slotNode(phOff(ind,r)+kk) = ind;
75 slotClass(phOff(ind,r)+kk) = r;
76 slotPhase(phOff(ind,r)+kk) = kk;
80smap.node = slotNode; smap.class = slotClass; smap.phase = slotPhase;
81smap.phOff = phOff; smap.nph = nph; smap.R = R;
83% Buffered phase-type service. A non-preemptive buffered station (FCFS/LCFS/
84% SIRO/HOL/SEPT/LEPT) with phase-type service cannot be handled like the INF/PS
85% family: only the jobs ACTUALLY in service carry a phase, and the waiting jobs
86% in the buffer have not started service, so nvec (which counts the whole class
87% population) does not record the in-service phase composition. That composition
88% is tracked in a SEPARATE auxiliary structure svcph{ind}(r,k) = number of
89% class-r jobs in service in phase k, maintained on arrivals, departures and
90% phase transitions. nvec keeps its meaning (total population) unchanged, so
91% every other consumer (FCR, balking, JSQ, retrial, metrics) is untouched; only
92% the buffered-PH departure/phase reactions read svcph instead of nvec. Pure
93% exponential buffered classes (nph == 1) keep the original rate law and never
94% touch svcph. Preemptive LCFSPR and POLLING are excluded: LCFSPR would need the
95% preempted job's phase remembered in
the buffer (preempt-resume), and a polling
96% controller carries only one in-service job; both stay on
the serial engine.
97bufPHSched = [SchedStrategy.FCFS, SchedStrategy.LCFS, SchedStrategy.SIRO, ...
98 SchedStrategy.HOL, SchedStrategy.SEPT, SchedStrategy.LEPT];
99bufPHClass =
false(I, R);
101 if sn.isstation(ind) && any(sn.sched(sn.nodeToStation(ind)) == bufPHSched)
104 bufPHClass(ind,r) =
true;
109bufPHNode = any(bufPHClass, 2);
111smap.bufPHClass = bufPHClass; smap.bufPHNode = bufPHNode;
113% Cache
nodes. A Cache
is an immediate
class-
switch: a job arrives in a READ
114%
class, reads an item drawn from pread, and leaves in
the hit or miss
class
115% depending on whether
the item
is currently cached, after which
the replacement
116% policy updates
the cache contents. The NRM models
this as a state-dependent
117%
class-
switch reaction at
the cache node (consume [cache,readClass], produce
118% [cache,hitClass] or [cache,missClass], chosen at firing by
the cache access),
119% mirroring State.afterEventCache. The cache contents ride alongside
the buffers
120% (buffers{cacheNode}) since no rate depends on them;
the hit/miss draw and
the
121% replacement update read and rewrite them at firing. A
class r
is a READ class
122% of cache ind iff its pread entry
is a non-empty probability row.
123isCacheNode =
false(I,1);
124isCacheReadClass =
false(I,R);
126 if sn.nodetype(ind) == NodeType.Cache
127 isCacheNode(ind) =
true;
128 np = sn.nodeparam{ind};
129 if isfield(np,
'retrievalClassIndices') && ~isempty(np.retrievalClassIndices)
130 rci = np.retrievalClassIndices(:)';
135 % A normal read class has a hit class; a retrieval class (created by
136 % setRetrievalSystem) reads its own one-hot item to COMPLETE a miss
137 % and has hitclass == 0 but a miss class. Both take a cache-access
138 % reaction;
the outcome class
is resolved at firing.
139 if r <= numel(np.pread) && ~isempty(np.pread{r}) && all(~isnan(np.pread{r}(:))) ...
140 && ((r <= numel(np.hitclass) && np.hitclass(r) > 0) || any(rci == r))
141 isCacheReadClass(ind,r) = true;
146smap.isCacheNode = isCacheNode;
148% Destination slot a BEGUN retrieval routes to (
the fetch queue). When a miss
149% starts a retrieval
the job must go to
the retrieval queue, be served (
the fetch
150% delay), and only THEN
return to
the cache to complete
the miss. If it were left
151% at [cache,retrievalClass]
the cache-access reaction would fire again and
152% complete
the miss instantly, collapsing
the fetch. So a begin lands
the job at
153%
the retrieval
class's routed destination; only queue-returns occupy
154% [cache,retrievalClass] and trigger completion. Resolved once from rtnodes.
155cacheRetrDest = zeros(I, R);
158 np = sn.nodeparam{ind};
159 if isfield(np,'retrievalClassIndices
') && ~isempty(np.retrievalClassIndices)
160 for rc = np.retrievalClassIndices(:)'
161 row = sn.rtnodes((ind-1)*R + rc, :);
162 dslot = find(row > 0, 1,
'first');
164 jnd = floor((dslot-1)/R) + 1; s = mod(dslot-1,R) + 1;
165 cacheRetrDest(ind, rc) = phOff(jnd, s) + 1;
172% ---------------------------------------------------------------------
173% Stochastic Petri net path --------------------------------------------
174% ---------------------------------------------------------------------
175% A model with Transition
nodes is a stochastic Petri net, not a queueing
176% network: its dynamics are firings of transition modes over a place marking,
177% not job departures routed by
the rt matrix. The stoichiometry matrix of
the
178% reaction network IS
the net
's incidence matrix, so the NRM is the natural
179% simulator, but the generic (node,class) departure grid below does not apply
180% (a firing produces to several places deterministically, never a routing
181% draw). Route Petri nets to the dedicated builder/runner, which shares the
182% Gibson & Bruck clocks but its own firing application and vanishing-marking
183% collapse for immediate transitions.
184if any(sn.nodetype == NodeType.Transition)
185 [QN, UN, RN, TN, CN, XN] = solver_ssa_nrm_spn(sn, options, phOff, nph, NS, smap);
190% ---------------------------------------------------------------------
191% Stoichiometry & reaction mapping (self‑loops included) ----------------
192% ---------------------------------------------------------------------
193S = zeros(0, NS); % will transpose at the end
198% this is currently M^2*R^2, it can be lowered to M*R decoupling the
200% Departure reactions, one per (node, class, PHASE). A departure is the
201% absorption of the phase-type service process, so it fires at mu(k)*phi(k) and
202% the job re-enters its destination in an entry phase drawn from pie: the
203% destination draw therefore carries the product of the routing probability and
204% the entry-phase probability. The existing weighted-destination sampler takes
205% that product unchanged.
207depPhase = []; % service phase each departure reaction consumes
208isBufSvcRx = false(0,1); % departure reaction of a buffered-PH class (reads svcph)
209isCacheRx = false(0,1); % cache-access reaction (read -> hit/miss at a cache node)
210cacheHitSlot = zeros(0,1); % nvec slot the hit-class job is produced into
211cacheMissSlot = zeros(0,1); % nvec slot the miss-class job is produced into
214 for kk = 1:nph(ind,r)
216 fromIR(k,:) = [ind, r];
218 isCacheRx(k,1) = false;
219 cacheHitSlot(k,1) = 0;
220 cacheMissSlot(k,1) = 0;
221 if isCacheReadClass(ind,r)
222 % Cache access: consume the read-class job at the cache; its
223 % production (hit or miss class, at the SAME cache node) and the
224 % contents update are resolved at firing by cacheAccess. No
225 % static routing: rtnodes has no out-edge for the read class.
226 fromIdx(k) = phOff(ind,r) + 1;
227 np = sn.nodeparam{ind};
229 Srow(fromIdx(k)) = -1;
233 isCacheRx(k,1) = true;
234 isBufSvcRx(k,1) = false;
235 % The outcome class (hit/miss/retrieval) is resolved at firing, so
236 % these slots are informational only; a retrieval class has
237 % hitclass 0, so guard the lookup.
238 if r <= numel(np.hitclass) && np.hitclass(r) > 0
239 cacheHitSlot(k,1) = phOff(ind, np.hitclass(r)) + 1;
241 if r <= numel(np.missclass) && np.missclass(r) > 0
242 cacheMissSlot(k,1) = phOff(ind, np.missclass(r)) + 1;
246 % At a buffered-PH source only the jobs in service carry a phase and
247 % the phase composition lives in svcph, not nvec; nvec holds the whole
248 % class population in its first phase slot. A departure therefore
249 % removes one job from that total slot regardless of which service
250 % phase completed -- the completing phase kk is carried in depPhase
251 % and consumed from svcph at firing.
253 fromIdx(k) = phOff(ind,r) + 1;
254 isBufSvcRx(k,1) = true;
256 fromIdx(k) = phOff(ind,r) + kk;
257 isBufSvcRx(k,1) = false;
261 Srow = zeros(1, NS); % build stoichiometry row
263 Srow(fromIdx(k)) = -Inf;
265 Srow(fromIdx(k)) = -1;
268 p = sn.rtnodes((ind-1)*R+r, (jnd-1)*R+s);
271 % A job arriving at a buffered-PH destination lands
272 % in the total-population slot; whether it enters
273 % service (and in which entry phase) or waits is
274 % decided at firing from the server occupancy and
275 % pie, not by the routing draw. So the destination
276 % collapses to the single total slot with weight p.
277 dslot = phOff(jnd,s) + 1;
278 toIdx{k}(end+1) = dslot;
279 probIR{k}(end+1) = p;
280 Srow(dslot) = Srow(dslot) + p;
282 pentry = entryProbs(sn, jnd, s, nph(jnd,s));
283 for ke = 1:nph(jnd,s)
287 dslot = phOff(jnd,s) + ke;
288 toIdx{k}(end+1) = dslot;
289 probIR{k}(end+1) = p * pentry(ke);
290 Srow(dslot) = Srow(dslot) + p * pentry(ke);
301nDepRx = k; % departure reactions occupy 1..nDepRx
302isBufSvcRx(end+1:k,1) = false;
304% Phase-transition reactions, one per (node, class, k -> k'). These move a job
305% between
the phases of its own service process and so never leave
the node;
306% D0
's off-diagonal carries their rates (State.afterEventStation, EventType.PHASE).
307isPhaseRx = false(k,1);
308phaseFrom = zeros(k,1);
310phaseRate = zeros(k,1);
312 if ~sn.isstation(ind)
315 ist = sn.nodeToStation(ind);
317 if nph(ind,r) <= 1 || isempty(sn.proc{ist}{r})
320 D0 = sn.proc{ist}{r}{1};
321 for ka = 1:nph(ind,r)
322 for kb = 1:nph(ind,r)
323 if ka == kb || D0(ka,kb) <= 0
327 fromIR(k,:) = [ind, r];
332 % A buffered-PH class keeps its in-service phase counts in
333 % svcph, not in nvec: a phase transition moves a job between
334 % phases of the SAME in-service composition, so it leaves nvec
335 % (the class total) unchanged. The stoichiometry column is
336 % therefore all zeros; the move is applied to svcph at firing
337 % and, like a retry/switchover, its dependency set must be
338 % supplied through a forced refresh (D cannot derive it from S).
339 fromIdx(k) = phOff(ind,r) + 1;
341 fromIdx(k) = phOff(ind,r) + ka;
342 Srow(phOff(ind,r) + ka) = -1;
343 Srow(phOff(ind,r) + kb) = 1;
346 isPhaseRx(k,1) = true;
349 phaseRate(k,1) = D0(ka,kb);
354isPhaseRx(end+1:k,1) = false;
355depPhase(end+1:k,1) = 0;
357% Reneging: each waiting (queued, not-in-service) class-r job abandons at the
358% memoryless rate sn.impatienceMu, so the aggregate rate out of the state is
359% (waiting count)*mu and the job leaves the system (the passive half of the
360% sync is LOCAL in refreshSync). This is a reaction the (node,class) departure
361% grid above cannot express -- it consumes a job without producing one -- so it
362% is appended as an extra column whose stoichiometry is a bare -1 at the source
363% slot. A renege is not a departure and must not count towards throughput; the
364% TN accumulator reads the first reaction with a given source slot, which is
365% always the departure, so the appended columns stay out of it.
366nDep = k; % departure reactions occupy 1..nDep
367isRenegeRx = false(nDep,1);
368renegeMu = zeros(nDep,1);
369if isfield(sn,'impatienceClass
') && ~isempty(sn.impatienceClass) ...
370 && any(sn.impatienceClass(:) == ImpatienceType.RENEGING)
372 ind = sn.stationToNode(ist);
374 if sn.impatienceClass(ist,r) == ImpatienceType.RENEGING && sn.impatienceMu(ist,r) > 0
376 fromIR(k,:) = [ind, r];
377 fromIdx(k) = (ind-1)*R + r;
380 Srow = zeros(1, I*R);
381 Srow((ind-1)*R + r) = -1; % job abandons and leaves the system
383 isRenegeRx(k,1) = true;
384 renegeMu(k,1) = sn.impatienceMu(ist,r);
389% Retrial: an orbiting class-r job retries entry at the memoryless rate
390% sn.retrialMu and succeeds only when a server is free; otherwise the event is
391% a no-op and is simply not generated (State.afterEventStation, EventType.RETRY).
392% The orbit needs no new state: orbiting jobs are already counted in the
393% station population and held in the buffer, so orbit_r is exactly the buffer
394% occupancy the FCFS-family rate law already reads. A retry moves a job from
395% the orbit into service WITHOUT changing any population, so its stoichiometry
396% column is all zeros -- which is why its dependency set has to be supplied by
397% hand below: D is derived from S, and an all-zero column would otherwise leave
398% every rate at the node stale after a retry fires.
399isRetryRx = false(k,1);
401retryNode = zeros(k,1);
402if isfield(sn,'retrialProc
') && ~isempty(sn.retrialProc)
404 if ~any(~cellfun(@isempty, sn.retrialProc(ist,:)))
407 ind = sn.stationToNode(ist);
409 if sn.retrialMu(ist,r) > 0
411 fromIR(k,:) = [ind, r];
412 fromIdx(k) = (ind-1)*R + r;
415 S(k,:) = zeros(1, I*R); % a retry moves no job between nodes
416 isRetryRx(k,1) = true;
417 retryMu(k,1) = sn.retrialMu(ist,r);
418 retryNode(k,1) = ind;
424% Polling switchover reactions. A polling server cycles through the buffers it
425% serves, carrying a controller [mode, pos, swphase, ctr] in the auxiliary
426% buffer of its node (mode 0 parked, 1 serving pos, 2 switching towards pos).
427% A service departure fires only while the controller serves that class (gated
428% in the propensity below); when a visit ends the server walks the cyclic order
429% (State.pollingNext folds every immediate switchover) and, on meeting a timed
430% switchover, dwells in mode 2. That dwell is a genuine timed event with no job
431% movement, so it is appended here as one reaction per polling node with a timed
432% switchover, exactly as a retry is: an all-zero stoichiometry column whose
433% propensity reads the controller and whose firing samples the switchover PH.
434% Only exponential service is expanded at a polling station (phaseNrmOK gates PH
435% service there); the switchover itself may be phase-type, its phases carried in
436% the controller rather than in nvec.
437poll = struct('on
', false);
438poll.isPoll = false(1, I);
439poll.pinfo = cell(I, 1);
440poll.swRx = zeros(1, I); % switchover reaction index of each polling node, 0 if none
442 if sn.isstation(ind) && sn.sched(sn.nodeToStation(ind)) == SchedStrategy.POLLING
443 poll.pinfo{ind} = State.pollingInfo(sn, ind);
444 poll.isPoll(ind) = true;
446 % The NRM tracks only the controller of a polling station, not the
447 % service phase of the single job in service, so phase-type service at a
448 % polling station is not expanded here. Reject it rather than spread the
449 % class over phases and gate each phase reaction on the same class (which
450 % would serve several fictitious phase-jobs at once). Switchover may be
451 % phase-type: its phase is carried in the controller.
454 line_error(mfilename, sprintf('NRM polling supports exponential service only; station %d
class %d has phase-type service. Use method=
''serial
''.
', sn.nodeToStation(ind), r));
459isPollSwRx = false(k, 1);
460pollSwNode = zeros(k, 1);
463 pinf = poll.pinfo{ind};
464 if isempty(pinf) || ~any(pinf.hasSw)
465 continue % no timed switchover: the server never dwells in a walk
468 fromIR(k,:) = [ind, 1]; % class field is a sentinel; never read as a class here
469 fromIdx(k) = (ind-1)*R + 1; % unused slot: a switchover consumes no job
472 S(k,:) = zeros(1, I*R); % a switchover moves no job between nodes
473 isPollSwRx(k,1) = true;
474 pollSwNode(k,1) = ind;
479% Pad every per-reaction marker to the final reaction count, so the reaction
480% loops below index them safely regardless of which extra-reaction families
481% (renege, retry, switchover) are present.
482isRenegeRx(end+1:k,1) = false;
483isRetryRx(end+1:k,1) = false;
484isPhaseRx(end+1:k,1) = false;
485depPhase(end+1:k,1) = 0;
486isPollSwRx(end+1:k,1) = false;
487pollSwNode(end+1:k,1) = 0;
488isBufSvcRx(end+1:k,1) = false;
489isCacheRx(end+1:k,1) = false;
490cacheHitSlot(end+1:k,1) = 0;
491cacheMissSlot(end+1:k,1) = 0;
492renegeMu(end+1:k,1) = 0;
493retryMu(end+1:k,1) = 0;
494retryNode(end+1:k,1) = 0;
496S = S.'; % states × reactions
498% ---------------------------------------------------------------------
499% Initial state vector --------------------------------------------------
500% ---------------------------------------------------------------------
501nvec0 = zeros(NS,1); % initial state (per node,
class and phase)
502% Non-preemptive policies that hold waiting jobs in a buffer. They share
the
503% rate law (a
class-r completion fires at mu_r times
the class-r jobs actually
504% in service) and differ only in which waiting job
is promoted on a departure;
506bufferedSched = [SchedStrategy.FCFS, SchedStrategy.LCFS, SchedStrategy.SIRO, ...
507 SchedStrategy.HOL, SchedStrategy.SEPT, SchedStrategy.LEPT, ...
508 SchedStrategy.LCFSPR, SchedStrategy.PAS];
509% Preemptive policies: an arrival at a fully busy station takes a server and
510% pushes
the incumbent it displaced back into
the buffer, rather than queueing
511% itself (State.afterEventStation,
the FCFSPR/LCFSPR arrival group). The rate
512% law
is unchanged -- it still counts
the jobs actually in service -- so no
513% extra state
is needed: in-service
is population minus buffer occupancy, and
514% that automatically names
the new arrival as
the one being served. With
515% exponential service preempt-resume needs no stored phase, because a resumed
516% job has
the same memoryless residual as a fresh one. LCFSPI
is deliberately
517% absent: SolverSSA.getFeatureSet does not advertise it (nor does SolverCTMC),
518% so
the NRM must not claim it either.
519preemptiveSched = SchedStrategy.LCFSPR;
520% Order-independent / pass-and-swap stations keep
the FULL ordered job list,
521% not just
the waiting jobs: there
is no server/buffer split at all, and
the
522% rate
is a function mu(c) of
the whole list (State.afterEventStationPAS). So
523% these carry a different buffer invariant -- numel(buf) == total, rather than
524% max(0, total - mi) -- and
the list runs OLDEST-FIRST (c(1)
is the oldest),
525%
the reverse of every other buffered policy here.
526% sn.sched carries PAS
for both PAS and OI stations: OI
is canonicalized to
527% pass-and-swap with an all-zero swap graph (see MNetwork.refreshLocalVars), so
528% reading
the graph covers both and OI needs no separate
case.
529listSched = SchedStrategy.PAS;
530% Of those,
the policies whose state keeps
the buffer as per-
class counts
531% rather than as an ordered list of
class ids (State.fromMarginalAndRunning).
532countBufferedSched = [SchedStrategy.SIRO, SchedStrategy.SEPT, SchedStrategy.LEPT];
533buffers0 = cell(I,1); % per-node ordered buffer of waiting job classes (FCFS/LCFS)
537% A cache node has no queueing buffer; its buffers slot instead carries
the cache
538% CONTENTS (
the item held in each of
the totalCacheCapacity slots, ordered by
539% list as State.afterEventCache lays them out). Any valid ordered placement
is a
540% correct warm start since
the chain
is ergodic, so slot i starts holding item i.
543 np = sn.nodeparam{ind};
544 if isfield(np,
'totalCacheCapacity') && ~isempty(np.totalCacheCapacity)
545 tcc = np.totalCacheCapacity;
547 tcc = sum(np.itemcap);
549 % With a retrieval system
the contents are followed by a per-item
550 % occupancy bitmap (State.spaceCache):
column tcc+i
is 1 iff item i
is
551 % currently being retrieved. Start with an empty bitmap.
552 if isfield(np,'retrievalSystemCapacity') && ~isempty(np.retrievalSystemCapacity) ...
553 && any(np.retrievalSystemCapacity > 0)
554 buffers0{ind} = [1:tcc, zeros(1, np.nitems)];
560% In-service phase multiset of each buffered-PH node: svcph0{ind}(r,k) counts
the
561%
class-r jobs in service in phase k. Empty
for every other node. Populated below
562% once
the waiting buffer of each buffered-PH node
is known (in-service =
class
563% total minus waiting), so it
is filled after
the buffer loop.
567 svcph0{ind} = zeros(R, maxnph);
573 if sn.isstateful(ind)
574 state_i = state{sn.nodeToStateful(ind)};
575 [~,nir] = State.toMarginalAggr(sn, ind, state_i);
578 if sn.nodetype(ind) == NodeType.Source
581 line_error(mfilename,
'Infinite population error.');
584 % Spread
the class population across its phases. The marginal
the
585 % initial state carries
is per
class, not per phase, so
the entry
586 % distribution pie
is the natural allocation: it
is the phase a job
587 % starts service in. For a single-phase
class this puts everything
588 % in slot 1, reproducing
the old flat layout exactly. A buffered-PH
589 %
class keeps its whole population in slot 1 too -- nvec
is the class
590 % total there and
the in-service phase composition lives in svcph0
591 % (built below), so
the phase slots 2..nph stay empty in nvec.
592 if nph(ind,r) <= 1 || bufPHClass(ind,r)
593 nvec0(phOff(ind,r) + 1,1) = nir(r);
595 pe = entryProbs(sn, ind, r, nph(ind,r));
597 for ke = 1:nph(ind,r)
601 take = min(left, round(nir(r) * pe(ke)));
603 nvec0(phOff(ind,r) + ke,1) = take;
609 % Populate buffers
for buffered
nodes from
the raw state vector
610 % (only stations have buffered scheduling; skip non-station
611 % stateful
nodes such as RROBIN dispatchers/Routers and Caches)
612 ist = sn.nodeToStation(ind);
613 if ist >= 1 && any(sn.sched(ist) == bufferedSched)
614 sumK = sum(sn.phasessz(ist,:));
615 sumNvars = sum(sn.nvars(ind,:));
616 bufCols = size(state_i,2) - sumK - sumNvars;
617 if any(sn.sched(ist) == listSched)
618 % PAS/OI stores
the full ordered list left-aligned in
the first
619 % cap(ist) columns, c(1) oldest, zero-padded on
the right --
620 % already
the order
the NRM needs, so it
is copied verbatim
621 % rather than reversed.
622 % Its width
is nCols - nvars, NOT
the shared bufCols: a PAS
623 % station has no server/phase block at all (there
is no
624 % server/buffer split), yet phasessz still floors to 1 per class
625 % as for any other station, so subtracting sumK here would drop
626 %
the last sum(phasessz) entries of
the list. Both PAS
627 % authorities, State.afterEventStationPAS and
the PAS branch of
628 % State.toMarginal, read W = size(inspace,2) - V.
629 pasCols = size(state_i,2) - sumNvars;
631 classId = state_i(1,pos);
632 if classId >= 1 && classId <= R
636 elseif any(sn.sched(ist) == countBufferedSched)
637 % SIRO/SEPT/LEPT keep an UN-ordered buffer:
the first R columns
638 % hold
the per-
class counts of waiting jobs, not class ids (see
639 % State.fromMarginalAndRunning). Expand them into
the NRM
's
640 % ordered list; the order within it is immaterial for these
641 % disciplines, which select by class and never by position.
642 for r = 1:min(R, bufCols)
643 buffers0{ind}(end+1:end+state_i(1,r)) = r;
646 % FCFS/HOL/LCFS keep an ordered list of class ids
648 classId = state_i(1,pos);
649 if classId >= 1 && classId <= R
650 buffers0{ind}(end+1) = classId; % addLast
652 % classId == 0 means empty position, skip
657 % Seed the in-service phase multiset of a buffered-PH node. The jobs in
658 % service are the class total minus the ones waiting in the buffer just
659 % built; their starting phases are drawn from the entry distribution pie,
660 % the same allocation the INF/PS init uses. Only in-service jobs get a
661 % phase -- waiting jobs have not started service and carry none.
664 waiting_r = sum(buffers0{ind} == r);
665 insvc_r = max(0, nir(r) - waiting_r);
667 svcph0{ind}(r,1) = insvc_r;
669 pe = entryProbs(sn, ind, r, nph(ind,r));
671 for ke = 1:nph(ind,r)
675 take = min(left, round(insvc_r * pe(ke)));
677 svcph0{ind}(r,ke) = take;
691 ist = sn.nodeToStation(ind);
692 muir = sn.rates(ist,r);
696 mi(ind,1) = sn.nservers(ist);
700 rates(ind,r) = GlobalConstants.Immediate;
701 mi(ind,1) = GlobalConstants.MaxInt;
704 mi(isinf(mi)) = GlobalConstants.MaxInt;
707% Limited load-dependent scaling lld(ist, ntot): a work-conserving factor that
708% multiplies the aggregate service rate at total station population ntot (as in
709% State.afterEventStation). Default (all ones) for stations without load
710% dependence, so it is inert for plain single-/multi-server queues.
711if isempty(sn.lldscaling)
712 lldMat = []; lldlimit = 0;
714 lldMat = sn.lldscaling; lldlimit = size(lldMat,2);
717% Class-dependent scaling cdscaling{ist}: a handle mapping the per-class
718% station population vector n to the 1xR vector of rate scalings beta_r(n)
719% (as in State.afterEventStation, evaluated per firing on the current state).
720if isempty(sn.cdscaling)
723 cdCell = sn.cdscaling;
726% Scheduling policies whose rate law reads per-class weights from
727% sn.schedparam. These are single-server only, as in State.afterEventStation.
728weightedSched = [SchedStrategy.DPS, SchedStrategy.GPS, ...
729 SchedStrategy.DPSPRIO, SchedStrategy.GPSPRIO];
731% Finite capacity regions (DROP rule). A region constrains an aggregate of the
732% per-class populations of its member stations, which is a linear function of
733% the NRM state vector, so admission is a multiplicative 0/1 gate on the
734% routing draw. The DROP rule censors the refused transition, and censoring an
735% exponential transition is exactly what zeroing its share of the propensity
736% does. WAITQ instead parks refused jobs in a per-region FIFO, which is extra
737% state the reaction network does not carry, so those models are routed to the
738% serial engine by SOLVER_SSA_ANALYZER and never reach here.
739fcr = fcrPrecompute(sn);
741% Balking. An arrival that balks is lost: it has left its source but never
742% joins the destination, so the departure rate is unchanged and only the
743% arrival outcome differs (State.afterEventStation scales the admitted
744% branches by 1-balkProb and adds a balked branch of probability balkProb that
745% leaves the destination state untouched). Only the QUEUE_LENGTH strategy is a
746% pure function of the state vector; EXPECTED_WAIT / COMBINED depend on the
747% mean wait and are rejected by the analyzers.
748balk = balkPrecompute(sn);
750% G-network signals. A signal class never joins the station it reaches: it acts
751% on the jobs already there and is annihilated (State.afterEventStationSignal).
752% That makes it an arrival-side effect exactly like balking, so the departure
753% rate is unchanged and only the arrival outcome differs. The reference
754% enumerates every victim subset with its probability because it builds a
755% generator; a simulator instead draws the batch size and the victims, which is
756% equivalent and avoids the enumeration.
757sig = signalPrecompute(sn);
759% Round-robin routing. The pointer that RROBIN/WRROBIN walk is a per-(node,
760% class) local variable, not a population, and no rate depends on it: it only
761% decides where a departure goes. In a generator that makes it a genuine extra
762% state dimension, but a simulator can carry it as auxiliary state alongside
763% the buffers, which is what happens here. State.afterEventRouter advances the
764% pointer on the departure and the routing closure then reads state_AFTER, so
765% the destination used is the one the pointer lands on -- advance first, then
767rr = rrPrecompute(sn, state);
769% Propensity function ---------------------------------------------------
770epstol = GlobalConstants.Zero;
772classprio = sn.classprio(:)'; % lower value = higher priority in LINE
773% Rate of
the service-process
event each reaction carries:
the absorption
774% mu(k)*phi(k)
for a departure,
the off-diagonal D0(k,k
') for a phase change.
775% For a single-phase class this is just the exponential rate, so an exponential
776% model sees exactly the rates it saw before.
777rateOf = zeros(length(fromIdx),1);
778for j = 1:length(fromIdx)
779 ind = fromIR(j,1); r = fromIR(j,2);
781 rateOf(j) = phaseRate(j);
782 elseif sn.isstation(ind)
783 ist = sn.nodeToStation(ind);
785 if nph(ind,r) > 1 && ~isempty(sn.proc{ist}{r})
786 rateOf(j) = sn.mu{ist}{r}(kk) * sn.phi{ist}{r}(kk);
788 rateOf(j) = rates(ind, r);
791 rateOf(j) = rates(ind, r);
795for j=1:length(fromIdx)
797 base = (ind-1)*R + 1; % first per-class state slot of this node
798 ldrow = []; % load-dependent scaling row of the station
799 cdbeta = []; % class-dependence handle of the station
800 wrow = []; % normalized DPS/GPS scheduling weights
802 istj = sn.nodeToStation(ind);
803 if istj >= 1 && ~isempty(lldMat), ldrow = lldMat(istj, :); end
804 if istj >= 1 && istj <= numel(cdCell) && ~isempty(cdCell{istj})
805 cdbeta = cdCell{istj};
807 if istj >= 1 && any(sn.sched(istj) == weightedSched)
808 wrow = sn.schedparam(istj, 1:R);
810 line_error(mfilename, sprintf('Station %d has %s scheduling with non-positive total weight.
', istj, SchedStrategy.toText(sn.sched(istj))));
812 wrow = wrow / sum(wrow);
813 % State.afterEventStation rejects multi-server DPS/GPS, so the
814 % rate law below is only defined for a single server. Fail here
815 % rather than silently simulate a different station.
817 line_error(mfilename, sprintf('Multi-server %s stations are not supported yet.
', SchedStrategy.toText(sn.sched(istj))));
821 % Buffered phase-type service. Only the jobs in service carry a phase, and
822 % their per-phase counts live in svc{ind}(r,k), not in nvec. Both the
823 % departure (absorption of phase kk) and the internal phase transition
824 % (kk -> kb) therefore fire at rate rateOf(j) times the number of class-r
825 % jobs currently in service in the source phase kk -- exactly the INF-family
826 % law rateOf*kir, but with kir read from the in-service multiset svc rather
827 % than from nvec (whose class total also counts the waiting jobs). The
828 % load-/class-dependent factors still read the total population, as for the
829 % exponential buffered law. A single-phase (exponential) buffered class is
830 % NOT bufPHClass and keeps its original rate law below.
831 if sn.isstation(ind) && bufPHClass(ind, fromIR(j,2))
834 kk_ph = phaseFrom(j);
838 a{j} = @(X, bufs, svc) rateOf(j) * svc{ind}(rr_ph, kk_ph) ...
839 * lldfac(ldrow, sum(classCounts(X, phOff, nph, ind, R)), lldlimit) ...
840 * cdfac(cdbeta, classCounts(X, phOff, nph, ind, R), fromIR(j,2));
844 switch sn.sched(sn.nodeToStation(ind))
845 case SchedStrategy.EXT
846 % A Source fires at a constant arrival rate. It has no service
847 % phases (nph == 1 there, enforced by phaseNrmOK), so the
848 % kir/nir share must NOT be applied: the Source's fictitious
849 % token would drive kirFrac to 0 and silence
the Source, which
850 % deadlocks every open model. rateOf(j)
is that constant rate.
851 a{j} = @(X,
bufs, svc) rateOf(j);
852 case SchedStrategy.INF
853 a{j} = @(X,
bufs, svc) rateOf(j) * kirFrac(X, fromIdx(j), phOff, nph, fromIR(j,1), fromIR(j,2)) * classPop(X, phOff, nph, ind, fromIR(j,2)) ...
854 * cdfac(cdbeta, classCounts(X, phOff, nph, ind, R), fromIR(j,2));
855 case {SchedStrategy.PS, SchedStrategy.LPS}
856 % LPS shares
the PS rate law in State.afterEventStation:
the
857 % sharing limit
is the server
count, so min(ni,c) covers both.
858 if R == 1 % single
class
859 a{j} = @(X,
bufs, svc) rateOf(j) * kirFrac(X, fromIdx(j), phOff, nph, fromIR(j,1), fromIR(j,2)) * min( mi(fromIR(j,1)), classPop(X, phOff, nph, ind, fromIR(j,2))) ...
860 * lldfac(ldrow, classPop(X, phOff, nph, ind, fromIR(j,2)), lldlimit) ...
861 * cdfac(cdbeta, classCounts(X, phOff, nph, ind, R), fromIR(j,2));
863 a{j} = @(X,
bufs, svc) rateOf(j) * kirFrac(X, fromIdx(j), phOff, nph, fromIR(j,1), fromIR(j,2)) * ( classPop(X, phOff, nph, ind, fromIR(j,2)) ./ ...
864 (epstol+sum( classCounts(X, phOff, nph, ind, R) ) )) * ...
865 min( mi(fromIR(j,1)), (epstol+sum( classCounts(X, phOff, nph, ind, R) )) ) ...
866 * lldfac(ldrow, sum(classCounts(X, phOff, nph, ind, R)), lldlimit) ...
867 * cdfac(cdbeta, classCounts(X, phOff, nph, ind, R), fromIR(j,2));
869 case SchedStrategy.DPS
870 % Discriminatory PS:
class r receives a share w_r*n_r/(w.n) of
871 %
the single server (State.afterEventStation,
case DPS).
872 a{j} = @(X,
bufs, svc) rateOf(j) * kirFrac(X, fromIdx(j), phOff, nph, fromIR(j,1), fromIR(j,2)) ...
873 * dpsshare(wrow, classCounts(X, phOff, nph, ind, R), fromIR(j,2)) ...
874 * lldfac(ldrow, sum(classCounts(X, phOff, nph, ind, R)), lldlimit) ...
875 * cdfac(cdbeta, classCounts(X, phOff, nph, ind, R), fromIR(j,2));
876 case SchedStrategy.GPS
877 % Generalized PS: share w_r/(w.c) where c_s = 1{n_s>0}, i.e.
878 % weights are split across
the *active* classes only.
879 a{j} = @(X,
bufs, svc) rateOf(j) * kirFrac(X, fromIdx(j), phOff, nph, fromIR(j,1), fromIR(j,2)) ...
880 * gpsshare(wrow, classCounts(X, phOff, nph, ind, R), fromIR(j,2)) ...
881 * lldfac(ldrow, sum(classCounts(X, phOff, nph, ind, R)), lldlimit) ...
882 * cdfac(cdbeta, classCounts(X, phOff, nph, ind, R), fromIR(j,2));
883 case SchedStrategy.PSPRIO
884 % Below capacity every job
is served, so priority
is inert;
885 % above it, only
the most urgent non-empty group shares
the
886 % servers. lld uses
the priority-group population, cd
the full
887 % one, mirroring State.afterEventStation exactly.
888 a{j} = @(X,
bufs, svc) rateOf(j) * kirFrac(X, fromIdx(j), phOff, nph, fromIR(j,1), fromIR(j,2)) ...
889 * psprioshare(classCounts(X, phOff, nph, ind, R), fromIR(j,2), mi(fromIR(j,1)), classprio) ...
890 * lldfac(ldrow, prioPop(classCounts(X, phOff, nph, ind, R), fromIR(j,2), mi(fromIR(j,1)), classprio), lldlimit) ...
891 * cdfac(cdbeta, classCounts(X, phOff, nph, ind, R), fromIR(j,2));
892 case SchedStrategy.DPSPRIO
893 % As DPS, but above capacity restricted to
the most urgent
894 % non-empty group; cd
is evaluated on
the priority-restricted
895 % population (State.afterEventStation,
case DPSPRIO).
896 a{j} = @(X,
bufs, svc) rateOf(j) * kirFrac(X, fromIdx(j), phOff, nph, fromIR(j,1), fromIR(j,2)) ...
897 * dpsprioshare(wrow, classCounts(X, phOff, nph, ind, R), fromIR(j,2), mi(fromIR(j,1)), classprio) ...
898 * lldfac(ldrow, prioPop(classCounts(X, phOff, nph, ind, R), fromIR(j,2), mi(fromIR(j,1)), classprio), lldlimit) ...
899 * cdfac(cdbeta, prioVec(classCounts(X, phOff, nph, ind, R), fromIR(j,2), mi(fromIR(j,1)), classprio), fromIR(j,2));
900 case SchedStrategy.GPSPRIO
901 a{j} = @(X,
bufs, svc) rateOf(j) * kirFrac(X, fromIdx(j), phOff, nph, fromIR(j,1), fromIR(j,2)) ...
902 * gpsprioshare(wrow, classCounts(X, phOff, nph, ind, R), fromIR(j,2), mi(fromIR(j,1)), classprio) ...
903 * lldfac(ldrow, prioPop(classCounts(X, phOff, nph, ind, R), fromIR(j,2), mi(fromIR(j,1)), classprio), lldlimit) ...
904 * cdfac(cdbeta, prioVec(classCounts(X, phOff, nph, ind, R), fromIR(j,2), mi(fromIR(j,1)), classprio), fromIR(j,2));
905 case SchedStrategy.PAS
906 % Position p of
the ordered list
is served at
907 % Delta_mu(c1..cp) = mu(c1..cp) - mu(c1..c_{p-1}), and
908 % pass-and-swap decides which
class that completion ejects. The
909 %
class-r departure rate
is therefore
the total Delta_mu over
910 %
the positions whose pass-and-swap ejects a
class-r job, which
911 %
is exactly what afterEventStationPAS enumerates.
912 muFun = sn.nodeparam{ind}.svcRateFun;
914 line_error(mfilename,
'PAS/OI station has no service rate function mu(c); set it via setService(@(c) ...).');
916 swapG = sn.nodeparam{ind}.swapGraph;
917 a{j} = @(X,
bufs, svc) oirate(muFun, swapG,
bufs{ind}, fromIR(j,2));
918 case SchedStrategy.POLLING
919 % A polling station has a single server that serves exactly one
920 % job, of
the class its controller currently attends. The
921 % departure of
class r therefore fires only while
the controller
922 %
is SERVING
class r, at
the plain service rate of
the one job in
923 % service -- never scaled by
the class population, since
the other
924 %
class-r jobs wait in
the buffer
for the server to come back to
925 % them. The controller rides in
bufs{ind} = [mode, pos, swk, ctr];
926 % pollServeGate returns 1 exactly when mode==SERVING and pos==r.
927 a{j} = @(X,
bufs, svc) rateOf(j) * pollServeGate(
bufs{fromIR(j,1)}, fromIR(j,2)) ...
928 * lldfac(ldrow, sum(classCounts(X, phOff, nph, ind, R)), lldlimit) ...
929 * cdfac(cdbeta, classCounts(X, phOff, nph, ind, R), fromIR(j,2));
930 case {SchedStrategy.FCFS, SchedStrategy.LCFS, SchedStrategy.SIRO, ...
931 SchedStrategy.HOL, SchedStrategy.SEPT, SchedStrategy.LEPT, ...
932 SchedStrategy.LCFSPR}
933 % Invariant: numel(
bufs{ind}) == max(0, total - mi(ind)).
934 % Rate
is proportional to
the jobs actually being served, i.e.
935 %
the class-r population minus
the class-r jobs waiting in buffer,
936 % scaled by
the load-dependent
factor at
the total population.
937 % Every non-preemptive buffered policy shares
this law: with
938 % exponential service
the departure rate depends only on
the
939 % in-service composition, never on
the buffer order, which enters
940 % solely through which job
is promoted next (pickFromBuffer).
941 a{j} = @(X,
bufs, svc) rateOf(j) * kirFrac(X, fromIdx(j), phOff, nph, fromIR(j,1), fromIR(j,2)) * ...
942 max(0, classPop(X, phOff, nph, ind, fromIR(j,2)) - sum(
bufs{fromIR(j,1)} == fromIR(j,2))) ...
943 * lldfac(ldrow, sum(classCounts(X, phOff, nph, ind, R)), lldlimit) ...
944 * cdfac(cdbeta, classCounts(X, phOff, nph, ind, R), fromIR(j,2));
947 a{j} = @(X,
bufs, svc) rateOf(j) * kirFrac(X, fromIdx(j), phOff, nph, fromIR(j,1), fromIR(j,2)) * min(1, classPop(X, phOff, nph, ind, fromIR(j,2)));
951% Reneging propensities ------------------------------------------------
952% Only
the jobs actually waiting can abandon, so
the rate
is the class-r
953% population minus
the class-r jobs in service, exactly
the buffer occupancy
954%
the FCFS-family rate law already relies on.
955for j = (nDep+1):length(fromIdx)
957 a{j} = @(X,
bufs, svc) renegeMu(j) * sum(
bufs{ind} == fromIR(j,2));
960% Retrial propensities --------------------------------------------------
961% Only jobs actually in orbit retry, and only a free server admits them.
962for j = 1:length(fromIdx)
967 a{j} = @(X,
bufs, svc) retryMu(j) * sum(
bufs{ind} == fromIR(j,2)) ...
968 *
double(sum(X(((ind-1)*R+1):(ind*R))) - numel(
bufs{ind}) < mi(ind));
971% Polling switchover propensities ---------------------------------------
972% The reneging loop above overwrote these appended columns with a zero-rate
973% renege closure; restore
the switchover law here. A switchover fires only
974%
while the controller
is walking (mode SWITCHING,
bufs{ind}(1)==2), at
the
975% total leaving rate -D0(swk,swk) of
the current phase swk of
the switchover
976% PH into buffer pos. The competition between advancing to another phase and
977% absorbing (arriving at pos)
is resolved at firing time, exactly as a routed
978% departure resolves its destination after it fires.
979for j = 1:length(fromIdx)
984 pinf = poll.pinfo{ind};
985 a{j} = @(X,
bufs, svc) pollSwRate(
bufs{ind}, pinf);
988% Finite capacity regions
do NOT gate
the propensities ------------------
989% Under
the DROP rule
the refused job
is DESTROYED, not held back:
the
990% departure fires at its full rate and
the job simply never reaches
the
991% destination. Scaling
the propensity by
the admitted share instead censors
992%
the transition, which keeps
the job at its SOURCE -- a different model, and
993% one that diverges as soon as
the source
is a real queue rather than a Source
994% node (an interior region makes
the upstream queue grow without bound
while
995% nothing
is ever lost). The two coincide only at a Source, whose population
is
996% fictitious, which
is why every FCR fixture placed a region on a Source-fed
997% station and never saw
the difference. Refusal
is applied at firing time
998% instead, on
the drawn destination, exactly as a balk
is (see balkDraw below):
999%
the source releases
the job and
the destination never receives it. This
1000% matches SOLVER_SSA (which marks
the refusal and suppresses only
the passive
1001% application) and
the exact CTMC.
1003% Propensity functions dependencies -----------------------------------
1004D = cell(1,size(S,2));
1006 J = find(S(:,k))'; % set of state variables affected by reaction k
1009 % Decode through
the slot
map, never arithmetically: with phase
1010 % expansion a state index
is a (node,class,PHASE) slot, so
1011 % mod(pos-1,R)+1 names
the wrong node as soon as any class has more
1012 % than one phase. Collect EVERY slot of each affected node, because a
1013 % rate law reads its node's whole class-
count vector (classCounts sums
1014 % each class over its phases) and
the per-phase share reads
the sibling
1015 % phases of its own class.
1016 ind = slotNode(J(j));
1017 % NB: not `rr` -- that name holds
the round-robin controller in this
1018 % scope, and shadowing it here would pass an integer to
the run loop.
1020 vecd(end+1:end+nph(ind,rcls)) = (phOff(ind,rcls)+1):(phOff(ind,rcls)+nph(ind,rcls));
1023 % vecd now contains all state variables affected by
the firing of
1024 % reaction k. We now find
the propensity functions that depend
1025 % on those variables
1027 % A retry has an all-zero stoichiometry
column, so
the generic
1028 % derivation below would return an empty dependency set and leave every
1029 % rate at
the node stale. A retry does change
the in-service
1030 % composition, hence every reaction whose source
is this node.
1031 base_k = (fromIR(k,1)-1)*R;
1032 D{k} = find(ismember(fromIdx, (base_k+1):(base_k+R)));
1036 vecd = unique(vecd);
1038 for j=1:length(vecd)
1039 % No `fcr.on` widening here: regions no longer gate
the
1040 % propensities (see
the FCR note above), so a departure
's rate
1041 % depends only on its own station's populations, as in
the
1042 % unregulated
case. Admission
is resolved at firing time on
the
1043 % drawn destination and changes no rate.
1044 vecs = [vecs,find(S(vecd(j),:)<0)];
1046 D{k} = unique(vecs);
1052% A retry has an all-zero stoichiometry
column, so
the derivation above -- which
1053% collects reactions by
the sign of their S entries -- can never place it in any
1054% OTHER reaction
's dependency set. It still has to be refreshed whenever the
1055% node it serves changes, because its rate reads both the orbit occupancy and
1056% whether a server is free: without this, a retry blocked at a busy server keeps
1057% its zero rate after the server frees, the orbit never drains and the station
1058% grows without bound.
1059for j = find(isRetryRx(:)')
1061 slots = ((indj-1)*R + 1):(indj*R);
1063 if any(S(slots, k) ~= 0) || fromIR(k,1) == indj
1064 if ~ismember(j, D{k})
1071% Having accounted
for them in D, we can now remove self-loops markings
1074% ---------------------------------------------------------------------
1075% Initialize performance metric matrices
1076% ---------------------------------------------------------------------
1077lG = 0; % Not computed in SSA
1079% ---------------------------------------------------------------------
1080% Run SSA/NRM with direct metric computation
1081% ---------------------------------------------------------------------
1082[QN, UN, RN, TN, CN, XN, cacheProd] = next_reaction_method_direct(S, D, a, nvec0,
buffers0, samples, options, sn, fromIdx, fromIR, mi, fcr, balk, isRenegeRx, sig, rr, isRetryRx, phOff, nph, nDepRx, isPhaseRx, smap, poll, isPollSwRx, pollSwNode, svcph0, isBufSvcRx, bufPHClass, bufPHNode, depPhase, phaseFrom, phaseTo, isCacheRx, cacheHitSlot, cacheMissSlot, isCacheNode, cacheRetrDest);
1083% Write
the measured hit/miss probabilities back into sn so
the analyzer can set
1084% them on each Cache node (State.afterEventCache convention: actualhitprob(r) =
1085% hit throughput / (hit+miss) throughput at
the cache, per read class r).
1086% The cache hit/miss probability of a read class
is the throughput of its hit
1087% class over hit+miss at
the cache -- exactly what cacheProd counts per produced
1088% class. A retrieval completion produces
the miss class, so retrieval misses are
1089% counted here too; a delayed hit produces nothing and
is excluded from
the
1090% ratio (matching
the serial engine, which folds delayed hits away). Retrieval
1091% classes (hitclass == 0) are internal and get no hit/miss probability of their
1096 % Size to nclasses with NaN defaults, exactly as
the serial analyzer does:
1097 %
the arrival-rate reconstruction (sn_get_arvr_from_tput) indexes
1098 % actual{hit,miss}prob at every origClass whose missclass
is set, which
1099 % includes
the internal retrieval classes.
1100 np.actualhitprob = NaN(1, R);
1101 np.actualmissprob = NaN(1, R);
1103 if isCacheReadClass(ind,r) && r <= numel(np.hitclass) && np.hitclass(r) > 0
1104 hc = np.hitclass(r); mc = np.missclass(r);
1105 hcount = cacheProd(ind, hc);
1106 mcount = cacheProd(ind, mc);
1107 tot = hcount + mcount;
1109 np.actualhitprob(r) = hcount / tot;
1110 np.actualmissprob(r) = mcount / tot;
1114 sn.nodeparam{ind} = np;
1120% ======================================================================
1121% Next-Reaction Method with direct metric computation
1122% ======================================================================
1123function [QN, UN, RN, TN, CN, XN, cacheProd] = next_reaction_method_direct(S, D, a, nvec0,
buffers0, samples, options, sn, fromIdx, fromIR, mi, fcr, balk, isRenegeRx, sig, rr, isRetryRx, phOff, nph, nDepRx, isPhaseRx, smap, poll, isPollSwRx, pollSwNode, svcph0, isBufSvcRx, bufPHClass, bufPHNode, depPhase, phaseFrom, phaseTo, isCacheRx, cacheHitSlot, cacheMissSlot, isCacheNode, cacheRetrDest)
1125numReactions = size(S,2);
1137% when a reaction fires,
this matrix helps selecting
the probability that a
1138% particular routing or phase
is selected as a result ------------------
1139P = S;
P(
P<0)=
P(
P<0)+1';
1140fromIdxCell = cell(numReactions,1);
1141toIdxCell = cell(numReactions,1);
1142cdfVec = cell(numReactions,1);
1144 nnzP(r) = nnz(
P(:,r));
1146 fromIdxCell{r} = find(S(:,r)<0);
1147 toIdxCell{r} = find(
P(:,r));
1148 cdfVec{r} = cumsum(
P(toIdxCell{r},r));
1152% JSQ routing: reactions whose source
class routes with JSQ select
the
1153% destination node holding
the smallest total population at firing time
1154% (each candidate evaluated on its own queue, never
the routing node
's;
1155% ties split uniformly)
1156isJSQ = false(numReactions,1);
1157% KCHOICES (power-of-k choices): sample k candidates uniformly WITH
1158% replacement, join the one holding the smallest total population, ties broken
1159% by first occurrence in the sampled tuple. This is the sampled form of the
1160% marginal enumerated by sub_kchoices in MNetwork.refreshRoutingMatrix and of
1161% LDES's selectKChoicesDestinationWithClass; drawing directly
is equivalent
for
1162% a simulator and avoids enumerating
the m^k tuples. The withMemory variant
1163% forces
the previous pick as
the last candidate, which needs a per-(node,
class)
1164% memory
the reaction network does not carry, so those models are routed to
the
1165% serial engine by SOLVER_SSA_ANALYZER and never reach here.
1166isKCH =
false(numReactions,1);
1167kchK = zeros(numReactions,1);
1168kchMem =
false(numReactions,1); % withMemory variant
1169% Anselmi SQ(d,N) memory: one recorded observation PER eligible destination,
1170% not a single last-selected node. Like
the round-robin pointer
this is
1171% auxiliary simulator state -- no rate reads it, it only steers
the
1172% destination draw -- so it rides alongside
the buffers.
1173kchMemory = cell(numReactions,1);
1175 if nnzP(r)>1 && fromIdx(r) > 0
1176 srcNode = smap.node(fromIdx(r));
1177 srcClass = smap.class(fromIdx(r));
1178 if sn.routing(srcNode, srcClass) == RoutingStrategy.JSQ
1180 elseif sn.routing(srcNode, srcClass) == RoutingStrategy.KCHOICES
1182 kk = 2; % sub_kchoices
default when
nodeparam carries no k
1183 if iscell(sn.nodeparam) && srcNode <= numel(sn.nodeparam) ...
1184 && iscell(sn.nodeparam{srcNode}) && srcClass <= numel(sn.nodeparam{srcNode})
1186 if ~isempty(np) && isfield(np,
'k') && ~isempty(np.k)
1190 kchK(r) = max(1, min(kk, numel(toIdxCell{r})));
1191 if ~isempty(np) && isfield(np,
'withMemory') && np.withMemory
1193 % Algorithm 1 line 2: Memory[i] = 0
for every destination.
1194 kchMemory{r} = zeros(1, numel(toIdxCell{r}));
1200% initialise Gillespie clocks ------------------------------------------
1202buffers =
buffers0; % working copy of
the per-node ordered buffers
1203svcph = svcph0; % working copy of
the in-service phase multiset (buffered-PH)
1204cacheProd = zeros(numel(
buffers0), sn.nclasses); % per (cache node, PRODUCED
class)
count
1205% Seed each polling controller into
the auxiliary buffer of its node. The seed
1206%
is a member of
the reachable controller space (State.pollingInit
's rule): the
1207% server walks from a canonical position and settles on the first tangible
1208% state -- a visit on a class with work, a switchover, or a park -- so the
1209% initial state carries no controller configuration the dynamics cannot reach.
1211 for ind = 1:sn.nnodes
1212 if ~poll.isPoll(ind)
1215 pinf = poll.pinfo{ind};
1216 nbuf = classCounts(nvec0, phOff, nph, ind, R)'; % 1xR per-
class populations
1217 [q0, mode0, budget0] = State.pollingNext(pinf, 1, nbuf, R,
true);
1218 buffers{ind} = pollLandCtrl(pinf, q0, mode0, budget0);
1221% Per-region WAITQ FIFO of parked (dstNode, dstClass) tokens, encoded as
1222% (dstNode-1)*R + dstClass. Empty and untouched unless a region uses WAITQ.
1225 fcrBuf = repmat({zeros(1,0)}, numel(fcr.classCap), 1);
1228 Ak(k) = a{k}(nvec0, buffers, svcph);
1231Pk = -log(rand(1,numReactions));
1232Tk = zeros(1,numReactions);
1234tau = (Pk - Tk) ./ Ak;
1236% Performance tracking variables
1238NK = sn.njobs
'; % Jobs per class
1239servers = sn.nservers;
1240PH = sn.proc; % service-process MAPs/PHs
1242% Normalized DPS/GPS weights and class priorities, mirroring the propensity
1243% construction so the utilization accumulators use identical sharing factors.
1244classprio = sn.classprio(:)';
1247 if any(sn.sched(ist) == [SchedStrategy.DPS, SchedStrategy.GPS, ...
1248 SchedStrategy.DPSPRIO, SchedStrategy.GPSPRIO])
1249 wnorm(ist, :) = sn.schedparam(ist, 1:R) / sum(sn.schedparam(ist, 1:R));
1255 [dt, kfire] = min(tau);
1256 if isinf(dt), line_error(mfilename,'Deadlock. Quitting nrm method.'); end
1258 totalTime = totalTime + dt;
1260 % Accumulate state-dependent metrics during this time interval
1262 ind = sn.stationToNode(ist);
1264 % nvec counts jobs per phase now, so
the class population
is the
1265 % sum over that class's phases.
1266 currentPop = classPop(nvec, phOff, nph, ind, k);
1268 % Accumulate queue length (QN)
1269 QN(ist, k) = QN(ist, k) + currentPop * dt;
1271 % Compute throughput contribution from departures
1272 % Throughput
is the total absorption rate of
the class: with phase
1273 % expansion each phase owns its own departure reaction, so they are
1274 % summed. Phase-change reactions move no job and are excluded, as
1275 % are
the appended renege/retry columns.
1278 if fromIR(jd,1) == ind && fromIR(jd,2) == k && ~isPhaseRx(jd)
1279 depRate = depRate + Ak(jd);
1282 TN(ist, k) = TN(ist, k) + depRate * dt;
1284 % Compute utilization based on scheduling policy. For
the whole PS
1285 % family
the class-k utilization
is the share of service capacity
1286 % it receives divided by
the server
count, so
the same sharing
1287 % factors that define
the propensities are reused here (without
the
1288 % lld/cd rate scalings, which rescale work but not occupancy).
1289 switch sn.sched(ist)
1290 case {SchedStrategy.INF, SchedStrategy.EXT}
1291 UN(ist, k) = UN(ist, k) + currentPop * dt;
1292 case {SchedStrategy.PS, SchedStrategy.LPS}
1293 totalPop = sum(classCounts(nvec, phOff, nph, ind, R));
1295 utilization = (currentPop / totalPop) * min(servers(ist), totalPop) / servers(ist);
1299 UN(ist, k) = UN(ist, k) + utilization * dt;
1300 case SchedStrategy.DPS
1301 npop = classCounts(nvec, phOff, nph, ind, R);
1302 UN(ist, k) = UN(ist, k) + dpsshare(wnorm(ist,:), npop, k) / servers(ist) * dt;
1303 case SchedStrategy.GPS
1304 npop = classCounts(nvec, phOff, nph, ind, R);
1305 UN(ist, k) = UN(ist, k) + gpsshare(wnorm(ist,:), npop, k) / servers(ist) * dt;
1306 case SchedStrategy.PSPRIO
1307 npop = classCounts(nvec, phOff, nph, ind, R);
1308 UN(ist, k) = UN(ist, k) + psprioshare(npop, k, servers(ist), classprio) / servers(ist) * dt;
1309 case SchedStrategy.DPSPRIO
1310 npop = classCounts(nvec, phOff, nph, ind, R);
1311 UN(ist, k) = UN(ist, k) + dpsprioshare(wnorm(ist,:), npop, k, servers(ist), classprio) / servers(ist) * dt;
1312 case SchedStrategy.GPSPRIO
1313 npop = classCounts(nvec, phOff, nph, ind, R);
1314 UN(ist, k) = UN(ist, k) + gpsprioshare(wnorm(ist,:), npop, k, servers(ist), classprio) / servers(ist) * dt;
1315 case SchedStrategy.PAS
1316 % Pass-and-swap / order-independent: utilization
is the
1317 % time-average number of in-service jobs per
class over
the
1318 % servers, where
"in service" means
the positions whose
1319 % marginal rate increment Delta_mu
is positive -- so a job
1320 % served by several server types still counts once, not
1321 % 1/rate (solver_ctmc_analyzer,
case PAS).
1322 UN(ist, k) = UN(ist, k) + pasInSvc(sn, ind, buffers{ind}, k) / servers(ist) * dt;
1323 case {SchedStrategy.FCFS, SchedStrategy.LCFS, SchedStrategy.SIRO, ...
1324 SchedStrategy.HOL, SchedStrategy.SEPT, SchedStrategy.LEPT, ...
1325 SchedStrategy.LCFSPR}
1326 if ~isempty(PH{ist}{k})
1327 waiting = sum(buffers{ind} == k);
1328 inService = currentPop - waiting;
1329 UN(ist, k) = UN(ist, k) + (inService / servers(ist)) * dt;
1331 case SchedStrategy.POLLING
1332 % The single server
is busy on exactly one
class-k job
while
1333 %
the controller serves
class k, and idle (switching or
1334 % parked) otherwise; so
class-k utilization
is the fraction
1335 % of time
the controller
is SERVING
class k.
1336 ctrl = buffers{ind};
1337 if numel(ctrl) >= 2 && ctrl(1) == 1 && ctrl(2) == k
1338 UN(ist, k) = UN(ist, k) + dt / servers(ist);
1346 % update aggregate state
1348 cacheChanged =
false;
1350 % Cache access. The read-
class job at
the cache reads an item drawn from
1351 % pread, and
the cache contents (carried in buffers{cacheNode}) decide a
1352 % hit or a miss;
the replacement policy then rewrites
the contents.
1353 % Mirrors State.afterEventCache (READ, isSimulation). The job leaves in
1354 %
the hit or miss
class at
the SAME cache node, and
the existing
1355 % immediate forwarding routes it downstream from there.
1356 cn = fromIR(kfire,1); rdc = fromIR(kfire,2);
1357 [outClass, newContents, cacheCat] = cacheAccess(sn, cn, rdc, buffers{cn});
1358 buffers{cn} = newContents;
1359 nvec(fromIdx(kfire)) = nvec(fromIdx(kfire)) - 1; % consume
the read-
class job
1362 % BEGIN retrieval:
the job must travel to
the fetch queue and
1363 %
return before
the miss completes, so it
is placed at
the
1364 % retrieval
class's routed destination (the queue), NOT left at
1365 % the cache where the cache-access reaction would fire again.
1366 destPos = cacheRetrDest(cn, outClass);
1368 % Hit or miss/completion: the job leaves in the hit or miss class
1369 % at the SAME cache node; the existing immediate forwarding routes
1370 % it downstream. Count the production per produced class so the
1371 % hit/miss probabilities are the hit/miss-class throughput at the
1372 % cache (State.afterEventCache convention).
1373 destPos = phOff(cn, outClass) + 1;
1374 cacheProd(cn, outClass) = cacheProd(cn, outClass) + 1;
1376 nvec(destPos) = nvec(destPos) + 1;
1378 % OUTCLASS == 0 is a delayed hit: the request is absorbed (produces
1379 % nothing), coalescing onto the in-flight retrieval.
1380 cacheChanged = true;
1381 elseif nnzP(kfire)>1
1382 cand = toIdxCell{kfire};
1383 % A finite capacity region does NOT filter the routing draw. Routing
1384 % picks the destination first and the region decides admission at the
1385 % destination's entry afterwards, dropping
the job on refusal; a
1386 % routing strategy that steered around full regions would be a
1387 % different (and better-behaved) model than
the one SOLVER_SSA and
the
1388 % CTMC implement. The refusal check
is applied to
the drawn destination
1391 % JSQ: join
the destination node with
the smallest total population
1392 % (ties split uniformly)
1393 npop = inf(numel(cand),1);
1395 jnd = smap.node(cand(x));
1396 npop(x) = sum(classCounts(nvec, phOff, nph, jnd, R));
1398 amins = find(npop == min(npop));
1399 r = amins(1 + floor(rand*length(amins)));
1400 elseif rr.on && rr.isrr(fromIR(kfire,1), fromIR(kfire,2))
1401 % Round-robin: advance
the pointer, then take
the destination it
1402 % lands on (State.afterEventRouter advances on DEP and
the routing
1403 % closure reads state_after).
1404 [rr, jnd] = rrNext(rr, fromIR(kfire,1), fromIR(kfire,2));
1405 % A phase-type destination contributes ONE candidate per entry phase,
1406 % each weighted by pentry in
the routing matrix. The pointer fixes
the
1407 % NODE;
the entry PHASE must still be drawn from pentry among that
1408 % node
's candidates. Taking the first match (phase 0) biases the
1409 % service time -- the RROBIN + phase-type residence bug (RUN-10). Use
1410 % smap.node (not the flat floor((cand-1)/R) formula, which is wrong
1411 % once phases expand the state) to find the node's candidates, then
1412 % sample among them in proportion to their routing weights.
1414 for x = 1:numel(cand)
1415 if smap.node(cand(x)) == jnd
1416 matches(end+1) = x; %#ok<AGROW>
1420 line_error(mfilename, sprintf(
'Round-robin selected node %d, which is not a routing destination of node %d.', jnd, fromIR(kfire,1)));
1422 if numel(matches) == 1
1426 w = zeros(numel(matches),1);
1427 for ii = 1:numel(matches)
1430 w(ii) = cd(x) - cd(x-1);
1439 u = rand * wsum; acc = 0; r = matches(end);
1440 for ii = 1:numel(matches)
1449 elseif isKCH(kfire) && kchMem(kfire)
1450 % Power-of-d-choices WITH MEMORY, per Anselmi & Dufour,
"Power-of-d
1451 % -Choices with Memory: Fluid Limit and Optimality" (Math. Oper.
1452 % Res.), Algorithm 1, SQ(d,N):
1453 % for i = 1..d: rnd = random(1..N); Memory[rnd] = get_state(rnd)
1454 % selected = random(argmin_i Memory[i])
1455 % Memory[selected]++
1456 % The memory holds one observation PER destination and
the winner
is
1457 %
the globally lowest RECORDED state -- not
the lowest among
the d
1458 % sampled. Observations of unsampled destinations stay stale, and
1459 %
the increment charges
the winner
for the job just sent, which
is
1460 % what lets
the scheme approach join-
the-shortest-queue.
1461 mem = kchMemory{kfire};
1462 for t = 1:kchK(kfire)
1463 % sampled uniformly WITH replacement, as in Algorithm 1
1464 x = 1 + floor(rand * numel(cand));
1465 jnd = smap.node(cand(x));
1466 mem(x) = sum(classCounts(nvec, phOff, nph, jnd, R));
1469 amins = find(score == min(score));
1470 r = amins(1 + floor(rand * numel(amins)));
1471 mem(r) = mem(r) + 1;
1472 kchMemory{kfire} = mem;
1474 npop = zeros(numel(cand),1);
1476 jnd = smap.node(cand(x));
1477 npop(x) = sum(classCounts(nvec, phOff, nph, jnd, R));
1479 % Memoryless SQ(d):
the k candidates are drawn uniformly with
1480 % replacement and
the least loaded wins;
the strict comparison
1481 % retains
the first occurrence, which
is the tie rule of
1482 % sub_kchoices. The withMemory variant
is handled above and never
1484 draws = min(kchK(kfire), numel(cand));
1488 x = 1 + floor(rand*numel(cand));
1489 if npop(x) < bestpop
1495 % Inverse-CDF sampling: smallest r such that cdfVec(r) > rand. The
1496 % previous formulation `1+find(rand>=cdfVec,1)` was a misuse of
1497 % find(...,1) that always returned 2 once rand exceeded cdfVec(1),
1498 % leaving destinations beyond
the second one unreachable (e.g. all
1499 % traffic skipping Station3 in a 3-way RAND split).
1500 r = find(cdfVec{kfire} > rand, 1);
1502 r = length(cdfVec{kfire});
1505 % Balking
is decided on
the pre-arrival population, so it
is drawn
1506 % before
the state
is updated. A balked job
is lost:
the source still
1507 % releases it,
the destination never receives it.
1510 balked = balkDraw(balk, nvec, toIdxCell{kfire}(r), R, smap);
1512 % An open arrival at a full physically-capped destination
is lost,
1513 % exactly as a balked one
is:
the source releases it,
the destination
1514 % never receives it. Mirrors State.afterEventStation.
1515 if ~balked && capacityLoss(sn, nvec, toIdxCell{kfire}(r), R, smap)
1518 % A region refuses
the drawn destination on
the same pre-arrival
1519 % population. Under DROP
the refused job
is lost, exactly as a balked
1520 % one
is; under WAITQ it
is parked in
the refusing region
's FIFO and
1521 % admitted later, head-of-line. Either way it does not enter the
1522 % destination now, so the source still departs and destPos is cleared.
1523 parkF = 0; parkTok = 0;
1524 if ~balked && fcr.on
1525 dstN = smap.node(toIdxCell{kfire}(r));
1526 dstC = smap.class(toIdxCell{kfire}(r));
1527 fref = fcrRefusingRegion(fcr, nvec, fromIR(kfire,1), fromIR(kfire,2), dstN, dstC, R, smap);
1530 if fcr.waitq(fref, dstC)
1531 parkF = fref; parkTok = (dstN-1)*R + dstC;
1535 nvec(fromIdxCell{kfire}) = nvec(fromIdxCell{kfire}) - 1;
1539 fcrBuf{parkF}(end+1) = parkTok;
1541 elseif sig.on && sigIsSignalArrival(sig, toIdxCell{kfire}(r), R, smap)
1542 % the signal is annihilated on arrival: it never joins the station
1543 [nvec, buffers] = sigApply(sig, nvec, buffers, toIdxCell{kfire}(r), R, mi, smap);
1546 nvec(toIdxCell{kfire}(r)) = nvec(toIdxCell{kfire}(r)) + 1;
1547 destPos = toIdxCell{kfire}(r);
1550 dpos = find(S(:,kfire) > 0); % deterministic destination (single move)
1552 if balk.on && ~isempty(dpos)
1553 balked = balkDraw(balk, nvec, dpos(1), R, smap);
1555 if ~balked && ~isempty(dpos) ...
1556 && ~(kfire <= numel(isRenegeRx) && isRenegeRx(kfire)) ...
1557 && ~(kfire <= numel(isRetryRx) && isRetryRx(kfire)) ...
1558 && capacityLoss(sn, nvec, dpos(1), R, smap)
1561 % Single-destination departures cross region boundaries too, so the
1562 % region gate applies here exactly as it does to a drawn destination.
1563 % Renege and retry columns carry no destination and are never gated.
1564 parkF = 0; parkTok = 0;
1565 if ~balked && fcr.on && ~isempty(dpos) ...
1566 && ~(kfire <= numel(isRenegeRx) && isRenegeRx(kfire)) ...
1567 && ~(kfire <= numel(isRetryRx) && isRetryRx(kfire))
1568 dstN = smap.node(dpos(1));
1569 dstC = smap.class(dpos(1));
1570 fref = fcrRefusingRegion(fcr, nvec, fromIR(kfire,1), fromIR(kfire,2), dstN, dstC, R, smap);
1573 if fcr.waitq(fref, dstC)
1574 parkF = fref; parkTok = (dstN-1)*R + dstC;
1579 % lost or parked on arrival: apply the source departure only
1580 nvec(fromIdx(kfire)) = nvec(fromIdx(kfire)) - 1;
1584 fcrBuf{parkF}(end+1) = parkTok;
1586 elseif sig.on && ~isempty(dpos) && sigIsSignalArrival(sig, dpos(1), R, smap)
1587 % the signal is annihilated on arrival: it never joins the station
1588 nvec(fromIdx(kfire)) = nvec(fromIdx(kfire)) - 1;
1589 [nvec, buffers] = sigApply(sig, nvec, buffers, dpos(1), R, mi, smap);
1593 nvec = nvec + S(:,kfire); % zero change for self-loops
1597 elseif ~(kfire <= numel(isRenegeRx) && isRenegeRx(kfire)) ...
1598 && ~(kfire <= numel(isPollSwRx) && isPollSwRx(kfire)) && sn.isslc(fromIR(kfire,2))
1599 % Self-looping class: the completed job re-enters the same node and
1600 % class (its stoichiometry is a no-op). At a buffered (FCFS/LCFS)
1601 % station it must rejoin the buffer so the ordering rotates; point
1602 % destPos at the source slot so updateBuffers applies the arrival.
1603 destPos = fromIdx(kfire);
1607 % maintain the buffers given the source/destination of this firing
1609 if kfire <= numel(isRetryRx) && isRetryRx(kfire)
1610 % A successful retry moves one orbiting job into the free server. The
1611 % population is unchanged (it was already counted at the station), so
1612 % only the orbit shrinks; in-service is read back as population minus
1614 ind = fromIR(kfire,1);
1615 slot = find(buffers{ind} == fromIR(kfire,2), 1, 'first
');
1617 buffers{ind}(slot) = [];
1619 elseif kfire <= numel(isRenegeRx) && isRenegeRx(kfire)
1620 % Reneging removes a job that was WAITING, so no server is freed and no
1621 % queued job is promoted; the abandoning job simply leaves the buffer.
1622 % State.afterEventStation drops the newest waiting job of the class and
1623 % notes that for memoryless patience all waiting jobs are exchangeable,
1624 % so the choice cannot affect the marginal distribution.
1625 ind = fromIR(kfire,1);
1626 slot = find(buffers{ind} == fromIR(kfire,2), 1, 'first
');
1628 buffers{ind}(slot) = [];
1630 elseif kfire <= numel(isPhaseRx) && isPhaseRx(kfire) && bufPHNode(fromIR(kfire,1))
1631 % A buffered-PH phase transition moves one in-service job between phases
1632 % of its own service process. It frees no server and adds no arrival, so
1633 % the buffer is untouched and only svcph changes (INF/PS phase moves are
1634 % already applied to nvec via the stoichiometry and fall to updateBuffers
1635 % below as a no-op, as before).
1636 ind = fromIR(kfire,1); r = fromIR(kfire,2);
1637 svcph{ind}(r, phaseFrom(kfire)) = svcph{ind}(r, phaseFrom(kfire)) - 1;
1638 svcph{ind}(r, phaseTo(kfire)) = svcph{ind}(r, phaseTo(kfire)) + 1;
1640 elseif isCacheRx(kfire)
1641 % The cache access already updated the cache contents (buffers{cacheNode})
1642 % and moved the job to the hit/miss class in the firing block above; there
1643 % is no job buffer to maintain at a cache node.
1645 [buffers, svcph, svcChanged] = updateBuffers(kfire, nvec, buffers, fromIR, destPos, mi, R, sn, smap, svcph, bufPHNode, isBufSvcRx, depPhase);
1648 % Polling controller advance. The controller of each polling node lives in
1649 % its auxiliary buffer as [mode, pos, swk, ctr]; a firing can move it in
1650 % three ways, mirroring State.afterEventStation exactly (EventType.DEP under
1651 % SchedStrategy.POLLING, EventType.SWITCH, and the parked-server arrival):
1652 % * a service completion at the node ends the visit unless the discipline
1653 % still admits another job of the served class, and on ending walks the
1654 % cyclic order to the next tangible controller state;
1655 % * a switchover reaction advances the switchover PH one phase, or on
1656 % absorption arrives at the target buffer and opens a visit or walks on;
1657 % * an arrival to a parked server wakes it, and the walk resolves at once
1658 % to a visit on the newly present work.
1659 % Any of these changes a service gate or the switchover rate, so a change is
1660 % flagged to force a full propensity refresh below (like a WAITQ release).
1661 pollChanged = false;
1663 srcNode = fromIR(kfire,1);
1664 if kfire <= numel(isPollSwRx) && isPollSwRx(kfire)
1665 pind = pollSwNode(kfire);
1666 pinf = poll.pinfo{pind};
1667 ctrl = buffers{pind};
1668 posS = ctrl(2); swkS = ctrl(3);
1669 D0S = pinf.swD0{posS};
1670 KswS = pinf.Ksw(posS);
1671 w = zeros(1, KswS + 1);
1673 if kd ~= swkS && D0S(swkS,kd) > 0
1674 w(kd) = D0S(swkS,kd);
1677 w(KswS + 1) = max(0, -sum(D0S(swkS,:))); % absorption (D1 row sum)
1678 pick = drawFromDist(w);
1679 if pick <= KswS && pick ~= swkS
1680 ctrl(3) = pick; % internal phase advance
1681 buffers{pind} = ctrl;
1683 nbufS = classCounts(nvec, phOff, nph, pind, R)';
1684 [qS, mdS, bgS] = State.pollingNext(pinf, posS, nbufS, R,
true);
1685 buffers{pind} = pollLandCtrl(pinf, qS, mdS, bgS);
1688 elseif poll.isPoll(srcNode) && kfire <= nDepRx && ~isPhaseRx(kfire)
1689 pinf = poll.pinfo{srcNode};
1690 ctrl = buffers{srcNode};
1691 posD = ctrl(2); ctrD = ctrl(4);
1692 nbufD = classCounts(nvec, phOff, nph, srcNode, R)
'; % after the departure
1694 case PollingType.EXHAUSTIVE
1695 ctrnextD = 0; goonD = nbufD(posD) > 0;
1696 case PollingType.GATED
1697 ctrnextD = ctrD - 1; goonD = ctrnextD > 0;
1698 case PollingType.KLIMITED
1699 ctrnextD = ctrD - 1; goonD = ctrnextD > 0 && nbufD(posD) > 0;
1700 case PollingType.DECREMENTING
1701 ctrnextD = ctrD; goonD = nbufD(posD) > ctrD;
1704 buffers{srcNode} = [1, posD, 0, ctrnextD];
1706 [qD, mdD, bgD] = State.pollingNext(pinf, posD, nbufD, R, false);
1707 buffers{srcNode} = pollLandCtrl(pinf, qD, mdD, bgD);
1711 if ~isempty(destPos) && destPos > 0
1712 jnd = smap.node(destPos);
1714 ctrlA = buffers{jnd};
1715 if ~isempty(ctrlA) && ctrlA(1) == 0
1716 pinfA = poll.pinfo{jnd};
1717 nbufA = classCounts(nvec, phOff, nph, jnd, R)'; % includes
the arrival
1718 [qA, mdA, bgA] = State.pollingNext(pinfA, ctrlA(2), nbufA, R,
true);
1719 buffers{jnd} = pollLandCtrl(pinfA, qA, mdA, bgA);
1726 % WAITQ: admit parked jobs whose regions
this firing may have relieved.
1727 % A release changes populations at arbitrary destination
nodes, so when
1728 % anything
is admitted every reaction
is refreshed rather than only
the
1729 % dependency set of
the fired reaction.
1731 if fcr.on && fcr.anyWaitq
1732 [nvec, buffers, fcrBuf, nReleased, svcph, relChanged] = ...
1733 fcrReleaseCascade(fcr, nvec, buffers, fcrBuf, mi, R, sn, smap, svcph, bufPHNode);
1734 svcChanged = svcChanged || relChanged;
1739 % update rates
for all reactions dependent on
the last fired reaction. A
1740 % polling controller move or a WAITQ release can change rates outside
the
1741 %
static dependency set of
the fired reaction (a switchover reaction has an
1742 % all-zero stoichiometry
column, and a controller move flips service gates),
1743 % so either forces a full refresh.
1744 if nReleased > 0 || pollChanged || svcChanged || cacheChanged
1745 for k=1:numReactions
1746 Ak(k) = a{k}(nvec, buffers, svcph);
1750 Ak(k) = a{k}(nvec, buffers, svcph);
1755 Pk(kfire) = Pk(kfire) - log(rand);
1756 tau = (Pk - Tk) ./ Ak;
1759 %
do not
count immediate events
1761 print_progress(options, n);
1763% Print newline after progress counter
1764if isfield(options,
'verbose') && options.verbose
1768% Normalize metrics by total time
1772 QN(ist, k) = QN(ist, k) / totalTime;
1773 UN(ist, k) = UN(ist, k) / totalTime;
1774 TN(ist, k) = TN(ist, k) / totalTime;
1779% Class-dependent stations report utilization as T*S/peak, where peak
is the
1780% declared per-
class peak rate scaling (sn.cdscalingpeak). This matches
the
1781% T*S/c convention of
the analytic
solvers and serial SSA;
the accumulated
1782% in-service fraction above divides by
the server
count (1
for a cd station),
1783% which
is not
the same quantity. Override those stations here.
1784if ~isempty(sn.cdscaling)
1786 if ist <= numel(sn.cdscaling) && ~isempty(sn.cdscaling{ist})
1788 peak = sn.cdscalingpeak(ist, k);
1789 if isfinite(sn.rates(ist, k)) && sn.rates(ist, k) > 0 && peak > 0
1790 UN(ist, k) = TN(ist, k) / sn.rates(ist, k) / peak;
1799% Compute derived metrics
1801 % System throughput at reference station
1802 XN(1, k) = TN(sn.refstat(k), k);
1807 RN(ist, k) = QN(ist, k) / TN(ist, k);
1815 CN(1, k) = NK(k) / XN(1, k);
1827 function print_progress(opt, samples_collected)
1828 if ~isfield(opt,
'verbose') || ~opt.verbose || batchStartupOptionUsed,
return; end
1829 if samples_collected == 1e3
1830 line_printf(
'\nSSA samples: %8d', samples_collected);
1831 elseif opt.verbose == 2
1832 if samples_collected == 0
1833 line_printf(
'\nSSA samples: %9d', samples_collected);
1835 line_printf(
'\b\b\b\b\b\b\b\b\b%9d', samples_collected);
1837 elseif mod(samples_collected,1e3)==0 || opt.verbose == 2
1838 line_printf(
'\b\b\b\b\b\b\b\b\b%9d', samples_collected);
1841end % next_reaction_method_direct
1843% ======================================================================
1844% Buffer maintenance
for FCFS/LCFS
nodes
1845% ======================================================================
1846function [buffers, svcph, svcChanged] = updateBuffers(kfire, nvec, buffers, fromIR, destPos, mi, R, sn, smap, svcph, bufPHNode, isBufSvcRx, depPhase)
1847% Maintain
the ordered per-node buffers when reaction KFIRE fires. A departure
1848% frees a server, so
the buffered job selected by
the station
's discipline is
1849% promoted into service and leaves the buffer; an arrival at a buffered
1850% destination whose servers are all busy joins the buffer head. At a buffered-PH
1851% node the same events also move jobs in and out of the in-service phase multiset
1852% svcph, and SVCCHANGED flags that so the caller forces a full propensity refresh
1853% (svcph is not part of the stoichiometry, so the static dependency set misses it).
1854ind = fromIR(kfire,1); % source node of the firing
1857% Buffered-PH departure: the completing job leaves service, so drop it from the
1858% in-service phase it occupied (carried in depPhase). The promotion below refills
1859% the freed server from the buffer at a fresh entry phase.
1860if bufPHNode(ind) && isBufSvcRx(kfire)
1861 r = fromIR(kfire,2);
1862 svcph{ind}(r, depPhase(kfire)) = svcph{ind}(r, depPhase(kfire)) - 1;
1866% An order-independent station keeps the full ordered list, so a departure is
1867% not a promotion but a pass-and-swap rewrite: the completing position's chain
1868% shifts classes along and removes one slot. Which position completed
is
1869% redrawn here in proportion to
the Delta_mu of
the positions that eject
this
1870%
class, which
is the same split afterEventStationPAS enumerates.
1871if isListSched(ind, sn) && ~isempty(buffers{ind})
1872 buffers{ind} = oiDepart(sn, ind, buffers{ind}, fromIR(kfire,2));
1876% Handle departure from a buffered source node: promote one waiting job. A
1877% retrial station
is the exception --
the freed server
is NOT filled from
the
1878% orbit, orbiting jobs re-enter only through RETRY events at
the memoryless
1879% retrial rate (State.afterEventStation suppresses promotion likewise).
1880if isBuffered(ind, sn) && ~isempty(buffers{ind}) && ~isRetrialStation(ind, sn) ...
1881 && ~isListSched(ind, sn)
1882 pos = pickFromBuffer(buffers{ind}, sn, sn.nodeToStation(ind));
1883 promoted = buffers{ind}(pos);
1884 buffers{ind}(pos) = [];
1886 % The promoted waiting job starts service now, entering a phase drawn
1887 % from its entry distribution pie (
the same allocation
the init uses).
1888 ke = drawEntryPhase(sn, ind, promoted, smap.nph(ind, promoted));
1889 svcph{ind}(promoted, ke) = svcph{ind}(promoted, ke) + 1;
1894% Handle arrival at a buffered destination node
1895if ~isempty(destPos) && destPos > 0
1896 [buffers, svcph, arrChanged] = applyArrivalBuffer(smap.node(destPos), smap.class(destPos), ...
1897 nvec, buffers, mi, R, sn, smap, svcph, bufPHNode);
1898 svcChanged = svcChanged || arrChanged;
1902function [buffers, svcph, svcChanged] = applyArrivalBuffer(jnd, s, nvec, buffers, mi, R, sn, smap, svcph, bufPHNode)
1903% Join a just-arrived
class-S job to
the ordered buffer of destination node
1904% JND,
if that node
is buffered. NVEC already includes
the arrival. Shared by
1905% updateBuffers (routed arrivals) and fcrReleaseCascade (WAITQ releases), so
1906%
the two paths cannot drift. At a buffered-PH destination a job that enters
1907% service (rather than waiting)
is added to
the in-service phase multiset svcph
1908% at a pie-drawn entry phase; SVCCHANGED flags that
for a propensity refresh.
1910 if isListSched(jnd, sn)
1911 % PAS/OI:
the arrival simply joins
the back of
the ordered list; there
1912 %
is no server/buffer split, so no capacity test against mi. Capacity
1913 %
is the station
's own cap, and an arrival past it is lost.
1914 if numel(buffers{jnd}) < sn.cap(sn.nodeToStation(jnd))
1915 buffers{jnd}(end+1) = s; % append at the back (newest last)
1917 elseif isBuffered(jnd, sn)
1918 totalAtDest = sum(classCounts(nvec, smap.phOff, smap.nph, jnd, R));
1919 enteredService = false;
1920 if isRetrialStation(jnd, sn)
1921 % A retrial station breaks the buffer invariant the other policies
1922 % share: because a departure does not promote, the orbit can be
1923 % occupied while servers sit idle, so "total > mi" no longer means
1924 % "the servers are busy". An arrival must consult the servers
1925 % directly and only join the orbit when none is free.
1926 inSvc = (totalAtDest - 1) - numel(buffers{jnd});
1928 buffers{jnd} = [s, buffers{jnd}];
1930 enteredService = true;
1932 elseif totalAtDest > mi(jnd)
1933 if isPreemptive(jnd, sn)
1934 % Preempt-resume: the arrival seizes a server and the incumbent
1935 % it displaces is the one that joins the buffer. The victim is
1936 % drawn in proportion to the class occupancies of the servers,
1937 % as State.afterEventStation weights its preemption branches by
1938 % si_preempt/sum(space_srv). Buffering the incumbent rather than
1939 % the arrival is what leaves the new job in service, since
1940 % in-service is read back as population minus buffer occupancy.
1941 c = pickPreempted(nvec, buffers{jnd}, jnd, s, R);
1943 buffers{jnd} = [c, buffers{jnd}]; % addFirst
1945 enteredService = true;
1947 % All servers busy - arriving job joins back of buffer
1948 buffers{jnd} = [s, buffers{jnd}]; % addFirst
1951 % A server is free: the job goes straight into service.
1952 enteredService = true;
1954 if enteredService && bufPHNode(jnd)
1955 ke = drawEntryPhase(sn, jnd, s, smap.nph(jnd, s));
1956 svcph{jnd}(s, ke) = svcph{jnd}(s, ke) + 1;
1962function ke = drawEntryPhase(sn, jnd, s, nphjs)
1963% Sample the service phase a class-S job starts in at node JND from its entry
1964% distribution pie. A single-phase class always enters phase 1.
1969pe = entryProbs(sn, jnd, s, nphjs);
1970ke = drawFromDist(pe);
1973function pos = pickFromBuffer(buf, sn, ist)
1974% Index of the waiting job that the discipline at station IST promotes into
1975% service. BUF is ordered newest-first / oldest-last, matching the convention
1976% of State.afterEventStation's space_buf (which inserts arrivals at
column 1
1977% and,
for HOL, promotes
the rightmost job of
the urgent priority group).
1979 case SchedStrategy.FCFS
1980 pos = numel(buf); % oldest
1981 case {SchedStrategy.LCFS, SchedStrategy.LCFSPR}
1982 pos = 1; % newest / most recently preempted
1983 case SchedStrategy.SIRO
1984 % Uniform over
the waiting jobs. State.afterEventStation promotes a
1985 %
class-r job with probability (nir(r)-sir(r))/(ni-sum(sir)), i.e.
the
1986 % waiting
class-r fraction, which
is exactly a uniform draw over buf.
1987 pos = 1 + floor(rand * numel(buf));
1988 case SchedStrategy.HOL
1989 % Highest priority (lowest classprio value); FCFS within
the group, so
1990 %
the oldest =
the last matching position.
1991 prio = sn.classprio(buf);
1992 pos = find(prio == min(prio), 1,
'last');
1993 case {SchedStrategy.SEPT, SchedStrategy.LEPT}
1994 % sn.schedparam(ist,r)
is the rank of
class r's mean service time
1995 % (ascending
for SEPT, descending
for LEPT), so
the promoted
class is
1996 %
the waiting one of least rank. Oldest first within a
class.
1997 ranks = sn.schedparam(ist, buf);
1998 pos = find(ranks == min(ranks), 1,
'last');
2000 line_error(mfilename, sprintf(
'pickFromBuffer: unsupported buffered policy %s.', ...
2001 SchedStrategy.toText(sn.sched(ist))));
2005function n = pasInSvc(sn, ind, c, r)
2006% Number of
class-r jobs in service at a PAS/OI station holding
the ordered
2007% list C:
the positions whose marginal rate increment Delta_mu
is positive.
2008% Mirrors
the sir
the PAS branch of State.toMarginal reports, which
is what
2009% solver_ctmc_analyzer divides by
the server
count.
2014muFun = sn.nodeparam{ind}.svcRateFun;
2017 muCur = muFun(c(1:p));
2018 if muCur - muPrev > 0 && c(p) == r
2025function buf = oiDepart(sn, ind, buf, r)
2026% Apply
the pass-and-swap rewrite
for a
class-r departure at OI station IND.
2027% The completing position
is drawn among those whose pass-and-swap ejects
class
2028% r, weighted by that position
's own service rate Delta_mu.
2029muFun = sn.nodeparam{ind}.svcRateFun;
2030G = sn.nodeparam{ind}.swapGraph;
2037 muCur = muFun(c(1:p));
2038 ratep = muCur - muPrev;
2043 [~, depClass] = State.passAndSwap(c, p, G);
2045 pos(end+1) = p; %#ok<AGROW>
2046 w(end+1) = ratep; %#ok<AGROW>
2050 return % this class cannot depart from the current list
2062buf = State.passAndSwap(c, pick, G);
2065function rt = oirate(muFun, G, c, r)
2066% Aggregate class-r departure rate of an order-independent / pass-and-swap
2067% station holding the ordered list C (oldest first). Mirrors the DEP branch of
2068% State.afterEventStationPAS: every position contributes its own service token
2069% at Delta_mu, and pass-and-swap decides which class actually leaves.
2075muPrev = 0; % mu of the empty prefix is 0
2077 muCur = muFun(c(1:p));
2078 ratep = muCur - muPrev;
2081 continue % position p receives no service
2083 [~, depClass] = State.passAndSwap(c, p, G);
2090function tf = isListSched(ind, sn)
2091% True for stations whose buffer holds the FULL ordered job list rather than
2092% only the waiting jobs.
2095 tf = (sn.sched(sn.nodeToStation(ind)) == SchedStrategy.PAS);
2099function tf = isRetrialStation(ind, sn)
2100% True for stations with a retrial orbit: their freed servers are not filled by
2101% promotion, only by a successful RETRY.
2103if sn.isstation(ind) && isfield(sn,'retrialProc
') && ~isempty(sn.retrialProc)
2104 ist = sn.nodeToStation(ind);
2105 tf = ist > 0 && any(~cellfun(@isempty, sn.retrialProc(ist,:)));
2109function tf = isPreemptive(ind, sn)
2110% True for the preempt-resume / preempt-independent policies, whose arrivals
2111% displace an incumbent instead of queueing behind it.
2114 ist = sn.nodeToStation(ind);
2115 tf = any(sn.sched(ist) == [SchedStrategy.LCFSPR]);
2119function c = pickPreempted(nvec, buf, jnd, arrClass, R)
2120% Class of the incumbent displaced by an arrival of class ARRCLASS at node JND,
2121% drawn in proportion to the servers' class occupancies. NVEC already counts
2122%
the arrival, so it
is discounted here to recover
the pre-arrival in-service
2123% composition (in-service = population minus buffer occupancy).
2127 insvc(r) = nvec(base + r) - sum(buf == r);
2129 insvc(r) = insvc(r) - 1; % discount
the job that just arrived
2132insvc(insvc < 0) = 0;
2140c = find(insvc > 0, 1,
'last');
2142 acc = acc + insvc(r);
2143 if insvc(r) > 0 && u < acc
2150function npop = classCounts(X, phOff, nph, ind, R)
2151% Per-
class populations at node IND, summing each class over its phases. The
2152% scheduling rate laws are
class-level: they are unchanged by phase expansion,
2153% and only
the per-phase share (see kirFrac)
is layered on top.
2156 npop(r) = sum(X((phOff(ind,r)+1):(phOff(ind,r)+nph(ind,r))));
2160function n = classPop(X, phOff, nph, ind, r)
2161% Population of
class R at node IND, summed over its phases.
2162n = sum(X((phOff(ind,r)+1):(phOff(ind,r)+nph(ind,r))));
2165function f = kirFrac(X, slot, phOff, nph, ind, r)
2166% Share of its
class that
the job population in one phase represents: kir/nir.
2167% The class-level rate law
is split across
the class's phases in this ratio,
2168% which
is exactly how State.afterEventStation writes every phase-aware case
2169% (e.g. DPS uses (kir/nir) * [class share]). For a single-phase class this
is
2170% 1 whenever
the class
is present, so an exponential model
is unaffected.
2171nir = sum(X((phOff(ind,r)+1):(phOff(ind,r)+nph(ind,r))));
2179function pentry = entryProbs(sn, jnd, s, nphjs)
2180% Entry-phase distribution of a class-s job arriving at node JND: pie of its
2181% service process there. A non-station node, or a station whose process is
2182% absent (a disabled class), has a single phase entered with probability 1.
2183pentry = zeros(1, nphjs);
2188ist = sn.nodeToStation(jnd);
2191if isempty(p) || all(isnan(p)) || sum(p) <= 0
2192 % no entry distribution declared: enter the first phase
2196pentry(1:min(nphjs,numel(p))) = p(1:min(nphjs,numel(p)));
2197pentry = pentry / sum(pentry);
2200function tf = isBuffered(ind, sn)
2201% True for stations whose waiting jobs are held in an ordered buffer.
2204 ist = sn.nodeToStation(ind);
2205 tf = any(sn.sched(ist) == [SchedStrategy.FCFS, SchedStrategy.LCFS, ...
2206 SchedStrategy.SIRO, SchedStrategy.HOL, SchedStrategy.SEPT, SchedStrategy.LEPT, ...
2207 SchedStrategy.LCFSPR]);
2211function f = lldfac(ldrow, ntot, lldlimit)
2212% Limited load-dependent scaling factor at total station population NTOT. Returns
2213% 1 when the station has no load dependence or is empty; otherwise the tabulated
2214% factor, clamped to the last entry beyond the tabulated limit.
2215if isempty(ldrow) || ntot < 1
2218 f = ldrow(min(round(ntot), lldlimit));
2222% ======================================================================
2223% Round-robin routing pointers
2224% ======================================================================
2226function rr = rrPrecompute(sn, state)
2227% Per-(node,class) round-robin pointers, seeded from the initial state. RROBIN
2228% stores the destination node index in its slot, WRROBIN a POSITION in the
2229% weighted cycle (each outlink replicated by its weight), matching
2230% State.fromMarginal and State.afterEventRouter.
2231rr = struct('on', false);
2232if ~isfield(sn,'routing') || isempty(sn.routing)
2235if ~any(sn.routing(:) == RoutingStrategy.RROBIN | sn.routing(:) == RoutingStrategy.WRROBIN)
2240rr.isrr = false(sn.nnodes, R);
2241rr.iswrr = false(sn.nnodes, R);
2242rr.cycle = cell(sn.nnodes, R); % ordered destination list walked per dispatch
2243rr.pos = ones(sn.nnodes, R); % current position in that list
2244for ind = 1:sn.nnodes
2246 isRR = sn.routing(ind,r) == RoutingStrategy.RROBIN;
2247 isWRR = sn.routing(ind,r) == RoutingStrategy.WRROBIN;
2251 np = sn.nodeparam{ind}{r};
2252 if isWRR && isfield(np,'weighted_outlinks') && ~isempty(np.weighted_outlinks)
2253 cyc = np.weighted_outlinks;
2257 rr.isrr(ind,r) = true;
2258 rr.iswrr(ind,r) = isWRR;
2259 rr.cycle{ind,r} = cyc(:)';
2260 % seed the pointer from the initial state slot so a warm start is honored
2262 if sn.isstateful(ind)
2263 st = state{sn.nodeToStateful(ind)};
2264 slot = sum(sn.nvars(ind, 1:(R + r)));
2265 if ~isempty(st) && slot >= 1 && slot <= numel(st)
2268 if v >= 1 && v <= numel(cyc), p0 = v; end
2270 j = find(cyc == v, 1);
2271 if ~isempty(j), p0 = j; end
2280function [rr, jnd] = rrNext(rr, ind, r)
2281% Advance the pointer cyclically and return the destination it lands on.
2282cyc = rr.cycle{ind,r};
2293% ======================================================================
2295% ======================================================================
2297function sig = signalPrecompute(sn)
2298% Signal classes and their removal parameters. A signal never joins a station:
2299% it removes jobs there and is annihilated (State.afterEventStationSignal).
2300sig = struct('on', false);
2301if ~isfield(sn,'issignal') || isempty(sn.issignal) || ~any(sn.issignal)
2305sig.sn = sn; % signalBatchPMF and isCatastropheSignal need it
2306sig.issignal = logical(sn.issignal(:)');
2307sig.nonsignal = find(~sig.issignal);
2310function tf = sigIsSignalArrival(sig, destPos, R, smap)
2311% True when the state slot DESTPOS is a signal class at a station.
2312s = smap.class(destPos);
2313jnd = smap.node(destPos);
2314tf = sig.issignal(s) && sig.sn.isstation(jnd);
2317function [nvec, buffers] = sigApply(sig, nvec, buffers, destPos, R, mi, smap)
2318% Apply the arrival of a signal class at a station: pick the victims and remove
2319% them. Mirrors State.afterEventStationSignal, sampled instead of enumerated.
2321jnd = smap.node(destPos);
2322cls = smap.class(destPos);
2323base = smap.phOff(jnd,1); % first slot of this node
2324ist = sn.nodeToStation(jnd);
2326% CATASTROPHE empties the station of every job, ignoring the batch-size
2327% distribution: a catastrophe removes all jobs by definition.
2328if State.isCatastropheSignal(sn, cls)
2329 nvec((base+1):(base+R)) = 0;
2334% Eligible victim classes. A signal that declares a target (forJobClass,
2335% sn.signaltarget >= 1) only removes that class; otherwise every non-signal
2336% class is eligible, which is the classic Gelenbe negative customer and is what
2337% SolverMAM and SolverLDES both do.
2339if isfield(sn,'signaltarget') && ~isempty(sn.signaltarget) && numel(sn.signaltarget) >= cls
2340 tgt = sn.signaltarget(cls);
2345 tgtclasses = sig.nonsignal;
2347tgtclasses = tgtclasses(nvec(base + tgtclasses) > 0);
2348ntot = sum(nvec(base + tgtclasses));
2349if isempty(tgtclasses) || ntot <= 0
2350 return % no victim: the signal simply vanishes
2353% Batch size, drawn from the pmf the reference enumerates. It is already
2354% clipped at the eligible population, so an oversized batch empties it rather
2355% than driving the queue negative.
2356[kvals, kprobs] = State.signalBatchPMF(sn, cls, ntot);
2357k = kvals(find(cumsum(kprobs) >= rand, 1));
2362policy = RemovalPolicy.RANDOM;
2363if isfield(sn,'signalrempolicy') && ~isempty(sn.signalrempolicy) && numel(sn.signalrempolicy) >= cls
2364 policy = sn.signalrempolicy(cls);
2368 [nvec, buffers, removed] = sigRemoveOne(sn, nvec, buffers, jnd, ist, base, ...
2369 tgtclasses, policy, R, mi);
2371 break % already drained
2376function [nvec, buffers, removed] = sigRemoveOne(sn, nvec, buffers, jnd, ist, base, tgtclasses, policy, R, mi)
2377% Remove one victim under the signal's removal policy. Waiting jobs live in the
2378% buffer; the rest of each class population is in service.
2381waitIdx = find(ismember(buf, tgtclasses)); % eligible waiting positions
2382nwait = numel(waitIdx);
2385 nsrv = nsrv + max(0, nvec(base + r) - sum(buf == r));
2387if nwait == 0 && nsrv == 0
2391% FCFS/LCFS rank the waiting line by age, which only an ordered buffer records.
2392% The NRM buffer is newest-first / oldest-last, so the head of line (oldest) is
2393% the last eligible position and the most recent arrival the first. A per-class
2394% count buffer (SIRO/SEPT/LEPT) carries no age, so an age-based policy
2395% degenerates to a uniform draw there, exactly as in the reference.
2396isOrdered = any(sn.sched(ist) == [SchedStrategy.FCFS, SchedStrategy.HOL, SchedStrategy.LCFS]);
2397ageOrdered = isOrdered && (policy == RemovalPolicy.FCFS || policy == RemovalPolicy.LCFS);
2398if ageOrdered && nwait > 0
2399 if policy == RemovalPolicy.FCFS
2400 pick = waitIdx(end); % head of line: the oldest waiting job
2402 pick = waitIdx(1); % the most recent arrival
2405 buffers{jnd}(pick) = [];
2406 nvec(base + victim) = nvec(base + victim) - 1;
2411% RANDOM draws uniformly over waiting and in-service alike; FCFS/LCFS drain the
2412% waiting line before reaching into the servers.
2413if policy == RemovalPolicy.RANDOM
2414 total = nwait + nsrv;
2422if nwait > 0 && (policy ~= RemovalPolicy.RANDOM || u < nwait)
2423 % a waiting victim, uniform over the eligible positions
2424 pick = waitIdx(1 + floor(rand * nwait));
2426 buffers{jnd}(pick) = [];
2427 nvec(base + victim) = nvec(base + victim) - 1;
2432% an in-service victim, uniform over the eligible in-service jobs
2434target = rand * nsrv;
2436 cnt = max(0, nvec(base + r) - sum(buf == r));
2438 if cnt > 0 && target < acc
2439 nvec(base + r) = nvec(base + r) - 1;
2441 % the freed server pulls the head of line in, which in the NRM is just
2442 % the waiting job leaving the buffer (in-service is derived as
2443 % population minus buffer occupancy)
2444 total_new = sum(nvec((base+1):(base+R)));
2445 if numel(buffers{jnd}) > max(0, total_new - mi(jnd))
2446 buffers{jnd}(end) = []; % head of line:
the oldest waiting job
2453% ======================================================================
2455% ======================================================================
2457function balk = balkPrecompute(sn)
2458% Per (station,
class) balking threshold table, for
the QUEUE_LENGTH strategy.
2459balk = struct('on', false);
2460if ~isfield(sn,'balkingStrategy') || isempty(sn.balkingStrategy)
2463if ~any(sn.balkingStrategy(:) == BalkingStrategy.QUEUE_LENGTH)
2467balk.strategy = sn.balkingStrategy;
2468balk.thresholds = sn.balkingThresholds;
2469% index maps needed by balkDraw, captured so it never needs
the whole sn
2470balk.isstation = sn.isstation;
2471balk.nodeToStation = sn.nodeToStation;
2474function tf = balkDraw(balk, nvec, destPos, R, smap)
2475% True if
the job routed to state slot DESTPOS balks. The threshold table
is
2476% scanned in order and
the FIRST interval containing
the pre-arrival total
2477% station population wins, matching State.afterEventStation.
2479jnd = smap.node(destPos);
2480s = smap.class(destPos);
2481if ~balk.isstation(jnd)
2484ist = balk.nodeToStation(jnd);
2485if ist < 1 || balk.strategy(ist, s) ~= BalkingStrategy.QUEUE_LENGTH
2488qlen = sum(classCounts(nvec, smap.phOff, smap.nph, jnd, R)); % pre-arrival total population
2489th = balk.thresholds{ist, s};
2493 if qlen >= t{1} && qlen <= t{2}
2498tf = balkProb > 0 && rand < balkProb;
2501function tf = capacityLoss(sn, nvec, destPos, R, smap)
2502% True
if an OPEN-
class job routed to state slot DESTPOS
is lost because its
2503% destination station
is a physically finite-capacity station that
is already
2504% full. Mirrors
the hasRoom gate + State.arrivalIsLost of afterEventStation:
2505% total occupancy (buffer + in service)
is capped at sn.cap, per
class at
2506% sn.classcap (0 = no per-
class bound). A refused CLOSED job must block, not
2507% vanish from
the conserved population, so it
is NOT dropped here. Inert unless
2508%
the destination declares a physical drop rule. This
is the finite-capacity
2509% loss
the NRM reaction network otherwise omits, which let a capped queue
2510% overflow well past sn.cap under simulation.
2512jnd = smap.node(destPos);
2513dstC = smap.class(destPos);
2514if ~sn.isstation(jnd)
2517ist = sn.nodeToStation(jnd);
2521if ~State.isPhysicalCapacity(sn, ist, dstC) || ~State.arrivalIsLost(sn, ist, dstC)
2524cc = classCounts(nvec, smap.phOff, smap.nph, jnd, R); % pre-arrival populations
2525capLimit = sn.cap(ist);
2526if isfinite(capLimit) && sum(cc) >= capLimit
2530if ~isempty(sn.classcap) && size(sn.classcap,1) >= ist && size(sn.classcap,2) >= dstC
2531 classCapLimit = sn.classcap(ist, dstC);
2532 if classCapLimit > 0 && cc(dstC) >= classCapLimit
2538% ======================================================================
2539% Finite capacity regions (DROP rule)
2540% ======================================================================
2542function fcr = fcrPrecompute(sn)
2543% Per-region member stations and admission caps, mirroring
the FCR precompute
2544% of SOLVER_SSA (
the serial engine) field
for field.
2545fcr =
struct(
'on',
false);
2546if ~isfield(sn,
'nregions') || sn.nregions == 0
2552fcr.memberMask =
false(F, sn.nstations);
2553fcr.classCap = cell(F,1);
2554fcr.globalCap = inf(F,1);
2555fcr.memCap = inf(F,1);
2559% Per-(region,
class) admission rule: DROP destroys a refused job, WAITQ parks
2560% it in
the region FIFO and admits it head-of-line as capacity frees. Mirrors
2561% SOLVER_SSA
's fcrRule (regionrule ~= DropStrategy.DROP). Regions with no
2562% WAITQ class carry no FIFO, so pure-DROP models pay nothing.
2563fcr.waitq = false(F, K);
2564if isfield(sn,'regionrule
') && ~isempty(sn.regionrule)
2567 fcr.waitq(f,r) = sn.regionrule(f,r) ~= DropStrategy.DROP;
2571fcr.anyWaitq = any(fcr.waitq(:));
2573 Rmat = sn.region{f}; % M x (K+1)
2574 % membership: any job-count cap OR the region memory budget set on the
2575 % station row (a memory-only region has all job-count entries at -1)
2576 memvec = -ones(sn.nstations,1);
2577 if isfield(sn,'regionmaxmem
') && numel(sn.regionmaxmem) >= f && ~isempty(sn.regionmaxmem{f})
2578 memvec = sn.regionmaxmem{f}(:);
2580 mask = (any(Rmat ~= -1, 2) | memvec ~= -1)';
2581 fcr.memberMask(f, 1:numel(mask)) = mask;
2582 members = find(mask);
2585 cv = Rmat(members, r); cv = cv(cv ~= -1);
2586 if ~isempty(cv); ccap(r) = min(cv); end
2588 fcr.classCap{f} = ccap;
2589 gv = Rmat(members, K+1); gv = gv(gv ~= -1);
2590 if ~isempty(gv); fcr.globalCap(f) = min(gv); end
2591 if isfield(sn,
'regionmaxmem') && numel(sn.regionmaxmem) >= f && ~isempty(sn.regionmaxmem{f})
2592 mv = sn.regionmaxmem{f}(members); mv = mv(mv ~= -1);
2593 if ~isempty(mv); fcr.memCap(f) = min(mv); end
2595 fcr.sz{f} = sn.regionsz(f,:);
2596 if isfield(sn,
'regionlincon') && size(sn.regionlincon,1) >= f && ~isempty(sn.regionlincon{f,1})
2597 fcr.A{f} = sn.regionlincon{f,1};
2598 fcr.b{f} = sn.regionlincon{f,2};
2601% node-level membership, so
the gate can be evaluated straight off
the NRM
2602% state vector without going through station indices on every firing
2603fcr.memberNode =
false(F, sn.nnodes);
2605 for ist = find(fcr.memberMask(f,:))
2606 fcr.memberNode(f, sn.stationToNode(ist)) = true;
2611function tf = fcrViolates(xn, ccap, gcap, memcap, sz, A, b)
2612% True
if per-
class population vector XN breaks any admission constraint of
2613%
the region. Mirrors fcr_violates in SOLVER_SSA.
2614tf = any(xn > ccap) || sum(xn) > gcap || (xn * sz(:) > memcap);
2615if ~tf && ~isempty(A)
2616 tf = any(A * xn(:) > b(:));
2620function x = fcrRegionPop(nvec, memberNodeRow, R, smap)
2621% Per-
class population of a region, read directly off
the NRM state vector.
2623for jnd = find(memberNodeRow)
2624 x = x + classCounts(nvec, smap.phOff, smap.nph, jnd, R)
';
2628function tf = fcrAdmits(fcr, nvec, srcNode, srcClass, dstNode, dstClass, R, smap)
2629% True if a class-DSTCLASS job may enter node DSTNODE, having just left node
2630% SRCNODE as class SRCCLASS. Only regions containing the destination can
2631% refuse the move; a move whose source is in the same region frees a slot
2632% first, so the departure is accounted for before the arrival is tested.
2633tf = fcrRefusingRegion(fcr, nvec, srcNode, srcClass, dstNode, dstClass, R, smap) == 0;
2636function f = fcrRefusingRegion(fcr, nvec, srcNode, srcClass, dstNode, dstClass, R, smap)
2637% Index of the FIRST region that refuses a class-DSTCLASS job entering
2638% DSTNODE, having just left SRCNODE as class SRCCLASS; 0 if every region
2639% admits it. Same admission test as the DROP path, but it names the refusing
2640% region so the caller can consult that region's DROP/WAITQ rule. Mirrors
the
2641% first-region `
break` of SOLVER_SSA
's blockFCR loop.
2646for ff = 1:size(fcr.memberNode,1)
2647 if ~fcr.memberNode(ff, dstNode)
2648 continue % this region does not constrain the destination
2650 x = fcrRegionPop(nvec, fcr.memberNode(ff,:), R, smap);
2651 % srcNode <= 0 means the mover has no live source in the state (a WAITQ
2652 % release, whose job already left its source when it was parked), so no
2653 % source slot is freed.
2654 if srcNode > 0 && fcr.memberNode(ff, srcNode)
2655 x(srcClass) = x(srcClass) - 1;
2657 x(dstClass) = x(dstClass) + 1;
2658 if fcrViolates(x, fcr.classCap{ff}, fcr.globalCap(ff), fcr.memCap(ff), ...
2659 fcr.sz{ff}, fcr.A{ff}, fcr.b{ff})
2666function [nvec, buffers, fcrBuf, released, svcph, svcChanged] = fcrReleaseCascade(fcr, nvec, buffers, fcrBuf, mi, R, sn, smap, svcph, bufPHNode)
2667% Strict-FIFO head-of-line release of parked WAITQ tokens: admit each region's
2668% FIFO head
while the admission constraints permit, applying
the arrival to
2669%
the destination station (entry-phase slot plus buffer join). Mirrors
2670% SOLVER_SSA
's fcr_release. A token is (dstNode, dstClass); the phase is drawn
2671% at release, as a routed arrival draws it. Loops until a full pass frees
2672% nothing, so a release that frees capacity elsewhere cascades.
2678 for f = 1:numel(fcrBuf)
2679 if isempty(fcrBuf{f})
2683 dstNode = floor((tok-1)/R) + 1;
2684 dstClass = mod(tok-1, R) + 1;
2685 % The parked job already left its source, so admission is tested with
2686 % the source term absent (srcNode = -1 never matches memberNode).
2687 if fcrRefusingRegion(fcr, nvec, -1, dstClass, dstNode, dstClass, R, smap) ~= 0
2688 continue % head-of-line: this FIFO stays blocked
2690 if bufPHNode(dstNode)
2691 % Buffered-PH destination: the released job lands in the class total
2692 % slot; whether it enters service (and its entry phase) is decided in
2693 % applyArrivalBuffer against the server occupancy, exactly as a routed
2695 nvec(smap.phOff(dstNode,dstClass) + 1) = nvec(smap.phOff(dstNode,dstClass) + 1) + 1;
2696 [buffers, svcph, arrCh] = applyArrivalBuffer(dstNode, dstClass, nvec, buffers, mi, R, sn, smap, svcph, bufPHNode);
2697 svcChanged = svcChanged || arrCh;
2699 pentry = entryProbs(sn, dstNode, dstClass, smap.nph(dstNode,dstClass));
2700 ke = drawFromDist(pentry);
2701 dslot = smap.phOff(dstNode,dstClass) + ke;
2702 nvec(dslot) = nvec(dslot) + 1;
2703 [buffers, svcph, arrCh] = applyArrivalBuffer(dstNode, dstClass, nvec, buffers, mi, R, sn, smap, svcph, bufPHNode);
2704 svcChanged = svcChanged || arrCh;
2707 released = released + 1;
2713function ke = drawFromDist(p)
2714% Index drawn from the (unnormalized, nonnegative) weight vector P.
2721ke = find(c > rand, 1);
2727function [outClass, var, category] = cacheAccess(sn, ind, class, var)
2728% Simulate one cache READ at cache node IND by a class-CLASS job over the cache
2729% state VAR (totalCacheCapacity content slots followed, when a retrieval system
2730% is present, by a per-item retrieval-occupancy bitmap). Returns the class the
2731% job leaves in -- OUTCLASS = 0 means the request was absorbed as a delayed hit
2732% and produces nothing -- the rewritten VAR, and a CATEGORY (1 hit, 2 miss/
2733% retrieval-complete, 3 delayed-hit, 4 begin-retrieval). A faithful port of
2734% State.afterEventCache (READ, isSimulation): non-retrieval hit/miss with all
2735% replacement policies, plus the retrieval (delayed-hit) system where a miss for
2736% an item not yet being fetched begins a retrieval (switch to the item's
2737% retrieval
class, mark
the bitmap), a concurrent request
for an item already
2738% being fetched
is absorbed, and a returning retrieval-
class read completes
the
2739% miss (clear
the bitmap, admit
the item).
2740np = sn.nodeparam{ind};
2744replacement_id = np.replacestrat;
2745if isfield(np,
'totalCacheCapacity') && ~isempty(np.totalCacheCapacity)
2746 totalCacheCapacity = np.totalCacheCapacity;
2748 totalCacheCapacity = sum(m);
2750hitclassArr = np.hitclass;
2751missclassArr = np.missclass;
2752if isfield(np,'retrievalClassIndices') && ~isempty(np.retrievalClassIndices)
2753 rci = np.retrievalClassIndices(:)';
2757isFromRetrieval = any(rci == class);
2758if isfield(np,'retrievalClasses') && ~isempty(np.retrievalClasses)
2759 retrClasses = np.retrievalClasses;
2763hasRetrieval = isfield(np,'retrievalSystemCapacity') && ~isempty(np.retrievalSystemCapacity) ...
2764 && any(np.retrievalSystemCapacity > 0);
2767k = drawFromDist(p); % requested item
2768l = drawFromDist(ac{
class,k}(1,:)); % target list
for a miss (1 => reject)
2769posk = find(k == var(1:totalCacheCapacity), 1,
'first');
2771 posk = []; % a returning retrieval always COMPLETES its own miss
2775 % ===================== CACHE HIT =====================
2776 outClass = hitclassArr(
class);
2778 if posk <= sum(m(1:h-1))
2779 % hit in list i < h: promote toward
the last list
2780 i = find(posk <= cumsum(m), 1);
2781 j = posk - sum(m(1:i-1));
2782 accrow = ac{
class,k}(1+i, (1+i):end);
2783 inew = i + drawFromDist(accrow / sum(accrow)) - 1;
2784 switch replacement_id
2785 case ReplacementStrategy.FIFO
2788 varp(cpos(i,j)) = var(cpos(inew,m(inew)));
2789 varp(cpos(inew,2):cpos(inew,m(inew))) = var(cpos(inew,1):cpos(inew,m(inew)-1));
2790 varp(cpos(inew,1)) = k;
2793 case ReplacementStrategy.RR
2795 rpos = randi(m(inew),1,1);
2796 varp(cpos(i,j)) = var(cpos(inew,rpos));
2797 varp(cpos(inew,rpos)) = k;
2799 case {ReplacementStrategy.LRU, ReplacementStrategy.SFIFO, ...
2800 ReplacementStrategy.HLRU, ReplacementStrategy.QLRU}
2802 varp(cpos(i,2):cpos(i,j)) = var(cpos(i,1):cpos(i,j-1));
2803 varp(cpos(i,1)) = var(cpos(inew,m(inew)));
2804 varp(cpos(inew,2):cpos(inew,m(inew))) = var(cpos(inew,1):cpos(inew,m(inew)-1));
2805 varp(cpos(inew,1)) = k;
2809 % hit in
the last list h
2810 j = posk - sum(m(1:h-1));
2811 switch replacement_id
2812 case {ReplacementStrategy.RR, ReplacementStrategy.FIFO, ReplacementStrategy.SFIFO}
2814 case {ReplacementStrategy.LRU, ReplacementStrategy.HLRU, ReplacementStrategy.QLRU}
2816 varp(cpos(h,2):cpos(h,j)) = var(cpos(h,1):cpos(h,j-1));
2817 varp(cpos(h,1)) = var(cpos(h,j));
2824% ===================== CACHE MISS / retrieval =====================
2825if hasRetrieval && ~isFromRetrieval
2826 % Consult
the retrieval system: an item with a retrieval
class is fetched
2827 % rather than admitted directly on a miss.
2829 if ~isempty(retrClasses) && k <= size(retrClasses,1) &&
class <= size(retrClasses,2)
2830 rClass = retrClasses(k,
class);
2833 inRetrieval = (totalCacheCapacity + k <= numel(var)) && var(totalCacheCapacity + k) ~= 0;
2835 % DELAYED HIT:
this request
is served by
the in-flight retrieval and
2836 % absorbed (no
class produced), coalescing onto
the pending fetch.
2841 % BEGIN retrieval:
switch to
the item
's retrieval class and mark the
2842 % item as being fetched; the job routes to the retrieval queue and
2843 % returns later to complete the miss.
2844 var(totalCacheCapacity + k) = 1;
2852% COMPLETE the miss: a returning retrieval, or a plain miss with no retrieval
2853% class. Clear the retrieval bit (if any) and admit item k per the policy.
2854if isFromRetrieval && (totalCacheCapacity + k <= numel(var))
2855 var(totalCacheCapacity + k) = 0;
2857outClass = missclassArr(class);
2860switch replacement_id
2861 case {ReplacementStrategy.FIFO, ReplacementStrategy.LRU, ...
2862 ReplacementStrategy.SFIFO, ReplacementStrategy.HLRU}
2865 varp(cpos(listidx,2):cpos(listidx,m(listidx))) = var(cpos(listidx,1):cpos(listidx,m(listidx)-1));
2866 varp(cpos(listidx,1)) = k;
2869 case ReplacementStrategy.RR
2871 rpos = randi(m(listidx),1,1);
2872 var(cpos(listidx,rpos)) = k;
2874 case ReplacementStrategy.QLRU
2875 if isfield(np,'qlru
') && ~isempty(np.qlru), qadm = np.qlru; else, qadm = 1.0; end
2876 if listidx > 0 && rand <= qadm
2878 varp(cpos(listidx,2):cpos(listidx,m(listidx))) = var(cpos(listidx,1):cpos(listidx,m(listidx)-1));
2879 varp(cpos(listidx,1)) = k;
2884 function pos = cpos(ii,jj)
2885 pos = sum(m(1:ii-1)) + jj;
2889% ======================================================================
2890% PS-family sharing factors
2892% Each returns the multiplier applied to the class-r service rate, i.e. the
2893% fraction of total service capacity that class r receives in population
2894% state NVECPOP. All mirror the corresponding case of
2895% State.afterEventStation specialized to exponential (single-phase) service,
2896% where the phase population kir equals the class population nir.
2897% ======================================================================
2899function f = dpsshare(w, nvecpop, r)
2900% DPS: rate_r = mu_r * w_r*n_r / (w.n) on a single server.
2901den = w(:)' * nvecpop(:);
2905 f = w(r) * nvecpop(r) / den;
2909function f = gpsshare(w, nvecpop, r)
2910% GPS: rate_r = mu_r * w_r / (w.c), c_s = 1{n_s>0}, on a single server. The
2911% weight denominator counts active classes, not jobs, so a
class with a
2912% single job gets
the same share as one with many.
2917cir = double(nvecpop(:) > 0);
2926function [act, niprio] = prioGroup(nvecpop, r, classprio)
2927% Population vector restricted to the priority group of class r, and its
2928% total. Empty classes never define the urgent group.
2929act = zeros(size(nvecpop));
2930same = (classprio(:) == classprio(r));
2931act(same) = nvecpop(same);
2935function tf = isUrgent(nvecpop, r, classprio)
2936% True when class r belongs to the most urgent non-empty priority group.
2937% LINE orders priorities with lower value = more urgent.
2938occupied = nvecpop(:) > 0;
2942 tf = (classprio(r) == min(classprio(occupied)));
2946function n = prioPop(nvecpop, r, c, classprio)
2947% Population that the lld factor is evaluated at: the full station
2948% population below capacity, the priority-group population above it.
2950if ni <= c || ~isUrgent(nvecpop, r, classprio)
2953 [~, n] = prioGroup(nvecpop, r, classprio);
2957function v = prioVec(nvecpop, r, c, classprio)
2958% Population vector that the cd factor is evaluated at for DPSPRIO/GPSPRIO:
2959% the priority-restricted vector above capacity, the full one below it.
2960% Note PSPRIO instead uses the full vector in both branches; that asymmetry
2961% is inherited from State.afterEventStation and is reproduced here.
2963if ni <= c || ~isUrgent(nvecpop, r, classprio)
2966 v = prioGroup(nvecpop, r, classprio);
2970function f = psprioshare(nvecpop, r, c, classprio)
2971% PSPRIO: PS below capacity; above it only the most urgent non-empty group
2972% shares the servers and everyone else is frozen.
2977 f = (nvecpop(r) / ni) * min(ni, c);
2978elseif ~isUrgent(nvecpop, r, classprio)
2981 [~, niprio] = prioGroup(nvecpop, r, classprio);
2982 f = (nvecpop(r) / niprio) * min(niprio, c);
2986function f = dpsprioshare(w, nvecpop, r, c, classprio)
2987% DPSPRIO: DPS below capacity, DPS restricted to the urgent group above it.
2992 f = dpsshare(w, nvecpop, r);
2993elseif ~isUrgent(nvecpop, r, classprio)
2996 f = dpsshare(w, prioGroup(nvecpop, r, classprio), r);
3000function f = gpsprioshare(w, nvecpop, r, c, classprio)
3001% GPSPRIO: GPS below capacity, GPS restricted to the urgent group above it.
3006 f = gpsshare(w, nvecpop, r);
3007elseif ~isUrgent(nvecpop, r, classprio)
3010 f = gpsshare(w, prioGroup(nvecpop, r, classprio), r);
3014function f = cdfac(cdbeta, nvecpop, r)
3015% Class-dependence factor for a class-r completion at a station with per-class
3016% population vector NVECPOP: the class-r component of the 1xR scaling vector
3017% returned by the handle CDBETA (see fes_beta_handle and State.cdclassfactor).
3018% Returns 1 when the station declares no class dependence.
3022 v = cdbeta(nvecpop(:)');
3023 f = v(min(r, numel(v)));
3028% ======================================================================
3029% Polling controller helpers
3030% ======================================================================
3032function ctrl = pollLandCtrl(pinf, q, mode, budget)
3033% Controller row [mode, pos, swk, ctr]
the server lands in after
3034% State.pollingNext resolves (q, mode, budget): SERVING q with
the visit budget,
3035% SWITCHING into q with
the entry phase drawn from
the switchover PH, or PARKED
3036% at
the canonical q. Mirrors State.pollingLand specialized to
the single-server
3037% polling station
the NRM carries (exponential service, so no in-service phase).
3040 ctrl = [1, q, 0, budget];
3042 swk = drawFromDist(pinf.swpie{q});
3043 ctrl = [2, q, swk, 0];
3045 ctrl = [0, q, 0, 0]; % parked
3049function g = pollServeGate(ctrl, r)
3050% 1 when
the polling controller CTRL
is serving
class r, else 0. This
is the
3051% single-server gate that turns a
class-r service departure on only
while the
3052% server attends
class r.
3053if numel(ctrl) >= 2 && ctrl(1) == 1 && ctrl(2) == r
3060function rate = pollSwRate(ctrl, pinf)
3061% Total leaving rate of
the switchover phase
the controller CTRL currently
3062% occupies, i.e. -D0(swk,swk) of
the switchover PH into buffer pos; 0 unless
the
3063% server
is walking (mode SWITCHING). The competition between advancing to
3064% another phase and absorbing
is resolved at firing time by
the run loop.
3066if numel(ctrl) >= 3 && ctrl(1) == 2
3067 pos = ctrl(2); swk = ctrl(3);
3068 D0 = pinf.swD0{pos};
3069 rate = -D0(swk, swk);
3072% ======================================================================
3073% Stochastic Petri net (Place / Transition) via
the Next-Reaction Method
3075% A stochastic Petri net maps onto
the reaction network exactly: a Place holds
3076% a per-
class token
count (a population slot of
the state vector), and a timed
3077% Transition mode
is a reaction whose stoichiometry
column is the arc
3078% incidence -- input (enabling) arcs consume, output (firing) arcs produce.
3079% Enabling
is a propensity gate (all input places at or above their arc weight,
3080% every inhibitor place strictly below its threshold); a single-server mode
3081% then fires at its exponential rate, an infinite/k-server mode at that rate
3082% times its enabling degree. Each firing applies
the mode
's stoichiometry once
3083% (consume the input weights, produce the output weights), which is the atomic
3084% GSPN firing shared by the exact CTMC (single server), JMT and GreatSPN.
3086% IMMEDIATE transitions fire in zero time and cannot be an exponential reaction.
3087% They are resolved by vanishing-marking elimination: after every timed firing
3088% (and once on the initial marking) every enabled immediate mode is fired,
3089% highest firing-priority first and, among equal priority, chosen in proportion
3090% to firing weight, until the marking is tangible (no immediate enabled). The
3091% timed race only resumes from tangible markings, so the immediate transitions
3092% never consume simulated time.
3094% Not handled here (rejected upstream by the SSA featset, never reached): a
3095% Transition whose firing distribution is non-exponential (phase-type or
3096% general). Representing an in-flight firing's phase needs per-mode phase state
3097%
the reaction network does not carry;
the exponential path covers
the standard
3098% GSPN
case and every all-exponential validation net (spn_inhibiting,
3099% spn_twomodes, spn_fourmodes).
3100% ======================================================================
3101function [QN, UN, RN, TN, CN, XN] = solver_ssa_nrm_spn(sn, options, phOff, nph, NS, smap)
3102samples = options.samples;
3108% Build
the reaction list. Timed modes become reactions (rx); immediate modes
3109% are collected separately (imm)
for the vanishing-marking collapse.
3110rx = spnEmptyRx(); rx(1) = [];
3111imm = spnEmptyRx(); imm(1) = [];
3112% consumers{ind,c}: indices into rx of timed modes that consume from place ind,
3113%
class c. Place throughput
is the aggregate firing rate of those modes (once
3114% per firing, unweighted --
the same depRates
the CTMC accumulates from PRE
3115% events), so
this map drives
the TN accumulator.
3116consumers = cell(I, R);
3118 if sn.nodetype(ind) ~= NodeType.Transition
3121 np = sn.nodeparam{ind};
3123 rec = spnBuildMode(sn, ind, m, phOff, NS);
3124 if np.timing(m) == TimingStrategy.IMMEDIATE
3125 imm(end+1) = rec; %#ok<AGROW>
3127 rx(end+1) = rec; %#ok<AGROW>
3129 for a = 1:numel(rec.enSlot)
3130 p = smap.node(rec.enSlot(a));
3131 c = smap.class(rec.enSlot(a));
3132 consumers{p, c}(end+1) = ridx;
3138% Source arrivals. A Source
is not a Transition, so its Poisson arrival
is not
3139% one of
the transition modes above; it needs its own reaction or
the fed Place
3140% stays empty and
the net deadlocks. Add one arrival reaction per (Source node,
3141% open
class, routed Place-
class edge). Splitting a Poisson stream by
the
3142% independent routing probabilities yields independent Poisson streams, so an
3143% edge of probability p carries rate lambda*p exactly. The reaction has an EMPTY
3144% enabling set (always enabled, state-independent propensity = lambda*p) and
3145% deposits +1 token into
the routed Place slot. producers{node,
class} indexes
3146% these so
the Source station reports its arrival rate as throughput, which
is
3147%
the reference-station throughput of
the open
class (matching JMT).
3148producers = cell(I, R);
3150 if sn.nodetype(ind) ~= NodeType.Source
3153 ist = sn.nodeToStation(ind);
3155 lambda = sn.rates(ist, r);
3156 if isnan(lambda) || lambda <= 0
3159 if sn.procid(ist, r) ~= ProcessType.EXP
3160 line_error(mfilename, sprintf(
'Source %s class %d has a non-exponential arrival, which the NRM SPN path does not support; use method=''serial'' or SolverJMT.', sn.nodenames{ind}, r));
3164 if sn.nodetype(jnd) ~= NodeType.Place
3168 p = sn.rtnodes((ind-1)*R + r, (jnd-1)*R + s);
3175 rec.mode = 0; % arrival, not a transition mode
3176 Svec = zeros(NS, 1);
3177 Svec(phOff(jnd, s) + 1) = Svec(phOff(jnd, s) + 1) + 1;
3179 rec.enSlot = []; rec.enW = [];
3180 rec.inhSlot = []; rec.inhThr = [];
3181 rec.baseRate = lambda * p; % Poisson thinning by
the routing prob
3182 rec.nservers = 1; % constant propensity = baseRate
3183 rec.weight = 1; rec.prio = 1;
3184 rx(end+1) = rec; %#ok<AGROW>
3185 producers{ind, r}(end+1) = numel(rx);
3189 line_error(mfilename, sprintf(
'Source %s class %d does not route to any Place; the NRM SPN path needs a Source->Place arc.', sn.nodenames{ind}, r));
3196 line_error(mfilename,
'Stochastic Petri net has no timed reaction; nothing to simulate.');
3199% Initial marking: token counts per (place,
class), read straight off
the
3200% initial state as
the marginal population of each Place.
3201nvec0 = zeros(NS, 1);
3204 if sn.nodetype(ind) ~= NodeType.Place || ~sn.isstateful(ind)
3207 state_i = state{sn.nodeToStateful(ind)};
3208 [~, nir] = State.toMarginalAggr(sn, ind, state_i);
3211 line_error(mfilename,
'Infinite marking at a Place is not supported.');
3213 nvec0(phOff(ind, c) + 1) = nir(c);
3217maxImmSteps = 100000; % livelock guard
for the vanishing-marking collapse
3219% Finite-capacity Place DROP enforcement. A Place with a finite per-
class
3220% capacity (sn.classcap) or total capacity (sn.cap) loses any arriving token
3221% that would exceed it (JMT/CTMC loss semantics: an M/M/1/1 Place with cap 1
3222% holds mean 0.333 at rho=0.5, not
the unbounded-M/M/1 value 1.0). Without this
3223%
the deposit nvec+Svec accumulates tokens past capacity. Precompute
the per-slot
3224% per-class caps,
the per-place total caps, and each reaction's deposited slots
3225% so
the clamp in
the loop touches only what just grew. Mirrors
the Python native
3226% _solver_ssa_nrm_spn.
3227pcapSlot = inf(NS, 1); % per-(place,class) slot cap
3228placeTotalCaps = cell(0, 2); % {totalCap, slotVec} per capped place
3230 if sn.nodetype(ind) ~= NodeType.Place || ~sn.isstateful(ind)
3233 ist = sn.nodeToStation(ind);
3234 slotsHere = zeros(1, R);
3236 slot = phOff(ind, c) + 1;
3237 slotsHere(c) = slot;
3238 if ist <= size(sn.classcap, 1)
3239 cc = sn.classcap(ist, c);
3241 pcapSlot(slot) = cc;
3246 if ist <= numel(sn.cap)
3250 placeTotalCaps(end+1, :) = {tcap, slotsHere}; %#ok<AGROW>
3253hasPlaceCaps = any(isfinite(pcapSlot)) || ~isempty(placeTotalCaps);
3254depSlots = cell(1, nR);
3256 depSlots{k} = find(rx(k).Svec > 0);
3259% ---------------------------------------------------------------------
3260% Next-Reaction Method run loop
3261% ---------------------------------------------------------------------
3262nvec = spnCollapse(nvec0, imm, maxImmSteps);
3264 nvec = applyPlaceCaps(nvec, (1:NS)
', pcapSlot, placeTotalCaps);
3268 Ak(k) = spnProp(nvec, rx(k));
3270Pk = -log(rand(1, nR));
3272tau = (Pk - Tk) ./ Ak;
3275QN = zeros(M, K); UN = zeros(M, K); RN = zeros(M, K);
3276TN = zeros(M, K); CN = zeros(1, K); XN = zeros(1, K);
3282 [dt, kfire] = min(tau);
3284 line_error(mfilename,
'Deadlock: no transition is enabled. Quitting nrm method.');
3286 totalTime = totalTime + dt;
3288 % Time-average accumulators over
the sojourn dt. A Place
is an INF station,
3289 % so its utilization
is its mean token
count (
the SPN convention
the CTMC
3290 % analyzer reports). Its throughput
is the summed firing rate of
the modes
3291 % consuming from it.
3293 ind = sn.stationToNode(ist);
3295 tokens = classPop(nvec, phOff, nph, ind, c);
3296 QN(ist, c) = QN(ist, c) + tokens * dt;
3297 UN(ist, c) = UN(ist, c) + tokens * dt;
3299 cons = consumers{ind, c};
3300 for a = 1:numel(cons)
3301 depr = depr + Ak(cons(a));
3303 % A Source station has no consuming transition; its throughput
is the
3304 % aggregate arrival rate it injects (producers), so
the reference
3305 % station reports
the open-
class arrival rate as its throughput.
3306 prod = producers{ind, c};
3307 for a = 1:numel(prod)
3308 depr = depr + Ak(prod(a));
3310 TN(ist, c) = TN(ist, c) + depr * dt;
3314 % Fire
the selected timed mode (single atomic firing), then collapse any
3315 % immediate transitions
the new marking enabled. A finite-capacity DROP Place
3316 % loses any token
the firing pushed above its capacity, before
the immediate
3317 % cascade sees
the new marking.
3318 nvec = nvec + rx(kfire).Svec;
3320 nvec = applyPlaceCaps(nvec, depSlots{kfire}, pcapSlot, placeTotalCaps);
3322 nvec = spnCollapse(nvec, imm, maxImmSteps);
3324 % Advance
the Gibson & Bruck clocks with
the pre-firing propensities, then
3325 % refresh every propensity from
the new marking. A firing plus its
3326 % immediate cascade can change any place, so every reaction
is refreshed
3327 % rather than a dependency subset --
the SPN reaction
count is small and
3328 %
this removes any dependency-graph blind spot.
3331 Ak(k) = spnProp(nvec, rx(k));
3333 Pk(kfire) = Pk(kfire) - log(rand);
3334 tau = (Pk - Tk) ./ Ak;
3338 if isfield(options,
'verbose') && options.verbose && mod(n, 1e3) == 0 && ~batchStartupOptionUsed
3339 line_printf(
'\b\b\b\b\b\b\b\b\b%9d', n);
3342if isfield(options,
'verbose') && options.verbose
3347 QN = QN / totalTime;
3348 UN = UN / totalTime;
3349 TN = TN / totalTime;
3352 XN(1, c) = TN(sn.refstat(c), c);
3355 RN(ist, c) = QN(ist, c) / TN(ist, c);
3359 CN(1, c) = NK(c) / XN(1, c);
3362QN(isnan(QN)) = 0; UN(isnan(UN)) = 0; RN(isnan(RN)) = 0;
3363XN(isnan(XN)) = 0; TN(isnan(TN)) = 0; CN(isnan(CN)) = 0;
3366function rec = spnEmptyRx()
3367% Prototype record
for a transition-mode reaction, so
struct arrays stay
3368% homogeneous (MATLAB
requires identical fields to concatenate).
3369rec =
struct(
'node', 0,
'mode', 0,
'Svec', [],
'enSlot', [],
'enW', [], ...
3370 'inhSlot', [],
'inhThr', [],
'baseRate', 0,
'nservers', 1, ...
3371 'weight', 1,
'prio', 1);
3374function rec = spnBuildMode(sn, ind, m, phOff, NS)
3375% Assemble
the reaction record of transition IND mode M. Enabling/firing/
3376% inhibiting are (nnodes x nclasses) matrices; find() gives linear indices
3377% p+(c-1)*nnodes that decode to
the (place, class) whose slot
is phOff(p,c)+1.
3384enSlot = []; enW = [];
3388 [p, c] = ind2sub([sn.nnodes, R], li(t));
3389 slot = phOff(p, c) + 1;
3390 enSlot(end+1) = slot; %#ok<AGROW>
3391 enW(end+1) = en(li(t)); %#ok<AGROW>
3392 Svec(slot) = Svec(slot) - en(li(t));
3397 [p, c] = ind2sub([sn.nnodes, R], lf(t));
3398 slot = phOff(p, c) + 1;
3399 Svec(slot) = Svec(slot) + fir(lf(t));
3401inhSlot = []; inhThr = [];
3402inh = np.inhibiting{m};
3403lh = find(~isinf(inh));
3405 [p, c] = ind2sub([sn.nnodes, R], lh(t));
3406 inhSlot(end+1) = phOff(p, c) + 1; %#ok<AGROW>
3407 inhThr(end+1) = inh(lh(t)); %#ok<AGROW>
3410rec.enSlot = enSlot; rec.enW = enW;
3411rec.inhSlot = inhSlot; rec.inhThr = inhThr;
3412% Exponential firing rate:
the single-phase completion rate sum(D1). A
3413% non-exponential firing distribution
is rejected by
the featset and must not
3415if np.timing(m) ~= TimingStrategy.IMMEDIATE
3416 fK = np.firingphases(m);
3417 if isnan(fK) || fK ~= 1 || isempty(np.firingproc{m})
3418 line_error(mfilename, sprintf('Transition %s mode %d has non-exponential firing, which
the NRM SPN path does not support.', sn.nodenames{ind}, m));
3420 D1 = np.firingproc{m}{2};
3421 rec.baseRate = sum(D1(:));
3423ns = np.nmodeservers(m);
3425 ns = GlobalConstants.MaxInt();
3428rec.weight = np.fireweight(m);
3429rec.prio = np.firingprio(m);
3432function d = spnEnDegree(nvec, rx)
3433% Enabling degree of a mode:
the number of concurrent firings
the marking
3434% supports, min over input arcs of floor(tokens/weight), zeroed by any active
3435% inhibitor arc. A mode with no input arc
is treated as single-degree.
3436for i = 1:numel(rx.inhSlot)
3437 if nvec(rx.inhSlot(i)) >= rx.inhThr(i)
3442if isempty(rx.enSlot)
3447for i = 1:numel(rx.enSlot)
3448 d = min(d, floor(nvec(rx.enSlot(i)) / rx.enW(i)));
3452function a = spnProp(nvec, rx)
3453% Propensity of a timed mode:
the exponential rate times
the effective number
3454% of servers, min(enabling degree, mode servers). Single-server modes therefore
3455% fire at their rate whenever enabled, infinite/k-server modes at
the rate
3456% scaled by
the enabling degree.
3457d = spnEnDegree(nvec, rx);
3458eff = min(d, rx.nservers);
3462 a = rx.baseRate * eff;
3466function nvec = applyPlaceCaps(nvec, deposited, pcapSlot, placeTotalCaps)
3467% Drop tokens a firing pushed above a Place per-class or total capacity. Only
the
3468% just-deposited slots (Svec > 0) can overflow, so
the clamp
is local. Mirrors
the
3469% Python native _apply_place_caps.
3470for a = 1:numel(deposited)
3472 if nvec(j) > pcapSlot(j)
3473 nvec(j) = pcapSlot(j);
3476for p = 1:size(placeTotalCaps, 1)
3477 tcap = placeTotalCaps{p, 1};
3478 slots = placeTotalCaps{p, 2};
3479 excess = sum(nvec(slots)) - tcap;
3481 for a = 1:numel(deposited)
3486 if any(slots == j) && nvec(j) > 0
3487 d = min(excess, nvec(j));
3488 nvec(j) = nvec(j) - d;
3489 excess = excess - d;
3496function nvec = spnCollapse(nvec, imm, maxsteps)
3497% Vanishing-marking elimination. Fire enabled immediate transitions until
the
3498% marking
is tangible: highest firing priority first, ties resolved in
3499% proportion to firing weight. Immediate firings take zero time and advance no
3500% clock, so
the timed race only ever samples from tangible markings.
3507 for m = 1:numel(imm)
3508 if spnEnDegree(nvec, imm(m)) >= 1
3509 enabled(end+1) = m; %#ok<AGROW>
3515 prios = zeros(1, numel(enabled));
3516 for i = 1:numel(enabled)
3517 prios(i) = imm(enabled(i)).prio;
3519 top = enabled(prios == max(prios)); % larger firing priority = more urgent
3523 w = zeros(1, numel(top));
3524 for i = 1:numel(top)
3525 w(i) = imm(top(i)).weight;
3527 pick = top(spnWeightedDraw(w));
3529 nvec = nvec + imm(pick).Svec;
3532 line_error(mfilename,
'Immediate-transition livelock: the vanishing-marking collapse did not reach a tangible marking.');
3537function idx = spnWeightedDraw(w)
3538% Index drawn in proportion to
the nonnegative weight vector W.
3545idx = find(c > rand, 1);