# cake 1.1.3 vs cake 1.2.0 vs EEVDF — decision-path delta

**Status: SOURCE-DERIVED THEORY, UNMEASURED.** Every claim here comes from reading
`cake.bpf.c` / `intf.h` / the kernel tree. Nothing below has a benchmark row behind it.
Treat it as the hypothesis to test, never as a finding. Line numbers were read on
`ad36ee54e10a`; re-grep before citing.

Written 2026-07-27 to answer the standing question: **why does 1.2.0 lose Palworld's
frame-time tails to EEVDF when it ties the median?** (regression table in STATE.md).

---

## 1. The shape the theory has to explain

From the measured regression: median frame identical to three decimals, but ~4-5
**isolated** severe stalls per 45 s — every bad frame a singleton, normal frame before
and after. 1% low (worst-avg) −15.2%, frames >16.7 ms at 3.5× with zero overlap.

That shape rules out a lot. It is not overhead (the median would move). It is not
locality or cache (misses cost microseconds, not 10-40 ms). A 10-40 ms late frame with
clean neighbours is **a runnable task that nothing ran** — a queueing/notification
failure, not a throughput one. The nearest known relatives are both in the corpus: the
WAKE_DSQ starvation class (2026-07-02) and "no kick on non-wakeup insert" (2026-06-17).

So the question is narrow: **where can 1.2.0 leave a runnable task un-notified, and for
how long?**

## 2. Two defects that answer it

### 2a. A home-routed wake can receive no kick at all

`cake_enqueue`, wake path (`:1269-1273`):

```c
idle = scx_bpf_pick_idle_cpu(p->cpus_ptr, 0);
if (idle >= 0)
        scx_bpf_kick_cpu(idle, CAKE_KICK_IDLE);
else if (!home && !cake_wake_preempt(p, tcpu)) {
        /* 3-neighbour compute-occupant probe */
}
return;
```

When the wake was homed (`home == true`) **and** no idle CPU exists, the `else if` is
unreachable and the function returns having inserted the task and kicked nobody. The
preempt decision just above (`:1228`) is the only other notification path:

```c
bool preempt = home && live && ran < HOME_PREEMPT_YOUNG_NS &&
               time_before(vt + HOME_PREEMPT_BASE_MARGIN_NS - (ran >> 1), live);
```

`HOME_PREEMPT_YOUNG_NS` is `SLICE_NS / CAKE_HOME_PREEMPT_YOUNG_DIV` = 3.00 ms / 32 =
**93.75 µs**. So the occupant is only preemptible during the first 93.75 µs of its slice
— 3.1% of it. For the remaining 96.9%, a homed wake onto a busy CPU is inserted silently
and waits for that CPU to reach `ops.dispatch` on its own, which happens at slice expiry.

At the 1.2.0 contended slice (`CAKE_CONTENDED_SLICE_NUM/DEN` = 3/2 → **4.5 ms**) that is
a single-hop wait of up to 4.5 ms. Frame budget at 120 fps is 8.33 ms.

This is the same defect class the corpus already named twice, in a new location. It is
also, per NO BUCKETS, not fixable by adding a rescue kick — the source ordering rule is
wrong: *insert and notify are two separable operations, and one path takes the first
without the second.*

### 2b. The starvation seals are denominated in vtime, but vtime is not wall time

`CAKE_PEER_WAKE_HYST_SLICES` and `CAKE_OVF_RESCUE_HYST_SLICES` are both 2 → **6.00 ms**
of hysteresis before a peer/overflow wake head can be taken. Read as wall time that is
already most of a frame. But they are compared against `cake.frontier`, and the frontier
advances only in `cake_running` (`:1580`).

