Spaces:
Runtime error
Runtime error
File size: 12,887 Bytes
aaef24a | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 | # /// 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()
|