Runner Types
Understanding how the runner layer is structured makes it easier to reason about performance trade‑offs, observer support, and how the fast-path API diverges from the ordinary wrapper loop.
Ordinary runners
run_with_wrapper (see src/dynlib/runtime/wrapper.py) orchestrates the default execution path. It:
- converts
Simknobs (t0/tend, adaptive flags, discrete horizons, stop phases, selective recording choices, observers, etc.) into buffers and scalar inputs that match the frozen runner ABI. - allocates recording/event arrays and workspaces once, then calls a
RunnerVariant.BASEorRunnerVariant.ANALYSISrunner throughrunner_variants.get_runner. - handles runner signals such as
GROW_REC,GROW_EVT,DONE,EARLY_EXIT,USER_BREAK, orNAN_DETECTED, growing buffers or pausing/resuming as needed so the compiled kernel never has to reallocate memory. - copies final state/dt, traces, and observer metadata into a
Resultsobject before returning. The wrapper therefore keeps the hot loop lean and lets the compiled runner focus on numerical stepping.
Because this path tracks events, variable recording lengths, stop phases, and growth codes, we call it the ordinary runner. It is the most flexible runner and the one used by Sim.run unless fastpath execution is explicitly requested. Both the continuous (ODE) and discrete (map) steppers share the ordinary runner templates (RunnerVariant.BASE or RunnerVariant.ANALYSIS with discrete flag), so the wrapper can handle time-based horizons and iteration-budget horizons with the same ABI.
Fast-path runners
runtime/fastpath/executor.py drives the specialized, fixed-step path. The executor allocates everything upfront (workspaces, selective recording buffers, stop flags, variational hooks) and then calls runner_variants.get_runner with RunnerVariant.FASTPATH or RunnerVariant.FASTPATH_ANALYSIS.
Fast-path runners are stripped down:
- no event-log growth or sticky buffer resizing—everything is sized based on the chosen
RecordingPlan(viaRecordingPlan.capacity) before the first invocation. - no
GROW_*statuses; the runner assumes the buffers it received are large enough, which keeps the loop tight. - no event/interruption loop inside the runner template itself; the executor is responsible for any preparatory transient warm-up, final trimming, and metadata construction.
This makes fast-path runners ideal for repeated batch runs (run_batch_fastpath), throughput benchmarks, or any scenario where you can guarantee fixed-step sizes and memory caps. The executor still marshals observer traces, variational hooks, and runtime workspaces, but it does so outside the numerically hot loop.
Analysis runners
Whenever observers are attached, both the ordinary and fast-path runners switch to an analysis variant:
RunnerVariant.ANALYSIS(wrapper path) andRunnerVariant.FASTPATH_ANALYSIS(fast-path path) inject the observer hooks as globals (ANALYSIS_PREandANALYSIS_POST) so the wrapper or executor does not need to pass function handles through the runner ABI.runner_variants.compile_analysis_hooksresolvesObserverModule.resolve_hooks, pre-compiles both hooks (or uses a no-op fallback), and stores them in therunnercache key, which includesanalysis_signature_hash.- The analysis runners also wire up
analysis_ws,analysis_out, and trace buffers, plus they honoranalysis.trace.record_interval()and metadata such asanalysis_kind, so users can capture side-channel data alongside the main simulation steps. - Variational observers can supply a
runner_variational_stepcallback that the runner calls instead of the default stepper, allowing hooks to adjust proposals without breaking JIT compatibility.
The analysis runners inherit all of the base runner features (recording, stopping, growth) or fast-path simplifications, depending on the variant.
-## Architecture Reference
runner_variants.py is the consolidated source of truth for every runner template. Sim.run() uses the base runner when no observers are attached, while runtime/fastpath/executor.py asks for analysis-aware fast-path runners when observers are present. Each executor/ordinary runner variant is separately cached so that fast-path batches and wrapper calls get their own compiled kernels.
runner_variants.py
- Defines templates and compilation logic for all runner variants (
BASE,ANALYSIS,FASTPATH,FASTPATH_ANALYSIS), covering both continuous and discrete models. - Exposes
get_runner(variant, ...), which builds a cache key, injects observer hooks as globals, optionally JIT-compiles with Numba, and stores both LRU (in-memory) and on-disk runner caches. - Responsible for generating Python source, bootstrapping analysis hooks, and ensuring the same runner templates back both
wrapper.pyandexecutor.py.
executor.py (fast-path)
- Orchestrates fixed-step fast-path execution, allocating buffers, workspaces, optional transient warm-up, and result marshalling.
- Chooses runners with
get_runner(RunnerVariant.FASTPATH, ...)orRunnerVariant.FASTPATH_ANALYSISdepending on observer presence. - Supports single and batch execution (with optional parallelization) while leaving trajectory logic inside the shared runner templates.
- Finalizes observer traces/metadata so the caller receives the same analysis payloads as the ordinary wrapper path.
Runner generation via runner_variants.py
src/dynlib/compiler/codegen/runner_variants.py is the single source of truth for every runner template.
- RunnerVariant enumerates the four supported flavors: BASE, ANALYSIS, FASTPATH, FASTPATH_ANALYSIS.
- _RUNNER_TEMPLATE_MAP pairs each variant and the continuous/discrete flag with the right template string and function name.
- get_runner builds a cache key composed of (model_hash, stepper_name, analysis_sig, variant, runner_kind, dtype, cache_token, jit flag, template version) and looks in either _variant_cache_continuous or _variant_cache_discrete.
- If the runner is missing, it synthesizes the source, optionally JIT-compiles it with Numba, injects the ANALYSIS_PRE/ANALYSIS_POST hook globals, and stores the callable in both the local LRU and the on-disk runner_cache.
- analysis_signature_hash reduces each observer set to a stable 16-character hash so runners remain cachable even when observers resolve to dynamically generated hooks.
Both the wrapper and the fast-path executor call get_runner, so adding a new runner variant (e.g., a variant that always writes to a special log or that fuses extra diagnostics) means adding a new template string, registering it in _RUNNER_TEMPLATE_MAP, and calling it from the appropriate execution path.
Fast-path executor responsibilities
executor.py does more than call a runner:
- It implements
_RunContext,_WorkspaceBundle, and_call_runner, which allocate the right buffers, manage cursor resets, and unify the call site betweenrun_single_fastpath,run_batch_fastpath, and the optimized batch helpers. - It selects the fast-path runner variant based on whether observers are provided and passes
analysis=Noneor the actual observer module toget_runner, mirroring the wrapper’s logic but in a more constrained setting. - When observers are present, the executor still collects trace buffers, metadata (
build_observer_metadata), and optional variational step callbacks so the runner can emit analysis output. - Batch helpers optionally use a thread pool to run the same runner across multiple IC/parameter sets, relying on the fact that the compiled runner is jit‑safe and GIL-free.
The separation means the fast-path executor and ordinary wrapper reuse the same templates and caching infrastructure but diverge in how much bookkeeping happens around the inner loop.