TimeWarp (TimeWarp v0.2.0)

Copy Markdown View Source

Optimistic parallel discrete-event simulation on the BEAM.

The engine handles every Time Warp mechanic — speculative execution, rollback, anti-messages, GVT, fossil collection. A domain author implements only TimeWarp.Model.

ids = [:a, :b, :c, :d]

{:ok, sim} =
  TimeWarp.start_run(
    model: TimeWarp.Examples.PHOLD,
    lps: Map.new(ids, &{&1, %{ids: ids, max_delay: 10}}),   # lp_id => init_args
    seed: 42,
    until: {:vtime, 1_000},
    init_events: for(i <- 0..7, do: {Enum.at(ids, rem(i, 4)), i, :ping}),
    gvt_interval_ms: 50,
    # A binding window is the only bound on retained state — see the warning
    # below. PHOLD is all-to-all and does not bound on its own.
    time_window: {:vtime, 20},
    check_purity: false,         # the purity divergence detector
    thrash_guard: nil            # or {rollbacks_per_s, window_ms}
  )

{:done, _} = TimeWarp.await(sim)
report = TimeWarp.report(sim)

Options

  • :model — a TimeWarp.Model implementation (required).
  • :lps%{lp_id => init_args} (required).
  • :seed — integer seed for per-LP PRNGs (default 0).
  • :until{:vtime, t} run horizon, or :infinity (default).
  • :init_events[{target, at, payload}] genesis events kicking off the run.
  • :gvt_interval_ms — wall-clock cadence of GVT cycles (default 50).
  • :check_purity — run the double-call purity probe (the divergence detector, default false).
  • :thrash_guard{rollbacks_per_s, window_ms} thrash circuit breaker. Off by default. Note it brakes on rollback rate, which is not the same as retained state — see the retention warning below.
  • :time_window{:vtime, W} to bound optimism to [GVT, GVT+W], or :none (default). Deadlock-free (the effective bound is max(GVT+W, global_min_event)). W trades parallelism for conflict, not a free storm fix, and it is also the only bound on retained state — see both notes below.
  • :cancellation:aggressive (default) | :lazy. Lazy suppresses an anti-message when re-execution re-emits an identical event; it only pays on models whose straggler-path events are PRNG-free (see TimeWarp.Model — it is inert and can amplify the storm on random models like PHOLD).

  • :placement%{lp_id => node} pinning LPs to nodes. Static, no migration; an LP not listed runs on the local node. LPs are addressed by pid, so a remote LP is mechanically identical to a local one and the straggler protocol is unchanged.
  • :gvt_mode:mattern (default) | :freeze. Mattern's colored-message algorithm is authoritative. :freeze is the earlier stop-the-world computation, kept as the test oracle and as the single end-of-run termination confirmation; it is not a steady-state path.

  • :lp_call_timeout — milliseconds, default 5_000. Timeout for the Coordinator's own calls into LPs (:dump behind report/1, :gvt, the freeze-confirm poll). These execute inside the Coordinator process and an LP may be remote, so exceeding this does not fail one call — it fails the run. Raise it for high-latency links or large per-LP state.
  • :quiescence_timeout_ms — milliseconds, default 1_000. Ceiling on the freeze-confirm quiescence poll; exceeding it raises, meaning in-flight messages are not settling. The workable value scales with link latency, so a distributed run legitimately needs more than a local one.
  • :net_delaytest-only. Artificial per-message delay in milliseconds, used by the suite to characterize behaviour under inter-node latency. Not a production knob.

One run at a time per BEAM

The Coordinator is a global singleton, so a node hosts one run. start_run/1 returns {:error, :run_active} while a run is in progress, and when it does start it tears down every LP on the placement nodes — including any left by a previous run. Concurrent runs are not supported: the teardown is not scoped to a single run, so anything else living under TimeWarp.LPSupervisor is destroyed with it. Await or stop a run before starting the next.

:none places no bound on retained state

GVT lag is what bounds memory. Fossil collection reclaims only below GVT, so nothing is reclaimed while GVT is pinned near the floor — retained history, output logs and per-event model state all accumulate. With :none there is no limit on how far a logical process runs ahead of GVT, so a workload whose input arrives far out of order can hold GVT down for an entire run and grow retained state until the VM exhausts memory. A binding W caps the lag and therefore caps the memory; it is the brake, and there is no separate one.

The cliff is steep rather than gradual. A 93-event run across three logical processes under strictly-descending arrival finishes in 3.6 s at nine windows per process (437,034 rollbacks) and fails to terminate at fifteen; the same shape with W bound to 50 finishes in 237 ms with 220 rollbacks. Confirm a workload bounds before raising until or widening it — max_gvt_lag in report/1 is the direct readout, and an OOM here is almost always an unbounded workload rather than a defect.

:time_window is a parallelism trade, not a storm fix

A tight W caps how far any LP speculates past GVT, so it bounds rollback depth and the storm for any workload — but it does so by throttling parallelism. A W tight enough to tame a high-conflict storm has throttled execution toward conservative execution: the right answer there is that the workload is a poor fit for optimism, not that a smaller W is a win. Find W empirically per workload; a value tuned on one model says nothing about another.

Summary

Functions

Block until the run terminates. Returns {:done, info} or {:failed, reason}.

Snapshot of the run: GVT, per-LP state/stats, and aggregate totals.

Start a run. Returns {:ok, sim} where sim is the handle for await/report.

Cancel a run: tear down its LPs and return the engine to idle.

Types

config()

@type config() :: keyword()

Functions

await(sim)

@spec await(pid()) :: {:done, map()} | {:failed, term()}

Block until the run terminates. Returns {:done, info} or {:failed, reason}.

report(sim)

@spec report(pid()) :: map()

Snapshot of the run: GVT, per-LP state/stats, and aggregate totals.

start_run(config)

@spec start_run(config()) :: {:ok, pid()} | {:error, :run_active}

Start a run. Returns {:ok, sim} where sim is the handle for await/report.

Returns {:error, :run_active} if a run is already in progress — the active run is left untouched. await/1 or stop_run/1 it first.

stop_run(sim)

@spec stop_run(pid()) :: :ok

Cancel a run: tear down its LPs and return the engine to idle.

For a run that never reaches a terminal state on its own — an unbounded horizon held open deliberately, or one abandoned early. Returns :ok.