Source code for line_solver.solvers.solver_ln.solver_ln

"""
Native Python implementation of Layered Network (LN) solver.

This implementation provides 100% parity with the MATLAB SolverLN implementation,
using the same layer-based decomposition with MVA solvers for each layer.

The architecture mirrors MATLAB's EnsembleSolver pattern:
1. Build layer submodels (Network objects) using buildLayersRecursive
2. Iterate until convergence using the EnsembleSolver pattern
3. Update metrics, think times, layers, and routing probabilities
4. Aggregate results using getEnsembleAvg

Pure Python implementation.
"""

import numpy as np
import pandas as pd
from typing import Optional, Dict, Any, List, Tuple, Callable, Union, Set
from dataclasses import dataclass, field
from ...constants import default_verbose
from enum import IntEnum
import copy
import os

# Import LINE network elements
from ...lang.network import Network
from ...lang.nodes import Queue, Delay, Source, Sink, Fork, Join, Router, Cache
from ...lang.classes import ClosedClass, OpenClass
from ...distributions import Exp, Immediate, Disabled
from ...constants import SchedStrategy, GlobalConstants
from ...lang.base import ReplacementStrategy
from ...api.sn.compat_rate import sn_compat_scaling
from ...api.io.logging import line_debug, line_warning
from ...layered import _call_count_dist
from ..base import EnsembleSolver


def _polyak_avg(prev, raw, k):
    """Running-mean update m = prev + (raw - prev)/k, robust to NaN entries in
    either operand (a NaN sample leaves the average untouched)."""
    if prev is None:
        return None if raw is None else np.array(raw, dtype=float, copy=True)
    prev = np.asarray(prev, dtype=float)
    if raw is None or np.shape(raw) != np.shape(prev):
        return prev
    raw = np.asarray(raw, dtype=float)
    m = prev + (raw - prev) / k
    bad = np.isnan(m)
    m[bad] = raw[bad]
    bad = np.isnan(m)
    m[bad] = prev[bad]
    return m


class LayeredNetworkElement(IntEnum):
    """Element types in layered queueing networks (matches MATLAB enum values)."""
    PROCESSOR = 0
    TASK = 1
    ENTRY = 2
    ACTIVITY = 3
    CALL = 4


class CallType(IntEnum):
    """Types of calls between entries."""
    SYNC = 1
    ASYNC = 2
    FWD = 3


from ...api.lqn import call_hashname, entry_workflow, ph_moments, serial_law
from ...distributions import APH, PH
from ...lang.workflow import Workflow

#: Method names that state the ensemble's LAYERING and ENCODING. They are the
#: vocabulary of SolverLN alone; a layer solver cannot dispatch on them.
_LN_LEVEL_METHODS = frozenset((
    'srvn', 'srvn.ph', 'srvn.cs', 'srvncs', 'ph', 'cs',
    'flat', 'flat.cs', 'flatcs', 'flat.ph', 'flatph', 'squashed', 'squashed.ph',
    'moment3', 'nlp', 'qdamva', 'qd-amva',
))


def ln_requested_method(method) -> str:
    """Normalise a SolverLN method name onto one the solver dispatches on.

    A method name carries TWO decisions: the LAYERING, which fixes what a
    submodel is, and the ENCODING, which fixes how an activity graph is written
    into it. 'srvn.cs' encodes the activity graph as ROUTING, 'srvn.ph' as a
    composed phase-type server law, 'srvn' is the alias that takes 'srvn.ph'
    where it can serve the model and 'srvn.cs' otherwise, 'flat.cs' squashes
    every server into one submodel with the routing encoding, 'flat.ph' squashes
    them with the composed one ('flat' is the alias of 'flat.cs' and resolves
    unconditionally rather than probing 'flat.ph', because a model is squashed in
    order to express what only the routing encoding carries), and 'moment3' is
    the three-moment distribution pass over the routing layers. 'default' is the srvn alias, so a
    model solved without naming a method takes the better of the two srvn
    encodings; an unrecognised token takes 'srvn.cs'.

    'nlp' names NO layering at all: it states the whole model as one nonlinear
    program over the QD-AMVA laws and roots it, so no submodel is built and no
    fixed point is driven between them. See solver_ln_nlp_analyzer.
    """
    if not method or not isinstance(method, str):
        return 'srvn'
    m = method.lower()
    if m in ('nlp', 'qdamva', 'qd-amva'):
        return 'nlp'
    if m in ('srvn.ph', 'ph'):
        return 'srvn.ph'
    if m in ('srvn.cs', 'srvncs', 'cs'):
        return 'srvn.cs'
    if m in ('srvn', 'default', 'auto', ''):
        return 'srvn'
    if m in ('flat.cs', 'flatcs', 'flat', 'squashed'):
        return 'flat.cs'
    if m in ('flat.ph', 'flatph', 'squashed.ph'):
        return 'flat.ph'
    if m == 'moment3':
        return 'moment3'
    # An unrecognised token takes the routing encoding, which is what every name
    # other than 'moment3' resolved to before the alias existed.
    return 'srvn.cs'


# CallType, as the struct stores it: 1=SYNC, 2=ASYNC, 3=FWD
_SYNC = 1
_ASYNC = 2
_FWD = 3


def _alpha_of(dist) -> np.ndarray:
    """Initial vector of a phase-type distribution, as a row."""
    return np.asarray(dist.getInitProb(), dtype=float).reshape(1, -1)


def _subgen_of(dist) -> np.ndarray:
    """Subgenerator of a phase-type distribution: D0 of its (D0, D1) pair."""
    return np.asarray(dist.getRepresentation()[0], dtype=float)


class PHLayer:
    """One two-station layer, and the caller classes that cycle through it."""

    def __init__(self):
        self.idx: int = 0
        self.ishost: bool = False
        self.callers: List[int] = []
        self.class_of_caller: Dict[int, int] = {}
        self.nreplicas: int = 1
        self.qstations: List[int] = []
        self.svcmean_by_class: Dict[int, float] = {}
        # entries are (class index, entry index) or (class index, -call index)
        self.open_arrivals: List[Tuple[int, int]] = []
        # Closed population of the MODEL this server sits in. Under 'flat.ph'
        # that is every caller of the single network, not only the callers of
        # this one station, so it is recorded here rather than recomputed.
        self.npop: float = 0.0


class OptionsDict(dict):
    """A dict that supports attribute-style access."""
    def __getattr__(self, name):
        try:
            return self[name]
        except KeyError:
            raise AttributeError(f"'OptionsDict' object has no attribute '{name}'")

    def __setattr__(self, name, value):
        self[name] = value

    def __delattr__(self, name):
        try:
            del self[name]
        except KeyError:
            raise AttributeError(f"'OptionsDict' object has no attribute '{name}'")


@dataclass
class SolverLNOptions:
    """Options for the native LN solver (matches MATLAB SolverLN.defaultOptions)."""
    method: str = 'default'
    iter_max: int = 200  # MATLAB default for LN
    iter_tol: float = 5e-3  # MATLAB default for LN (looser than default for LQN models)
    verbose: bool = field(default_factory=default_verbose)
    tol: float = 1e-4  # MATLAB SolverOptions LN case; JAR SolverOptions.java:379, cpp solver_ln.h:228
    seed: Optional[int] = None  # base seed for stochastic layer solvers; randomized when not set
    # Transient window [t0, t1] for getTranAvg; propagated to layer solvers only
    # around the transient call (SolverENV over an LQN stage sets this).
    timespan: Optional[Any] = None
    # 'python' (native), 'java' (delegate the layered solve to jline.jar via
    # JSON) or 'cpp' (delegate it to line-cli via the .lqnx interchange, since
    # the C++ port has no LQN JSON reader); env LINE_SOLVER_LANG overrides the
    # default.
    lang: str = field(default_factory=lambda: os.environ.get('LINE_SOLVER_LANG', 'python'))
    # Arithmetic backend, lang='cpp' ONLY: 'double' (default), 'exact' or
    # 'real:<digits>'. The other langs are IEEE double throughout, so it is left
    # None and line-cli is invoked without --arith unless the caller sets it. The
    # layer solver must be MVA, since the C++ fluid layers are double-only. USE
    # 'real:<digits>' AND NOT 'exact' HERE: rational arithmetic grows the
    # coefficients unboundedly along an outer fixed point, so an exact layered
    # solve does not terminate in practice, while real:64 costs 4x double.
    arith: Optional[str] = None

    # Config options (matches MATLAB options.config)
    config: OptionsDict = field(default_factory=lambda: OptionsDict({
        'interlocking': True,
        # Layering strategy: 'srvn' places each server in its own submodel,
        # 'flat' places every processor and task in a single submodel
        'layering': 'srvn',
        'relax': 'fixed',  # 'none', 'fixed', 'adaptive', 'auto' - matches LQNS default
        'relax_factor': 0.5,  # under-relaxation factor
        'relax_min': 0.1,  # MATLAB default
        'relax_history': 5,  # MATLAB default
        # stochastic iteration options for simulation/MC layer solvers; see _kb/06-solver-catalog.md LN Convergence test.
        'stochiter': 'auto',        # 'auto' | 'rm' | 'crn' | 'off'
        'stochiter_alpha': 0.6,     # Robbins-Monro step decay exponent, in (0.5,1]
        'stochiter_a0': 1.0,        # Robbins-Monro initial step after burn-in
        'stochiter_burnin': 5,      # Picard burn-in iterations before step decay starts
        'stochiter_conseq': 3,      # consecutive sub-tolerance iterations required to stop
        # method='nlp' only; see solver_ln_nlp_analyzer. `lld` declares a smooth
        # queue-dependent rate multiplier per station, {name: expression in n};
        # `nlp_alpha` sharpens the softmin standing in for min(n, m);
        # `nlp_warm` sets where the warm-start ladder begins; `nlp_maxiter` is
        # the budget of the descent the root-find falls back to; `nlp_source`
        # also writes the program out, for inspection.
        'lld': None,
        'nlp_alpha': 20.0,
        'nlp_warm': 0,
        'nlp_maxiter': 3000,
        'nlp_source': None,
        # 'nlp' and 'mol' seed the LAYERED fixed point instead of starting it
        # from the bare host demands: 'nlp' from the QD-AMVA program's solution,
        # 'mol' from lqn_mol, the Method of Layers on the SRVN decomposition.
        # Neither is a method, both are starting points, so they compose with the
        # layered methods and not with method='nlp' itself. `iter_min` does not
        # apply to a warm-started iteration: the floor exists to keep a cold
        # iterate from calling an early plateau convergence, and a warm one
        # starts past that. See converged().
        'warmstart': None,
        # read by the 'mol' seed alone. Its tolerance is ITS OWN and not the
        # layered run's: a seed handed over at LN's 5e-3 is still two digits out.
        'mol_iter_max': 200,
        'mol_iter_tol': 1e-6,
        'mol_relax_factor': 0.5,
    }))

    def __post_init__(self):
        """Fill in the config entries the caller did not name.

        A CALLER'S CONFIG NAMES WHAT IT CHANGES, NOT THE WHOLE TABLE. The
        dataclass default only applies when the field is omitted, so passing
        config={'layering': 'flat'} used to replace the defaults wholesale and
        leave `interlocking`, `relax` and the rest to whatever fallback each
        read site happened to spell, quietly turning interlocking off. None
        means the same as omitting it.
        """
        base = SolverLNOptions.__dataclass_fields__['config'].default_factory()
        if self.config:
            base.update(self.config)
        self.config = base


def _region_capable_layer_solver(model):
    """
    Pick the first solver whose feature set covers a layer carrying an admission
    constraint. The order is by decreasing accuracy: CTMC is exact but
    state-space bound, LDES and SSA simulate. Selection is by supports() so it
    self-corrects if another solver later declares Region.
    """
    from ..solver_ctmc.solver_ctmc import SolverCTMC
    from ..solver_ssa.solver_ssa import SolverSSA
    from ..wrappers.solver_ldes.solver_ldes import SolverLDES
    for ctor in (SolverCTMC, SolverLDES, SolverSSA):
        if ctor.supports(model):
            return ctor(model, verbose=False)
    raise ValueError(f"LN layer {model.getName()} carries an admission constraint but none of "
                     f"SolverCTMC, SolverLDES, SolverSSA supports it. Supply a layer solver "
                     f"factory explicitly.")


def _layer_dep_handle(f, cols, nclasses, layer_model):
    """
    Lift a service-rate dependence handle declared on a LayeredNetwork server to
    the layer station that represents it. F maps the per-operand population vector
    of that server to a scalar scaling shared by every operand or to a per-operand
    vector; COLS[j] lists the layer classes (1-based) through which operand j
    occupies the station.

    Solvers evaluate the handle in two different index spaces: CTMC and the exact
    recursions pass a per-class vector, while the AMVA and NC chain recursions
    pass a per-chain vector. The handle therefore reads len(n) to pick the space,
    aggregates the operand populations in it, and answers a vector of the SAME
    length, since the caller indexes the answer with the index it passed in. An
    index belonging to no operand keeps the neutral scaling 1.
    """
    chain_cols = {}

    def handle(n):
        n = np.atleast_1d(np.asarray(n, dtype=float)).ravel()
        idx = cols
        if n.size != nclasses:
            if n.size not in chain_cols:
                chain_cols[n.size] = _layer_chain_cols(cols, layer_model, n.size)
            idx = chain_cols[n.size]
        nop = np.array([float(np.sum(n[[c - 1 for c in idx[j]]])) if idx[j] else 0.0
                        for j in range(len(idx))])
        w = np.atleast_1d(np.asarray(f(nop), dtype=float)).ravel()
        v = np.ones(n.size)
        for j in range(len(idx)):
            for c in idx[j]:
                v[c - 1] = w[min(j, w.size - 1)]
        return v
    return handle


def _layer_chain_cols(cols, layer_model, nchains):
    """Operand columns of a layer station in the chain index space."""
    sn = layer_model.getStruct()
    chains = np.atleast_2d(np.asarray(sn.chains, dtype=float))
    out = []
    for cols_of_operand in cols:
        ch = set()
        for c in cols_of_operand:
            for k in np.where(chains[:, c - 1] > 0)[0]:
                if k + 1 <= nchains:
                    ch.add(int(k) + 1)
        out.append(sorted(ch))
    return out


def _layer_peak(peak_per_operand, cols, nclasses):
    """Spread a per-operand peak rate scaling onto the classes of the layer station."""
    peak_per_operand = np.atleast_1d(np.asarray(peak_per_operand, dtype=float)).ravel()
    peak = np.ones(nclasses)
    for j in range(len(cols)):
        pj = peak_per_operand[min(j, peak_per_operand.size - 1)]
        for c in cols[j]:
            peak[c - 1] = pj
    return peak


[docs] class SolverLN(EnsembleSolver): """ Native Python Layered Network (LN) solver. This implementation matches MATLAB's SolverLN at 100% parity: - Uses the same layer decomposition algorithm (buildLayersRecursive) - Creates Network objects for each layer with proper classes and routing - Uses MVA solvers for each layer - Implements the same fixed-point iteration with convergence testing The algorithm:: 1. Build layer submodels: one per processor (host layer) and per task 2. Initialize service demands and think times from LQN structure 3. Iterate until convergence: a. Solve each layer using MVA b. Update service times based on lower-layer response times c. Update think times based on caller waiting times d. Update routing probabilities based on throughputs e. Check convergence 4. Aggregate results from all layers ``options.method='nlp'`` takes none of those steps. It states the whole model as ONE nonlinear program over the QD-AMVA laws and roots it, so no submodel is built and no fixed point is driven between them; see ``solver_ln_nlp_analyzer``. It is served natively and refuses a delegated ``options.lang``. ``options.lang`` delegates the whole layered solve instead: ``'java'`` to ``jline.jar`` over JSON, ``'cpp'`` to the C++ ``line-cli`` over the ``.lqnx`` interchange (steady state only, and it refuses what that interchange cannot carry -- see ``solvers/cpp_dispatch.py``). Either way the native fixed point never runs and ``options.arith`` selects the C++ arithmetic backend. Args: model: LayeredNetwork model solver_factory: Optional factory function to create layer solvers options: Solver options **kwargs: Additional options """ def __init__(self, model, solver_factory_or_options=None, options=None, **kwargs): self.model = model self._result = None # Parse options (matches MATLAB signature handling) self._parse_options(solver_factory_or_options, options, kwargs) # Layer structures (matches MATLAB SolverLN properties) self.ensemble: List[Network] = [] # Network objects for each layer self.solvers: List[Any] = [] # Solver instances for each layer self.nlayers: int = 0 self.lqn = None # LayeredNetworkStruct # True once the program's solution has been written into the iterate; # read by converged(), which drops iter_min for such a run. self.warmstarted: bool = False # Index mappings (matches MATLAB) self.idxhash: np.ndarray = None # Maps LQN indices to layer indices self.hostLayerIndices: List[int] = [] self.taskLayerIndices: List[int] = [] # Job counts for interlocking self.njobs: np.ndarray = None self.njobsorig: np.ndarray = None # Update maps (populated by buildLayersRecursive) self.servt_classes_updmap: np.ndarray = None self.thinkt_classes_updmap: np.ndarray = None self.actthinkt_classes_updmap: np.ndarray = None self.arvproc_classes_updmap: np.ndarray = None self.call_classes_updmap: np.ndarray = None self.route_prob_updmap: np.ndarray = None self.unique_route_prob_updmap: np.ndarray = None # Reset indices self.routereset: List[int] = [] self.svcreset: List[int] = [] # Replication tracking (matches MATLAB singleReplicaTasks) self.single_replica_tasks: List[int] = [] # Metric arrays self.util: np.ndarray = None self.tput: np.ndarray = None self.tputproc: List = None self.servt: np.ndarray = None self.residt: np.ndarray = None self.servtproc: List = None self.servtcdf: List = None self.thinkt: np.ndarray = None self.thinkproc: List = None self.thinktproc: List = None self.entryproc: List = None # method='moment3': True once the moment-based entry-law pass has run. self.moment_pass_done: bool = False self.entrycdfrespt: List = None self.callresidt: np.ndarray = None # per-entry service time resolved by the servtmatrix solve, kept for # inspection exactly as MATLAB's SolverLN keeps it self.entry_servt: np.ndarray = None self.callservt: np.ndarray = None self.callservtproc: List = None self.callservtcdf: List = None self.ignore: np.ndarray = None # Service matrix for entry service time calculation self.servtmatrix: np.ndarray = None # Caller probability tracking self.ptaskcallers: np.ndarray = None self.ptaskcallers_step: List = None self.ilscaling: np.ndarray = None # Interlock path tables of Franks (1999), Ch. 4 (built once at init) self.il_table_all: np.ndarray = None # (nentries x nentries) reachability, all phases self.il_table_ph1: np.ndarray = None # (nentries x nentries) reachability, phase-1 only self.il_common_entries: list = None # common parent entry abs-indices per server self.il_source_tasks_all: list = None # all-phase source tasks per server self.il_source_tasks_ph2: list = None # phase-2 source tasks per server self.il_num_sources: np.ndarray = None # total source multiplicity per server # Convergence tracking self.hasconverged: bool = False self.averagingstart: int = None self.maxitererr: List[float] = [] self.results: List[List[Dict]] = [] # Under-relaxation state self.relax_omega: float = 1.0 self.relax_err_history: List[float] = [] self.servt_prev: np.ndarray = None self.residt_prev: np.ndarray = None self.tput_prev: np.ndarray = None self.thinkt_prev: np.ndarray = None self.callservt_prev: np.ndarray = None self.callresidt_prev: np.ndarray = None # Stochastic iteration (Robbins-Monro / Polyak-Ruppert) state, used # when one or more layer solvers return noisy estimates self.stochiter_mode: str = None # resolved mode: 'rm' | 'crn' | 'off' self.stochiter_auto: bool = False # True if mode was resolved from 'auto' self.stochiter_start: int = None # iteration at which RM averaging started self.stochiter_seed_base: int = None # base seed for layer seed control self.stochlayers: np.ndarray = None # bool per layer: solver is stochastic self.stoch_avg: List[Dict] = None # Polyak-Ruppert averages of layer results self.stoch_avg_count: int = 0 # iterations accumulated into stoch_avg self.stoch_servt_avg: np.ndarray = None # Polyak-Ruppert average of the servt iterate self.stoch_residt_avg: np.ndarray = None # Polyak-Ruppert average of the residt iterate # Phase-2 support self.hasPhase2: bool = False self.servt_ph1: np.ndarray = None self.servt_ph2: np.ndarray = None self.util_ph1: np.ndarray = None self.util_ph2: np.ndarray = None self.prOvertake: np.ndarray = None # Extract LQN structure and construct layers self._extract_lqn_structure() self._construct() def _parse_options(self, solver_factory_or_options, options, kwargs): """Parse options handling MATLAB-style signatures.""" from ..solver_mva.solver_mva import SolverMVA self.solver_factory = None if solver_factory_or_options is None: # Check kwargs for method parameter method = kwargs.get('method', 'default') if isinstance(method, str): method = method.lower() elif callable(solver_factory_or_options) and not isinstance(solver_factory_or_options, SolverLNOptions): # a solver class (SolverMVA) is as valid a factory as a lambda, and # MATLAB's @SolverMVA maps onto the class, so both must forward the # third argument; excluding types dropped `options` silently self.solver_factory = solver_factory_or_options if options is not None: if hasattr(options, 'get') and not hasattr(options, 'method'): method = options.get('method', 'default') else: method = getattr(options, 'method', 'default') # An options object passed alongside a factory carries the same # fields as one passed on its own; forwarding only `method` # would silently drop config entries such as `layering`. # lang/arith are forwarded with the rest: a factory says which # solver runs each layer, not which engine runs the ensemble, so # dropping them would silently ignore a requested lang='cpp'. for _f in ('iter_max', 'iter_tol', 'verbose', 'tol', 'config', 'lang', 'arith'): if hasattr(options, _f): _v = getattr(options, _f) if _v is not None: kwargs.setdefault(_f, _v) elif hasattr(options, 'get'): _v = options.get(_f, None) if _v is not None: kwargs.setdefault(_f, _v) else: # honor a method passed as a keyword argument alongside a factory method = kwargs.get('method', 'default') if isinstance(method, str): method = method.lower() elif isinstance(solver_factory_or_options, str): method = solver_factory_or_options.lower() elif hasattr(solver_factory_or_options, 'get'): method = solver_factory_or_options.get('method', 'default') if 'verbose' in solver_factory_or_options: kwargs.setdefault('verbose', solver_factory_or_options['verbose']) if 'iter_max' in solver_factory_or_options: kwargs.setdefault('iter_max', solver_factory_or_options['iter_max']) elif isinstance(solver_factory_or_options, SolverLNOptions): # Handle SolverLNOptions dataclass - extract all relevant attributes method = solver_factory_or_options.method kwargs.setdefault('iter_max', solver_factory_or_options.iter_max) kwargs.setdefault('iter_tol', solver_factory_or_options.iter_tol) kwargs.setdefault('verbose', solver_factory_or_options.verbose) kwargs.setdefault('tol', solver_factory_or_options.tol) kwargs.setdefault('config', solver_factory_or_options.config) kwargs.setdefault('lang', solver_factory_or_options.lang) kwargs.setdefault('arith', solver_factory_or_options.arith) elif hasattr(solver_factory_or_options, 'method'): method = getattr(solver_factory_or_options, 'method', 'default') else: method = 'default' # NC does not handle LQN class-switching layer structure; always use MVA for layer solving. if self.solver_factory is None: if method == 'nc': import warnings warnings.warn( "NC method for SolverLN is not fully supported in native Python. " "Falling back to MVA for layer solving. Use the default method for " "correct results.", UserWarning ) # MVA handles LQN layer models correctly; LN iter_tol=5e-3 is forwarded. self.solver_factory = lambda m: ( _region_capable_layer_solver(m) if getattr(m.getStruct(), 'nregions', 0) > 0 else SolverMVA(m, self._layer_options(), verbose=False) ) elif isinstance(self.solver_factory, type): # a bare solver class carries no options of its own, so the layer # solver is given the LN options exactly as the default factory does _cls = self.solver_factory # kept because the lambda hides the class from the feature checks self._layer_solver_cls = _cls self.solver_factory = lambda m: _cls(m, self._layer_options(), verbose=False) kwargs.pop('method', None) self.options = SolverLNOptions(method=method, **kwargs) # 'nlp' is a NATIVE method: the program it roots is built in python and # has no counterpart in the JAR or in line-cli, both of which would # resolve the unknown name onto their own default layered engine and # answer under the requested one. Refuse the pair by name instead. if (ln_requested_method(method) == 'nlp' and getattr(self.options, 'lang', 'python') != 'python'): raise ValueError( "method='nlp' is served by the native python analyzer only; lang='%s' has " "no layered engine that states the model as one nonlinear program, and " "would answer with its own default method under this name. Use " "lang='python'." % getattr(self.options, 'lang', 'python')) warm = self.options.config.get('warmstart', None) if warm is not None: if warm not in ('nlp', 'mol'): raise ValueError( "config['warmstart'] takes 'nlp', 'mol' or None, got %r. 'nlp' " "seeds the layered fixed point with the QD-AMVA program's " "solution and 'mol' with the Method of Layers'; None starts it " "from the model's own host demands." % (warm,)) if getattr(self.options, 'lang', 'python') != 'python': raise ValueError( "config['warmstart']='%s' is built by the native python API only; " "lang='%s' runs its own layered engine, which this solver cannot " "seed. Use lang='python'." % (warm, getattr(self.options, 'lang', 'python'))) if ln_requested_method(method) == 'nlp': raise ValueError( "config['warmstart']='%s' is a starting point for a LAYERED " "method, and method='nlp' runs no iteration to start. Drop one of " "the two." % (warm,)) def _extract_lqn_structure(self): """Extract layered network structure from model.""" if hasattr(self.model, 'getStruct'): self.lqn = self.model.getStruct() else: raise ValueError("Model must be a LayeredNetwork with getStruct() method") # Normalize structure format self._normalize_lqn_structure() # forwarding rewritten as caller-side pseudo rendezvous; see _kb/06-solver-catalog.md LN Forwarding as caller-side pseudo-rendezvous. self._apply_forwarding_rendezvous() # Detect and initialize phase-2 support (matches MATLAB SolverLN.m lines 127-137) if (hasattr(self.lqn, 'actphase') and self.lqn.actphase is not None and np.any(self.lqn.actphase > 1)): self.hasPhase2 = True self.servt_ph1 = np.zeros(self.lqn.nidx) self.servt_ph2 = np.zeros(self.lqn.nidx) self.util_ph1 = np.zeros(self.lqn.nidx) self.util_ph2 = np.zeros(self.lqn.nidx) self.prOvertake = np.zeros(self.lqn.nentries) else: self.hasPhase2 = False def _apply_forwarding_rendezvous(self): """Forwarding transformation of Franks (1999), Sec. 3.3.1 and Fig. 3.8. Each forwarding chain reachable from a synchronous call is reconnected to the client that issued the original rendezvous, as a pseudo rendezvous (SYNC) call whose mean is the original call mean times the product of the forwarding probabilities along the path. One level of servers disappears from the layering and the forwarded workload is carried by ordinary SYNC call classes, so layer construction, think times, populations and the interlock analysis all see plain rendezvous arcs. As the thesis notes, the pseudo arcs are excluded from the slice times and from the overtaking and interlock probabilities. FWD calls remain in the struct but no longer contribute blocking anywhere in SolverLN. Asynchronous calls into a forwarding chain are left untouched, since a send-no-reply terminates the chain of blocking.""" lqn = self.lqn if lqn.ncalls == 0 or not np.any(np.asarray(lqn.calltype[:lqn.ncalls]) == CallType.FWD): return ncalls0 = lqn.ncalls for cidx in range(ncalls0): if int(lqn.calltype[cidx]) != CallType.SYNC: continue aidx = int(lqn.callpair[cidx, 0]) tidx = self._get_parent(aidx) base_mean = self._get_call_mean(cidx) if base_mean is None or base_mean <= 0: continue # BFS through the forwarding chain of the sync target frontier = [int(lqn.callpair[cidx, 1])] probs = [1.0] visited_e = [] while frontier: eidx = frontier.pop(0) p_path = probs.pop(0) if eidx in visited_e: continue visited_e.append(eidx) for fcidx in range(ncalls0): if int(lqn.calltype[fcidx]) != CallType.FWD or int(lqn.callpair[fcidx, 0]) != eidx: continue fprob = self._get_call_mean(fcidx) tgt = int(lqn.callpair[fcidx, 1]) pseudo_mean = base_mean * p_path * (fprob or 0.0) target_tidx = self._get_parent(tgt) if pseudo_mean > 0 and target_tidx != tidx: # merge into an existing SYNC call with the same (activity,target) pair, else append a new pseudo SYNC call. # call indices are 0-based here, so 0 is a real call and cannot double as the not-found sentinel (MATLAB/C++ are 1-based and do use 0) mrow = -1 for scan in range(lqn.ncalls): if int(lqn.calltype[scan]) == CallType.SYNC \ and int(lqn.callpair[scan, 0]) == aidx \ and int(lqn.callpair[scan, 1]) == tgt: mrow = scan break if mrow >= 0: newmean = self._get_call_mean(mrow) + pseudo_mean lqn.callpair[mrow, 2] = newmean if isinstance(lqn.callproc, list) and mrow < len(lqn.callproc): lqn.callproc[mrow] = _call_count_dist(newmean) elif isinstance(lqn.callproc, dict): lqn.callproc[mrow] = _call_count_dist(newmean) else: ncall = lqn.ncalls lqn.ncalls = ncall + 1 newrow = np.zeros((1, lqn.callpair.shape[1])) newrow[0, 0] = aidx newrow[0, 1] = tgt newrow[0, 2] = pseudo_mean lqn.callpair = np.vstack([lqn.callpair, newrow]) lqn.calltype = np.append(lqn.calltype, CallType.SYNC) if isinstance(lqn.callproc, list): lqn.callproc.append(_call_count_dist(pseudo_mean)) elif isinstance(lqn.callproc, dict): lqn.callproc[ncall] = _call_count_dist(pseudo_mean) if hasattr(lqn, 'callsof') and isinstance(lqn.callsof, dict): lqn.callsof.setdefault(aidx, []).append(ncall) if hasattr(lqn, 'iscaller') and lqn.iscaller is not None: lqn.iscaller[tidx, target_tidx] = 1 lqn.iscaller[aidx, target_tidx] = 1 lqn.iscaller[tidx, tgt] = 1 lqn.iscaller[aidx, tgt] = 1 if hasattr(lqn, 'issynccaller') and lqn.issynccaller is not None: lqn.issynccaller[tidx, target_tidx] = 1 lqn.issynccaller[aidx, target_tidx] = 1 lqn.issynccaller[tidx, tgt] = 1 lqn.issynccaller[aidx, tgt] = 1 if hasattr(lqn, 'graph') and lqn.graph is not None: lqn.graph[aidx, tgt] = 1 if hasattr(lqn, 'taskgraph') and lqn.taskgraph is not None: lqn.taskgraph[tidx, target_tidx] = 1 # Follow the chain if tgt not in visited_e and tgt not in frontier: frontier.append(tgt) probs.append(p_path * (fprob or 0.0)) def _normalize_lqn_structure(self): """Normalize LQN structure to consistent format.""" lqn = self.lqn # Convert numpy arrays to dicts for mapping attributes if needed for attr in ['tasksof', 'entriesof', 'actsof', 'callsof']: data = getattr(lqn, attr, None) if data is not None and isinstance(data, np.ndarray): result = {} for i in range(len(data)): if data[i] is not None: if hasattr(data[i], '__iter__') and not isinstance(data[i], str): result[i + 1] = list(data[i]) else: result[i + 1] = [data[i]] if data[i] else [] setattr(lqn, attr, result) # Rebuild callsof from callpair if empty if isinstance(lqn.callsof, dict) and len(lqn.callsof) == 0: if hasattr(lqn, 'callpair') and lqn.callpair is not None: for cidx in range(lqn.ncalls): if cidx < lqn.callpair.shape[0]: src_aidx = int(lqn.callpair[cidx, 0]) # source activity in column 0 if src_aidx > 0: if src_aidx not in lqn.callsof: lqn.callsof[src_aidx] = [] lqn.callsof[src_aidx].append(cidx) def _act_thinktime(self, aidx): """Think time of activity aidx, 0.0 when it has none. In series with the activity's host demand and held at its task: the task keeps its thread for the whole hostdem+thinktime interval, so it serializes against the task multiplicity, but the host processor is released for it. Mirrors lqns, whose think-time attribute LINE writes out, and MATLAB lqn_act_thinktime. Read through actthinkproc, which the constructor has already filtered to genuinely positive durations. """ proc = getattr(self, 'actthinkproc', None) if proc is None or aidx >= len(proc): return 0.0 zt = proc[aidx] if zt is None: return 0.0 try: v = zt.getMean() except AttributeError: return 0.0 if v is None or not np.isfinite(v) or v <= 1e-8: return 0.0 return float(v) def _construct(self): """Construct layer models (matches MATLAB construct method).""" lqn = self.lqn # Mark disconnected components to ignore # MATLAB SolverLN.construct lines 169-185: weaklyconncomp(graph'+graph) self.ignore = np.zeros(lqn.nidx, dtype=bool) if hasattr(lqn, 'graph') and lqn.graph is not None: graph = np.asarray(lqn.graph) n = graph.shape[0] # Undirected adjacency for weak connectivity symm = graph + graph.T symm = (symm > 0).astype(int) try: from scipy.sparse.csgraph import connected_components from scipy.sparse import csr_matrix n_components, labels = connected_components( csr_matrix(symm[:n, :n]), directed=False) except ImportError: # Fallback: BFS-based connected components n_components, labels = 0, np.zeros(n, dtype=int) visited = np.zeros(n, dtype=bool) for start in range(n): if not visited[start]: queue = [start] visited[start] = True while queue: node = queue.pop(0) labels[node] = n_components for nbr in range(n): if symm[node, nbr] > 0 and not visited[nbr]: visited[nbr] = True queue.append(nbr) n_components += 1 if n_components > 1: # Find which components contain REF tasks wcc_has_ref = np.zeros(n_components, dtype=bool) for t in range(lqn.ntasks): tidx = lqn.tshift + t if tidx < n and self._is_ref_task(tidx): wcc_has_ref[labels[tidx]] = True # Components with an entry-level open arrival are also workload-anchored if hasattr(lqn, 'arrival') and lqn.arrival: for eidx in lqn.arrival: if lqn.arrival[eidx] is not None and eidx < n: wcc_has_ref[labels[eidx]] = True # Mark all elements in components without REF tasks as ignored for comp in range(n_components): if not wcc_has_ref[comp]: for idx in range(n): if labels[idx] == comp and idx <= lqn.nidx: self.ignore[idx] = True # Initialize internal data structures self.entrycdfrespt = [None] * lqn.nentries self.hasconverged = False self.moment_pass_done = False # Initialize service and think time processes self.servtproc = [None] * lqn.nidx self.thinkproc = [None] * lqn.nidx self.callservtproc = [None] * lqn.ncalls self.tputproc = [None] * lqn.nidx # prefer the full Distribution from lqn.hostdem_proc (keeps SCV/phase-type); fall back to the Exp-fitted scalar mean. hostdem_proc = getattr(lqn, 'hostdem_proc', None) def _servtproc_from(mean_or_dist, idx_abs): proc = None if isinstance(hostdem_proc, dict): proc = hostdem_proc.get(idx_abs) elif hostdem_proc is not None and idx_abs < len(hostdem_proc): proc = hostdem_proc[idx_abs] # zero host-demand activities map to Immediate; check the scalar mean before the fitted proc so a rate-0 proc cannot leak in as service rate 0. if isinstance(mean_or_dist, (int, float)) and float(mean_or_dist) <= 0: return Immediate() if proc is not None and not isinstance(proc, (int, float)): return proc if isinstance(mean_or_dist, (int, float)): return Exp.fit_mean(float(mean_or_dist)) return mean_or_dist if isinstance(lqn.hostdem, dict): for idx, mean_or_dist in lqn.hostdem.items(): if mean_or_dist is not None: self.servtproc[idx] = _servtproc_from(mean_or_dist, idx) else: for idx in range(len(lqn.hostdem)): if lqn.hostdem[idx] is not None: self.servtproc[idx + 1] = _servtproc_from(lqn.hostdem[idx], idx + 1) # Copy think times - convert floats to Exp distributions if isinstance(lqn.think, dict): for idx, mean_or_dist in lqn.think.items(): if mean_or_dist is not None: if isinstance(mean_or_dist, (int, float)): mean_val = float(mean_or_dist) if mean_val <= 0: self.thinkproc[idx] = Immediate() else: self.thinkproc[idx] = Exp.fit_mean(mean_val) else: self.thinkproc[idx] = mean_or_dist else: for idx in range(len(lqn.think)): if lqn.think[idx] is not None: mean_or_dist = lqn.think[idx] if isinstance(mean_or_dist, (int, float)): mean_val = float(mean_or_dist) if mean_val <= 0: self.thinkproc[idx + 1] = Immediate() else: self.thinkproc[idx + 1] = Exp.fit_mean(mean_val) else: self.thinkproc[idx + 1] = mean_or_dist # Copy activity think times - convert floats to Exp distributions self.actthinkproc = [None] * lqn.nidx if hasattr(lqn, 'actthink') and isinstance(lqn.actthink, dict): for idx, mean_or_dist in lqn.actthink.items(): if mean_or_dist is not None: if isinstance(mean_or_dist, (int, float)): mean_val = float(mean_or_dist) if mean_val > 1e-8: self.actthinkproc[idx] = Exp.fit_mean(mean_val) elif hasattr(mean_or_dist, 'getMean') and mean_or_dist.getMean() > 1e-8: self.actthinkproc[idx] = mean_or_dist # entries have Immediate servtproc initially; entry service time (servt) is computed iteratively from activities during update_layers. for e in range(lqn.nentries): eidx = lqn.eshift + e # Set servtproc to Immediate for entries (matches MATLAB: hostdem{eidx} is empty for entries) self.servtproc[eidx] = Immediate() # call service time process = target entry's hostdem (Immediate for entries, matches MATLAB line 194-196). for cidx in range(lqn.ncalls): tgt_eidx = self._get_call_target_entry(cidx) if tgt_eidx is not None and tgt_eidx > 0 and tgt_eidx < len(self.servtproc): if self.servtproc[tgt_eidx] is not None: self.callservtproc[cidx] = self.servtproc[tgt_eidx] else: self.callservtproc[cidx] = Immediate() else: self.callservtproc[cidx] = Immediate() # Build entry service matrix (matches MATLAB getEntryServiceMatrix) # This matrix maps activities and calls to entries for computing entry service times self.servtmatrix = self._get_entry_service_matrix() # Initialize job counts self.njobs = np.zeros((lqn.tshift + lqn.ntasks, lqn.tshift + lqn.ntasks)) # method 'nlp' solves the model as ONE program and never decomposes it, # so there is no ensemble to build and nothing for the fixed point to # iterate over. Everything above this point is index bookkeeping the # analyzer still reads (self.ignore, in particular); everything below is # the layer machinery, which would only cost the construction time of an # ensemble no one solves. if ln_requested_method(getattr(self.options, 'method', None)) == 'nlp': self.lnmethod = 'nlp' self.nlayers = 0 self.njobsorig = self.njobs.copy() self.model.ensemble = self.ensemble return # Build layers self._build_layers() # A setup no longer forces the MAM decomposition on the layer. The open # M/G/1-with-setup QBD reads the idle period from the Poisson rate 1/X, and # in a CLOSED layer the idle period a thread sees is the rest of the cycle, # 1/X - S: on lqn_setup that is 1.0 against the 2.29 the open reading gives, # so the thread was powered down far more often than it is and the answer # landed 12.67% below LDES. The cold start is charged to the ENTRY instead, # with the probability that the thread was actually found down: see # _setup_charge. self.njobsorig = self.njobs.copy() # Build the interlock path tables of Sec. 4.2 if self.options.config.get('interlocking', False): self._init_interlock() self.nlayers = len(self.ensemble) line_debug("LN construct: built %d layers from LQN model (%d hosts, %d tasks, %d entries, %d activities)", self.nlayers, lqn.nhosts, lqn.ntasks, lqn.nentries, lqn.nacts) # Initialize caller probability tracking self.ptaskcallers = np.zeros((lqn.nhosts + lqn.ntasks, lqn.nhosts + lqn.ntasks)) self.ptaskcallers_step = [np.zeros_like(self.ptaskcallers) for _ in range(self.nlayers + 2)] # Compute reset indices (convert to int for list indexing) if self.route_prob_updmap is not None and len(self.route_prob_updmap) > 0: self.routereset = list(set(int(self.idxhash[int(x)]) for x in self.route_prob_updmap[:, 0] if not np.isnan(self.idxhash[int(x)]))) if self.thinkt_classes_updmap is not None and len(self.thinkt_classes_updmap) > 0: self.svcreset = list(set(int(self.idxhash[int(x)]) for x in self.thinkt_classes_updmap[:, 0] if not np.isnan(self.idxhash[int(x)]))) if self.call_classes_updmap is not None and len(self.call_classes_updmap) > 0: self.svcreset = list(set(self.svcreset) | set(int(self.idxhash[int(x)]) for x in self.call_classes_updmap[:, 0] if not np.isnan(self.idxhash[int(x)]))) # Store ensemble in model self.model.ensemble = self.ensemble
[docs] def listValidMethods(self): """Valid methods for this solver, SolverLN.m verbatim. Each name states the LAYERING and the ENCODING; ln_requested_method normalises the alias spellings ('ph', 'cs', 'srvncs', 'flatcs', 'squashed', 'squashed.ph') onto these, and they are left out here to keep the list unambiguous, exactly as the reference does. """ return ['srvn', 'srvn.ph', 'srvn.cs', 'flat', 'flat.cs', 'flat.ph', 'moment3', 'default']
list_valid_methods = listValidMethods
[docs] def supportsModelMethod(self, method): """The encoding rules the layer builders enforce at solve time, stated here so a CALLER can see them before running. 'srvn.ph' and 'flat.ph' compose each entry into ONE phase-type law, and several constructs have nowhere to go in that law: a forwarding call whose target is not in the caller's activity graph, a routed call group whose dispatch order the composition folds away, a cache task, an admission constraint, a queue-dependent rate on a station the composition replaces. 'flat.ph' additionally squashes every layer into one network, which per-layer state (a replica, a powered-down setup thread) cannot survive. None of these is a feature name, so none can be a feature-set delta: they are properties of what the METHOD does to the model. Left only in the builders they were invisible to every gate above them, and ``listValidMethods`` returns the same eight names for every model, so a report offered every encoding on every layered model. Phase 2 is deliberately NOT tested: that refusal reads ``self.hasPhase2``, which is built during layering rather than being a property of the model, so a gate cannot ask it without doing the layering it precedes. Mirrors MATLAB ``ln_method_refusal``. """ m = str(method).lower() if m not in ('srvn.ph', 'flat.ph'): return True, '' lqn = getattr(self, 'lqn', None) if lqn is None: return True, '' # -- the squashing refusals, 'flat.ph' only ------------------------ # Each carries PER-LAYER state that one submodel cannot hold, so they # are properties of the flattening and not of the encoding. if m == 'flat.ph': nelem = lqn.nhosts + lqn.ntasks for i in range(nelem): if float(lqn.repl[0, i]) > 1: return False, ("method='flat.ph' does not support replicated processors or " "tasks, whose replicas need a submodel each. " "Use method='srvn.ph'.") hs = getattr(lqn, 'hassetup', None) if hs is not None and np.any(np.asarray(hs).ravel()[:nelem]): return False, ("method='flat.ph' does not support setup tasks, whose " "powered-down threads are per-layer state. " "Use method='srvn.ph'.") # -- the composed-entry-law refusals, both PH encodings ------------- iscache = getattr(lqn, 'iscache', None) if iscache is not None and np.any(np.asarray(iscache).ravel()): return False, ("method='%s' does not support cache tasks. " "Use method='default'." % m) for cidx in range(lqn.ncalls): if self._ph_call_type(cidx) == _FWD: return False, ("method='%s' does not support forwarding calls, whose target is " "not part of the caller's activity graph. " "Use method='default'." % m) hs = getattr(lqn, 'hassetup', None) if hs is not None: hsf = np.asarray(hs).ravel() for i in range(len(hsf)): if not hsf[i]: continue if self._get_sched(i) == SchedStrategy.INF or not np.isfinite(float(lqn.mult[0, i])): return False, ("method='%s': task '%s' declares a setup time on an " "infinite-server task, which holds no thread to power down; " "give it a finite multiplicity." % (m, self._ph_name(i))) if getattr(lqn, 'callgroups', None): return False, ("method='%s' does not support routed call groups, whose dispatch " "order is a routing property. Use method='flat.cs'." % m) if getattr(lqn, 'lincon', None): return False, ("method='%s' does not support admission constraints on a layer " "station. Use method='default'." % m) for fndep in ('lldscaling', 'cdscaling', 'jdscaling', 'pools'): dep = getattr(lqn, fndep, None) or {} if dep: sidxdep = sorted(dep.keys())[0] what = 'server pools' if fndep == 'pools' else fndep return False, ("method='%s' does not support queue-dependent service rates on " "a layer station ('%s' declares %s). Use method='srvn.cs'." % (m, self._ph_name(sidxdep), what)) return True, ''
supports_model_method = supportsModelMethod
[docs] def supports(self, model) -> bool: """Check if the layered model is supported. Mirrors MATLAB SolverLN.supports: an LQN is solved layer by layer, so the gate is the conjunction of the per-layer solvers' own gates against their own layer, not a feature set of SolverLN's own. This cannot be a static method, since it needs self.solvers[e]. No supports() existed anywhere in the MRO, so calling it raised AttributeError and the solver had no gate at all. """ # python LayeredNetwork.getEnsemble returns SELF, not a list of layers, unlike MATLAB's @LayeredNetwork. ensemble = self.ensemble if not ensemble and hasattr(model, 'ensemble'): candidate = model.ensemble if isinstance(candidate, (list, tuple)): ensemble = candidate if not ensemble or not self.solvers: # The layers are built by _construct(); with none built there is # nothing to gate against. return True from ..base import supports_via_featureset for e in range(min(len(ensemble), len(self.solvers))): solver = self.solvers[e] layer = ensemble[e] # SolverMAM/SolverFLD declare supports(sn,method)->(bool,reason), unlike boolean supports(model) MATLAB assumes; gate via the layer solver's featset. get_featureset = getattr(type(solver), 'getFeatureSet', None) if get_featureset is not None: if not supports_via_featureset(type(solver), layer): return False continue supports = getattr(solver, 'supports', None) if supports is None: continue try: if not supports(layer): return False except TypeError: # Solver with a non-model supports() signature; nothing to gate. continue return True
def _get_call_target_entry(self, cidx: int) -> Optional[int]: """Get the target entry index for a call.""" lqn = self.lqn if cidx < 0 or cidx >= lqn.ncalls: return None if isinstance(lqn.callpair, dict): pair = lqn.callpair.get(cidx, None) if pair is not None: return pair[1] # Column 1 is target entry else: if cidx < lqn.callpair.shape[0]: return int(lqn.callpair[cidx, 1]) # Column 1 is target entry return None def _get_call_source_activity(self, cidx: int) -> Optional[int]: """Get the source activity index for a call.""" lqn = self.lqn if cidx < 0 or cidx >= lqn.ncalls: return None if isinstance(lqn.callpair, dict): pair = lqn.callpair.get(cidx, None) if pair is not None: return pair[0] # Column 0 is source activity else: if cidx < lqn.callpair.shape[0]: return int(lqn.callpair[cidx, 0]) # Column 0 is source activity return None def _assert_series_parallel_forks(self): """Reject activity graphs whose AND forks and joins are not properly nested. The traversal pairs a join with the most recent fork through a LIFO stack of fork classes, so it can only represent series-parallel graphs. """ lqn = self.lqn graph = getattr(lqn, 'graph', None) posttype = getattr(lqn, 'actposttype', None) pretype = getattr(lqn, 'actpretype', None) if graph is None or posttype is None or pretype is None: return post_and_value = 12 # ActivityPrecedenceType.ID_POST_AND pre_and_value = 2 # ActivityPrecedenceType.ID_PRE_AND post = np.asarray(posttype).flatten() pre = np.asarray(pretype).flatten() ashift = lqn.nhosts + lqn.ntasks + lqn.nentries nidx = lqn.nidx acts = range(ashift, nidx) def preds(x): return [p for p in acts if p < graph.shape[0] and x < graph.shape[1] and graph[p, x] != 0] is_fork = set() for f in acts: for b in acts: if (f < graph.shape[0] and b < graph.shape[1] and graph[f, b] != 0 and b < len(post) and post[b] == post_and_value): is_fork.add(f) break def enclosing_fork(a): seen, queue = set(), [a] while queue: cur = queue.pop(0) if cur in seen: continue seen.add(cur) ps = preds(cur) hit = [p for p in ps if p in is_fork] if hit: return hit[0] queue.extend(ps) return -1 for j in acts: inputs = [i for i in preds(j) if i < len(pre) and pre[i] == pre_and_value] if len(inputs) < 2: continue forks = {enclosing_fork(i) for i in inputs} if len(forks) > 1 or -1 in forks: name = lqn.hashnames[j] if j < len(lqn.hashnames) else str(j) raise RuntimeError( "Activity '%s' joins branches of different AND forks; SolverLN supports " "only properly nested (series-parallel) fork-join graphs." % name) def _is_srvn_ph(self) -> bool: """True when the layers are the collapsed phase-type ones of 'srvn.ph'.""" return getattr(self, 'lnmethod', None) == 'srvn.ph' def _is_ph_encoding(self) -> bool: """True when the layers carry the COMPOSED phase-type server law rather than the routing encoding of the activity graph, under either layering. The encoding, not the layering, decides which update and reconstruction passes run, so every such dispatch asks this and not for one method name. """ return getattr(self, 'lnmethod', None) in ('srvn.ph', 'flat.ph') def _assert_warmstart_encoding(self): """Refuse a warm start the composed phase-type layers cannot take. Under 'srvn.ph' and 'flat.ph' a layer's server carries a phase-type law composed from the entry's workflow, not a mean, and both seeds solve for means only: there is nothing in either answer to seed a distribution with. Say so by name rather than seed half the state and report the iteration as warm-started. """ if not self._is_ph_encoding(): return warm = self.options.config.get('warmstart', None) if warm is None: return raise ValueError( "config['warmstart']='%s' cannot seed method='%s': its layers carry a " "COMPOSED PHASE-TYPE service law and the seed solves for means, so there " "is no phase distribution to start it from. Use method='srvn.cs' for the " "same layering under the routing encoding, which those means do " "parameterize." % (warm, self.lnmethod)) def _probe_srvn_ph(self) -> bool: """Answer whether 'srvn.ph' can serve this model, without disturbing the solver. Both the feature gate and the series-parallel reduction can refuse, and the second only finds out by composing the per-entry workflows -- work the build then reuses, since those laws do not depend on the iterate. """ try: self._ph_init_state() self._assert_srvn_ph_supported() self._ph_init_laws() self._ph_laws_ready = True return True except Exception as e: # noqa: BLE001 self._ph_laws_ready = False line_debug("LN: method=srvn cannot use srvn.ph on this model (%s)", e) return False def _build_layers(self): """Build layer submodels (matches MATLAB buildLayers).""" lqn = self.lqn # Method resolution. A method name carries both the LAYERING and the # ENCODING: 'srvn.ph' replaces the routing encoding of the activity graph # by a composed phase-type server law, 'srvn' is the alias that takes it # where it can serve the model and 'srvn.cs' otherwise, and 'flat.cs' # squashes every server into one submodel. The choice is made ONCE, here, # and every later dispatch reads self.lnmethod. # See _kb/06-solver-catalog.md (LN section). requested = ln_requested_method(getattr(self.options, 'method', None)) # the method names the layering, so it sets it self._force_flat = requested in ('flat.cs', 'flat.ph') if requested == 'flat.ph': # the squashed layering with the composed law: ONE submodel holding # every server, and a caller visiting each of them once per # invocation. The feature gate is the srvn.ph one plus the refusals a # single submodel carries -- see _ph_flat_server_set. self._ph_laws_ready = False self._ph_init_state() self.lnmethod = 'flat.ph' self._assert_warmstart_encoding() self._build_layers_ph(flat=True) return if requested in ('srvn.ph', 'srvn'): hard = requested == 'srvn.ph' if self._is_flat_layering(): if hard: raise ValueError("method='srvn.ph' requires the srvn layering, because it " "replaces each server by a submodel of its own. Use " "method='srvn.cs' for that layering.") line_debug("LN: method=srvn cannot use srvn.ph under the flat layering") else: self._ph_laws_ready = False if hard: self._ph_init_state() self.lnmethod = 'srvn.ph' self._assert_warmstart_encoding() self._build_layers_ph() return if self._probe_srvn_ph(): self.lnmethod = 'srvn.ph' self._assert_warmstart_encoding() self._build_layers_ph() return # The label reports what was BUILT, so a model squashed through # options.config.layering reads back as 'flat.cs' even when no method # named it. if requested == 'moment3': self.lnmethod = 'moment3' else: self.lnmethod = 'flat.cs' if self._is_flat_layering() else 'srvn.cs' self._assert_series_parallel_forks() self._assert_warmstart_encoding() # Initialize ensemble with None for each potential layer self.ensemble = [None] * (lqn.nhosts + lqn.ntasks) # Initialize update maps as lists of lists servt_map = [[] for _ in range(lqn.nhosts + lqn.ntasks)] thinkt_map = [[] for _ in range(lqn.nhosts + lqn.ntasks)] actthinkt_map = [[] for _ in range(lqn.nhosts + lqn.ntasks)] arvproc_map = [[] for _ in range(lqn.nhosts + lqn.ntasks)] call_map = [[] for _ in range(lqn.nhosts + lqn.ntasks)] route_map = [[] for _ in range(lqn.nhosts + lqn.ntasks)] # see _kb/06-solver-catalog.md (LN section) for the layering taxonomy self._assert_call_groups() flat_servers = self._flat_server_set() if self._is_flat_layering() else [] if flat_servers: flat_callers = [lqn.tshift + t for t in range(lqn.ntasks) if not self.ignore[lqn.tshift + t]] self._build_layer_recursive(flat_servers, flat_callers, False, servt_map, thinkt_map, actthinkt_map, arvproc_map, call_map, route_map, flat=True) else: self._build_layers_srvn(servt_map, thinkt_map, actthinkt_map, arvproc_map, call_map, route_map) self._finish_layers(servt_map, thinkt_map, actthinkt_map, arvproc_map, call_map, route_map, flat_servers) def _build_layers_srvn(self, servt_map, thinkt_map, actthinkt_map, arvproc_map, call_map, route_map): """One submodel per processor and per called task (default layering).""" lqn = self.lqn # Build one submodel for every processor (host layer) for hidx in range(lqn.nhosts): if not self.ignore[hidx]: tasks_on_host = self._get_tasks_of_host(hidx) if tasks_on_host: # skip a host layer with no callers, no nonzero demand, and no REF task (matches MATLAB's pure-delay-host skip, e.g. USAGE_DELAY). has_callers = False has_demand = False has_ref_task = False for tidx in tasks_on_host: # Check if task is a reference task if self._is_ref_task(tidx): has_ref_task = True break # Check if task has callers if self._get_callers_of_task(tidx): has_callers = True break # forwarding targets need a host layer even without direct callers; MATLAB builds host layers unconditionally. if self._is_forwarding_target_task(tidx): has_callers = True break # a task needs its layer built if it has nonzero host demand via its activities (base Distribution wrappers expose only .mean). activities = self._get_activities_of_task(tidx) for aidx in activities: if aidx < len(self.servtproc) and self.servtproc[aidx] is not None: proc = self.servtproc[aidx] if hasattr(proc, 'getMean'): dem_mean = proc.getMean() elif hasattr(proc, 'mean'): dem_mean = proc.mean else: dem_mean = 0.0 if dem_mean is not None and dem_mean > 0: has_demand = True break if has_demand: break # Only build layer if tasks have callers, demand, or a REF task if has_callers or has_demand or has_ref_task: self._build_layer_recursive(hidx, tasks_on_host, True, servt_map, thinkt_map, actthinkt_map, arvproc_map, call_map, route_map) # Build one submodel for every task (task layer) for t in range(lqn.ntasks): tidx = lqn.tshift + t if not self.ignore[tidx] and not self._is_ref_task(tidx): # Check if task has callers callers = self._get_callers_of_task(tidx) if callers: self._build_layer_recursive(tidx, callers, False, servt_map, thinkt_map, actthinkt_map, arvproc_map, call_map, route_map) def _finish_layers(self, servt_map, thinkt_map, actthinkt_map, arvproc_map, call_map, route_map, flat_servers): """Flatten the update maps and index the ensemble.""" lqn = self.lqn # Convert maps to numpy arrays self.servt_classes_updmap = self._flatten_map(servt_map) self.thinkt_classes_updmap = self._flatten_map(thinkt_map) self.actthinkt_classes_updmap = self._flatten_map(actthinkt_map) self.arvproc_classes_updmap = self._flatten_map(arvproc_map) self.call_classes_updmap = self._flatten_map(call_map) self.route_prob_updmap = self._flatten_map(route_map) if self.route_prob_updmap is not None and len(self.route_prob_updmap) > 0: self.unique_route_prob_updmap = np.unique(self.route_prob_updmap[:, 0]) else: self.unique_route_prob_updmap = np.array([]) # Remove empty models and create idxhash empty_models = [i for i, e in enumerate(self.ensemble) if e is None] self.ensemble = [e for e in self.ensemble if e is not None] # Also compact solvers list to match ensemble self.solvers = [s for i, s in enumerate(self.solvers) if i not in empty_models and i < len(self.solvers)] # Extend solvers if needed to match ensemble length while len(self.solvers) < len(self.ensemble): self.solvers.append(None) # Position of each host/task element in the compacted ensemble. Element 0 # is the first host, not the dead slot the 1-based space used to carry. self.idxhash = np.full(lqn.nhosts + lqn.ntasks, np.nan) layer_idx = 0 for orig_idx in range(lqn.nhosts + lqn.ntasks): if orig_idx not in empty_models: self.idxhash[orig_idx] = layer_idx layer_idx += 1 # Layers carrying an admission constraint need the region wait recovered in # update_metrics -- see _kb/06-solver-catalog.md (LN section) self.layer_has_region = [bool(getattr(e, 'regions', None)) for e in self.ensemble] self.layer_chains = [None] * len(self.ensemble) for e_idx, has_region in enumerate(self.layer_has_region): if has_region: # layer structure is iteration-invariant, so cache the chain matrix self.layer_chains[e_idx] = np.asarray(self.ensemble[e_idx].getStruct().chains) # Classify layers as host or task self.hostLayerIndices = [] self.taskLayerIndices = [] if flat_servers: # every server resolves to the single flat layer, which is at once # the host layer and the task layer self.idxhash = np.full(lqn.nhosts + lqn.ntasks, np.nan) for sidx in flat_servers: self.idxhash[sidx] = 0 self.hostLayerIndices = [0] self.taskLayerIndices = [0] return for hidx in range(lqn.nhosts): if not np.isnan(self.idxhash[hidx]): self.hostLayerIndices.append(int(self.idxhash[hidx])) for t in range(lqn.ntasks): tidx = lqn.tshift + t if not np.isnan(self.idxhash[tidx]): self.taskLayerIndices.append(int(self.idxhash[tidx])) def _flatten_map(self, map_list: List[List]) -> np.ndarray: """Flatten a list of lists into a numpy array.""" all_rows = [] for rows in map_list: all_rows.extend(rows) if all_rows: return np.array(all_rows) return np.array([]).reshape(0, 4) def _get_tasks_of_host(self, hidx: int) -> List[int]: """Get task indices for a host processor.""" lqn = self.lqn if isinstance(lqn.tasksof, dict): return lqn.tasksof.get(hidx, []) return [] def _is_ref_task(self, tidx: int) -> bool: """Check if a task is a reference (REF) task.""" lqn = self.lqn if hasattr(lqn, 'isref') and lqn.isref is not None: if isinstance(lqn.isref, dict): return lqn.isref.get(tidx, False) elif isinstance(lqn.isref, np.ndarray): # Handle 2D arrays - flatten and use direct index flat_isref = lqn.isref.flatten() if tidx < len(flat_isref): return bool(flat_isref[tidx]) if hasattr(lqn, 'sched') and lqn.sched is not None: ref_value = SchedStrategy.REF.value if hasattr(SchedStrategy.REF, 'value') else SchedStrategy.REF if isinstance(lqn.sched, dict): sched_val = lqn.sched.get(tidx, None) if sched_val is not None: # Compare against both the enum and its value return sched_val == SchedStrategy.REF or sched_val == ref_value elif isinstance(lqn.sched, np.ndarray): flat_sched = lqn.sched.flatten() if tidx < len(flat_sched): sched_val = flat_sched[tidx] return sched_val == SchedStrategy.REF or sched_val == ref_value return False def _get_callers_of_task(self, tidx: int) -> List[int]: """Get caller task indices for a task.""" lqn = self.lqn callers = [] # iscaller is a task-to-task matrix: iscaller[caller_task_idx, callee_task_idx] if hasattr(lqn, 'iscaller') and lqn.iscaller is not None: if isinstance(lqn.iscaller, np.ndarray): # Find all tasks that call this task (callers in column tidx) caller_indices = np.where(lqn.iscaller[:, tidx] > 0)[0] for caller_idx in caller_indices: # Check if caller is a task (not processor/entry/activity) if lqn.tshift <= caller_idx < lqn.tshift + lqn.ntasks: if caller_idx not in callers: callers.append(caller_idx) return callers def _extract_latest_metrics(self, aidx: int, layer_idx: int, nodeidx_0: int, classidx_0: int, refstat_k: int = None, refclass_c: int = None): """Extract metrics from the latest result (helper for _update_metrics_default). Computes residt from QN/TN_ref instead of WN to avoid fork+loop visit distortion (matches MATLAB updateMetricsDefault.m). """ if layer_idx >= 0 and len(self.results) > 0 and layer_idx < len(self.results[-1]): result = self.results[-1][layer_idx] if result is not None and 'RN' in result: RN = result['RN'] WN = result.get('WN', RN) TN = result['TN'] QN = result.get('QN') if RN is not None and nodeidx_0 < RN.shape[0] and classidx_0 < RN.shape[1]: rn_val = RN[nodeidx_0, classidx_0] tn_val = TN[nodeidx_0, classidx_0] # Safeguard against extreme values from MVA numerical instability # If RN becomes too large (> 1e10) or is NaN/Inf, keep previous value max_servt = 1e10 if np.isfinite(rn_val) and rn_val <= max_servt and rn_val >= 0: self.servt[aidx] = rn_val # else: keep previous servt[aidx] value # Compute residt from QN/TN_ref (matches MATLAB updateMetricsDefault.m) if (refstat_k is not None and refclass_c is not None and QN is not None and TN is not None): TN_ref = TN[refstat_k, refclass_c] if (0 <= refstat_k < TN.shape[0] and 0 <= refclass_c < TN.shape[1]) else 0.0 if TN_ref > 1e-8: # GlobalConstants.FineTol qn_val = QN[nodeidx_0, classidx_0] if np.isfinite(qn_val) and qn_val >= 0: self.residt[aidx] = qn_val / TN_ref else: wn_val = WN[nodeidx_0, classidx_0] if WN is not None else rn_val if np.isfinite(wn_val) and wn_val <= max_servt and wn_val >= 0: self.residt[aidx] = wn_val else: # Fallback to WN if no refstat/refclass info wn_val = WN[nodeidx_0, classidx_0] if WN is not None else rn_val if np.isfinite(wn_val) and wn_val <= max_servt and wn_val >= 0: self.residt[aidx] = wn_val if np.isfinite(tn_val) and tn_val >= 0: self.tput[aidx] = tn_val def _get_fork_fanout(self, aidx: int) -> int: """ Get the fork fanout correction factor for an activity. For activities that are in a fork branch (after fork, before join), returns the number of parallel branches so throughput can be corrected. For fork sources, join targets, and non-fork activities, returns 1. Without Fork/Join nodes, probabilistic routing divides throughput by the number of branches. This function identifies activities that need correction (multiplication by fanout) to recover the correct throughput. Activities needing correction: 1. Fork sources (B1) - routing normalization divides their throughput 2. POST_AND activities (fork branch targets like B2, B3, B4) 3. Activities in fork branch chains (successors of POST_AND before join) Activities NOT needing correction: - Join targets (B6) - receive sum from all branches """ lqn = self.lqn if not hasattr(lqn, 'graph') or lqn.graph is None: return 1 graph = lqn.graph if not isinstance(graph, np.ndarray): return 1 post_and_value = 12 # ActivityPrecedenceType.ID_POST_AND pre_and_value = 2 # ActivityPrecedenceType.ID_PRE_AND def is_post_and_activity(act_idx: int) -> bool: """Check if an activity is POST_AND (fork branch target).""" if not hasattr(lqn, 'actposttype') or lqn.actposttype is None: return False actposttype = lqn.actposttype if not isinstance(actposttype, np.ndarray): return False flat_posttype = actposttype.flatten() if 0 < act_idx < len(flat_posttype): return flat_posttype[act_idx] == post_and_value return False def is_join_target(act_idx: int) -> bool: """Check if an activity is a join target (has PRE_AND predecessors).""" # A join target has predecessors that are PRE_AND (join sources) if not hasattr(lqn, 'actpretype') or lqn.actpretype is None: return False actpretype = lqn.actpretype if not isinstance(actpretype, np.ndarray): return False flat_pretype = actpretype.flatten() # Check if ANY predecessor of this activity is PRE_AND for pred_idx in range(graph.shape[0]): if pred_idx != act_idx and graph[pred_idx, act_idx] > 0: if 0 < pred_idx < len(flat_pretype) and flat_pretype[pred_idx] == pre_and_value: return True return False def count_post_and_successors(act_idx: int) -> int: """Count POST_AND successors of an activity.""" count = 0 for succ_idx in range(graph.shape[1]): if graph[act_idx, succ_idx] > 0 and is_post_and_activity(succ_idx): count += 1 return count def is_fork_source(act_idx: int) -> bool: """Check if an activity is a fork source (has POST_AND successors).""" return count_post_and_successors(act_idx) > 1 and not is_post_and_activity(act_idx) def get_fork_fanout_for_activity(act_idx: int, visited: set) -> int: """Recursively determine fork fanout for an activity.""" if act_idx in visited: return 1 visited.add(act_idx) # Join targets don't need correction - they receive from all branches if is_join_target(act_idx): return 1 # fork sources need throughput correction (routing normalization dilutes it) but not visit correction (already correct). if is_fork_source(act_idx): return count_post_and_successors(act_idx) # Case 1: Activity is POST_AND (fork branch target) if is_post_and_activity(act_idx): # Find predecessor (fork source) and count its POST_AND successors for pred_idx in range(graph.shape[0]): if pred_idx != act_idx and graph[pred_idx, act_idx] > 0: fanout = count_post_and_successors(pred_idx) if fanout > 1: return fanout return 1 # Case 2: Activity is in a fork branch chain (predecessor has fanout) for pred_idx in range(graph.shape[0]): if pred_idx != act_idx and graph[pred_idx, act_idx] > 0: # Check if predecessor is POST_AND or has fanout pred_fanout = get_fork_fanout_for_activity(pred_idx, visited) if pred_fanout > 1: return pred_fanout return 1 return get_fork_fanout_for_activity(aidx, set()) def _get_max_caller_fork_fanout(self, tidx: int) -> int: """ Get the maximum fork fanout among all activities that call this task. For tasks called from AND-fork branches, this returns the fork fanout so that throughput can be corrected (multiplied back after probabilistic routing approximation divides it). Args: tidx: Task index Returns: Maximum fork fanout among callers (1 if no AND-fork callers) """ lqn = self.lqn max_fanout = 1 task_name = self._get_hashname(tidx) if tidx else str(tidx) # Get entries of this task entries = self._get_entries_of_task(tidx) if not entries: return 1 # For each entry, find calling activities via callpair if not hasattr(lqn, 'callpair') or lqn.callpair is None: return 1 callpair = lqn.callpair if not isinstance(callpair, np.ndarray): return 1 for eidx in entries: # Find calls targeting this entry (column 2 of callpair has target entry index) for cidx in range(callpair.shape[0]): if cidx < callpair.shape[0]: tgt_eidx = int(callpair[cidx, 1]) if callpair.shape[1] > 1 else 0 if tgt_eidx == eidx: # Found a call to this entry - get the source activity src_aidx = self._get_call_source_activity(cidx) if src_aidx is not None and src_aidx > 0: # Get fork fanout of the calling activity fanout = self._get_fork_fanout(src_aidx) src_name = self._get_hashname(src_aidx) if src_aidx else str(src_aidx) max_fanout = max(max_fanout, fanout) return max_fanout # NOTE: Visit correction for fork-join activities is NOT implemented because MVA # recomputes visits internally from the routing matrix, ignoring any manual # modifications to nodevisits. Instead, throughput correction is applied in # get_ensemble_avg by multiplying raw MVA throughputs by the fork fanout. # # For exact MATLAB parity, Fork/Join/Router nodes would need to be added to the # layer models (as MATLAB does in buildLayersRecursive.m), but this is a # significant undertaking. The current throughput correction provides reasonable # approximations for most fork-join networks. def _build_layer_recursive(self, idx_set, callers: List[int], is_host_layer: bool, servt_map, thinkt_map, actthinkt_map, arvproc_map, call_map, route_map, flat: bool = False): """Build a layer submodel (matches MATLAB buildLayersRecursive). IDX_SET is the server element of this layer, a scalar under 'srvn' layering and the whole host+task set under 'flat' layering. """ lqn = self.lqn if isinstance(idx_set, (list, tuple, np.ndarray)): idx_set = [int(v) for v in idx_set] else: idx_set = [int(idx_set)] idx = idx_set[0] # layer key: model name, ensemble slot and update-map column # Create Network for this layer model_name = self._get_hashname(idx) layer_model = Network(model_name + '.Flat' if flat else model_name) if hasattr(layer_model, 'set_checks'): layer_model.set_checks(False) # Create attribute storage layer_model.attribute = OptionsDict({ 'hosts': [], 'tasks': [], 'entries': [], 'activities': [], 'calls': [], 'clientIdx': None, 'serverIdx': None, 'sourceIdx': None, 'cacheIdx': None, # Cache node index if cache layer 'iscachelayer': False, # Flag for cache layer }) # Detect cache layer (MATLAB buildLayersRecursive line 36) # iscachelayer = all(lqn.iscache(callers)) && ishostlayer iscachelayer = False if not flat and is_host_layer and hasattr(lqn, 'iscache') and lqn.iscache is not None: iscache_arr = lqn.iscache.flatten() if isinstance(lqn.iscache, np.ndarray) else lqn.iscache # Check if ALL callers are cache tasks if len(callers) > 0: iscachelayer = True for caller_idx in callers: if caller_idx < len(iscache_arr): if not iscache_arr[caller_idx]: iscachelayer = False break else: iscachelayer = False break layer_model.attribute['iscachelayer'] = iscachelayer # Get number of servers nservers = self._get_nservers(idx) sched = self._get_sched(idx) # fan-out replication: single replica when caller fan-out covers task replicas; see _kb/06-solver-catalog.md LN Fan-out single-replica modeling. raw_replicas = 1 if flat else int(self._get_repl(idx)) reduce_fanout = False if raw_replicas > 1 and len(callers) > 0: if not is_host_layer and hasattr(lqn, 'fanout') and lqn.fanout is not None: reduce_fanout = True for c in callers: fo = lqn.fanout[c, idx] if c < lqn.fanout.shape[0] and idx < lqn.fanout.shape[1] else 0 if fo < raw_replicas: reduce_fanout = False break elif is_host_layer: reduce_fanout = True for c in callers: if int(self._get_repl(c)) != raw_replicas: reduce_fanout = False break if reduce_fanout: nreplicas = 1 if not is_host_layer: self.single_replica_tasks.append(idx) else: nreplicas = raw_replicas # Create stations has_sync_callers = self._has_sync_callers(idx, callers) if flat or is_host_layer or has_sync_callers: # Create client delay node client_delay = Delay(layer_model, 'Clients') layer_model.attribute['clientIdx'] = 1 layer_model.attribute['serverIdx'] = 2 else: layer_model.attribute['serverIdx'] = 1 layer_model.attribute['clientIdx'] = None # One station (times its replicas) per server element of the layer srv_stations = {} server_idx_of = {} host_stations = [] task_stations = [] for sidx in idx_set: s_is_host = sidx <= lqn.nhosts s_name = self._get_hashname(sidx) if flat and any(str(n.getName()) == s_name for n in layer_model.get_nodes()): # an LQN processor and the task it hosts may share a name; two # stations of one layer must not, or link() gives their # class-switch nodes the same name and merges their arcs s_name = s_name + ('.host' if s_is_host else '.task') s_nservers = self._get_nservers(sidx) s_sched = self._get_sched(sidx) stations_of = [] for m in range(1, nreplicas + 1): if m == 1: ss = Queue(layer_model, s_name, s_sched) else: ss = Queue(layer_model, s_name + '.' + str(m), s_sched) ss.set_number_of_servers(s_nservers) ss.attribute = OptionsDict({ 'ishost': s_is_host, 'idx': sidx }) # successive same-host activities retain the server; mark immediate feedback so simulators do not re-queue behind waiting jobs. ss.set_immediate_feedback(True) stations_of.append(ss) srv_stations[sidx] = stations_of server_idx_of[sidx] = len(layer_model.get_nodes()) - nreplicas + 1 if s_is_host: host_stations.append(server_idx_of[sidx]) else: task_stations.append(server_idx_of[sidx]) layer_model.attribute['srv_stations'] = srv_stations layer_model.attribute['serverIdxOf'] = server_idx_of layer_model.attribute['hostStations'] = host_stations layer_model.attribute['taskStations'] = task_stations layer_model.attribute['flat'] = flat server_stations = srv_stations[idx] server_station = server_stations[0] # the layer's own server, sole server under 'srvn' layer_model.attribute['nreplicas'] = nreplicas layer_model.attribute['server_stations'] = server_stations # Source/Sink lazily created and reused for all open classes in this layer; mirrors JAR SolverLN.java:719-726. source_station = None sink_station = None if hasattr(lqn, 'arrival') and lqn.arrival: for tidx_caller in callers: # an arrival that is the only way into the task is carried by the caller # chain instead, not by a stream -- see _open_arrival_rate_of if self._is_open_arrival_only(tidx_caller): continue for eidx in self._get_entries_of_task(tidx_caller): if eidx in lqn.arrival and lqn.arrival[eidx] is not None: source_station = Source(layer_model, 'Source') sink_station = Sink(layer_model, 'Sink') break if source_station is not None: break layer_model.attribute['source_station'] = source_station layer_model.attribute['sink_station'] = sink_station layer_model.attribute['entry_open_classes'] = [] # list of (OpenClass, eidx) layer_model.attribute['async_open_classes'] = [] # list of (OpenClass, cidx, callmean) # Detect POST_AND / PRE_AND activities for Fork/Join routing # (MATLAB buildLayersRecursive.m lines 42-66) post_and_value = 12 # ActivityPrecedenceType.ID_POST_AND pre_and_value = 2 # ActivityPrecedenceType.ID_PRE_AND is_post_and_act = set() is_pre_and_act = set() acts_in_caller = [] for tidx_caller in callers: acts_in_caller.extend(self._get_activities_of_task(tidx_caller)) if hasattr(lqn, 'actposttype') and lqn.actposttype is not None: flat_posttype = lqn.actposttype.flatten() flat_pretype = lqn.actpretype.flatten() if hasattr(lqn, 'actpretype') and lqn.actpretype is not None else np.array([]) for aidx in acts_in_caller: if 0 < aidx < len(flat_posttype): if flat_posttype[aidx] == post_and_value: is_post_and_act.add(aidx) if 0 < aidx < len(flat_pretype): if flat_pretype[aidx] == pre_and_value: is_pre_and_act.add(aidx) has_fork = any(aidx in is_post_and_act for aidx in acts_in_caller) maxfanout = 1 graph = lqn.graph if hasattr(lqn, 'graph') and isinstance(lqn.graph, np.ndarray) else None if graph is not None: for aidx in acts_in_caller: if aidx < graph.shape[0]: successors = [j for j in range(graph.shape[1]) if graph[aidx, j] != 0] post_and_count = sum(1 for s in successors if s in is_post_and_act) if post_and_count > 0: maxfanout = max(maxfanout, post_and_count) fork_node = None fork_output_routers = {} if has_fork: fork_node = Fork(layer_model, 'Fork_PostAnd') for f in range(1, maxfanout + 1): fork_output_routers[f] = Router(layer_model, f'Fork_PostAnd_{f}') has_join = any(aidx in is_pre_and_act for aidx in acts_in_caller) join_node = None if has_join: join_node = Join(layer_model, 'Join_PreAnd', fork_node) # Store Fork/Join info in layer attributes for throughput correction layer_model.attribute['fork_node'] = fork_node layer_model.attribute['fork_output_routers'] = fork_output_routers layer_model.attribute['join_node'] = join_node layer_model.attribute['is_post_and_act'] = is_post_and_act layer_model.attribute['is_pre_and_act'] = is_pre_and_act layer_model.attribute['maxfanout'] = maxfanout layer_model.attribute['has_fork'] = has_fork # The fork-join transform mints its own Source/Sink pair, detaching the open # stream already routed through this one: see _kb/06-solver-catalog.md (LN section) if has_fork and source_station is not None: raise ValueError(f"SolverLN: layer '{layer_model.getName()}' carries both an AND fork " "and an open stream (an async call or an entry arrival); the " "fork-join transform needs a Source of its own") # Create Cache node for cache layers (MATLAB buildLayersRecursive.m lines 36-39) cache_node = None if iscachelayer and len(callers) > 0: # Get cache parameters from the first cache task caller cache_task_idx = callers[0] if hasattr(lqn, 'nitems') and lqn.nitems is not None: nitems = int(lqn.nitems[cache_task_idx, 0]) if cache_task_idx < lqn.nitems.shape[0] else 0 if nitems > 0: # Get item capacity itemcap = lqn.itemcap.get(cache_task_idx, np.array([1])) if hasattr(lqn, 'itemcap') and lqn.itemcap else np.array([1]) # Get replacement strategy replacestrat_val = int(lqn.replacestrat[cache_task_idx, 0]) if hasattr(lqn, 'replacestrat') and lqn.replacestrat is not None else 0 # Convert to ReplacementStrategy enum try: replacestrat = ReplacementStrategy(replacestrat_val) except (ValueError, KeyError): replacestrat = ReplacementStrategy.RR # Default to Random Replacement # Get cache name from hashnames cache_name = lqn.hashnames[cache_task_idx] if hasattr(lqn, 'hashnames') and cache_task_idx < len(lqn.hashnames) else f'Cache_{cache_task_idx}' # Create Cache node cache_node = Cache(layer_model, cache_name, nitems, itemcap, replacestrat) layer_model.attribute['cacheNode'] = cache_node # Update cacheIdx - Cache is added after server, so its index is serverIdx + 1 # Cache is the last node added, so use len(get_nodes()) after it was added layer_model.attribute['cacheIdx'] = len(layer_model.get_nodes()) # Store server attributes. Under flat layering the station indices live # in hostStations/taskStations only: attribute['hosts'] / ['tasks'] rows # are [class index, LQN element] pairs that consumers match on column 2. if not flat: if is_host_layer: layer_model.attribute['hosts'].append([None, layer_model.attribute['serverIdx']]) else: layer_model.attribute['tasks'].append([None, layer_model.attribute['serverIdx']]) # Create classes and set up routing self._create_classes_and_routing(layer_model, idx_set, callers, is_host_layer, servt_map, thinkt_map, actthinkt_map, arvproc_map, call_map, route_map, reduce_fanout=reduce_fanout, flat=flat) # fork-join visit correction not applied here: MVA recomputes visits internally; throughput correction in get_ensemble_avg handles FJ semantics. # Store the layer model self.ensemble[idx] = layer_model # A setup no longer changes how a layer is solved: the cold start is # charged to the entry by _setup_charge, not wired into the station, so # the layer is an ordinary one and the user's own solver serves it. solver = self.solver_factory(layer_model) self._assert_layer_solver_supports_model(solver, layer_model, idx) self._detach_layer_config(solver) self._silence_layer_solver(solver) if idx < len(self.solvers): self.solvers[idx] = solver else: while len(self.solvers) <= idx: self.solvers.append(None) self.solvers[idx] = solver def _is_flat_layering(self) -> bool: """True when the method or options.config.layering asks for the single flat layer.""" # method='flat'/'flat.cs' names the layering, so it wins here if getattr(self, '_force_flat', False): return True cfg = getattr(self.options, 'config', None) if cfg is None: return False try: lay = cfg['layering'] except (KeyError, TypeError): lay = getattr(cfg, 'layering', None) return isinstance(lay, str) and lay.lower() in ('flat', 'squashed') def _assert_call_groups(self) -> None: """Reject routed call groups under any layering that cannot carry them. A group states the order in which one caller visits several callees. The srvn layering puts every callee in a submodel of its own and replaces it, in the caller's submodel, by a surrogate delay, so the callees are never co-resident and no node has arcs to more than one of them: the order has nowhere to be expressed and would be silently degraded to the aggregate call means. The squashed layering keeps all of them as stations of one model, which is what makes the strategy representable. """ groups = getattr(self.lqn, 'callgroups', None) if not groups: return if not self._is_flat_layering(): strategies = sorted({str(getattr(s, 'name', s)) for _, s, _ in groups}) raise ValueError( "Call groups routed by %s require the squashed layering; set " "options.config['layering']='flat'. Under srvn the targets never " "share a submodel, so the dispatch order cannot be represented." % ', '.join(strategies)) # Under flat the group becomes one dispatch hop with n destinations (see # the routing walk), so the strategy is representable. It is only honoured # by a layer solver that implements state-dependent routing, though: MVA, # NC and FLD would silently return the probabilistic split instead. if not self._layer_solver_supports_routed_groups(): raise ValueError( 'Routed call groups need a layer solver with state-dependent ' 'routing (CTMC or SSA); MVA, NC and FLD would silently return ' 'the probabilistic split under a round-robin or JSQ label.')
[docs] def probe_layer_solver_name(self): """Name the layer solver this ensemble runs, WITHOUT building the layers. A delegated solve (lang='java'/'cpp') never enters iterate(), so `self.solvers` is still empty when the dispatcher has to name the layer engine, and reading it off that list reports the NATIVE DEFAULT (MVA) however the caller built the solver. A lambda factory hides the class from `_layer_solver_cls` too, so `LN(model, lambda m: NC(m, opts))` was delegated as an MVA-layered ensemble -- a different fixed point, not a different spelling of the same one (lcq_threehosts: cache hit 0.5 under MVA layers against 0.48331 under NC ones). THE FACTORY IS THE DECLARATION, so it is applied to a layer and the product named. CTMC and MAM are skipped because they are the automatic per-layer substitutions (a finite capacity region, a SetupTask's setup times), not a choice the caller made; if every layer resolves to one of those the answer is None and the caller keeps its own default. """ cls = getattr(self, '_layer_solver_cls', None) if cls is not None: return getattr(cls, '__name__', str(cls)) factory = getattr(self, 'solver_factory', None) ensemble = getattr(self, 'ensemble', None) or [] if factory is None: return None for layer in ensemble: if layer is None: continue try: probe = factory(layer) except Exception: continue if probe is None: continue name = None getname = getattr(probe, 'getName', None) if getname is not None: try: name = getname() except Exception: name = None if not name: name = type(probe).__name__ bare = str(name).replace('Solver', '').upper() if bare in ('CTMC', 'MAM'): continue return name return None
def _layer_solver_supports_routed_groups(self) -> bool: """True when the layer solver factory declares state-dependent routing.""" cls = getattr(self, '_layer_solver_cls', None) or getattr(self, 'solver_factory', None) name = getattr(cls, '__name__', '') or type(cls).__name__ if cls is not None else '' return any(tag in name for tag in ('CTMC', 'SSA', 'LDES', 'JMT')) def _call_groups_by_cidx(self): """Resolve lqn.callgroups from target entries to call indices. Returns (by_cidx, members): by_cidx maps a call index to (gid, strategy), members maps gid to the ordered list of that group's call indices. A group that does not resolve to at least two calls is dropped, so a stale group cannot silently rewrite a single call's routing. """ if getattr(self, '_callgroup_cache', None) is not None: return self._callgroup_cache groups = getattr(self.lqn, 'callgroups', None) or [] by_cidx, members = {}, {} callsof = self.lqn.callsof if isinstance(self.lqn.callsof, dict) else {} for gid, (aidx, strategy, entry_idxs) in enumerate(groups): want = {int(e) for e in entry_idxs} found = [] for cidx in callsof.get(aidx, []): tgt = self._get_call_target_entry(cidx) if tgt is not None and int(tgt) in want: found.append(cidx) if len(found) >= 2: members[gid] = found for cidx in found: by_cidx[cidx] = (gid, strategy) self._callgroup_cache = (by_cidx, members) return self._callgroup_cache def _flat_server_set(self) -> List[int]: """Processors and called tasks that become stations of the flat layer. The features a single submodel cannot carry are rejected here rather than silently dropped. """ lqn = self.lqn nelem = lqn.nhosts + lqn.ntasks for idx in range(nelem): if float(self._get_repl(idx)) > 1: raise ValueError('Flat layering does not support replicated processors or ' 'tasks, use the default srvn layering.') if getattr(lqn, 'iscache', None) is not None: arr = lqn.iscache.flatten() if any(bool(arr[i]) for i in range(min(nelem, len(arr)))): raise ValueError('Flat layering does not support cache tasks, use the ' 'default srvn layering.') if getattr(lqn, 'hassetup', None) is not None: arr = np.asarray(lqn.hassetup).flatten() if any(bool(arr[i]) for i in range(min(nelem, len(arr)))): raise ValueError('Flat layering does not support setup tasks, use the ' 'default srvn layering.') servers = [] for hidx in range(lqn.nhosts): if not self.ignore[hidx] and self._get_tasks_of_host(hidx): servers.append(hidx) for t in range(lqn.ntasks): tidx = lqn.tshift + t if self.ignore[tidx] or self._is_ref_task(tidx): continue if self._get_callers_of_task(tidx): servers.append(tidx) if not servers: raise ValueError('Flat layering found no server: the model has no processor ' 'with tasks.') return servers def _servers_for(self, layer_model, elem_idx) -> List: """Stations of ELEM_IDX when it is a server of this layer, empty otherwise.""" if elem_idx is None: return [] srv = layer_model.attribute.get('srv_stations') if hasattr(layer_model, 'attribute') else None if not srv: return [] return srv.get(int(elem_idx), []) def _host_is_server(self, layer_model, tidx) -> bool: """True when the processor of task TIDX is a server of this layer.""" return bool(self._servers_for(layer_model, self._get_parent(tidx))) def _station_idx_of(self, layer_model, elem_idx): """Station index of ELEM_IDX inside LAYER_MODEL, falling back to the layer's own server when ELEM_IDX is not a server there.""" default = layer_model.attribute.get('serverIdx', 1) if hasattr(layer_model, 'attribute') else 1 if elem_idx is None: return default table = layer_model.attribute.get('serverIdxOf') if hasattr(layer_model, 'attribute') else None if table and int(elem_idx) in table: return table[int(elem_idx)] return default def _station_idx_of_class(self, layer_model, cls): """Station of LAYER_MODEL serving CLS: the processor of an activity, the called task of a call, the layer's own server otherwise.""" elem = None attr = getattr(cls, 'attribute', None) if attr is not None and len(attr) > 1: if attr[0] == LayeredNetworkElement.ACTIVITY: elem = self._get_parent(self._get_parent(attr[1])) elif attr[0] == LayeredNetworkElement.CALL: cidx = int(attr[1]) if self.lqn.callpair is not None and cidx < len(self.lqn.callpair): elem = self._get_parent(int(self.lqn.callpair[cidx, 1])) return self._station_idx_of(layer_model, elem) def _get_hashname(self, idx: int) -> str: """Get the hash name for an LQN element.""" lqn = self.lqn if hasattr(lqn, 'hashnames') and lqn.hashnames is not None: if isinstance(lqn.hashnames, dict): return lqn.hashnames.get(idx, f'Node_{idx}') elif isinstance(lqn.hashnames, (list, np.ndarray)): # hashnames is 0-based and contiguous: index i is element i if idx < len(lqn.hashnames): return lqn.hashnames[idx] return f'Node_{idx}' def _get_nservers(self, idx: int): """Get number of servers for an element. Matches MATLAB's use of maxmult in buildLayersRecursive line 7-8, 31: mult = lqn.maxmult; % this removes spare capacity that cannot be used serverStation{m}.setNumberOfServers(mult(idx)) Uses maxmult instead of mult because maxmult "removes spare capacity that cannot be used" (MATLAB comment). For processors with INF mult (delay nodes), maxmult = 0 which means all capacity can be used. """ lqn = self.lqn val = 1 # Use maxmult if available (MATLAB line 7: mult = lqn.maxmult) if hasattr(lqn, 'maxmult') and lqn.maxmult is not None: if isinstance(lqn.maxmult, dict): val = lqn.maxmult.get(idx, 1) elif isinstance(lqn.maxmult, np.ndarray): flat_maxmult = lqn.maxmult.flatten() if idx < len(flat_maxmult): val = flat_maxmult[idx] if isinstance(val, np.ndarray): val = val.item() if val.size == 1 else 1 elif hasattr(lqn, 'mult') and lqn.mult is not None: # Fallback to mult if maxmult not available if isinstance(lqn.mult, dict): val = lqn.mult.get(idx, 1) elif isinstance(lqn.mult, np.ndarray): flat_mult = lqn.mult.flatten() if idx < len(flat_mult): val = flat_mult[idx] if isinstance(val, np.ndarray): val = val.item() if val.size == 1 else 1 if not isinstance(val, (int, float)): return 1 # Return np.inf for infinite servers (INF scheduling) if not np.isfinite(val): return np.inf # maxmult=0 means infinite servers (MATLAB: if isinf(mult), maxmult=0) if val == 0: return np.inf # INF scheduling always returns infinite servers regardless of maxmult (MATLAB warns but uses infinite servers). sched = self._get_sched(idx) if sched == SchedStrategy.INF: return np.inf return max(1, int(val)) def _get_sched(self, idx: int) -> SchedStrategy: """Get scheduling strategy for an element.""" lqn = self.lqn sched_val = None if hasattr(lqn, 'sched') and lqn.sched is not None: if isinstance(lqn.sched, dict): sched_val = lqn.sched.get(idx, None) elif isinstance(lqn.sched, np.ndarray): # Handle 2D arrays (shape like (1, n)) flat_sched = lqn.sched.flatten() if idx < len(flat_sched): sched_val = int(flat_sched[idx]) if sched_val is None: return SchedStrategy.PS # Convert integer value to SchedStrategy enum if isinstance(sched_val, int): # Find the SchedStrategy with this value for strategy in SchedStrategy: if strategy.value == sched_val: return strategy # Fallback to PS if value not found return SchedStrategy.PS elif isinstance(sched_val, SchedStrategy): return sched_val else: return SchedStrategy.PS def _has_sync_callers(self, idx: int, callers: List[int]) -> bool: """Check if any callers make synchronous calls to this element.""" lqn = self.lqn # Get entries of this element entries = [] if isinstance(lqn.entriesof, dict): entries = lqn.entriesof.get(idx, []) if not entries: return False # Check for sync callers if hasattr(lqn, 'issynccaller') and lqn.issynccaller is not None: for tidx in callers: for eidx in entries: if isinstance(lqn.issynccaller, np.ndarray): if lqn.issynccaller[tidx - 1, eidx - 1] > 0: return True return True # Default to true for safety def _has_direct_callers_for_caller(self, tidx_caller: int) -> bool: """True if caller task is REF, has sync/async callers, or its entries have open arrivals. Mirrors MATLAB buildLayersRecursive.m:130-154 and JAR SolverLN.java:634-655. """ lqn = self.lqn if self._is_ref_task(tidx_caller): return True entries = self._get_entries_of_task(tidx_caller) for eidx in entries: if hasattr(lqn, 'issynccaller') and lqn.issynccaller is not None: col = np.asarray(lqn.issynccaller) if col.ndim == 2 and eidx < col.shape[1]: if np.any(col[:, eidx] != 0): return True if hasattr(lqn, 'isasynccaller') and lqn.isasynccaller is not None: col = np.asarray(lqn.isasynccaller) if col.ndim == 2 and eidx < col.shape[1]: if np.any(col[:, eidx] != 0): return True if hasattr(lqn, 'arrival') and lqn.arrival \ and eidx in lqn.arrival and lqn.arrival[eidx] is not None: return True return False def _open_arrival_rate_of(self, tidx: int) -> float: """Total exogenous rate into the entries of TIDX, zero unless the arrival is the only way in. A task reached only by an entry arrival has no task layer, because no task calls it, so _update_think_times never gives its caller class a surrogate delay and the class cycles against an Immediate one. Adding an open stream on top of that unthrottled chain saturated lqn_open_arrival: the processor at 0.68 against 0.32 from lqns, lqsim and LDES alike. The chain is the representation that honours the thread pool, so the layer builder drops the stream for these tasks and the chain is closed on this rate instead, exactly as a forwarding target is. With a caller or a forwarding source the stream rides a class of its own and this returns 0. """ lqn = self.lqn if self._is_ref_task(tidx) or not getattr(lqn, 'arrival', None): return 0.0 entries = self._get_entries_of_task(tidx) if not entries: return 0.0 for eidx in entries: for name in ('issynccaller', 'isasynccaller'): mat = getattr(lqn, name, None) if mat is None: continue col = np.asarray(mat) if col.ndim == 2 and eidx < col.shape[1] and np.any(col[:, eidx] != 0): return 0.0 if self._is_forwarding_target_task(tidx): return 0.0 rate = 0.0 for eidx in entries: arv = lqn.arrival.get(eidx) if arv is None: continue m = arv.getMean() if np.isfinite(m) and m > GlobalConstants.FineTol: rate += 1.0 / m return rate def _is_open_arrival_only(self, tidx: int) -> bool: """True when an entry arrival is the only way requests reach task TIDX.""" return self._open_arrival_rate_of(tidx) > GlobalConstants.FineTol def _is_forwarding_target_task(self, tidx: int) -> bool: """True if any entry of this task is the target of a forwarding call. Mirrors MATLAB buildLayersRecursive.m isForwardingTarget (lines 150-156): forwarding targets get host-layer classes even without direct callers. """ lqn = self.lqn if not hasattr(lqn, 'calltype') or lqn.calltype is None: return False entries = self._get_entries_of_task(tidx) if not entries: return False for cidx in range(lqn.ncalls): if cidx < len(lqn.calltype) and int(lqn.calltype[cidx]) == CallType.FWD: if int(lqn.callpair[cidx, 1]) in entries: return True return False def _is_sync_caller_to_entries_of(self, tidx_caller: int, idx: int) -> bool: """True if caller syncs to any entry under element idx. MATLAB line 156 right-side, JAR SolverLN.java:624-630. """ lqn = self.lqn entries = self._get_entries_of_task(idx) if not entries: return False if not hasattr(lqn, 'issynccaller') or lqn.issynccaller is None: return False mat = np.asarray(lqn.issynccaller) if mat.ndim != 2: return False for eidx in entries: if tidx_caller < mat.shape[0] and eidx < mat.shape[1]: if mat[tidx_caller, eidx] != 0: return True return False def _create_classes_and_routing(self, layer_model: Network, idx_set, callers: List[int], is_host_layer: bool, servt_map, thinkt_map, actthinkt_map, arvproc_map, call_map, route_map, reduce_fanout: bool = False, flat: bool = False): """ Create classes and routing for a layer (simplified version). This is a simplified implementation. For 100% parity, the full MATLAB buildLayersRecursive logic would need to be ported. """ lqn = self.lqn # Initialize SetupTask/MAM flags (these are set in _build_layer but # also referenced here for DelayOff handling) use_mam_solver = False function_task_idx = None if is_host_layer and hasattr(lqn, 'hassetup') and lqn.hassetup is not None: all_callers_function = True for caller_idx in callers: if caller_idx < lqn.hassetup.shape[1]: if lqn.hassetup[0, caller_idx] != 1: all_callers_function = False break else: all_callers_function = False break if all_callers_function and len(callers) > 0: function_task_idx = callers[0] if hasattr(lqn, 'setuptime') and lqn.setuptime is not None: if isinstance(lqn.setuptime, dict) and function_task_idx in lqn.setuptime and lqn.setuptime[function_task_idx] is not None: use_mam_solver = True elif isinstance(lqn.setuptime, np.ndarray): flat_setuptime = lqn.setuptime.flatten() if function_task_idx < len(flat_setuptime) and flat_setuptime[function_task_idx] is not None: use_mam_solver = True # Get stations if isinstance(idx_set, (list, tuple, np.ndarray)): idx_set = [int(v) for v in idx_set] else: idx_set = [int(idx_set)] idx = idx_set[0] # layer key: update-map column and ensemble slot srv_stations = layer_model.attribute.get('srv_stations', {}) stations = layer_model.get_nodes() client_delay = None for s in stations: if isinstance(s, Delay): client_delay = s break server_station = srv_stations.get(idx, [None])[0] def servers_for(elem_idx): # Stations of ELEM_IDX when it is a server of this layer, [] otherwise if elem_idx is None: return [] return srv_stations.get(int(elem_idx), []) def all_server_stations(): out = [] for _sid in idx_set: out.extend(srv_stations.get(_sid, [])) return out def host_is_server(tidx_): return bool(servers_for(self._get_parent(tidx_))) if server_station is None: return # Create classes for each caller for tidx_caller in callers: # phase-2a gating mirrors MATLAB buildLayersRecursive.m:156/JAR SolverLN.java:657; activity/call loops unconditional, async-only callers reach ASYNC. caller_on_server = host_is_server(tidx_caller) has_direct_callers = self._has_direct_callers_for_caller(tidx_caller) if caller_on_server else False is_fwd_target = self._is_forwarding_target_task(tidx_caller) if caller_on_server else False # the TASK members of the set, and 0-BASED: hosts occupy 0..nhosts-1 # and tasks nhosts..nhosts+ntasks-1, so the first task sits AT # nhosts. MATLAB's `sidx > lqn.nhosts` is the 1-based form of this # test and reads across unchanged only there. Under `>` the first # task's own layer never sees its callers as layer clients, so it is # built with no closed class and no population at all: on lqn_ofbiz # that is FrontEnd_CPU_Task, whose processor layer then ran 90 jobs # against a zero think time and saturated (Util 1.000 against 0.116). is_sync_caller_to_entries = any(self._is_sync_caller_to_entries_of(tidx_caller, _sid) for _sid in idx_set if _sid >= lqn.nhosts) create_caller_class = (caller_on_server and (has_direct_callers or is_fwd_target)) or is_sync_caller_to_entries if client_delay is None: continue if create_caller_class: # job population: single-replica callers use mult(caller), else mult(caller)*repl(caller); mirrors MATLAB buildLayersRecursive.m:162-168. mult = self._get_mult(tidx_caller) repl = self._get_repl(tidx_caller) caller_is_single_replica = reduce_fanout or (tidx_caller in self.single_replica_tasks) if caller_is_single_replica: njobs = mult else: njobs = mult * repl if np.isinf(njobs): # If caller is infinite server, use sum of its callers' multiplicities callers_of_caller = self._get_callers_of_task(tidx_caller) if callers_of_caller: njobs = sum(self._get_mult(c) * self._get_repl(c) for c in callers_of_caller if not np.isinf(self._get_mult(c) * self._get_repl(c))) if njobs == 0 or np.isinf(njobs): # fallback njobs heuristic capped at 1000 (not MATLAB's 1e6, to bound load-dependent MVA's O(prod(N+1)) state space). lqn = self.lqn mult_arr = lqn.mult.flatten() if hasattr(lqn, 'mult') and lqn.mult is not None else np.array([1.0]) repl_arr = lqn.repl.flatten() if hasattr(lqn, 'repl') and lqn.repl is not None else np.ones_like(mult_arr) finite_mask = np.isfinite(mult_arr) & np.isfinite(repl_arr) if np.any(finite_mask): njobs = min(np.sum(mult_arr[finite_mask] * repl_arr[finite_mask]), 1000) else: njobs = 100 self.njobs[tidx_caller, idx] = njobs caller_name = self._get_hashname(tidx_caller) # Create closed class for this caller caller_class = ClosedClass(layer_model, caller_name, int(njobs), client_delay) # client delay: host layers = think time only; task layers = think time + host demand; call response time handled by CALL classes. if is_host_layer: # Host layer: TASK class client delay = think time only # a served task's declared think time is not a per-request # delay, so the seed carries none either -- see # _ref_think_mean; update_layers replaces this from the # first iteration on think_time = self._ref_think_mean(tidx_caller) # TASK class delay at client = think time only; non-REF tasks with no think time use Immediate (matches MATLAB Exp(0)). if think_time > 0: client_delay.set_service(caller_class, Exp.fit_mean(think_time)) else: client_delay.set_service(caller_class, Immediate()) else: # Task layers: client service = caller's think time + host demand # This represents time the caller spends NOT waiting for this server # a served task's declared think time is not a per-request # delay, so the seed carries none either -- see # _ref_think_mean; update_layers replaces this from the # first iteration on think_time = self._ref_think_mean(tidx_caller) # TASK class at client = think time only (matches MATLAB Layer-1 T1 rate); non-REF/no-think-time tasks use Immediate. if think_time > 0: client_delay.set_service(caller_class, Exp.fit_mean(think_time)) else: client_delay.set_service(caller_class, Immediate()) # Set service at server total_demand = 0.0 if is_host_layer: # Host layer: server is processor, service = caller's activities' host demands activities = self._get_activities_of_task(tidx_caller) for aidx in activities: if self.servtproc[aidx] is not None: proc = self.servtproc[aidx] if isinstance(proc, (int, float, np.integer, np.floating)): total_demand += float(proc) elif hasattr(proc, 'getMean'): total_demand += proc.getMean() elif hasattr(proc, 'mean'): total_demand += proc.mean else: # Task layer: server is called task, service = called entry's service time # This represents time the server spends processing this caller's request total_demand = self._get_initial_call_response_time(tidx_caller, idx) # TASK class service at server is always Disabled in both HOST and TASK layers. for _srv in all_server_stations(): _srv.set_service(caller_class, Disabled()) # Set class attribute (matches MATLAB class.attribute = [type, idx]) caller_class.attribute = [LayeredNetworkElement.TASK, tidx_caller] caller_class.completes = False # matches MATLAB line 188 and JAR line 688 caller_class.setReferenceClass(True) # renormalize residence times using the visits to the task (MATLAB buildLayersRecursive line 150) # Record task attribute layer_model.attribute['tasks'].append([caller_class.get_index(), tidx_caller]) # servt_map only tracks ACTIVITY indices, not TASK; activity entries are added separately in host layers. # only non-REF tasks join thinkt_classes_updmap; REF tasks keep their user-specified think time unchanged (matches MATLAB line 154-155). if not self._is_ref_task(tidx_caller): thinkt_map[idx].append([idx, tidx_caller, 1, caller_class.get_index()]) # Create ENTRY classes for each entry of this caller (matches MATLAB buildLayersRecursive lines 158-173) entries = self._get_entries_of_task(tidx_caller) if 'entries' not in layer_model.attribute: layer_model.attribute['entries'] = [] source_station = layer_model.attribute.get('source_station') for eidx in entries: entry_name = self._get_hashname(eidx) entry_class = ClosedClass(layer_model, entry_name, 0, client_delay) # ENTRY class: Immediate at client, Disabled at server client_delay.set_service(entry_class, Immediate()) for _srv in all_server_stations(): _srv.set_service(entry_class, Disabled()) entry_class.attribute = [LayeredNetworkElement.ENTRY, eidx] entry_class.completes = False layer_model.attribute['entries'].append([entry_class.get_index(), eidx]) # entry open-arrival distribution creates OpenClass on layer's Source/Sink; mirrors MATLAB buildLayersRecursive.m:214-255/JAR SolverLN.java:718-750. if source_station is not None and eidx in lqn.arrival and lqn.arrival[eidx] is not None \ and not self._is_open_arrival_only(tidx_caller): open_class = OpenClass(layer_model, entry_name + '_Open', 0) source_station.set_arrival(open_class, lqn.arrival[eidx]) client_delay.set_service(open_class, Disabled()) # entries have Immediate servtproc; use the first bound activity's host demand as the initial server estimate (refined via servt_classes_updmap). bound_act_svc = None if hasattr(lqn, 'graph') and lqn.graph is not None and isinstance(lqn.graph, np.ndarray): if eidx < lqn.graph.shape[0]: for cand in range(lqn.graph.shape[1]): if lqn.graph[eidx, cand] > 0: if cand < len(self.servtproc) and self.servtproc[cand] is not None: bound_act_svc = self.servtproc[cand] break if bound_act_svc is None: bound_act_svc = self.servtproc[eidx] if self.servtproc[eidx] is not None else Immediate() all_servers = layer_model.attribute.get('server_stations', [server_station]) for srv in all_servers: srv.set_service(open_class, bound_act_svc) open_class.attribute = [LayeredNetworkElement.ENTRY, eidx] open_class.completes = False layer_model.attribute['entry_open_classes'].append((open_class, eidx)) # arvproc_classes_updmap uses negative eidx convention; mirrors JAR SolverLN.java:745 / MATLAB buildLayersRecursive.m:250. src_node_idx = layer_model.get_nodes().index(source_station) + 1 arvproc_map[idx].append([idx, -eidx, src_node_idx, open_class.get_index()]) # Create ACTIVITY classes for each activity of this caller (matches MATLAB buildLayersRecursive) activities = self._get_activities_of_task(tidx_caller) if 'activities' not in layer_model.attribute: layer_model.attribute['activities'] = [] for aidx in activities: act_stations = servers_for(self._get_parent(self._get_parent(aidx))) if flat \ else (srv_stations.get(idx, []) if is_host_layer else []) # the activity's demand belongs on its own processor's station act_stn = act_stations[0] if act_stations else server_station if act_stations or any(self._has_sync_callers(_sid, callers) for _sid in idx_set): activity_name = self._get_hashname(aidx) activity_class = ClosedClass(layer_model, activity_name, 0, client_delay) if act_stations: # the activity runs on a server of this layer, so its demand sits there and the client is Disabled; mirrors MATLAB buildLayersRecursive ~line 236. client_delay.set_service(activity_class, Disabled()) if aidx < len(self.servtproc) and self.servtproc[aidx] is not None: base_proc = self.servtproc[aidx] base_mean = 0.0 if hasattr(base_proc, 'getMean'): base_mean = base_proc.getMean() elif hasattr(base_proc, 'mean'): base_mean = base_proc.mean elif isinstance(base_proc, (int, float)): base_mean = float(base_proc) # A SetupTask's cold start is NOT wired into the layer # station any more, and it is not folded into the host # demand either: it is charged to the entry with # probability p by _setup_charge. Wiring it here routed # the layer through the open M/G/1-with-setup QBD, which # powers the thread down far more often than a closed # layer does, and charged the delay to the ACTIVITY, # where it is not host demand. # The host layer keeps the real host-demand Distribution # (not an Exp fit), so moment2/moment3 do not collapse. act_stn.set_service(activity_class, base_proc) else: act_stn.set_service(activity_class, Exp.fit_mean(0.001)) else: # task layer: activities process at CLIENT with host demand (the caller's own activity time), mirrors MATLAB buildLayersRecursive. if aidx < len(self.servtproc) and self.servtproc[aidx] is not None: proc = self.servtproc[aidx] if hasattr(proc, 'getMean'): hostdem = proc.getMean() elif hasattr(proc, 'mean'): hostdem = proc.mean else: hostdem = 0.0 # Handle zero/negative hostdem - use Immediate for zero demand if hostdem > 0: client_delay.set_service(activity_class, Exp.fit_mean(hostdem)) else: client_delay.set_service(activity_class, Immediate()) else: client_delay.set_service(activity_class, Immediate()) act_stn.set_service(activity_class, Disabled()) activity_class.attribute = [LayeredNetworkElement.ACTIVITY, aidx] activity_class.completes = False layer_model.attribute['activities'].append([activity_class.get_index(), aidx]) # Add servt_map entry for activity classes in host layers (matches MATLAB line 484) # servt_classes_updmap stores: [model_idx, activity_lqn_idx, node_idx, class_idx] if act_stations: _hidx = self._get_parent(self._get_parent(aidx)) if flat else idx servt_map[idx].append([idx, aidx, layer_model.attribute['serverIdxOf'].get(int(_hidx), layer_model.attribute['serverIdx']), activity_class.get_index()]) else: # task layer: activity service at client updates from thinkt_map (host processor response time); mirrors MATLAB buildLayersRecursive:585-586. thinkt_map[idx].append([idx, aidx, 1, activity_class.get_index()]) # host-layer-only aux think-time class: other layers carry think time in servtproc, so adding it here would double-charge it (U(P2) 9x high). if (act_stations and hasattr(self, 'actthinkproc') and aidx < len(self.actthinkproc) and self.actthinkproc[aidx] is not None): think_name = self._get_hashname(aidx) + '.Think' think_class = ClosedClass(layer_model, think_name, 0, client_delay) think_class.completes = False think_class.attribute = [LayeredNetworkElement.ACTIVITY, aidx] client_delay.set_service(think_class, self.actthinkproc[aidx]) for _srv in all_server_stations(): _srv.set_service(think_class, Disabled()) actthinkt_map[idx].append([idx, aidx, 1, think_class.get_index()]) # Create CALL classes for sync calls from this caller's activities (matches MATLAB lines 287-302) if 'calls' not in layer_model.attribute: layer_model.attribute['calls'] = [] _cgroup_by_cidx, _cgroup_members = self._call_groups_by_cidx() _group_classes = layer_model.attribute.setdefault('call_group_classes', {}) for aidx in activities: if isinstance(self.lqn.callsof, dict): calls = self.lqn.callsof.get(aidx, []) else: calls = [] for cidx in calls: # Check if this is a SYNC call is_sync = True if hasattr(self.lqn, 'calltype') and self.lqn.calltype is not None: if isinstance(self.lqn.calltype, np.ndarray): # calltype is 1-indexed (like MATLAB), so use cidx directly calltype = self.lqn.calltype.flatten()[cidx] if cidx < len(self.lqn.calltype.flatten()) else CallType.SYNC elif isinstance(self.lqn.calltype, dict): calltype = self.lqn.calltype.get(cidx, CallType.SYNC) else: calltype = CallType.SYNC is_sync = (calltype == CallType.SYNC) if is_sync: # A routed call group is ONE dispatch with n destinations, not # n calls: its members share the dispatch class, which is the # class the strategy routes and the one that visits the targets # (its per-station service carries the per-target service time). # The strategy is a property of a NODE and routes over that # node's links, so the choice is made at a router whose only # links are the group's targets; the hop itself must not switch # class, because a state-dependent routing function is evaluated # at zero off the class diagonal. The class switch goes on the # return arc, into a group class the job continues in. _grp = _cgroup_by_cidx.get(cidx) if _grp is not None: _gid = _grp[0] if _gid in _group_classes: call_class = _group_classes[_gid][0] _group_reuse = True else: _disp_name = self._get_hashname(aidx) + '.Dispatch%d' % _gid _router = Router(layer_model, _disp_name + '.Router') call_class = ClosedClass(layer_model, _disp_name, 0, client_delay) client_delay.set_service(call_class, Immediate()) for _srv in all_server_stations(): _srv.set_service(call_class, Disabled()) return_class = ClosedClass( layer_model, self._get_call_hashname(cidx) + '.Group%d' % _gid, 0, client_delay) return_class.completes = False return_class.attribute = [LayeredNetworkElement.CALL, cidx] client_delay.set_service(return_class, Immediate()) for _srv in all_server_stations(): _srv.set_service(return_class, Disabled()) _group_classes[_gid] = (call_class, return_class, _grp[1], _router) _group_reuse = False else: _group_reuse = False call_name = self._get_call_hashname(cidx) call_class = ClosedClass(layer_model, call_name, 0, client_delay) layer_model.attribute.setdefault('call_class_of_cidx', {})[cidx] = call_class.get_index() # Get call mean for Aux class creation (MATLAB lines 305-315) call_mean = self._get_call_mean(cidx) nreplicas = 1 # Typically 1, could be based on processor replication # Create Aux class for fractional call means (matches MATLAB lines 308-314). # A group member's mean is the 1/n share of the dispatch, which the # n-way split already carries, so the Aux skip path must not also fire. aux_class = None if call_mean != 1 and _grp is None: aux_name = call_name + '.Aux' aux_class = ClosedClass(layer_model, aux_name, 0, client_delay) aux_class.completes = False aux_class.attribute = [LayeredNetworkElement.CALL, cidx] # Same attribute as call class client_delay.set_service(aux_class, Immediate()) for _srv in all_server_stations(): _srv.set_service(aux_class, Disabled()) # Track aux class: [class_index, cidx, call_mean] if 'aux_classes' not in layer_model.attribute: layer_model.attribute['aux_classes'] = [] layer_model.attribute['aux_classes'].append([aux_class.get_index(), cidx, call_mean]) # Get call service time (callservtproc) tgt_eidx = self._get_call_target_entry(cidx) tgt_tidx = self._get_parent(tgt_eidx) if tgt_eidx else None # minRespT for server = sum of activities' hostdem in host layers (processor, no activities -> 0) or the server task's activities in task layers. tgt_stations = servers_for(tgt_tidx) seed_idx = tgt_tidx if flat else idx if flat: minRespT = self._get_initial_task_total_hostdem(tgt_tidx) if tgt_tidx else 0.0 elif is_host_layer: # Host processor has no activities - minRespT = 0 minRespT = 0.0 else: # Task layer: server is a task with activities minRespT = self._get_initial_task_total_hostdem(idx) if idx else 0.0 # CALL class service times: a call to a task that is a server of # this layer is served THERE, any other call is a delay at the client. call_to_server = bool(tgt_stations) if call_to_server: # Call to this layer's server - service at SERVER # MATLAB line 727: clientDelay.setService(cidxClass{cidx}, Immediate.getInstance()) client_delay.set_service(call_class, Immediate()) for _srv in tgt_stations: if cidx < len(self.callservtproc) and self.callservtproc[cidx] is not None: _srv.set_service(call_class, self.callservtproc[cidx]) else: _srv.set_service(call_class, Immediate()) # Record the station of the called task for call_classes_updmap call_map[idx].append([idx, cidx, layer_model.attribute['serverIdxOf'][int(tgt_tidx)], call_class.get_index()]) else: # Call to another task - service at CLIENT (MATLAB lines 750, 804) # MATLAB: clientDelay.setService(cidxClass{cidx}, callservtproc{cidx}) if cidx < len(self.callservtproc) and self.callservtproc[cidx] is not None: client_delay.set_service(call_class, self.callservtproc[cidx]) else: client_delay.set_service(call_class, Immediate()) # MATLAB keeps server at Exp.fitMean(minRespT) which is 1e-8 for minRespT=0 # This is set initially at lines 299-300 and NOT changed in the routing setup for _srv in all_server_stations(): _srv.set_service(call_class, Exp.fit_mean(max(minRespT, 1e-8))) # Record with clientIdx=1 for call_classes_updmap (MATLAB lines 751, 805) call_map[idx].append([idx, cidx, 1, call_class.get_index()]) if not _group_reuse: call_class.attribute = [LayeredNetworkElement.CALL, cidx] call_class.completes = False # Track call: [class_index, cidx, src_aidx, tgt_eidx, aux_class_index] src_aidx = aidx aux_class_idx = aux_class.get_index() if aux_class else -1 layer_model.attribute['calls'].append([call_class.get_index(), cidx, src_aidx, tgt_eidx if tgt_eidx else 0, aux_class_idx]) # SYNC forwarding-chain classes unnecessary: rewritten caller-side; see _kb/06-solver-catalog.md LN Forwarding as caller-side pseudo-rendezvous. else: # ASYNC call fires only when target entry's task matches this layer's server; mirrors MATLAB buildLayersRecursive.m:299-324/JAR SolverLN.java:780-814. tgt_eidx_async = self._get_call_target_entry(cidx) tgt_parent = self._get_parent(tgt_eidx_async) if tgt_eidx_async else None if tgt_parent != idx: continue # Lazy-create Source/Sink for async-only layers # (MATLAB line 301-306 hasSource branch). source_station = layer_model.attribute.get('source_station') sink_station = layer_model.attribute.get('sink_station') if layer_model.attribute.get('has_fork', False): raise ValueError(f"SolverLN: layer '{layer_model.getName()}' carries both an " "AND fork and an open stream (an async call or an entry " "arrival); the fork-join transform needs a Source of its own") if source_station is None: source_station = Source(layer_model, 'Source') sink_station = Sink(layer_model, 'Sink') layer_model.attribute['source_station'] = source_station layer_model.attribute['sink_station'] = sink_station call_name = self._get_call_hashname(cidx) open_class = OpenClass(layer_model, call_name, 0) # async-call Source arrival starts Immediate, refreshed from the caller activity's tputproc; mirrors JAR:789/MATLAB:308. source_station.set_arrival(open_class, Immediate()) client_delay.set_service(open_class, Disabled()) # async-call server service starts Immediate, upper-bounded by sum of hostdem over the target's activities; mirrors MATLAB:317-323/JAR:801-812. minRespT = 0.0 activities_of_idx = self._get_activities_of_task(idx) for tidx_act in activities_of_idx: if tidx_act < len(self.servtproc) and self.servtproc[tidx_act] is not None: proc = self.servtproc[tidx_act] if hasattr(proc, 'getMean'): minRespT += proc.getMean() elif hasattr(proc, 'mean'): minRespT += proc.mean elif isinstance(proc, (int, float, np.integer, np.floating)): minRespT += float(proc) all_servers = layer_model.attribute.get('server_stations', [server_station]) if minRespT > 0: srv_service = Exp.fit_mean(minRespT) else: srv_service = Immediate() for srv in all_servers: srv.set_service(open_class, srv_service) open_class.attribute = [LayeredNetworkElement.CALL, cidx] open_class.completes = False # Stash for routing phase call_mean_async = self._get_call_mean(cidx) layer_model.attribute['async_open_classes'].append( (open_class, cidx, call_mean_async) ) # async calls use POSITIVE cidx in arvproc_classes_updmap; mirrors MATLAB:427/JAR:871. src_node_idx = layer_model.get_nodes().index(source_station) + 1 arvproc_map[idx].append([idx, cidx, src_node_idx, open_class.get_index()]) # call_classes_updmap records server-side class for # each replica (MATLAB 428-430, JAR 872-876). for srv in all_servers: srv_node_idx = layer_model.get_nodes().index(srv) + 1 call_map[idx].append([idx, cidx, srv_node_idx, open_class.get_index()]) # Track in layer calls list for consistency with SYNC path layer_model.attribute['calls'].append( [open_class.get_index(), cidx, aidx, tgt_eidx_async if tgt_eidx_async else 0, -1] ) # Configure cache node for cache layers (MATLAB buildLayersRecursive.m lines 548-561) if is_host_layer and layer_model.attribute.get('iscachelayer') and layer_model.attribute.get('cacheNode'): self._configure_cache_node(layer_model, idx, callers) # Link the model with routing self._setup_routing(layer_model, idx, route_map) def _configure_cache_node(self, layer_model: Network, idx: int, callers: List[int]): """ Configure the cache node with hit/miss classes and access probabilities. This matches MATLAB buildLayersRecursive.m lines 548-561: - setReadItemEntry: set item access probability for the entry class - setHitClass: map input class to hit output class - setMissClass: map input class to miss output class Args: layer_model: The layer Network containing the Cache node idx: Layer index (task/processor absolute index) callers: List of caller task indices on this layer """ lqn = self.lqn cache_node = layer_model.attribute.get('cacheNode') if cache_node is None: return # Get the cache task index (first caller that is a cache task) cache_task_idx = None for caller_idx in callers: if hasattr(lqn, 'iscache') and lqn.iscache is not None: iscache_arr = lqn.iscache.flatten() if isinstance(lqn.iscache, np.ndarray) else lqn.iscache if caller_idx < len(iscache_arr) and iscache_arr[caller_idx]: cache_task_idx = caller_idx break if cache_task_idx is None: return # Find the ItemEntry associated with this cache task # In MATLAB: the entry bound to the cache activity has lqn.itemproc set item_entry_idx = None item_access_prob = None entries = self._get_entries_of_task(cache_task_idx) for eidx in entries: if hasattr(lqn, 'itemproc') and isinstance(lqn.itemproc, dict): if eidx in lqn.itemproc and lqn.itemproc[eidx] is not None: item_entry_idx = eidx item_access_prob = lqn.itemproc[eidx] break if item_entry_idx is None: return # Find the cache entry activity (bound to ItemEntry) cache_entry_aidx = None activities = self._get_activities_of_task(cache_task_idx) for aidx in activities: # Check if this activity is bound to the item entry bound_entry = self._get_activity_bound_entry(aidx) if bound_entry == item_entry_idx: cache_entry_aidx = aidx break if cache_entry_aidx is None: return # Find hit/miss activities from the graph (successors of cache entry activity) # MATLAB: lqn.hitmissaidx = find(lqn.graph(nextaidx,:)) hit_aidx = None miss_aidx = None if hasattr(lqn, 'graph') and lqn.graph is not None: successors = [] for j in range(lqn.graph.shape[1]): if lqn.graph[cache_entry_aidx, j] != 0: successors.append(j) # MATLAB convention: first successor is hit, second is miss # (matches buildLayersRecursive.m lines 552-553) if len(successors) >= 2: hit_aidx = successors[0] miss_aidx = successors[1] elif len(successors) == 1: # If only one successor, assume it's miss (cache always misses) miss_aidx = successors[0] # Build mapping from activity index to class object # layer_model.classes is a list (0-indexed internally but class.get_index() returns 1-indexed) classes_list = layer_model.classes if hasattr(layer_model, 'classes') else [] activity_to_class = {} if 'activities' in layer_model.attribute: for class_info in layer_model.attribute['activities']: if len(class_info) >= 2: class_idx, act_idx = class_info[0], class_info[1] # class_idx is 1-indexed from get_index(), convert to 0-indexed for list access list_idx = class_idx - 1 if class_idx > 0 else 0 if 0 <= list_idx < len(classes_list): activity_to_class[act_idx] = classes_list[list_idx] # Get the cache entry class entry_class = activity_to_class.get(cache_entry_aidx) if entry_class is None: # Try to find entry class from 'entries' attribute if 'entries' in layer_model.attribute: for class_info in layer_model.attribute['entries']: if len(class_info) >= 2: class_idx, entry_idx = class_info[0], class_info[1] if entry_idx == item_entry_idx: list_idx = class_idx - 1 if class_idx > 0 else 0 if 0 <= list_idx < len(classes_list): entry_class = classes_list[list_idx] break if entry_class is None: return # Set up hit/miss classes hit_class = activity_to_class.get(hit_aidx) if hit_aidx else None miss_class = activity_to_class.get(miss_aidx) if miss_aidx else None if hit_class: cache_node.set_hit_class(entry_class, hit_class) if miss_class: cache_node.set_miss_class(entry_class, miss_class) # Set up access probability # The item_access_prob should be a DiscreteSampler or similar distribution if item_access_prob is not None: cache_node.set_read(entry_class, item_access_prob) # delayed-hit retrieval cache wiring (EXPERIMENTAL); see _kb/06-solver-catalog.md LN Delayed-hit retrieval cache wiring. hasretr = getattr(lqn, 'hasretrieval', None) if (hasretr is not None and cache_task_idx < hasretr.shape[0] and hasretr[cache_task_idx, 0] != 0 and entry_class is not None and miss_class is not None and miss_aidx is not None): fetch = Queue(layer_model, str(cache_node.name) + '.Fetch', SchedStrategy.PS) layer_model.attribute['retrieval_wiring'] = { 'read_class': entry_class, 'miss_class': miss_class, 'miss_aidx': miss_aidx, 'fetch': fetch, 'cache_node': cache_node, } def _get_activity_bound_entry(self, aidx: int) -> Optional[int]: """Get the entry index that an activity is bound to.""" lqn = self.lqn # Check replygraph - if activity replies to an entry, it's bound to that entry's task if hasattr(lqn, 'replygraph') and lqn.replygraph is not None: # replygraph is (nacts x nentries), rows are activities (relative index) act_rel = aidx - lqn.ashift # Convert to relative activity index if 0 <= act_rel < lqn.replygraph.shape[0]: for e in range(lqn.replygraph.shape[1]): entry_abs = e + lqn.eshift # Check parent relationship as fallback pass # Check graph for direct entry->activity edge (activity bound to entry) if hasattr(lqn, 'graph') and lqn.graph is not None: for eidx in range(lqn.eshift, lqn.ashift): if lqn.graph[eidx, aidx] != 0: return eidx return None def _get_initial_call_response_time(self, caller_tidx: int, layer_idx: int) -> float: """ Get initial call response time estimate for a caller in a layer. For the first iteration, this uses the host demand of called entries. After iterations start, this is updated with actual response times. """ lqn = self.lqn total_call_time = 0.0 if not hasattr(lqn, 'callpair') or lqn.callpair is None: return 0.0 # Find all synch calls from this caller's activities activities = self._get_activities_of_task(caller_tidx) for aidx in activities: if isinstance(lqn.callsof, dict): calls = lqn.callsof.get(aidx, []) else: calls = [] for cidx in calls: # Check call type - assume SYNC if calltype not available is_sync = True if hasattr(lqn, 'calltype') and lqn.calltype is not None: if isinstance(lqn.calltype, np.ndarray): calltype = lqn.calltype.flatten()[cidx] if cidx < len(lqn.calltype.flatten()) else CallType.SYNC elif isinstance(lqn.calltype, dict): calltype = lqn.calltype.get(cidx, CallType.SYNC) else: calltype = CallType.SYNC is_sync = (calltype == CallType.SYNC) if is_sync: # Get target entry (column 2 of callpair) tgt_eidx = self._get_call_target_entry(cidx) if tgt_eidx is None or tgt_eidx == 0: continue # Check if this call targets the server in this layer tgt_tidx = self._get_parent(tgt_eidx) if tgt_tidx != layer_idx: continue # Get call mean (number of calls) call_mean = self._get_call_mean(cidx) # Get initial response time = entry service time (recursive) entry_resp = self._get_initial_entry_service_time(tgt_eidx, visited=set()) total_call_time += call_mean * entry_resp return total_call_time def _get_initial_entry_service_time(self, eidx: int, visited: set = None) -> float: """ Compute initial entry service time recursively. Includes: - Sum of activities' host demands bound to this entry - Plus call_mean * target_entry_service_time for all downstream synch calls Uses memoization via visited set to avoid infinite loops. """ if visited is None: visited = set() if eidx in visited: return 0.0 # Avoid infinite recursion visited.add(eidx) lqn = self.lqn total_time = 0.0 # Get activities bound to this entry tgt_activities = self._get_activities_of_entry(eidx) for aidx in tgt_activities: # Add activity's host demand if self.servtproc[aidx] is not None: proc = self.servtproc[aidx] if isinstance(proc, (int, float, np.integer, np.floating)): total_time += float(proc) elif hasattr(proc, 'getMean'): total_time += proc.getMean() elif hasattr(proc, 'mean'): total_time += proc.mean # Add downstream call response times if isinstance(lqn.callsof, dict): calls = lqn.callsof.get(aidx, []) else: calls = [] for cidx in calls: # Check if this is a synch call is_sync = True if hasattr(lqn, 'calltype') and lqn.calltype is not None: if isinstance(lqn.calltype, np.ndarray): calltype = lqn.calltype.flatten()[cidx] if cidx < len(lqn.calltype.flatten()) else CallType.SYNC elif isinstance(lqn.calltype, dict): calltype = lqn.calltype.get(cidx, CallType.SYNC) else: calltype = CallType.SYNC is_sync = (calltype == CallType.SYNC) if is_sync: # Get target entry target_eidx = self._get_call_target_entry(cidx) if target_eidx is not None and target_eidx > 0: call_mean = self._get_call_mean(cidx) # Recursively get target entry's service time target_resp = self._get_initial_entry_service_time(target_eidx, visited.copy()) total_time += call_mean * target_resp return total_time def _get_initial_task_total_hostdem(self, tidx: int) -> float: """ Get total host demand of all activities of a task. This matches MATLAB buildLayersRecursive lines 296-302: minRespT = 0; for tidx_act = lqn.actsof{idx} minRespT = minRespT + lqn.hostdem{tidx_act}.getMean; end This provides an upper bound on the task's response time for initial service time estimates. """ lqn = self.lqn total_hostdem = 0.0 # Get all activities of this task activities = self._get_activities_of_task(tidx) for aidx in activities: # Use lqn.hostdem (host CPU demand) NOT self.servtproc (service time) # This matches MATLAB's lqn.hostdem{tidx_act}.getMean if aidx in lqn.hostdem: hostdem_val = lqn.hostdem[aidx] if isinstance(hostdem_val, (int, float, np.integer, np.floating)): total_hostdem += float(hostdem_val) elif hasattr(hostdem_val, 'getMean'): total_hostdem += hostdem_val.getMean() elif hasattr(hostdem_val, 'mean'): total_hostdem += hostdem_val.mean elif hasattr(hostdem_val, 'get_mean'): total_hostdem += hostdem_val.get_mean() return total_hostdem def _get_entry_service_matrix(self) -> np.ndarray: """ Build entry service matrix (matches MATLAB getEntryServiceMatrix). Returns a matrix U of shape (nidx + ncalls, nidx + ncalls) where: - U[eidx, aidx] = probability that activity aidx contributes to entry eidx's service time - U[eidx, nidx + cidx] = probability that call cidx contributes to entry eidx's service time The entry service time is then computed as: entry_servt = U @ [residt; callresidt] NOTE: Unlike MATLAB which binarizes the matrix, Python preserves the probabilities from the LQN graph. This is because Python doesn't have full CacheNode support, so the hit/miss probabilities need to be applied via the servtmatrix. """ lqn = self.lqn size = lqn.nidx + lqn.ncalls U = np.zeros((size, size)) # For each entry, recursively trace the activity graph for e in range(lqn.nentries): eidx = lqn.eshift + e self._entry_service_matrix_recursion(eidx, eidx, U, 1.0) # Binarize the matrix (matches MATLAB: U = double(U > 0)) # This prevents accumulation of probabilities from multiple paths U = (U > 0).astype(float) return U def _entry_service_matrix_recursion(self, aidx: int, eidx: int, U: np.ndarray, prob: float = 1.0, visited: set = None): """ Auxiliary function to build entry service matrix recursively. Traverses the activity graph from aidx, marking all activities and calls that contribute to entry eidx's service time, weighted by probability. Uses a visited set to detect and break cycles in the activity graph. Args: aidx: Current activity index eidx: Entry index we're building service time for U: Service matrix to update prob: Cumulative probability of reaching this activity from entry visited: Set of already-visited activity indices for cycle detection """ if visited is None: visited = set() visited = visited | {aidx} lqn = self.lqn graph = lqn.graph # Find next activities in the graph if aidx >= len(graph): return # Get all successors of current activity nextaidxs = np.where(graph[aidx, :] > 0)[0] for nextaidx in nextaidxs: # Check if this is a loop edge (graph differs from dag) # MATLAB: isLoop = (lqn.graph(aidx,nextaidx) ~= lqn.dag(aidx,nextaidx)) is_loop = False if hasattr(lqn, 'dag') and lqn.dag is not None: if isinstance(lqn.dag, np.ndarray) and aidx < lqn.dag.shape[0] and nextaidx < lqn.dag.shape[1]: is_loop = (graph[aidx, nextaidx] != lqn.dag[aidx, nextaidx]) # Detect cycles: skip if we've already visited this node in the current path if nextaidx in visited: is_loop = True # Get parent of current and next nodes parent_aidx = self._get_parent(aidx) parent_nextaidx = self._get_parent(nextaidx) # Get edge probability edge_prob = graph[aidx, nextaidx] # Cumulative probability = path probability * edge probability next_prob = prob * edge_prob # If parents differ, this is a call to another task/entry if parent_aidx != parent_nextaidx: # Process calls from this activity if isinstance(lqn.callsof, dict): calls = lqn.callsof.get(aidx, []) else: calls = [] for cidx in calls: # Check call type - only SYNC calls contribute to response time is_sync = True if hasattr(lqn, 'calltype') and lqn.calltype is not None: if isinstance(lqn.calltype, np.ndarray): # calltype is 1-indexed, so use cidx directly if cidx < len(lqn.calltype.flatten()): calltype = lqn.calltype.flatten()[cidx] is_sync = (calltype == CallType.SYNC) if is_sync: # U(eidx,nidx+cidx)=1: mean number of calls is already factored into callresidt via visits. U[eidx, lqn.nidx + cidx] = 1 # If parents are the same, this is an activity within the same task if parent_aidx == parent_nextaidx: if nextaidx != aidx and not is_loop: # Mark activity as contributing to entry with cumulative probability # Use max to handle multiple paths to same activity U[eidx, nextaidx] = max(U[eidx, nextaidx], next_prob) # Recurse to process the rest of the graph self._entry_service_matrix_recursion(nextaidx, eidx, U, next_prob, visited) def _get_initial_call_response_time_for_task(self, tidx: int) -> float: """ Get initial total call response time for a task. This is the sum of (call_mean * target_entry_service_time) for all synch calls from this task's activities. """ lqn = self.lqn total_call_time = 0.0 # Find all activities of this task activities = self._get_activities_of_task(tidx) for aidx in activities: # Get calls from this activity if isinstance(lqn.callsof, dict): calls = lqn.callsof.get(aidx, []) else: calls = [] for cidx in calls: # Check if this is a synch call is_sync = True if hasattr(lqn, 'calltype') and lqn.calltype is not None: if isinstance(lqn.calltype, np.ndarray): calltype = lqn.calltype.flatten()[cidx] if cidx < len(lqn.calltype.flatten()) else CallType.SYNC elif isinstance(lqn.calltype, dict): calltype = lqn.calltype.get(cidx, CallType.SYNC) else: calltype = CallType.SYNC is_sync = (calltype == CallType.SYNC) if is_sync: # Get target entry target_eidx = self._get_call_target_entry(cidx) if target_eidx is not None and target_eidx > 0: call_mean = self._get_call_mean(cidx) # Recursively get target entry's service time target_resp = self._get_initial_entry_service_time(target_eidx, visited=set()) total_call_time += call_mean * target_resp return total_call_time def _get_host_layer_response_time(self, tidx: int) -> float: """ Get the host layer response time for a task. This is the response time at the processor from the host layer results. Falls back to host demand if results not available. """ lqn = self.lqn # Find the host of this task hidx = self._get_parent(tidx) if hidx is None or hidx == 0: return self._get_task_total_host_demand(tidx) # Get host layer results if np.isnan(self.idxhash[hidx]): return self._get_task_total_host_demand(tidx) host_layer_idx = int(self.idxhash[hidx]) if len(self.results) == 0 or host_layer_idx >= len(self.results[-1]): return self._get_task_total_host_demand(tidx) result = self.results[-1][host_layer_idx] if result is None or 'RN' not in result: return self._get_task_total_host_demand(tidx) RN = result['RN'] server_idx = self.ensemble[host_layer_idx].attribute.get('serverIdx', 1) if server_idx is None: return self._get_task_total_host_demand(tidx) server_idx_0 = server_idx - 1 if server_idx >= 1 else 0 if server_idx_0 >= RN.shape[0]: return self._get_task_total_host_demand(tidx) # Find this task's class in the host layer caller_class_idx = self._find_caller_class_in_layer(tidx, host_layer_idx) if caller_class_idx is not None: caller_class_idx_0 = caller_class_idx - 1 if caller_class_idx >= 1 else 0 if caller_class_idx_0 < RN.shape[1]: return RN[server_idx_0, caller_class_idx_0] # Fallback to host demand return self._get_task_total_host_demand(tidx) def _get_activities_of_entry(self, eidx: int) -> List[int]: """Get all activities belonging to an entry. This includes: - The activity directly bound to the entry (edge from entry to activity) - All successor activities reachable via precedence edges (until reaching an activity that makes a call or replies to an entry) """ lqn = self.lqn activities = [] # Get task of this entry tidx = self._get_parent(eidx) if tidx is None: return activities # Get all activities of the task all_activities = set(self._get_activities_of_task(tidx)) if not hasattr(lqn, 'graph') or lqn.graph is None: return activities # Find the activity directly bound to this entry bound_activity = None for aidx in all_activities: if isinstance(lqn.graph, np.ndarray): if eidx < lqn.graph.shape[0] and aidx < lqn.graph.shape[1]: if lqn.graph[eidx, aidx] > 0: bound_activity = aidx break if bound_activity is None: return activities # Follow precedence chain from bound activity # Use BFS to find all reachable activities within this task visited = set() queue = [bound_activity] while queue: aidx = queue.pop(0) if aidx in visited: continue visited.add(aidx) activities.append(aidx) # Find successor activities (in the same task) if isinstance(lqn.graph, np.ndarray) and aidx < lqn.graph.shape[0]: for succ in range(lqn.graph.shape[1]): if lqn.graph[aidx, succ] > 0: # Check if successor is an activity in the same task if succ in all_activities and succ not in visited: queue.append(succ) return activities def _get_mult(self, idx: int) -> float: """Get multiplicity (job count) for an element. Matches MATLAB buildLayersRecursive line 7-8, 129: mult = lqn.maxmult; % this removes spare capacity that cannot be used lqn.mult = mult; ... njobs = mult(tidx_caller)*lqn.repl(tidx_caller); Uses maxmult because MATLAB replaces mult with maxmult at start of buildLayersRecursive to "remove spare capacity that cannot be used". """ lqn = self.lqn # Use maxmult if available (MATLAB line 7: mult = lqn.maxmult) if hasattr(lqn, 'maxmult') and lqn.maxmult is not None: if isinstance(lqn.maxmult, dict): return lqn.maxmult.get(idx, 1) elif isinstance(lqn.maxmult, np.ndarray): flat_maxmult = lqn.maxmult.flatten() if idx < len(flat_maxmult): return float(flat_maxmult[idx]) # Fallback to mult if maxmult not available if hasattr(lqn, 'mult') and lqn.mult is not None: if isinstance(lqn.mult, dict): return lqn.mult.get(idx, 1) elif isinstance(lqn.mult, np.ndarray): # Handle 2D arrays (shape like (1, n)) flat_mult = lqn.mult.flatten() if idx < len(flat_mult): return float(flat_mult[idx]) return 1.0 def _get_repl(self, idx: int) -> float: """Get replication factor for an element (matches MATLAB lqn.repl).""" lqn = self.lqn if hasattr(lqn, 'repl') and lqn.repl is not None: if isinstance(lqn.repl, dict): return lqn.repl.get(idx, 1) elif isinstance(lqn.repl, np.ndarray): flat_repl = lqn.repl.flatten() if idx < len(flat_repl): val = float(flat_repl[idx]) return val if val > 0 else 1.0 return 1.0 def _get_activities_of_task(self, tidx: int) -> List[int]: """Get activity indices for a task.""" lqn = self.lqn if isinstance(lqn.actsof, dict): return lqn.actsof.get(tidx, []) return [] def _get_entries_of_task(self, tidx: int) -> List[int]: """Get entry indices for a task.""" lqn = self.lqn if isinstance(lqn.entriesof, dict): return lqn.entriesof.get(tidx, []) return [] def _get_call_hashname(self, cidx: int) -> str: """Get hash name for a call (e.g., 'AS2=>E:E2' for sync calls).""" lqn = self.lqn if not hasattr(lqn, 'callpair') or lqn.callpair is None: return f'Call_{cidx}' # callpair format: [src_aidx, tgt_eidx, mean] (columns 0 and 1 are src and tgt) if isinstance(lqn.callpair, np.ndarray): if cidx < len(lqn.callpair) and lqn.callpair.ndim > 1: src_aidx = int(lqn.callpair[cidx, 0]) # Column 0 = source activity tgt_eidx = int(lqn.callpair[cidx, 1]) # Column 1 = target entry else: return f'Call_{cidx}' elif isinstance(lqn.callpair, dict): pair = lqn.callpair.get(cidx, [0, 0, 0, 0]) src_aidx = int(pair[0]) if len(pair) > 0 else 0 tgt_eidx = int(pair[1]) if len(pair) > 1 else 0 else: return f'Call_{cidx}' # Get call type (default to SYNC if calltype not available) calltype = CallType.SYNC if hasattr(lqn, 'calltype') and lqn.calltype is not None: if isinstance(lqn.calltype, np.ndarray): if cidx < len(lqn.calltype.flatten()): calltype = lqn.calltype.flatten()[cidx] elif isinstance(lqn.calltype, dict): calltype = lqn.calltype.get(cidx, CallType.SYNC) # Get names src_name = self._get_hashname(src_aidx) tgt_name = self._get_hashname(tgt_eidx) # Format based on call type if calltype == CallType.SYNC: return f'{src_name}=>{tgt_name}' elif calltype == CallType.ASYNC: return f'{src_name}->{tgt_name}' else: return f'{src_name}~>{tgt_name}' def _get_task_total_host_demand(self, tidx: int) -> float: """Get total host demand for a task (sum of all activities' host demands).""" total = 0.0 activities = self._get_activities_of_task(tidx) for aidx in activities: if self.servtproc[aidx] is not None: proc = self.servtproc[aidx] if isinstance(proc, (int, float, np.integer, np.floating)): total += float(proc) elif hasattr(proc, 'getMean'): total += proc.getMean() elif hasattr(proc, 'mean'): total += proc.mean return total # Constants for jobPos tracking in recurActGraph _AT_CLIENT = 1 _AT_SERVER = 2 _AT_CACHE = 3 def _recur_act_graph(self, P, tidx_caller, aidx, cur_class, job_pos, ctx): """ Recursively traverse the activity graph and set up routing. Matches MATLAB recurActGraph in buildLayersRecursive.m. Args: P: RoutingMatrix tidx_caller: Task index of the calling task aidx: Current activity/entry index cur_class: Current class object job_pos: Current position (_AT_CLIENT, _AT_SERVER, _AT_CACHE) ctx: Context dict with layer info (nodes, classes, fork/join state) Returns: (P, cur_class, job_pos) """ lqn = self.lqn graph = lqn.graph # Save current state (MATLAB line 427-428) ctx['job_pos_key'][aidx] = job_pos ctx['cur_class_key'][aidx] = cur_class # Find successors (MATLAB line 430) nextaidxs = [] if isinstance(graph, np.ndarray) and aidx < graph.shape[0]: for j in range(graph.shape[1]): if graph[aidx, j] != 0: nextaidxs.append(j) # Check if any successor is POST_AND (fork target) (MATLAB line 432-433) is_post_and_act = ctx['is_post_and_act'] is_pre_and_act = ctx['is_pre_and_act'] is_next_prec_fork = any(n in is_post_and_act for n in nextaidxs) if not nextaidxs: return P, cur_class, job_pos # Pre-fork state, captured at the first branch so that calls this # activity issues before the fork stay sequential # (MATLAB buildLayersRecursive.m lines 522-525) fork_saved = False fork_save_cur_class = cur_class fork_save_job_pos = job_pos fork_save_station = ctx.get('cur_station') for nextaidx in nextaidxs: # Restore pre-fork state at start of each branch # (MATLAB buildLayersRecursive.m lines 531-534) if is_next_prec_fork: if not fork_saved: if nextaidx in is_post_and_act: fork_saved = True fork_save_cur_class = cur_class fork_save_job_pos = job_pos fork_save_station = ctx.get('cur_station') else: cur_class = fork_save_cur_class job_pos = fork_save_job_pos ctx['cur_station'] = fork_save_station # Loop detection (MATLAB line 440-442) is_loop = False if hasattr(lqn, 'dag') and lqn.dag is not None: if isinstance(lqn.dag, np.ndarray) and aidx < lqn.dag.shape[0] and nextaidx < lqn.dag.shape[1]: is_loop = (graph[aidx, nextaidx] != lqn.dag[aidx, nextaidx]) parent_aidx = self._get_parent(aidx) parent_nextaidx = self._get_parent(nextaidx) if parent_aidx != parent_nextaidx: # cross-task call routing mirrors MATLAB routeSynchCall in buildLayersRecursive.m:821-947. call_classes = ctx.get('call_classes', {}) aux_classes = ctx.get('aux_classes', {}) call_mean_map = ctx.get('call_mean_map', {}) think_classes = ctx.get('think_classes', {}) client_delay = ctx['client_delay'] server_station = ctx['server_station'] is_host_layer = ctx['is_host_layer'] layer_idx = ctx.get('idx') # Find cidx by matching callpair [aidx, nextaidx] cidx = None if isinstance(lqn.callsof, dict): for c in lqn.callsof.get(aidx, []): if c < lqn.callpair.shape[0]: pair = lqn.callpair[c] src = int(pair[0]) if pair.shape[0] > 0 else -1 tgt = int(pair[1]) if pair.shape[0] > 1 else -1 if src == aidx and tgt == nextaidx: cidx = c break calltype = 1 # default SYNC if cidx is not None and hasattr(lqn, 'calltype') and lqn.calltype is not None: flat_ct = lqn.calltype.flatten() if isinstance(lqn.calltype, np.ndarray) else None if flat_ct is not None and cidx < len(flat_ct): calltype = int(flat_ct[cidx]) elif isinstance(lqn.calltype, dict): calltype = int(lqn.calltype.get(cidx, 1)) call_cls = call_classes.get(cidx) if cidx is not None else None if cidx is not None and call_cls is not None and calltype == 1: # SYNC call_mean = call_mean_map.get(cidx, 1.0) aux_cls = aux_classes.get(cidx) # nreplicas for this layer (task layers are single-replica) nreplicas = 1 # Target entry's parent task — is it the server of this layer? tgt_eidx_c = int(lqn.callpair[cidx, 1]) if cidx < lqn.callpair.shape[0] else None tgt_parent = self._get_parent(tgt_eidx_c) if tgt_eidx_c is not None else None _srv_map = ctx.get('srv_stations', {}) if ctx.get('flat'): tgt_stn = _srv_map[int(tgt_parent)][0] if int(tgt_parent) in _srv_map else None call_to_server = tgt_stn is not None if call_to_server: server_station = tgt_stn else: call_to_server = (tgt_parent == layer_idx) callservt_proc = None if cidx < len(self.callservtproc): callservt_proc = self.callservtproc[cidx] if job_pos == self._AT_CLIENT: if call_to_server: # MATLAB routeSynchCall atClient, call to server (lines 823-861) if call_mean < 1: if aux_cls is not None: P.set(cur_class, aux_cls, client_delay, client_delay, 1 - call_mean) P.set(cur_class, call_cls, client_delay, server_station, call_mean / nreplicas) P.set(call_cls, call_cls, server_station, client_delay, 1.0) if aux_cls is not None: P.set(aux_cls, call_cls, client_delay, client_delay, 1.0) elif call_mean == 1: P.set(cur_class, call_cls, client_delay, server_station, 1.0 / nreplicas) P.set(call_cls, call_cls, server_station, client_delay, 1.0) else: # call_mean > 1 P.set(cur_class, call_cls, client_delay, server_station, 1.0 / nreplicas) if aux_cls is not None: P.set(call_cls, aux_cls, server_station, client_delay, 1.0) P.set(aux_cls, call_cls, client_delay, server_station, (1.0 - 1.0 / call_mean) / nreplicas) P.set(aux_cls, call_cls, client_delay, client_delay, 1.0 / call_mean) # Services: Immediate at client, callservt at server client_delay.set_service(call_cls, Immediate()) if callservt_proc is not None: server_station.set_service(call_cls, callservt_proc) job_pos = self._AT_CLIENT cur_class = call_cls else: # MATLAB routeSynchCall atClient, call NOT to server (lines 863-879) if call_mean < 1: # call mean is embedded in the demand; see _kb/06-solver-catalog.md LN Call mean embedded in demand. P.set(cur_class, call_cls, client_delay, client_delay, 1.0) if aux_cls is not None: P.set(call_cls, aux_cls, client_delay, client_delay, 1.0) cur_class = aux_cls else: cur_class = call_cls elif call_mean == 1: P.set(cur_class, call_cls, client_delay, client_delay, 1.0) cur_class = call_cls else: # call_mean > 1 P.set(cur_class, call_cls, client_delay, client_delay, 1.0) if aux_cls is not None: P.set(call_cls, aux_cls, client_delay, client_delay, 1.0) cur_class = aux_cls else: cur_class = call_cls if callservt_proc is not None: client_delay.set_service(call_cls, callservt_proc) job_pos = self._AT_CLIENT else: # job_pos == _AT_SERVER if call_to_server: # MATLAB routeSynchCall atServer, call to server (lines 882-910) _from = (ctx.get('cur_station') or server_station) if ctx.get('flat') else server_station if call_mean < 1: # The skip flow must enter the Aux class and the reply # must transit the client in the call class, which is # therefore declared there: sn_refresh_visits drops any # (station, class) state whose rate is NaN, and dropping # this one severs the chain. Routing the skip into the # call class instead and leaving in Aux gives Aux no # inbound arc at all, so its chain has no reference # class; this branch did that under 'srvn' until # 2026-08-11 (buildLayersRecursive.m:1100-1118, and the # JAR has carried the reference form all along). if aux_cls is not None: P.set(cur_class, aux_cls, _from, client_delay, 1 - call_mean) P.set(aux_cls, call_cls, client_delay, client_delay, 1.0) else: P.set(cur_class, call_cls, _from, client_delay, 1 - call_mean) P.set(cur_class, call_cls, _from, server_station, call_mean) P.set(call_cls, call_cls, server_station, client_delay, 1.0) client_delay.set_service(call_cls, Immediate()) # both the skip and the visit end in the call class cur_class = call_cls job_pos = self._AT_CLIENT ctx['cur_station'] = None elif call_mean == 1: P.set(cur_class, call_cls, _from, server_station, 1.0) if ctx.get('flat'): # the reply returns the job to the client, as the # successor restoration downstream assumes P.set(call_cls, call_cls, server_station, client_delay, 1.0) client_delay.set_service(call_cls, Immediate()) job_pos = self._AT_CLIENT ctx['cur_station'] = None else: job_pos = self._AT_SERVER ctx['cur_station'] = server_station cur_class = call_cls else: # call_mean > 1 P.set(cur_class, call_cls, _from, server_station, 1.0) if ctx.get('flat'): # the geometric repeat transits the client between # visits; a self-loop would merge them into one if aux_cls is not None: P.set(call_cls, aux_cls, server_station, client_delay, 1.0) P.set(aux_cls, call_cls, client_delay, server_station, 1 - 1.0 / call_mean) P.set(aux_cls, call_cls, client_delay, client_delay, 1.0 / call_mean) client_delay.set_service(call_cls, Immediate()) cur_class = call_cls else: if aux_cls is not None: P.set(call_cls, call_cls, server_station, server_station, 1 - 1.0 / call_mean) P.set(call_cls, aux_cls, server_station, client_delay, 1.0 / call_mean) cur_class = aux_cls if aux_cls is not None else call_cls job_pos = self._AT_CLIENT ctx['cur_station'] = None if callservt_proc is not None: server_station.set_service(call_cls, callservt_proc) else: # MATLAB routeSynchCall atServer, call NOT to server (lines 912-936) if call_mean < 1: P.set(cur_class, call_cls, server_station, client_delay, 1.0) if aux_cls is not None: P.set(call_cls, aux_cls, client_delay, client_delay, 1.0) cur_class = aux_cls else: cur_class = call_cls elif call_mean == 1: P.set(cur_class, call_cls, server_station, client_delay, 1.0) cur_class = call_cls else: # call_mean > 1 P.set(cur_class, call_cls, server_station, client_delay, 1.0) if aux_cls is not None: P.set(call_cls, aux_cls, client_delay, client_delay, 1.0) cur_class = aux_cls else: cur_class = call_cls if callservt_proc is not None: client_delay.set_service(call_cls, callservt_proc) job_pos = self._AT_CLIENT # (forwarding handled via pseudo rendezvous calls) else: # Same-task intra-activity routing (MATLAB lines 503-666) client_delay = ctx['client_delay'] server_station = ctx['server_station'] is_host_layer = ctx['is_host_layer'] is_cache_layer = ctx['is_cache_layer'] srv_stations = ctx.get('srv_stations', {}) # station of the processor the next activity runs on, None when # that processor is not a server of this layer host_stn = None if ctx.get('flat'): _h = self._get_parent(self._get_parent(nextaidx)) if _h is not None and int(_h) in srv_stations: host_stn = srv_stations[int(_h)][0] elif is_host_layer: host_stn = server_station fork_node = ctx['fork_node'] join_node = ctx['join_node'] fork_output_routers = ctx['fork_output_routers'] fork_class_stack = ctx['fork_class_stack'] activity_classes = ctx['activity_classes'] act_cls = activity_classes.get(nextaidx) if act_cls is None: continue # Check if any successor is an entry (MATLAB lines 1010-1021) entry_range = set(lqn.eshift + i for i in range(lqn.nentries)) intersects = any(n in entry_range for n in nextaidxs) if not intersects: # Restore state from saved values (MATLAB line 1023-1025) job_pos = ctx['job_pos_key'].get(aidx, job_pos) cur_class = ctx['cur_class_key'].get(aidx, cur_class) ctx['cur_station'] = ctx.setdefault('cur_station_key', {}).get(aidx, ctx.get('cur_station')) else: # Entry routing state restoration (MATLAB lines 1026-1040) idx_in_nextaidxs = nextaidxs.index(nextaidx) if nextaidx in nextaidxs else 0 is_member = False if idx_in_nextaidxs > 0: prev_val = nextaidxs[idx_in_nextaidxs - 1] is_member = prev_val in entry_range if is_member: ctx['cur_class_c'] = cur_class job_pos = self._AT_CLIENT cur_class = ctx.get('cur_class_c', cur_class) ctx['cur_station'] = None # Route based on jobPos and layer type if job_pos == self._AT_CLIENT: if host_stn is not None: server_station = host_stn if not is_cache_layer: # HOST LAYER, NON-CACHE, atClient (MATLAB lines 1044-1096) if is_next_prec_fork and fork_node is not None: # FORK routing P.set(cur_class, cur_class, client_delay, fork_node, 1.0) post_and_succs = [s for s in nextaidxs if s in is_post_and_act] f_idx = post_and_succs.index(nextaidx) + 1 if nextaidx in post_and_succs else -1 if f_idx > 0 and f_idx in fork_output_routers: fork_class_stack.append(cur_class) P.set(cur_class, cur_class, fork_node, fork_output_routers[f_idx], 1.0) P.set(cur_class, act_cls, fork_output_routers[f_idx], server_station, 1.0) else: P.set(cur_class, act_cls, client_delay, server_station, graph[aidx, nextaidx]) elif aidx in is_pre_and_act and join_node is not None: # JOIN routing fork_class = fork_class_stack.pop() P.set(cur_class, fork_class, client_delay, join_node, 1.0) P.set(fork_class, act_cls, join_node, server_station, 1.0) else: # Serial routing P.set(cur_class, act_cls, client_delay, server_station, graph[aidx, nextaidx]) # Set service at server (servtproc holds Distribution, not float) if nextaidx < len(self.servtproc) and self.servtproc[nextaidx] is not None: server_station.set_service(act_cls, self.servtproc[nextaidx]) job_pos = self._AT_SERVER ctx['cur_station'] = server_station cur_class = act_cls # Record servt update map if ctx.get('servt_map') is not None and ctx.get('idx') is not None: ctx['servt_map'][ctx['idx']].append([ctx['idx'], nextaidx, 2, act_cls.get_index()]) else: # CACHE LAYER, atClient (MATLAB lines 1097-1118) P.set(cur_class, act_cls, client_delay, ctx.get('cache_node', server_station), graph[aidx, nextaidx]) job_pos = self._AT_CACHE cur_class = act_cls else: # TASK LAYER, atClient (MATLAB lines 1119-1160) if is_next_prec_fork and fork_node is not None: # FORK routing P.set(cur_class, cur_class, client_delay, fork_node, 1.0) post_and_succs = [s for s in nextaidxs if s in is_post_and_act] f_idx = post_and_succs.index(nextaidx) + 1 if nextaidx in post_and_succs else -1 if f_idx > 0 and f_idx in fork_output_routers: fork_class_stack.append(cur_class) P.set(cur_class, cur_class, fork_node, fork_output_routers[f_idx], 1.0) P.set(cur_class, act_cls, fork_output_routers[f_idx], client_delay, 1.0) else: P.set(cur_class, act_cls, client_delay, client_delay, graph[aidx, nextaidx]) elif aidx in is_pre_and_act and join_node is not None: # JOIN routing fork_class = fork_class_stack.pop() P.set(cur_class, fork_class, client_delay, join_node, 1.0) P.set(fork_class, act_cls, join_node, client_delay, 1.0) else: # Serial routing P.set(cur_class, act_cls, client_delay, client_delay, graph[aidx, nextaidx]) # Set service at client if nextaidx in self.servtproc and self.servtproc[nextaidx] is not None: client_delay.set_service(act_cls, self.servtproc[nextaidx]) job_pos = self._AT_CLIENT cur_class = act_cls # Record thinkt update map if ctx.get('thinkt_map') is not None and ctx.get('idx') is not None: ctx['thinkt_map'][ctx['idx']].append([ctx['idx'], nextaidx, 1, act_cls.get_index()]) elif job_pos == self._AT_SERVER or job_pos == self._AT_CACHE: if host_stn is not None: from_stn = ctx.get('cur_station') or server_station server_station = host_stn if not is_cache_layer: # HOST LAYER, NON-CACHE, atServer (MATLAB lines 1217-1258) if is_next_prec_fork and fork_node is not None: # FORK routing P.set(cur_class, cur_class, from_stn, fork_node, 1.0) post_and_succs = [s for s in nextaidxs if s in is_post_and_act] f_idx = post_and_succs.index(nextaidx) + 1 if nextaidx in post_and_succs else -1 if f_idx > 0 and f_idx in fork_output_routers: fork_class_stack.append(cur_class) P.set(cur_class, cur_class, fork_node, fork_output_routers[f_idx], 1.0) P.set(cur_class, act_cls, fork_output_routers[f_idx], server_station, 1.0) else: P.set(cur_class, act_cls, from_stn, server_station, graph[aidx, nextaidx]) elif aidx in is_pre_and_act and join_node is not None: # JOIN routing fork_class = fork_class_stack.pop() P.set(cur_class, fork_class, from_stn, join_node, 1.0) P.set(fork_class, act_cls, join_node, server_station, 1.0) else: # Serial routing P.set(cur_class, act_cls, from_stn, server_station, graph[aidx, nextaidx]) # Set service at server (servtproc holds Distribution, not float) if nextaidx < len(self.servtproc) and self.servtproc[nextaidx] is not None: server_station.set_service(act_cls, self.servtproc[nextaidx]) job_pos = self._AT_SERVER ctx['cur_station'] = server_station cur_class = act_cls if ctx.get('servt_map') is not None and ctx.get('idx') is not None: ctx['servt_map'][ctx['idx']].append([ctx['idx'], nextaidx, 2, act_cls.get_index()]) else: # CACHE LAYER, atServer/atCache (MATLAB lines 1163-1216) cache_node = ctx.get('cache_node', server_station) source_node = cache_node if job_pos == self._AT_CACHE else server_station if is_next_prec_fork and fork_node is not None: P.set(cur_class, cur_class, source_node, fork_node, 1.0) post_and_succs = [s for s in nextaidxs if s in is_post_and_act] f_idx = post_and_succs.index(nextaidx) + 1 if nextaidx in post_and_succs else -1 if f_idx > 0 and f_idx in fork_output_routers: fork_class_stack.append(cur_class) P.set(cur_class, cur_class, fork_node, fork_output_routers[f_idx], 1.0) P.set(cur_class, act_cls, fork_output_routers[f_idx], server_station, 1.0) else: P.set(cur_class, act_cls, source_node, server_station, graph[aidx, nextaidx]) elif aidx in is_pre_and_act and join_node is not None: fork_class = fork_class_stack.pop() P.set(cur_class, fork_class, source_node, join_node, 1.0) P.set(fork_class, act_cls, join_node, server_station, 1.0) else: P.set(cur_class, act_cls, source_node, server_station, graph[aidx, nextaidx]) if nextaidx < len(self.servtproc) and self.servtproc[nextaidx] is not None: server_station.set_service(act_cls, self.servtproc[nextaidx]) job_pos = self._AT_SERVER cur_class = act_cls if ctx.get('servt_map') is not None and ctx.get('idx') is not None: ctx['servt_map'][ctx['idx']].append([ctx['idx'], nextaidx, 2, act_cls.get_index()]) else: # TASK LAYER, atServer (MATLAB lines 1276-1313) if is_next_prec_fork and fork_node is not None: # FORK routing P.set(cur_class, cur_class, server_station, fork_node, 1.0) post_and_succs = [s for s in nextaidxs if s in is_post_and_act] f_idx = post_and_succs.index(nextaidx) + 1 if nextaidx in post_and_succs else -1 if f_idx > 0 and f_idx in fork_output_routers: fork_class_stack.append(cur_class) P.set(cur_class, cur_class, fork_node, fork_output_routers[f_idx], 1.0) P.set(cur_class, act_cls, fork_output_routers[f_idx], client_delay, 1.0) else: P.set(cur_class, act_cls, server_station, client_delay, graph[aidx, nextaidx]) elif aidx in is_pre_and_act and join_node is not None: # JOIN routing fork_class = fork_class_stack.pop() P.set(cur_class, fork_class, server_station, join_node, 1.0) P.set(fork_class, act_cls, join_node, client_delay, 1.0) else: # Serial routing P.set(cur_class, act_cls, server_station, client_delay, graph[aidx, nextaidx]) # Set service at client if nextaidx in self.servtproc and self.servtproc[nextaidx] is not None: client_delay.set_service(act_cls, self.servtproc[nextaidx]) job_pos = self._AT_CLIENT ctx['cur_station'] = None cur_class = act_cls if ctx.get('thinkt_map') is not None and ctx.get('idx') is not None: ctx['thinkt_map'][ctx['idx']].append([ctx['idx'], nextaidx, 1, act_cls.get_index()]) # Recursive call (MATLAB lines 1316-1336) if aidx != nextaidx and not is_loop: # cur_class_c is per-invocation in MATLAB and saved around the # recursion in the JAR; a shared ctx entry leaks the callee's # class back into the next fork branch saved_cur_class_c = ctx.get('cur_class_c') P, cur_class, job_pos = self._recur_act_graph( P, tidx_caller, nextaidx, cur_class, job_pos, ctx) ctx['cur_class_c'] = saved_cur_class_c # Route back to task class (MATLAB lines 1322-1335) task_cls = ctx['task_classes'][tidx_caller] if job_pos == self._AT_CLIENT: P.set(cur_class, task_cls, client_delay, client_delay, 1.0) else: # the job returns from the station it is actually at, which # under flat is the callee's station, not this layer's server _back = (ctx.get('cur_station') or server_station) \ if ctx.get('flat') else server_station P.set(cur_class, task_cls, _back, client_delay, 1.0) if not cur_class.name.endswith('.Aux'): cur_class.completes = True return P, cur_class, job_pos def _setup_routing(self, layer_model: Network, idx: int = None, route_map: list = None): """Set up routing for a layer model with class switching (4-class model).""" stations = layer_model.get_nodes() classes = layer_model.get_classes() if len(stations) < 2 or len(classes) < 1: return client = None server = None cache_node = None for s in stations: if isinstance(s, Delay): client = s elif isinstance(s, Queue): if server is None: server = s # Use FIRST Queue as primary server (not last) elif isinstance(s, Cache): cache_node = s if client is None or server is None: return # Check if this is a cache layer is_cache_layer = layer_model.attribute.get('iscachelayer', False) if hasattr(layer_model, 'attribute') and layer_model.attribute else False P = layer_model.init_routing_matrix() # Separate classes by type task_classes = {} entry_classes = {} activity_classes = {} think_classes = {} # Activity think-time classes call_classes = {} aux_classes = {} # Aux classes for fractional call means for cls in classes: if hasattr(cls, 'attribute') and cls.attribute is not None: elem_type = cls.attribute[0] if len(cls.attribute) > 0 else 0 elem_idx = cls.attribute[1] if len(cls.attribute) > 1 else 0 if isinstance(elem_idx, np.integer): elem_idx = int(elem_idx) if elem_type == LayeredNetworkElement.TASK: task_classes[elem_idx] = cls elif elem_type == LayeredNetworkElement.ENTRY: # an entry-arrival OpenClass carries the same [ENTRY, eidx] # attribute as the entry's own closed class (as in MATLAB), so # it must not displace it here: the task -> entry and # entry -> activity routes below would then be wired to the # open stream, which walks no activity graph if not isinstance(cls, OpenClass): entry_classes[elem_idx] = cls elif elem_type == LayeredNetworkElement.ACTIVITY: # Separate Think classes from regular activity classes if hasattr(cls, 'name') and cls.name.endswith('.Think'): think_classes[elem_idx] = cls else: activity_classes[elem_idx] = cls elif elem_type == LayeredNetworkElement.CALL: # Check if this is an Aux class (name ends with .Aux) if hasattr(cls, 'name') and cls.name.endswith('.Aux'): aux_classes[elem_idx] = cls else: call_classes[elem_idx] = cls # A routed group's members all resolve to the group's shared call class; # cls.attribute can only name one cidx, so the mapping is explicit. _cls_by_index = {c.get_index(): c for c in classes} for _cidx, _clsidx in (layer_model.attribute.get('call_class_of_cidx') or {}).items(): if _clsidx in _cls_by_index: call_classes[_cidx] = _cls_by_index[_clsidx] # Build call_mean map from layer attribute call_mean_map = {} if 'aux_classes' in layer_model.attribute: for aux_info in layer_model.attribute['aux_classes']: if len(aux_info) >= 3: aux_cls_idx, cidx, call_mean = aux_info[0], aux_info[1], aux_info[2] call_mean_map[cidx] = call_mean # Check if this is a host layer (activities at server) or task layer (activities at client) # The 'ishost' attribute is set on the server station during layer construction is_host_layer = True # Default to host layer if server is not None and hasattr(server, 'attribute'): is_host_layer = server.attribute.get('ishost', True) # Determine activity station: HOST layer = server, TASK layer = client act_station = server if is_host_layer else client # Set up class switching routing for each task for tidx, task_cls in task_classes.items(): entries = self._get_entries_of_task(tidx) activities = self._get_activities_of_task(tidx) entry_cls_list = [entry_classes[eidx] for eidx in entries if eidx in entry_classes] activity_cls_list = [activity_classes[aidx] for aidx in activities if aidx in activity_classes] if not entry_cls_list and not activity_cls_list: # No entry or activity classes - simple routing P.set(task_cls, task_cls, client, server, 1.0) P.set(task_cls, task_cls, server, client, 1.0) elif entry_cls_list: # TASK -> ENTRY routing (at client with equal probability) ncaller_entries = len(entry_cls_list) for i, entry_cls in enumerate(entry_cls_list): P.set(task_cls, entry_cls, client, client, 1.0 / ncaller_entries) # routing probabilities among multiple entries updated from throughput ratios each iteration; mirrors MATLAB lines 392-394. if ncaller_entries > 1 and idx is not None and route_map is not None: eidx = entries[i] # Format: [idx, tidx_caller, eidx, nodefrom, nodeto, classidxfrom, classidxto] # nodefrom=nodeto=1 means client node (1-based index) route_map[idx].append([idx, tidx, eidx, 1, 1, task_cls.get_index(), entry_cls.get_index()]) # Check if this layer has Fork/Join nodes has_forkjoin = (layer_model.attribute.get('fork_node') is not None or layer_model.attribute.get('join_node') is not None) if has_forkjoin: # Use recursive activity graph traversal with Fork/Join routing # (matches MATLAB recurActGraph in buildLayersRecursive.m) ctx = { 'client_delay': client, 'server_station': server, 'is_host_layer': is_host_layer, 'is_cache_layer': is_cache_layer, 'fork_node': layer_model.attribute.get('fork_node'), 'fork_output_routers': layer_model.attribute.get('fork_output_routers', {}), 'join_node': layer_model.attribute.get('join_node'), 'fork_class_stack': [], 'activity_classes': activity_classes, 'task_classes': task_classes, 'call_classes': call_classes, 'aux_classes': aux_classes, 'think_classes': think_classes, 'call_mean_map': call_mean_map, 'is_post_and_act': layer_model.attribute.get('is_post_and_act', set()), 'is_pre_and_act': layer_model.attribute.get('is_pre_and_act', set()), 'job_pos_key': {}, 'cur_class_key': {}, 'cur_station_key': {}, 'cur_station': None, # flat layering resolves the station per element rather than # using the layer's single server 'flat': bool(layer_model.attribute.get('flat')), 'srv_stations': layer_model.attribute.get('srv_stations', {}), 'cache_node': cache_node, 'servt_map': None, # Already set during class creation 'thinkt_map': None, 'idx': idx, 'layer_model': layer_model, } for eidx, entry_cls in zip(entries, entry_cls_list): if eidx in entry_classes: P, _, _ = self._recur_act_graph( P, tidx, eidx, entry_cls, self._AT_CLIENT, ctx) else: # Original flat routing (no Fork/Join needed) # Under flat layering each processor and task owns a station, so # placement is resolved per element instead of using the layer's # single server. _srv_map = layer_model.attribute.get('srv_stations', {}) \ if hasattr(layer_model, 'attribute') else {} _flat_layer = bool(layer_model.attribute.get('flat')) \ if hasattr(layer_model, 'attribute') else False def _act_station(act_cls, default_srv): # Station of the processor the activity class runs on if not _flat_layer: return default_srv _a = act_cls.attribute[1] if getattr(act_cls, 'attribute', None) is not None \ and len(act_cls.attribute) > 1 else None if _a is None: return default_srv _h = self._get_parent(self._get_parent(int(_a))) _st = _srv_map.get(int(_h)) if _h is not None else None return _st[0] if _st else None # ENTRY -> ACTIVITY routing (for each entry, route to its bound activities) for eidx, entry_cls in zip(entries, entry_cls_list): if eidx in entry_classes: bound_activities = self._get_activities_of_entry(eidx) bound_act_cls_list = [activity_classes[aidx] for aidx in bound_activities if aidx in activity_classes] is_cache_entry = False if is_cache_layer and cache_node is not None: if hasattr(self.lqn, 'itemproc') and isinstance(self.lqn.itemproc, dict): if eidx in self.lqn.itemproc and self.lqn.itemproc[eidx] is not None: is_cache_entry = True if is_cache_entry and bound_act_cls_list: first_act_cls = bound_act_cls_list[0] P.set(entry_cls, first_act_cls, client, cache_node, 1.0) cache_entry_aidx = bound_activities[0] if bound_activities else None if cache_entry_aidx is not None and hasattr(self.lqn, 'graph') and self.lqn.graph is not None: successors = [] for j in range(self.lqn.graph.shape[1]): if self.lqn.graph[cache_entry_aidx, j] != 0: successors.append(j) if len(successors) >= 2: hit_aidx = successors[0] miss_aidx = successors[1] hit_cls = activity_classes.get(hit_aidx) miss_cls = activity_classes.get(miss_aidx) if hit_cls is not None: P.set(first_act_cls, hit_cls, cache_node, server, 0.5) if miss_cls is not None: P.set(first_act_cls, miss_cls, cache_node, server, 0.5) elif bound_act_cls_list: first_act_cls = bound_act_cls_list[0] _as = _act_station(first_act_cls, server) if (is_host_layer or _flat_layer) else None if _as is not None: P.set(entry_cls, first_act_cls, client, _as, 1.0) else: P.set(entry_cls, first_act_cls, client, client, 1.0) elif activity_cls_list: if is_host_layer: P.set(entry_cls, activity_cls_list[0], client, server, 1.0) else: P.set(entry_cls, activity_cls_list[0], client, client, 1.0) else: P.set(entry_cls, task_cls, client, client, 1.0) if not has_forkjoin: # For HOST layers, add explicit routing for activity classes from client to server if (is_host_layer or _flat_layer) and activity_cls_list: for act_cls in activity_cls_list: _as = _act_station(act_cls, server) if _as is not None: P.set(act_cls, act_cls, client, _as, 1.0) # Route through activities using flat loop (no fork/join) for i, aidx in enumerate(activities): if aidx not in activity_classes: continue act_cls = activity_classes[aidx] if _flat_layer: # the activity runs on its own processor's station _as_i = _act_station(act_cls, server) if _as_i is not None: act_station = _as_i sync_call_classes = [] _cgrp_by_cidx, _cgrp_members = self._call_groups_by_cidx() _seen_groups = set() if isinstance(self.lqn.callsof, dict): calls = self.lqn.callsof.get(aidx, []) for cidx in calls: if cidx in call_classes: is_sync = True if hasattr(self.lqn, 'calltype') and self.lqn.calltype is not None: if isinstance(self.lqn.calltype, np.ndarray): calltype = self.lqn.calltype.flatten()[cidx] if cidx < len(self.lqn.calltype.flatten()) else CallType.SYNC elif isinstance(self.lqn.calltype, dict): calltype = self.lqn.calltype.get(cidx, CallType.SYNC) else: calltype = CallType.SYNC is_sync = (calltype == CallType.SYNC) if is_sync: # a group is one dispatch: take its shared class # once, at the position of its first member _g = _cgrp_by_cidx.get(cidx) if _g is not None: if _g[0] in _seen_groups: continue _seen_groups.add(_g[0]) sync_call_classes.append(call_classes[cidx]) has_sync_call = len(sync_call_classes) > 0 if has_sync_call: # Process each sync call individually, matching MATLAB routeSynchCall # Each call checks its own target to determine server vs client routing # under flat layering the activity sits on its own # processor's station, so the job is at a server job_at_client = (not is_host_layer) and not ( _flat_layer and act_station is not client) cur_cls = act_cls for ci, call_cls in enumerate(sync_call_classes): call_cidx = call_cls.attribute[1] if hasattr(call_cls, 'attribute') else None tgt_eidx_c = (self._get_call_target_entry(call_cidx) if call_cidx is not None else None) tgt_tidx_c = (self._get_parent(tgt_eidx_c) if tgt_eidx_c is not None else None) this_call_to_server = False call_srv = server if _flat_layer: # every called task has its own station here, so a # name match against the layer server never fires _st = _srv_map.get(int(tgt_tidx_c)) if tgt_tidx_c is not None else None if _st: call_srv = _st[0] this_call_to_server = True elif tgt_tidx_c is not None and server is not None: tgt_name_c = self._get_hashname(tgt_tidx_c) this_call_to_server = (tgt_name_c == server.name) call_mean = (call_mean_map.get(call_cidx, 1.0) if call_cidx is not None else 1.0) nreplicas = 1 has_aux = call_cidx in aux_classes aux_cls = aux_classes.get(call_cidx) if has_aux else None # A routed group is ONE hop with n destinations, taken at a # router whose only links are those destinations: the # strategy routes over a NODE's links, not over one class's # arcs, so any other node would let the job wander to # stations the group never calls. The hop keeps the class # (a state-dependent routing function is zero off the class # diagonal); the switch is on the return arc. The 1/n split # laid down here is the probabilistic reading a solver # without state-dependent routing would see. _grp = _cgrp_by_cidx.get(call_cidx) if call_cidx is not None else None if _grp is not None and _flat_layer: _gid, _strategy = _grp _disp_cls, _ret_cls, _, _router = layer_model.attribute[ 'call_group_classes'][_gid] _tgt_stations = [] for _mcidx in _cgrp_members[_gid]: _meidx = self._get_call_target_entry(_mcidx) _mtidx = self._get_parent(_meidx) if _meidx else None _st = _srv_map.get(int(_mtidx)) if _mtidx is not None else None if _st: _tgt_stations.append((_st[0], _mcidx)) if len(_tgt_stations) >= 2: _from_node = client if job_at_client else act_station _share = 1.0 / len(_tgt_stations) P.set(cur_cls, _disp_cls, _from_node, _router, 1.0) for _st, _mcidx in _tgt_stations: P.set(_disp_cls, _disp_cls, _router, _st, _share) P.set(_disp_cls, _ret_cls, _st, client, 1.0) layer_model.attribute.setdefault('rrobin_sites', []).append( (_router.name, _disp_cls.get_index(), _strategy)) cur_cls = _ret_cls job_at_client = True continue if job_at_client: if this_call_to_server: # MATLAB: atClient, call to server entry if call_mean < 1: P.set(cur_cls, call_cls, client, call_srv, call_mean / nreplicas) P.set(call_cls, call_cls, call_srv, client, 1.0) if has_aux: P.set(cur_cls, aux_cls, client, client, 1 - call_mean) P.set(aux_cls, call_cls, client, client, 1.0) cur_cls = call_cls elif call_mean == 1: P.set(cur_cls, call_cls, client, call_srv, 1.0 / nreplicas) P.set(call_cls, call_cls, call_srv, client, 1.0) cur_cls = call_cls else: # call_mean > 1 P.set(cur_cls, call_cls, client, call_srv, 1.0 / nreplicas) if has_aux: P.set(call_cls, aux_cls, call_srv, client, 1.0) P.set(aux_cls, call_cls, client, call_srv, (1.0 - 1.0 / call_mean) / nreplicas) P.set(aux_cls, call_cls, client, client, 1.0 / call_mean) cur_cls = call_cls # matches MATLAB line 783: curClass = cidxClass{cidx} else: cur_cls = call_cls job_at_client = True else: # MATLAB: atClient, call NOT to server if call_mean < 1: # Deterministic visit: the call mean is # embedded in the demand (callservt) P.set(cur_cls, call_cls, client, client, 1.0) if has_aux: P.set(call_cls, aux_cls, client, client, 1.0) cur_cls = aux_cls else: cur_cls = call_cls elif call_mean == 1: P.set(cur_cls, call_cls, client, client, 1.0) cur_cls = call_cls else: # call_mean > 1 P.set(cur_cls, call_cls, client, client, 1.0) if has_aux: P.set(call_cls, aux_cls, client, client, 1.0) cur_cls = aux_cls else: cur_cls = call_cls job_at_client = True else: # job at server if this_call_to_server: # MATLAB: atServer, call to server entry _from_stn = act_station if _flat_layer else server if call_mean < 1: if _flat_layer: # the skip flow enters the Aux class and the # reply transits the client in the call class if has_aux: P.set(cur_cls, aux_cls, _from_stn, client, 1 - call_mean) P.set(aux_cls, call_cls, client, client, 1.0) else: P.set(cur_cls, call_cls, _from_stn, client, 1 - call_mean) P.set(cur_cls, call_cls, _from_stn, call_srv, call_mean) P.set(call_cls, call_cls, call_srv, client, 1.0) client.set_service(call_cls, Immediate()) # both the skip and the visit end in the call # class, so continuing from Aux would emit the # next call's arcs out of a class that has # already been routed away cur_cls = call_cls else: P.set(cur_cls, call_cls, server, client, 1 - call_mean) P.set(cur_cls, call_cls, server, server, call_mean) cur_cls = aux_cls if has_aux else call_cls job_at_client = True elif call_mean == 1: if _flat_layer: # the reply returns the job to the client P.set(cur_cls, call_cls, _from_stn, call_srv, 1.0) P.set(call_cls, call_cls, call_srv, client, 1.0) client.set_service(call_cls, Immediate()) job_at_client = True else: P.set(cur_cls, call_cls, server, server, 1.0) job_at_client = False cur_cls = call_cls else: # call_mean > 1 if _flat_layer: # the geometric repeat visits the CALLED task's # station and transits the client between # visits, as the atClient split does: a # self-loop merges the visits into one and # under-counts the call's aggregate service P.set(cur_cls, call_cls, _from_stn, call_srv, 1.0) if has_aux: P.set(call_cls, aux_cls, call_srv, client, 1.0) P.set(aux_cls, call_cls, client, call_srv, 1 - 1.0 / call_mean) P.set(aux_cls, call_cls, client, client, 1.0 / call_mean) client.set_service(call_cls, Immediate()) job_at_client = True cur_cls = call_cls else: P.set(cur_cls, call_cls, server, server, 1.0) if has_aux: P.set(call_cls, call_cls, server, server, 1 - 1.0 / call_mean) P.set(call_cls, aux_cls, server, client, 1.0 / call_mean) job_at_client = True cur_cls = aux_cls if has_aux else call_cls else: # atServer, call NOT to server # callmean not needed since we use ResidT to model service time at client P.set(cur_cls, call_cls, server, client, 1.0) if call_mean < 1: if has_aux: P.set(call_cls, aux_cls, client, client, 1.0) cur_cls = aux_cls else: cur_cls = call_cls elif call_mean == 1: cur_cls = call_cls else: # call_mean > 1 if has_aux: P.set(call_cls, aux_cls, client, client, 1.0) cur_cls = aux_cls else: cur_cls = call_cls job_at_client = True # (forwarding handled via pseudo rendezvous calls) # After all calls, route to successor activity or back to task source_node = client if job_at_client else server graph_successors = self._get_activity_successors(aidx, activity_classes) if graph_successors: for succ_aidx, prob in graph_successors: succ_act_cls = activity_classes[succ_aidx] P.set(cur_cls, succ_act_cls, source_node, act_station, prob) else: P.set(cur_cls, task_cls, source_node, client, 1.0) else: graph = self.lqn.graph has_graph_successors = False if isinstance(graph, np.ndarray) and aidx < graph.shape[0]: successors = [] for succ_aidx in range(graph.shape[1]): if graph[aidx, succ_aidx] > 0: if succ_aidx in activity_classes: prob = graph[aidx, succ_aidx] successors.append((succ_aidx, prob)) if successors: has_graph_successors = True for succ_aidx, prob in successors: succ_act_cls = activity_classes[succ_aidx] P.set(act_cls, succ_act_cls, act_station, act_station, prob) if not has_graph_successors: is_terminal = False if hasattr(self.lqn, 'replygraph') and self.lqn.replygraph is not None: act_local_idx = aidx - self.lqn.ashift if isinstance(self.lqn.replygraph, np.ndarray): if 0 <= act_local_idx < self.lqn.replygraph.shape[0]: if np.any(self.lqn.replygraph[act_local_idx, :] > 0): is_terminal = True if is_terminal: # activity think time in series with host demand; see _kb/06-solver-catalog.md LN Activity think time section. if aidx in think_classes: tc = think_classes[aidx] P.set(act_cls, tc, act_station, client, 1.0) P.set(tc, task_cls, client, client, 1.0) else: P.set(act_cls, task_cls, act_station, client, 1.0) elif i < len(activities) - 1: next_aidx = activities[i + 1] if next_aidx in activity_classes: next_act_cls = activity_classes[next_aidx] P.set(act_cls, next_act_cls, act_station, act_station, 1.0) else: P.set(act_cls, task_cls, act_station, client, 1.0) else: P.set(act_cls, task_cls, act_station, client, 1.0) else: # No entry classes but have activity classes # TASK -> first ACTIVITY first_act_cls = activity_cls_list[0] P.set(task_cls, first_act_cls, client, act_station, 1.0) # Route through activities, last returns to task at client for i, act_cls in enumerate(activity_cls_list): if i < len(activity_cls_list) - 1: next_act_cls = activity_cls_list[i + 1] P.set(act_cls, next_act_cls, act_station, act_station, 1.0) else: P.set(act_cls, task_cls, act_station, client, 1.0) # entry-level open-arrival routing Source->server->Sink; mirrors JAR SolverLN.java:881-890 (replication handled separately). source_station = layer_model.attribute.get('source_station') sink_station = layer_model.attribute.get('sink_station') entry_open_classes = layer_model.attribute.get('entry_open_classes', []) if source_station is not None and sink_station is not None and entry_open_classes: nreplicas_for_open = layer_model.attribute.get('nreplicas', 1) or 1 for open_cls, _eidx in entry_open_classes: # buildLayersRecursive.m:480-485 clears the class first: an entry # arrival walks no activity graph, and a leftover class-switch arc # puts the open class in the closed chain, making it mixed. P.remove_job_class(open_cls) P.set(open_cls, open_cls, source_station, server, 1.0 / float(nreplicas_for_open)) P.set(open_cls, open_cls, server, sink_station, 1.0) # async-call-injection open class recirculates at server until calls done, drains to sink; mirrors MATLAB buildLayersRecursive.m:415-432/JAR:856-878. async_open_classes = layer_model.attribute.get('async_open_classes', []) if source_station is not None and sink_station is not None and async_open_classes: nreplicas_for_open = layer_model.attribute.get('nreplicas', 1) or 1 for open_cls, _cidx, call_mean in async_open_classes: try: cm = float(call_mean) if call_mean is not None else 1.0 except (TypeError, ValueError): cm = 1.0 if cm <= 0 or not np.isfinite(cm): cm = 1.0 p_drain = 1.0 / cm if cm < 1: # fewer than one call per arrival: a single Bernoulli pass, the # geometric loop below would need a negative repeat probability P.set(open_cls, open_cls, source_station, sink_station, 1.0 - cm) P.set(open_cls, open_cls, source_station, server, cm / float(nreplicas_for_open)) P.set(open_cls, open_cls, server, sink_station, 1.0) else: P.set(open_cls, open_cls, source_station, server, 1.0 / float(nreplicas_for_open)) # Server self-loop for recirculation (primary only; the replication # block below mirrors self-loops to each replica). if cm != 1: P.set(open_cls, open_cls, server, server, (1.0 - p_drain) / float(nreplicas_for_open)) P.set(open_cls, open_cls, server, sink_station, p_drain) # replicate routing to additional server replicas (nreplicas>1), splitting incoming probabilities; mirrors MATLAB serverStation loops. nreplicas = layer_model.attribute.get('nreplicas', 1) if nreplicas > 1: all_server_stations = layer_model.attribute.get('server_stations', []) if len(all_server_stations) > 1: primary = all_server_stations[0] replicas = all_server_stations[1:] # Copy service distributions from primary to each replica for replica in replicas: for jc, dist in primary._service_process.items(): replica.set_service(jc, dist) # Copy delay-off if present if hasattr(primary, '_setup_time') and primary._setup_time: if not hasattr(replica, '_setup_time') or replica._setup_time is None: replica._setup_time = {} replica._setup_time.update(primary._setup_time) if hasattr(primary, '_delay_off_time') and primary._delay_off_time: if not hasattr(replica, '_delay_off_time') or replica._delay_off_time is None: replica._delay_off_time = {} replica._delay_off_time.update(primary._delay_off_time) # Replicate routing entries: for each P entry involving the primary, # create entries for each replica with adjusted probabilities new_entries = [] remove_entries = [] for (cs, cd), route_dict in P._routes.items(): for (ns, nd), prob in list(route_dict.items()): if nd == primary and ns != primary: # Incoming to primary from another node: split across replicas # Primary gets prob/nreplicas, each replica gets prob/nreplicas new_prob = prob / nreplicas remove_entries.append((cs, cd, ns, nd)) new_entries.append((cs, cd, ns, primary, new_prob)) for replica in replicas: new_entries.append((cs, cd, ns, replica, new_prob)) elif ns == primary and nd != primary: # Outgoing from primary to another node: same for each replica for replica in replicas: new_entries.append((cs, cd, replica, nd, prob)) elif ns == primary and nd == primary: # Self-loop on primary: replicate as self-loop on each replica for replica in replicas: new_entries.append((cs, cd, replica, replica, prob)) # Apply changes for cs, cd, ns, nd in remove_entries: key = (cs, cd) if key in P._routes and (ns, nd) in P._routes[key]: del P._routes[key][(ns, nd)] for cs, cd, ns, nd, prob in new_entries: P.set(cs, cd, ns, nd, prob) # deferred delayed-hit retrieval wiring applied here (dict-based RoutingMatrix needs no P growth); see _kb/09-ldes-and-cache.md. rw = layer_model.attribute.get('retrieval_wiring') if isinstance(layer_model.attribute, dict) else None if rw is not None: cache_node = rw['cache_node'] fetch = rw['fetch'] read_class = rw['read_class'] miss_class = rw['miss_class'] miss_aidx = rw['miss_aidx'] # Fetch service = the miss activity's full service (host demand + backend call). svc = None if self.servtproc is not None and miss_aidx < len(self.servtproc): svc = self.servtproc[miss_aidx] if svc is None: svc = Exp(1.0) fetch.set_service(read_class, svc) P.set(read_class, read_class, cache_node, fetch, 1.0) P.set(read_class, read_class, fetch, cache_node, 1.0) cache_node.set_retrieval_system(read_class, miss_class, fetch) # Tag the auto-generated retrieval classes as non-completing (they map to no # LQN activity and are skipped by the LN class->activity updmaps). for rcls in getattr(cache_node, '_retrieval_classes', {}).values(): if rcls is not None and hasattr(rcls, 'completes'): rcls.completes = False # Pad every service station's service to the full class count with Disabled: # the retrieval classes are served only at the fetch station. for st in layer_model.get_nodes(): if isinstance(st, Queue): for jc in layer_model.classes: if st.get_service(jc) is None: st.set_service(jc, Disabled()) if layer_model.attribute.get('flat'): # link() installs RAND routing for every (node, class) pair left # without an outgoing arc. With one station per server in a single # layer those spurious uniform arcs let a class wander to stations # it never visits, trapping the flow in a sub-cycle and leaving the # reference class with zero visits (MATLAB buildLayersRecursive.m). from ...constants import RoutingStrategy as _RS from ...lang.nodes import Sink as _Sink outflow = {} for (class_src, _class_dst), routes in P._routes.items(): for (node_src, _node_dst), prob in routes.items(): if prob > 0: outflow.setdefault(id(node_src), set()).add(id(class_src)) for node in layer_model.get_nodes(): if isinstance(node, _Sink): continue here = outflow.get(id(node), set()) for jobclass in layer_model.classes: if id(jobclass) not in here: node.setRouting(jobclass, _RS.DISABLED) layer_model.link(P) # link() installs the probabilistic split; the declared strategy replaces # it on the dispatch (node, class), whose only arcs are the group's targets for _node_name, _disp_idx, _strategy in layer_model.attribute.get('rrobin_sites', []): for _n in layer_model.get_nodes(): if _n.name == _node_name: for _c in layer_model.classes: if _c.get_index() == _disp_idx: _n.setRouting(_c, _strategy) break self._add_layer_admission_constraint(layer_model, idx) self._add_layer_rate_dependence(layer_model) def _add_layer_rate_dependence(self, layer_model): """ Emit the service-rate dependences declared on the server elements of this layer onto their stations -- see _kb/06-solver-catalog.md (LN section). Load dependence reads the total station population and maps directly; the class- and joint-dependent handles are declared over the server's operands, which the layer represents as job classes, so each operand is expanded onto the classes that occupy the server on its behalf. """ lqn = self.lqn lld = getattr(lqn, 'lldscaling', None) or {} cd = getattr(lqn, 'cdscaling', None) or {} jd = getattr(lqn, 'jdscaling', None) or {} pools = getattr(lqn, 'pools', None) or {} if not lld and not cd and not jd and not pools: return srv_stations = layer_model.attribute.get('srv_stations', {}) nclasses = len(layer_model.classes) for sidx, stations in srv_stations.items(): has_ld = sidx in lld has_cd = sidx in cd has_jd = sidx in jd has_pools = sidx in pools if not (has_ld or has_cd or has_jd or has_pools): continue cols = self._layer_operand_classes(layer_model, sidx) one_class_per_operand = all(len(c) <= 1 for c in cols) for ss in stations: if has_ld: ss.set_load_dependence(lld[sidx]) if has_cd: # beta_{i,r} is product-form only while an operand maps to a single # class; where it aggregates several, the same scaling is emitted as # a joint dependence, which is numerically identical but not exact handle = _layer_dep_handle(cd[sidx], cols, nclasses, layer_model) peak = _layer_peak(lqn.cdscalingpeak[sidx], cols, nclasses) if one_class_per_operand: ss.set_class_dependence(handle, peak) else: ss.set_joint_dependence(handle, peak) if has_jd: ss.set_joint_dependence(_layer_dep_handle(jd[sidx], cols, nclasses, layer_model), _layer_peak(lqn.jdscalingpeak[sidx], cols, nclasses)) if has_pools: # A compatibility declaration IS a rate law: the pools clear # mu(n) of sn_compat_rate, which reads only the SUPPORT of n # and is therefore order independent. Normalising by the # every-pool-active peak makes eta(n) <= 1 with equality at # full support, so a fully-compatible pool reproduces the # plain multiplicity station exactly. The lowering is to a # JOINT dependence, hence an approximation in the layer: see # _kb/06-solver-catalog.md (LN section) for why the exact OI # analyzer cannot serve a class-switching layer. pl = pools[sidx] def _eta_pool(nop, _pl=pl): return sn_compat_scaling(_pl['compat'], _pl['counts'], _pl['rates'], nop) ss.set_joint_dependence( _layer_dep_handle(_eta_pool, cols, nclasses, layer_model), _layer_peak(np.ones(len(cols)), cols, nclasses)) def _layer_operand_classes(self, layer_model, sidx): """ Layer classes (1-based) through which each operand of server SIDX occupies its station: the tasks of a host through the classes of their activities, the entries of a task through the classes of the calls that target them. """ lqn = self.lqn if sidx <= lqn.nhosts: operand_idx = lqn.tasksof.get(sidx, []) cls_by_elem = {} for cls_index, aidx in layer_model.attribute.get('activities', []): cls_by_elem.setdefault(aidx, []).append(cls_index) return [[c for a in lqn.actsof.get(j, []) for c in cls_by_elem.get(a, [])] for j in operand_idx] operand_idx = lqn.entriesof.get(sidx, []) cls_by_elem = {} for row in layer_model.attribute.get('calls', []): cls_by_elem.setdefault(row[3], []).append(row[0]) return [list(cls_by_elem.get(j, [])) for j in operand_idx] def _add_layer_admission_constraint(self, layer_model, idx): """ Emit the admission constraint of host or task IDX as a finite capacity region on that layer's server station -- see _kb/06-solver-catalog.md (LN section). The constraint is declared over entries or tasks, which the layer represents as job classes, so each declared column is expanded onto the classes that occupy the server on its behalf. """ lqn = self.lqn lincon = getattr(lqn, 'lincon', None) if idx is None or not lincon or idx not in lincon: return a_elem, b_elem = lincon[idx] if a_elem is None or a_elem.size == 0: return is_host_layer = layer_model.attribute.get('ishost', False) nclasses = len(layer_model.classes) a_layer = np.zeros((a_elem.shape[0], nclasses)) if is_host_layer: # column j is task constrained_idx[j], occupying the host through its activities constrained_idx = lqn.tasksof.get(idx, []) cls_by_elem = {} for cls_index, aidx in layer_model.attribute.get('activities', []): cls_by_elem.setdefault(aidx, []).append(cls_index) members = {j: [c for a in lqn.actsof.get(constrained_idx[j], []) for c in cls_by_elem.get(a, [])] for j in range(len(constrained_idx))} else: # column j is entry constrained_idx[j], occupied by the calls that target it constrained_idx = lqn.entriesof.get(idx, []) cls_by_elem = {} for row in layer_model.attribute.get('calls', []): cls_by_elem.setdefault(row[3], []).append(row[0]) members = {j: list(cls_by_elem.get(constrained_idx[j], [])) for j in range(len(constrained_idx))} for j, cls_indices in members.items(): for cls_index in cls_indices: # the attribute maps carry 1-based class indices if 1 <= cls_index <= nclasses: a_layer[:, cls_index - 1] += a_elem[:, j] if not np.any(a_layer): return stations = layer_model.attribute.get('server_stations', []) if not stations: return # One region spanning every replica: the constraint models a passive # resource of the server as a whole (a semaphore, a connection pool), so # replicas share the tokens rather than each holding a private copy region = layer_model.add_region(stations[0], *stations[1:]) region.setConstraint(a_layer, b_elem) def _region_wait(self, layer_idx, nodeidx_0, classidx_0, result): """ Waiting time absorbed by an admission constraint in a layer, recovered by Little's law from the layer population deficit. A job blocked at the constraint is counted at no station (JMT WAITQ convention), so its wait is absent from RN; without this the caller never sees the blocking and the fixed point loses flow balance. The population is conserved per chain, not per class: a job in a layer switches class along the activity graph, so the call class itself carries population 0. Splitting the chain deficit by throughput gives every region-visiting class the same wait. """ flags = getattr(self, 'layer_has_region', None) if not flags or layer_idx < 0 or layer_idx >= len(flags) or not flags[layer_idx]: return 0.0 chains = self.layer_chains[layer_idx] if chains is None or chains.size == 0: return 0.0 rows = np.flatnonzero(chains[:, classidx_0]) if rows.size == 0: return 0.0 chain_classes = np.flatnonzero(chains[rows[0], :]) QN = result.get('QN') TN = result.get('TN') if QN is None or TN is None: return 0.0 layer = self.ensemble[layer_idx] chain_pop = 0.0 for k in chain_classes: pop = getattr(layer.classes[k], 'population', None) if pop is not None and np.isfinite(pop): chain_pop += pop deficit = chain_pop - float(np.sum(QN[:, chain_classes])) xregion = float(np.sum(TN[nodeidx_0, chain_classes])) if np.isfinite(deficit) and deficit > 0 and xregion > GlobalConstants.FineTol: return deficit / xregion return 0.0
[docs] def init(self): """Initialize before starting iterations (matches MATLAB init).""" # The moment3 pass is terminal WITHIN ONE SOLVE, so the flag is scoped to # one iterate(): left standing, the terminal test in converged() fires at # it=0 on the NEXT solve, the loop body never runs and every metric comes # back zero. See BUGS.md BUG-97. self.moment_pass_done = False line_debug("LN init: %d layers, relaxation=%s (omega=%.3f)", self.nlayers, self.options.config.get('relax', 'none'), getattr(self, 'relax_omega', 1.0)) lqn = self.lqn self.unique_route_prob_updmap = np.unique(self.route_prob_updmap[:, 0]) if len(self.route_prob_updmap) > 0 else np.array([]) self.tput = np.zeros(lqn.nidx) self.tputproc = [None] * lqn.nidx self.util = np.zeros(lqn.nidx) self.servt = np.zeros(lqn.nidx) self.residt = np.zeros(lqn.nidx) self.thinkt = np.zeros(lqn.nidx) self.thinktproc = [None] * lqn.nidx self.callservt = np.zeros(lqn.ncalls) self.callresidt = np.zeros(lqn.ncalls) self.servtmatrix = self._get_entry_service_matrix() # feature-set gate stays armed on layer solvers; see _kb/06-solver-catalog.md LN Feature checks stay armed. for e in range(self.nlayers): if self.solvers[e] is not None: if hasattr(self.solvers[e], 'enable_checks'): self.solvers[e].enable_checks = True # Initialize relaxation state relax_mode = self.options.config.get('relax', 'none') if relax_mode == 'auto': self.relax_omega = 1.0 elif relax_mode in ['fixed', 'adaptive']: self.relax_omega = self.options.config.get('relax_factor', 0.9) else: self.relax_omega = 1.0 self.relax_err_history = [] self.servt_prev = np.full(lqn.nidx, np.nan) self.residt_prev = np.full(lqn.nidx, np.nan) self.tput_prev = np.full(lqn.nidx, np.nan) self.thinkt_prev = np.full(lqn.nidx, np.nan) self.callservt_prev = np.full(lqn.ncalls, np.nan) self.callresidt_prev = np.full(lqn.ncalls, np.nan) # stochastic iteration mode resolution; see _kb/06-solver-catalog.md LN Convergence test: stochastic iteration dispatch. self.stochlayers = np.zeros(self.nlayers, dtype=bool) for e in range(self.nlayers): solver = self.solvers[e] if solver is not None and hasattr(solver, 'isStochastic'): self.stochlayers[e] = solver.isStochastic() stoch_mode = str(self.options.config.get('stochiter', 'auto')).lower() self.stochiter_auto = (stoch_mode == 'auto') if self.stochiter_auto: stoch_mode = 'rm' if np.any(self.stochlayers) else 'off' self.stochiter_mode = stoch_mode self.stochiter_start = None self.stoch_avg = [None] * self.nlayers self.stoch_avg_count = 0 self.stoch_servt_avg = None self.stoch_residt_avg = None # rm/crn layer seed base: user seed if given, else randomized; matches MATLAB/JAR default. user_seed = getattr(self.options, 'seed', None) if user_seed is not None: self.stochiter_seed_base = int(user_seed) else: self.stochiter_seed_base = int(np.random.randint(1, 10**6)) line_debug("LN init: stochastic iteration mode=%s (%d stochastic layers)", self.stochiter_mode, int(np.sum(self.stochlayers))) # AND-fork visit correction applied post struct-build; MVA solver reset afterward so it recomputes demands from corrected visits. for e in range(self.nlayers): if e < len(self.ensemble) and self.ensemble[e] is not None: layer = self.ensemble[e] if layer.attribute.get('has_fork', False): # Force struct build if not already done if not layer._has_struct: layer.refresh_struct() self._apply_fork_visit_correction(layer) # Reset solver to pick up corrected visits if e < len(self.solvers) and self.solvers[e] is not None: if hasattr(self.solvers[e], 'reset'): self.solvers[e].reset() # layer_init='bound' seeds layer throughput from Majumdar-Woodside box bounds; see _kb/06-solver-catalog.md LN Feature checks stay armed section. init_mode = self.options.config.get('layer_init', None) \ if isinstance(self.options.config, dict) \ else getattr(self.options.config, 'layer_init', None) if init_mode and str(init_mode).lower() in ('bound', 'boxbound', 'mwba'): try: tup, _ = self._box_bounds(True) tlo, _ = self._box_bounds(False) for idx in range(lqn.nidx): u = tup[idx] l = tlo[idx] if np.isfinite(u) and np.isfinite(l) and u > 0 and l > 0: x = np.sqrt(u * l) elif np.isfinite(u): x = u elif np.isfinite(l): x = l else: x = 0.0 if x > 0: self.tput[idx] = x self.tputproc[idx] = Exp.fit_rate(x) line_debug("LN init: throughputs initialized from robust box bounds") except Exception as ex: line_debug("LN box-bound initialization skipped: %s", str(ex)) # warmstart replaces the starting point, and comes LAST so it wins over # layer_init: both write the iterate, and a seed is a solution of the # WHOLE model where a box bound is an envelope of one layer's throughput. self.warmstarted = False warm = self.options.config.get('warmstart', None) if warm == 'nlp': from .solver_ln_nlp_analyzer import ln_nlp_warm_start obj = ln_nlp_warm_start(self) line_debug("LN init: iterate seeded from the QD-AMVA program " "(objective %.3e); iter_min does not apply", obj) elif warm == 'mol': from .solver_ln_mol_warmstart import ln_mol_warm_start resid = ln_mol_warm_start(self) line_debug("LN init: iterate seeded from the Method of Layers " "(residual %.3e); iter_min does not apply", resid)
def _layer_index_of(self, elem_idx: int) -> Optional[int]: """0-based ensemble index of the layer where ELEM_IDX is a server, None if there is none.""" if self.idxhash is None or elem_idx >= len(self.idxhash): return None e = self.idxhash[elem_idx] if e is None or (isinstance(e, float) and np.isnan(e)): return None e = int(e) return e if 0 <= e < len(self.ensemble) else None def _layer_takes_interlock(self, e: int) -> bool: """True when the layer solver applies Eq. (4.7) inside its own MVA. Only the MVA layer solver reads options.config['interlock'], and only a layer whose sole queueing stations are the host's own tasks can take a matrix built for that host: under flat layering one layer holds every server, so the correction stays on the residence times there. """ from ..solver_mva import SolverMVA as _SolverMVA if e >= len(self.solvers) or not isinstance(self.solvers[e], _SolverMVA): return False cfg = getattr(self.options, 'config', None) layering = None if isinstance(cfg, dict): layering = cfg.get('layering', None) elif cfg is not None: layering = getattr(cfg, 'layering', None) if isinstance(layering, str) and layering.lower() in ('flat', 'squashed'): return False # A layer whose MVA path has no interlock term would be moved to another algorithm by # the matrix alone: exact multiserver MVA would become AMVA, the linearizer would # become the load-dependent forward step. That swap is worth far more than the # correction it carries, and on a layer sitting near a bifurcation it turns the LN # iteration into a limit cycle. Such a layer keeps the residt scaling instead. from ...api.solvers.mva.analyzers import mva_carries_interlock return mva_carries_interlock(self.ensemble[e].getStruct(), self.solvers[e].options) def _build_layer_interlock(self, e: int, host_tasks, task_pr_il, task_PrIL): """Class-level interlock matrix of one host layer. IL[r,s] is the share of the class-s queue that a class-r arrival must not see at the host. The matrix is CLASS-indexed, not chain-indexed, so that a later refreshChains cannot leave it stale; the layer solver aggregates it to chains against the struct it is about to solve. Two classes are interlocked only if BOTH their tasks are, which is the 0/1 relation ir_mkj of Eq. (5); the diagonal stays zero, since a request always sees its own class in full. The entry is the Eq. (5) product Pr(IL_ms)*IR_ms*IR_mr, asymmetric in (r,s) because Pr(IL) is taken from the QUEUED class s, so that the layer's ILw(r,s) = 1-IL(r,s) is the lower-level adjustment rate r_lower. """ classes = self.ensemble[e].classes nclasses = len(classes) class_pr_il = np.zeros(nclasses) # IR class_PrIL = np.zeros(nclasses) # Pr(IL) host_tasks = list(host_tasks) for r in range(nclasses): tidx = self._client_task_of_class(e, r) if tidx is None: continue if tidx in host_tasks: ti = host_tasks.index(tidx) class_pr_il[r] = task_pr_il[ti] class_PrIL[r] = task_PrIL[ti] IL = np.zeros((nclasses, nclasses)) for r in range(nclasses): if class_pr_il[r] <= GlobalConstants.FineTol: continue for sIl in range(nclasses): if sIl == r or class_pr_il[sIl] <= GlobalConstants.FineTol: continue IL[r, sIl] = class_PrIL[sIl] * class_pr_il[sIl] * class_pr_il[r] return IL if np.any(IL > GlobalConstants.FineTol) else None def _client_task_of_class(self, e: int, c: int) -> Optional[int]: """Task that a layer class belongs to, None when the class names no task.""" lqn = self.lqn cls = self.ensemble[e].classes[c] attr = getattr(cls, 'attribute', None) if attr is None or len(attr) < 2: return None tidx = None if attr[0] == LayeredNetworkElement.TASK: tidx = int(attr[1]) elif attr[0] in (LayeredNetworkElement.ENTRY, LayeredNetworkElement.ACTIVITY): tidx = self._get_parent(int(attr[1])) elif attr[0] == LayeredNetworkElement.CALL: cidx = int(attr[1]) if lqn.callpair is not None and cidx < len(lqn.callpair): tidx = self._get_parent(int(lqn.callpair[cidx, 0])) if tidx is None: return None tidx = int(tidx) if tidx < lqn.tshift or tidx >= lqn.tshift + lqn.ntasks: return None return tidx def _get_parent(self, idx: int) -> Optional[int]: """Get parent index for an element.""" lqn = self.lqn if hasattr(lqn, 'parent') and lqn.parent is not None: if isinstance(lqn.parent, dict): return lqn.parent.get(idx) elif isinstance(lqn.parent, np.ndarray): # parent is 0-indexed over elements and carries -1 where an # element has no parent, since 0 is the first host if idx < len(lqn.parent): val = lqn.parent[idx] if isinstance(val, np.ndarray): val = val.flatten()[0] if len(val) > 0 else -1 return int(val) if val >= 0 else None return None def _calls_of(self, aidx: int) -> List[int]: """Indices of the calls issued by activity aidx.""" lqn = self.lqn callsof = getattr(lqn, 'callsof', None) if isinstance(callsof, dict): return [int(c) for c in callsof.get(aidx, [])] if callsof is not None and aidx < len(callsof): entry = callsof[aidx] if entry is None: return [] return [int(c) for c in np.asarray(entry).flatten()] return [] def _chain_ref_indices(self, layer_idx: int, classidx_0: int): """(refstat, refclass) of the chain holding class CLASSIDX_0 in layer LAYER_IDX.""" refstat_k = None refclass_c = None if layer_idx < 0 or layer_idx >= len(self.ensemble) or self.ensemble[layer_idx] is None: return refstat_k, refclass_c layer_sn = self.ensemble[layer_idx]._sn if hasattr(self.ensemble[layer_idx], '_sn') else None if layer_sn is None: return refstat_k, refclass_c if getattr(layer_sn, 'chains', None) is not None: chains_arr = np.asarray(layer_sn.chains) if chains_arr.ndim == 2 and classidx_0 < chains_arr.shape[1]: for ch in range(chains_arr.shape[0]): if chains_arr[ch, classidx_0] > 0: if getattr(layer_sn, 'refclass', None) is not None: rc = np.asarray(layer_sn.refclass).flatten() if ch < len(rc): refclass_c = int(rc[ch]) break if getattr(layer_sn, 'refstat', None) is not None: rs = np.asarray(layer_sn.refstat).flatten() if classidx_0 < len(rs): refstat_k = int(rs[classidx_0]) return refstat_k, refclass_c def _is_activity_of_entry(self, aidx: int, eidx: int) -> bool: """Check if an activity is bound to an entry.""" lqn = self.lqn if hasattr(lqn, 'graph') and lqn.graph is not None: if isinstance(lqn.graph, np.ndarray): if eidx <= lqn.graph.shape[0] and aidx <= lqn.graph.shape[1]: return lqn.graph[eidx - 1, aidx - 1] > 0 return False def _get_activity_successors(self, aidx: int, activity_classes: Dict[int, Any]) -> List[Tuple[int, float]]: """ Get successor activities and their routing probabilities from lqn.graph. MATLAB equivalent: nextaidxs = find(lqn.graph(aidx,:)) in recurActGraph Args: aidx: Activity index activity_classes: Dict mapping activity index to class object Returns: List of (successor_aidx, probability) tuples for activities in same task """ lqn = self.lqn successors = [] if not hasattr(lqn, 'graph') or lqn.graph is None: return successors if not isinstance(lqn.graph, np.ndarray): return successors if aidx >= lqn.graph.shape[0]: return successors # Find successor activities in the graph (same task only, not entries) for succ_aidx in range(lqn.graph.shape[1]): prob = lqn.graph[aidx, succ_aidx] if prob > 0: # Check if successor is an activity in same task (not an entry/call target) if succ_aidx in activity_classes: successors.append((succ_aidx, float(prob))) return successors def _compute_fork_scope(self, layer_model) -> Set[int]: """Compute the set of activity indices in the fork scope (between fork source and join output). For AND-fork layers, activities in the fork scope have visits that are 1/fanout of their correct values due to flat routing. This method identifies these activities so their visits can be corrected. Returns: Set of absolute activity indices in the fork scope. """ lqn = self.lqn is_post_and = layer_model.attribute.get('is_post_and_act', set()) is_pre_and = layer_model.attribute.get('is_pre_and_act', set()) has_fork = layer_model.attribute.get('has_fork', False) if not has_fork or not is_post_and: return set() if not hasattr(lqn, 'graph') or lqn.graph is None or not isinstance(lqn.graph, np.ndarray): return set() # Get all activity indices in this layer acts_in_layer = set() classes = layer_model.get_classes() for cls in classes: if hasattr(cls, 'attribute') and cls.attribute is not None: if cls.attribute[0] == LayeredNetworkElement.ACTIVITY: acts_in_layer.add(cls.attribute[1]) # Find fork source: activity whose graph successors include POST_AND activities fork_source = None for aidx in acts_in_layer: if aidx < lqn.graph.shape[0]: successors = [j for j in range(lqn.graph.shape[1]) if lqn.graph[aidx, j] != 0] if any(s in is_post_and for s in successors): fork_source = aidx break if fork_source is None: return set() # Find join output: successor of PRE_AND activities that is not PRE_AND itself join_output = set() for aidx in is_pre_and: if aidx < lqn.graph.shape[0]: successors = [j for j in range(lqn.graph.shape[1]) if lqn.graph[aidx, j] != 0] for s in successors: if s not in is_pre_and and s in acts_in_layer: join_output.add(s) # Fork scope: all activities reachable from fork source, excluding join output scope = set() stack = [fork_source] while stack: a = stack.pop() if a in scope or a in join_output: continue scope.add(a) if a < lqn.graph.shape[0]: successors = [j for j in range(lqn.graph.shape[1]) if lqn.graph[a, j] != 0] for s in successors: if s in acts_in_layer and s not in scope and s not in join_output: stack.append(s) return scope def _apply_fork_visit_correction(self, layer_model): """Apply fork fanout correction to visit ratios in a layer model. For AND-fork layers with flat routing, the DTMC computes visits that are 1/fanout for fork-scope activities. This multiplies their visits by fanout to restore correct values. """ has_fork = layer_model.attribute.get('has_fork', False) maxfanout = layer_model.attribute.get('maxfanout', 1) if not has_fork or maxfanout <= 1: return # real Fork/Join nodes already get fanout via MMT; a post-hoc fanout multiplier here would double-count it. if layer_model.attribute.get('fork_node') is not None: return sn = layer_model._sn if hasattr(layer_model, '_sn') else None if sn is None or not hasattr(sn, 'visits') or sn.visits is None: return # fork-visit correction scales visits in place and is NOT idempotent; the corrected struct is marked so a surviving struct is never corrected twice. if getattr(sn, '_fork_visits_corrected', False): return sn._fork_visits_corrected = True # Compute or retrieve cached fork scope fork_scope = layer_model.attribute.get('_fork_scope') if fork_scope is None: fork_scope = self._compute_fork_scope(layer_model) layer_model.attribute['_fork_scope'] = fork_scope if not fork_scope: return # Find class indices for fork-scope activities fork_scope_class_indices = set() classes = layer_model.get_classes() for c_idx, cls in enumerate(classes): if hasattr(cls, 'attribute') and cls.attribute is not None: if cls.attribute[0] == LayeredNetworkElement.ACTIVITY: aidx = cls.attribute[1] if aidx in fork_scope: fork_scope_class_indices.add(c_idx) if not fork_scope_class_indices: return # Multiply visits for fork-scope classes by fanout at all stateful nodes for c in range(len(sn.visits)): v = sn.visits[c] for k in fork_scope_class_indices: if k < v.shape[1]: for ist in range(v.shape[0]): v[ist, k] *= maxfanout def _find_caller_class_in_layer(self, caller_tidx: int, layer_idx: int) -> Optional[int]: """Find the class index for a caller task in a layer.""" if layer_idx < 0 or layer_idx >= len(self.ensemble): return None layer = self.ensemble[layer_idx] if layer is None: return None tasks_matrix = layer.attribute.get('tasks', []) if isinstance(tasks_matrix, np.ndarray) and len(tasks_matrix) > 0: for row in range(tasks_matrix.shape[0]): if tasks_matrix[row, 1] == caller_tidx: return int(tasks_matrix[row, 0]) elif isinstance(tasks_matrix, list): for row in tasks_matrix: if len(row) > 1 and row[1] == caller_tidx: return row[0] return None def _find_activity_class_in_layer(self, aidx: int, layer_idx: int) -> Optional[int]: """Find the class index for an activity in a layer.""" if layer_idx < 0 or layer_idx >= len(self.ensemble): return None layer = self.ensemble[layer_idx] if layer is None: return None # Look for activity in the activities matrix activities_matrix = layer.attribute.get('activities', []) if isinstance(activities_matrix, np.ndarray) and len(activities_matrix) > 0: for row in range(activities_matrix.shape[0]): if activities_matrix[row, 1] == aidx: return int(activities_matrix[row, 0]) elif isinstance(activities_matrix, list): for row in activities_matrix: if len(row) > 1 and row[1] == aidx: return row[0] return None def _find_call_class_in_layer(self, cidx: int, layer_idx: int) -> Optional[int]: """Find the class index for a call in a layer.""" if layer_idx < 0 or layer_idx >= len(self.ensemble): return None layer = self.ensemble[layer_idx] if layer is None: return None # Look for call in the calls attribute # calls format: [class_index, cidx, src_aidx, tgt_eidx] calls_list = layer.attribute.get('calls', []) if isinstance(calls_list, np.ndarray) and len(calls_list) > 0: for row in range(calls_list.shape[0]): if calls_list[row, 1] == cidx: return int(calls_list[row, 0]) elif isinstance(calls_list, list): for row in calls_list: if len(row) > 1 and row[1] == cidx: return row[0] return None
[docs] def pre(self, it: int): """Operations before each iteration (matches MATLAB pre). Seed control for stochastic layer solvers. """ if self.stochiter_mode is None or self.stochlayers is None: return if self.stochiter_mode == 'rm': # rm mode rotates per-layer seeds each iteration for independent noise, as Robbins-Monro averaging requires. for e in np.where(self.stochlayers)[0]: solver = self.solvers[e] if solver is not None and hasattr(solver, 'options'): solver.options.seed = self.stochiter_seed_base + (it - 1) * self.nlayers + int(e) + 1 elif self.stochiter_mode == 'crn': # crn mode pins a constant per-layer seed (sample-average approximation), carrying an O(1/sqrt(samples)) bias vs the true fixed point. for e in np.where(self.stochlayers)[0]: solver = self.solvers[e] if solver is not None and hasattr(solver, 'options'): solver.options.seed = self.stochiter_seed_base + int(e) + 1
[docs] def analyze(self, it: int, e: int) -> Tuple[Dict, float]: """ Analyze a layer (matches MATLAB analyze). Returns: Tuple of (result dict, runtime) """ import time t0 = time.time() solver_name = type(self.solvers[e]).__name__ if self.solvers[e] is not None else 'None' line_debug("LN analyze: iteration %d, layer %d (%s)", it, e, solver_name) result = {} try: solver = self.solvers[e] if solver is not None: # Get average metrics from solver (try different method names) if hasattr(solver, 'getAvg'): QN, UN, RN, TN, AN, WN = solver.getAvg() elif hasattr(solver, 'get_avg'): QN, UN, RN, TN, AN, WN = solver.get_avg() else: raise AttributeError("Solver has no getAvg or get_avg method") # Sanitize results to prevent extreme values from MVA numerical instability max_val = 1e10 for arr in [QN, UN, RN, TN, WN]: if arr is not None and isinstance(arr, np.ndarray): # Clamp extreme positive values arr[arr > max_val] = np.nan # Replace negative values (shouldn't happen) with nan arr[arr < 0] = 0.0 # Replace inf with nan arr[np.isinf(arr)] = np.nan result['QN'] = QN result['UN'] = UN result['RN'] = RN result['TN'] = TN result['AN'] = AN result['WN'] = WN # stochastic classification refreshed from the method actually resolved at runtime, captured before post() resets the layer solvers. if it == 1 and self.stochlayers is not None and hasattr(solver, 'isStochastic'): self.stochlayers[e] = solver.isStochastic() # warm-start the next AMVA solve from the current chain-aggregated queue lengths; see _kb/06-solver-catalog.md LN AMVA warm-start section. if solver_name == 'SolverMVA' and isinstance(QN, np.ndarray): sne = self.ensemble[e].getStruct() if QN.shape == (sne.nstations, sne.nclasses): chains = np.asarray(sne.chains) Qch = np.zeros((sne.nstations, sne.nchains)) QNfin = np.nan_to_num(QN, nan=0.0, posinf=0.0, neginf=0.0) for c in range(sne.nchains): cls = np.where(chains[c, :] > 0)[0] Qch[:, c] = np.sum(QNfin[:, cls], axis=1) solver.options.init_sol = Qch except Exception as ex: # If solver fails, use previous iteration if available if it > 1 and len(self.results) >= it - 1: prev_result = self.results[it - 2][e] result = prev_result.copy() else: raise runtime = time.time() - t0 return result, runtime
[docs] def post(self, it: int): """Operations after each iteration (matches MATLAB post).""" line_debug("LN post: iteration %d, updating metrics and layer parameters", it) # Update metrics self.update_metrics(it) # Update think times self.update_think_times(it) # Update populations if interlocking enabled if self.options.config.get('interlocking', False): self.update_populations(it) # Update layer parameters self.update_layers(it) # Update routing probabilities self.update_routing_probabilities(it) self._refresh_ensemble() # Refresh layer structure if interlocking enabled (to update populations) if self.options.config.get('interlocking', False): for e in range(self.nlayers): if self.ensemble[e] is not None: if not self.ensemble[e]._has_struct: self.ensemble[e].refresh_struct() self._apply_fork_visit_correction(self.ensemble[e]) # Disable checks after first iteration if it == 1: for e in range(self.nlayers): if self.solvers[e] is not None: if hasattr(self.solvers[e], 'set_checks'): self.solvers[e].set_checks(False)
def _refresh_ensemble(self): """Carry the updated laws into the layer models and their solvers. The tail of post(), factored out because a warm start needs it too: it writes the same laws into the same layers, only from the program's solution rather than from an iteration's results. """ # refresh_rates() when only service times changed, full refresh_struct() when routing invalidated; mirrors MATLAB refreshRates/refreshChains split. for e in range(self.nlayers): if e < len(self.ensemble) and self.ensemble[e] is not None: if not self.ensemble[e]._has_struct: # Struct was invalidated (routing changed) - full rebuild needed self.ensemble[e].refresh_struct() # The rebuild can change the chain basis, invalidating the # warm-start solution cached by analyze() if e < len(self.solvers) and self.solvers[e] is not None \ and hasattr(self.solvers[e], 'options'): self.solvers[e].options.init_sol = None elif self._is_ph_encoding(): # a phase-type service law, whose phases a rate-only refresh # would drop -- see _kb/06-solver-catalog.md (LN section) self.ensemble[e].refresh_struct() else: # Only service rates changed - lightweight update # (may trigger full rebuild if _sn was set to None by set_service) self.ensemble[e].refresh_rates() # Re-apply fork visit correction (no-op for non-fork layers) self._apply_fork_visit_correction(self.ensemble[e]) # Reset solver to force recomputation if e < len(self.solvers) and self.solvers[e] is not None: if hasattr(self.solvers[e], 'reset'): self.solvers[e].reset()
[docs] def update_metrics(self, it: int): """Update metrics (matches MATLAB updateMetrics).""" method = self.lnmethod if self._is_ph_encoding(): # see _kb/06-solver-catalog.md (LN section) for rationale self._update_metrics_ph(it) elif method == 'moment3': self._update_metrics_moment_based(it) else: self._update_metrics_default(it)
def _layer_respt_cdf(self, repo, layer_idx): """Per-(station, class) response time CDFs of one layer, memoised in REPO. The fluid passage time is asked for first, as the reference does, and the LAYER's own solver answers when it refuses -- python's fluid getter raises ``passage-time integration failed (stiff augmented system)`` on the PS and INF layers an LQN is mostly made of. Returns: RD[station][class], an ``(n, 2)`` ``[cdf, time]`` array or None per cell, or None when neither solver produced anything. """ if layer_idx in repo: return repo[layer_idx] raw = None try: from ..solver_fld import SolverFLD raw = SolverFLD(self.ensemble[layer_idx]).getCdfRespT() except Exception: try: raw = self.solvers[layer_idx].getCdfRespT() except Exception: raw = None repo[layer_idx] = self._nested_respt_cdf(raw, self.ensemble[layer_idx]) return repo[layer_idx] @staticmethod def _nested_respt_cdf(raw, layer): """Bring either getCdfRespT contract to RD[station][class] = [cdf, time]. TWO CONTRACTS MEET HERE, and the entry assembly indexes only the first. ``SolverFLD`` returns the NESTED shape, ``RD[station][class]`` an ``(n, 2)`` ``[cdf, time]`` array. ``SolverMVA`` and ``SolverNC`` return the FLAT native contract, a list of dicts carrying 1-based ``station`` and ``class`` with numpy ``t`` and ``p`` -- the same contract ``cpp_dispatch.cdf_respt_via_cpp`` documents. The flat one is CONVERTED here rather than dropped. The isinstance guard this replaces tested the station row for ``list`` and silently discarded every dict, so on any model whose fluid passage time failed -- which is the common case -- a caller entry convolved its own host demand and NONE of its call terms, and reported an entry service time equal to that bare demand while the activity row beside it carried the full value. """ if raw is None: return None if not hasattr(raw, '__len__') or len(raw) == 0: return None first = next((cell for cell in raw if cell is not None), None) if first is None: return None if isinstance(first, (list, tuple)): return raw if isinstance(first, dict): sn = layer.getStruct() M, K = int(sn.nstations), int(sn.nclasses) RD = [[None] * K for _ in range(M)] for cell in raw: if cell is None: continue # 1-based on the wire, as the native contract specifies i = int(cell['station']) - 1 r = int(cell['class']) - 1 if not (0 <= i < M and 0 <= r < K): raise ValueError( "getCdfRespT returned station=%d class=%d, outside the layer's " "%d x %d index space" % (i + 1, r + 1, M, K)) t = np.asarray(cell['t'], dtype=float).ravel() p = np.asarray(cell['p'], dtype=float).ravel() if t.size != p.size: raise ValueError( "getCdfRespT cell (station=%d, class=%d) carries %d times and " "%d probabilities" % (i + 1, r + 1, t.size, p.size)) RD[i][r] = np.column_stack([p, t]) return RD raise TypeError( "unrecognised getCdfRespT return shape: expected the nested " "RD[station][class] arrays of SolverFLD or the flat list of " "{station, class, t, p} dicts of SolverMVA/SolverNC, got a %s" % type(first).__name__) def _update_metrics_moment_based(self, it: int): """Moment-based metrics update (matches MATLAB updateMetricsMomentBased).""" lqn = self.lqn if not self.hasconverged: # ===== PRE-CONVERGENCE: Mean-based propagation using exponential fits ===== # First obtain servt of activities at hostlayers self.servt = np.zeros(lqn.nidx) self.residt = np.zeros(lqn.nidx) if self.servt_classes_updmap is not None: for r in range(len(self.servt_classes_updmap)): idx = int(self.servt_classes_updmap[r, 0]) aidx = int(self.servt_classes_updmap[r, 1]) nodeidx = int(self.servt_classes_updmap[r, 2]) classidx = int(self.servt_classes_updmap[r, 3]) layer_idx = int(self.idxhash[idx]) nodeidx_0 = nodeidx - 1 if nodeidx >= 1 else 0 classidx_0 = classidx - 1 if classidx >= 1 else 0 if layer_idx >= 0 and len(self.results) > 0 and layer_idx < len(self.results[-1]): result = self.results[-1][layer_idx] if result is not None and 'RN' in result: RN = result['RN'] TN = result['TN'] QN = result.get('QN') WN = result.get('WN', RN) if RN is not None and nodeidx_0 < RN.shape[0] and classidx_0 < RN.shape[1]: self.servt[aidx] = RN[nodeidx_0, classidx_0] self.tput[aidx] = TN[nodeidx_0, classidx_0] if self.servt[aidx] > 0: self.servtproc[aidx] = Exp.fit_mean(self.servt[aidx]) # Compute residt from QN/TN_ref (matching updateMetricsDefault) refstat_k = None refclass_c = None if layer_idx < len(self.ensemble) and self.ensemble[layer_idx] is not None: layer_sn = self.ensemble[layer_idx]._sn if hasattr(self.ensemble[layer_idx], '_sn') else None if layer_sn is not None and hasattr(layer_sn, 'chains') and layer_sn.chains is not None: chains_arr = np.asarray(layer_sn.chains) if chains_arr.ndim == 2 and classidx_0 < chains_arr.shape[1]: for ch in range(chains_arr.shape[0]): if chains_arr[ch, classidx_0] > 0: if hasattr(layer_sn, 'refclass') and layer_sn.refclass is not None: rc = np.asarray(layer_sn.refclass).flatten() if ch < len(rc): refclass_c = int(rc[ch]) break if hasattr(layer_sn, 'refstat') and layer_sn.refstat is not None: rs = np.asarray(layer_sn.refstat).flatten() if classidx_0 < len(rs): refstat_k = int(rs[classidx_0]) if (refstat_k is not None and refclass_c is not None and QN is not None and TN is not None and 0 <= refstat_k < TN.shape[0] and 0 <= refclass_c < TN.shape[1]): TN_ref = TN[refstat_k, refclass_c] if TN_ref > 1e-8: # GlobalConstants.FineTol self.residt[aidx] = QN[nodeidx_0, classidx_0] / TN_ref else: self.residt[aidx] = WN[nodeidx_0, classidx_0] if WN is not None else RN[nodeidx_0, classidx_0] else: self.residt[aidx] = WN[nodeidx_0, classidx_0] if WN is not None else RN[nodeidx_0, classidx_0] # An activity think time is in series with the host demand zt_act = self._act_thinktime(aidx) if zt_act > 0: self.servt[aidx] += zt_act self.residt[aidx] += zt_act self.servtproc[aidx] = Exp.fit_mean(self.servt[aidx]) # async-only targets carry no visit-ratio scaling (matching _update_metrics_default) if lqn.ashift <= aidx < lqn.ashift + lqn.nacts: if hasattr(lqn, 'graph') and isinstance(lqn.graph, np.ndarray): for eidx in range(lqn.eshift, lqn.eshift + lqn.nentries): if eidx < lqn.graph.shape[0] and aidx < lqn.graph.shape[1] and lqn.graph[eidx, aidx] > 0: has_sync_callers = False has_async_callers = False if isinstance(getattr(lqn, 'issynccaller', None), np.ndarray) and eidx < lqn.issynccaller.shape[1]: has_sync_callers = np.any(lqn.issynccaller[:, eidx]) if isinstance(getattr(lqn, 'isasynccaller', None), np.ndarray) and eidx < lqn.isasynccaller.shape[1]: has_async_callers = np.any(lqn.isasynccaller[:, eidx]) if has_async_callers and not has_sync_callers: self.residt[aidx] = self.servt[aidx] break # Estimate call response times at hostlayers self.callservt = np.zeros(lqn.ncalls) self.callresidt = np.zeros(lqn.ncalls) if self.call_classes_updmap is not None: for c in range(len(self.call_classes_updmap)): idx = int(self.call_classes_updmap[c, 0]) cidx = int(self.call_classes_updmap[c, 1]) nodeidx = int(self.call_classes_updmap[c, 2]) classidx = int(self.call_classes_updmap[c, 3]) if nodeidx > 1: layer_idx = int(self.idxhash[idx]) if layer_idx >= 0 and len(self.results) > 0 and layer_idx < len(self.results[-1]): result = self.results[-1][layer_idx] if result is not None and 'RN' in result: RN = result['RN'] WN = result.get('WN', RN) nodeidx_0 = nodeidx - 1 if nodeidx >= 1 else 0 classidx_0 = classidx - 1 if classidx >= 1 else 0 if nodeidx_0 < RN.shape[0] and classidx_0 < RN.shape[1]: if nodeidx == 1: self.callservt[cidx] = 0.0 self.callresidt[cidx] = 0.0 else: # Include call multiplicity (matching updateMetricsDefault) call_mean = self._get_call_mean(cidx) fcr_wait = self._region_wait(layer_idx, nodeidx_0, classidx_0, result) self.callservt[cidx] = (RN[nodeidx_0, classidx_0] + fcr_wait) * call_mean # callresidt uses WN which already includes visit multiplicity self.callresidt[cidx] = WN[nodeidx_0, classidx_0] + fcr_wait # Resolve the entry servt summing up these contributions; the terms are # residence times (Vtask=1), rescaled to Ventry=1 by the task/entry tput ratio below # entry_servt = (I - servtmatrix)^(-1) * [residt; callresidt] size = lqn.nidx + lqn.ncalls combined_vec = np.zeros(size) combined_vec[:lqn.nidx] = self.residt combined_vec[lqn.nidx:lqn.nidx + lqn.ncalls] = self.callresidt identity = np.eye(size) system = identity - self.servtmatrix try: entry_servt = np.linalg.solve(system, combined_vec) except np.linalg.LinAlgError: entry_servt = np.linalg.lstsq(system, combined_vec, rcond=None)[0] # Clear entries up to eshift entry_servt[:lqn.eshift] = 0 # NO forwarding propagation here. _lqn_fwd_rendezvous has already # reconnected every forwarding chain reachable from a synchronous call # to the client that issued the rendezvous (Franks 1999, Sec. 3.3.1), # so the forwarded service is in the caller's chain before this runs; # adding it again inflated the caller by exactly the forwarded entry's # mean. An asynchronous call into a chain is left untouched there by # design -- a send-no-reply does not block -- so it must not accumulate # the forwarded service either. See BUGS.md BUG-91. # A SetupTask's cold start is charged HERE, to the entry, and with the # probability that the thread was actually found powered down. It is not # host demand, so it does not belong to any activity's residence: # reporting it there put RespT(A2) at 1.29479 on lqn_setup against the # 0.333178 LDES measures, which is the bare demand. See _setup_charge. for i in range(lqn.eshift, lqn.eshift + lqn.nentries): entry_servt[i] += self._setup_charge(self._get_parent(i)) # Update servt for entries for i in range(lqn.eshift, lqn.eshift + lqn.nentries): self.servt[i] = entry_servt[i] # Clear activities after ashift for i in range(lqn.ashift, len(entry_servt)): entry_servt[i] = 0 # Published for inspection, as MATLAB's SolverLN carries entry_servt # on the object: a debug driver reads it back after an iteration and # a local would leave it unreachable. self.entry_servt = entry_servt.copy() # Compute entry-level residt using servtmatrix and activity residt combined_residt = np.zeros(size) combined_residt[:lqn.nidx] = self.residt combined_residt[lqn.nidx:lqn.nidx + lqn.ncalls] = self.callresidt entry_residt_vec = self.servtmatrix @ combined_residt entry_residt_vec[:lqn.eshift] = 0 # Scale entry residt/servt by task/entry throughput ratio for e in range(lqn.nentries): eidx = lqn.eshift + e tidx = self._get_parent(eidx) hidx = self._get_parent(tidx) if tidx is not None else None if tidx is None or hidx is None: continue if self.ignore[tidx] or self.ignore[hidx]: continue has_sync_callers = self._has_sync_callers_for_entry(eidx) if has_sync_callers: tput_ratio = self._get_entry_tput_ratio(eidx, tidx, hidx) if tput_ratio is not None: task_tput, entry_tput = tput_ratio if entry_tput > GlobalConstants.Zero: self.servt[eidx] = entry_servt[eidx] * task_tput / entry_tput self.residt[eidx] = entry_residt_vec[eidx] * task_tput / entry_tput else: self.residt[eidx] = entry_residt_vec[eidx] else: self.residt[eidx] = entry_residt_vec[eidx] else: self.residt[eidx] = entry_residt_vec[eidx] # Update servtproc for entries if self.call_classes_updmap is not None: for row in self.call_classes_updmap: cidx = int(row[1]) nodeidx = int(row[2]) if nodeidx > 1: eidx = self._get_call_target_entry(cidx) if eidx is not None and eidx > 0 and eidx < len(self.servt): if self.servt[eidx] > 0: self.servtproc[eidx] = Exp.fit_mean(self.servt[eidx]) # Determine call response times processes if self.call_classes_updmap is not None: for row in self.call_classes_updmap: cidx = int(row[1]) nodeidx = int(row[2]) if nodeidx > 1: eidx = self._get_call_target_entry(cidx) if eidx is not None and eidx > 0: if it == 1: if eidx < len(self.servt): self.callservt[cidx] = self.servt[eidx] if eidx < len(self.servtproc) and self.servtproc[eidx] is not None: self.callservtproc[cidx] = self.servtproc[eidx] else: if self.callservt[cidx] > 0: self.callservtproc[cidx] = Exp.fit_mean(self.callservt[cidx]) else: # ===== POST-CONVERGENCE: Full CDF-based 3-moment APH fitting ===== from ...api.kpctoolbox.aph import aph_convseq, aph_simplify from ...api.butools.ph.canonical import APHFrom3Moments from ...distributions.markovian import APH self.servtcdf = [None] * lqn.nidx repo = {} # First obtain servt of activities at hostlayers self.servt = np.zeros(lqn.nidx) self.residt = np.zeros(lqn.nidx) if self.servt_classes_updmap is not None: for r in range(len(self.servt_classes_updmap)): idx = int(self.servt_classes_updmap[r, 0]) aidx = int(self.servt_classes_updmap[r, 1]) nodeidx = int(self.servt_classes_updmap[r, 2]) classidx = int(self.servt_classes_updmap[r, 3]) layer_idx = int(self.idxhash[idx]) nodeidx_0 = nodeidx - 1 if nodeidx >= 1 else 0 classidx_0 = classidx - 1 if classidx >= 1 else 0 if layer_idx >= 0 and len(self.results) > 0 and layer_idx < len(self.results[-1]): result = self.results[-1][layer_idx] if result is not None and 'TN' in result: self.tput[aidx] = result['TN'][nodeidx_0, classidx_0] # Compute residt from QN/TN_ref if result is not None and 'RN' in result: QN = result.get('QN') TN = result['TN'] WN = result.get('WN', result['RN']) refstat_k = None refclass_c = None if layer_idx < len(self.ensemble) and self.ensemble[layer_idx] is not None: layer_sn = self.ensemble[layer_idx]._sn if hasattr(self.ensemble[layer_idx], '_sn') else None if layer_sn is not None and hasattr(layer_sn, 'chains') and layer_sn.chains is not None: chains_arr = np.asarray(layer_sn.chains) if chains_arr.ndim == 2 and classidx_0 < chains_arr.shape[1]: for ch in range(chains_arr.shape[0]): if chains_arr[ch, classidx_0] > 0: if hasattr(layer_sn, 'refclass') and layer_sn.refclass is not None: rc = np.asarray(layer_sn.refclass).flatten() if ch < len(rc): refclass_c = int(rc[ch]) break if hasattr(layer_sn, 'refstat') and layer_sn.refstat is not None: rs = np.asarray(layer_sn.refstat).flatten() if classidx_0 < len(rs): refstat_k = int(rs[classidx_0]) if (refstat_k is not None and refclass_c is not None and QN is not None and TN is not None and 0 <= refstat_k < TN.shape[0] and 0 <= refclass_c < TN.shape[1]): TN_ref = TN[refstat_k, refclass_c] if TN_ref > 1e-8: self.residt[aidx] = QN[nodeidx_0, classidx_0] / TN_ref else: self.residt[aidx] = WN[nodeidx_0, classidx_0] if WN is not None else 0 else: self.residt[aidx] = WN[nodeidx_0, classidx_0] if WN is not None else 0 # Get CDFs - try SolverFluid first, fall back to layer solver cdf_data = self._layer_respt_cdf(repo, layer_idx) if (cdf_data is not None and nodeidx_0 < len(cdf_data) and cdf_data[nodeidx_0] is not None and classidx_0 < len(cdf_data[nodeidx_0])): self.servtcdf[aidx] = cdf_data[nodeidx_0][classidx_0] # Initialize callservtcdf self.callservtcdf = [None] * lqn.ncalls self.callservt = np.zeros(lqn.ncalls) self.callresidt = np.zeros(lqn.ncalls) if self.call_classes_updmap is not None: for c in range(len(self.call_classes_updmap)): idx = int(self.call_classes_updmap[c, 0]) cidx = int(self.call_classes_updmap[c, 1]) nodeidx = int(self.call_classes_updmap[c, 2]) classidx = int(self.call_classes_updmap[c, 3]) if nodeidx > 1: layer_idx = int(self.idxhash[idx]) nodeidx_0 = nodeidx - 1 if nodeidx >= 1 else 0 classidx_0 = classidx - 1 if classidx >= 1 else 0 cdf_data = self._layer_respt_cdf(repo, layer_idx) if (cdf_data is not None and nodeidx_0 < len(cdf_data) and cdf_data[nodeidx_0] is not None and classidx_0 < len(cdf_data[nodeidx_0])): self.callservtcdf[cidx] = cdf_data[nodeidx_0][classidx_0] # Also set callresidt from WN if layer_idx >= 0 and len(self.results) > 0 and layer_idx < len(self.results[-1]): result = self.results[-1][layer_idx] if result is not None and 'WN' in result: WN = result['WN'] if WN is not None and nodeidx_0 < WN.shape[0] and classidx_0 < WN.shape[1]: self.callresidt[cidx] = WN[nodeidx_0, classidx_0] \ + self._region_wait(layer_idx, nodeidx_0, classidx_0, result) # Build combined CDF list (servtcdf + callservtcdf) cdf = self.servtcdf + self.callservtcdf # Resolve entry service times using matrix inversion size = lqn.nidx + lqn.ncalls identity = np.eye(size) system = identity - self.servtmatrix try: matrix = np.linalg.inv(system) except np.linalg.LinAlgError: matrix = np.linalg.pinv(system) # Process each entry for i in range(lqn.nentries): eidx = lqn.eshift + i # Find contributing indices (where matrix[eidx,:] > 0) convolidx = [] for j in range(matrix.shape[1]): if matrix[eidx, j] > 0 and (j >= lqn.eshift + lqn.nentries): convolidx.append(j) # Build APH convolution list param_list = [] for fitidx in convolidx: cdf_data = cdf[fitidx] if fitidx < len(cdf) else None if cdf_data is None: continue # Extract raw moments from CDF data # CDF data is 2D array with columns [cdf_vals, times] if isinstance(cdf_data, np.ndarray) and cdf_data.ndim == 2 and cdf_data.shape[1] >= 2: cdf_vals = cdf_data[:, 0] times = cdf_data[:, 1] # bin midpoints weighted by the CDF increment, as EmpiricalCDF.getMoments x = times[:-1] + np.diff(times) / 2.0 dF = np.diff(cdf_vals) m1 = np.sum(x * dF) m2 = np.sum(x**2 * dF) m3 = np.sum(x**3 * dF) else: continue # An activity think time is in series with the host demand, # so its raw moments convolve with the measured ones before # the APH fit, as MATLAB's lqn_act_thinktime block does if fitidx < lqn.nidx and self._act_thinktime(fitidx) > 0: ztd = self.actthinkproc[fitidx] t1 = ztd.getMean() sig2 = ztd.getSCV() * t1 ** 2 t2 = sig2 + t1 ** 2 t3 = ztd.getSkewness() * sig2 ** 1.5 + 3 * t1 * t2 - 2 * t1 ** 3 m3 = m3 + 3 * m2 * t1 + 3 * m1 * t2 + t3 m2 = m2 + 2 * m1 * t1 + t2 m1 = m1 + t1 # Use CoarseTol to skip near-zero mean CDFs if m1 > GlobalConstants.CoarseTol: try: alpha, T = APHFrom3Moments([m1, m2, m3]) except Exception: continue # For call indices, multiply repetitions by mean number of calls reps = matrix[eidx, fitidx] # The CALL block starts AT nidx in this 0-based index # space; MATLAB numbers from 1, so its `> nidx` becomes # `>= nidx` here. Dead code until the layer CDF repo # started supplying call terms, and wrong the moment it # was not: it credited call 0 to the last activity. if fitidx >= lqn.nidx: cidx_local = fitidx - lqn.nidx reps = reps * self._get_call_mean(cidx_local) integer_reps = int(np.floor(reps)) fractional_part = reps - integer_reps if fractional_part == 0: for _ in range(integer_reps): param_list.append((alpha, T)) elif integer_reps > 0 and fractional_part > 0: for _ in range(integer_reps): param_list.append((alpha, T)) try: zero_alpha, zero_T = APHFrom3Moments([1e-8, 2e-16, 6e-24]) alpha_br, T_br = aph_simplify( alpha, T, zero_alpha, zero_T, fractional_part, 1.0 - fractional_part, 3) param_list.append((alpha_br, T_br)) except Exception: pass else: try: zero_alpha, zero_T = APHFrom3Moments([1e-8, 2e-16, 6e-24]) alpha_br, T_br = aph_simplify( alpha, T, zero_alpha, zero_T, fractional_part, 1.0 - fractional_part, 3) param_list.append((alpha_br, T_br)) except Exception: pass # Update servtproc and callservtproc # Same 0-based boundary: servtproc holds nidx entries, # so `fitidx == nidx` is the FIRST CALL, not the last # activity, and writing it here raised IndexError. if fitidx < lqn.nidx: self.servtproc[fitidx] = Exp.fit_mean(m1) self.servt[fitidx] = m1 else: self.callservtproc[fitidx - lqn.nidx] = Exp.fit_mean(m1) self.callservt[fitidx - lqn.nidx] = m1 # Convolve all contributions if not param_list: self.servt[eidx] = 0 else: entry_dist = None try: alpha_conv, T_conv = aph_convseq(param_list) entry_dist = APH(alpha_conv, T_conv) except Exception: self.servt[eidx] = 0 if entry_dist is not None: # ENTRY-LOCAL INDEX, 0-BASED. MATLAB numbers the entries # 1..nentries and guards `0 < e <= nentries`; this index # space runs 0..nentries-1, so carrying that guard over # verbatim silently dropped entry 0 -- its law was never # stored and getCdfRespT had nothing to return for it. entry_index = eidx - lqn.eshift if self.entryproc is None: self.entryproc = [None] * lqn.nentries self.entryproc[entry_index] = entry_dist self.servt[eidx] = entry_dist.getMean() self.servtproc[eidx] = Exp.fit_mean(self.servt[eidx]) # Unguarded, as the reference is: a law that cannot be # tabulated is a defect to surface, not a service time # to quietly replace with zero. self.entrycdfrespt[entry_index] = entry_dist.evalCDF() # fallback linear-system entry servt when APH fitting fails (CDF unavailable), matching MATLAB's SolverFluid-always-succeeds assumption. any_zero_entry = any( self.servt[lqn.eshift + i] == 0 for i in range(lqn.nentries) ) if any_zero_entry: # Rebuild activity-level servt from results for the system solve fallback_servt = np.zeros(lqn.nidx) if self.servt_classes_updmap is not None: for r in range(len(self.servt_classes_updmap)): idx = int(self.servt_classes_updmap[r, 0]) aidx = int(self.servt_classes_updmap[r, 1]) nodeidx = int(self.servt_classes_updmap[r, 2]) classidx = int(self.servt_classes_updmap[r, 3]) layer_idx = int(self.idxhash[idx]) nodeidx_0 = nodeidx - 1 if nodeidx >= 1 else 0 classidx_0 = classidx - 1 if classidx >= 1 else 0 if layer_idx >= 0 and len(self.results) > 0 and layer_idx < len(self.results[-1]): result = self.results[-1][layer_idx] if result is not None and 'RN' in result: RN = result['RN'] if nodeidx_0 < RN.shape[0] and classidx_0 < RN.shape[1]: fallback_servt[aidx] = RN[nodeidx_0, classidx_0] fallback_callservt = np.zeros(lqn.ncalls) if self.call_classes_updmap is not None: for c in range(len(self.call_classes_updmap)): idx = int(self.call_classes_updmap[c, 0]) cidx = int(self.call_classes_updmap[c, 1]) nodeidx = int(self.call_classes_updmap[c, 2]) classidx = int(self.call_classes_updmap[c, 3]) if nodeidx > 1: layer_idx = int(self.idxhash[idx]) nodeidx_0 = nodeidx - 1 if nodeidx >= 1 else 0 classidx_0 = classidx - 1 if classidx >= 1 else 0 if layer_idx >= 0 and len(self.results) > 0 and layer_idx < len(self.results[-1]): result = self.results[-1][layer_idx] if result is not None and 'RN' in result: RN = result['RN'] if nodeidx_0 < RN.shape[0] and classidx_0 < RN.shape[1]: call_mean = self._get_call_mean(cidx) fallback_callservt[cidx] = (RN[nodeidx_0, classidx_0] + self._region_wait(layer_idx, nodeidx_0, classidx_0, result)) * call_mean # Solve (I - servtmatrix) * entry_servt = [servt; callservt] combined_vec = np.zeros(size) combined_vec[:lqn.nidx] = fallback_servt combined_vec[lqn.nidx:lqn.nidx + lqn.ncalls] = fallback_callservt try: entry_servt_fb = np.linalg.solve(system, combined_vec) except np.linalg.LinAlgError: entry_servt_fb = np.linalg.lstsq(system, combined_vec, rcond=None)[0] entry_servt_fb[:lqn.eshift] = 0 for i in range(lqn.nentries): eidx = lqn.eshift + i if self.servt[eidx] == 0 and entry_servt_fb[eidx] > 0: self.servt[eidx] = entry_servt_fb[eidx] self.servtproc[eidx] = Exp.fit_mean(self.servt[eidx]) # NO forwarding propagation here, for the reason given at the # entry_servt assembly above: _lqn_fwd_rendezvous has already charged # the forwarded service to the caller. See BUGS.md BUG-91. # Compute entry-level residt combined_residt = np.zeros(size) combined_residt[:lqn.nidx] = self.residt combined_residt[lqn.nidx:lqn.nidx + lqn.ncalls] = self.callresidt entry_residt_vec = self.servtmatrix @ combined_residt entry_residt_vec[:lqn.eshift] = 0 for e in range(lqn.nentries): eidx = lqn.eshift + e tidx = self._get_parent(eidx) hidx = self._get_parent(tidx) if tidx is not None else None if tidx is None or hidx is None: continue if self.ignore[tidx] or self.ignore[hidx]: continue has_sync_callers = self._has_sync_callers_for_entry(eidx) if has_sync_callers: tput_ratio = self._get_entry_tput_ratio(eidx, tidx, hidx) if tput_ratio is not None: task_tput, entry_tput = tput_ratio if entry_tput > GlobalConstants.Zero: self.residt[eidx] = entry_residt_vec[eidx] * task_tput / entry_tput else: self.residt[eidx] = entry_residt_vec[eidx] else: self.residt[eidx] = entry_residt_vec[eidx] else: self.residt[eidx] = entry_residt_vec[eidx] # Determine call response times processes (final loop) if self.call_classes_updmap is not None: for row in self.call_classes_updmap: cidx = int(row[1]) nodeidx = int(row[2]) if nodeidx > 1: eidx = self._get_call_target_entry(cidx) if eidx is not None and eidx > 0: if it == 1: if eidx < len(self.servt): self.callservt[cidx] = self.servt[eidx] if eidx < len(self.servtproc) and self.servtproc[eidx] is not None: self.callservtproc[cidx] = Exp.fit_mean(self.servt[eidx]) # This pass IS the moment3 answer, and it is TERMINAL. Its entry laws # are convolutions of the activities' own response distributions; the # pre-convergence branch instead reads QN/TN_ref, a residence per # REFERENCE cycle, which the entry assembly then treats as a # per-entry-visit time. The two disagree by the entry's visit ratio # whenever it is not 1, so letting the iteration fall back to that # branch after this one has run DISCARDS the moment-based laws and # reports the other quantity. See BUGS.md BUG-97. self.moment_pass_done = True def _has_sync_callers_for_entry(self, eidx: int) -> bool: """Check if entry has sync callers.""" lqn = self.lqn if hasattr(lqn, 'callpair') and lqn.callpair is not None and hasattr(lqn, 'calltype'): for cidx in range(lqn.ncalls): if cidx < len(lqn.callpair): tgt_eidx = int(lqn.callpair[cidx, 1]) if lqn.callpair[cidx, 1] > 0 else 0 if tgt_eidx == eidx: calltype = lqn.calltype[cidx] if cidx < len(lqn.calltype) else 0 is_sync = (calltype == CallType.SYNC or calltype == CallType.SYNC.value or (isinstance(calltype, (int, np.integer)) and int(calltype) == CallType.SYNC.value)) if is_sync: return True return False def _get_entry_tput_ratio(self, eidx: int, tidx: int, hidx: int) -> Optional[Tuple[float, float]]: """Get task/entry throughput ratio from host layer results.""" if np.isnan(self.idxhash[hidx]): return None layer_idx = int(self.idxhash[hidx]) if layer_idx < 0 or layer_idx >= len(self.ensemble) or self.ensemble[layer_idx] is None: return None layer = self.ensemble[layer_idx] result = self.results[-1][layer_idx] if len(self.results) > 0 and layer_idx < len(self.results[-1]) else None if result is None or 'TN' not in result: return None TN = result['TN'] client_idx = layer.attribute.get('clientIdx', 1) client_idx_0 = (client_idx - 1) if client_idx >= 1 else 0 # Find task class index tasks_matrix = layer.attribute.get('tasks', []) tidxclass = None if isinstance(tasks_matrix, np.ndarray) and len(tasks_matrix) > 0: for row in range(tasks_matrix.shape[0]): if tasks_matrix[row, 1] == tidx: tidxclass = int(tasks_matrix[row, 0]) - 1 break elif isinstance(tasks_matrix, list): for row in tasks_matrix: if len(row) > 1 and row[1] == tidx: tidxclass = row[0] - 1 break # Find entry class index entries_matrix = layer.attribute.get('entries', []) eidxclass = None if isinstance(entries_matrix, np.ndarray) and len(entries_matrix) > 0: for row in range(entries_matrix.shape[0]): if entries_matrix[row, 1] == eidx: eidxclass = int(entries_matrix[row, 0]) - 1 break elif isinstance(entries_matrix, list): for row in entries_matrix: if len(row) > 1 and row[1] == eidx: eidxclass = row[0] - 1 break task_tput = 0.0 entry_tput = 0.0 if tidxclass is not None and client_idx_0 < TN.shape[0] and tidxclass < TN.shape[1]: task_tput = TN[client_idx_0, tidxclass] if eidxclass is not None and client_idx_0 < TN.shape[0] and eidxclass < TN.shape[1]: entry_tput = TN[client_idx_0, eidxclass] return (task_tput, entry_tput) def _branch_members(self, joinaidx: int): """ The activities belonging to each branch of an AND-join. A branch is recovered by walking backwards from each immediate predecessor of the join until an activity marked POST_AND is reached, that activity being the branch head spawned by the AND-fork. Branches between a fork and its join are disjoint paths, so the walk is unambiguous. actposttype, like the graph and residt, is indexed by global element index. """ lqn = self.lqn graph = lqn.graph ashift = lqn.ashift nacts = lqn.nacts post_and_value = 12 # ActivityPrecedenceType.ID_POST_AND flat_posttype = lqn.actposttype.flatten() if getattr(lqn, 'actposttype', None) is not None \ else np.zeros(0) members = [] for tail in range(graph.shape[0]): if graph[tail, joinaidx] <= 0: continue if tail < ashift or tail >= ashift + nacts: continue # not an activity chain = [tail] cur = tail for _ in range(nacts): if 0 < cur < len(flat_posttype) and flat_posttype[cur] == post_and_value: break # branch head prevs = [p for p in range(graph.shape[0]) if p != cur and graph[p, cur] > 0 and ashift <= p < ashift + nacts] if len(prevs) != 1: break # a merge or the start of the graph cur = prevs[0] chain.append(cur) members.append(chain) return members def _update_join_delays(self): """ Compute the completion time of every AND-join and the correction it implies. The branches of an AND-fork run concurrently, so the time to pass the join is the k-th smallest of the branch completion times, k being the quorum of the join (k equals the branch count when the join waits for all its branches). Times are taken over residt because that is the quantity entry_servt aggregates. Returns a dict mapping each join target to the join time minus the sequential sum of its branch times, i.e. the amount by which the reachability matrix overcounts. """ from line_solver.api.fj import quorum_moments lqn = self.lqn pre_and_value = 2 # ActivityPrecedenceType.ID_PRE_AND excess = {} if getattr(lqn, 'actpretype', None) is None: return excess flat_pretype = lqn.actpretype.flatten() flat_quorum = lqn.actquorum.flatten() if getattr(lqn, 'actquorum', None) is not None \ else np.zeros(0) self.joint = np.zeros(lqn.nidx) # Restricted to activities: PRE_AND marks the branch tails, so the joins are the # activities whose predecessors carry that mark. for aidx in range(lqn.ashift, min(lqn.ashift + lqn.nacts, lqn.graph.shape[0])): # A join target is an activity whose predecessors are PRE_AND. is_join = False for pred in range(lqn.graph.shape[0]): if pred != aidx and lqn.graph[pred, aidx] > 0: if 0 < pred < len(flat_pretype) and flat_pretype[pred] == pre_and_value: is_join = True break if not is_join: continue branches = self._branch_members(aidx) n = len(branches) if n == 0: continue # A branch time is residt PLUS the callresidt of every synchronous call # its activities issue: a branch activity with an Immediate host demand # does all its work in a rendezvous, and reading residt alone would make # this whole correction vanish silently. See _kb/06-solver-catalog.md. branch_times = [] for b in branches: bt = float(sum(self.residt[m] for m in b)) for baidx in b: for cidx in self._calls_of(baidx): if int(lqn.calltype[cidx]) == CallType.SYNC and cidx < len(self.callresidt): bt += float(self.callresidt[cidx]) branch_times.append(bt) if n == 1: self.joint[aidx] = branch_times[0] continue quorum = n if 0 < aidx < len(flat_quorum): q = int(flat_quorum[aidx]) if 1 <= q <= n: quorum = q # Branch times are taken as exponential, so the variance is the square of the mean. jt, _ = quorum_moments(branch_times, [t * t for t in branch_times], quorum) self.joint[aidx] = jt excess[aidx] = jt - sum(branch_times) return excess def _update_metrics_default(self, it: int): """Default metrics update (matches MATLAB updateMetricsDefault).""" lqn = self.lqn # Update activity service times from layer results self.servt = np.zeros(lqn.nidx) self.residt = np.zeros(lqn.nidx) # Calculate iter_min for averaging window (matches MATLAB updateMetricsDefault line 16) # MATLAB: iter_min = min(30, ceil(self.options.iter_max/4)) iter_min = min(30, max(1, (self.options.iter_max + 3) // 4)) # Python ceil equivalent if self.servt_classes_updmap is not None: for r in range(len(self.servt_classes_updmap)): idx = int(self.servt_classes_updmap[r, 0]) aidx = int(self.servt_classes_updmap[r, 1]) nodeidx = int(self.servt_classes_updmap[r, 2]) classidx = int(self.servt_classes_updmap[r, 3]) layer_idx = int(self.idxhash[idx]) # Convert 1-based indices to 0-based for numpy nodeidx_0 = nodeidx - 1 if nodeidx >= 1 else 0 classidx_0 = classidx - 1 if classidx >= 1 else 0 # Compute refstat/refclass from layer struct for QN/TN_ref computation # (matches MATLAB updateMetricsDefault.m lines 23-27) refstat_k = None refclass_c = None if layer_idx < len(self.ensemble) and self.ensemble[layer_idx] is not None: layer_sn = self.ensemble[layer_idx]._sn if hasattr(self.ensemble[layer_idx], '_sn') else None if layer_sn is not None and hasattr(layer_sn, 'chains') and layer_sn.chains is not None: chains_arr = np.asarray(layer_sn.chains) if chains_arr.ndim == 2 and classidx_0 < chains_arr.shape[1]: for ch in range(chains_arr.shape[0]): if chains_arr[ch, classidx_0] > 0: if hasattr(layer_sn, 'refclass') and layer_sn.refclass is not None: rc = np.asarray(layer_sn.refclass).flatten() if ch < len(rc): refclass_c = int(rc[ch]) break if hasattr(layer_sn, 'refstat') and layer_sn.refstat is not None: rs = np.asarray(layer_sn.refstat).flatten() if classidx_0 < len(rs): refstat_k = int(rs[classidx_0]) # Apply averaging window for steady-state (matches MATLAB lines 18-31) if self.averagingstart is not None and it >= iter_min and len(self.results) > 1: # Calculate window size (how many iterations since averaging started) wnd_size = it - self.averagingstart + 1 if wnd_size > 1: # Average over past iterations self.servt[aidx] = 0.0 self.residt[aidx] = 0.0 self.tput[aidx] = 0.0 valid_samples = 0 for w in range(0, wnd_size): result_idx = len(self.results) - 1 - w if result_idx >= 0 and result_idx < len(self.results): if layer_idx < len(self.results[result_idx]): hist_result = self.results[result_idx][layer_idx] if hist_result is not None and 'RN' in hist_result: hist_RN = hist_result['RN'] hist_TN = hist_result['TN'] hist_QN = hist_result.get('QN') hist_WN = hist_result.get('WN', hist_RN) if (hist_RN is not None and nodeidx_0 < hist_RN.shape[0] and classidx_0 < hist_RN.shape[1]): self.servt[aidx] += hist_RN[nodeidx_0, classidx_0] # Compute residt from QN/TN_ref (matches MATLAB) if (refstat_k is not None and refclass_c is not None and hist_QN is not None and hist_TN is not None and 0 <= refstat_k < hist_TN.shape[0] and 0 <= refclass_c < hist_TN.shape[1]): TN_ref_w = hist_TN[refstat_k, refclass_c] if TN_ref_w > 1e-8: # GlobalConstants.FineTol self.residt[aidx] += hist_QN[nodeidx_0, classidx_0] / TN_ref_w else: self.residt[aidx] += (hist_WN[nodeidx_0, classidx_0] if hist_WN is not None else hist_RN[nodeidx_0, classidx_0]) else: self.residt[aidx] += (hist_WN[nodeidx_0, classidx_0] if hist_WN is not None else hist_RN[nodeidx_0, classidx_0]) self.tput[aidx] += hist_TN[nodeidx_0, classidx_0] valid_samples += 1 if valid_samples > 0: # MATLAB divides by wnd_size (not valid_samples) for damping effect # (matches MATLAB updateMetricsDefault.m lines 22-25) self.servt[aidx] /= wnd_size self.residt[aidx] /= wnd_size self.tput[aidx] /= wnd_size else: # No valid historical samples, fall through to latest result self._extract_latest_metrics(aidx, layer_idx, nodeidx_0, classidx_0, refstat_k, refclass_c) else: # Window size is 1, use latest result self._extract_latest_metrics(aidx, layer_idx, nodeidx_0, classidx_0, refstat_k, refclass_c) else: # Before averaging starts, use latest result directly self._extract_latest_metrics(aidx, layer_idx, nodeidx_0, classidx_0, refstat_k, refclass_c) # activity think time in series with host demand; see _kb/06-solver-catalog.md LN Activity think time section. zt_act = self._act_thinktime(aidx) if zt_act > 0: self.servt[aidx] = self.servt[aidx] + zt_act self.residt[aidx] = self.residt[aidx] + zt_act # Inf/NaN fallback: if layer MVA returned Inf/NaN, use previous value if it > 1: if (np.isinf(self.servt[aidx]) or np.isnan(self.servt[aidx])) and not np.isnan(self.servt_prev[aidx]): self.servt[aidx] = self.servt_prev[aidx] if (np.isinf(self.residt[aidx]) or np.isnan(self.residt[aidx])) and not np.isnan(self.residt_prev[aidx]): self.residt[aidx] = self.residt_prev[aidx] if (np.isinf(self.tput[aidx]) or np.isnan(self.tput[aidx])) and not np.isnan(self.tput_prev[aidx]): self.tput[aidx] = self.tput_prev[aidx] # Apply under-relaxation omega = self.relax_omega if omega < 1.0 and it > 1: if not np.isnan(self.servt_prev[aidx]): self.servt[aidx] = omega * self.servt[aidx] + (1 - omega) * self.servt_prev[aidx] if not np.isnan(self.residt_prev[aidx]): self.residt[aidx] = omega * self.residt[aidx] + (1 - omega) * self.residt_prev[aidx] if not np.isnan(self.tput_prev[aidx]): self.tput[aidx] = omega * self.tput[aidx] + (1 - omega) * self.tput_prev[aidx] self.servt_prev[aidx] = self.servt[aidx] self.residt_prev[aidx] = self.residt[aidx] self.tput_prev[aidx] = self.tput[aidx] # Update service time process with bounds checking # Safeguard against MVA numerical instability producing extreme values max_servt = 1e10 if self.servt[aidx] > 0 and self.servt[aidx] <= max_servt: self.servtproc[aidx] = Exp.fit_mean(self.servt[aidx]) # mirrors MATLAB updateMetricsDefault.m:124; an async call's Source reads it. # Exp rejects rate 0 here where MATLAB admits it, so a null rate is Disabled. self.tputproc[aidx] = Exp.fit_rate(self.tput[aidx]) \ if self.tput[aidx] > 0 else Disabled() # async-only entries use RN for residt, not WN, as async arrivals don't share closed chain's visit ratio; mirrors MATLAB updateMetricsDefault.m:33-54. if lqn.ashift <= aidx < lqn.ashift + lqn.nacts: # This is an activity - find its bound entry for eidx in range(lqn.eshift, lqn.eshift + lqn.nentries): # Check if activity is bound to this entry (edge from entry to activity in graph) if hasattr(lqn, 'graph') and lqn.graph is not None: if isinstance(lqn.graph, np.ndarray): if eidx < lqn.graph.shape[0] and aidx < lqn.graph.shape[1]: if lqn.graph[eidx, aidx] > 0: # Found bound entry - check if async-only has_sync_callers = False has_async_callers = False if hasattr(lqn, 'issynccaller') and lqn.issynccaller is not None: if isinstance(lqn.issynccaller, np.ndarray): if eidx < lqn.issynccaller.shape[1]: has_sync_callers = np.any(lqn.issynccaller[:, eidx]) if hasattr(lqn, 'isasynccaller') and lqn.isasynccaller is not None: if isinstance(lqn.isasynccaller, np.ndarray): if eidx < lqn.isasynccaller.shape[1]: has_async_callers = np.any(lqn.isasynccaller[:, eidx]) if has_async_callers and not has_sync_callers: # Async-only target: use RN (response time per visit) # instead of WN (residence time with visit ratio) self.residt[aidx] = self.servt[aidx] # servt already has RN break # throughput of activities that appear only as client-side classes, so that an # async call's Source has a rate; mirrors MATLAB updateMetricsDefault.m:161-183 if self.thinkt_classes_updmap is not None: for r in range(len(self.thinkt_classes_updmap)): idx = int(self.thinkt_classes_updmap[r, 0]) aidx = int(self.thinkt_classes_updmap[r, 1]) nodeidx_0 = int(self.thinkt_classes_updmap[r, 2]) - 1 classidx_0 = int(self.thinkt_classes_updmap[r, 3]) - 1 if aidx >= len(self.tputproc) or self.tputproc[aidx] is not None: continue if np.isnan(self.idxhash[idx]): continue layer_idx = int(self.idxhash[idx]) if layer_idx < 0 or not self.results or layer_idx >= len(self.results[-1]): continue tp = 0.0 wnd_size = (it - self.averagingstart + 1) if self.averagingstart is not None else 1 if self.averagingstart is not None and it >= iter_min and wnd_size > 1 and len(self.results) > 1: seen = 0 for w in range(0, wnd_size): result_idx = len(self.results) - 1 - w if result_idx < 0 or layer_idx >= len(self.results[result_idx]): continue hist = self.results[result_idx][layer_idx] if hist is None or 'TN' not in hist or hist['TN'] is None: continue hist_TN = hist['TN'] if nodeidx_0 < hist_TN.shape[0] and classidx_0 < hist_TN.shape[1]: tp += hist_TN[nodeidx_0, classidx_0] seen += 1 tp = tp / wnd_size if seen > 0 else 0.0 if tp == 0.0: latest = self.results[-1][layer_idx] if latest is not None and latest.get('TN') is not None: TNl = latest['TN'] if nodeidx_0 < TNl.shape[0] and classidx_0 < TNl.shape[1]: tp = TNl[nodeidx_0, classidx_0] self.tput[aidx] = tp self.tputproc[aidx] = Exp.fit_rate(tp) if tp > 0 else Disabled() # Update call service times (matches MATLAB updateMetricsDefault lines 140-162) self.callservt = np.zeros(lqn.ncalls) self.callresidt = np.zeros(lqn.ncalls) if self.call_classes_updmap is not None: for c in range(len(self.call_classes_updmap)): cidx = int(self.call_classes_updmap[c, 1]) nodeidx = int(self.call_classes_updmap[c, 2]) idx = int(self.call_classes_updmap[c, 0]) classidx = int(self.call_classes_updmap[c, 3]) # callresidt only updated for SERVER calls (nodeidx>1); CLIENT calls (Immediate) get callresidt from the callee's own SERVER-call entry. if nodeidx > 1: layer_idx = int(self.idxhash[idx]) if layer_idx >= 0 and len(self.results) > 0 and layer_idx < len(self.results[-1]): result = self.results[-1][layer_idx] if result is not None and 'RN' in result: RN = result['RN'] WN = result.get('WN', RN) if RN is not None and WN is not None: # Convert 1-based indices to 0-based for numpy nodeidx_0 = nodeidx - 1 if nodeidx >= 1 else 0 classidx_0 = classidx - 1 if classidx >= 1 else 0 if nodeidx_0 < RN.shape[0] and classidx_0 < RN.shape[1]: call_mean = self._get_call_mean(cidx) fcr_wait = self._region_wait(layer_idx, nodeidx_0, classidx_0, result) # MATLAB line 152: callservt = RN * callproc.getMean self.callservt[cidx] = (RN[nodeidx_0, classidx_0] + fcr_wait) * call_mean # Normalise per chain-reference visit, as residt does. # WN divides by the class's own reference rate when the # layer is open (an INF client task), which is per-ENTRY # visit, and the entry rescaling below would then count # the call once per entry. QNr = result.get('QN', None) TNr = result.get('TN', None) TN_ref = 0.0 if QNr is not None and TNr is not None: refstat_k, refclass_c = self._chain_ref_indices(layer_idx, classidx_0) if refstat_k is not None and refclass_c is not None \ and 0 <= refstat_k < TNr.shape[0] and 0 <= refclass_c < TNr.shape[1]: TN_ref = TNr[refstat_k, refclass_c] if TN_ref > GlobalConstants.FineTol: self.callresidt[cidx] = QNr[nodeidx_0, classidx_0] / TN_ref + fcr_wait else: self.callresidt[cidx] = WN[nodeidx_0, classidx_0] + fcr_wait # Inf/NaN fallback: if layer MVA returned Inf/NaN, use previous value if (np.isinf(self.callservt[cidx]) or np.isnan(self.callservt[cidx])) and it > 1 and not np.isnan(self.callservt_prev[cidx]): self.callservt[cidx] = self.callservt_prev[cidx] if (np.isinf(self.callresidt[cidx]) or np.isnan(self.callresidt[cidx])) and it > 1 and not np.isnan(self.callresidt_prev[cidx]): self.callresidt[cidx] = self.callresidt_prev[cidx] # Apply under-relaxation to call service times (MATLAB lines 155-160) omega = self.relax_omega if omega < 1.0 and it > 1 and not np.isnan(self.callservt_prev[cidx]): self.callservt[cidx] = omega * self.callservt[cidx] + (1 - omega) * self.callservt_prev[cidx] # no growth-rate capping on callservt: it would prevent convergence from near-zero (Immediate) initial values. self.callservt_prev[cidx] = self.callservt[cidx] self.callresidt_prev[cidx] = self.callresidt[cidx] # entry_servt = servtmatrix * [residt; callresidt]; servtmatrix carries cache hit/miss weighting probabilities. # servtmatrix indices 0..nidx are LQN elements, nidx+1..nidx+ncalls are calls. size = lqn.nidx + lqn.ncalls # Build combined vector combined_vec = np.zeros(size) # Fill activity residence times (indices are activity indices in LQN) for aidx in range(lqn.ashift, lqn.ashift + lqn.nacts): if self.residt[aidx] > 0: combined_vec[aidx] = self.residt[aidx] elif self.servtproc[aidx] is not None: proc = self.servtproc[aidx] if hasattr(proc, 'getMean'): combined_vec[aidx] = proc.getMean() elif hasattr(proc, 'mean'): combined_vec[aidx] = proc.mean # call residence times filled at index nidx+cidx; callresidt (=WN=RN*visits) already accounts for call_mean via the Aux class routing. for cidx in range(lqn.ncalls): combined_vec[lqn.nidx + cidx] = self.callresidt[cidx] # Compute entry service times: entry_servt = servtmatrix @ combined_vec entry_servt_vec = self.servtmatrix @ combined_vec entry_servt_vec[:lqn.eshift] = 0 # FWD calls carry no blocking: callservt/callresidt stay zero since forwarding is handled by caller-side pseudo rendezvous. # Recompute entry_servt with forwarding-adjusted callresidt # (MATLAB updateMetricsDefault.m lines 349-351) for cidx in range(lqn.ncalls): combined_vec[lqn.nidx + cidx] = self.callresidt[cidx] entry_servt_vec = self.servtmatrix @ combined_vec entry_servt_vec[:lqn.eshift] = 0 # AND-fork join correction; see _kb/06-solver-catalog.md LN Activity think time / AND-join concurrency section. joint_excess = self._update_join_delays() for eidx in range(lqn.eshift, lqn.eshift + lqn.nentries): corrected = entry_servt_vec[eidx] for aidx, exc in joint_excess.items(): if exc != 0 and eidx < self.servtmatrix.shape[0] \ and aidx < self.servtmatrix.shape[1] \ and self.servtmatrix[eidx, aidx] > 0: corrected += exc entry_servt_vec[eidx] = max(corrected, 0.0) # A SetupTask's cold start is charged HERE, to the entry, and with the # probability that the thread was actually found powered down. It is not # host demand, so it does not belong to any activity's residence: # reporting it there put RespT(A2) at 1.29479 on lqn_setup against the # 0.333178 LDES measures, which is the bare demand. See _setup_charge. for eidx in range(lqn.eshift, lqn.eshift + lqn.nentries): entry_servt_vec[eidx] += self._setup_charge(self._get_parent(eidx)) # entry results scaled by throughput ratio so entries reach Ventry=1 while the task keeps Vtask=1; mirrors MATLAB lines 200-226. for e in range(lqn.nentries): eidx = lqn.eshift + e tidx = self._get_parent(eidx) # task of entry hidx = self._get_parent(tidx) if tidx is not None else None # host of entry if tidx is None or hidx is None: continue if self.ignore[tidx] or self.ignore[hidx]: continue entry_servt = entry_servt_vec[eidx] if entry_servt <= 0: continue # Check if this entry has sync callers (which create closed classes) # Use callpair and calltype to detect sync calls targeting this entry has_sync_callers = False if hasattr(lqn, 'callpair') and lqn.callpair is not None and hasattr(lqn, 'calltype'): for cidx in range(lqn.ncalls): if cidx < len(lqn.callpair): # callpair columns: [unused, src_aidx, tgt_eidx, mean_calls] tgt_eidx = int(lqn.callpair[cidx, 1]) if lqn.callpair[cidx, 1] > 0 else 0 if tgt_eidx == eidx: # Check if this is a SYNC call calltype = lqn.calltype[cidx] if cidx < len(lqn.calltype) else 0 # Handle both integer and Enum values for calltype comparison is_sync = (calltype == CallType.SYNC or calltype == CallType.SYNC.value or (isinstance(calltype, (int, np.integer)) and int(calltype) == CallType.SYNC.value)) if is_sync: has_sync_callers = True break if has_sync_callers: # Get throughput ratio from host layer results # This scales the entry service time by task_tput / entry_tput if not np.isnan(self.idxhash[hidx]): layer_idx = int(self.idxhash[hidx]) if 0 <= layer_idx < len(self.ensemble) and self.ensemble[layer_idx] is not None: layer = self.ensemble[layer_idx] result = self.results[-1][layer_idx] if len(self.results) > 0 and layer_idx < len(self.results[-1]) else None if result is not None and 'TN' in result: TN = result['TN'] client_idx = layer.attribute.get('clientIdx', 1) client_idx_0 = (client_idx - 1) if client_idx >= 1 else 0 # Find ALL task class indices (MATLAB: find(...==tidx) returns vector) tasks_matrix = layer.attribute.get('tasks', []) tidxclasses = [] if isinstance(tasks_matrix, np.ndarray) and len(tasks_matrix) > 0: for row in range(tasks_matrix.shape[0]): if tasks_matrix[row, 1] == tidx: tidxclasses.append(int(tasks_matrix[row, 0]) - 1) elif isinstance(tasks_matrix, list): for row in tasks_matrix: if len(row) > 1 and row[1] == tidx: tidxclasses.append(row[0] - 1) # Find ALL entry class indices entries_matrix = layer.attribute.get('entries', []) eidxclasses = [] if isinstance(entries_matrix, np.ndarray) and len(entries_matrix) > 0: for row in range(entries_matrix.shape[0]): if entries_matrix[row, 1] == eidx: eidxclasses.append(int(entries_matrix[row, 0]) - 1) elif isinstance(entries_matrix, list): for row in entries_matrix: if len(row) > 1 and row[1] == eidx: eidxclasses.append(row[0] - 1) # Compute throughput ratio (sum over all matching classes) task_tput = 0.0 entry_tput = 0.0 for tc in tidxclasses: if client_idx_0 < TN.shape[0] and tc < TN.shape[1]: task_tput += TN[client_idx_0, tc] for ec in eidxclasses: if client_idx_0 < TN.shape[0] and ec < TN.shape[1]: entry_tput += TN[client_idx_0, ec] # scale entry service time by task_tput/entry_tput (a task may call several entries per cycle); mirrors MATLAB updateMetricsDefault.m:217. if entry_tput > GlobalConstants.Zero: self.servt[eidx] = entry_servt * task_tput / entry_tput self.residt[eidx] = entry_servt * task_tput / entry_tput else: self.servt[eidx] = entry_servt self.residt[eidx] = entry_servt else: # No results yet, use unscaled entry_servt self.servt[eidx] = entry_servt self.residt[eidx] = entry_servt else: self.servt[eidx] = entry_servt self.residt[eidx] = entry_servt else: self.servt[eidx] = entry_servt self.residt[eidx] = entry_servt else: # For async-only targets, use entry_servt directly # No throughput ratio scaling needed since there are no closed classes self.servt[eidx] = entry_servt self.residt[eidx] = entry_servt # Phase-2 support: split activity service times by phase and apply correction # Matches MATLAB updateMetricsDefault.m lines 105-330 if self.hasPhase2: # Reset phase-specific arrays self.servt_ph1 = np.zeros(lqn.nidx) self.servt_ph2 = np.zeros(lqn.nidx) # Split activity service times by phase for a in range(lqn.nacts): aidx = lqn.ashift + a if lqn.actphase[a - 1] == 1: # actphase is 0-indexed numpy array self.servt_ph1[aidx] = self.servt[aidx] else: self.servt_ph2[aidx] = self.servt[aidx] # Aggregate phase service times to entry level for e in range(lqn.nentries): eidx = lqn.eshift + e acts = lqn.actsof.get(eidx, []) for aidx in acts: a = aidx - lqn.ashift if 1 <= a <= lqn.nacts: if lqn.actphase[a - 1] == 1: self.servt_ph1[eidx] += self.servt_ph1[aidx] else: self.servt_ph2[eidx] += self.servt_ph2[aidx] # overtaking probability response-time correction; see _kb/06-solver-catalog.md LN phase-2 overtaking section. for e in range(lqn.nentries): eidx = lqn.eshift + e if self.servt_ph2[eidx] > 1e-8: # GlobalConstants.FineTol tidx = self._get_parent(eidx) # REF tasks and entries without sync callers see the full service time if (tidx is not None and self._is_ref_task(tidx)) \ or not self._has_sync_callers_for_entry(eidx): self.residt[eidx] = self.servt[eidx] continue # Get entry throughput if self.tput[eidx] > 1e-8: entry_tput = self.tput[eidx] elif tidx is not None and self.tput[tidx] > 1e-8: entry_tput = self.tput[tidx] else: entry_tput = 0 # Compute overtaking probability if entry_tput > 1e-8: self.prOvertake[e] = self._overtake_prob(eidx) else: self.prOvertake[e] = 0 # Caller's response time = phase-1 + P(overtake) * phase-2 overtake_delay = self.prOvertake[e] * self.servt_ph2[eidx] self.residt[eidx] = self.servt_ph1[eidx] + overtake_delay # servtproc for entries updated before callservtproc (which reads it); mirrors MATLAB updateMetricsDefault.m:265-271. if self.call_classes_updmap is not None and len(self.call_classes_updmap) > 0: for row in self.call_classes_updmap: cidx = int(row[1]) nodeidx = int(row[2]) # Get serverIdx for this layer idx = int(row[0]) layer_idx = int(self.idxhash[idx]) if not np.isnan(self.idxhash[idx]) else -1 if layer_idx < 0 or layer_idx >= len(self.ensemble): continue layer = self.ensemble[layer_idx] if layer is None: continue # any non-client node is a server station; under flat layering the callee # station is not the layer's serverIdx, so test nodeidx > 1 as MATLAB does. if nodeidx > 1: eidx = self._get_call_target_entry(cidx) if eidx is not None and eidx > 0 and eidx < len(self.servt): if self.servt[eidx] > 0: self.servtproc[eidx] = Exp.fit_mean(self.servt[eidx]) # callservtproc only for SERVER calls (nodeidx>1); CLIENT calls stay Immediate, resp via think-time; mirrors MATLAB updateMetricsDefault.m:274-287. if self.call_classes_updmap is not None and len(self.call_classes_updmap) > 0: for row in self.call_classes_updmap: idx = int(row[0]) cidx = int(row[1]) nodeidx = int(row[2]) classidx = int(row[3]) # Get serverIdx for this layer to check if call is at server layer_idx = int(self.idxhash[idx]) if not np.isnan(self.idxhash[idx]) else -1 if layer_idx < 0 or layer_idx >= len(self.ensemble): continue layer = self.ensemble[layer_idx] if layer is None: continue # only SERVER-node calls update callservtproc, never CLIENT-node calls; mirrors MATLAB line 277. if nodeidx > 1: eidx = self._get_call_target_entry(cidx) if eidx is not None and eidx > 0: if it == 1: # first iteration uses servtproc[eidx] (Immediate for entries, non-zero only via bound activities); mirrors MATLAB line 281. if eidx < len(self.servt): self.callservt[cidx] = self.servt[eidx] # Use servtproc for callservtproc (matches MATLAB: callservtproc{cidx} = servtproc{eidx}) if eidx < len(self.servtproc) and self.servtproc[eidx] is not None: self.callservtproc[cidx] = self.servtproc[eidx] else: # Subsequent iterations: use callservt from layer results if self.callservt[cidx] > 0: self.callservtproc[cidx] = Exp.fit_mean(self.callservt[cidx]) # Compute ptaskcallers - probability that request to task/host comes from caller self._compute_ptaskcallers() def _overtake_prob(self, eidx): """Compute overtaking probability using 3-state CTMC. Matches MATLAB overtake_prob.m. States: 0=idle, 1=phase-1, 2=phase-2 By PASTA, P(overtake) = steady-state prob of being in phase-2. """ lqn = self.lqn S1 = self.servt_ph1[eidx] S2 = self.servt_ph2[eidx] # Get throughput tidx = self._get_parent(eidx) if self.tput[eidx] > 1e-8: lam = self.tput[eidx] elif tidx is not None and self.tput[tidx] > 1e-8: lam = self.tput[tidx] else: return 0.0 # Number of servers (multiplicity of parent task) c = 1 if tidx is not None and hasattr(lqn, 'mult') and lqn.mult is not None: if isinstance(lqn.mult, (dict,)): c = int(lqn.mult.get(tidx, 1)) elif isinstance(lqn.mult, np.ndarray) and tidx < len(lqn.mult): c = int(lqn.mult[tidx]) # Degenerate cases if S2 < 1e-8 or lam < 1e-8 or S1 < 1e-8: return 0.0 mu1 = 1.0 / S1 mu2 = 1.0 / S2 if c == 1: # single-server exact CTMC steady-state via the augmented balance system [Q';1 1 1]*pi=[0;0;0;1]. A = np.array([ [-lam, 0, mu2, 1], [lam, -mu1, 0, 1], [0, mu1, -mu2, 1] ]).T # 4x3 b = np.array([0, 0, 0, 1]) # Least squares solve pi, _, _, _ = np.linalg.lstsq(A, b, rcond=None) return max(0.0, min(1.0, pi[2])) else: # Multi-server approximation rho = lam * (S1 + S2) / c if rho >= 1: return S2 / (S1 + S2) else: return max(0.0, min(1.0, (S2 / (S1 + S2)) * rho)) def _compute_ptaskcallers(self): """ Compute caller probability matrices for interlocking correction. This implements the MATLAB updateMetricsDefault ptaskcallers computation: 1. Compute direct caller probabilities from throughputs 2. Compute indirect caller probabilities via DTMC random walk """ lqn = self.lqn # Reset ptaskcallers self.ptaskcallers = np.zeros((lqn.nhosts + lqn.ntasks, lqn.nhosts + lqn.ntasks)) # Compute direct caller probabilities for tasks for t in range(lqn.ntasks): tidx = lqn.tshift + t if self._is_ref_task(tidx): continue # Get callers of this task (via iscaller matrix) callers = self._get_callers_of_task(tidx) if not callers: continue # Get throughput of each caller from task layer results caller_tput = np.zeros(lqn.ntasks) if np.isnan(self.idxhash[tidx]): continue layer_idx = int(self.idxhash[tidx]) if layer_idx < 0 or layer_idx >= len(self.ensemble): continue layer = self.ensemble[layer_idx] if layer is None or len(self.results) == 0: continue result = self.results[-1][layer_idx] if layer_idx < len(self.results[-1]) else None if result is None or 'TN' not in result: continue TN = result['TN'] client_idx = layer.attribute.get('clientIdx', 1) if client_idx is None: continue client_idx_0 = client_idx - 1 if client_idx >= 1 else 0 tasks_matrix = layer.attribute.get('tasks', []) for caller_idx in callers: # Find class index for this caller in the layer caller_class_idx = None if isinstance(tasks_matrix, np.ndarray) and len(tasks_matrix) > 0: for row in range(tasks_matrix.shape[0]): if tasks_matrix[row, 1] == caller_idx: caller_class_idx = int(tasks_matrix[row, 0]) break elif isinstance(tasks_matrix, list): for row in tasks_matrix: if len(row) > 1 and row[1] == caller_idx: caller_class_idx = row[0] break if caller_class_idx is not None: caller_class_idx_0 = caller_class_idx - 1 if caller_class_idx >= 1 else 0 if client_idx_0 < TN.shape[0] and caller_class_idx_0 < TN.shape[1]: caller_tput[caller_idx - lqn.tshift] = TN[client_idx_0, caller_class_idx_0] # Normalize to get probabilities total_tput = np.sum(caller_tput) if total_tput > GlobalConstants.Zero: self.ptaskcallers[tidx, lqn.tshift:lqn.tshift + lqn.ntasks] = caller_tput / total_tput # Compute direct caller probabilities for hosts for hidx in range(lqn.nhosts): if np.isnan(self.idxhash[hidx]): continue layer_idx = int(self.idxhash[hidx]) if layer_idx < 0 or layer_idx >= len(self.ensemble): continue layer = self.ensemble[layer_idx] if layer is None or len(self.results) == 0: continue result = self.results[-1][layer_idx] if layer_idx < len(self.results[-1]) else None if result is None or 'TN' not in result: continue TN = result['TN'] client_idx = layer.attribute.get('clientIdx', 1) if client_idx is None: continue client_idx_0 = client_idx - 1 if client_idx >= 1 else 0 callers = self._get_tasks_of_host(hidx) tasks_matrix = layer.attribute.get('tasks', []) caller_tput = np.zeros(lqn.ntasks) for caller_idx in callers: # Find class index for this caller in the layer caller_class_idx = None if isinstance(tasks_matrix, np.ndarray) and len(tasks_matrix) > 0: for row in range(tasks_matrix.shape[0]): if tasks_matrix[row, 1] == caller_idx: caller_class_idx = int(tasks_matrix[row, 0]) break elif isinstance(tasks_matrix, list): for row in tasks_matrix: if len(row) > 1 and row[1] == caller_idx: caller_class_idx = row[0] break if caller_class_idx is not None: caller_class_idx_0 = caller_class_idx - 1 if caller_class_idx >= 1 else 0 if client_idx_0 < TN.shape[0] and caller_class_idx_0 < TN.shape[1]: caller_tput[caller_idx - lqn.tshift] += TN[client_idx_0, caller_class_idx_0] # Normalize to get probabilities total_tput = np.sum(caller_tput) if total_tput > GlobalConstants.Zero: self.ptaskcallers[hidx, lqn.tshift:lqn.tshift + lqn.ntasks] = caller_tput / total_tput # Compute ptaskcallers_step using DTMC random walk P = self.ptaskcallers.copy() # Make stochastic: rows that sum to 0 should self-loop row_sums = P.sum(axis=1) for i in range(P.shape[0]): if row_sums[i] < GlobalConstants.FineTol: P[i, i] = 1.0 # Self-loop at absorbing states self.ptaskcallers_step[0] = P.copy() # Walk backward through caller graph for hidx in range(lqn.nhosts): if np.isnan(self.idxhash[hidx]): continue callers = self._get_tasks_of_host(hidx) for tidx in callers: # Initialize probability mass at host x0 = np.zeros(len(self.ptaskcallers)) x0[hidx] = 1.0 x = x0 @ P # First step for step in range(1, self.nlayers + 1): x = x @ P if step < len(self.ptaskcallers_step): self.ptaskcallers_step[step][tidx, :] = x # Weight by caller probability for host self.ptaskcallers_step[step][hidx, :] = self.ptaskcallers[hidx, tidx] * x # Check if all probability reached REF tasks ref_prob = 0.0 for t in range(lqn.ntasks): t_idx = lqn.tshift + t if self._is_ref_task(t_idx): ref_prob += x[t_idx] if ref_prob > 1.0 - self.options.tol: break # Update max callers self.ptaskcallers[:, tidx] = np.maximum(self.ptaskcallers[:, tidx], x) def _get_call_mean(self, cidx: int) -> float: """Get mean number of calls.""" lqn = self.lqn # First try callproc (distribution) if hasattr(lqn, 'callproc') and lqn.callproc is not None: if isinstance(lqn.callproc, dict): proc = lqn.callproc.get(cidx) elif isinstance(lqn.callproc, (list, np.ndarray)): if cidx < len(lqn.callproc): proc = lqn.callproc[cidx] # 1-indexed else: proc = None else: proc = None if proc is not None: if hasattr(proc, 'getMean'): return proc.getMean() elif hasattr(proc, 'mean'): return proc.mean # Fallback to callpair column 3 (call mean) if hasattr(lqn, 'callpair') and lqn.callpair is not None: if isinstance(lqn.callpair, np.ndarray): if cidx < lqn.callpair.shape[0]: return float(lqn.callpair[cidx, 2]) return 1.0 def _get_call_response_time(self, caller_tidx: int) -> float: """ Get the total call response time for a task. This is the sum of (call_mean * callee_response_time) for all calls made by activities of this task. """ lqn = self.lqn total_call_time = 0.0 # Find all calls from this task's activities if not hasattr(lqn, 'callpair') or lqn.callpair is None: return 0.0 for cidx in range(lqn.ncalls): if cidx >= lqn.callpair.shape[0]: continue # Get source activity (column 1) and target entry (column 2) src_aidx = int(lqn.callpair[cidx, 0]) tgt_eidx = int(lqn.callpair[cidx, 1]) call_mean = float(lqn.callpair[cidx, 2]) if lqn.callpair.shape[1] > 2 else 1.0 if src_aidx == 0 or tgt_eidx == 0: continue # Get parent task of source activity src_tidx = self._get_parent(src_aidx) if src_tidx != caller_tidx: continue # Get parent task of target entry tgt_tidx = self._get_parent(tgt_eidx) if tgt_tidx is None: continue # call response time = callee task layer response time, from callservtproc when available. if cidx < len(self.callservtproc) and self.callservtproc[cidx] is not None: proc = self.callservtproc[cidx] if hasattr(proc, 'getMean'): total_call_time += call_mean * proc.getMean() elif hasattr(proc, 'mean'): total_call_time += call_mean * proc.mean continue # Fall back to task layer results if callservtproc not available if not np.isnan(self.idxhash[tgt_tidx]): tgt_layer_idx = int(self.idxhash[tgt_tidx]) if len(self.results) > 0 and tgt_layer_idx < len(self.results[-1]): result = self.results[-1][tgt_layer_idx] if result is not None and 'RN' in result: RN = result['RN'] server_idx = self.ensemble[tgt_layer_idx].attribute.get('serverIdx', 1) if server_idx is not None: server_idx_0 = server_idx - 1 if server_idx >= 1 else 0 if server_idx_0 < RN.shape[0]: # Find the caller's activity class (not task class) in this layer caller_class_idx = self._find_activity_class_in_layer(src_aidx, tgt_layer_idx) if caller_class_idx is None: # Fallback to task class caller_class_idx = self._find_caller_class_in_layer(caller_tidx, tgt_layer_idx) if caller_class_idx is not None: caller_class_idx_0 = caller_class_idx - 1 if caller_class_idx >= 1 else 0 if caller_class_idx_0 < RN.shape[1]: callee_resp = RN[server_idx_0, caller_class_idx_0] total_call_time += call_mean * callee_resp continue # Fallback: average across all classes callee_resp = np.mean(RN[server_idx_0, :]) total_call_time += call_mean * callee_resp continue # Fallback: use entry's service time if task layer not available if tgt_eidx < len(self.servt) and self.servt[tgt_eidx] > 0: total_call_time += call_mean * self.servt[tgt_eidx] return total_call_time def _get_throughput_from_callers(self, tidx: int) -> float: """ Compute task throughput from callers' rates. For a purely called task T, throughput = sum of (caller_tput * call_mean) for all calls that target entries of T. """ lqn = self.lqn total_tput = 0.0 # Get entries of this task entries = self._get_entries_of_task(tidx) if not entries: return 0.0 # For each call, check if it targets one of our entries if not hasattr(lqn, 'callpair') or lqn.callpair is None: return 0.0 for cidx in range(lqn.ncalls): if cidx >= lqn.callpair.shape[0]: continue tgt_eidx = int(lqn.callpair[cidx, 1]) # Target entry if tgt_eidx not in entries: continue # This call targets our task - get caller's throughput src_aidx = int(lqn.callpair[cidx, 0]) # Source activity if src_aidx <= 0: continue # Get task of source activity caller_tidx = self._get_parent(src_aidx) if caller_tidx is None or caller_tidx <= 0: continue # call rate = caller ACTIVITY throughput * call_mean (the call fires each activity execution, not each task cycle). caller_tput = self.tput[src_aidx] if src_aidx < len(self.tput) else 0.0 call_mean = self._get_call_mean(cidx) total_tput += caller_tput * call_mean return total_tput def _setup_dist_mean(self, procs, tidx: int) -> float: """Mean of a setup or delay-off process of task TIDX, 0 when it declares none.""" if procs is None: return 0.0 p = None if isinstance(procs, dict): p = procs.get(tidx) else: arr = np.asarray(procs).flatten() if tidx < len(arr): p = arr[tidx] if p is None: return 0.0 try: m = float(p.getMean()) except (AttributeError, TypeError, ValueError): return 0.0 return 0.0 if (np.isnan(m) or np.isinf(m)) else m def _setup_charge(self, tidx: int) -> float: """Mean cold start one request of task TIDX pays, 0 when it declares none. A SetupTask powers a thread down when it goes idle and pays a setup before it can serve again. The thread is released at a reply and starts a delay-off countdown D of mean d; it powers off only if D expires before the next request arrives, and a request arriving first cancels the countdown and pays nothing. With the idle interval I seen by one thread and exponential D, p = P(D < I) = E[I] / (E[I] + d), and the charge is p * s. E[I] comes from the current iterate. Admission takes an ACTIVE idle thread before it wakes a sleeping one, so the pool that actually cycles is only as large as the load needs: with offered load b = X*S = rho*mult threads, about max(1,b) stay hot, each seeing arrivals at rate X/max(1,b) and busy S per arrival, so E[I] = (max(1,b) - b) / X. At mult = 1 this is (1-rho)/X and is EXACT given p, returning p = a/(a+d) for the one-customer model the LDES engine is checked against. Above one thread it is an approximation, the exact answer for c servers with setup being matrix-analytic (Gandhi, Harchol-Balter and Adan, Performance Evaluation 67(11), 2010). Twin of MATLAB lqn_setup_charge.m and the JAR SolverLN.setupCharge. """ lqn = self.lqn hs = getattr(lqn, 'hassetup', None) if hs is None: return 0.0 hsf = np.asarray(hs).flatten() if tidx < 0 or tidx >= len(hsf) or not hsf[tidx]: return 0.0 s = self._setup_dist_mean(getattr(lqn, 'setuptime', None), tidx) d = self._setup_dist_mean(getattr(lqn, 'delayofftime', None), tidx) if not (s > GlobalConstants.FineTol) or not (d > GlobalConstants.FineTol): return 0.0 mult = float(lqn.mult[0, tidx]) if not np.isfinite(mult) or mult <= 0: return 0.0 # an infinite-server task holds no thread to power down if self.tput is None or self.util is None or tidx >= len(self.tput) or tidx >= len(self.util): return s # nothing has arrived yet, so the thread is down when the first does X = float(self.tput[tidx]) if not np.isfinite(X) or X <= GlobalConstants.FineTol: return s rho = float(self.util[tidx]) if not np.isfinite(rho) or rho < 0: rho = 0.0 rho = min(rho, 1 - GlobalConstants.FineTol) b = rho * mult # offered load, in threads EI = (max(1.0, b) - b) / X # idle interval of a thread in the hot pool return s * EI / (EI + d)
[docs] def update_think_times(self, it: int): """Update think times (matches MATLAB updateThinkTimes).""" # Under 'srvn.ph' a caller reaches the server once per invocation, so the # station rate is not the task's invocation rate -- see the PH twin if self._is_ph_encoding(): self._update_think_times_ph(it) return lqn = self.lqn if not hasattr(lqn, 'iscaller') or lqn.iscaller is None: return for t in range(lqn.ntasks): tidx = lqn.tshift + t # Only a REFERENCE task's think time separates one request from the # next; on a served task it is not a per-request delay and charging # it throttles the task -- see _ref_think_mean tidx_thinktime = self._ref_think_mean(tidx) # Get call response time (time spent waiting for calls to complete) call_response_time = self._get_call_response_time(tidx) if not np.isnan(self.idxhash[tidx]): # Get throughput and utilization from layer results layer_idx = int(self.idxhash[tidx]) njobs = max(self.njobs[tidx, :]) if len(self.results) > 0 and layer_idx < len(self.results[-1]): result = self.results[-1][layer_idx] if result is not None and 'TN' in result: TN = result['TN'] UN = result.get('UN', np.zeros_like(TN)) # the station of TIDX inside its layer: under flat layering # every server shares one layer, so the scalar serverIdx # would point at the first processor instead server_idx = self._station_idx_of(self.ensemble[layer_idx], tidx) if server_idx is not None: # Convert to 0-based indexing for numpy server_idx_0 = server_idx - 1 if server_idx >= 1 else 0 # task throughput read from its own layer's SERVER node, repl-scaled; mirrors MATLAB updateThinkTimes line 24 (no fork_fanout correction there). repl = lqn.repl[0, tidx] if hasattr(lqn, 'repl') and lqn.repl is not None and lqn.repl.shape[1] > tidx else 1.0 tput_sum = np.nansum(TN[server_idx_0, :]) self.tput[tidx] = repl * tput_sum if np.isfinite(tput_sum) else 0.0 util_sum = np.nansum(UN[server_idx_0, :]) self.util[tidx] = util_sum if np.isfinite(util_sum) else 0.0 # Compute think time - MATLAB formula only uses user think time # Call response time is handled separately in update_layers sched = self._get_sched(tidx) if sched == SchedStrategy.INF: # Infinite server case if self.tput[tidx] > GlobalConstants.Zero: self.thinkt[tidx] = max(GlobalConstants.Zero, (njobs - self.util[tidx]) / self.tput[tidx] - tidx_thinktime) else: # Regular queue case if self.tput[tidx] > GlobalConstants.Zero: self.thinkt[tidx] = max(GlobalConstants.Zero, njobs * abs(1 - self.util[tidx]) / self.tput[tidx] - tidx_thinktime) # A caller class cycles as delay plus station service, and the station # serves only the host demand: a cold start is charged to the entry, # not to any activity's demand, so the station never sees it and the # delay has to carry it. Without this the callee layer cycled at # 0.529412 against the 0.5 its callers drive on lqn_setup. Zero for # every task without a setup. self.thinkt[tidx] = max(GlobalConstants.Zero, self.thinkt[tidx] + self._setup_charge(tidx)) # Recover from Inf/NaN: snap back to previous iteration's value if it > 1 and not np.isnan(self.thinkt_prev[tidx]): if np.isinf(self.thinkt[tidx]) or np.isnan(self.thinkt[tidx]): self.thinkt[tidx] = self.thinkt_prev[tidx] # Apply under-relaxation omega = self.relax_omega if omega < 1.0 and it > 1 and not np.isnan(self.thinkt_prev[tidx]): rawT = self.thinkt[tidx] prevT = self.thinkt_prev[tidx] # If recovering from crash (prev much larger than raw), snap to raw if prevT > 10 * rawT and rawT > GlobalConstants.FineTol: self.thinkt_prev[tidx] = rawT # reset prev to allow recovery self.thinkt[tidx] = omega * self.thinkt[tidx] + (1 - omega) * self.thinkt_prev[tidx] self.thinkt_prev[tidx] = self.thinkt[tidx] # Update think time process - MATLAB style: thinkt + user_think only # Call response time will be added in update_layers if self.thinkt[tidx] + tidx_thinktime > 0: self.thinktproc[tidx] = Exp.fit_mean(self.thinkt[tidx] + tidx_thinktime) else: self.thinktproc[tidx] = Immediate() # for non-REF called tasks, task-layer throughput takes precedence over host-layer (INF-scheduled hosts overstate it). if self.tput[tidx] == 0 or np.isnan(self.tput[tidx]): hidx = self._get_parent(tidx) # host = parent of task if hidx is not None and not np.isnan(self.idxhash[hidx]): host_layer_idx = int(self.idxhash[hidx]) if host_layer_idx >= 0 and len(self.results) > 0 and host_layer_idx < len(self.results[-1]): result = self.results[-1][host_layer_idx] if result is not None and 'TN' in result: TN = result['TN'] # For called tasks, find the server node and task class if self.servt_classes_updmap is not None: for r in range(len(self.servt_classes_updmap)): if int(self.servt_classes_updmap[r, 0]) == hidx: # Check if this mapping is for our task's activity aidx = int(self.servt_classes_updmap[r, 1]) if self._get_parent(aidx) == tidx: nodeidx = int(self.servt_classes_updmap[r, 2]) classidx = int(self.servt_classes_updmap[r, 3]) nodeidx_0 = nodeidx - 1 if nodeidx >= 1 else 0 classidx_0 = classidx - 1 if classidx >= 1 else 0 if nodeidx_0 < TN.shape[0] and classidx_0 < TN.shape[1]: # Only set if not already set by task layer if self.tput[tidx] == 0 or np.isnan(self.tput[tidx]): tn_val = TN[nodeidx_0, classidx_0] if np.isfinite(tn_val): self.tput[tidx] = tn_val break else: # Ref task, forwarding target or open-arrival target (no task layer). # An entry arrival that is the only way in drives the thread pool # directly: the layer builder dropped its open class precisely so the # cycle can be closed on the known rate here. See _open_arrival_rate_of. arvrate = self._open_arrival_rate_of(tidx) if arvrate > GlobalConstants.FineTol: njobs_arv = max(self.njobs[tidx, :]) if not njobs_arv > 0: njobs_arv = lqn.maxmult[tidx] self.tput[tidx] = arvrate host_residt = 0.0 for eidx_arv in self._get_entries_of_task(tidx): if hasattr(lqn, 'actsof') and eidx_arv in lqn.actsof: for aidx_arv in lqn.actsof[eidx_arv]: if np.isfinite(self.residt[aidx_arv]): host_residt += self.residt[aidx_arv] z_arv = max(GlobalConstants.Zero, njobs_arv / arvrate - host_residt - tidx_thinktime) omega = self.relax_omega if omega < 1.0 and it > 1 and not np.isnan(self.thinkt_prev[tidx]): z_arv = omega * z_arv + (1 - omega) * self.thinkt_prev[tidx] self.thinkt[tidx] = z_arv self.thinkt_prev[tidx] = z_arv self.thinktproc[tidx] = Exp.fit_mean(z_arv + tidx_thinktime) continue # Check if this is a forwarding target task (MATLAB updateThinkTimes.m:54-104) is_fwd_target = False fwd_cidx_found = None source_tidx = None fwd_prob = 0.0 if not self._is_ref_task(tidx) and hasattr(lqn, 'calltype') and lqn.calltype is not None: for eidx_fwd in self._get_entries_of_task(tidx): for cidx_fwd in range(lqn.ncalls): if cidx_fwd < len(lqn.calltype) and int(lqn.calltype[cidx_fwd]) == CallType.FWD \ and int(lqn.callpair[cidx_fwd, 1]) == eidx_fwd: is_fwd_target = True fwd_cidx_found = cidx_fwd source_eidx = int(lqn.callpair[cidx_fwd, 0]) source_tidx = self._get_parent(source_eidx) fwd_prob = self._get_call_mean(cidx_fwd) break if is_fwd_target: break if is_fwd_target and source_tidx is not None: # forwarding-target think time: thinkt = njobs/arrival_rate - host_residt, derived from source throughput and forwarding probability. njobs_fwd = max(self.njobs[tidx, :]) arrival_rate = self.tput[source_tidx] * fwd_prob if arrival_rate > GlobalConstants.FineTol and njobs_fwd > 0: self.tput[tidx] = arrival_rate # Subtract the processor response time for the target's # activities (already computed by _update_metrics_default) target_eidx = int(lqn.callpair[fwd_cidx_found, 1]) host_residt = 0.0 if hasattr(lqn, 'actsof') and target_eidx in lqn.actsof: for aidx_fwd in lqn.actsof[target_eidx]: host_residt += self.residt[aidx_fwd] self.thinkt[tidx] = max(GlobalConstants.Zero, njobs_fwd / arrival_rate - host_residt - tidx_thinktime) else: # Source throughput not yet available; use large think time self.thinkt[tidx] = 1000 # Apply under-relaxation omega = self.relax_omega if omega < 1.0 and it > 1 and not np.isnan(self.thinkt_prev[tidx]): self.thinkt[tidx] = omega * self.thinkt[tidx] + (1 - omega) * self.thinkt_prev[tidx] self.thinkt_prev[tidx] = self.thinkt[tidx] self.thinktproc[tidx] = Exp.fit_mean(self.thinkt[tidx] + tidx_thinktime) continue # Ref task - think time is just user-specified think time self.thinkt[tidx] = GlobalConstants.FineTol self.thinktproc[tidx] = Exp.fit_mean(tidx_thinktime) if tidx_thinktime > 0 else Immediate() # Get REF task throughput from its HOST layer # REF tasks are clients in their host layer, so get TN from there hidx = self._get_parent(tidx) # host = parent of task if hidx is not None and not np.isnan(self.idxhash[hidx]): host_layer_idx = int(self.idxhash[hidx]) if host_layer_idx >= 0 and len(self.results) > 0 and host_layer_idx < len(self.results[-1]): result = self.results[-1][host_layer_idx] if result is not None and 'TN' in result: TN = result['TN'] UN = result.get('UN', np.zeros_like(TN)) # Find the task class in the host layer using layer.attribute['tasks'] # REF tasks are NOT in thinkt_classes_updmap, but ARE in layer.attribute['tasks'] layer = self.ensemble[host_layer_idx] if layer is not None and hasattr(layer, 'attribute'): tasks_attr = layer.attribute.get('tasks', []) client_idx = layer.attribute.get('clientIdx', 1) nodeidx_0 = client_idx - 1 if client_idx >= 1 else 0 # Client node for TN extraction for task_entry in tasks_attr: class_idx_1based = task_entry[0] # 1-indexed class index task_tidx = task_entry[1] # Task's absolute index if task_tidx == tidx: classidx_0 = class_idx_1based - 1 # Convert to 0-indexed if nodeidx_0 < TN.shape[0] and classidx_0 < TN.shape[1]: self.tput[tidx] = TN[nodeidx_0, classidx_0] self.util[tidx] = UN[nodeidx_0, classidx_0] break
def _init_interlock(self): """Build the interlock path table and locate the common parents. Interlocking arises when requests issued by one client reach a common lower-level server along two or more independent paths, so that arrivals a layer decomposition treats as independent are in fact correlated. Franks (1999), Ch. 4: Phase A: the path table path(a,b) of Sec. 4.2, the calls to entry b caused by one invocation of entry a, with a unit diagonal; a second table restricts the count to the phase-1 flow Phase B: the common-parent finder of Fig. 4.2, retaining only the entries at which the flow genuinely splits Phase C: the source tasks and the source count n_s of Eq. (4.7) The phase-aware tables, the branch-point test and the source count are refinements beyond the published algorithm, which assumes one path table and counts source tasks directly. The interlock table is built once at solver initialization and reused across iterations. Only the interlock flow computation (in update_populations) uses iteration-dependent throughput values. """ lqn = self.lqn # Phase A: Build interlock reachability table nentries = lqn.nentries il_all = np.zeros((nentries, nentries)) il_ph1 = np.zeros((nentries, nentries)) for e in range(nentries): eidx = lqn.eshift + e visited = np.zeros(nentries, dtype=bool) self._trace_interlock_paths(eidx, e, 1.0, 1.0, visited, il_all, il_ph1, 0) self.il_table_all = il_all self.il_table_ph1 = il_ph1 # Phase B+C: Find common entries and sources per server entity max_idx = lqn.tshift + lqn.ntasks self.il_common_entries = [None] * max_idx self.il_source_tasks_all = [None] * max_idx self.il_source_tasks_ph2 = [None] * max_idx self.il_num_sources = np.zeros(max_idx) # Process task servers for t in range(lqn.ntasks): tidx = lqn.tshift + t if self._is_ref_task(tidx) or self._get_sched(tidx) == SchedStrategy.INF: continue ce, sa, sp, ns = self._find_interlock_for_server(tidx, il_all, il_ph1) self.il_common_entries[tidx] = ce self.il_source_tasks_all[tidx] = sa self.il_source_tasks_ph2[tidx] = sp self.il_num_sources[tidx] = ns # Process host servers for h in range(lqn.nhosts): hidx = h if self._get_sched(hidx) == SchedStrategy.INF: continue ce, sa, sp, ns = self._find_interlock_for_server(hidx, il_all, il_ph1) self.il_common_entries[hidx] = ce self.il_source_tasks_all[hidx] = sa self.il_source_tasks_ph2[hidx] = sp self.il_num_sources[hidx] = ns def _trace_interlock_paths(self, eidx: int, root_e: int, prob_all: float, prob_ph1: float, visited: np.ndarray, il_all: np.ndarray, il_ph1: np.ndarray, depth: int): """Phase A: Recursive path tracing for interlock reachability.""" lqn = self.lqn e = eidx - lqn.eshift if e < 1 or e > lqn.nentries: return if visited[e]: return visited[e] = True # Record reachability from root to this entry il_all[root_e, e] += prob_all il_ph1[root_e, e] += prob_ph1 # Follow synchronous calls from activities of this entry acts = lqn.actsof.get(eidx, []) if isinstance(lqn.actsof, dict) else [] for aidx in acts: if aidx < lqn.ashift or aidx >= lqn.ashift + lqn.nacts: continue a = aidx - lqn.ashift # Pruning: at non-root entries (depth > 0), skip phase-2+ activities has_actphase = hasattr(lqn, 'actphase') and lqn.actphase is not None if depth > 0 and has_actphase and a - 1 < len(lqn.actphase) and lqn.actphase[a - 1] > 1: continue is_ph1 = True if has_actphase and a - 1 < len(lqn.actphase) and lqn.actphase[a - 1] > 1: is_ph1 = False # Follow calls from this activity calls_from_act = lqn.callsof.get(aidx, []) if isinstance(lqn.callsof, dict) else [] for cidx in calls_from_act: if cidx < 0 or cidx >= lqn.ncalls: continue # Check SYNC call if isinstance(lqn.calltype, np.ndarray): ct = int(lqn.calltype.flatten()[cidx]) if cidx < len(lqn.calltype.flatten()) else 0 elif isinstance(lqn.calltype, dict): ct = lqn.calltype.get(cidx, 0) else: ct = 0 if ct != CallType.SYNC: continue call_mean = self._get_call_mean(cidx) if call_mean <= 0: continue dst_eidx = int(lqn.callpair[cidx, 1]) dst_e = dst_eidx - lqn.eshift if dst_e < 1 or dst_e > lqn.nentries: continue next_all = prob_all * call_mean next_ph1 = prob_ph1 * call_mean if is_ph1 else 0.0 self._trace_interlock_paths(dst_eidx, root_e, next_all, next_ph1, visited, il_all, il_ph1, depth + 1) visited[e] = False def _get_server_entry_nums(self, server_idx: int) -> List[int]: """Get entry numbers (1-based, relative to eshift) for a server.""" lqn = self.lqn nums = [] if server_idx <= lqn.nhosts: # Host server: entries of all tasks on this host tasks = lqn.tasksof.get(server_idx, []) if isinstance(lqn.tasksof, dict) else [] for tidx in tasks: entries = lqn.entriesof.get(tidx, []) if isinstance(lqn.entriesof, dict) else [] for se in entries: nums.append(se - lqn.eshift) else: # Task server: entries of this task entries = lqn.entriesof.get(server_idx, []) if isinstance(lqn.entriesof, dict) else [] for se in entries: nums.append(se - lqn.eshift) return nums def _get_client_tasks(self, server_idx: int) -> List[int]: """Get client task indices for a server.""" lqn = self.lqn if server_idx <= lqn.nhosts: return lqn.tasksof.get(server_idx, []) if isinstance(lqn.tasksof, dict) else [] else: server_entries = lqn.entriesof.get(server_idx, []) if isinstance(lqn.entriesof, dict) else [] client_tasks = [] for se in server_entries: if hasattr(lqn, 'iscaller') and lqn.iscaller is not None and isinstance(lqn.iscaller, np.ndarray): if se < lqn.iscaller.shape[1]: calling_idx = np.where(lqn.iscaller[:, se] > 0)[0] for ci in calling_idx: if lqn.tshift <= ci < lqn.tshift + lqn.ntasks: if ci not in client_tasks: client_tasks.append(ci) return client_tasks def _has_phase2_activities(self, eidx: int) -> bool: """Check if entry has phase-2 activities.""" lqn = self.lqn if not hasattr(lqn, 'actphase') or lqn.actphase is None: return False acts = lqn.actsof.get(eidx, []) if isinstance(lqn.actsof, dict) else [] for aidx in acts: a = aidx - lqn.ashift if 1 <= a <= lqn.nacts and a - 1 < len(lqn.actphase) and lqn.actphase[a - 1] > 1: return True return False def _is_branch_point_check(self, src_x_eidx: int, entry_a_eidx: int, src_y_eidx: int, entry_b_eidx: int, il_all: np.ndarray) -> bool: """Check if (srcX, srcY) form a branch point for (entryA, entryB).""" lqn = self.lqn task_a = self._get_parent(entry_a_eidx) task_b = self._get_parent(entry_b_eidx) task_x = self._get_parent(src_x_eidx) # Multiserver client: if X, A, B same task => not branch point if task_x == task_a and task_x == task_b: return False # Quick check: direct call if src_x_eidx == entry_a_eidx or src_y_eidx == entry_b_eidx: return True entry_a_num = entry_a_eidx - lqn.eshift entry_b_num = entry_b_eidx - lqn.eshift # Check downstream calls diverge to different tasks dst_tasks_x = self._get_call_dst_tasks(src_x_eidx, entry_a_num, il_all) dst_tasks_y = self._get_call_dst_tasks(src_y_eidx, entry_b_num, il_all) for dx in dst_tasks_x: for dy in dst_tasks_y: if dx != dy: return True return False def _get_call_dst_tasks(self, src_eidx: int, target_e_num: int, il_all: np.ndarray) -> List[int]: """Get destination tasks of sync calls from an entry reaching a target.""" lqn = self.lqn dst_tasks = [] acts = lqn.actsof.get(src_eidx, []) if isinstance(lqn.actsof, dict) else [] for aidx in acts: if aidx < lqn.ashift or aidx >= lqn.ashift + lqn.nacts: continue calls = lqn.callsof.get(aidx, []) if isinstance(lqn.callsof, dict) else [] for cidx in calls: if cidx < 0 or cidx >= lqn.ncalls: continue if isinstance(lqn.calltype, np.ndarray): ct = int(lqn.calltype.flatten()[cidx]) if cidx < len(lqn.calltype.flatten()) else 0 elif isinstance(lqn.calltype, dict): ct = lqn.calltype.get(cidx, 0) else: ct = 0 if ct != CallType.SYNC: continue dst_eidx = int(lqn.callpair[cidx, 1]) dst_e = dst_eidx - lqn.eshift if 1 <= dst_e <= lqn.nentries and il_all[dst_e, target_e_num] > 0: parent = self._get_parent(dst_eidx) if parent is not None and parent not in dst_tasks: dst_tasks.append(parent) return dst_tasks def _find_interlocked_tasks(self, src_eidx: int, server_idx: int, il_all: np.ndarray) -> List[int]: """Get interlocked tasks on paths from an entry to a server.""" lqn = self.lqn visited = np.zeros(lqn.nentries, dtype=bool) return self._trace_to_server_rec(src_eidx, server_idx, il_all, visited, [], True) def _trace_to_server_rec(self, eidx: int, server_idx: int, il_all: np.ndarray, visited: np.ndarray, itasks: List[int], is_head: bool) -> List[int]: """Recursively trace paths from entry to server, collecting interlocked tasks.""" lqn = self.lqn e = eidx - lqn.eshift if e < 1 or e > lqn.nentries or visited[e]: return itasks owner_task = self._get_parent(eidx) if owner_task is None: return itasks # Check if we reached the server if owner_task == server_idx: return itasks if server_idx <= lqn.nhosts: parent_of_owner = self._get_parent(owner_task) if parent_of_owner == server_idx: return itasks visited[e] = True # Follow synchronous calls from ALL phases acts = lqn.actsof.get(eidx, []) if isinstance(lqn.actsof, dict) else [] found = False for aidx in acts: if aidx < lqn.ashift or aidx >= lqn.ashift + lqn.nacts: continue calls = lqn.callsof.get(aidx, []) if isinstance(lqn.callsof, dict) else [] for cidx in calls: if cidx < 0 or cidx >= lqn.ncalls: continue if isinstance(lqn.calltype, np.ndarray): ct = int(lqn.calltype.flatten()[cidx]) if cidx < len(lqn.calltype.flatten()) else 0 elif isinstance(lqn.calltype, dict): ct = lqn.calltype.get(cidx, 0) else: ct = 0 if ct != CallType.SYNC: continue dst_eidx = int(lqn.callpair[cidx, 1]) dst_task = self._get_parent(dst_eidx) # Check if destination reaches server reaches_server = False if dst_task == server_idx: reaches_server = True elif server_idx <= lqn.nhosts and self._get_parent(dst_task) == server_idx: reaches_server = True else: dst_e = dst_eidx - lqn.eshift server_entry_nums = self._get_server_entry_nums(server_idx) for se_num in server_entry_nums: if 1 <= dst_e <= lqn.nentries and 1 <= se_num <= lqn.nentries and il_all[dst_e, se_num] > 0: reaches_server = True break if reaches_server: itasks = self._trace_to_server_rec(dst_eidx, server_idx, il_all, visited, itasks, False) found = True if found and not is_head: if owner_task not in itasks: itasks.append(owner_task) visited[e] = False return itasks def _find_interlock_for_server(self, server_idx: int, il_all: np.ndarray, il_ph1: np.ndarray): """Phase B+C: Find interlock for a single server. Returns (commonEntries, srcAll, srcPh2, numSources). """ lqn = self.lqn empty = ([], [], [], 0) # Get server entry numbers server_entry_nums = self._get_server_entry_nums(server_idx) if not server_entry_nums: return empty # Get client tasks client_tasks = self._get_client_tasks(server_idx) if len(client_tasks) < 1: return empty # Get client entries that reach the server client_entry_pairs = [] # list of (taskIdx, entryNum) for ct in client_tasks: entries = lqn.entriesof.get(ct, []) if isinstance(lqn.entriesof, dict) else [] for ce in entries: ce_num = ce - lqn.eshift if ce_num < 1 or ce_num > lqn.nentries: continue for se_num in server_entry_nums: if 1 <= se_num <= lqn.nentries and il_all[ce_num, se_num] > 0: client_entry_pairs.append((ct, ce_num)) break if len(client_entry_pairs) < 2: return empty # Find common parent entries (branch points) common_entries_set = [] n_pairs = len(client_entry_pairs) for i in range(n_pairs): for j in range(i + 1, n_pairs): if client_entry_pairs[i][0] == client_entry_pairs[j][0]: continue # Same task entry_a_num = client_entry_pairs[i][1] entry_c_num = client_entry_pairs[j][1] # Search all tasks for common parents for t in range(lqn.ntasks): tidx = lqn.tshift + t entries_of_task = lqn.entriesof.get(tidx, []) if isinstance(lqn.entriesof, dict) else [] for ex in entries_of_task: for ey in entries_of_task: ex_num = ex - lqn.eshift ey_num = ey - lqn.eshift if ex_num < 1 or ey_num < 1 or ex_num > lqn.nentries or ey_num > lqn.nentries: continue if il_all[ex_num, entry_a_num] > 0 and il_all[ey_num, entry_c_num] > 0: if self._is_branch_point_check( ex, lqn.eshift + entry_a_num, ey, lqn.eshift + entry_c_num, il_all): if ex not in common_entries_set: common_entries_set.append(ex) # Unique common_entries_set = sorted(set(common_entries_set)) if not common_entries_set: return empty # Phase C: Find source tasks interlocked_tasks = [] for ce_eidx in common_entries_set: it = self._find_interlocked_tasks(ce_eidx, server_idx, il_all) for t in it: if t not in interlocked_tasks: interlocked_tasks.append(t) # All source tasks = tasks owning common entries all_src_tasks = [] for ce_eidx in common_entries_set: owner_tidx = self._get_parent(ce_eidx) if owner_tidx is not None and owner_tidx not in all_src_tasks: all_src_tasks.append(owner_tidx) # Remove interlocked tasks from allSrcTasks all_src_tasks = [t for t in all_src_tasks if t not in interlocked_tasks] # Ph2 sources: interlocked tasks with phase-2 activities reaching server ph2_src_tasks = [] for it in interlocked_tasks: entries_it = lqn.entriesof.get(it, []) if isinstance(lqn.entriesof, dict) else [] for ie in entries_it: if self._has_phase2_activities(ie): ie_num = ie - lqn.eshift if 1 <= ie_num <= lqn.nentries: for se_num in server_entry_nums: if 1 <= se_num <= lqn.nentries: if il_all[ie_num, se_num] - il_ph1[ie_num, se_num] > 0: if it not in ph2_src_tasks: ph2_src_tasks.append(it) break # Add external sources (tasks calling into interlocked paths from outside) for it in interlocked_tasks: entries_it = lqn.entriesof.get(it, []) if isinstance(lqn.entriesof, dict) else [] for ie in entries_it: if hasattr(lqn, 'iscaller') and lqn.iscaller is not None and isinstance(lqn.iscaller, np.ndarray): if ie < lqn.iscaller.shape[1]: calling_idx = np.where(lqn.iscaller[:, ie] > 0)[0] for ci in calling_idx: if lqn.tshift <= ci < lqn.tshift + lqn.ntasks: if ci not in interlocked_tasks and ci not in all_src_tasks: all_src_tasks.append(ci) # Count total source multiplicity nsrc = 0 for st in all_src_tasks: nsrc += self._get_mult(st) return common_entries_set, all_src_tasks, ph2_src_tasks, nsrc def _get_hostdem_mean(self, aidx: int) -> float: """Get mean host demand for an activity index.""" lqn = self.lqn if isinstance(lqn.hostdem, dict): val = lqn.hostdem.get(aidx, 0.0) elif isinstance(lqn.hostdem, (list, np.ndarray)): if aidx < len(lqn.hostdem): val = lqn.hostdem[aidx] else: val = 0.0 else: val = 0.0 if val is None: return 0.0 if isinstance(val, (int, float, np.integer, np.floating)): return float(val) if hasattr(val, 'getMean'): return val.getMean() if hasattr(val, 'mean'): return val.mean if hasattr(val, 'get_mean'): return val.get_mean() return 0.0 def _get_calltype(self, cidx: int) -> int: """Get call type for a call index.""" lqn = self.lqn if isinstance(lqn.calltype, np.ndarray): flat = lqn.calltype.flatten() return int(flat[cidx]) if cidx < len(flat) else 0 elif isinstance(lqn.calltype, dict): return lqn.calltype.get(cidx, 0) return 0 def _get_entry_tput(self, eidx: int, task_idx: int) -> float: """Get entry throughput (helper for interlock computation).""" lqn = self.lqn tput_val = self.tput[eidx] if eidx < len(self.tput) else 0.0 if tput_val <= GlobalConstants.FineTol: # Try first activity acts = lqn.actsof.get(eidx, []) if isinstance(lqn.actsof, dict) else [] if acts: first_act = acts[0] if first_act < len(self.tput): tput_val = self.tput[first_act] if tput_val <= GlobalConstants.FineTol: if task_idx < len(self.tput): tput_val = self.tput[task_idx] return float(tput_val) def _get_task_tput(self, tidx: int) -> float: """Get task throughput (helper for interlock computation).""" lqn = self.lqn tput_val = self.tput[tidx] if tidx < len(self.tput) else 0.0 if tput_val <= GlobalConstants.FineTol: entries = lqn.entriesof.get(tidx, []) if isinstance(lqn.entriesof, dict) else [] for eidx in entries: et = self.tput[eidx] if eidx < len(self.tput) else 0.0 if et <= GlobalConstants.FineTol: acts = lqn.actsof.get(eidx, []) if isinstance(lqn.actsof, dict) else [] if acts: first_act = acts[0] if first_act < len(self.tput): et = self.tput[first_act] tput_val += et return float(tput_val) def _compute_interlock_prob(self, client_tidx: int, server_idx: int, is_processor_host: bool = False): """Interlock probability for one (client, server) pair, as (IR, Pr(IL)). Li and Franks, "An improved interlocking correction for decomposition of layered queueing networks", CCECE 2015, Eqs. (3) and (4). ``is_processor_host`` selects the m' rule of lqns ``Interlock::ilrate_pril_flow``: at a PROCESSOR the common-source population is doubled above 3 customers and squared at or below it, which is what turns m = 4 into the pril = 1/8 its trace reports. The two factors are multiplied into the Eq. (5) rate by ``_build_layer_interlock``, so neither carries the source count on its own -- that lives in m'. This replaces the superseded (n_s-1)/n_s discount of Franks (1999), Eq. (4.7). """ lqn = self.lqn if server_idx >= len(self.il_common_entries) or self.il_common_entries[server_idx] is None: return 0.0, 0.0 common_entries = self.il_common_entries[server_idx] num_sources = self.il_num_sources[server_idx] all_src_tasks = self.il_source_tasks_all[server_idx] ph2_src_tasks = self.il_source_tasks_ph2[server_idx] if num_sources == 0 or not common_entries: return 0.0, 0.0 # Get client entries client_entries = lqn.entriesof.get(client_tidx, []) if isinstance(lqn.entriesof, dict) else [] # Interlocked flow lambda^IL of Eq. (4), and alongside it the flow weighted by 1/m', # which gives Pr(IL) of Eq. (3). sum_flow = 0.0 sum_pril = 0.0 for ce_eidx in common_entries: src_task = self._get_parent(ce_eidx) ce_num = ce_eidx - lqn.eshift # population of this common source, in customer copies m_src = self._get_mult(src_task) if not np.isfinite(m_src) or m_src < 1: m_src = 1.0 if is_processor_host: m_eff = (m_src + m_src) if m_src > 3 else (m_src * m_src) else: m_eff = m_src for dst_a_eidx in client_entries: dst_a_num = dst_a_eidx - lqn.eshift if dst_a_num < 1 or dst_a_num > lqn.nentries: continue if self.il_table_all[ce_num, dst_a_num] <= 0: continue # Get source entry throughput ce_tput = self._get_entry_tput(ce_eidx, src_task) if ce_tput <= GlobalConstants.FineTol: continue # Deferred flow is scored separately from the phase-1 flow has_p2 = self._has_phase2_activities(ce_eidx) if not has_p2 and src_task in all_src_tasks: contrib = ce_tput * self.il_table_all[ce_num, dst_a_num] sum_flow += contrib sum_pril += contrib / m_eff elif has_p2 and src_task in all_src_tasks: contrib = ce_tput * self.il_table_ph1[ce_num, dst_a_num] sum_flow += contrib sum_pril += contrib / m_eff ph2 = self.il_table_all[ce_num, dst_a_num] - self.il_table_ph1[ce_num, dst_a_num] if ph2 > 0 and src_task in ph2_src_tasks: contrib = ce_tput * ph2 sum_flow += contrib sum_pril += contrib / m_eff # Get client throughput client_tput = self._get_task_tput(client_tidx) if client_tput <= GlobalConstants.FineTol: return 0.0, 0.0 ir = min(sum_flow, client_tput) / client_tput ir = min(1.0, max(0.0, ir)) pr_il = 0.0 if sum_flow <= GlobalConstants.FineTol else sum_pril / sum_flow pr_il = min(1.0, max(0.0, pr_il)) return float(ir), float(pr_il)
[docs] def update_populations(self, it: int): """Apply the interlock correction to call residence times. The path tables built by _init_interlock are combined with the current iterate to obtain, for each (client, server) pair, the interlocked flow of Eq. (4.3) of Franks (1999), and from it the interlock probability, the share of that flow that the layer decomposition would otherwise count twice. Eq. (4.7) removes one source in n_s from the queue length inside MVA; the equivalent correction is applied here to the residence times returned by the layer:: R_adj = S + (1 - prIL) * W, W = R - S, which leaves service and utilization untouched and removes only the interlocked share of the waiting time. Called after update_metrics, which produces the raw callresidt from the layer solutions, and before update_think_times. """ lqn = self.lqn if self.il_table_all is None: return # Save originals for proportional entry_servt update callresidt_orig = self.callresidt.copy() if self.callresidt is not None else np.zeros(1) residt_orig = self.residt.copy() if self.residt is not None else np.zeros(1) adjusted = False # Pass 1: For each sync call, check if destination server has interlock for cidx in range(lqn.ncalls): if self._get_calltype(cidx) != CallType.SYNC: continue dst_eidx = int(lqn.callpair[cidx, 1]) server_tidx = self._get_parent(dst_eidx) if server_tidx is None: continue # Find the server entity with interlock data server_for_il = None if (server_tidx < len(self.il_common_entries) and self.il_common_entries[server_tidx] is not None and len(self.il_common_entries[server_tidx]) > 0): server_for_il = server_tidx else: # Check host server if server_tidx >= lqn.tshift: host_idx = self._get_parent(server_tidx) if (host_idx is not None and 0 <= host_idx < len(self.il_common_entries) and self.il_common_entries[host_idx] is not None and len(self.il_common_entries[host_idx]) > 0): server_for_il = host_idx if server_for_il is None: continue # Get client task (activity -> task via parent) src_aidx = int(lqn.callpair[cidx, 0]) client_tidx = self._get_parent(src_aidx) if client_tidx is None: continue # Interlock probability for this client and server. This path serves a TASK, not a # processor, so the m' rule of Li/lqns leaves the source population alone; the # product IR*Pr(IL) reproduces the scalar this branch used before. ir_c, pr_il_c = self._compute_interlock_prob(client_tidx, server_for_il, False) pr_il = ir_c * pr_il_c if pr_il <= GlobalConstants.FineTol: continue # Compute waiting time reduction S = self.servt[dst_eidx] if dst_eidx < len(self.servt) else 0.0 call_mean = self._get_call_mean(cidx) if call_mean <= 0 or self.callservt[cidx] <= 0: continue RN = self.callservt[cidx] / call_mean # response time per visit W = max(0.0, RN - S) # waiting time per visit if W > GlobalConstants.FineTol: RN_adj = S + (1 - pr_il) * W scale = RN_adj / RN self.callservt[cidx] = self.callservt[cidx] * scale self.callresidt[cidx] = self.callresidt[cidx] * scale if self.callservt[cidx] > 0: self.callservtproc[cidx] = Exp.fit_mean(self.callservt[cidx]) adjusted = True # Pass 2: Host-level interlock — reduce processor queueing in residt. # Every layer starts the pass without a matrix, so a host that stops being # interlocked does not keep the previous iteration's correction alive. for e in range(len(self.ensemble)): slv = self.solvers[e] if e < len(self.solvers) else None cfg = getattr(getattr(slv, 'options', None), 'config', None) if isinstance(cfg, dict): cfg['interlock'] = None elif cfg is not None and hasattr(cfg, 'interlock'): cfg.interlock = None for h in range(lqn.nhosts): hidx = h if self.il_common_entries[hidx] is None or len(self.il_common_entries[hidx]) == 0: continue # Compute prIL and processor utilization for each task on this host host_tasks = lqn.tasksof.get(hidx, []) if isinstance(lqn.tasksof, dict) else [] task_pr_il = np.zeros(len(host_tasks)) # IR, Eq. (4) task_PrIL = np.zeros(len(host_tasks)) # Pr(IL), Eq. (3) task_util = np.zeros(len(host_tasks)) for ti, tidx in enumerate(host_tasks): # The host of a task layer is a PROCESSOR, which is what selects the m' rule. task_pr_il[ti], task_PrIL[ti] = self._compute_interlock_prob(tidx, hidx, True) # Compute task's processor utilization entries = lqn.entriesof.get(tidx, []) if isinstance(lqn.entriesof, dict) else [] for eidx in entries: acts = lqn.actsof.get(eidx, []) if isinstance(lqn.actsof, dict) else [] for aidx in acts: if aidx < len(self.tput): task_util[ti] += self.tput[aidx] * self._get_hostdem_mean(aidx) U_total = np.sum(task_util) U_interlocked = np.sum(task_util[task_pr_il > GlobalConstants.FineTol]) if U_total <= GlobalConstants.FineTol or U_interlocked <= GlobalConstants.FineTol: continue il_fraction = U_interlocked / U_total # When the layer solver carries Eq. (4.7) inside its own MVA, the interlock goes # to the layer as a class-level matrix and the residence times are left untouched. # Scaling them here as well would remove the same waiting twice, and would still # leave the layer's own THROUGHPUT uncorrected, which is what breaks flow balance # across a call: the reported task rate then comes from a cycle time the correction # has already shortened elsewhere. layer_of_host = self._layer_index_of(hidx) if layer_of_host is not None and self._layer_takes_interlock(layer_of_host): il_mat = self._build_layer_interlock(layer_of_host, host_tasks, task_pr_il, task_PrIL) opts = self.solvers[layer_of_host].options cfg = getattr(opts, 'config', None) if cfg is None: opts.config = {'interlock': il_mat} elif isinstance(cfg, dict): cfg['interlock'] = il_mat else: cfg.interlock = il_mat continue for ti, tidx in enumerate(host_tasks): if task_pr_il[ti] <= GlobalConstants.FineTol: continue # Weight by the share of host utilization that is interlocked. The rate is the # SAME Eq. (5) product IR*Pr(IL) that pass 1 applies to a call and that # _build_layer_interlock puts in the layer matrix -- IR alone is a flow SHARE, # ~1 whenever a layer has a single common source, and using it here removed the # whole processor queueing rather than the interlocked part of it, which broke # flow balance across a call. effective_pr_il = task_pr_il[ti] * task_PrIL[ti] * il_fraction entries = lqn.entriesof.get(tidx, []) if isinstance(lqn.entriesof, dict) else [] for eidx in entries: acts = lqn.actsof.get(eidx, []) if isinstance(lqn.actsof, dict) else [] for aidx in acts: D = self._get_hostdem_mean(aidx) if D > 0 and aidx < len(self.residt) and self.residt[aidx] > D + GlobalConstants.FineTol: W_proc = self.residt[aidx] - D self.residt[aidx] = D + (1 - effective_pr_il) * W_proc adjusted = True if not adjusted: return # Recompute entry service times from adjusted callresidt/residt # Use proportional scaling to preserve visit ratio adjustments residt_vec = self.residt.flatten() if self.residt is not None else np.zeros(1) callresidt_vec = self.callresidt.flatten() if self.callresidt is not None else np.zeros(1) residt_orig_vec = residt_orig.flatten() callresidt_orig_vec = callresidt_orig.flatten() # The servtmatrix column space is [element 0..nidx-1, call nidx..nidx+ncalls-1] # and callresidt is ncalls long with no leading pad, so the two concatenate # directly. Dropping a leading entry here would shift every call one column # to the left, crediting each entry with its NEXT call's residence. concat_old = np.concatenate([residt_orig_vec, callresidt_orig_vec]) concat_new = np.concatenate([residt_vec, callresidt_vec]) if self.servtmatrix is not None: # Ensure dimensions match n_sm_cols = self.servtmatrix.shape[1] if len(concat_old) < n_sm_cols: concat_old = np.pad(concat_old, (0, n_sm_cols - len(concat_old))) concat_new = np.pad(concat_new, (0, n_sm_cols - len(concat_new))) elif len(concat_old) > n_sm_cols: concat_old = concat_old[:n_sm_cols] concat_new = concat_new[:n_sm_cols] entry_servt_old = self.servtmatrix @ concat_old entry_servt_new = self.servtmatrix @ concat_new # The entry servt is rescaled only when it was itself assembled from # these residence times, which is the default path. After the moment3 # pass it is the MEAN OF AN APH CONVOLUTION of the activities' own # response laws, and a ratio of residence-time sums is not a # correction to it: applying it multiplies the entry law by the # entry's visit ratio and reports a service time BELOW that of the # single activity the entry contains. The residence times keep their # correction either way. See BUGS.md BUG-97. moment_laws = self.lnmethod == 'moment3' and self.moment_pass_done for eidx in range(lqn.eshift, lqn.eshift + lqn.nentries): if eidx < len(entry_servt_old) and entry_servt_old[eidx] > GlobalConstants.FineTol: ratio = entry_servt_new[eidx] / entry_servt_old[eidx] if not moment_laws: if eidx < len(self.servt): self.servt[eidx] = self.servt[eidx] * ratio if eidx < len(self.servt) and self.servt[eidx] > 0: self.servtproc[eidx] = Exp.fit_mean(self.servt[eidx]) if eidx < len(self.residt): self.residt[eidx] = self.residt[eidx] * ratio
[docs] def update_layers(self, it: int): """Update layer parameters (matches MATLAB updateLayers).""" # Under 'srvn.ph' the layer classes are one per caller task and their laws # are composed, not read off the update maps -- see _kb/06-solver-catalog.md if self._is_ph_encoding(): self._update_layers_ph(it) return lqn = self.lqn # Update REF task think times in host layers # REF tasks' think times = base_think + call_response_time for hidx in range(lqn.nhosts): if np.isnan(self.idxhash[hidx]): continue layer_idx = int(self.idxhash[hidx]) if layer_idx < 0 or layer_idx >= len(self.ensemble): continue layer = self.ensemble[layer_idx] if layer is None: continue # Find REF tasks on this host tasks_on_host = self._get_tasks_of_host(hidx) classes = layer.get_classes() nodes = layer.get_nodes() # Find the Clients delay node (first node, typically index 0) clients_node = None for node in nodes: if isinstance(node, Delay): clients_node = node break if clients_node is None: continue for class_idx, tidx in enumerate(tasks_on_host): if self._is_ref_task(tidx): # REF task TASK-class think time = base_think only; call response times are carried by separate CALL classes. base_think = 0.0 if self.thinkproc[tidx] is not None: proc = self.thinkproc[tidx] if hasattr(proc, 'getMean'): base_think = proc.getMean() elif hasattr(proc, 'mean'): base_think = proc.mean # TASK class think time stays as base_think (no call response added) if base_think > 0 and class_idx < len(classes): cls = classes[class_idx] clients_node.set_service(cls, Exp.fit_mean(base_think)) # Update think times in layers (matches MATLAB updateLayers.m lines 14-79) if self.thinkt_classes_updmap is not None: for r in range(len(self.thinkt_classes_updmap)): # Elevator iteration order if it % 2 == 1: ri = len(self.thinkt_classes_updmap) - r - 1 else: ri = r idx = int(self.thinkt_classes_updmap[ri, 0]) aidx = int(self.thinkt_classes_updmap[ri, 1]) nodeidx = int(self.thinkt_classes_updmap[ri, 2]) classidx = int(self.thinkt_classes_updmap[ri, 3]) layer_idx = int(self.idxhash[idx]) if layer_idx >= 0 and layer_idx < len(self.ensemble): layer = self.ensemble[layer_idx] if layer is not None: classes = layer.get_classes() nodes = layer.get_nodes() if classidx <= len(classes) and nodeidx <= len(nodes): cls = classes[classidx - 1] node = nodes[nodeidx - 1] # Get clientIdx and serverIdx from layer attribute client_idx = layer.attribute.get('clientIdx', 1) if hasattr(layer, 'attribute') else 1 server_idx = layer.attribute.get('serverIdx', 2) if hasattr(layer, 'attribute') else 2 lqn_type = self._get_type(aidx) if nodeidx == client_idx: # Client node handling (MATLAB lines 37-75) if lqn_type == LayeredNetworkElement.TASK: is_ref = self._is_ref_task(aidx) if not is_ref: # Non-REF TASK: use computed thinktproc (MATLAB line 42) if self.thinktproc[aidx] is not None: node.set_service(cls, self.thinktproc[aidx]) else: # REF TASK: use servtproc (host demand) — matches MATLAB line 43 and JAR line 2665 if self.servtproc[aidx] is not None: node.set_service(cls, self.servtproc[aidx]) else: # Non-TASK types (ACTIVITY, ENTRY): use servtproc (MATLAB line 66) if self.servtproc[aidx] is not None: node.set_service(cls, self.servtproc[aidx]) else: # Server replica (any of them) (MATLAB line 77) if self.servtproc[aidx] is not None: node.set_service(cls, self.servtproc[aidx]) # Propagate service updates to all replicas when nreplicas > 1 nrep = layer.attribute.get('nreplicas', 1) if hasattr(layer, 'attribute') else 1 if nrep > 1: all_ss = layer.attribute.get('server_stations', []) for replica_ss in all_ss[1:]: # Skip primary dist = node._service_process.get(cls) if dist is not None: replica_ss.set_service(cls, dist) # servt_classes_updmap is read-only here (extracts RN in update_metrics); host-layer processor service time is the fixed host demand. # Reassign call service times / response times (like MATLAB lines 106-142) if self.call_classes_updmap is not None and len(self.call_classes_updmap) > 0: for c in range(len(self.call_classes_updmap)): # Elevator iteration order if it % 2 == 1: ci = len(self.call_classes_updmap) - c - 1 else: ci = c idx = int(self.call_classes_updmap[ci, 0]) cidx = int(self.call_classes_updmap[ci, 1]) nodeidx = int(self.call_classes_updmap[ci, 2]) classidx = int(self.call_classes_updmap[ci, 3]) # Get the layer if np.isnan(self.idxhash[idx]): continue layer_idx_actual = int(self.idxhash[idx]) if layer_idx_actual < 0 or layer_idx_actual >= len(self.ensemble): continue layer = self.ensemble[layer_idx_actual] if layer is None: continue classes = layer.get_classes() nodes = layer.get_nodes() if classidx > len(classes) or nodeidx > len(nodes): continue cls = classes[classidx - 1] node = nodes[nodeidx - 1] # Get clientIdx and serverIdx from layer attribute client_idx = layer.attribute.get('clientIdx', 1) if hasattr(layer, 'attribute') else 1 server_idx = layer.attribute.get('serverIdx', 2) if hasattr(layer, 'attribute') else 2 if nodeidx == client_idx: # CALL at client: use callservtproc (call response time) if cidx < len(self.callservtproc) and self.callservtproc[cidx] is not None: proc = self.callservtproc[cidx] node.set_service(cls, proc if hasattr(proc, 'getMean') else Exp.fit_mean(float(proc))) else: # CALL at server replica (any of them): use servtproc[eidx] (entry service time) eidx_raw = self.lqn.callpair[cidx, 1] if cidx < len(self.lqn.callpair) else None eidx = int(eidx_raw) if eidx_raw is not None and not np.isnan(eidx_raw) else None if eidx is not None and eidx < len(self.servtproc) and self.servtproc[eidx] is not None: proc = self.servtproc[eidx] dist = proc if hasattr(proc, 'getMean') else Exp.fit_mean(float(proc)) # A phase-2 entry replies before phase 2 runs, so the caller is # held for residt, not servt. Charging it servt here while its # own layer charges residt makes the two layers settle at # different rates and breaks flow conservation across the call. # Under flat layering both live in one model, where the # correction would be applied twice. if (self.hasPhase2 and not self._is_flat_layering() and self.servt_ph2 is not None and eidx < len(self.servt_ph2) and self.servt_ph2[eidx] > 1e-8 and self.residt is not None and eidx < len(self.residt) and self.residt[eidx] > 0): dist = Exp.fit_mean(float(self.residt[eidx])) node.set_service(cls, dist) # Propagate to replicas nrep = layer.attribute.get('nreplicas', 1) if hasattr(layer, 'attribute') else 1 if nrep > 1: all_ss = layer.attribute.get('server_stations', []) for replica_ss in all_ss[1:]: replica_ss.set_service(cls, dist) # Source arrival rates per iter from lqn.arrival (entry-level) or tputproc (async-call); mirrors updateLayers.m:66-74/JAR SolverLN.java:2702-2724. if self.arvproc_classes_updmap is not None and len(self.arvproc_classes_updmap) > 0: for r in range(len(self.arvproc_classes_updmap)): if it % 2 == 1: ri = len(self.arvproc_classes_updmap) - r - 1 else: ri = r idx = int(self.arvproc_classes_updmap[ri, 0]) eidx_or_cidx = int(self.arvproc_classes_updmap[ri, 1]) nodeidx = int(self.arvproc_classes_updmap[ri, 2]) classidx = int(self.arvproc_classes_updmap[ri, 3]) if np.isnan(self.idxhash[idx]): continue layer_idx_actual = int(self.idxhash[idx]) if layer_idx_actual < 0 or layer_idx_actual >= len(self.ensemble): continue layer = self.ensemble[layer_idx_actual] if layer is None: continue classes = layer.get_classes() nodes = layer.get_nodes() if classidx > len(classes) or nodeidx > len(nodes): continue cls = classes[classidx - 1] node = nodes[nodeidx - 1] if eidx_or_cidx < 0: # entry-level open arrival re-applies the static lqn.arrival rate; server service was fixed at creation from the bound activity's host demand. eidx = -eidx_or_cidx if hasattr(self.lqn, 'arrival') and eidx in self.lqn.arrival \ and self.lqn.arrival[eidx] is not None: try: node.set_arrival(cls, self.lqn.arrival[eidx]) except Exception: pass else: # Async-call open arrival: use tputproc at caller activity. cidx = eidx_or_cidx caller_aidx = int(self.lqn.callpair[cidx, 0]) if cidx < len(self.lqn.callpair) else 0 if caller_aidx > 0 and caller_aidx < len(self.tputproc) \ and self.tputproc[caller_aidx] is not None: try: node.set_arrival(cls, self.tputproc[caller_aidx]) except Exception: pass
def _compute_layer_service_time(self, caller_tidx: int, layer_idx: int, it: int) -> float: """ Compute total service time for a caller in a layer. For host layers: caller's activities' host demands For task layers: called entry's service time (from servt array) """ lqn = self.lqn total_demand = 0.0 # Check if this is a host layer actual_layer_idx = int(self.idxhash[layer_idx]) is_host = actual_layer_idx in self.hostLayerIndices if is_host: # Host layer: server service = caller's activities' host demands activities = self._get_activities_of_task(caller_tidx) for aidx in activities: if self.servtproc[aidx] is not None: proc = self.servtproc[aidx] if isinstance(proc, (int, float, np.integer, np.floating)): total_demand += float(proc) elif hasattr(proc, 'getMean'): total_demand += proc.getMean() elif hasattr(proc, 'mean'): total_demand += proc.mean else: # Task layer: server service = called entry's service time # Use the iteratively updated servt values total_demand = self._get_layer_call_response_time(caller_tidx, layer_idx) return total_demand def _get_layer_call_response_time(self, caller_tidx: int, layer_idx: int) -> float: """ Get call response time for synch calls from caller to entries in layer. Uses the current servt (entry service time) which is updated iteratively. """ lqn = self.lqn total_call_time = 0.0 if not hasattr(lqn, 'callpair') or lqn.callpair is None: return 0.0 # Find all synch calls from this caller's activities activities = self._get_activities_of_task(caller_tidx) for aidx in activities: if isinstance(lqn.callsof, dict): calls = lqn.callsof.get(aidx, []) else: calls = [] for cidx in calls: # Check call type - assume SYNC if calltype not available is_sync = True if hasattr(lqn, 'calltype') and lqn.calltype is not None: if isinstance(lqn.calltype, np.ndarray): calltype = lqn.calltype.flatten()[cidx] if cidx < len(lqn.calltype.flatten()) else CallType.SYNC elif isinstance(lqn.calltype, dict): calltype = lqn.calltype.get(cidx, CallType.SYNC) else: calltype = CallType.SYNC is_sync = (calltype == CallType.SYNC) if is_sync: # Get target entry (column 2 of callpair) tgt_eidx = self._get_call_target_entry(cidx) if tgt_eidx is None or tgt_eidx == 0: continue # Check if this call targets the server in this layer tgt_tidx = self._get_parent(tgt_eidx) if tgt_tidx != layer_idx: continue # Get call mean (number of calls) call_mean = self._get_call_mean(cidx) # Use the entry's current service time (updated each iteration) entry_resp = self.servt[tgt_eidx] if tgt_eidx < len(self.servt) and self.servt[tgt_eidx] > 0 else 0.0 # If entry servt is not yet computed, use host demand estimate if entry_resp <= 0: tgt_activities = self._get_activities_of_entry(tgt_eidx) for tgt_aidx in tgt_activities: if self.servtproc[tgt_aidx] is not None: proc = self.servtproc[tgt_aidx] if isinstance(proc, (int, float, np.integer, np.floating)): entry_resp += float(proc) elif hasattr(proc, 'getMean'): entry_resp += proc.getMean() elif hasattr(proc, 'mean'): entry_resp += proc.mean total_call_time += call_mean * entry_resp return total_call_time def _get_type(self, idx: int) -> int: """Get element type based on index ranges.""" lqn = self.lqn # First try the type array if it exists if hasattr(lqn, 'type') and lqn.type is not None: if isinstance(lqn.type, dict): return lqn.type.get(idx, 0) elif isinstance(lqn.type, np.ndarray): if idx < len(lqn.type): return int(lqn.type[idx]) # type array is 0-indexed over elements # Compute type from index ranges if lqn.hshift <= idx < lqn.hshift + lqn.nhosts: return LayeredNetworkElement.PROCESSOR elif lqn.tshift <= idx < lqn.tshift + lqn.ntasks: return LayeredNetworkElement.TASK elif lqn.eshift <= idx < lqn.eshift + lqn.nentries: return LayeredNetworkElement.ENTRY elif lqn.ashift <= idx < lqn.ashift + lqn.nacts: return LayeredNetworkElement.ACTIVITY return 0
[docs] def update_routing_probabilities(self, it: int): """Update routing probabilities (matches MATLAB updateRoutingProbabilities).""" if self.route_prob_updmap is None or len(self.route_prob_updmap) == 0: return if self.unique_route_prob_updmap is None or len(self.unique_route_prob_updmap) == 0: return for u in range(len(self.unique_route_prob_updmap)): # Alternate direction (elevator) like MATLAB if it % 2 == 0: idx = int(self.unique_route_prob_updmap[u]) else: idx = int(self.unique_route_prob_updmap[len(self.unique_route_prob_updmap) - u - 1]) layer_idx = int(self.idxhash[idx]) if not np.isnan(self.idxhash[idx]) else -1 if layer_idx < 0 or layer_idx >= len(self.ensemble): continue idx_updated = False layer = self.ensemble[layer_idx] if layer is None: continue # Get current routing matrix object P = layer.get_routing_matrix() if P is None: P = layer.init_routing_matrix() classes = layer.get_classes() nodes = layer.get_nodes() # Find rows in route_prob_updmap for this idx for r in range(len(self.route_prob_updmap)): if int(self.route_prob_updmap[r, 0]) != idx: continue host = int(self.route_prob_updmap[r, 0]) tidx_caller = int(self.route_prob_updmap[r, 1]) eidx = int(self.route_prob_updmap[r, 2]) nodefrom = int(self.route_prob_updmap[r, 3]) nodeto = int(self.route_prob_updmap[r, 4]) classidxfrom = int(self.route_prob_updmap[r, 5]) classidxto = int(self.route_prob_updmap[r, 6]) # Get caller's layer results caller_layer_idx = int(self.idxhash[tidx_caller]) if not np.isnan(self.idxhash[tidx_caller]) else -1 if caller_layer_idx < 0 or caller_layer_idx >= len(self.results[-1]): continue result = self.results[-1][caller_layer_idx] if result is None or 'TN' not in result: continue caller_layer = self.ensemble[caller_layer_idx] if caller_layer is None: continue # cache layers (iscachelayer) use hit/miss throughput instead of entry throughput; mirrors MATLAB's items-based cache-layer test. is_cache_layer = layer.attribute.get('iscachelayer', False) if layer.attribute else False if is_cache_layer: # MATLAB: for cache nodes, get results from host layer (not caller layer) host_layer_idx = int(self.idxhash[host]) if not np.isnan(self.idxhash[host]) else -1 if host_layer_idx < 0 or host_layer_idx >= len(self.results[-1]): continue host_result = self.results[-1][host_layer_idx] if host_result is None or 'TN' not in host_result: continue host_layer = self.ensemble[host_layer_idx] if host_layer is None: continue server_idx = self._station_idx_of(host_layer, host) server_idx_0 = server_idx - 1 if server_idx >= 1 else 0 TN = host_result['TN'] if TN is None or server_idx_0 >= TN.shape[0]: continue # Get total throughput at server Xtot = np.sum(TN[server_idx_0, :]) if Xtot <= 0: continue # Get hit/miss throughput using classidxto # MATLAB: hm_tput = sum(TN(serverIdx, classidxto)) cls_to_idx_0 = classidxto - 1 if classidxto >= 1 else 0 if cls_to_idx_0 < TN.shape[1]: hm_tput = TN[server_idx_0, cls_to_idx_0] else: hm_tput = 0.0 new_prob = hm_tput / Xtot if Xtot > 0 else 0.0 else: # Non-cache layer: use entry throughput # Get server index from caller layer server_idx = self._station_idx_of(caller_layer, tidx_caller) server_idx_0 = server_idx - 1 if server_idx >= 1 else 0 TN = result['TN'] if TN is None or server_idx_0 >= TN.shape[0]: continue # Get total throughput at server Xtot = np.sum(TN[server_idx_0, :]) if Xtot <= 0: continue # find ALL entry class indices in the caller layer, not just the first; mirrors MATLAB calls(find(calls(:,4)==eidx),1). matching_eidxclasses = [] calls_attr = caller_layer.attribute.get('calls', []) for call_info in calls_attr: if len(call_info) >= 4 and call_info[3] == eidx: matching_eidxclasses.append(call_info[0]) # class index # MATLAB: entry_tput = sum(TN(serverIdx, eidxclass)) # Sum throughput across ALL matching entry classes if not matching_eidxclasses: entry_tput = 0.0 else: entry_tput = 0.0 for eidxclass in matching_eidxclasses: eidxclass_0 = eidxclass - 1 if eidxclass >= 1 else 0 if eidxclass_0 < TN.shape[1]: entry_tput += TN[server_idx_0, eidxclass_0] new_prob = entry_tput / Xtot if Xtot > 0 else 0.0 # Get class and node indices (convert from 1-based to 0-based) cls_from_idx = classidxfrom - 1 if classidxfrom >= 1 else 0 cls_to_idx = classidxto - 1 if classidxto >= 1 else 0 node_from_idx = nodefrom - 1 if nodefrom >= 1 else 0 node_to_idx = nodeto - 1 if nodeto >= 1 else 0 if (cls_from_idx < len(classes) and cls_to_idx < len(classes) and node_from_idx < len(nodes) and node_to_idx < len(nodes)): cls_from = classes[cls_from_idx] cls_to = classes[cls_to_idx] node_from = nodes[node_from_idx] node_to = nodes[node_to_idx] # update _original_routes (used for rt computation) with entry_tput/Xtot; mirrors MATLAB P{r,s}(from,to)=entry_tput/Xtot. if P._original_routes is not None: if (cls_from, cls_to) not in P._original_routes: P._original_routes[(cls_from, cls_to)] = {} P._original_routes[(cls_from, cls_to)][(node_from, node_to)] = new_prob else: # No ClassSwitch nodes - update routes directly P.set(cls_from, cls_to, node_from, node_to, new_prob) # Also update ClassSwitch node's switching matrix for rtnodes from line_solver.lang.nodes import ClassSwitch cs_name = f'CS_{node_from.name}_to_{node_to.name}' for n in nodes: if isinstance(n, ClassSwitch) and n.name == cs_name: cs_matrix = n.get_class_switching_matrix() if cs_matrix is not None and cls_from_idx < cs_matrix.shape[0] and cls_to_idx < cs_matrix.shape[1]: cs_matrix[cls_from_idx, cls_to_idx] = new_prob n.set_class_switching_matrix(cs_matrix) break idx_updated = True # Clear cached matrix and reset struct to force recomputation if idx_updated: P._matrix = None # Clear cached toMatrix() result layer.reset_struct()
[docs] def converged(self, it: int) -> bool: """Check convergence (matches MATLAB converged).""" # Stochastic iteration dispatch: see _kb/06-solver-catalog.md (LN # section) for the rationale. if self.stochiter_mode is not None: if (self.stochiter_auto and self.stochiter_mode == 'off' and it >= 1 and self.stochlayers is not None and np.any(self.stochlayers)): # a layer with method 'default' resolved at runtime to a # stochastic method (captured in analyze() at iteration 1) self.stochiter_mode = 'rm' line_debug("LN: stochastic layer method detected at runtime, " "switching to Robbins-Monro iteration") if self.stochiter_mode == 'rm': return self.converged_stoch(it) # The moment3 pass is terminal: it runs once hasconverged is set, and its # own output perturbs the error test below. See BUG-97 and the note where # moment_pass_done is set. if self.lnmethod == 'moment3' and self.moment_pass_done: return True if it < 2: return False # MATLAB: iter_min = max([2*length(self.model.ensemble), ceil(self.options.iter_max/4)]) iter_min = max(2 * self.nlayers, (self.options.iter_max + 3) // 4) # ceil equivalent # ITER_MIN DOES NOT APPLY TO THE STOP TEST OF A WARM-STARTED RUN. The # floor is there so a cold iterate cannot read an early plateau -- the # first sweeps of a layered solve move slowly while the service times # are still the bare demands -- as convergence, and an iterate seeded # from the QD-AMVA program starts past that plateau. The test itself is # unchanged, three consecutive errors below iter_tol plus the # layer-reset confirmation; it is now allowed to fire from the start. # ONLY the stop test is unfloored: `iter_min` still sizes the smoothing # window and still says when smoothing begins, so a warm run iterates # the same map as a cold one and lands on the same fixed point. See # _kb/06-solver-catalog.md (LN section). stop_floor = 0 if self.warmstarted else iter_min # Apply moving window average to help convergence (matches MATLAB lines 58-79) # MATLAB: wnd_size = max(5, ceil(iter_min/5)) wnd_size = max(5, (iter_min + 4) // 5) # ceil equivalent mov_avg_weight = 1.0 / wnd_size if it >= iter_min and len(self.results) >= it: # moving-average smoothing reads the PREVIOUS iteration's raw (unsmoothed) row so repeated smoothing does not compound the window. averaged_row = list(self.results[it - 1]) for e in range(self.nlayers): if len(self.results[it - 1]) > e and self.results[it - 1][e] is not None: result = dict(self.results[it - 1][e]) # Apply moving average (matches MATLAB exactly) for key in ['QN', 'UN', 'RN', 'TN', 'AN', 'WN']: if key in result and result[key] is not None: # Start with current result * weight avg = mov_avg_weight * result[key].copy() # Add past wnd_size-1 results (MATLAB: for k=1:(wnd_size-1)) for k in range(1, wnd_size): hist_idx = it - 1 - k if hist_idx >= 0 and hist_idx < len(self.results) and len(self.results[hist_idx]) > e: prev_result = self.results[hist_idx][e] if prev_result is not None and key in prev_result and prev_result[key] is not None: avg = avg + prev_result[key] * mov_avg_weight result[key] = avg averaged_row[e] = result self.results[it - 1] = averaged_row # Compute max error across all layers if it > 1 and len(self.results) >= it: self.maxitererr.append(0.0) for e in range(self.nlayers): if len(self.results[it - 1]) > e and len(self.results[it - 2]) > e: result = self.results[it - 1][e] result_prev = self.results[it - 2][e] if result is not None and result_prev is not None: if 'QN' in result and 'QN' in result_prev: QN = result['QN'] QN_prev = result_prev['QN'] if QN is not None and QN_prev is not None: # Get total jobs in this layer N = np.sum(self.ensemble[e].get_number_of_jobs()) if self.ensemble[e] is not None else 1 if N > 0: try: iter_err = np.max(np.abs(QN.flatten() - QN_prev.flatten())) / N self.maxitererr[-1] += iter_err except: pass if it == iter_min: self.averagingstart = it # Update relaxation factor for adaptive/auto modes (matches MATLAB lines 112-152) relax_mode = self.options.config.get('relax', 'none') if relax_mode in ['adaptive', 'auto']: # Track error history self.relax_err_history.append(self.maxitererr[-1]) wnd = self.options.config.get('relax_history', 5) if len(self.relax_err_history) > wnd: self.relax_err_history = self.relax_err_history[-wnd:] if len(self.relax_err_history) >= 3: # Detect oscillation by counting sign changes in error differences err = np.array(self.relax_err_history) diff_err = np.diff(err) if len(diff_err) >= 2: sign_changes = np.sum(diff_err[:-1] * diff_err[1:] < 0) if relax_mode == 'auto' and self.relax_omega == 1.0: # For 'auto' mode: enable relaxation when oscillation detected # (matches MATLAB converged.m line 130 - no iter_min check) if sign_changes >= len(diff_err) * 0.5: self.relax_omega = self.options.config.get('relax_factor', 0.1) if self.options.verbose: print(f'LN: enabling relaxation, omega={self.relax_omega:.2f}') elif relax_mode == 'adaptive': # For 'adaptive' mode: adjust omega based on error trajectory relax_min = self.options.config.get('relax_min', 0.1) if sign_changes >= len(diff_err) * 0.5: # Oscillating - reduce omega self.relax_omega = max(relax_min, self.relax_omega * 0.8) elif sign_changes == 0 and len(self.maxitererr) >= 2 and self.maxitererr[-1] < self.maxitererr[-2]: # Monotonically decreasing - can increase omega slightly self.relax_omega = min(1.0, self.relax_omega * 1.05) # Check convergence (matches MATLAB converged.m line 164: it > iter_min and check last 3 errors) if it > stop_floor and len(self.maxitererr) >= 3: if (self.maxitererr[-1] < self.options.iter_tol and self.maxitererr[-2] < self.options.iter_tol and self.maxitererr[-3] < self.options.iter_tol): if not self.hasconverged: # Reset layers and check again for e in range(self.nlayers): if self.ensemble[e] is not None: self.ensemble[e].reset() self.hasconverged = True else: return True else: self.hasconverged = False return False
[docs] def converged_stoch(self, it: int) -> bool: """Convergence controller for stochastic layer solvers (Robbins-Monro mode). When one or more layer solvers return noisy estimates (simulation, e.g. JMT/SSA/LDES, or Monte Carlo integration, e.g. NC with mci/imci/ls), the deterministic Picard iteration in converged() cannot terminate: the successive-difference error is bounded below by the standard error of the layer estimates, and the layer-reset confirmation step merely resamples the noise. This routine implements a stochastic approximation iteration instead: 1. Burn-in: for the first stochiter_burnin iterations the plain Picard iteration runs with the relaxation factor configured at init. 2. Robbins-Monro step: afterwards the relaxation factor applied by update_metrics to the fed-forward iterate (servt, residt, tput, callservt) decays as omega_k = a0/k**alpha with alpha in (0.5,1]. Under the contraction assumption already made by the deterministic iteration, and zero-mean noise with bounded variance, the iterate converges almost surely to the true fixed point (Robbins and Monro, 1951). Layer seeds are rotated per iteration in pre() so successive evaluations observe independent noise. 3. Polyak-Ruppert averaging: running averages of the layer results and of the reported iterates are maintained and installed as the final solution in finish(), giving the optimal O(1/sqrt(k)) rate and robustness to the choice of a0 (Polyak and Juditsky, 1992). 4. Stopping: iteration stops when the drift of the averaged results stays below iter_tol for stochiter_conseq consecutive iterations. The drift of a running average decays like 1/k even under persistent noise, so the test terminates, and it self-calibrates: larger noise keeps the drift above tolerance longer, forcing more averaging. """ if it < 1: return False burnin = int(self.options.config.get('stochiter_burnin', 5)) a0 = float(self.options.config.get('stochiter_a0', 1.0)) alpha = float(self.options.config.get('stochiter_alpha', 0.6)) while len(self.maxitererr) <= it: self.maxitererr.append(0.0) # Schedule the Robbins-Monro step used by update_metrics at the next iteration if it >= burnin: self.relax_omega = min(1.0, a0 / max(1, it - burnin + 1) ** alpha) if it <= burnin: # pure Picard burn-in; no averaging or convergence testing yet self.maxitererr[it] = np.inf if self.options.verbose: print(f'Stochastic iteration burn-in {it}/{burnin}.') return False if self.stochiter_start is None: self.stochiter_start = it if self.options.verbose: print('Started Robbins-Monro averaging (stochastic layer solvers detected).') # Polyak-Ruppert update of the layer result averages and drift metric k = self.stoch_avg_count + 1 err = 0.0 latest = self.results[-1] if self.results else [] for e in range(self.nlayers): raw = latest[e] if e < len(latest) else None if raw is None: continue if k == 1 or self.stoch_avg[e] is None: avg = {key: (None if raw.get(key) is None else np.array(raw[key], dtype=float, copy=True)) for key in ('QN', 'UN', 'RN', 'TN', 'AN', 'WN')} else: prev = self.stoch_avg[e] avg = {key: _polyak_avg(prev.get(key), raw.get(key), k) for key in ('QN', 'UN', 'RN', 'TN', 'AN', 'WN')} # drift of the averaged queue lengths, normalized by population N = np.sum(self.ensemble[e].get_number_of_jobs()) if self.ensemble[e] is not None else 0 if N > 0 and avg.get('QN') is not None and prev.get('QN') is not None: try: d = np.abs(np.asarray(avg['QN'], dtype=float).flatten() - np.asarray(prev['QN'], dtype=float).flatten()) d[np.isnan(d)] = 0.0 err += float(np.max(d)) / N except Exception: pass self.stoch_avg[e] = avg self.stoch_avg_count = k # Polyak-Ruppert averages of the fed-forward iterates used in reporting if k == 1: self.stoch_servt_avg = None if self.servt is None else np.array(self.servt, dtype=float, copy=True) self.stoch_residt_avg = None if self.residt is None else np.array(self.residt, dtype=float, copy=True) else: self.stoch_servt_avg = _polyak_avg(self.stoch_servt_avg, self.servt, k) self.stoch_residt_avg = _polyak_avg(self.stoch_residt_avg, self.residt, k) self.maxitererr[it] = err if self.options.verbose: print(f'RMIterErr={err:.6e} (tol={self.options.iter_tol:.6e}, ' f'omega={self.relax_omega:.3f}, k={k})') # Stop when the averaged-iterate drift stays below tolerance conseq = int(self.options.config.get('stochiter_conseq', 3)) if k > conseq: below = all(self.maxitererr[j] < self.options.iter_tol for j in range(it - conseq + 1, it + 1)) self.hasconverged = below return below return False
[docs] def finish(self): """Operations after iterations complete (matches MATLAB finish).""" line_debug("LN finish: final analysis of %d layers", self.nlayers) # In Robbins-Monro mode, report the Polyak-Ruppert averaged results # rather than the last (noisy) iterate if self.stochiter_mode == 'rm' and self.stoch_avg_count > 0 and self.results: latest = self.results[-1] for e in range(self.nlayers): if e < len(latest) and latest[e] is not None and self.stoch_avg[e] is not None: for key in ('QN', 'UN', 'RN', 'TN', 'AN', 'WN'): latest[e][key] = self.stoch_avg[e].get(key) if self.stoch_servt_avg is not None: self.servt = self.stoch_servt_avg if self.stoch_residt_avg is not None: self.residt = self.stoch_residt_avg for e in range(self.nlayers): if self.solvers[e] is not None: if hasattr(self.solvers[e], 'getAvg'): self.solvers[e].getAvg() elif hasattr(self.solvers[e], 'get_avg'): self.solvers[e].get_avg() self.model.ensemble = self.ensemble
[docs] def iterate(self): """Run iteration (matches MATLAB EnsembleSolver iterate).""" # Solver console: SolverLN drives an ensemble of layer models and does # not pass through the NetworkSolver entry point, so it opens its own # run here, at the method every caller reaches. from line_solver.api.io import console as _console with _console.run_scope(self, self.options): _console.loop('solving the layered fixed point over %d layers', self.nlayers) return self._iterate_body()
def _iterate_body(self): from line_solver.api.io import console as _console line_debug("LN solver iterate starting: method=%s, nlayers=%d", self.options.method if hasattr(self.options, 'method') else 'default', self.nlayers) it = 0 # Raw, un-smoothed history; self.results is refreshed from it below. results = [] self.results = [] self.init() while not self.converged(it) and it < self.options.iter_max: it += 1 self.pre(it) # Analyze all layers layer_results = [] for e in range(self.nlayers): result, _ = self.analyze(it, e) layer_results.append(result) results.append(layer_results) # finish iteration loop. self.results = [list(row) for row in results] self.post(it) self.finish()
[docs] def get_ensemble_avg(self) -> Tuple[np.ndarray, ...]: """Get ensemble average (matches MATLAB getEnsembleAvg).""" # method 'nlp' is tested BEFORE the empty-ensemble guard below: it never # builds one, so the guard would otherwise report it as an unsolvable # model rather than running its analyzer. if getattr(self, 'lnmethod', None) == 'nlp': from .solver_ln_nlp_analyzer import solver_ln_nlp_analyzer return solver_ln_nlp_analyzer(self) if not self.ensemble: return (np.array([]),) * 6 # lang=java dispatch bypasses the native fixed point; see _kb/11-conventions-and-gotchas.md lang=java SolverLN skips iterate(). if getattr(self.options, 'lang', 'python') == 'java': from ..jar_dispatch import ln_ensemble_avg_via_jar return ln_ensemble_avg_via_jar(self) # lang=cpp solves the whole ensemble in one line-cli run and likewise never # enters iterate(). An absent binary is the ONLY automatic fallback: a # construct or option the C++ layered path refuses propagates, since # answering it natively would report a python number under lang='cpp'. if getattr(self.options, 'lang', 'python') == 'cpp': from ..cpp_dispatch import LineCliNotAvailable, ln_ensemble_avg_via_cpp try: return ln_ensemble_avg_via_cpp(self) except LineCliNotAvailable as e: line_warning("SolverLN", "lang='cpp' requested but the C++ solver is " "unavailable (%s); falling back to lang='python'." % e) self.iterate() # the layers of method 'srvn.ph' carry one class per caller task, so the # per-element results are rebuilt analytically -- see its get_ensemble_avg if self._is_ph_encoding(): return self._get_ensemble_avg_ph() lqn = self.lqn QN = np.full(lqn.nidx, np.nan) # Queue lengths (will become utilization) UN = np.full(lqn.nidx, np.nan) RN = np.full(lqn.nidx, np.nan) TN = np.full(lqn.nidx, np.nan) PN = np.full(lqn.nidx, np.nan) # Utilization stored here first SN = np.full(lqn.nidx, np.nan) # Response time stored here first WN = np.full(lqn.nidx, np.nan) # Residence time WN_processed = np.zeros(lqn.nidx, dtype=bool) # Track activities already accumulated into task WN AN = np.full(lqn.nidx, np.nan) # Not available yet E = self.nlayers for e in range(E): if len(self.results) == 0 or e >= len(self.results[-1]): continue result = self.results[-1][e] if result is None: continue layer = self.ensemble[e] if layer is None: continue client_idx = layer.attribute.get('clientIdx') server_idx = layer.attribute.get('serverIdx') source_idx = layer.attribute.get('sourceIdx') if server_idx is None: continue # Convert 1-based to 0-based indices server_idx_0 = server_idx - 1 if server_idx and server_idx >= 1 else 0 client_idx_0 = client_idx - 1 if client_idx and client_idx >= 1 else None source_idx_0 = source_idx - 1 if source_idx and not np.isnan(source_idx) else None # Get result matrices result_QN = result.get('QN') result_UN = result.get('UN') result_RN = result.get('RN') result_TN = result.get('TN') result_WN = result.get('WN', result_RN) if result_QN is None or result_TN is None: continue # Get stations and check if ishost stations = layer.get_nodes() if server_idx_0 < len(stations): server_station = stations[server_idx_0] else: server_station = None is_host = False hidx = None if server_station is not None and hasattr(server_station, 'attribute'): is_host = server_station.attribute.get('ishost', False) hidx = server_station.attribute.get('idx') # For host layers, determine processor metrics, one processor at a # time: under flat layering the layer carries every processor. host_station_idx = layer.attribute.get('hostStations') or ([server_idx] if is_host else []) for hs in host_station_idx: hs0 = hs - 1 if hs >= 1 else 0 if hs0 >= len(stations): continue h_station = stations[hs0] hidx = h_station.attribute.get('idx') if hasattr(h_station, 'attribute') else None if hidx is None or not h_station.attribute.get('ishost', False): continue # Aggregate metrics across all classes for processor if np.isnan(QN[hidx]): QN[hidx] = 0.0 if np.isnan(PN[hidx]): PN[hidx] = 0.0 classes = layer.get_classes() for c_idx, cls in enumerate(classes): # Add queue length and utilization from server node # Add queue length (for ALL classes) if hs0 < result_QN.shape[0] and c_idx < result_QN.shape[1]: QN[hidx] = QN[hidx] + result_QN[hs0, c_idx] # activity index extracted from the class attribute tuple. if hasattr(cls, 'attribute') and cls.attribute is not None: elem_type = cls.attribute[0] if len(cls.attribute) > 0 else 0 if elem_type == LayeredNetworkElement.ACTIVITY: aidx = cls.attribute[1] if len(cls.attribute) > 1 else None if aidx is not None: # only the activities that run on this processor if self._station_idx_of(layer, self._get_parent(self._get_parent(aidx))) != hs: continue tidx = self._get_parent(aidx) # Get parent task if np.isnan(PN[aidx]): PN[aidx] = 0.0 if tidx is not None and np.isnan(PN[tidx]): PN[tidx] = 0.0 if hs0 < result_UN.shape[0] and c_idx < result_UN.shape[1]: # MATLAB does NOT apply fork_fanout correction here # (matches MATLAB getEnsembleAvg lines 55-59) util = result_UN[hs0, c_idx] PN[aidx] = PN[aidx] + util if tidx is not None: PN[tidx] = PN[tidx] + util PN[hidx] = PN[hidx] + util # Processor utilization from ACTIVITY only TN[hidx] = np.nan # Added for consistency with LQNS is_host = is_host or bool(layer.attribute.get('hostStations')) # Determine remaining metrics for all classes classes = layer.get_classes() for c_idx, cls in enumerate(classes): if not hasattr(cls, 'attribute') or cls.attribute is None: continue elem_type = cls.attribute[0] if len(cls.attribute) > 0 else 0 # under flat layering each class is served at its own station, so # read the layer result there rather than at the layer's serverIdx server_idx_0 = self._station_idx_of_class(layer, cls) - 1 if elem_type == LayeredNetworkElement.TASK: tidx = cls.attribute[1] if len(cls.attribute) > 1 else None if tidx is None: continue if is_host: # throughput read from the layer result at the client index. if np.isnan(TN[tidx]): if client_idx_0 is not None and client_idx_0 < result_TN.shape[0] and c_idx < result_TN.shape[1]: # Get throughput from layer result at clientIdx TN[tidx] = result_TN[client_idx_0, c_idx] else: # Task layer: get queue length (nop for utilization - matches MATLAB) if server_idx_0 < result_QN.shape[0] and c_idx < result_QN.shape[1]: if np.isnan(QN[tidx]): QN[tidx] = 0.0 QN[tidx] = QN[tidx] + result_QN[server_idx_0, c_idx] elif elem_type == LayeredNetworkElement.ENTRY: eidx = cls.attribute[1] if len(cls.attribute) > 1 else None if eidx is None: continue # Entry response time: for phase-2 models, use residt (caller's view) # which is phase-1 + overtaking correction (MATLAB getEnsembleAvg lines 84-90) if (self.hasPhase2 and self.servt_ph2 is not None and eidx < len(self.servt_ph2) and self.servt_ph2[eidx] > 1e-8): SN[eidx] = self.residt[eidx] else: SN[eidx] = self.servt[eidx] # Entry throughput - use layer result directly (matches MATLAB getEnsembleAvg lines 90-96) if is_host and client_idx_0 is not None and np.isnan(TN[eidx]): if client_idx_0 < result_TN.shape[0] and c_idx < result_TN.shape[1]: # Get throughput from layer result # LQN throughput is total (not per-instance) - matches MATLAB TN[eidx] = result_TN[client_idx_0, c_idx] elif elem_type == LayeredNetworkElement.ACTIVITY: aidx = cls.attribute[1] if len(cls.attribute) > 1 else None if aidx is None: continue tidx = self._get_parent(aidx) # Add queue length to task (matches MATLAB line 111-112) if tidx is not None: if np.isnan(QN[tidx]): QN[tidx] = 0.0 if server_idx_0 < result_QN.shape[0] and c_idx < result_QN.shape[1]: QN[tidx] = QN[tidx] + result_QN[server_idx_0, c_idx] # Initialize TN and QN for activity (matches MATLAB lines 113-114) if np.isnan(TN[aidx]): TN[aidx] = 0.0 if np.isnan(QN[aidx]): QN[aidx] = 0.0 # Propagate activity throughput to task if task doesn't have its own class # (matches MATLAB getEnsembleAvg lines 116-127) if tidx is not None: tasks_attr = layer.attribute.get('tasks', []) has_task_class = any(t[1] == tidx for t in tasks_attr) if not has_task_class: if np.isnan(TN[tidx]): TN[tidx] = 0.0 if server_idx_0 < result_TN.shape[0] and c_idx < result_TN.shape[1]: TN[tidx] = TN[tidx] + result_TN[server_idx_0, c_idx] # Find entry this activity is bound to (matches MATLAB lines 130-153) if tidx is not None and hasattr(lqn, 'entriesof') and lqn.entriesof is not None: entries = lqn.entriesof.get(tidx, []) for eidx_check in entries: if (hasattr(lqn, 'graph') and lqn.graph is not None and eidx_check < lqn.graph.shape[0] and aidx < lqn.graph.shape[1] and lqn.graph[eidx_check, aidx] > 0): if np.isnan(TN[eidx_check]): TN[eidx_check] = 0.0 if np.isnan(QN[eidx_check]): QN[eidx_check] = 0.0 if np.isnan(SN[eidx_check]): SN[eidx_check] = 0.0 act_tput = 0.0 if server_idx_0 < result_TN.shape[0] and c_idx < result_TN.shape[1]: act_tput = result_TN[server_idx_0, c_idx] # Only add if entry doesn't have its own class entries_attr = layer.attribute.get('entries', []) has_entry_class = any(ea[1] == eidx_check for ea in entries_attr) if entries_attr else False if not has_entry_class: TN[eidx_check] = TN[eidx_check] + act_tput if server_idx_0 < result_QN.shape[0] and c_idx < result_QN.shape[1]: QN[eidx_check] = QN[eidx_check] + result_QN[server_idx_0, c_idx] if server_idx_0 < result_RN.shape[0] and c_idx < result_RN.shape[1]: SN[eidx_check] = SN[eidx_check] + result_RN[server_idx_0, c_idx] break # accumulate activity throughput; response time computed next (mirrors MATLAB lines 161-164). if server_idx_0 < result_TN.shape[0] and c_idx < result_TN.shape[1]: act_tput = result_TN[server_idx_0, c_idx] TN[aidx] = TN[aidx] + act_tput # Activity response time (matches MATLAB lines 161-164) act_resp_time = 0.0 if server_idx_0 < result_RN.shape[0] and c_idx < result_RN.shape[1]: if np.isnan(RN[aidx]): RN[aidx] = 0.0 act_resp_time = result_RN[server_idx_0, c_idx] RN[aidx] = RN[aidx] + act_resp_time if np.isnan(SN[aidx]): SN[aidx] = 0.0 SN[aidx] = SN[aidx] + act_resp_time # Activity queue length (matches MATLAB lines 169-170) if server_idx_0 < result_QN.shape[0] and c_idx < result_QN.shape[1]: QN[aidx] = QN[aidx] + result_QN[server_idx_0, c_idx] # guard WN against NaN before use, per activity. if np.isnan(WN[aidx]): WN[aidx] = 0.0 WN[aidx] = self.residt[aidx] if tidx is not None: if np.isnan(WN[tidx]): WN[tidx] = 0.0 if not WN_processed[aidx]: WN[tidx] = WN[tidx] + self.residt[aidx] WN_processed[aidx] = True elif elem_type == LayeredNetworkElement.CALL: # Handle CALL classes (matches MATLAB getEnsembleAvg lines 99-107) cidx = cls.attribute[1] if len(cls.attribute) > 1 else None if cidx is not None and cidx >= 0: # Get source activity from callpair if hasattr(lqn, 'callpair') and lqn.callpair is not None: if cidx < lqn.callpair.shape[0]: # callpair column 1 is the source activity (0-indexed in the array) aidx = int(lqn.callpair[cidx, 0]) if aidx > 0: # Check if this is a SYNC call calltype = CallType.SYNC if hasattr(lqn, 'calltype') and lqn.calltype is not None: if isinstance(lqn.calltype, np.ndarray): if cidx < len(lqn.calltype.flatten()): calltype = lqn.calltype.flatten()[cidx] elif isinstance(lqn.calltype, dict): calltype = lqn.calltype.get(cidx, CallType.SYNC) # call_mean defaults to 1.0 absent an explicit callproc entry. if calltype == CallType.SYNC: # Get call mean from callproc call_mean = 1.0 if hasattr(lqn, 'callproc') and lqn.callproc is not None: if cidx < len(lqn.callproc) and lqn.callproc[cidx] is not None: proc = lqn.callproc[cidx] if hasattr(proc, 'getMean'): call_mean = proc.getMean() # Add layer result RN * call_mean to SN[aidx] if server_idx_0 < result_RN.shape[0] and c_idx < result_RN.shape[1]: if np.isnan(SN[aidx]): SN[aidx] = 0.0 SN[aidx] = SN[aidx] + result_RN[server_idx_0, c_idx] * call_mean # MATLAB getEnsembleAvg lines 106-107: # QN(aidx) = QN(aidx) + self.results{end,e}.QN(serverIdx,c) if np.isnan(QN[aidx]): QN[aidx] = 0.0 if server_idx_0 < result_QN.shape[0] and c_idx < result_QN.shape[1]: QN[aidx] = QN[aidx] + result_QN[server_idx_0, c_idx] # entry/task throughput fallback when layer results leave them unset; mirrors MATLAB getEnsembleAvg. for t in range(lqn.ntasks): tidx = lqn.tshift + t entries = self._get_entries_of_task(tidx) # entry throughput falls back to its task's throughput when unset. # For entries without layer-derived throughput, use task throughput for eidx in entries: if np.isnan(TN[eidx]) and not np.isnan(TN[tidx]): TN[eidx] = TN[tidx] # Entry service time = servt for eidx in entries: if np.isnan(SN[eidx]) and eidx < len(self.servt): SN[eidx] = self.servt[eidx] # iterate activities of the task for response-time aggregation. for t in range(lqn.ntasks): tidx = lqn.tshift + t activities = self._get_activities_of_task(tidx) for aidx in activities: # Activity response time = residt (includes queueing) + call response times if np.isnan(SN[aidx]): act_resp_time = 0.0 # Add activity's own residence time if aidx < len(self.residt) and self.residt[aidx] > 0: act_resp_time = self.residt[aidx] elif aidx < len(self.servtproc) and self.servtproc[aidx] is not None: # Fallback to host demand if no residt available if hasattr(self.servtproc[aidx], 'getMean'): act_resp_time = self.servtproc[aidx].getMean() elif hasattr(self.servtproc[aidx], 'mean'): act_resp_time = self.servtproc[aidx].mean # accumulate activity response time from the server-node result; mirrors MATLAB line 162. # Set activity response time from layer result only (not including calls) # MATLAB line 162: SN(aidx) = SN(aidx) + result.RN(serverIdx,c) if np.isnan(SN[aidx]): SN[aidx] = 0.0 SN[aidx] = SN[aidx] + act_resp_time # Calculate entry utilization from throughput and service times # (matches MATLAB getEnsembleAvg lines 175-197) for e in range(lqn.nentries): eidx = lqn.eshift + e tidx = self._get_parent(eidx) if tidx is not None and np.isnan(UN[tidx]): UN[tidx] = 0.0 # Phase-2 support: utilization includes both phases # (MATLAB getEnsembleAvg lines 187-197) if (self.hasPhase2 and self.servt_ph2 is not None and eidx < len(self.servt_ph2) and self.servt_ph2[eidx] > 1e-8 and not np.isnan(TN[eidx])): self.util_ph1[eidx] = TN[eidx] * self.servt_ph1[eidx] self.util_ph2[eidx] = TN[eidx] * self.servt_ph2[eidx] UN[eidx] = self.util_ph1[eidx] + self.util_ph2[eidx] elif not np.isnan(TN[eidx]) and not np.isnan(SN[eidx]): # Standard calculation for entries without phase-2 UN[eidx] = TN[eidx] * SN[eidx] # Entry utilization = sum of activity processor utilizations for that entry entry_acts = lqn.actsof.get(eidx, []) if isinstance(lqn.actsof, dict) else [] if entry_acts: entry_util = sum(PN[a] for a in entry_acts if not np.isnan(PN[a])) PN[eidx] = entry_util # Activity queue length (UN array) = throughput * response time for aidx in self._get_activities_of_task(tidx) if tidx else []: if not np.isnan(TN[aidx]) and not np.isnan(SN[aidx]): UN[aidx] = TN[aidx] * SN[aidx] # Queue length (throughput * response time) # processor/task utilization comes directly from the layer result UN, set earlier in the layer loop. if tidx is not None and not np.isnan(UN[eidx]): UN[tidx] = UN[tidx] + UN[eidx] # python has no Cache nodes, so cache-task metrics must be zeroed explicitly (MATLAB's Cache nodes zero them internally). # CacheTask: find each entry's bound activity to attribute cache metrics. if hasattr(lqn, 'iscache') and lqn.iscache is not None: for t in range(lqn.ntasks): tidx = lqn.tshift + t if lqn.iscache[tidx, 0] > 0: # This is a CacheTask - find the bound activity of each entry entries = self._get_entries_of_task(tidx) for eidx in entries: # The bound activity is the one directly connected from entry in the graph for aidx in self._get_activities_of_task(tidx): if lqn.graph[eidx, aidx] > 0: # This is the bound activity of a cache entry - zero out QN[aidx] = 0.0 UN[aidx] = 0.0 RN[aidx] = 0.0 TN[aidx] = 0.0 PN[aidx] = 0.0 SN[aidx] = 0.0 WN[aidx] = 0.0 # AN IGNORED ELEMENT IS IDLE, NOT UNDEFINED, and the two are different # cells. Its component holds no reference task, so nothing reaches it and # every measure it HAS is zero -- but the measures its kind never has stay # NaN, exactly as they do for a reachable element. A flat zero over all six # columns broke the table's NaN mask (a processor with a queue length of 0, # an arrival rate reported where no solver reports one), and the mask is # part of the answer: see _kb/06-solver-catalog.md. Reported columns are # QLen=UN, Util=PN, RespT=SN, ResidT=WN, ArvR=AN, Tput=TN, so the pre-swap # QN and RN are discarded below and are not written here. for idx in range(lqn.nidx): if self.ignore[idx]: PN[idx] = 0.0 # every kind reports a utilization AN[idx] = np.nan # nothing reports an arrival rate on an LQN kind = self._get_type(idx) if kind == LayeredNetworkElement.PROCESSOR: UN[idx] = SN[idx] = WN[idx] = TN[idx] = np.nan elif kind == LayeredNetworkElement.TASK: UN[idx] = WN[idx] = TN[idx] = 0.0 SN[idx] = np.nan elif kind == LayeredNetworkElement.ENTRY: UN[idx] = SN[idx] = TN[idx] = 0.0 WN[idx] = np.nan elif kind == LayeredNetworkElement.ACTIVITY: UN[idx] = SN[idx] = WN[idx] = TN[idx] = 0.0 # UN=PN, RN=SN by convention (processor utilization, response time). final_QN = UN.copy() # MATLAB: QN = UN (utilization in jobs) final_UN = PN.copy() # MATLAB: UN = PN (processor utilization) final_RN = SN.copy() # MATLAB: RN = SN (response time) return final_QN, final_UN, final_RN, TN, AN, WN
[docs] def get_avg(self) -> Tuple[np.ndarray, ...]: """Get average metrics (alias for get_ensemble_avg).""" return self.get_ensemble_avg()
[docs] def getCdfRespT(self) -> List[Optional[np.ndarray]]: """Response time distribution of every entry of the layered network. Mirrors MATLAB ``@SolverLN/getCdfRespT.m``. The distribution is formed by the ``moment3`` pass alone -- the mean-based update builds no law at all -- so a solver constructed with any other method re-runs the ensemble under ``moment3`` here and restores the caller's method afterwards. The routing layers already built serve ``moment3`` unchanged, so only the update pass changes. Returns: A list of ``nentries`` items, one per entry in the entry-local index space (``lqn.eshift + i``). Each is an ``(n, 2)`` array whose columns are ``[F(t), t]``, the column order every CDF getter in LINE uses, or None for an entry the pass fitted no law to. Raises: ValueError: if the layers were built for a phase-type encoding, which carries no activity-graph routing to re-run over. """ if not self.entrycdfrespt or self.entrycdfrespt[0] is None: # The distribution pass reads the routing encoding of the activity # graph, which the srvn.ph / flat.ph layers do not carry: re-running # get_avg over them would reconstruct the wrong topology rather than # a coarser answer. Refuse by name. if self._is_ph_encoding(): raise ValueError( "getCdfRespT needs the routing encoding of the activity graph, which " "method='%s' does not build. Rebuild the solver with method='srvn.cs' " "or method='moment3'." % self.lnmethod) cur_method = getattr(self.options, 'method', None) cur_lnmethod = self.lnmethod # BOTH the option and the RESOLVED method have to move: update_metrics # dispatches on self.lnmethod, which _build_layers resolved once, so # setting options.method alone leaves the mean-based update in place # and returns an EMPTY table. self.options.method = 'moment3' self.lnmethod = 'moment3' try: self.get_avg() finally: self.options.method = cur_method self.lnmethod = cur_lnmethod return self.entrycdfrespt
get_cdf_resp_t = getCdfRespT
[docs] def getTranAvg(self, *args): """Transient average station metrics of the layered network. ``options.config['ln_transient']`` selects the inter-layer coupling of the transient: - ``'decoupled'``: freeze inter-layer demands at the converged fixed point (get_ensemble_avg) and run each layer's transient in isolation. - ``'coupled'`` (default): reconcile the per-layer transients by waveform relaxation, so layer populations and inter-layer demands co-evolve in model time (getTranAvgCoupled). Both modes return the SAME block-diagonal layout; iteration 0 of the coupled relaxation is exactly the decoupled result. Mirrors MATLAB SolverLN.getTranAvg. """ # The C++ layered path serves -a avg only, and the transient below is a # native computation: returning it under lang='cpp' would label python # numbers as C++ ones, which is what that option exists to rule out. if getattr(self.options, 'lang', 'python') == 'cpp': raise RuntimeError( "lang='cpp' delegates the steady-state layered solve only (the C++ layered " "path implements -a avg); the layered transient is native. Use lang='python' " "for getTranAvg.") cfg = getattr(self.options, 'config', None) mode = None if cfg is not None: mode = cfg.get('ln_transient') if isinstance(cfg, dict) \ else getattr(cfg, 'ln_transient', None) mode = (mode or 'coupled').lower() if mode == 'coupled': return self.getTranAvgCoupled(*args) if mode == 'decoupled': return self.getTranAvgDecoupled(*args) raise ValueError( "Unknown ln_transient mode '%s' (use 'coupled' or 'decoupled')." % mode)
def _replay_init_marginal(self): """Re-install the warm start supplied by LayeredNetwork.initFromMarginal on the layer networks, and drop the layer solvers' cached results. The fixed-point solve hard-resets every layer when it detects convergence, after which the layer solver re-initializes to the default state, so a warm start does not survive it. The steady solve ignores the initial state but the transients below do not, hence the replay here. This is what carries the queue lengths of a SolverENV stage across an environment switch. Returns: True if a warm start was available and re-applied. """ model = getattr(self, 'model', None) blocks = model.get_init_marginal_blocks() if hasattr(model, 'get_init_marginal_blocks') else None if not blocks or len(blocks) != self.nlayers: return False from ...api.state.marginal import roundMarginalPreservingChains for e in range(self.nlayers): layer = self.ensemble[e] if e < len(self.ensemble) else None if layer is None: continue solver = self.solvers[e] if e < len(self.solvers) else None block = np.asarray(blocks[e], dtype=float).copy() sname = type(solver).__name__ if solver is not None else '' if 'Fluid' not in sname and 'FLD' not in sname: # reset the layer solver so its cached steady-state result does not shadow the transient run from the replayed state. block = roundMarginalPreservingChains(block, layer.get_struct()) layer.init_from_marginal(block) if solver is not None and hasattr(solver, 'reset'): # Otherwise the cached steady-state result shadows the # transient run from the replayed state. solver.reset() return True
[docs] def getTranAvgDecoupled(self, *args): """Decoupled (frozen-demand) transient average station metrics. Mirrors MATLAB SolverLN.getTranAvgDecoupled: runs the ensemble fixed-point solve, then delegates the transient analysis to each layer solver and assembles the per-layer station x class traces block-diagonally (layer e in a disjoint row/column block). Off-block cells are left None. Transient traces are only produced by transient-capable layer solvers (Fluid, CTMC, SSA); with steady-state-only layers (MVA, NC) the delegated getTranAvg raises, matching the MATLAB behaviour. Returns: (QNlqn_t, UNlqn_t, TNlqn_t): each a block-diagonal nested list [rows][cols] of TranResult (or None off-block), where layer e occupies a disjoint block of rows (its stations) and columns (its classes). """ # Run the ensemble fixed point (mirrors self.getAvg in MATLAB). self.get_ensemble_avg() # timespan validity check before replaying the initial marginal. ts = getattr(self.options, 'timespan', None) has_ts = (ts is not None and len(ts) >= 2 and np.all(np.isfinite(np.asarray(ts, dtype=float)))) self._replay_init_marginal() # Collect the per-layer transient traces from each layer solver. per_layer = [] for e in range(self.nlayers): solver = self.solvers[e] if e < len(self.solvers) else None if solver is None: per_layer.append(([], [], [])) continue saved_ts = None if has_ts and hasattr(solver, 'options') and hasattr(solver.options, 'timespan'): saved_ts = solver.options.timespan solver.options.timespan = ts try: QNe, UNe, TNe = solver.getTranAvg() finally: if saved_ts is not None: solver.options.timespan = saved_ts per_layer.append((QNe, UNe, TNe)) # QN blocks drive the block extent, matching the MATLAB assembly. total_rows = sum(len(p[0]) for p in per_layer) total_cols = sum((len(p[0][0]) if len(p[0]) > 0 else 0) for p in per_layer) QNlqn_t = [[None for _ in range(total_cols)] for _ in range(total_rows)] UNlqn_t = [[None for _ in range(total_cols)] for _ in range(total_rows)] TNlqn_t = [[None for _ in range(total_cols)] for _ in range(total_rows)] r0 = 0 c0 = 0 for QNe, UNe, TNe in per_layer: nr = len(QNe) nc = len(QNe[0]) if nr > 0 else 0 for i in range(nr): for r in range(nc): QNlqn_t[r0 + i][c0 + r] = QNe[i][r] if i < len(UNe) and r < len(UNe[i]): UNlqn_t[r0 + i][c0 + r] = UNe[i][r] if i < len(TNe) and r < len(TNe[i]): TNlqn_t[r0 + i][c0 + r] = TNe[i][r] r0 += nr c0 += nc return QNlqn_t, UNlqn_t, TNlqn_t
[docs] def getBlockAvg(self): """Block-diagonal aggregate STEADY tables, in the layout getTranAvg returns. Three (M x K) arrays over the SAME aggregate station x class space as getTranAvg and LayeredNetwork.get_tran_handles: layer e occupies a disjoint block of rows (its stations) and columns (its classes), and every off-block cell is 0, because no station of one layer carries a class of another. THIS IS NOT getAvg, AND THE DIFFERENCE IS AN INDEX SPACE, not a layout. SolverLN.getAvg is getEnsembleAvg: ONE ROW over the LQN's own nodes -- hosts, tasks, entries, activities -- which carries no station or class meaning whatsoever. Copying its leading block into an (M, K) aggregate therefore reinterprets LQN node k as CLASS k, silently, and the value lands on a cell no layer owns. SolverENV's exit-metric seed did exactly that for a layered stage until 2026-09-14: the stray cell sat off-block where the transient never overwrites anything, survived the probEnv blend, and inflated the aggregate queue length and throughput past the closed population the stages conserve. Reads the layer solvers' own cached averages and does NOT re-run the layered fixed point: the coupling that wants this is mid-iteration and is still reading the results that solve produced. Returns: (QN, UN, TN): each an (M x K) numpy array, off-block cells 0. """ ens = self.ensemble if self.ensemble else [] nl = min(int(self.nlayers), len(self.solvers), len(ens)) Msz = [int(ens[e].get_number_of_stations()) for e in range(nl)] Ksz = [int(ens[e].get_number_of_classes()) for e in range(nl)] M, K = int(sum(Msz)), int(sum(Ksz)) QN = np.zeros((M, K)) UN = np.zeros((M, K)) TN = np.zeros((M, K)) r0 = 0 c0 = 0 for e in range(nl): solver = self.solvers[e] if solver is not None and hasattr(solver, 'getAvg'): res = solver.getAvg() if res is not None: for dst, idx in ((QN, 0), (UN, 1), (TN, 3)): a = res[idx] if len(res) > idx else None if a is None: continue a = np.atleast_2d(np.asarray(a, dtype=float)) m = min(Msz[e], a.shape[0]) k = min(Ksz[e], a.shape[1]) # A NaN is ABSENT, not a number: it must not reach the # probEnv-weighted sum this seeds. blk = a[:m, :k] dst[r0:r0 + m, c0:c0 + k] = np.where(np.isfinite(blk), blk, 0.0) r0 += Msz[e] c0 += Ksz[e] return QN, UN, TN
get_tran_avg = getTranAvg get_tran_avg_decoupled = getTranAvgDecoupled get_block_avg = getBlockAvg # ---- Coupled layered transient (waveform relaxation) -------------------
[docs] def getTranAvgCoupled(self, *args): """Coupled layered transient by waveform relaxation over the LQN ensemble. Port of MATLAB ``@SolverLN/getTranAvgCoupled.m``. Unlike getTranAvgDecoupled, which freezes inter-layer demands at the converged fixed point, this reconciles the per-layer transients iteratively: each layer's transient is driven by TIME-VARYING inter-layer demand trajectories taken from the other layers' latest transients, and the loop repeats until the trajectories stop changing (sup-norm gap over time). The time-varying demands are injected into each layer solver through the per-(station,class) rate schedule (``options.config['rate_sched']``), honoured by the fluid rate multiplier and by the CTMC time-varying transient. Iteration 0 uses the frozen equilibrium demands, so it reproduces getTranAvgDecoupled exactly; at convergence every layer relaxes to its fixed point, so the endpoint equals get_ensemble_avg. The return layout is the same block-diagonal (station x class per layer) as getTranAvgDecoupled. Coupled channels: task think times (client delay) and synchronous-call service demands (caller client station). Both are the dominant inter-layer couplings; intra-layer host service stays at its equilibrium value. """ # timespan validity check before triggering the ensemble average solve. ts = getattr(self.options, 'timespan', None) ts = np.asarray(ts, dtype=float) if ts is not None else None has_ts = ts is not None and ts.size >= 2 and bool(np.all(np.isfinite(ts))) self.get_ensemble_avg() if not has_ts: # No finite transient horizon: nothing to co-evolve, defer to decoupled. return self.getTranAvgDecoupled(*args) E = self.nlayers cfg = getattr(self.options, 'config', None) cfg = cfg if isinstance(cfg, dict) else {} maxit = int(cfg.get('ln_transient_iter_max') or 20) tol = float(cfg.get('ln_transient_tol') or 1e-2) ngrid = 100 tgrid = np.linspace(float(ts[0]), float(ts[1]), ngrid) # Per-layer sn (for node->station and class bookkeeping), cached once. layer_sn = [self.ensemble[e].getStruct() for e in range(E)] # Iteration 0: decoupled transients (frozen equilibrium demands already # set by the fixed-point solve). blocks, traj = self._ln_run_layers(ts, tgrid, [None] * E) for it in range(1, maxit + 1): traj_prev = traj # 1) recompute inter-layer demand trajectories from the latest traj demand = self._ln_recompute_demand(traj, tgrid) # 2) build the per-layer rate_sched injections from those demands sched_by_layer = self._ln_build_rate_sched(demand, tgrid, layer_sn) # 3) re-run each layer with its injected time-varying demand blocks, traj = self._ln_run_layers(ts, tgrid, sched_by_layer) # 4) convergence: sup-norm gap of the queue-length trajectories gap = max((float(np.max(np.abs(traj[e]['Q'] - traj_prev[e]['Q']))) for e in range(E) if traj[e]['Q'].size), default=0.0) line_debug("LN coupled transient: iter %d, sup-norm gap %.3e", it, gap, options=self.options) if gap < tol: break # Assemble the block-diagonal aggregate exactly as getTranAvgDecoupled does. total_rows = sum(len(b[0]) for b in blocks) total_cols = sum((len(b[0][0]) if len(b[0]) > 0 else 0) for b in blocks) QNlqn_t = [[None for _ in range(total_cols)] for _ in range(total_rows)] UNlqn_t = [[None for _ in range(total_cols)] for _ in range(total_rows)] TNlqn_t = [[None for _ in range(total_cols)] for _ in range(total_rows)] r0 = 0 c0 = 0 for QNe, UNe, TNe in blocks: nr = len(QNe) nc = len(QNe[0]) if nr > 0 else 0 for i in range(nr): for r in range(nc): QNlqn_t[r0 + i][c0 + r] = QNe[i][r] if i < len(UNe) and r < len(UNe[i]): UNlqn_t[r0 + i][c0 + r] = UNe[i][r] if i < len(TNe) and r < len(TNe[i]): TNlqn_t[r0 + i][c0 + r] = TNe[i][r] r0 += nr c0 += nc return QNlqn_t, UNlqn_t, TNlqn_t
get_tran_avg_coupled = getTranAvgCoupled def _ln_run_layers(self, ts, tgrid, sched_by_layer): """Run each layer's transient (optionally with an injected rate_sched). Returns: (blocks, traj) where blocks[e] = (QNe, UNe, TNe) native per-layer handles for the block-diagonal assembly, and traj[e] holds (M x K x len(tgrid)) arrays Q, U, T, R resampled onto tgrid (R = Q/T residence via Little's law). """ E = self.nlayers ng = len(tgrid) self._replay_init_marginal() blocks = [] traj = [] for e in range(E): solver = self.solvers[e] if e < len(self.solvers) else None if solver is None: blocks.append(([], [], [])) traj.append({'Q': np.zeros((0, 0, ng)), 'U': np.zeros((0, 0, ng)), 'T': np.zeros((0, 0, ng)), 'R': np.zeros((0, 0, ng))}) continue saved_ts = getattr(solver.options, 'timespan', None) cfg = getattr(solver.options, 'config', None) if not isinstance(cfg, dict): cfg = {} solver.options.config = cfg had_sched = 'rate_sched' in cfg saved_sched = cfg.get('rate_sched') solver.options.timespan = [float(tgrid[0]), float(tgrid[-1])] if sched_by_layer[e]: cfg['rate_sched'] = sched_by_layer[e] elif had_sched: cfg['rate_sched'] = None # best-effort layer solver reset, tolerating solvers without one. try: solver.reset() except AttributeError: pass try: QNe, UNe, TNe = solver.getTranAvg() finally: solver.options.timespan = saved_ts if had_sched: cfg['rate_sched'] = saved_sched else: cfg.pop('rate_sched', None) blocks.append((QNe, UNe, TNe)) M = len(QNe) K = len(QNe[0]) if M > 0 else 0 Q = np.zeros((M, K, ng)) U = np.zeros((M, K, ng)) T = np.zeros((M, K, ng)) R = np.zeros((M, K, ng)) for i in range(M): for r in range(K): qv = self._ln_resample(QNe[i][r], tgrid) uv = self._ln_resample(UNe[i][r] if i < len(UNe) and r < len(UNe[i]) else None, tgrid) tv = self._ln_resample(TNe[i][r] if i < len(TNe) and r < len(TNe[i]) else None, tgrid) Q[i, r, :] = qv U[i, r, :] = uv T[i, r, :] = tv R[i, r, :] = qv / np.maximum(tv, GlobalConstants.FineTol) traj.append({'Q': Q, 'U': U, 'T': T, 'R': R}) return blocks, traj @staticmethod def _ln_resample(h, tgrid): """Resample a transient handle (t/metric pair, or a scalar) onto tgrid.""" ng = len(tgrid) if h is None: return np.zeros(ng) t = np.asarray(getattr(h, 't', []), dtype=float).ravel() m = np.asarray(getattr(h, 'metric', []), dtype=float).ravel() if t.size >= 2 and m.size == t.size: return np.interp(tgrid, t, m) if m.size >= 1: return np.full(ng, m[-1]) return np.zeros(ng) def _ln_recompute_demand(self, traj, tgrid): """Recompute the time-varying inter-layer demands from the layer trajectories, pointwise in t, mirroring the scalar updateThinkTimes / updateMetricsDefault formulas. Returns: dict with 'thinkt': {tidx: (ng,) think-time trajectory} and 'callservt': {cidx: (ng,) call service-time trajectory}. """ lqn = self.lqn ng = len(tgrid) thinkt = {} callservt = {} # Task think times: from the task's own server-layer utilization/throughput. for t in range(lqn.ntasks): tidx = lqn.tshift + t if np.isnan(self.idxhash[tidx]) or self._is_ref_task(tidx): continue e = int(self.idxhash[tidx]) s_idx = self._ln_server_row(e) if s_idx is None or s_idx >= traj[e]['U'].shape[0]: continue uti = traj[e]['U'][s_idx, :, :].sum(axis=0) tti = traj[e]['T'][s_idx, :, :].sum(axis=0) njobs = float(np.max(self.njobs[tidx, :])) userthink = self._mwrbb_think_mean(tidx) tsafe = np.maximum(tti, GlobalConstants.FineTol) if self._get_sched(tidx) == SchedStrategy.INF: tk = (njobs - uti) / tsafe - userthink else: tk = njobs * np.abs(1.0 - uti) / tsafe - userthink # total mean including the user think time thinkt[tidx] = np.maximum(GlobalConstants.Zero, tk) + userthink # Synchronous-call service demands: callee entry response time * call mean. for cidx in range(lqn.ncalls): if self._get_calltype(cidx) != CallType.SYNC: continue eidx = int(lqn.callpair[cidx, 1]) # callee entry tidx = self._get_parent(eidx) # callee task if tidx is None or np.isnan(self.idxhash[tidx]): continue e = int(self.idxhash[tidx]) s_idx = self._ln_server_row(e) if s_idx is None or s_idx >= traj[e]['R'].shape[0]: continue # response time of the callee at its server, summed over the entry classes rc = np.zeros(ng) classes = self.ensemble[e].get_classes() for r in range(min(len(classes), traj[e]['R'].shape[1])): attr = getattr(classes[r], 'attribute', None) if attr is not None and len(attr) >= 2 \ and attr[0] == LayeredNetworkElement.ENTRY and int(attr[1]) == eidx: rc = rc + traj[e]['R'][s_idx, r, :] if not np.any(rc): # fall back to the entry's activities response time rc = traj[e]['R'][s_idx, :, :].sum(axis=0) callservt[cidx] = rc * self._get_call_mean(cidx) return {'thinkt': thinkt, 'callservt': callservt} def _ln_server_row(self, e): """0-based station row of layer e's server node (None when absent).""" layer = self.ensemble[e] attr = getattr(layer, 'attribute', None) if not isinstance(attr, dict): return None s = attr.get('serverIdx') if s is None: return None return int(s) - 1 def _ln_build_rate_sched(self, demand, tgrid, layer_sn): """Map the recomputed demand trajectories to per-layer rate_sched injections, using the same update maps updateLayers uses to place setService calls. ``options.config['ln_transient_channels']`` selects which inter-layer coupling channels are injected: ``'both'`` (default), ``'thinkt'`` (client-delay only), or ``'callservt'`` (synchronous-call service only). Used to isolate each channel's contribution to the coupled transient. """ E = self.nlayers lqn = self.lqn sched_by_layer = [[] for _ in range(E)] cfg = getattr(self.options, 'config', None) cfg = cfg if isinstance(cfg, dict) else {} channels = (cfg.get('ln_transient_channels') or 'both').lower() # think-time channel (client delay of caller tasks) if channels in ('both', 'thinkt') and self.thinkt_classes_updmap is not None: for row in np.atleast_2d(self.thinkt_classes_updmap): idx, aidx, nodeidx, classidx = (int(row[0]), int(row[1]), int(row[2]), int(row[3])) if np.isnan(self.idxhash[idx]): continue e = int(self.idxhash[idx]) if nodeidx != self._ln_client_node(e): continue if self._get_type(aidx) == LayeredNetworkElement.TASK \ and self._get_sched(aidx) != SchedStrategy.REF \ and aidx in demand['thinkt']: self._ln_add_sched(sched_by_layer[e], layer_sn[e], nodeidx, classidx, tgrid, demand['thinkt'][aidx]) # call-service channel (client station of caller for each sync call) if channels in ('both', 'callservt') and self.call_classes_updmap is not None: for row in np.atleast_2d(self.call_classes_updmap): idx, cidx, nodeidx, classidx = (int(row[0]), int(row[1]), int(row[2]), int(row[3])) if np.isnan(self.idxhash[idx]): continue e = int(self.idxhash[idx]) if nodeidx != self._ln_client_node(e): continue if cidx in demand['callservt']: self._ln_add_sched(sched_by_layer[e], layer_sn[e], nodeidx, classidx, tgrid, demand['callservt'][cidx]) return sched_by_layer def _ln_client_node(self, e): """1-based node index of layer e's client node.""" layer = self.ensemble[e] attr = getattr(layer, 'attribute', None) if not isinstance(attr, dict): return None return attr.get('clientIdx') @staticmethod def _ln_add_sched(sched, sn, nodeidx, classidx, tgrid, demand): """Append one rate_sched entry that MODULATES the layer's equilibrium rate by the ratio of the transient demand to its steady-state (end-of-horizon) value: ``effective_rate(t) = nominal*demand(end)/demand(t)``. Passing ``rates = 1/demand(t)`` and ``nominal = 1/demand(end)`` makes the multiplier ``demand(end)/demand(t)``, which is exactly 1 at the horizon end, so the layer relaxes to its unmodified fixed point (endpoint == get_ensemble_avg) regardless of any small mismatch between the transient residence Q/T and the scalar equilibrium demand. During the transient the ratio modulates the rate. """ ist = int(sn.nodeToStation[nodeidx - 1]) if ist < 0: return d = np.asarray(demand, dtype=float).ravel().copy() dend = d[-1] if not (dend > GlobalConstants.FineTol): return # degenerate steady-state demand; skip this channel # clamp the rate multiplier to [dend/cap, dend*cap] so the fluid ODE's tolerated spikes do not destabilize the CTMC propagation. cap = 20.0 d = np.clip(d, dend / cap, dend * cap) sched.append({'station': ist, 'class': classidx - 1, 'tgrid': np.asarray(tgrid, dtype=float).copy(), 'rates': 1.0 / d, 'nominal': 1.0 / dend}) # ---- Majumdar-Woodside robust box bounds for the LQN -------------------- # ================================================================= # Method 'srvn.ph': the activity graph of an entry as a phase-type # server law. # # Each layer is a two-station cycle, Delay('Clients') + Queue(server), # with one closed class per caller task. The sequencing the default # method encodes as routing -- a class per entry, per activity and per # call, plus Fork, Join, Router and ClassSwitch nodes -- is composed # instead into a single phase-type service law per (layer, caller), by # the exact series-parallel reduction of Workflow. # # Twin of the MATLAB @SolverLN/buildLayersPH.m and its siblings, of the # JAR SolverLN *PH methods and of the C++ *_ph members of # solvers/ln/solver_ln.h. See _kb/06-solver-catalog.md (LN section). # ================================================================= def _ph_init_state(self): """Allocate the per-entry law tables of method 'srvn.ph'.""" n = self.lqn.nidx self._ph_wf: List[Any] = [None] * n self._ph_wfhost: List[Any] = [None] * n self._ph_execs: List[Optional[Dict[int, float]]] = [None] * n self._ph_callexecs: List[Optional[Dict[int, float]]] = [None] * n self._ph_hostalpha: List[Any] = [None] * n self._ph_hostT: List[Any] = [None] * n self._ph_hostmean = np.zeros(n) self._ph_entryalpha: List[Any] = [None] * n self._ph_entryT: List[Any] = [None] * n self._ph_entrymean = np.zeros(n) self._ph_entryscv = np.ones(n) self._ph_share = np.zeros(n) self._ph_overlap = np.ones(n) self._ph_setupshare = np.zeros(n) self._ph_xdemand = np.zeros(n) self._ph_ncalls = np.zeros((n, n)) self._ph_calltime = np.zeros((n, n)) self._ph_procresid = np.zeros(n) self._ph_actthinkt = np.zeros(n) self._ph_calltotal = np.zeros(n) self._ph_layer: List[Optional[PHLayer]] = [None] * (self.lqn.nhosts + self.lqn.ntasks) # ================================================================= # Layer construction # ================================================================= def _build_layers_ph(self, flat: bool = False): """Build the ensemble of a PH encoding. FLAT False is method 'srvn.ph': one layer per served element, each a two-station cycle Delay('Clients') + Queue(server), with one closed class per caller task. FLAT True is method 'flat.ph': ONE layer holding a station for every processor and every called task, with the same one closed class per caller task, which now visits each of the servers it uses once per invocation instead of meeting them through surrogate delays. """ lqn = self.lqn nelem = lqn.nhosts + lqn.ntasks if not getattr(self, '_ph_laws_ready', False): self._assert_srvn_ph_supported(flat) # The interlock correction rewrites the populations of the call classes, # which this method does not create: its callers reach the server in one # class each. The setting is turned off on a COPY: options.config is the # very dict the caller passed to the constructor, so writing into it # rewrote the caller's own object, and a config reused across solvers # carried the ph decision into models that never took this method. try: if self.options.config.get('interlocking', False): cfg = type(self.options.config)(self.options.config) cfg['interlocking'] = False self.options.config = cfg except (AttributeError, TypeError): pass # A preceding probe has already composed the per-entry workflows; they do # not depend on the iterate, so they are not rebuilt here. if not getattr(self, '_ph_laws_ready', False): self._ph_init_laws() # Seed the fixed point with the static demands, then compose the entry laws self.residt = np.zeros(lqn.nidx) self.servt = np.zeros(lqn.nidx) self.callservt = np.zeros(lqn.ncalls) self.callresidt = np.zeros(lqn.ncalls) self.tput = np.zeros(lqn.nidx) self.util = np.zeros(lqn.nidx) self.thinkt = np.zeros(lqn.nidx) for aidx in range(lqn.ashift, lqn.ashift + lqn.nacts): self.residt[aidx] = self._ph_hostdem_mean(aidx) for cidx in range(lqn.ncalls): ct = self._ph_call_type(cidx) if ct in (_SYNC, _ASYNC): eidx = int(lqn.callpair[cidx, 1]) v = self._ph_call_mean(cidx) * self._ph_hostmean[eidx] self.callservt[cidx] = v self.callresidt[cidx] = v self._ph_compose_entry_laws() self.ensemble = [None] * nelem self.solvers = [None] * nelem if flat: # ONE subnetwork holding every processor and every called task servers = self._build_ph_flat_layer() self.ensemble = [self.ensemble[0]] self.solvers = [self.solvers[0]] self.idxhash = np.full(nelem, np.nan) for idx in servers: self.idxhash[idx] = 0 self.nlayers = 1 self.layer_has_region = [False] self.layer_chains = [None] # every server resolves to the single flat layer, which is at once # the host layer and the task layer self.hostLayerIndices = [0] self.taskLayerIndices = [0] self._update_layers_ph(0) self.servt_classes_updmap = self._flatten_map(self._ph_servt_map) self.thinkt_classes_updmap = self._flatten_map(self._ph_thinkt_map) self.actthinkt_classes_updmap = self._flatten_map([[]]) self.arvproc_classes_updmap = self._flatten_map(self._ph_arvproc_map) self.call_classes_updmap = self._flatten_map(self._ph_call_map) self.route_prob_updmap = self._flatten_map([[]]) self.unique_route_prob_updmap = np.array([]) return # One subnetwork per processor for hidx in range(lqn.nhosts): if self.ignore[hidx]: continue callers = self._ph_host_layer_callers(hidx) if not callers: continue self._build_ph_layer(hidx, callers, True) # One subnetwork per called task for t in range(lqn.ntasks): tidx = lqn.tshift + t if self.ignore[tidx] or self._is_ref_task(tidx): continue callers = self._ph_task_layer_callers(tidx) if not callers and not self._ph_async_calls_into(tidx): continue self._build_ph_layer(tidx, callers, False) # Compact the ensemble and index it empty = [i for i, e in enumerate(self.ensemble) if e is None] self.solvers = [sv for i, sv in enumerate(self.solvers) if i not in empty] self.ensemble = [e for e in self.ensemble if e is not None] self.idxhash = np.full(nelem, np.nan) layer_idx = 0 for orig in range(nelem): if orig not in empty: self.idxhash[orig] = layer_idx layer_idx += 1 self.nlayers = len(self.ensemble) self.layer_has_region = [False] * self.nlayers self.layer_chains = [None] * self.nlayers self.hostLayerIndices = [int(self.idxhash[h]) for h in range(lqn.nhosts) if not np.isnan(self.idxhash[h])] self.taskLayerIndices = [int(self.idxhash[lqn.tshift + t]) for t in range(lqn.ntasks) if not np.isnan(self.idxhash[lqn.tshift + t])] # install the initial laws, so that iteration 1 sees the seeded demands # rather than the placeholders the stations were created with self._update_layers_ph(0) # The maps carry no law of this method -- update_layers composes them # instead -- but post() resets the layers they name, so they are filled self.servt_classes_updmap = self._flatten_map(self._ph_servt_map) self.thinkt_classes_updmap = self._flatten_map(self._ph_thinkt_map) self.actthinkt_classes_updmap = self._flatten_map([[]]) self.arvproc_classes_updmap = self._flatten_map(self._ph_arvproc_map) self.call_classes_updmap = self._flatten_map(self._ph_call_map) self.route_prob_updmap = self._flatten_map([[]]) self.unique_route_prob_updmap = np.array([]) def _build_ph_flat_layer(self) -> List[int]: """ Build the ONE layer of method 'flat.ph': a client delay plus a station for every processor and every called task. A caller task is one closed class, and it visits each server it uses ONCE per invocation, carrying there the composed law of the demand it places on that server -- the same law method 'srvn.ph' installs in the server's own layer. What changes is that the servers now contend inside one network instead of seeing each other through surrogate delays, so the client delay keeps only the think times and whatever of the cycle this model does not hold. That is the whole difference between the two encodings of the PH composition, and it is why the reconstruction passes are shared verbatim. """ from ...lang.classes import ClosedClass, OpenClass from ...lang.network import Network from ...lang.nodes import Delay, Queue, Sink, Source from .solver_ln import OptionsDict lqn = self.lqn servers = self._ph_flat_server_set() model = Network('FlatPH') try: model.setChecks(False) except AttributeError: pass model.attribute = OptionsDict({ 'hosts': [], 'tasks': [], 'entries': [], 'activities': [], 'calls': [], 'clientIdx': 1, 'serverIdx': 2, 'sourceIdx': None, 'cacheIdx': None, 'iscachelayer': False, }) client_delay = Delay(model, 'Clients') srv = [] station_of = {} model.attribute['hostStations'] = [] model.attribute['taskStations'] = [] server_idx_of = {} for idx in servers: ishost = idx <= lqn.nhosts st = Queue(model, self._get_hashname(idx), self._get_sched(idx)) st.set_number_of_servers(self._get_nservers(idx)) st.attribute = OptionsDict({'ishost': ishost, 'idx': idx}) srv.append(st) stn = len(model.get_nodes()) station_of[idx] = stn server_idx_of[idx] = stn if ishost: model.attribute['hostStations'].append(stn) model.attribute['hosts'].append([None, stn]) else: model.attribute['taskStations'].append(stn) model.attribute['tasks'].append([None, stn]) model.attribute['serverIdxOf'] = server_idx_of model.attribute['server_stations'] = srv model.attribute['nreplicas'] = 1 # the scalar fallback of the station lookup, which no served element reaches model.attribute['serverIdx'] = station_of[servers[0]] # Callers of each server, and the union of them, which becomes the class set callers_of = {} all_callers = [] for idx in servers: cs = (self._ph_host_layer_callers(idx) if idx <= lqn.nhosts else self._ph_task_layer_callers(idx)) callers_of[idx] = list(cs) for c in cs: if c not in all_callers: all_callers.append(c) all_callers.sort() # One closed class per caller task class_of_caller = {} npop = 0.0 for c in all_callers: # _ph_flat_server_set has refused every replicated element, so the # per-replica reduction the srvn builder makes is the identity here njobs = self._ph_layer_population(servers[0], c, 1) cls = ClosedClass(model, self._get_hashname(c), int(njobs), client_delay) cls.setReferenceClass(True) cls.attribute = [LayeredNetworkElement.TASK, c] class_of_caller[c] = cls.get_index() model.attribute['tasks'].append([cls.get_index(), c]) npop += njobs client_delay.set_service(cls, Exp.fitMean(max(GlobalConstants.FineTol, self._ref_think_mean(c)))) # A station this caller never reaches must say so with Disabled, NOT # with a tiny placeholder law. An FCFS station carries ONE service law # across its classes, so a placeholder is not inert there: it is mixed # into the multiserver correction and invents waiting where there is # none. Under 'srvn.ph' the question never arises, since every class of # a layer visits that layer's single server. for st in srv: st.set_service(cls, Disabled()) for si, idx in enumerate(servers): if c not in callers_of[idx]: continue srv[si].set_service(cls, Exp.fitMean(GlobalConstants.FineTol)) self.njobs[c, idx] = njobs self._ph_thinkt_map[idx].append([idx, c, 1, cls.get_index()]) self._ph_servt_map[idx].append([idx, c, station_of[idx], cls.get_index()]) # Open classes: entry arrivals on a processor station, async calls on a task one open_arrivals_of = {idx: [] for idx in servers} source_station = None sink_station = None for si, hidx in enumerate(servers): # 0-based: anything at or past nhosts is a TASK, not a host. The # MATLAB twin (buildLayersPH.m:476) is 1-based, where `>` is right. if hidx >= lqn.nhosts: continue for c in callers_of[hidx]: # A task no other task calls has no task station, so the think-time # closure never gives its caller class a surrogate delay: the class # cycles against an Immediate one and an open stream on top of it # doubles the load. The chain is the representation that honours the # thread pool, so it is kept and closed on the arrival rate instead. if self._ph_open_arrival_only(c): continue for eidx in self._ph_entries_of(c): if not self._ph_has_open_arrival(eidx): continue if source_station is None: model.attribute['sourceIdx'] = len(model.get_nodes()) + 1 source_station = Source(model, 'Source') sink_station = Sink(model, 'Sink') ocls = OpenClass(model, self._get_hashname(eidx) + '.Open', 0) ocls.attribute = [LayeredNetworkElement.ENTRY, eidx] source_station.set_arrival(ocls, lqn.arrival[eidx]) client_delay.set_service(ocls, Disabled()) # Disabled, not a placeholder, at every station this stream misses for st in srv: st.set_service(ocls, Disabled()) srv[si].set_service(ocls, Exp.fitMean(max(GlobalConstants.FineTol, self._ph_hostmean[eidx]))) open_arrivals_of[hidx].append((ocls.get_index(), eidx)) model.attribute['entries'].append([ocls.get_index(), eidx]) self._ph_arvproc_map[hidx].append([hidx, -eidx, model.attribute['sourceIdx'], ocls.get_index()]) for si, tidx in enumerate(servers): if tidx <= lqn.nhosts: continue for cidx in self._ph_async_calls_into(tidx): if source_station is None: model.attribute['sourceIdx'] = len(model.get_nodes()) + 1 source_station = Source(model, 'Source') sink_station = Sink(model, 'Sink') ocls = OpenClass(model, call_hashname(lqn, cidx), 0) ocls.attribute = [LayeredNetworkElement.CALL, cidx] source_station.set_arrival(ocls, Immediate()) client_delay.set_service(ocls, Disabled()) # Disabled, not a placeholder, at every station this stream misses for st in srv: st.set_service(ocls, Disabled()) eidx = int(lqn.callpair[cidx, 1]) srv[si].set_service(ocls, Exp.fitMean(max(GlobalConstants.FineTol, self._ph_entrymean[eidx]))) open_arrivals_of[tidx].append((ocls.get_index(), -cidx)) model.attribute['calls'].append([ocls.get_index(), cidx, int(lqn.callpair[cidx, 0]), eidx]) self._ph_arvproc_map[tidx].append([tidx, cidx, model.attribute['sourceIdx'], ocls.get_index()]) self._ph_call_map[tidx].append([tidx, cidx, station_of[tidx], ocls.get_index()]) if source_station is not None: for jc in model.classes: if isinstance(jc, ClosedClass): source_station.set_arrival(jc, Disabled()) # Routing: one visit per server the caller uses, in server order. The number # of calls is carried by the service law, not by a visit ratio, so no arc # ever moves. P = model.init_routing_matrix() for c in all_callers: cls = model.classes[class_of_caller[c] - 1] prev = client_delay visited = False for si, idx in enumerate(servers): if c not in callers_of[idx]: continue P.set(cls, cls, prev, srv[si], 1.0) prev = srv[si] visited = True if visited: P.set(cls, cls, prev, client_delay, 1.0) for si, idx in enumerate(servers): for (k, _tag) in open_arrivals_of[idx]: cls = model.classes[k - 1] P.set(cls, cls, source_station, srv[si], 1.0) P.set(cls, cls, srv[si], sink_station, 1.0) model.link(P) for idx in servers: L = PHLayer() L.idx = idx L.ishost = idx <= lqn.nhosts L.callers = list(callers_of[idx]) L.class_of_caller = class_of_caller L.nreplicas = 1 L.qstations = [station_of[idx]] L.svcmean_by_class = {} L.open_arrivals = list(open_arrivals_of[idx]) L.npop = max(npop, 1.0) self._ph_layer[idx] = L self.ensemble[0] = model solver = self.solver_factory(model) self._assert_layer_solver_supports_model(solver, model, servers[0]) self._detach_layer_config(solver) self._silence_layer_solver(solver) self.solvers[0] = solver return servers def _ph_flat_server_set(self) -> List[int]: """ Processors and called tasks that become stations of the flat layer. The set is the elements the srvn builder would have given a layer of their own, so 'flat.ph' and 'srvn.ph' place the SAME stations and differ only in how many networks hold them. The refusals are those of _flat_server_set, since they are properties of the squashing and not of the encoding: each of these carries per-layer state that one submodel cannot hold. """ lqn = self.lqn nelem = lqn.nhosts + lqn.ntasks for i in range(nelem): if float(lqn.repl[0, i]) > 1: raise ValueError("method='flat.ph' does not support replicated processors or " "tasks, whose replicas need a submodel each. Use " "method='srvn.ph'.") iscache = getattr(lqn, 'iscache', None) if iscache is not None and np.any(np.asarray(iscache).ravel()[:nelem]): raise ValueError("method='flat.ph' does not support cache tasks. Use " "method='default'.") hs = getattr(lqn, 'hassetup', None) if hs is not None and np.any(np.asarray(hs).ravel()[:nelem]): raise ValueError("method='flat.ph' does not support setup tasks, whose powered-down " "threads are per-layer state. Use method='srvn.ph'.") servers = [] for hidx in range(lqn.nhosts): if self.ignore[hidx]: continue if not self._get_tasks_of_host(hidx): continue if not self._ph_host_layer_callers(hidx): continue servers.append(hidx) for t in range(lqn.ntasks): tidx = lqn.tshift + t if self.ignore[tidx] or self._is_ref_task(tidx): continue if not self._ph_task_layer_callers(tidx) and not self._ph_async_calls_into(tidx): continue servers.append(tidx) if not servers: raise ValueError("method='flat.ph' found no server: the model has no processor " "with tasks.") return servers def _build_ph_layer(self, idx: int, callers: List[int], ishost: bool): """Build the two-station layer of server element IDX.""" from ...lang.classes import ClosedClass, OpenClass from ...lang.network import Network from ...lang.nodes import Delay, Queue, Sink, Source from .solver_ln import OptionsDict lqn = self.lqn model = Network(self._get_hashname(idx)) try: model.setChecks(False) except AttributeError: pass model.attribute = OptionsDict({ 'hosts': [], 'tasks': [], 'entries': [], 'activities': [], 'calls': [], 'clientIdx': 1, 'serverIdx': 2, 'sourceIdx': None, 'cacheIdx': None, 'iscachelayer': False, }) client_delay = Delay(model, 'Clients') nreplicas = self._ph_replica_count(idx, callers, ishost) srv = [] for m in range(1, nreplicas + 1): nm = self._get_hashname(idx) if m == 1 else (self._get_hashname(idx) + '.' + str(m)) st = Queue(model, nm, self._get_sched(idx)) st.set_number_of_servers(self._get_nservers(idx)) st.attribute = OptionsDict({'ishost': ishost, 'idx': idx}) srv.append(st) server_station_idx = len(model.get_nodes()) - nreplicas + 1 model.attribute['serverIdxOf'] = {idx: server_station_idx} model.attribute['server_stations'] = srv model.attribute['nreplicas'] = nreplicas if ishost: model.attribute['hostStations'] = [server_station_idx] model.attribute['taskStations'] = [] model.attribute['hosts'].append([None, server_station_idx]) else: model.attribute['hostStations'] = [] model.attribute['taskStations'] = [server_station_idx] model.attribute['tasks'].append([None, server_station_idx]) # --- closed class per caller task L = PHLayer() L.idx, L.ishost, L.callers, L.nreplicas = idx, ishost, list(callers), nreplicas L.qstations = [1 + m for m in range(1, nreplicas + 1)] for c in callers: njobs = self._ph_layer_population(idx, c, nreplicas) self.njobs[c, idx] = njobs cls = ClosedClass(model, self._get_hashname(c), int(njobs), client_delay) cls.setReferenceClass(True) cls.attribute = [LayeredNetworkElement.TASK, c] L.class_of_caller[c] = cls.get_index() model.attribute['tasks'].append([cls.get_index(), c]) client_delay.set_service(cls, Exp.fitMean(max(GlobalConstants.FineTol, self._ref_think_mean(c)))) for st in srv: st.set_service(cls, Exp.fitMean(GlobalConstants.FineTol)) # every layer must be refreshed after a law change: post() resets the # layers named by the think-time map self._ph_thinkt_map[idx].append([idx, c, 1, cls.get_index()]) self._ph_servt_map[idx].append([idx, c, server_station_idx, cls.get_index()]) # --- open classes: entry arrivals on a host layer, async calls on a task layer source_station = None sink_station = None if ishost: for c in callers: # A task no other task calls has no task layer, so update_think_times # never gives its caller class a surrogate delay: the class cycles # against an Immediate one and an open stream on top of it doubles the # load. The chain is the representation that honours the thread pool, # so it is kept and closed on the arrival rate instead. if self._ph_open_arrival_only(c): continue for eidx in self._ph_entries_of(c): if not self._ph_has_open_arrival(eidx): continue if source_station is None: model.attribute['sourceIdx'] = len(model.get_nodes()) + 1 source_station = Source(model, 'Source') sink_station = Sink(model, 'Sink') ocls = OpenClass(model, self._get_hashname(eidx) + '.Open', 0) ocls.attribute = [LayeredNetworkElement.ENTRY, eidx] source_station.set_arrival(ocls, lqn.arrival[eidx]) client_delay.set_service(ocls, Disabled()) for st in srv: st.set_service(ocls, Exp.fitMean(max(GlobalConstants.FineTol, self._ph_hostmean[eidx]))) L.open_arrivals.append((ocls.get_index(), eidx)) model.attribute['entries'].append([ocls.get_index(), eidx]) self._ph_arvproc_map[idx].append([idx, -eidx, model.attribute['sourceIdx'], ocls.get_index()]) else: for cidx in self._ph_async_calls_into(idx): if source_station is None: model.attribute['sourceIdx'] = len(model.get_nodes()) + 1 source_station = Source(model, 'Source') sink_station = Sink(model, 'Sink') ocls = OpenClass(model, call_hashname(lqn, cidx), 0) ocls.attribute = [LayeredNetworkElement.CALL, cidx] source_station.set_arrival(ocls, Immediate()) client_delay.set_service(ocls, Disabled()) eidx = int(lqn.callpair[cidx, 1]) for st in srv: st.set_service(ocls, Exp.fitMean(max(GlobalConstants.FineTol, self._ph_entrymean[eidx]))) L.open_arrivals.append((ocls.get_index(), -cidx)) model.attribute['calls'].append([ocls.get_index(), cidx, int(lqn.callpair[cidx, 0]), eidx]) self._ph_arvproc_map[idx].append([idx, cidx, model.attribute['sourceIdx'], ocls.get_index()]) self._ph_call_map[idx].append([idx, cidx, server_station_idx, ocls.get_index()]) if source_station is not None: for jc in model.classes: if isinstance(jc, ClosedClass): source_station.set_arrival(jc, Disabled()) # Routing: one visit to the server per client cycle. The number of calls is # carried by the service law, not by a visit ratio, so no arc ever changes P = model.init_routing_matrix() for c in callers: cls = model.classes[L.class_of_caller[c] - 1] for st in srv: P.set(cls, cls, client_delay, st, 1.0 / nreplicas) P.set(cls, cls, st, client_delay, 1.0) for (k, _tag) in L.open_arrivals: cls = model.classes[k - 1] for st in srv: P.set(cls, cls, source_station, st, 1.0 / nreplicas) P.set(cls, cls, st, sink_station, 1.0) model.link(P) L.svcmean_by_class = {} np_pop = 0.0 for c in callers: v = self.njobs[c, idx] if np.isfinite(v) and v > 0: np_pop += v L.npop = max(np_pop, 1.0) self._ph_layer[idx] = L self.ensemble[idx] = model solver = self.solver_factory(model) self._assert_layer_solver_supports_model(solver, model, idx) self._detach_layer_config(solver) self._silence_layer_solver(solver) self.solvers[idx] = solver # ================================================================= # Composition of the entry laws # ================================================================= def _ph_init_laws(self): """Build the per-entry workflows and the iteration-invariant processor law.""" lqn = self.lqn nelem = lqn.nhosts + lqn.ntasks self._ph_servt_map = [[] for _ in range(nelem)] self._ph_thinkt_map = [[] for _ in range(nelem)] self._ph_arvproc_map = [[] for _ in range(nelem)] self._ph_call_map = [[] for _ in range(nelem)] for e in range(lqn.nentries): eidx = lqn.eshift + e tidx = int(lqn.parent[eidx, 0]) if self.ignore[tidx]: continue ew = entry_workflow(self.model, lqn, eidx, True) self._ph_wf[eidx] = ew.wf self._ph_execs[eidx] = ew.execs self._ph_callexecs[eidx] = ew.callexecs eh = entry_workflow(self.model, lqn, eidx, False) self._ph_wfhost[eidx] = eh.wf # the processor sees the WORK of concurrent branches, not their elapsed # time, so the host law serialises an AND fork -- see serial_law alpha, T = serial_law(self._ph_wfhost[eidx]) self._ph_hostalpha[eidx] = alpha self._ph_hostT[eidx] = T self._ph_hostmean[eidx] = ph_moments(alpha, T)[0] # until the first iteration reports throughputs, a task splits its # requests evenly over its entries for t in range(lqn.ntasks): tidx = lqn.tshift + t entries = self._ph_entries_of(tidx) for eidx in entries: self._ph_share[eidx] = 1.0 / len(entries) def _ph_compose_entry_laws(self): """ Recompose the entry service laws from the current fixed-point iterate. The composed mean is NOT the sum of the leaf means when the graph forks: the branches of an AND fork overlap, and the entry finishes with the last of them. The ratio of the two, the overlap factor, is what the caller-side aggregates are scaled by, so that the pieces of a cycle still add up to the cycle. """ lqn = self.lqn entry_setup_share = np.zeros(lqn.nidx) self._ph_overlap[:] = 1.0 for e in range(lqn.nentries): eidx = lqn.eshift + e if self._ph_wf[eidx] is None: continue w = self._ph_wf[eidx] ex = self._ph_execs[eidx] entrysum = 0.0 procsum = 0.0 for aidx in self._ph_acts_of(eidx): m = self.residt[aidx] + self._ph_act_think_mean(aidx) procsum += ex[aidx] * m w.setActivityDemandMean(self._ph_name(aidx), max(m, GlobalConstants.FineTol)) for cidx in lqn.callsof.get(aidx, []): if self._ph_call_type(cidx) != _SYNC: continue w.setActivityDemand(call_hashname(lqn, cidx), self._ph_call_burst_law(cidx)) m += self.callservt[cidx] entrysum += ex[aidx] * m alpha, T = w.refreshPH() alpha = np.asarray(alpha, dtype=float).reshape(1, -1) T = np.asarray(T, dtype=float) m1, scv = ph_moments(alpha, T) # All activities of an entry run on ONE processor, so the branches of an # AND fork cannot overlap the processor residence they request: the # composed maximum is a lower bound on the entry service time only above # that total. Where it falls below, the law is rescaled in time to it, # which keeps its shape, its SCV and its order. if procsum > m1 + GlobalConstants.FineTol: T = T * (m1 / procsum) m1 = procsum # A SetupTask powers a thread down when it goes idle, so a request may # find it off and pay a cold start before the entry runs at all. The # setup is not part of the activity graph and never enters the # series-parallel reduction: it is prefixed to the composed law # afterwards, as the mixture p*(setup THEN entry) + (1-p)*entry, which # is again phase-type. See _setup_prob for p. p = self._ph_setup_prob(eidx) if p > GlobalConstants.FineTol: sl = self._ph_setup_law(int(lqn.parent[eidx, 0])) if sl is not None: ac, Tc = Workflow._composeSerial(sl[0], sl[1], alpha, T) alpha, T = Workflow._composeMixture([ac, alpha], [Tc, T], np.array([p, 1 - p])) alpha = np.asarray(alpha, dtype=float).reshape(1, -1) T = np.asarray(T, dtype=float) m1, scv = ph_moments(alpha, T) # The share of the entry law that is cold start and not work. The # surrogate-delay closure measures a thread's cycle in WORK, so it # must not read a station utilization that this has inflated -- # see update_think_times. entry_setup_share[eidx] = ( p * self._setup_dist_mean(getattr(lqn, 'setuptime', None), int(lqn.parent[eidx, 0])) / max(m1, GlobalConstants.FineTol)) self._ph_entryalpha[eidx] = alpha self._ph_entryT[eidx] = T self._ph_entrymean[eidx] = m1 self._ph_entryscv[eidx] = scv if entrysum > GlobalConstants.FineTol: self._ph_overlap[eidx] = min(1.0, m1 / entrysum) # Per task, the share-weighted fraction of its station service that is # cold start rather than work. self._ph_setupshare[:] = 0.0 for t in range(lqn.ntasks): tidx = lqn.tshift + t if self.ignore[tidx]: continue for eidx in self._ph_entries_of(tidx): self._ph_setupshare[tidx] += self._ph_share[eidx] * entry_setup_share[eidx] # Expected number of calls per invocation, and the caller-side aggregates self._ph_ncalls[:] = 0.0 self._ph_calltime[:] = 0.0 self._ph_procresid[:] = 0.0 self._ph_actthinkt[:] = 0.0 self._ph_calltotal[:] = 0.0 for t in range(lqn.ntasks): tidx = lqn.tshift + t if self.ignore[tidx]: continue for eidx in self._ph_entries_of(tidx): w = self._ph_share[eidx] if w <= 0 or self._ph_execs[eidx] is None: continue ex = self._ph_execs[eidx] r = self._ph_overlap[eidx] for aidx in self._ph_acts_of(eidx): self._ph_procresid[tidx] += w * r * ex[aidx] * self.residt[aidx] self._ph_actthinkt[tidx] += w * r * ex[aidx] * self._ph_act_think_mean(aidx) for cidx in lqn.callsof.get(aidx, []): if self._ph_call_type(cidx) != _SYNC: continue tgte = int(lqn.callpair[cidx, 1]) tgtt = int(lqn.parent[tgte, 0]) # the COUNT of calls does not change with the overlap, only # the time the caller is held by them self._ph_ncalls[tidx, tgte] += w * ex[aidx] * self._ph_call_mean(cidx) self._ph_calltime[tidx, tgtt] += w * r * ex[aidx] * self.callservt[cidx] self._ph_calltotal[tidx] += w * r * ex[aidx] * self.callservt[cidx] def _ph_call_burst_law(self, cidx: int): """ Law of the total time one execution of the issuing activity spends in call CIDX: the geometric compound, of mean callproc_mean, of the response law of the called entry. The response law is fitted to the response time reported by the callee's layer and to the SCV of the callee's own composed law, so no extra solver output is needed. """ m = self._ph_call_mean(cidx) eidx = int(self.lqn.callpair[cidx, 1]) if m <= GlobalConstants.FineTol: return Immediate() R = self.callservt[cidx] / m scv = self._ph_entryscv[eidx] if not np.isfinite(scv) or scv <= GlobalConstants.FineTol: scv = 1.0 base = APH.fitMeanAndSCV(max(R, GlobalConstants.FineTol), scv) alpha, T = Workflow._composeLoopGeometric(_alpha_of(base), _subgen_of(base), m) if Workflow.isAcyclicGenerator(T): return APH(alpha, T) return PH(alpha, T) # ================================================================= # Pushing the composed laws into the layers # ================================================================= def _update_layers_ph(self, it: int): """ Push the composed laws into the layers. A layer of this method carries no routing that depends on the iterate: the number of calls a caller makes is folded into its service law rather than into a visit ratio, so only two laws move per (layer, class) -- the phase-type service law at the server and the mean of the surrogate delay at the client. """ lqn = self.lqn for idx in range(lqn.nhosts + lqn.ntasks): if np.isnan(self.idxhash[idx]) or self._ph_layer[idx] is None: continue L = self._ph_layer[idx] model = self.ensemble[int(self.idxhash[idx])] stations = model.get_stations() client_delay = stations[0] for c in L.callers: k = L.class_of_caller[c] cls = model.classes[k - 1] alpha, T = self._ph_service_law(idx, L.ishost, c) L.svcmean_by_class[k] = ph_moments(alpha, T)[0] law = self._ph_station_law(alpha, T) for st in L.qstations: stations[st - 1].set_service(cls, law) client_delay.set_service(cls, Exp.fitMean( max(GlobalConstants.FineTol, self._ph_delay_mean(idx, c)))) for (k, tag) in L.open_arrivals: cls = model.classes[k - 1] if tag > 0: # entry arrival: the processor demand law of the entry is static L.svcmean_by_class[k] = self._ph_hostmean[tag] continue cidx = -tag eidx = int(lqn.callpair[cidx, 1]) L.svcmean_by_class[k] = self._ph_entrymean[eidx] law = self._ph_station_law(self._ph_entryalpha[eidx], self._ph_entryT[eidx]) for st in L.qstations: stations[st - 1].set_service(cls, law) aidx = int(lqn.callpair[cidx, 0]) rate = self.tput[aidx] * self._ph_call_mean(cidx) if not np.isfinite(rate) or rate <= GlobalConstants.FineTol: rate = GlobalConstants.FineTol model.get_nodes()[model.attribute['sourceIdx'] - 1].set_arrival( cls, Exp.fitRate(rate)) def _ph_service_law(self, idx: int, ishost: bool, c: int): """Law of the demand caller C places on the server of layer IDX per invocation.""" lqn = self.lqn if ishost: # mixture over the entries of C, weighted by their share of its requests alphas, Ts, probs = [], [], [] for eidx in self._ph_entries_of(c): if self._ph_hostT[eidx] is None or self._ph_share[eidx] <= 0: continue alphas.append(self._ph_hostalpha[eidx]) Ts.append(self._ph_hostT[eidx]) probs.append(self._ph_share[eidx]) if not alphas: return self._ph_immediate_law() probs = np.asarray(probs, dtype=float) probs = probs / probs.sum() return Workflow._composeMixture(alphas, Ts, probs) # task layer: the total demand is the sum, over the entries of the server, of # a geometric compound of the entry law of mean equal to the number of calls alpha = None T = None for eidx in self._ph_entries_of(idx): n = self._ph_ncalls[c, eidx] if n <= GlobalConstants.FineTol or self._ph_entryT[eidx] is None: continue a2, T2 = Workflow._composeLoopGeometric(self._ph_entryalpha[eidx], self._ph_entryT[eidx], n) if alpha is None: alpha, T = a2, T2 else: alpha, T = Workflow._composeSerial(alpha, T, a2, T2) if alpha is None: return self._ph_immediate_law() return alpha, T @staticmethod def _ph_immediate_law(): return np.array([[1.0]]), np.array([[-GlobalConstants.Immediate]]) def _ph_delay_mean(self, idx: int, c: int) -> float: """ Mean time a thread of caller C spends away from the stations of the model that holds server IDX, per invocation: idle, plus whatever of its cycle that model does not hold as a station of its own. This is ONE closure for both layerings. Under 'srvn.ph' the model holds a single server, so a host layer charges the whole call burst to the delay and a task layer charges the caller's processor plus every other callee. Under 'flat.ph' the model holds every server, and only the think times are left. Every term is SUMMED in rather than obtained by subtracting from a total. That subtraction cancels catastrophically once a call time is large: a caller whose only callee is this server has the two terms equal, and 7 + 1.4e47 - 1.4e47 is 0, not 7, because the think time falls below the ULP of the call time. The layer then sees a client delay of zero, saturates, reports a residence time that inflates the very call time that caused the cancellation, and the fixed point runs away -- lqn_sockshop reached RespT 1.4e47 this way. """ lqn = self.lqn z = self.thinkt[c] + self._ref_think_mean(c) if not np.isfinite(z) or z < 0: z = 0.0 z += self._ph_actthinkt[c] # the caller's own processor residence, unless this model holds it hidx = int(lqn.parent[c, 0]) if c < len(lqn.parent) else -1 if not self._ph_served_here(idx, hidx): z += self._ph_procresid[c] # and the time spent at every callee this model does not hold for t in range(lqn.ntasks): tidx = lqn.tshift + t if self._ph_served_here(idx, tidx): continue z += float(self._ph_calltime[c][tidx]) if not np.isfinite(z) or z < 0: z = GlobalConstants.FineTol return float(z) def _ph_served_here(self, idx: int, elem: int) -> bool: """ True when LQN element ELEM is a station of the same model that holds server IDX. Under 'srvn.ph' that is ELEM == IDX, since each server has a layer of its own; under 'flat.ph' it is every server of the one network. """ if elem is None or elem < 0 or elem >= len(self.idxhash): return False if idx < 0 or idx >= len(self.idxhash): return False a, b = self.idxhash[elem], self.idxhash[idx] return bool(np.isfinite(a) and np.isfinite(b) and a == b) def _ph_station_law(self, alpha, T): """ Station law of a composed workflow. A geometric loop over a body of two or more phases closes a cycle in the phase graph, and a cyclic generator is a PH and not an APH: no layer solver declares PH, so such a law is reduced to the APH with the SAME first two moments. AMVA and NC read exactly those two, so the reduction is lossless for them and is a two-moment fit for the phase-aware layer solvers. """ if Workflow.isAcyclicGenerator(T): return APH(alpha, T) m1, scv = ph_moments(alpha, T) return APH.fitMeanAndSCV(m1, scv) # ================================================================= # Metric reconstruction # ================================================================= def _update_metrics_ph(self, it: int): """ Reconstruct the LQN metrics. A layer of this method reports one row per caller task, not one per entry, activity and call, so the per-element quantities the rest of SolverLN reads -- servt, residt, callservt, callresidt, tput -- are recovered analytically from the series-parallel weights of the entry workflows. The split is conservative by construction. A station reports a residence time R per visit against a service law of mean S, so the queueing inflation R/S is attributed to every leaf of that visit in proportion to its own mean: the pieces sum back to R exactly. """ lqn = self.lqn n = lqn.nidx self.servt = np.zeros(n) self.residt = np.zeros(n) self.callservt = np.zeros(lqn.ncalls) self.callresidt = np.zeros(lqn.ncalls) infl_num = np.zeros(n) infl_den = np.zeros(n) task_tput = np.zeros(n) open_tput = np.zeros(n) # Host layers: the queueing inflation of the processor demand for hidx in range(lqn.nhosts): if np.isnan(self.idxhash[hidx]) or self._ph_layer[hidx] is None: continue L = self._ph_layer[hidx] res = self.results[-1][int(self.idxhash[hidx])] if not res: continue npop = self._ph_layer_pop(L, hidx) for c in L.callers: k = L.class_of_caller[c] kc = k - 1 # result matrices index classes from zero X = self._ph_sum_over(res['TN'], L.qstations, kc) R = self._ph_residence(self._ph_sum_over(res['QN'], L.qstations, kc), X, res['RN'][L.qstations[0] - 1, kc]) f = self._ph_inflation_of(R, L.svcmean_by_class.get(k, 0.0), npop) if not np.isfinite(X) or X < 0: X = 0.0 # TOTAL over the replicas. The processor layer of a replicated element # models ONE representative replica, so X is one replica's rate and the # element's own rate is REPL times it. The matching per replica quantity # is xdemand, which the think-time closure divides down for the same reason. task_tput[c] += self._ph_repl(c) * X for eidx in self._ph_entries_of(c): w = max(self._ph_share[eidx], 0.0) * X infl_num[eidx] += w * f infl_den[eidx] += w for (k, tag) in L.open_arrivals: if tag <= 0: continue # an async call is served in the task layer, not here eidx = tag kc = k - 1 X = self._ph_sum_over(res['TN'], L.qstations, kc) if not np.isfinite(X) or X <= 0: continue f = self._ph_inflation_of( self._ph_residence(self._ph_sum_over(res['QN'], L.qstations, kc), X, res['RN'][L.qstations[0] - 1, kc]), L.svcmean_by_class.get(k, 0.0), npop) infl_num[eidx] += X * f infl_den[eidx] += X open_tput[eidx] += X task_tput[int(lqn.parent[eidx, 0])] += X for e in range(lqn.nentries): eidx = lqn.eshift + e f = 1.0 if infl_den[eidx] > GlobalConstants.FineTol: f = infl_num[eidx] / infl_den[eidx] if not np.isfinite(f) or f < 1: f = 1.0 # a residence time cannot fall below the demand it contains for aidx in self._ph_acts_of(eidx): self.residt[aidx] = f * self._ph_hostdem_mean(aidx) # Task layers: the response time of every call relw = np.zeros(n) for t in range(lqn.ntasks): tidx = lqn.tshift + t if np.isnan(self.idxhash[tidx]) or self._ph_layer[tidx] is None: continue L = self._ph_layer[tidx] res = self.results[-1][int(self.idxhash[tidx])] if not res: continue npop = self._ph_layer_pop(L, tidx) for c in L.callers: k = L.class_of_caller[c] kc = k - 1 X = self._ph_sum_over(res['TN'], L.qstations, kc) if not np.isfinite(X) or X < 0: X = 0.0 g = self._ph_inflation_of( self._ph_residence(self._ph_sum_over(res['QN'], L.qstations, kc), X, res['RN'][L.qstations[0] - 1, kc]), L.svcmean_by_class.get(k, 0.0), npop) for cidx in self._ph_sync_calls_between(c, tidx): eidx = int(lqn.callpair[cidx, 1]) v = self._ph_call_mean(cidx) * g * self._ph_entrymean[eidx] self.callservt[cidx] = v self.callresidt[cidx] = v for eidx in self._ph_entries_of(tidx): relw[eidx] += X * self._ph_ncalls[c, eidx] for (k, tag) in L.open_arrivals: if tag >= 0: continue cidx = -tag eidx = int(lqn.callpair[cidx, 1]) X = self._ph_sum_over(res['TN'], L.qstations, k - 1) R = res['RN'][L.qstations[0] - 1, k - 1] if np.isfinite(R) and R > 0: v = R * self._ph_call_mean(cidx) self.callservt[cidx] = v self.callresidt[cidx] = v if np.isfinite(X) and X > 0: relw[eidx] += X # Entry shares and throughputs for t in range(lqn.ntasks): tidx = lqn.tshift + t entries = self._ph_entries_of(tidx) if not entries: continue # How the requests SPLIT over the entries is a flow-balance question, and # is answered at the task layer: a caller class reaches that server once # per invocation of the caller, carrying its whole call burst in its # service law, so the station rate counts caller cycles and the per-entry # rate is that rate times the calls the caller makes. tot = sum(relw[eidx] + open_tput[eidx] for eidx in entries) if tot > GlobalConstants.FineTol: for eidx in entries: self._ph_share[eidx] = (relw[eidx] + open_tput[eidx]) / tot else: for eidx in entries: self._ph_share[eidx] = 1.0 / len(entries) # HOW MANY requests the task completes is a different question, and the # flow-balance total does not answer it: that total is what the callers # DEMAND, not what the task's threads can deliver. A thread cycles through # its host demand AND then through the task think time, and only the # processor layer of the task carries both, so the rate is read there. if task_tput[tidx] > GlobalConstants.FineTol: self.tput[tidx] = task_tput[tidx] else: self.tput[tidx] = tot # no processor layer of its own for eidx in entries: self.tput[eidx] = self.tput[tidx] * self._ph_share[eidx] # The DEMAND is kept apart because it, and not the rate just reported, is # what closes the surrogate delay: normalising the think time by a rate the # same think time produced makes the processor layer self-referential and it # settles wherever it started -- see update_think_times. PER REPLICA, # because the thread count it is paired with there is per replica. nrep = max(1.0, self._ph_repl(tidx)) self._ph_xdemand[tidx] = (tot / nrep) if tot > GlobalConstants.FineTol \ else (self.tput[tidx] / nrep) # Recovery, under-relaxation, and the derived per-element quantities omega = self.relax_omega for aidx in range(lqn.ashift, lqn.ashift + lqn.nacts): v = self.residt[aidx] if (not np.isfinite(v)) and it > 1 and np.isfinite(self.residt_prev[aidx]): v = self.residt_prev[aidx] if omega < 1.0 and it > 1 and not np.isnan(self.residt_prev[aidx]): v = omega * v + (1 - omega) * self.residt_prev[aidx] self.residt[aidx] = v self.residt_prev[aidx] = v for cidx in range(lqn.ncalls): v = self.callservt[cidx] if not np.isfinite(v): v = self.callservt_prev[cidx] if (it > 1 and np.isfinite(self.callservt_prev[cidx])) else 0.0 if omega < 1.0 and it > 1 and not np.isnan(self.callservt_prev[cidx]): v = omega * v + (1 - omega) * self.callservt_prev[cidx] self.callservt[cidx] = v self.callresidt[cidx] = v self.callservt_prev[cidx] = v self.callresidt_prev[cidx] = v if v > 0: self.callservtproc[cidx] = Exp.fitMean(v) # Recompose the entry laws from the iterate just computed. The entry service # time is then the mean of the COMPOSED law and not the sum of the parts: the # branches of an AND fork overlap, so an entry that forks finishes with the # last of its branches and is not charged their sum. self._ph_compose_entry_laws() for e in range(lqn.nentries): eidx = lqn.eshift + e if self._ph_execs[eidx] is None: continue ex = self._ph_execs[eidx] for aidx in self._ph_acts_of(eidx): sa = self.residt[aidx] + self._ph_act_think_mean(aidx) for cidx in lqn.callsof.get(aidx, []): if self._ph_call_type(cidx) == _SYNC: sa += self.callservt[cidx] self.servt[aidx] = sa self.servt_prev[aidx] = sa self.tput[aidx] = self.tput[eidx] * ex[aidx] self.tput_prev[aidx] = self.tput[aidx] # Exp rejects rate 0 here where MATLAB admits it and the JAR clamps it; a null rate is Disabled, as in the default path. self.tputproc[aidx] = Exp.fitRate(self.tput[aidx]) \ if self.tput[aidx] > 0 else Disabled() if sa > 0: self.servtproc[aidx] = Exp.fitMean(sa) self.servt[eidx] = self._ph_entrymean[eidx] self.residt[eidx] = self._ph_entrymean[eidx] if self.servt[eidx] > 0: self.servtproc[eidx] = Exp.fitMean(self.servt[eidx]) # published for inspection, as MATLAB's SolverLN carries entry_servt # on the object; this encoding resolves it entry by entry rather # than by one servtmatrix solve, so it is filled in here if self.entry_servt is None or len(self.entry_servt) < lqn.nidx: self.entry_servt = np.zeros(lqn.nidx) self.entry_servt[eidx] = self.servt[eidx] # ================================================================= # Surrogate delays # ================================================================= def _update_think_times_ph(self, it: int): """ Surrogate delay of every caller. Same closure as update_think_times -- a thread of the task is idle for whatever of its cycle the task's own station does not hold -- but the rate it is normalised by is the INVOCATION rate of the task and not the throughput of its station. Under this method a caller class reaches the server once per invocation of the caller, carrying its whole call burst in its service law, so the station rate counts caller cycles rather than calls and the two differ by the mean number of calls. """ lqn = self.lqn for t in range(lqn.ntasks): tidx = lqn.tshift + t if self.ignore[tidx]: continue # only a reference task's think time separates one request from the next; # on a served task it is not a per-request delay -- see _ref_think_mean ztask = self._ref_think_mean(tidx) if np.isnan(self.idxhash[tidx]): # A task no other task calls but whose entries carry an arrival still # has a cycle: its threads are driven by the stream. build_layers drops # the open class for it precisely so this closure can set the rate. arvrate = self._ph_arrival_rate(tidx) if arvrate > GlobalConstants.FineTol: njobs = self._ph_maxmult(tidx) if not np.isfinite(njobs) or njobs <= 0: njobs = float(np.max(self.njobs[tidx, :])) z = max(GlobalConstants.Zero, njobs / arvrate - self._ph_host_resid(tidx) - ztask) om = self.relax_omega if om < 1.0 and it > 1 and not np.isnan(self.thinkt_prev[tidx]): z = om * z + (1 - om) * self.thinkt_prev[tidx] self.tput[tidx] = arvrate self.thinkt[tidx] = z self.thinkt_prev[tidx] = z self.thinktproc[tidx] = Exp.fitMean(z + ztask) continue # a reference task, or one no other task calls: it has no station of # its own, so its only delay is the think time the user declared self.thinkt[tidx] = GlobalConstants.FineTol self.thinktproc[tidx] = Immediate() continue L = self._ph_layer[tidx] res = self.results[-1][int(self.idxhash[tidx])] U = float(np.nansum(res['UN'][L.qstations[0] - 1, :])) if res else 0.0 self.util[tidx] = U # The closure below measures a thread's cycle in WORK: it is idle for # whatever of the cycle its station does not hold it working. A SetupTask's # station service also carries a cold start, which is time the thread is # unavailable but is not work, so it is taken back out of U before the # closure reads it. Zero for every task without a setup. if self._ph_setupshare[tidx] > 0: U = U * (1 - self._ph_setupshare[tidx]) # the rate the CALLERS ask of the task, not the rate its processor layer # reported: the latter is itself a function of this think time X = self._ph_xdemand[tidx] if not (X > GlobalConstants.FineTol): X = self.tput[tidx] # The thread pool of ONE replica, the convention xdemand is kept in. njobs = self._ph_maxmult(tidx) if not np.isfinite(njobs) or njobs <= 0: njobs = float(np.max(self.njobs[tidx, :])) if X > GlobalConstants.FineTol: if self._get_sched(tidx) == SchedStrategy.INF: # an infinite server reports a mean number of busy threads z = (njobs - U) / X - ztask else: z = njobs * abs(1 - U) / X - ztask else: z = self.thinkt[tidx] z = max(GlobalConstants.Zero, z) if it > 1 and not np.isnan(self.thinkt_prev[tidx]) and not np.isfinite(z): z = self.thinkt_prev[tidx] omega = self.relax_omega if omega < 1.0 and it > 1 and not np.isnan(self.thinkt_prev[tidx]): z = omega * z + (1 - omega) * self.thinkt_prev[tidx] self.thinkt[tidx] = z self.thinkt_prev[tidx] = z self.thinktproc[tidx] = Exp.fitMean(z + ztask) def _ph_arrival_rate(self, tidx: int) -> float: """ Total exogenous rate into the entries of task TIDX, zero unless the arrival is the only way in -- the predicate build_layers drops the open class on. """ lqn = self.lqn if self._is_ref_task(tidx): return 0.0 for eidx in self._ph_entries_of(tidx): if self._ph_any_caller_of(eidx): return 0.0 rate = 0.0 for eidx in self._ph_entries_of(tidx): d = lqn.arrival.get(eidx) if isinstance(lqn.arrival, dict) else None if d is None: continue try: m = float(d.getMean()) except (AttributeError, TypeError, ValueError): continue if np.isfinite(m) and m > GlobalConstants.FineTol: rate += 1.0 / m return rate def _ph_host_resid(self, tidx: int) -> float: """Response time the caller class of task TIDX sees at its processor layer.""" lqn = self.lqn hidx = int(lqn.parent[tidx, 0]) if hidx < 0 or hidx >= len(self.idxhash) or np.isnan(self.idxhash[hidx]): return 0.0 L = self._ph_layer[hidx] if L is None or tidx not in L.class_of_caller: return 0.0 res = self.results[-1][int(self.idxhash[hidx])] if not res: return 0.0 r = res['RN'][L.qstations[0] - 1, L.class_of_caller[tidx] - 1] return 0.0 if np.isnan(r) else float(r) # ================================================================= # Result reconstruction # ================================================================= def _get_ensemble_avg_ph(self): """ LQN-level results. The layers report per caller task, so every entry, activity and call figure is rebuilt from the converged fixed point rather than read off a class row, in the same layout get_ensemble_avg returns. Returns: (QN, UN, RN, TN, AN, WN), each indexed by absolute element index """ lqn = self.lqn n = lqn.nidx QN = np.full(n, np.nan) UN = np.full(n, np.nan) RN = np.full(n, np.nan) TN = np.full(n, np.nan) AN = np.full(n, np.nan) WN = np.full(n, np.nan) PN = np.full(n, np.nan) # processor utilization UT = np.full(n, np.nan) # task and entry utilization for a in range(lqn.nacts): aidx = lqn.ashift + a tidx = int(lqn.parent[aidx, 0]) if self.ignore[tidx]: continue hidx = int(lqn.parent[tidx, 0]) TN[aidx] = self.tput[aidx] RN[aidx] = self.servt[aidx] UT[aidx] = self.tput[aidx] * self.servt[aidx] # LINE scales the utilization of a queueing station into [0,1] whatever its # multiplicity, and reports a mean number of busy servers at an infinite # server: the processor share of an activity follows the same convention PN[aidx] = self.tput[aidx] * self._ph_hostdem_mean(aidx) / self._ph_host_servers(hidx) if np.isnan(PN[hidx]): PN[hidx] = 0.0 PN[hidx] += PN[aidx] for e in range(lqn.nentries): eidx = lqn.eshift + e tidx = int(lqn.parent[eidx, 0]) if self.ignore[tidx]: continue TN[eidx] = self.tput[eidx] RN[eidx] = self.servt[eidx] UT[eidx] = self.tput[eidx] * self.servt[eidx] acts = self._ph_acts_of(eidx) if acts: PN[eidx] = float(np.nansum([PN[a] for a in acts])) # ResidT is reported per visit to the TASK, not per execution of the # activity: an activity of this entry runs EXECS times per invocation, and # the entry takes SHARE of the task's invocations. RespT stays per # execution. if self._ph_execs[eidx] is not None: ex = self._ph_execs[eidx] w = self._ph_share[eidx] for aidx in acts: WN[aidx] = w * ex[aidx] * self.residt[aidx] if np.isnan(UT[tidx]): UT[tidx] = 0.0 UT[tidx] += UT[eidx] for t in range(lqn.ntasks): tidx = lqn.tshift + t if self.ignore[tidx]: continue TN[tidx] = self.tput[tidx] acts = self._ph_acts_of(tidx) if acts: PN[tidx] = float(np.nansum([PN[a] for a in acts])) WN[tidx] = float(np.nansum([WN[a] for a in acts])) for hidx in range(lqn.nhosts): TN[hidx] = np.nan # kept NaN for consistency with LQNS # Idle, not undefined -- the same rule getEnsembleAvg applies, and for the # same reason: an unreachable element reports zero for the measures its kind # HAS and NaN for the ones it never has, so that the table's NaN mask # survives a disconnected component. Reported columns here are QLen=UT, # Util=PN, RespT=RN, ResidT=WN, ArvR=AN, Tput=TN; the pre-swap QN and UN are # discarded below and are not written. for idx in range(n): if self.ignore[idx]: PN[idx] = 0.0 # every kind reports a utilization AN[idx] = np.nan # nothing reports an arrival rate on an LQN kind = self._get_type(idx) if kind == LayeredNetworkElement.PROCESSOR: UT[idx] = RN[idx] = WN[idx] = TN[idx] = np.nan elif kind == LayeredNetworkElement.TASK: UT[idx] = WN[idx] = TN[idx] = 0.0 RN[idx] = np.nan elif kind == LayeredNetworkElement.ENTRY: UT[idx] = RN[idx] = TN[idx] = 0.0 WN[idx] = np.nan elif kind == LayeredNetworkElement.ACTIVITY: UT[idx] = RN[idx] = WN[idx] = TN[idx] = 0.0 return UT, PN, RN, TN, AN, WN # ================================================================= # Feature gate # ================================================================= def _assert_srvn_ph_supported(self, flat: bool = False): """ Features the composed law cannot represent are refused by name rather than silently degraded -- see _kb/06-solver-catalog.md (LN section). The list is a property of the ENCODING, so it is the same under either layering; what the squashing adds on top is refused in _ph_flat_server_set. """ lqn = self.lqn mname = 'flat.ph' if flat else 'srvn.ph' if getattr(self, 'hasPhase2', False): raise ValueError("method='%s' does not support second-phase activities: the " "composed entry law has no reply point. Use method='default'." % mname) for cidx in range(lqn.ncalls): if self._ph_call_type(cidx) == _FWD: raise ValueError("method='%s' does not support forwarding calls, whose target " "is not part of the caller's activity graph. Use method='default'." % mname) iscache = getattr(lqn, 'iscache', None) if iscache is not None and np.any(np.asarray(iscache).ravel()): raise ValueError("method='%s' does not support cache tasks. Use method='default'." % mname) # A SetupTask IS supported: the setup is not part of the activity graph, so it # never enters the series-parallel reduction and is prefixed to the composed # entry law afterwards as the phase-type mixture. An INF task is the exception, # as in LDES: it holds no thread to power down, so the cycle has no meaning. hs = getattr(lqn, 'hassetup', None) if hs is not None: hsf = np.asarray(hs).ravel() for i in range(len(hsf)): if not hsf[i]: continue if self._get_sched(i) == SchedStrategy.INF or not np.isfinite(float(lqn.mult[0, i])): raise ValueError("method='%s': task '%s' declares a setup time on an " "infinite-server task, which holds no thread to power down; " "give it a finite multiplicity." % (mname, self._ph_name(i))) if getattr(lqn, 'callgroups', None): # The group states the ORDER in which one caller visits several # callees, and the composed law folds every call into one visit, so # the order has nowhere to be expressed. Squashing does not recover # it: 'flat.cs' is the only encoding that dispatches a group. raise ValueError("method='%s' does not support routed call groups, whose dispatch " "order is a routing property. Use method='flat.cs'." % mname) if getattr(lqn, 'lincon', None): raise ValueError("method='%s' does not support admission constraints on a layer " "station. Use method='default'." % mname) # A queue-dependent service rate is a property of the layer STATION, and # the composed law replaces that station by an entry law, so the scaling # has nowhere to attach. Only _add_layer_rate_dependence emits it; this # encoding used to DROP it in silence, which reads as a solved model # rather than a refused one. for fndep in ('lldscaling', 'cdscaling', 'jdscaling', 'pools'): dep = getattr(lqn, fndep, None) or {} if dep: sidxdep = sorted(dep.keys())[0] what = 'server pools' if fndep == 'pools' else fndep raise ValueError("method='%s' does not support queue-dependent service rates on " "a layer station ('%s' declares %s). Use method='srvn.cs'." % (mname, self._ph_name(sidxdep), what)) # ================================================================= # Small helpers # ================================================================= def _ph_host_layer_callers(self, hidx: int) -> List[int]: """Tasks that run on processor HIDX and reach it with requests.""" out = [] for tidx in self._get_tasks_of_host(hidx): if self.ignore[tidx]: continue if self._is_ref_task(tidx): out.append(tidx) continue for eidx in self._ph_entries_of(tidx): if self._ph_any_caller_of(eidx) or self._ph_has_open_arrival(eidx): out.append(tidx) break return out def _ph_task_layer_callers(self, tidx: int) -> List[int]: """Tasks issuing a synchronous call to an entry of TIDX.""" lqn = self.lqn out = [] for c in range(lqn.tshift, lqn.tshift + lqn.ntasks): if c == tidx or self.ignore[c]: continue for eidx in self._ph_entries_of(tidx): if lqn.issynccaller[c, eidx]: out.append(c) break return out def _ph_async_calls_into(self, tidx: int) -> List[int]: """Asynchronous calls whose target entry belongs to TIDX.""" lqn = self.lqn targets = set(self._ph_entries_of(tidx)) return [cidx for cidx in range(lqn.ncalls) if self._ph_call_type(cidx) == _ASYNC and int(lqn.callpair[cidx, 1]) in targets] def _ph_open_arrival_only(self, tidx: int) -> bool: """ True when an entry arrival is the ONLY way requests reach task TIDX. 'srvn.ph' refuses forwarding calls outright, so sync/async callers are the whole test. """ if self._is_ref_task(tidx): return False for eidx in self._ph_entries_of(tidx): if self._ph_any_caller_of(eidx): return False return any(self._ph_has_open_arrival(eidx) for eidx in self._ph_entries_of(tidx)) def _ph_any_caller_of(self, eidx: int) -> bool: lqn = self.lqn return bool(np.any(lqn.issynccaller[:, eidx])) or bool(np.any(lqn.isasynccaller[:, eidx])) def _ph_has_open_arrival(self, eidx: int) -> bool: arr = getattr(self.lqn, 'arrival', None) return isinstance(arr, dict) and arr.get(eidx) is not None def _ph_replica_count(self, idx: int, callers: List[int], ishost: bool) -> int: """ Replicas of the server station, with the same fan-out reduction as the default builder: a caller that reaches every replica sees one representative. """ lqn = self.lqn raw = int(self._ph_repl(idx)) if raw <= 1 or not callers: return max(1, raw) reduce = False if not ishost and getattr(lqn, 'fanout', None) is not None: reduce = all(lqn.fanout[c, idx] >= raw for c in callers) elif ishost: reduce = all(int(self._ph_repl(c)) == raw for c in callers) if reduce: if not ishost: self.single_replica_tasks.append(idx) return 1 return raw def _ph_layer_population(self, idx: int, c: int, nreplicas: int) -> float: """Threads of caller C present in the layer of IDX.""" single = (nreplicas == 1 and self._ph_repl(idx) > 1) or (c in self.single_replica_tasks) mc = self._ph_maxmult(c) njobs = mc if single else mc * self._ph_repl(c) if not np.isfinite(njobs): njobs = sum(self._ph_maxmult(i) for i in self._get_callers_of_task(c)) if not np.isfinite(njobs) or njobs == 0: tot = 0.0 for i in range(self.lqn.nidx): m = self._ph_maxmult(i) if np.isfinite(m): tot += m * self._ph_repl(i) njobs = min(tot, 1000.0) return float(njobs) def _ph_layer_pop(self, L: PHLayer, idx: int) -> float: """Closed population of the MODEL the server sits in, i.e. how many jobs a job can queue behind. Under 'flat.ph' that is every caller of the single network and not only the callers of this one station, which is why it is taken from the layer record rather than recomputed from the callers.""" if getattr(L, 'npop', 0.0) >= 1.0: return float(L.npop) n = 0.0 for c in L.callers: v = self.njobs[c, idx] if np.isfinite(v) and v > 0: n += v return max(n, 1.0) @staticmethod def _ph_residence(Q: float, X: float, RN: float) -> float: """ Residence time per visit, by Little from the queue length rather than from the reported RN. A layer that saturates can come back from AMVA with an RN that no closed model can produce, and a reconstruction that trusts it feeds the impossible value straight back into the call response times. """ if np.isfinite(Q) and Q >= 0 and np.isfinite(X) and X > GlobalConstants.FineTol: return Q / X return RN @staticmethod def _ph_inflation_of(R: float, S: float, npop: float) -> float: """ Ratio of a residence time to the mean of the law it was measured against, bounded above by the layer population: a job can wait behind at most every other job in a closed layer. """ f = 1.0 if S > GlobalConstants.FineTol and np.isfinite(R) and R > 0: f = R / S if not np.isfinite(f) or f < 1: f = 1.0 if np.isfinite(npop) and npop >= 1 and f > npop: f = npop return f def _ph_sync_calls_between(self, c: int, tidx: int) -> List[int]: """Synchronous calls issued by task C to an entry of task TIDX.""" lqn = self.lqn out = [] for cidx in range(lqn.ncalls): if self._ph_call_type(cidx) != _SYNC: continue if int(lqn.parent[int(lqn.callpair[cidx, 0]), 0]) == c \ and int(lqn.parent[int(lqn.callpair[cidx, 1]), 0]) == tidx: out.append(cidx) return out def _ph_setup_prob(self, eidx: int) -> float: """ Probability that a request for entry EIDX finds its task's thread powered off. ONE closure for both methods: _setup_charge returns p*s, so p is that over self. It also answers p = 1 during construction, before the first solve has sized tput or util. """ lqn = self.lqn tidx = int(lqn.parent[eidx, 0]) hs = getattr(lqn, 'hassetup', None) if hs is None: return 0.0 hsf = np.asarray(hs).ravel() if tidx >= len(hsf) or not hsf[tidx]: return 0.0 d = self._setup_dist_mean(getattr(lqn, 'delayofftime', None), tidx) st = self._setup_dist_mean(getattr(lqn, 'setuptime', None), tidx) if not (d > GlobalConstants.FineTol) or not (st > GlobalConstants.FineTol): return 0.0 return min(1.0, max(0.0, self._setup_charge(tidx) / st)) def _ph_setup_law(self, tidx: int): """Phase-type law of task TIDX's setup time, None when it declares none.""" procs = getattr(self.lqn, 'setuptime', None) if not isinstance(procs, dict): return None proc = procs.get(tidx) if proc is None: return None try: m = float(proc.getMean()) scv = float(proc.getSCV()) except (AttributeError, TypeError, ValueError): return None if not np.isfinite(m) or m <= GlobalConstants.FineTol: return None if not np.isfinite(scv) or scv <= GlobalConstants.FineTol: scv = 1.0 law = APH.fitMeanAndSCV(m, scv) return _alpha_of(law), _subgen_of(law) def _ph_host_servers(self, hidx: int) -> float: """ Divisor that scales a processor utilization into [0,1]. An infinite server reports a mean number of busy servers instead, so it divides by one. """ if self._get_sched(hidx) == SchedStrategy.INF: return 1.0 m = self._ph_maxmult(hidx) return m if (np.isfinite(m) and m > 0) else 1.0 @staticmethod def _ph_sum_over(M, stations: List[int], col: int) -> float: s = 0.0 for st in stations: v = M[st - 1, col] if not np.isnan(v): s += v return s def _ph_entries_of(self, idx: int) -> List[int]: return list(self.lqn.entriesof.get(idx, [])) def _ph_acts_of(self, idx: int) -> List[int]: return list(self.lqn.actsof.get(idx, [])) def _ph_name(self, idx: int) -> str: v = self.lqn.names return v.get(idx, 'Node_%d' % idx) if isinstance(v, dict) else str(v[idx]) def _ph_call_type(self, cidx: int) -> int: ct = getattr(self.lqn, 'calltype', None) if ct is None: return _SYNC arr = np.asarray(ct).ravel() return int(arr[cidx]) if cidx < len(arr) else _SYNC def _ph_call_mean(self, cidx: int) -> float: return float(self.lqn.callpair[cidx, 2]) def _ph_hostdem_mean(self, aidx: int) -> float: hd = self.lqn.hostdem if isinstance(hd, dict): return float(hd.get(aidx, 0.0)) return float(np.asarray(hd).ravel()[aidx]) def _ph_act_think_mean(self, aidx: int) -> float: at = getattr(self.lqn, 'actthink', None) if not isinstance(at, dict): return 0.0 d = at.get(aidx) if d is None: return 0.0 if isinstance(d, (int, float)): return float(d) if float(d) > GlobalConstants.FineTol else 0.0 try: m = float(d.getMean()) except (AttributeError, TypeError, ValueError): return 0.0 return m if (np.isfinite(m) and m > GlobalConstants.FineTol) else 0.0 def _ph_maxmult(self, idx: int) -> float: return float(np.asarray(self.lqn.maxmult).ravel()[idx]) def _ph_repl(self, idx: int) -> float: r = getattr(self.lqn, 'repl', None) if r is None: return 1.0 v = float(np.asarray(r).ravel()[idx]) return v if v > 0 else 1.0 def _ref_think_mean(self, tidx: int) -> float: """Declared think time of a task as it enters the thread cycle: the value for a REFERENCE task, zero for any other. A think time is an attribute of the closed customer population a reference task stands for, and it is what separates one request of that population from the next. On a served task it has no such meaning, and charging it per request throttles the task: lqn_basic's T3 has 25 threads and a declared think time of 4, and reading it as a per-request delay caps it at 25/(4+0.02) = 6.219 completions per second. Three independent oracles put the rate at five calls per caller request instead -- lqsim 66.5, LDES 66.955, lqns 75.6. See _kb/06-solver-catalog.md (LN section). """ if not self._is_ref_task(tidx): return 0.0 lqn = self.lqn # lqn.think is keyed by ABSOLUTE element index and is a dict whenever the # struct was built sparsely, so its length is the number of tasks that # declare a think time and not an index bound: a positional guard here # read a reference task's think time as absent and dropped it. think = getattr(lqn, 'think', None) if isinstance(think, dict): tp = think.get(tidx) elif think is not None and tidx < len(think): tp = think[tidx] else: tp = None if tp is None: return 0.0 for attr in ('getMean', 'get_mean'): if hasattr(tp, attr): v = getattr(tp, attr)() return float(v) if np.isfinite(v) and v > 0 else 0.0 if isinstance(tp, (int, float, np.integer, np.floating)): return float(tp) if np.isfinite(tp) and tp > 0 else 0.0 if hasattr(tp, 'mean'): v = tp.mean return float(v) if np.isfinite(v) and v > 0 else 0.0 return 0.0 def _mwrbb_think_mean(self, tidx: int) -> float: # same closure as update_think_times, so the same gate -- see # _ref_think_mean if not self._is_ref_task(tidx): return 0.0 tp = self.thinkproc[tidx] if (self.thinkproc is not None and tidx < len(self.thinkproc)) else None if tp is None: return 0.0 for attr in ('getMean', 'get_mean'): if hasattr(tp, attr): v = getattr(tp, attr)() return float(v) if np.isfinite(v) else 0.0 if hasattr(tp, 'mean'): return float(tp.mean) return 0.0 @staticmethod def _mwrbb_disc_code(s) -> int: # 0=FIFO, 1=PS, 2=non-preemptive priority, 3=preemptive priority, # 4=ABA full-contention (discipline-independent) name = s.name if hasattr(s, 'name') else str(s) if name == 'FCFS': return 0 if name in ('PS', 'DPS', 'GPS', 'PSPRIO', 'DPSPRIO', 'GPSPRIO'): return 1 if name in ('HOL', 'FCFSPRIO'): return 2 if name in ('FCFSPRPRIO', 'LCFSPRPRIO'): return 3 return 4 # non-FCFS work-conserving: ABA full-contention def _mwrbb_visit_entry(self, eidx, mult, r, D, Vis): Vis[eidx, r] += mult tidx = self._get_parent(eidx) if tidx is not None and tidx < Vis.shape[0]: Vis[tidx, r] += mult for aidx in self._get_activities_of_entry(eidx): if self._get_parent(aidx) == tidx: self._mwrbb_visit_activity(aidx, mult, r, D, Vis) def _mwrbb_visit_activity(self, aidx, mult, r, D, Vis): Vis[aidx, r] += mult tidx = self._get_parent(aidx) hidx = self._get_parent(tidx) if tidx is not None else None dem = self._get_hostdem_mean(aidx) if hidx is not None: hrow = hidx - self.lqn.hshift # host rows are 0-based if 0 <= hrow < D.shape[0]: D[hrow, r] += mult * dem calls = self.lqn.callsof.get(aidx, []) if isinstance(self.lqn.callsof, dict) else [] for cidx in calls: if self._get_calltype(cidx) == CallType.SYNC: cmean = self._get_call_mean(cidx) callee = self._get_call_target_entry(cidx) if callee is not None: self._mwrbb_visit_entry(callee, mult * cmean, r, D, Vis) def _box_bounds(self, upper: bool): from ...api.pfqn.bounds import pfqn_mwrbb lqn = self.lqn nH = lqn.nhosts nidx = lqn.nidx refs = [lqn.tshift + t for t in range(lqn.ntasks) if self._is_ref_task(lqn.tshift + t)] R = len(refs) D = np.zeros((nH, R)) Vis = np.zeros((nidx, R)) N = np.zeros(R) Z = np.zeros(R) for r, tidx in enumerate(refs): m = self._get_mult(tidx) N[r] = 1.0 if not np.isfinite(m) else m Z[r] = self._mwrbb_think_mean(tidx) for eidx in self._get_entries_of_task(tidx): self._mwrbb_visit_entry(eidx, 1.0, r, D, Vis) V = (D > 0).astype(float) S = D sched = np.zeros(nH) for h in range(nH): sched[h] = self._mwrbb_disc_code(self._get_sched(lqn.hshift + h)) prio = np.zeros(R) Xlo, Xup, _ = pfqn_mwrbb(V, S, N, Z, sched, prio) X = Xup if upper else Xlo TN = np.full(nidx, np.nan) UN = np.full(nidx, np.nan) for i in range(nidx): if np.any(Vis[i] > 0): TN[i] = float(np.sum(X * Vis[i])) for h in range(nH): UN[lqn.hshift + h] = float(np.sum(X * D[h])) return TN, UN def _box_bounds_table(self, upper: bool) -> pd.DataFrame: TN, UN = self._box_bounds(upper) lqn = self.lqn rows = [] for idx in range(lqn.nidx): t = TN[idx] u = UN[idx] rows.append({ 'Node': self._get_hashname(idx), 'NodeType': self._get_type_name(idx), 'QLen': 0.0, 'Util': 0.0 if np.isnan(u) else u, 'RespT': 0.0, 'ResidT': 0.0, 'ArvR': 0.0, 'Tput': 0.0 if np.isnan(t) else t, }) df = pd.DataFrame(rows) if not self._table_silent and len(df) > 0: print(df.to_string(index=False)) from line_solver.indexed_table import IndexedTable return IndexedTable(df) @staticmethod def _sanitize_avg(values): """Snap near-tenth entries onto the tenth and flatten near-zero entries. Mirrors the sanitization MATLAB SolverLN applies before formatting getAvgTable. NaN entries are left untouched, as are negative ones (the MATLAB relative test is vacuous there). """ v = np.asarray(values, dtype=float).copy() with np.errstate(invalid='ignore'): scaled = v * 10.0 rounded = np.round(scaled) to_round = np.abs(scaled - rounded) < GlobalConstants.CoarseTol * scaled to_round &= ~np.isnan(v) v[to_round] = rounded[to_round] / 10.0 v[(~np.isnan(v)) & (v <= GlobalConstants.FineTol)] = 0.0 return v
[docs] def get_avg_table(self) -> pd.DataFrame: """Get average metrics as a table (matches MATLAB getAvgTable).""" # lang=cpp is tested BEFORE the mwba branch below: those bounds are # computed natively, so serving them here would report python numbers # under a C++ label. The dispatch refuses the method by name instead. if getattr(self.options, 'lang', 'python') == 'cpp': from ..cpp_dispatch import LineCliNotAvailable, ln_avg_table_via_cpp try: df = ln_avg_table_via_cpp(self) except LineCliNotAvailable as e: line_warning("SolverLN", "lang='cpp' requested but the C++ solver is " "unavailable (%s); falling back to lang='python'." % e) else: if not self._table_silent and len(df) > 0: print(df.to_string(index=False)) from line_solver.indexed_table import IndexedTable return IndexedTable(df) # Majumdar-Woodside robust box bounds for the LQN (processor-contention) _bmethod = getattr(self.options, 'method', None) if _bmethod in ('mwba.upper', 'mwba.lower'): return self._box_bounds_table(_bmethod == 'mwba.upper') # print the lang=java ensemble average table unless output is silenced. if getattr(self.options, 'lang', 'python') == 'java': from ..jar_dispatch import ln_avg_table_via_jar df = ln_avg_table_via_jar(self) if not self._table_silent and len(df) > 0: print(df.to_string(index=False)) from line_solver.indexed_table import IndexedTable return IndexedTable(df) QN, UN, RN, TN, AN, WN = self.get_ensemble_avg() # sanitize averages (snap-to-tenth/flatten-to-zero) before formatting; see _kb/11-conventions-and-gotchas.md LN avg tables are sanitized. QN, UN, RN, TN, AN, WN = (self._sanitize_avg(v) for v in (QN, UN, RN, TN, AN, WN)) lqn = self.lqn # Build table rows = [] for idx in range(lqn.nidx): name = self._get_hashname(idx) node_type = self._get_type_name(idx) # Get metric values qlen = QN[idx] util = UN[idx] respt = RN[idx] residt = WN[idx] arvr = AN[idx] tput = TN[idx] rows.append({ 'Node': name, 'NodeType': node_type, 'QLen': qlen, 'Util': util, 'RespT': respt, 'ResidT': residt, 'ArvR': arvr, 'Tput': tput, }) df = pd.DataFrame(rows) # Print table if not silent (matches LQNS behavior) if not self._table_silent and len(df) > 0: print(df.to_string(index=False)) # Return as IndexedTable for MATLAB-style number formatting from line_solver.indexed_table import IndexedTable return IndexedTable(df)
def _get_type_name(self, idx: int) -> str: """Get element type name.""" lqn = self.lqn elem_type = self._get_type(idx) if elem_type == LayeredNetworkElement.PROCESSOR: return 'Processor' elif elem_type == LayeredNetworkElement.TASK: if self._is_ref_task(idx): return 'RefTask' return 'Task' elif elem_type == LayeredNetworkElement.ENTRY: return 'Entry' elif elem_type == LayeredNetworkElement.ACTIVITY: return 'Activity' elif elem_type == LayeredNetworkElement.CALL: return 'Call' return 'Unknown'
[docs] def reset(self): """Reset solver state.""" self.hasconverged = False self.moment_pass_done = False self.results = [] self.maxitererr = []
# per-layer state fields carried across iterations (util/tput/servt/residt/thinkt/callresidt/callservt and their _prev counterparts). _STATE_ATTRS = ( 'servtproc', 'thinktproc', 'callservtproc', 'tputproc', 'entryproc', 'util', 'tput', 'servt', 'residt', 'thinkt', 'callresidt', 'callservt', 'relax_omega', 'servt_prev', 'residt_prev', 'tput_prev', 'thinkt_prev', 'callservt_prev', 'callresidt_prev', 'results', 'njobs', 'ilscaling', ) def get_state(self): """Export current solver state for continuation with another solver. Returns a dict snapshotting service/think/call processes, performance metrics, relaxation state and last-iteration results, consumable by set_state() on a solver built with a different layer-solver factory. """ import copy state = {} for attr in self._STATE_ATTRS: if hasattr(self, attr): state[attr] = copy.deepcopy(getattr(self, attr)) return state
[docs] def set_state(self, state): """Import a previously exported state (see get_state) for continuation.""" import copy for attr, value in state.items(): setattr(self, attr, copy.deepcopy(value))
[docs] def update_solver(self, solver_factory): """Replace all per-layer solvers with ones built by solver_factory, preserving the current solution state for refinement.""" self.solver_factory = solver_factory for e in range(self.nlayers): solver = solver_factory(self.ensemble[e]) self._assert_layer_solver_supports_model(solver, self.ensemble[e], e) self._detach_layer_config(solver) self._silence_layer_solver(solver) self.solvers[e] = solver
def _layer_options(self): """The LN options as a LAYER solver may read them. An LN method name states the LAYERING and the ENCODING of the ensemble, not the algorithm a single layer is solved with, and the two vocabularies do not overlap. The default factory hands the LN options straight to SolverMVA, so naming any LN method -- 'srvn.ph', 'flat.cs', 'flat.ph', even the 'srvn' alias -- reached the layer solver as its own method. It refused the unknown token outright, or, worse, resolved it to something else and answered: LN(model, method='srvn') returned Tput 0.694282 on the two-task probe where the identical LN(model) returns 1.402605, both having resolved lnmethod to 'srvn.ph'. MATLAB keeps the two apart by giving each layer an options struct of its own. """ import copy as _copy opts = self.options m = getattr(opts, 'method', None) if isinstance(m, str) and m.lower() in _LN_LEVEL_METHODS: opts = _copy.copy(opts) opts.method = 'default' return opts def _detach_layer_config(self, layer_solver): """Give the layer solver a config dict of its own. MATLAB hands each layer an options STRUCT, copied by value; in Python every layer solver built from the LN options aliases one config dict, so a per-layer write such as the interlock matrix of Eq. (4.7) would reach every other layer, whose classes are neither the same in number nor in meaning. """ opts = getattr(layer_solver, 'options', None) cfg = getattr(opts, 'config', None) if opts is not None else None if isinstance(cfg, dict): opts.config = type(cfg)(cfg) def _silence_layer_solver(self, layer_solver): """Set a layer solver to VerboseLevel.SILENT (spelled False here). A LAYER SOLVER NEVER NARRATES. The fixed point runs every layer once per iteration, so a layer left at the caller's verbosity prints its own banner nlayers*iter_max times and buries the layered narration the caller actually asked for. The level is stamped HERE rather than in the factory because a factory the USER supplied -- SolverLN(model, lambda m: SolverNC(m)) -- never sees the LN options at all, and stamping it in the default factory alone left exactly that case loud. SolverLN's own reporting is unaffected: it reads self.options.verbose, not the layer's. A NATIVE SOLVER OPTION SPELLS ITS VERBOSITY AS A BOOL, not as a VerboseLevel (see constants.default_verbose), and an Enum member is truthy: assigning VerboseLevel.SILENT here would read as VERBOSE at every `if options.verbose:` in the tree. False is the same level in the type this field actually carries, and is what console._is_silent reads. """ opts = getattr(layer_solver, 'options', None) if opts is not None and hasattr(opts, 'verbose'): opts.verbose = False def _assert_layer_solver_supports_model(self, layer_solver, layer_model, idx): """Reject a layer solver that cannot represent its layer model, upfront with a clear message. LN server-layer stations carry immediate feedback (sn.immfeed): successive same-host activities retain the server, modelled as immediate-feedback self-loops so the layer solver does not re-queue the job. SolverJMT rejects any model with immfeed and returns no solution, so a pure-JMT layer factory otherwise fails cryptically at the first iteration. Detect it here instead. The guard is CONDITIONAL: it fires only when the specific layer model actually carries immfeed. A SolverJMT layer solver on an immfeed-free layer is allowed, and non-JMT factories are never rejected. """ from ..wrappers.solver_jmt import SolverJMT if isinstance(layer_solver, SolverJMT): sn = layer_model.get_struct() immfeed = getattr(sn, 'immfeed', None) if immfeed is not None and np.any(immfeed): raise ValueError( "SolverJMT cannot solve LN layer %d: the layer carries " "immediate feedback (sn.immfeed), which SolverJMT does not " "support, so LN would fail at the first iteration. Use the " "default layer factory (MVA/NC) or another layer solver " "that supports immediate feedback." % idx) # camelCase aliases (JAR-style) def getState(self): return self.get_state() def setState(self, state): return self.set_state(state)
[docs] def updateSolver(self, solver_factory): return self.update_solver(solver_factory)
[docs] @staticmethod def defaultOptions() -> SolverLNOptions: """Get default LN solver options.""" return SolverLNOptions()
[docs] @staticmethod def default_options() -> SolverLNOptions: """Get default options (Python convention).""" return SolverLNOptions()
[docs] def getSensitivityTable(self, method='auto', step=None, scheme='forward'): """Layer-wise performance sensitivities of a layered network. Solves the layered model and then delegates to each layer solver, returning the concatenation of the layer tables with a leading Layer column. Every row is a (Layer, Station, JobClass) triple carrying the derivative of that row's mean measures with respect to that station-class service RATE: dTput_dRate, dRespT_dRate, dQLen_dRate, dUtil_dRate. The options are passed through to the layer solvers unchanged, with the same meaning as in NetworkSolver.getSensitivityTable: each layer independently takes the analytic branch where its own solver supports it and the model is in scope, and finite differences otherwise. In practice a layer submodel is chain-based (the callers switch class), which puts it out of scope of the analytic branch, so the layers normally finite-difference their own solver. IMPORTANT, on what these derivatives mean. Each entry is a derivative WITHIN ITS LAYER, taken with the layer parameters that the fixed point produced held fixed. It is a partial derivative of the layer submodel, not the total derivative of the layered model: perturbing a host demand in one layer moves the think times, populations and service rates of the other layers through the fixed-point map, and that indirect term is not included here. The layer table is the right object for attributing a bottleneck inside a layer, and the wrong one for predicting the effect of a parameter change on the solved layered model. Returns the table; the per-layer second outputs are on ``DataFrame.attrs['sens']`` and the per-layer branch labels on ``DataFrame.attrs['layer_methods']``, with ``attrs['method']`` the summary ('exact', 'fd', or 'mixed'). """ import pandas as pd # The derivatives below are each layer solver's own, so a method that # builds no layers has none to concatenate. An empty table would read as # "no sensitivities here" rather than "this method has no layers". if getattr(self, 'lnmethod', None) == 'nlp': raise RuntimeError( "method='nlp' states the model as one program and builds no layers, so " "there are no per-layer sensitivities to report. Use a layered method " "('srvn', 'srvn.ph', 'flat.cs') for getSensitivityTable.") # The layered CLI has no sensitivity analysis, so the derivatives below are # native; refuse rather than label them as C++ numbers. if getattr(self.options, 'lang', 'python') == 'cpp': raise RuntimeError( "lang='cpp' delegates the steady-state layered solve only; the per-layer " "sensitivities are computed natively. Use lang='python' for " "getSensitivityTable.") # gate accessors on self.results, not the ensemble table; see _kb/11-conventions-and-gotchas.md lang=java SolverLN skips iterate(). if not self.results: self.iterate() rows = [] sens = [] layer_methods = [] for e, solver in enumerate(self.solvers): if solver is None: sens.append(None) layer_methods.append(None) continue T = solver.getSensitivityTable(method=method, step=step, scheme=scheme) sens.append(T.attrs.get('sens')) layer_methods.append(T.attrs.get('method')) layer_name = self.ensemble[e].getName() for _, row in T.iterrows(): rows.append({ 'Layer': str(layer_name), 'Station': row['Station'], 'JobClass': row['JobClass'], 'dTput_dRate': float(row['dTput_dRate']), 'dRespT_dRate': float(row['dRespT_dRate']), 'dQLen_dRate': float(row['dQLen_dRate']), 'dUtil_dRate': float(row['dUtil_dRate']), }) table = pd.DataFrame(rows, columns=['Layer', 'Station', 'JobClass', 'dTput_dRate', 'dRespT_dRate', 'dQLen_dRate', 'dUtil_dRate']) present = [m for m in layer_methods if m] if not present: summary = '' elif all(m == present[0] for m in present): summary = present[0] else: summary = 'mixed' table.attrs['method'] = summary table.attrs['layer_methods'] = layer_methods table.attrs['sens'] = sens return table
get_sensitivity_table = getSensitivityTable # Aliases for compatibility avg_table = get_avg_table getAvgTable = get_avg_table avgTable = get_avg_table avgT = get_avg_table aT = get_avg_table
# Alias for compatibility LN = SolverLN