marimo-learn / queueing /03_littles_law.py
Greg Wilson
feat: overhaul for relaunch
aaef24a
# /// script
# requires-python = ">=3.13"
# dependencies = [
# "altair",
# "asimpy",
# "marimo",
# "polars==1.24.0",
# ]
# ///
import marimo
__generated_with = "0.20.4"
app = marimo.App(width="medium")
@app.cell(hide_code=True)
def _():
import marimo as mo
import random
import statistics
import altair as alt
import polars as pl
from asimpy import Environment, Process, Resource
return Environment, Process, Resource, alt, mo, pl, random, statistics
@app.cell(hide_code=True)
def _(mo):
mo.md(r"""
# Little's Law
## *The Universal Conservation Law of Queues*
Little's Law states that in a stable system, L = λW, where:
- L = mean number of customers in the system
- λ = mean arrival rate
- W = mean time a customer spends in the system
The law holds regardless of arrival or service distributions, number of
servers, or scheduling discipline.
""")
return
@app.cell(hide_code=True)
def _(mo):
mo.md(r"""
## Why It Is Surprising
Little's Law holds without any assumptions about the distribution of arrival rates or service times. It does not matter whether arrivals are Poisson, deterministic, or correlated, whether service times are exponential, constant, or heavy-tailed, whether there is one server or a hundred, or what scheduling discipline is used (FIFO, LIFO, random, or priority). As long as the system is stable and stationary, $L = \lambda W$. This universality is remarkable because almost every other formula in queueing theory *does* depend on distributional assumptions.
""")
return
@app.cell(hide_code=True)
def _(mo):
mo.md(r"""
## Practical Use
Because $L = \lambda W$ is universal, it can be used to measure hard-to-observe quantities from easy-to-observe ones. For example, the mean number of requests in a web server ($L$) and the observed request rate ($\lambda$) immediately give the mean response time ($W = L/\lambda$) without needing to instrument individual request latencies.
""")
return
@app.cell(hide_code=True)
def _(mo):
mo.md(r"""
## Proof Sketch
Consider a flow diagram where time runs horizontally and each customer traces a horizontal line from arrival to departure. The area under all lines equals both:
- $\sum_i W_i$ (sum of individual sojourn times), and
- $\int_0^T L(t)\,dt$ (integral of instantaneous queue length).
Dividing both sides by $T$ and taking $T \to \infty$:
$$\bar{L} = \lambda \bar{W}$$
The argument is purely combinatorial: no probability is needed.
""")
return
@app.cell(hide_code=True)
def _(mo):
mo.md(r"""
## Simulation Design
The simulation verifies Little's Law across three configurations:
- M/M/1 (Poisson arrivals, exponential service, one server)
- M/D/1 (Poisson arrivals, deterministic service, one server)
- M/M/3 (Poisson arrivals, exponential service, three servers)
For each configuration, $L$ is measured two ways: by direct sampling of the
queue length, and by computing $\lambda W$ from observed throughput and mean
sojourn time.
The scenarios below sweep arrival rate $\lambda$ over (0.5, 1.0, 1.5, 2.0, 2.5)
at server capacities 2, 3, and 4. The `error_%` column shows how closely
$L_{\text{Little}} = \lambda W$ matches the directly sampled $L_{\text{direct}}$.
""")
return
@app.cell
def _():
SEED = 192 # random seed for reproducibility
SIM_TIME = 1000 # simulated time units per scenario
SAMPLE_INTERVAL = 1 # sim-time units between Monitor samples
SERVICE_RATE = 1.0 # exponential service rate (mu) for random service
return SAMPLE_INTERVAL, SEED, SERVICE_RATE, SIM_TIME
@app.cell(hide_code=True)
def _(mo):
mo.md(r"""
## Random Processes
""")
return
@app.cell
def _(Process, SERVICE_RATE, random):
class RandomCustomer(Process):
def init(self, server, in_system, sojourn_times):
self.server = server
self.in_system = in_system
self.sojourn_times = sojourn_times
async def run(self):
arrival = self.now
self.in_system[0] += 1
async with self.server:
await self.timeout(random.expovariate(SERVICE_RATE))
self.in_system[0] -= 1
self.sojourn_times.append(self.now - arrival)
return (RandomCustomer,)
@app.cell
def _(Process, RandomCustomer, random):
class RandomArrivals(Process):
def init(self, rate, server, in_system, sojourn_times):
self.rate = rate
self.server = server
self.in_system = in_system
self.sojourn_times = sojourn_times
async def run(self):
while True:
await self.timeout(random.expovariate(self.rate))
RandomCustomer(self._env, self.server, self.in_system, self.sojourn_times)
return (RandomArrivals,)
@app.cell(hide_code=True)
def _(mo):
mo.md(r"""
## Deterministic Processes
""")
return
@app.cell
def _(Process):
DETERMINISTIC_SERVICE = 1.0 # fixed service time for M/D/1 scenarios
class DeterministicCustomer(Process):
def init(self, server, in_system, sojourn_times):
self.server = server
self.in_system = in_system
self.sojourn_times = sojourn_times
async def run(self):
arrival = self.now
self.in_system[0] += 1
async with self.server:
await self.timeout(DETERMINISTIC_SERVICE)
self.in_system[0] -= 1
self.sojourn_times.append(self.now - arrival)
return DETERMINISTIC_SERVICE, DeterministicCustomer
@app.cell
def _(DeterministicCustomer, Process, random):
class DeterministicArrivals(Process):
def init(self, rate, server, in_system, sojourn_times):
self.rate = rate
self.server = server
self.in_system = in_system
self.sojourn_times = sojourn_times
async def run(self):
while True:
await self.timeout(random.expovariate(self.rate))
DeterministicCustomer(self._env, self.server, self.in_system, self.sojourn_times)
return (DeterministicArrivals,)
@app.cell
def _(Process, SAMPLE_INTERVAL):
class Monitor(Process):
def init(self, in_system, samples):
self.in_system = in_system
self.samples = samples
async def run(self):
while True:
self.samples.append(self.in_system[0])
await self.timeout(SAMPLE_INTERVAL)
return (Monitor,)
@app.cell
def _(Environment, Monitor, Resource, SIM_TIME, statistics):
def run_scenario(lam, capacity, arrivals_cls):
in_system = [0]
sojourns = []
samples = []
env = Environment()
server = Resource(env, capacity=capacity)
arrivals_cls(env, lam, server, in_system, sojourns)
Monitor(env, in_system, samples)
env.run(until=SIM_TIME)
L_direct = statistics.mean(samples)
W = statistics.mean(sojourns)
lam_obs = len(sojourns) / SIM_TIME
L_little = lam_obs * W
error = 100.0 * (L_little - L_direct) / L_direct
return {
"lambda": round(lam_obs, 3),
"capacity": capacity,
"W": round(W, 3),
"L_direct": round(L_direct, 3),
"L_little": round(L_little, 3),
"error_%": round(error, 2),
}
return (run_scenario,)
@app.cell(hide_code=True)
def _(mo):
mo.md(r"""
## Verification: L = λW
""")
return
@app.cell
def _(RandomArrivals, SEED, pl, random, run_scenario):
random.seed(SEED)
rows = []
for lam in (0.5, 1.0, 1.5, 2.0, 2.5):
for capacity in (2, 3, 4):
rows.append(run_scenario(lam, capacity, RandomArrivals))
df = pl.DataFrame(rows)
df
return (df,)
@app.cell
def _(alt, df, pl):
points = (
alt.Chart(df)
.mark_point(size=100, filled=True)
.encode(
x=alt.X("L_direct:Q", title="L (direct sample)"),
y=alt.Y("L_little:Q", title="L = λW (Little's Law)"),
color=alt.Color("capacity:O", title="Capacity"),
tooltip=["lambda:Q", "capacity:O", "L_direct:Q", "L_little:Q", "error_%:Q"],
)
)
max_val = max(df["L_direct"].to_list()) * 1.1
diagonal = (
alt.Chart(pl.DataFrame({"x": [0.0, max_val], "y": [0.0, max_val]}))
.mark_line(color="gray", strokeDash=[4, 4])
.encode(x="x:Q", y="y:Q")
)
(diagonal + points).properties(title="Little's Law: Direct Sample vs. λW")
return
@app.cell(hide_code=True)
def _(mo):
mo.md(r"""
## The Error Column
The large error at $\lambda = 2.5$, capacity $= 2$ is a stability problem, not a simulation bug.
Little's Law only holds in steady state. For an M/M/c queue, steady state requires
that the arrival rate $\lambda$ be less than capacity times service rate $\mu$.
With `SERVICE_RATE = 1.0` and `capacity = 2`,
the maximum sustainable throughput is $2 \times 1.0 = 2.0$.
At $\lambda = 2.5$, the load exceeds service capacity, so the queue grows without bound.
By the time the simulation ends,
hundreds of customers are waiting in queue, and their sojourns are never recorded.
## Key Points
1. `Monitor` samples `in_system[0]` every `SAMPLE_INTERVAL` time units to
estimate $L$ directly without any queueing formula.
2. The `error_%` column shows that $L_{\text{direct}}$ and $\lambda W$ agree to within
less than 1% for all stable configurations, even though the service-time
distributions are completely different.
3. `DeterministicCustomer` uses the fixed `DETERMINISTIC_SERVICE` constant
rather than a random draw; everything else in the simulation is unchanged.
The law still holds.
4. `Resource(env, capacity=3)` creates a three-slot server for M/M/3.
Setting the arrival rate to 2.4 gives utilization 0.8 per server.
""")
return
@app.cell(hide_code=True)
def _(mo):
mo.md(r"""
## Understanding the Math
### The area argument made concrete
Draw a horizontal time axis from $t = 0$ to $t = T$. Each customer gets a horizontal bar starting at their arrival time and ending at their departure time. The length of their bar is exactly their sojourn time $W_i$ — the total time they spend in the system. At any moment $t$, the number of bars that cross that vertical slice is exactly $L(t)$, the instantaneous number of customers in the system.
Now compute the total area under all the bars in two different ways. First, add up the lengths of all the bars: total area $= \sum_i W_i$. Second, integrate the height of the stack over time: total area $= \int_0^T L(t)\,dt$. These are the same area, so $\sum_i W_i = \int_0^T L(t)\,dt$.
Divide both sides by $T$. The right side becomes the time-average $\bar{L}$. The left side becomes $(n/T) \cdot \bar{W}$, where $n$ is the total number of customers and $\bar{W}$ is their mean sojourn time. As $T \to \infty$, $n/T \to \lambda$ (the long-run arrival rate). That gives $\bar{L} = \lambda \bar{W}$, which is Little's Law.
### No distribution required
The argument above uses only geometry. There is no probability distribution, no exponential assumption, no Poisson process. The shape of each bar (i.e., how long each customer takes) can be anything. This is why the law applies to M/M/1, M/D/1, M/M/3, and every other configuration equally.
### Using it in practice
Suppose you run a web service. Your monitoring dashboard shows $\lambda = 500$ requests per second and your server logs show a mean response time of $W = 20$ milliseconds. Little's Law immediately tells you that the mean number of active requests in the system is $L = \lambda W = 500 \times 0.02 = 10$ requests. Alternatively, if you observe $L$ and $\lambda$ but not individual response times, you get $W = L/\lambda$ without any per-request timing instrumentation.
### Units check
$\lambda$ has units of customers per unit time; $W$ has units of time; so $L = \lambda W$ is dimensionless — a pure count of customers. Always verify units when applying Little's Law to a new problem: if your units do not cancel correctly, you have applied the law incorrectly.
### Stability condition
Little's Law requires the system to reach steady state: over the long run, arrivals and departures must balance. If $\lambda > \mu$ (the arrival rate exceeds the service rate), the queue grows without bound. $L = \infty$ and $W = \infty$; the law still holds, but it tells you the system is broken, not that it is well-behaved.
""")
return
if __name__ == "__main__":
app.run()