marimo-team/marimo-learn
3
1# /// script2# requires-python = ">=3.13"3# dependencies = [4# "altair",5# "asimpy",6# "marimo",7# "polars==1.24.0",8# ]9# ///10 11import marimo12 13__generated_with = "0.20.4"14app = marimo.App(width="medium")15 16 17@app.cell(hide_code=True)18def _():19 import marimo as mo20 import random21 import statistics22 23 import altair as alt24 import polars as pl25 26 from asimpy import Environment, Process, Queue27 28 return Environment, Process, Queue, alt, mo, pl, random, statistics29 30 31@app.cell(hide_code=True)32def _(mo):33 mo.md(r"""34 # Priority Starvation35 36 ## *When High-Priority Traffic Crowds Out Low-Priority Jobs*37 38 A single server processes two job classes:39 40 - High-priority jobs (class H) arrive frequently and are served quickly.41 - Low-priority jobs (class L) arrive rarely and take longer to serve.42 43 The server always picks the highest-priority job available. Total server utilization $\rho = \rho_H + \rho_L < 1$, so the server has spare capacity on average. Yet low-priority jobs can wait far longer than the utilization level suggests they should.44 45 ### Static Priority: Starvation at Moderate Load46 47 With a static priority queue, high-priority jobs *never* yield to low-priority ones. Even when $\rho_H < 1$, high-priority bursts can lock out low-priority jobs for extended periods. The mean wait for low-priority jobs under a static non-preemptive priority queue is:48 49 $$W_L = \frac{\overline{s}_L}{1-\rho_H} \cdot \frac{1}{1-\rho_H-\rho_L}$$50 51 This diverges as $\rho_H \to 1$ independently of $\rho_L$. As $\rho_H$ approaches 100%, low-priority jobs wait arbitrarily long, even if only a few low-priority jobs ever arrive.52 53 ### Aging: Solving Starvation Creates Oscillation54 55 The standard remedy for starvation is *priority aging*: a waiting job's priority improves over time until it eventually beats even high-priority arrivals. This guarantees finite wait for all jobs.56 57 However, aging introduces a new pathology. When aged low-priority jobs finally burst through, they occupy the server and leave a backlog of high-priority jobs waiting. The high-priority queue then drains, and the cycle repeats — producing oscillating bursts rather than smooth, uniform service.58 59 ### Intuition60 61 Suppose H jobs arrive in random bursts. During a burst, the server never pauses for L jobs. An L job unlucky enough to arrive at the start of a long burst must wait for every H job in that burst to be served before getting its turn. As bursts grow more frequent (larger $\rho_H$), the expected burst length grows, and with it the expected wait for that unlucky L job. The math confirms: starvation is a real risk at moderate $\rho_H$, not just at extreme loads.62 63 ### What aging does64 65 Aging assigns each waiting L job a maximum patience time $T_{\max}$. After waiting $T_{\max}$, the job is promoted to high priority. This caps the worst-case wait: no L job can wait longer than $T_{\max}$ plus one service time. Mathematically, the effective $W_L$ is bounded by $T_{\max} + 1/\mu_L$.66 67 ## Practical Implications68 69 Priority queues appear throughout computing:70 71 - OS scheduling: interactive processes (high priority) vs. batch jobs (low priority). Linux uses dynamic priority aging (nice values + sleep bonuses) to avoid starvation.72 - Network QoS: real-time traffic (VoIP, video) vs. bulk data. Traffic shaping with Deficit Round Robin (DRR) or Weighted Fair Queuing (WFQ) guarantees bandwidth shares without starvation.73 - Database query planning: short OLTP queries vs. long OLAP queries. Resource groups and query timeouts implement a form of aging.74 """)75 return76 77 78@app.cell(hide_code=True)79def _(mo):80 mo.md(r"""81 ## Implementation82 83 Two runs are compared:84 85 1. Static priority: H jobs are inserted as `(0, ...)` and L jobs as `(1, ...)` into a `Queue(priority=True)`. The server always picks the smallest key first, so H jobs are always served before L jobs.86 2. Aging: an `Ager` process wakes up every `AGING_INTERVAL` time units, inspects waiting L jobs, and promotes sufficiently old ones by reducing their priority key until it falls below the H threshold and they move into the server's feed queue.87 """)88 return89 90 91@app.cell(hide_code=True)92def _(mo):93 sim_time_slider = mo.ui.slider(94 start=0,95 stop=100_000,96 step=1_000,97 value=20_000,98 label="Simulation time",99 )100 101 aging_threshold_slider = mo.ui.slider(102 start=1.0,103 stop=60.0,104 step=1.0,105 value=15.0,106 label="Aging threshold",107 )108 109 seed_input = mo.ui.number(110 value=192,111 step=1,112 label="Random seed",113 )114 115 run_button = mo.ui.run_button(label="Run simulation")116 117 mo.vstack([118 sim_time_slider,119 aging_threshold_slider,120 seed_input,121 run_button,122 ])123 return aging_threshold_slider, seed_input, sim_time_slider124 125 126@app.cell127def _(aging_threshold_slider, seed_input, sim_time_slider):128 SIM_TIME = int(sim_time_slider.value)129 AGING_THRESHOLD = float(aging_threshold_slider.value)130 SEED = int(seed_input.value)131 SERVICE_RATE_HI = 2.0132 SERVICE_RATE_LO = 1.0133 ARRIVAL_RATE_LO = 0.2134 return (135 AGING_THRESHOLD,136 ARRIVAL_RATE_LO,137 SEED,138 SERVICE_RATE_HI,139 SERVICE_RATE_LO,140 SIM_TIME,141 )142 143 144@app.cell145def _(Process):146 class StaticPriorityServer(Process):147 def init(self, hi_q, lo_q, sojourn_hi, sojourn_lo):148 self.hi_q = hi_q149 self.lo_q = lo_q150 self.sojourn_hi = sojourn_hi151 self.sojourn_lo = sojourn_lo152 153 async def _serve(self, arrival, svc, record):154 await self.timeout(svc)155 record.append(self.now - arrival)156 157 async def run(self):158 while True:159 if not self.hi_q.is_empty():160 arrival, svc = await self.hi_q.get()161 await self._serve(arrival, svc, self.sojourn_hi)162 elif not self.lo_q.is_empty():163 arrival, svc = await self.lo_q.get()164 await self._serve(arrival, svc, self.sojourn_lo)165 else:166 await self.timeout(0.01)167 168 return (StaticPriorityServer,)169 170 171@app.cell172def _(AGING_THRESHOLD, Process):173 class AgingServer(Process):174 def init(self, hi_q, lo_q, sojourn_hi, sojourn_lo):175 self.hi_q = hi_q176 self.lo_q = lo_q177 self.sojourn_hi = sojourn_hi178 self.sojourn_lo = sojourn_lo179 180 async def run(self):181 while True:182 lo_aged = (183 not self.lo_q.is_empty()184 and self.now - self.lo_q._items[0][0] >= AGING_THRESHOLD185 )186 if lo_aged:187 arrival, svc = await self.lo_q.get()188 await self.timeout(svc)189 self.sojourn_lo.append(self.now - arrival)190 elif not self.hi_q.is_empty():191 arrival, svc = await self.hi_q.get()192 await self.timeout(svc)193 self.sojourn_hi.append(self.now - arrival)194 elif not self.lo_q.is_empty():195 arrival, svc = await self.lo_q.get()196 await self.timeout(svc)197 self.sojourn_lo.append(self.now - arrival)198 else:199 await self.timeout(0.01)200 201 return (AgingServer,)202 203 204@app.cell205def _(Process, SERVICE_RATE_HI, random):206 class HiSource(Process):207 def init(self, rate, q):208 self.rate = rate209 self.q = q210 211 async def run(self):212 while True:213 await self.timeout(random.expovariate(self.rate))214 svc = random.expovariate(SERVICE_RATE_HI)215 await self.q.put((self.now, svc))216 217 return (HiSource,)218 219 220@app.cell221def _(ARRIVAL_RATE_LO, Process, SERVICE_RATE_LO, random):222 class LoSource(Process):223 def init(self, q):224 self.q = q225 226 async def run(self):227 while True:228 await self.timeout(random.expovariate(ARRIVAL_RATE_LO))229 svc = random.expovariate(SERVICE_RATE_LO)230 await self.q.put((self.now, svc))231 232 return (LoSource,)233 234 235@app.cell236def _(237 AgingServer,238 Environment,239 HiSource,240 LoSource,241 Queue,242 SIM_TIME,243 StaticPriorityServer,244 statistics,245):246 def simulate(arrival_rate_hi, use_aging):247 env = Environment()248 hi_q = Queue(env)249 lo_q = Queue(env)250 sojourn_hi = []251 sojourn_lo = []252 HiSource(env, arrival_rate_hi, hi_q)253 LoSource(env, lo_q)254 if use_aging:255 AgingServer(env, hi_q, lo_q, sojourn_hi, sojourn_lo)256 else:257 StaticPriorityServer(env, hi_q, lo_q, sojourn_hi, sojourn_lo)258 env.run(until=SIM_TIME)259 return sojourn_hi, sojourn_lo260 261 def mean_or_none(lst):262 return statistics.mean(lst) if lst else None263 264 def pct_or_none(lst, p):265 if not lst:266 return None267 return sorted(lst)[int(p * len(lst))]268 269 return mean_or_none, pct_or_none, simulate270 271 272@app.cell273def _(274 ARRIVAL_RATE_LO,275 SEED,276 SERVICE_RATE_HI,277 SERVICE_RATE_LO,278 mean_or_none,279 pl,280 random,281 simulate,282):283 def sweep():284 sweep_rows = []285 for rho_hi in [0.10, 0.20, 0.40, 0.60, 0.70, 0.80]:286 rate_hi = rho_hi * SERVICE_RATE_HI287 hi, lo = simulate(rate_hi, use_aging=False)288 rho_total = rho_hi + ARRIVAL_RATE_LO / SERVICE_RATE_LO289 sweep_rows.append({290 "rho_hi": rho_hi,291 "rho_total": rho_total,292 "mean_W_hi": mean_or_none(hi),293 "mean_W_lo": mean_or_none(lo),294 })295 return pl.DataFrame(sweep_rows)296 297 random.seed(SEED)298 df_sweep = sweep()299 return (df_sweep,)300 301 302@app.cell303def _(304 ARRIVAL_RATE_LO,305 SERVICE_RATE_HI,306 SERVICE_RATE_LO,307 mean_or_none,308 pct_or_none,309 pl,310 simulate,311):312 FIXED_RHO_HI = 0.70313 rho_total = FIXED_RHO_HI + ARRIVAL_RATE_LO / SERVICE_RATE_LO314 def compare():315 rate_hi = FIXED_RHO_HI * SERVICE_RATE_HI316 hi_static, lo_static = simulate(rate_hi, use_aging=False)317 hi_aging, lo_aging = simulate(rate_hi, use_aging=True)318 319 compare_rows = [320 {321 "policy": "static", "class": "hi", "n": len(hi_static),322 "mean_W": mean_or_none(hi_static),323 "p95": pct_or_none(hi_static, 0.95),324 "p99": pct_or_none(hi_static, 0.99),325 },326 {327 "policy": "static", "class": "lo", "n": len(lo_static),328 "mean_W": mean_or_none(lo_static),329 "p95": pct_or_none(lo_static, 0.95),330 "p99": pct_or_none(lo_static, 0.99),331 },332 {333 "policy": "aging", "class": "hi", "n": len(hi_aging),334 "mean_W": mean_or_none(hi_aging),335 "p95": pct_or_none(hi_aging, 0.95),336 "p99": pct_or_none(hi_aging, 0.99),337 },338 {339 "policy": "aging", "class": "lo", "n": len(lo_aging),340 "mean_W": mean_or_none(lo_aging),341 "p95": pct_or_none(lo_aging, 0.95),342 "p99": pct_or_none(lo_aging, 0.99),343 },344 ]345 return pl.DataFrame(compare_rows)346 347 df_compare = compare()348 return (df_compare, FIXED_RHO_HI, rho_total,)349 350 351@app.cell(hide_code=True)352def _(AGING_THRESHOLD, ARRIVAL_RATE_LO, SERVICE_RATE_LO, mo):353 mo.md(f"""354 ## Part 1 — Static Priority: Effect of Hi-Priority Load on Lo-Priority Wait355 356 Lo-priority: arrival rate {ARRIVAL_RATE_LO}, mean service {1 / SERVICE_RATE_LO:.1f},357 ρ_lo = {ARRIVAL_RATE_LO / SERVICE_RATE_LO:.2f}358 359 Aging threshold: {AGING_THRESHOLD} time units360 """)361 return362 363 364@app.cell365def _(df_sweep):366 df_sweep367 return368 369 370@app.cell(hide_code=True)371def _(FIXED_RHO_HI, mo, rho_total):372 mo.md(f"""373 ## Part 2 — Static vs. Aging at ρ_hi = {FIXED_RHO_HI:.2f}, ρ_total = {rho_total:.2f}374 """)375 return376 377 378@app.cell379def _(df_compare):380 df_compare381 return382 383 384@app.cell385def _(alt, df_compare, df_sweep):386 df_plot = df_sweep.unpivot(387 on=["mean_W_hi", "mean_W_lo"],388 index=["rho_hi", "rho_total"],389 variable_name="job_class",390 value_name="mean_W",391 )392 sweep_chart = (393 alt.Chart(df_plot)394 .mark_line(point=True)395 .encode(396 x=alt.X("rho_hi:Q", title="Hi-priority utilization (ρ_hi)"),397 y=alt.Y("mean_W:Q", title="Mean sojourn time (W)"),398 color=alt.Color("job_class:N", title="Job class"),399 tooltip=["rho_hi:Q", "job_class:N", "mean_W:Q"],400 )401 .properties(title="Priority Starvation: Effect of Hi-Priority Load")402 )403 compare_chart = (404 alt.Chart(df_compare)405 .mark_bar()406 .encode(407 x=alt.X("class:N", title="Job class"),408 y=alt.Y("mean_W:Q", title="Mean sojourn time (W)"),409 color=alt.Color("policy:N", title="Policy"),410 xOffset="policy:N",411 tooltip=["policy:N", "class:N", "mean_W:Q", "p99:Q"],412 )413 .properties(title="Static Priority vs. Aging")414 )415 (sweep_chart | compare_chart)416 return417 418 419@app.cell(hide_code=True)420def _(mo):421 mo.md(r"""422 ## Understanding the Math423 424 ### Mean wait for two-priority queues425 426 Let $\lambda_i$, $\mu_i$, and $\rho_i = \lambda_i / \mu_i$ be the arrival rate, service rate, and utilization of class $i \in \{H, L\}$. For a non-preemptive priority queue:427 428 $$W_H = \frac{R_0}{1 - \rho_H}$$429 430 $$W_L = \frac{R_0}{(1 - \rho_H)(1 - \rho_H - \rho_L)}$$431 432 where $R_0 = \tfrac{1}{2}(\lambda_H \overline{s_H^2} + \lambda_L \overline{s_L^2})$ is the mean residual work seen by an arriving customer. The ratio $W_L / W_H = 1/(1 - \rho_H)$ grows without bound as $\rho_H \to 1$.433 434 ### Utilization of each class435 436 Let $\lambda_H$ be the arrival rate of high-priority jobs (H) and $\mu_H$ be their service rate. The utilization contributed by H jobs alone is $\rho_H = \lambda_H / \mu_H$ — the fraction of server time that H jobs would consume if they were the only class. Similarly, $\rho_L = \lambda_L / \mu_L$ for low-priority jobs. The total utilization is $\rho = \rho_H + \rho_L$. Requiring $\rho < 1$ means the server has enough capacity for both classes on average.437 438 ### Why "on average" is not enough439 440 Even when $\rho < 1$, randomness creates bursts of H arrivals. During a burst, the server is continuously occupied by H jobs, and L jobs must wait in the background. The mean wait for low-priority jobs in a non-preemptive priority queue is:441 442 $$W_L = \frac{R_0}{(1 - \rho_H)(1 - \rho_H - \rho_L)}$$443 444 where $R_0$ is the mean residual work in the system when a job arrives. The critical observation is the factor $(1 - \rho_H)$ in the denominator. As $\rho_H \to 1$, this factor approaches zero and $W_L \to \infty$ — even if $\rho_L$ stays small and the total load $\rho$ is comfortably below 1.445 446 ### The trade-off447 448 Without aging, $W_L$ can be infinite when $\rho_H$ is large. With aging, $W_L \leq T_{\max} + 1/\mu_L$, but during promotion events the effective $\rho_H$ spikes temporarily, increasing $W_H$. Choosing $T_{\max}$ is a design decision: a small $T_{\max}$ protects L jobs but forces more promotions and penalizes H jobs more often; a large $T_{\max}$ is kinder to H jobs but allows L jobs to wait longer. There is no setting that simultaneously minimizes both — the trade-off is fundamental.449 """)450 return451 452 453if __name__ == "__main__":454 app.run()455 