Migrating from SimPy 3¶
This guide is for SimPy 3 users porting an existing model to llmsim. llmsim
keeps SimPy's generator-as-process model — processes are generators, they
yield events, resources are context managers — so a port is mostly a
mechanical rename plus a handful of deliberate semantic changes. What llmsim
does not keep is API compatibility: per the project mission, there is no
compatibility shim or adapter layer, and none is planned. The clean break is
what buys the fully typed, __slots__-based core and the share-nothing
parallelism on top of it; Why llmsim will outperform
SimPy argues the why — this page covers the how.
Every code excerpt below is lifted verbatim from the benchmark model pairs in
benchmarks/models/ (SimPy 3) and benchmarks/llmsim_models/ (llmsim), which
run in CI with exact-KPI-equality checks between the two. A snippet-sync test
keeps this page byte-identical to those files, so the code you read here is
code that runs.
Concept mapping¶
Environment and event loop¶
| SimPy 3 | llmsim | Notes |
|---|---|---|
simpy.Environment() |
llmsim.Sim(seed=...) |
A Sim owns a seeded RNG (sim.rng) and belongs to exactly one thread. |
env.now |
sim.now |
The float simulation clock, unchanged. |
env.run(until=...) |
sim.run(until=...) |
until accepts a time or an event in both; omit it to run the schedule dry. |
env.step() / env.peek() |
sim.step() / sim.peek() |
Manual stepping, unchanged. |
simpy.core.EmptySchedule |
llmsim.EmptySchedule |
Raised by step() on an empty queue; importable from the top level. |
Processes¶
| SimPy 3 | llmsim | Notes |
|---|---|---|
env.process(fn(env, arg)) |
sim.spawn(fn, arg) |
You pass the callable, not a started generator; spawn injects the Sim as the first argument. |
simpy.Process |
llmsim.Process |
Returned by sim.spawn(); still an event you can yield to join. |
env.timeout(delay, value) |
sim.delay(delay, value) |
Renamed; same semantics, including the optional value. |
simpy.Timeout |
llmsim.Timeout |
The event type sim.delay() returns. |
proc.interrupt(cause) |
proc.interrupt(cause) |
Unchanged, including proc.is_alive. |
env.active_process |
sim.active_process |
Unchanged. |
Events and conditions¶
| SimPy 3 | llmsim | Notes |
|---|---|---|
env.event() |
sim.event() |
llmsim's Event[T] is generic in its success value. |
simpy.Event |
llmsim.Event |
succeed(value), fail(exception), and trigger(other) keep their names; event.callbacks is still the public callback list. |
ev1 & ev2 / ev1 \| ev2 |
ev1 & ev2 / ev1 \| ev2 |
Operator composition, unchanged. |
env.all_of() / env.any_of() |
sim.all_of() / sim.any_of() |
Also available as llmsim.AllOf / llmsim.AnyOf. |
simpy.AllOf / simpy.AnyOf |
llmsim.AllOf / llmsim.AnyOf |
Both yield a dict-like ConditionValue. |
simpy.Condition |
llmsim.Condition |
Semantic delta: when several grouped events fail, llmsim aggregates all failures into one ExceptionGroup instead of surfacing only the first. |
Interrupts and errors¶
| SimPy 3 | llmsim | Notes |
|---|---|---|
simpy.Interrupt |
llmsim.Interrupt |
Caught the same way; the interrupt's .cause carries whatever was passed to interrupt(). |
event.defused |
event.defused |
Semantic delta: an explicit, always-present boolean attribute rather than SimPy 3's dynamically added one — see What has no equivalent. |
Resources¶
| SimPy 3 | llmsim | Notes |
|---|---|---|
simpy.Resource(env, capacity) |
llmsim.Resource(sim, capacity) |
with resource.request() as req: yield req works unchanged. |
simpy.PriorityResource |
llmsim.PriorityResource |
.request(priority=...) unchanged. |
simpy.PreemptiveResource |
llmsim.PreemptiveResource |
Preemption interrupts the victim with a Preempted cause, as in SimPy. |
simpy.Container |
llmsim.Container |
put(amount) / get(amount) / .level unchanged. |
simpy.Store |
llmsim.Store |
Generic Store[T]; put(item) / get() unchanged. |
simpy.PriorityStore |
llmsim.PriorityStore |
Unchanged. |
simpy.FilterStore |
llmsim.FilterStore |
get(filter) unchanged. |
RNG and determinism¶
| SimPy 3 | llmsim | Notes |
|---|---|---|
module-level random, or a hand-seeded random.Random(seed) |
sim.rng |
Every Sim owns its own seeded random.Random stream; nothing in llmsim ever touches the global random state. |
| ad-hoc reproducibility | (time, priority, eid) event ordering |
Both engines break ties deterministically; llmsim additionally guarantees the same (seed, config) gives identical results on every backend and build. |
Real-time mode¶
| SimPy 3 | llmsim | Notes |
|---|---|---|
simpy.rt.RealtimeEnvironment(factor=..., strict=...) |
llmsim.rt.run(sim, factor=..., strict=...) |
Real time is a run driver applied to an ordinary Sim, not an environment subclass; falling behind in strict mode raises llmsim.RealtimeDriftError instead of RuntimeError. |
What has no equivalent¶
These SimPy 3 names intentionally do not map to anything — dropping them is part of the clean-break decision recorded in the project mission:
BoundClass— SimPy's descriptor trick for constructing events through the environment. llmsim uses plain methods and plain constructors; there is nothing to port.StopProcess/env.exit(value)— long deprecated in SimPy itself. Use a plainreturn valueinside the process generator.simpy.rt.RealtimeEnvironment(the class) — you never rebuild a model around a different environment type. Build the sameSimyou would run normally and hand it tollmsim.rt.run()(see the table above).- SimPy 3's dynamic
defusedattribute — SimPy 3 tracks defusal by attribute presence (hasattr). llmsim'sEvent.defusedis an ordinary boolean that always exists; read and set it directly. Code that probed for the attribute has no equivalent and should test the flag instead.
Worked port: M/M/1 queue¶
The smallest of the three canonical models: one server, Poisson arrivals,
exponential service. Full sources: benchmarks/models/mm1.py (SimPy 3) and
benchmarks/llmsim_models/mm1.py (llmsim).
Setup replaces the Environment plus a hand-seeded random.Random with a
single seeded Sim:
SimPy 3
rng = random.Random(seed)
env = simpy.Environment()
server = simpy.Resource(env, capacity=1)
total_wait = 0.0
total_service = 0.0
num_served = 0
llmsim
sim = Sim(seed=seed)
rng = sim.rng
server = Resource(sim, capacity=1)
total_wait = 0.0
total_service = 0.0
num_served = 0
Two deltas: Sim(seed=seed) seeds the simulation's own RNG stream, so
rng = sim.rng replaces constructing a random.Random by hand, and llmsim
classes are imported directly (from llmsim import Resource, Sim) rather than
referenced through a simpy. module prefix.
The customer process shows the two renames every port hits — env.now →
sim.now and env.timeout() → sim.delay() — while the resource
request/release protocol is untouched:
SimPy 3
def customer() -> object:
nonlocal total_wait, total_service, num_served
arrived_at = env.now
with server.request() as slot:
yield slot
total_wait += env.now - arrived_at
service_time = rng.expovariate(service_rate)
total_service += service_time
num_served += 1
yield env.timeout(service_time)
llmsim
def customer(sim: Sim) -> Generator[Event[Any], Any, None]:
nonlocal total_wait, total_service, num_served
arrived_at = sim.now
with server.request() as slot:
yield slot
total_wait += sim.now - arrived_at
service_time = rng.expovariate(service_rate)
total_service += service_time
num_served += 1
yield sim.delay(service_time)
The signature changes for two reasons. First, sim.spawn() always injects the
Sim as the process's first argument, so every llmsim process takes a sim
parameter instead of closing over a global env. Second, the benchmark port
is fully typed — Generator[Event[Any], Any, None] where the SimPy original
says object — because llmsim's public API is fully annotated and the
project's strict type gates cover its models. The annotations are optional in
your own code; def customer(sim): works the same.
Spawning shows the other structural change — you hand spawn the function,
and llmsim starts it, rather than calling the generator function yourself:
SimPy 3
def arrivals() -> object:
for _ in range(num_customers):
yield env.timeout(rng.expovariate(arrival_rate))
env.process(customer())
llmsim
def arrivals(sim: Sim) -> Generator[Event[Any], Any, None]:
for _ in range(num_customers):
yield sim.delay(rng.expovariate(arrival_rate))
sim.spawn(customer)
And the entry point is a one-line-each swap:
SimPy 3
env.process(arrivals())
env.run()
llmsim
sim.spawn(arrivals)
sim.run()
Worked port: machine shop¶
The classic SimPy machine-shop example: machines make parts, break down at
random, and queue for shared repairers under a preemptive priority discipline.
It exercises interrupts, PreemptiveResource, and process-to-process
interaction. Full sources: benchmarks/models/machine_shop.py and
benchmarks/llmsim_models/machine_shop.py.
Class-based models port the same way as free functions. Note how the two background processes are started:
SimPy 3
def __init__(
self,
env: simpy.Environment,
repairers: simpy.PreemptiveResource,
rng: random.Random,
) -> None:
self.env = env
self.repairers = repairers
self.rng = rng
self.parts_made = 0
self.broken = False
self.process = env.process(self._work())
env.process(self._break())
llmsim
def __init__(
self, sim: Sim, repairers: PreemptiveResource, rng: random.Random
) -> None:
self.sim = sim
self.repairers = repairers
self.rng = rng
self.parts_made = 0
self.broken = False
self.process = sim.spawn(self._work)
sim.spawn(self._break)
env.process(self._work()) calls the generator method itself; sim.spawn
takes the bound method uncalled — sim.spawn(self._work) — and starts it.
(The constructor parameter list is also formatted more compactly in the port;
that is style, not semantics.)
The work loop is where interrupt handling migrates. except simpy.Interrupt
becomes except Interrupt with llmsim.Interrupt, and the preemptive
request with a priority is unchanged:
SimPy 3
def _work(self) -> object:
while True:
remaining = _time_per_part(self.rng)
while remaining > 0:
started = self.env.now
try:
yield self.env.timeout(remaining)
remaining = 0.0
self.parts_made += 1
except simpy.Interrupt:
self.broken = True
remaining -= self.env.now - started
with self.repairers.request(priority=1) as repair:
yield repair
yield self.env.timeout(REPAIR_TIME)
self.broken = False
llmsim
def _work(self, sim: Sim) -> Generator[Event[Any], Any, None]:
while True:
remaining = _time_per_part(self.rng)
while remaining > 0:
started = self.sim.now
try:
yield self.sim.delay(remaining)
remaining = 0.0
self.parts_made += 1
except Interrupt:
self.broken = True
remaining -= self.sim.now - started
with self.repairers.request(priority=1) as repair:
yield repair
yield self.sim.delay(REPAIR_TIME)
self.broken = False
Because spawn injects the Sim even into bound methods, _work gains a
sim parameter; this model keeps using self.sim in the body (the two are
the same object), so the parameter simply absorbs the injection.
Process-to-process interaction — one process interrupting another through the
Process handle returned at spawn time — is unchanged:
SimPy 3
def _break(self) -> object:
while True:
yield self.env.timeout(self.rng.expovariate(1.0 / MEAN_TIME_TO_FAILURE))
if not self.broken:
self.process.interrupt()
llmsim
def _break(self, sim: Sim) -> Generator[Event[Any], Any, None]:
while True:
yield self.sim.delay(self.rng.expovariate(1.0 / MEAN_TIME_TO_FAILURE))
if not self.broken:
self.process.interrupt()
The entry point repeats the M/M/1 setup pattern, this time with a
PreemptiveResource and a bounded run:
SimPy 3
rng = random.Random(seed)
env = simpy.Environment()
repairers = simpy.PreemptiveResource(env, capacity=num_repairers)
machines = [_Machine(env, repairers, rng) for _ in range(num_machines)]
env.run(until=SHOP_DURATION)
llmsim
sim = Sim(seed=seed)
rng = sim.rng
repairers = PreemptiveResource(sim, capacity=num_repairers)
machines = [_Machine(sim, repairers, rng) for _ in range(num_machines)]
sim.run(until=SHOP_DURATION)
Worked port: grid conveyor¶
A 100×100 grid of single-capacity conveyor segments; items enter at the
top-left and contend for shared cells along an L-shaped route. This is the
resource-contention-at-scale model: ten thousand Resource objects in one
simulation. Full sources: benchmarks/models/grid_conveyor.py and
benchmarks/llmsim_models/grid_conveyor.py. (The Store family does not
appear in any of the three canonical models; its mapping lives in the
concept table.)
Building ten thousand resources is the same dict comprehension in both:
SimPy 3
rng = random.Random(seed)
env = simpy.Environment()
segments = {
(row, col): simpy.Resource(env, capacity=1)
for row in range(rows)
for col in range(cols)
}
route = _route(rows, cols)
total_transit = 0.0
delivered = 0
llmsim
sim = Sim(seed=seed)
rng = sim.rng
segments = {
(row, col): Resource(sim, capacity=1)
for row in range(rows)
for col in range(cols)
}
route = _route(rows, cols)
total_transit = 0.0
delivered = 0
The item process — request a segment, transit, release, repeat down the
route — carries over with only the by-now-familiar renames (sim parameter,
sim.now, sim.delay):
SimPy 3
def item() -> object:
nonlocal total_transit, delivered
entered_at = env.now
for cell in route:
with segments[cell].request() as slot:
yield slot
yield env.timeout(SEGMENT_TRANSIT)
total_transit += env.now - entered_at
delivered += 1
llmsim
def item(sim: Sim) -> Generator[Event[Any], Any, None]:
nonlocal total_transit, delivered
entered_at = sim.now
for cell in route:
with segments[cell].request() as slot:
yield slot
yield sim.delay(SEGMENT_TRANSIT)
total_transit += sim.now - entered_at
delivered += 1
The payoff at this scale is determinism you can lean on. Like SimPy, llmsim
processes events in (time, priority, eid) order, so ties never depend on
hash order or wall clock; beyond SimPy, the seeded sim.rng makes the whole
run a pure function of the seed — the same seed produces the same event trace
on every backend, worker count, and build, and CI enforces exact KPI equality
between this port and its SimPy reference. That guarantee is what the
parallel layers build on when you later scale a migrated model out; see
Which parallelism do I need? when you are
ready for that step.