The kernel does **not** call `ops.running` on a keep-running slice refill. So on a CPU
that keeps re-selecting the same task, the frontier does not advance at wall rate — it
advances at roughly 1/*d* of it, where *d* is queue depth. A constant written as "6 ms"
becomes 6·*d* ms in wall terms. At *d* = 4 that is 24 ms: one frame missed outright, two
if it lands badly. The stall is *rare* because it needs depth and phase to coincide —
which matches "4-5 isolated singletons per 45 s" far better than any continuous tax.

### 2c. Nothing bounds either one

`SCX_OPS_DEFINE(cake_ops, …)` registers **no `.tick`**. Cake has no periodic callback, so
a missed kick is uncorrectable until some unrelated event happens to visit that CPU. The
only backstop is the sched_ext watchdog at 5000 ms — a 40 ms stall clears it by 125×, so
it is invisible to every safety mechanism cake has.

Compare EEVDF, where the same situation is bounded three ways: `check_preempt_wakeup_fair`
runs the eligibility test on every wakeup; `hrtick` arms a timer at the current entity's
remaining slice; and the 1 kHz tick calls `entity_tick` → `resched_curr` regardless. Its
worst-case wake→run under load is bounded around 700 µs by construction. Cake's is
bounded by nothing.

**This is the core asymmetry.** Cake's fast path is competitive — it wins 8 sealed
benchmarks and ties Palworld's median. What it lacks is EEVDF's *floor*: a mechanism that
guarantees a bound when the fast path's assumptions do not hold. Every one of cake's
sealed wins is a mean/throughput result. Every one of its game-tail losses is a
worst-case result. That is exactly the signature of a system with a good average and no
bound.

## 3. Why 1.1.3 is not the control it looks like

Tempting reading: 1.2.0 regressed, so restore 1.1.3. The source does not support it.

| | 1.1.3 | 1.2.0 |
|---|---|---|
| base slice | 2 ms | 3 ms (4.5 ms contended) |
| wake tiering | none | WAKE_DSQ + OVF, 6 ms vtime seals |
| depth multiplier | none | present |
| kick policy | cruder latch | margin-gated, 93.75 µs young window |
| register spills | 0 (maintained deliberately) | present |

1.1.3's kick policy is *cruder* but its **coverage is better** — a latch does not have a
93.75 µs window to miss. And its exposure is smaller in every dimension that sets stall
length: half the slice, no vtime-denominated seals, no depth multiplier. So 1.2.0's tail
exposure is larger *even though* its decision quality is higher.

That means 1.1.3-vs-1.2.0 is not a clean A/B of one variable — it is at least four
changes at once, and the interesting comparison is not "which is better" but "1.2.0's
decisions with 1.1.3's coverage".

Caveat on measuring it at all: **shipped 1.1.3 does not currently attach** on kernel
7.1.5-1-cachyos. It builds and launches (logs `scx_cake 1.1.3 … 16 CPUs, 1 LLCs, profile:
Gaming`) but libbpf reports `map 'cake_ops': BPF map skeleton link is uninitialized`,
sched_ext never enables, and the runner correctly refuses the arm. Unresolved; the third
arm is blocked on it.

## 4. Register spills (the 1.1.3 discipline)

1.1.3 held spills at zero deliberately — `cake.bpf.c:1752` in that tree carries the
comment *"3 args + 1 survivor = 0 spills"*, and its function decomposition is a
**compiler-pressure** split, not a readability one. BPF gives r0 for return, r1-r5
clobbered by every call, r6-r9 callee-saved: only four values survive a call. The 1.1.3
discipline is that each subprogram re-derives its own state from `p` plus globals rather
than receiving it, trading a few ALU ops for zero spill traffic.

1.2.0 lost this. Note the honest framing though: the measured hot path is ~0.2% of one
core in-game (2026-07-09), so spills are **not** a plausible cause of 10-40 ms stalls.
Getting to zero is a maintained code-quality invariant and a real per-wake win at
scheduler frequencies (see the ns×ops/sec framing), but it should not be sold as the
Palworld fix. Two separate goals; do not let one justify the other.

## 5. What this predicts, and how to kill it

The theory is falsifiable and cheap to attack:

- **P1.** Stalls concentrate on CPUs with queue depth ≥ 2 at wake time, not on cold or
  migrated tasks. (Migration is already FALSIFIED as the cause — schbench-light corpus.)
- **P2.** Stall durations cluster near multiples of the *contended* slice (4.5 ms), not
  the base slice.
- **P3.** A census of `cake_enqueue` wake exits taking the `home && idle < 0` path
  produces a nonzero rate under game load. If that count is ~0, **2a is dead**.
- **P4.** Frontier advance rate divided by wall time falls below 1/2 on at least one CPU
  during a stall window. If it tracks wall time, **2b is dead**.

P3 and P4 are both read-only counters — no policy change, no receipt, no game A/B needed
to run them. They are the cheapest discriminating experiment and should come first.

## 6. The change this implies (if the theory survives)

Not four patches — one architectural change with three inseparable parts, per the
no-partial-fixes law:

1. **Insert and notify become one indivisible operation.** No path may insert without
   deciding notification. Zero spills falls out of this as a *consequence* of the
   restructure, not as a separate workstream.
2. **Seals measured in wall time, not vtime.** A "6 ms" constant must mean 6 ms
   regardless of queue depth.
3. **`ops.tick` as the enforcement mechanism** — the bound that makes a missed kick
   self-correcting instead of permanent.

They only work together: (1) without (3) still has no floor; (3) without (2) enforces a
bound whose units drift.

**Register this as one experiment before touching code** — hypothesis, all three steps,
the endpoint (Palworld 1% low worst-avg), the step budget, and the kill conditions. Do
not measure step 1 alone and conclude (CLAUDE.md §Running an experiment): removing the
current seals before the wall-time replacement lands is precisely the case where an
interim read is biased low by construction.
