The EEVDF Scheduler

EEVDFEarliest Eligible Virtual Deadline First — is the default Linux scheduler for ordinary (SCHED_OTHER/SCHED_NORMAL) tasks since kernel 6.6 (October 2023), where it replaced the task-picking logic of the Completely Fair Scheduler (CFS) inside kernel/sched/fair.c. Originating in a 1995 paper by Ion Stoica and Hussein Abdel-Wahab (EEVDF paper; cited as [1] in sched-eevdf.rst) and brought to Linux by Peter Zijlstra, EEVDF keeps CFS’s fairness substrate — per-task virtual runtime and nice-derived weights — but adds two ideas that give it a principled latency lever CFS never had: each task is eligible to run only once its lag (its owed-but-unreceived service) is non-negative, and among the eligible tasks the scheduler runs the one with the earliest virtual deadline, where a task’s deadline is computed from its requested time slice r_i. A task that asks for a shorter slice gets an earlier deadline and thus runs sooner — responsiveness without a larger CPU share. The version pinned throughout this note is Linux 6.12, a maintained longterm (LTS) branch — 6.12.108 was current on 2026-09-02, while mainline had moved on to 7.3-rc1 (kernel.org releases, fetched 2026-09-04). EEVDF did not arrive finished in 6.6 and stand still: between 6.6 and 6.12 the run-queue tree was re-keyed, an O(1) pick fastpath was added, short-slice wakeup preemption was introduced, and delayed dequeue — which Zijlstra called the thing “missing from the EEVDF paper” — landed. Those deltas are walked in What Actually Changed Between 6.6 and 6.12. This note is the integrative overview of the picking algorithm, the numbers behind it, and why it beats CFS; the underlying lag/deadline algebra and the slice mechanics live in dedicated sibling notes.

Scope of this note

This is the “what EEVDF is and why” overview. The symbol-by-symbol math — lag, the eligibility test, the vd_i = ve_i + r_i/w_i deadline formula — belongs to Eligibility Lag and Virtual Deadlines in EEVDF. The shared vruntime/weight invariant is Virtual Runtime and the Fair Scheduling Invariant. The base_slice/sched_setattr request-size details are Time Slices and Request Sizes in EEVDF. This note recaps each just enough to explain the integrated pick, and cross-links the rest to avoid duplication.

Mental Model: Eligible First, Then Earliest Deadline

EEVDF makes its decision in two stages, and the kernel source states them plainly: “EEVDF selects the best runnable task from two criteria: (1) the task must be eligible (must be owed service), and (2) from those tasks that meet criterion 1, we select the one with the earliest virtual deadline” (fair.c, pick comment).

Think of it as two gates. The first gate, eligibility, is the fairness gate: a task is eligible only if it is at or behind its fair share — formally, if its lag (entitled service minus received service) is ≥ 0. A task that has been running and is now ahead of its share has negative lag and is filtered out until virtual time advances enough that it is owed service again. This gate is what makes EEVDF fair — no task can run while it is ahead of everyone else. The second gate, earliest deadline, is the latency gate: among the (possibly many) eligible tasks, run the one whose virtual deadline is soonest. Because a task’s deadline is its eligible time plus its requested slice, a task that requested a small slice has a tight deadline and wins the latency race; a task that requested a large slice has a loose deadline and yields to the urgent ones.

flowchart TB
  RUNNABLE["all runnable fair tasks<br/>in the rbtree (keyed on deadline)"] --> G1{"Gate 1: eligible?<br/>lag &gt;= 0<br/>(owed service)"}
  G1 -- "no (ahead of share)" --> SKIP["skip — not yet eligible"]
  G1 -- "yes" --> ELIG["eligible set"]
  ELIG --> G2{"Gate 2: earliest<br/>virtual deadline<br/>vd_i = ve_i + r_i/w_i"}
  G2 --> PICK["run this task"]
  PICK -.-> RTP["RUN_TO_PARITY:<br/>keep running it until<br/>it is no longer eligible<br/>(avoid thrash-switching)"]

The EEVDF two-gate pick. What it shows: the fairness gate (lag ≥ 0) filters the runnable set down to tasks that are actually owed CPU, then the latency gate selects the earliest virtual deadline among those — and RUN_TO_PARITY lets the chosen task keep the CPU until it stops being eligible, instead of re-deciding every tick. The insight: fairness and latency are handled by separate gates, which is exactly what CFS could not do — in CFS the only lever was vruntime, so the only way to lower latency was to grant more CPU. Here, a short requested slice buys an earlier deadline (gate 2) without affecting the fairness accounting (gate 1). The deadline formula is walked symbol-by-symbol in Eligibility Lag and Virtual Deadlines in EEVDF.

Lag: The Currency of Fairness, Drawn

Everything in EEVDF is denominated in lag. The 1995 paper defines it directly: for a client i that became active at time t_i0, if S_i(t_i0, t) is the service it should have received by time t in an idealised fluid-flow system where the CPU can be split infinitely finely, and s_i(t_i0, t) is what it actually received, then

lag_i(t) = S_i(t_i0, t) − s_i(t_i0, t)

(Stoica & Abdel-Wahab 1995, Eq. 3). Walking the symbols: S_i is the entitlement — the integral of the task’s share f_i = w_i / Σw_j over the interval, i.e. “how much CPU a perfectly fair machine would have handed you”. s_i is the receipt — actual nanoseconds on a CPU. Their difference is a signed debt in units of time. Positive lag means the machine owes you; negative lag means you owe the machine.

Two properties make lag usable as a scheduling primitive. First, lag is conserved: Σ lag_i = 0 at every instant (paper Lemma 2; restated in fair.c as \Sum lag_i = 0 above avg_vruntime_add()). One task can only get ahead by putting others behind, so the vector of lags is a zero-sum ledger of the whole run queue. Second, lag is bounded. Theorem 1 of the paper proves that in a steady system −r_max < lag_k < max(r_max, q), where r_max is the largest request the task ever makes and q is the quantum; Corollary 2 tightens this to −q < lag_k < q for any task whose requests never exceed one quantum. Linux quotes that theorem verbatim in the comment above entity_lag() — “EEVDF gives the following limit for a steady state system: -r_max < lag < max(r_max, q)” — and then enforces its own, cruder, symmetric clamp instead, for reasons the same comment explains.

Jonathan Corbet’s worked example makes the ledger concrete: three CPU-bound tasks A, B, C of equal weight on one CPU, each running a 30 ms slice to exhaustion (Corbet, “Completing the EEVDF scheduler”, LWN 969062).

flowchart LR
  subgraph T0["t = 0 ms · nobody has run"]
    A0["A<br/>lag 0"]:::eligible
    B0["B<br/>lag 0"]:::eligible
    C0["C<br/>lag 0"]:::eligible
  end
  subgraph T1["t = 30 ms · A ran 30 ms"]
    A1["A<br/>lag −20"]:::inelig
    B1["B<br/>lag +10"]:::eligible
    C1["C<br/>lag +10"]:::eligible
  end
  subgraph T2["t = 60 ms · B ran 30 ms"]
    A2["A<br/>lag −10"]:::inelig
    B2["B<br/>lag −10"]:::inelig
    C2["C<br/>lag +20"]:::eligible
  end
  subgraph T3["t = 90 ms · C ran 30 ms"]
    A3["A<br/>lag 0"]:::eligible
    B3["B<br/>lag 0"]:::eligible
    C3["C<br/>lag 0"]:::eligible
  end
  T0 --> T1 --> T2 --> T3
  classDef eligible fill:#dff0d8,stroke:#3c763d,color:#1b3a1b;
  classDef inelig fill:#f2dede,stroke:#a94442,color:#4a1f1f;

Lag accumulating and being shed across one full round of three equal-weight tasks. What it shows: every 30 ms interval hands each task an entitlement of 10 ms (one third of 30 ms). The task that actually ran banks −20 ms of lag (it got 30, was owed 10); the two that did not each bank +10 ms. Green nodes are eligible (lag ≥ 0, allowed to be picked); red nodes are ineligible and are filtered out of the pick entirely. The insight: the ledger sums to zero at every column, and after one full round every task is back at lag 0 — fairness is not enforced by a heuristic, it is a bookkeeping identity. Note also that at t = 30 ms the scheduler has no choice but to pick B or C: A is ineligible even though nothing else about it changed. That single filter is the whole fairness half of EEVDF.

Linux never computes S_i and s_i separately. It tracks only vruntime (weighted service, see Virtual Runtime and the Fair Scheduling Invariant) and recovers lag from the run queue’s weighted-average virtual time V, via the identity spelled out in fair.c: lag_i = S − s_i = w_i * (V − v_i). Because the w_i factor is a positive constant per task, the kernel tracks only the virtual lag vl_i = V − v_i — that is the se->vlag field of struct sched_entity — and multiplies back by weight only when it matters. entity_lag() then clamps the result:

static s64 entity_lag(u64 avruntime, struct sched_entity *se)
{
	s64 vlag, limit;
 
	vlag = avruntime - se->vruntime;
	limit = calc_delta_fair(max_t(u64, 2*se->slice, TICK_NSEC), se);
 
	return clamp(vlag, -limit, limit);
}

Line by line: avruntime is V, the weighted average virtual runtime of everything on this run queue. vlag = V − v_i is the raw virtual lag. limit is two slices, floored at one tick (TICK_NSEC, i.e. 1 ms at CONFIG_HZ=1000), converted into virtual time by calc_delta_fair() so it is measured in the same units as vlag. The final clamp() bounds the stored lag symmetrically. The comment above the function explains why this clamp is not in the paper: because Linux approximates V as a weighted average over a set of entities that tasks are constantly joining and leaving, “it is possible — by addition/removal/reweight to the tree — to move V around and end up with a larger lag than we started with.” The clamp stops that drift from compounding into an unbounded debt or credit.

The Eligibility Test, Drawn

Eligibility is a single comparison against the run queue’s centre of mass, and drawing it is the fastest way to internalise why an earliest-deadline task can be told to wait.

The kernel maintains V, the weight-weighted average of all vruntimes on the run queue. The derivation in fair.c is three lines: start from conservation, Σ lag_i = 0; substitute lag_i = w_i(V − v_i); solve for V to get V = (Σ v_i·w_i) / Σ w_i. Then lag_i ≥ 0 collapses to V ≥ v_i: a task is eligible exactly when its own virtual runtime is at or behind the weighted average.

flowchart LR
  subgraph LINE["virtual-time number line for one cfs_rq"]
    direction LR
    L1["A<br/>v = 100<br/>lag +30"]:::eligible
    L2["B<br/>v = 120<br/>lag +10"]:::eligible
    V["V = 130<br/>weighted average<br/>(avg_vruntime)"]:::pivot
    L3["C<br/>v = 140<br/>lag −10<br/>deadline 145 ← earliest!"]:::inelig
    L4["D<br/>v = 160<br/>lag −30"]:::inelig
    L1 --- L2 --- V --- L3 --- L4
  end
  V -. "everything left of V<br/>is owed service → ELIGIBLE" .-> L1
  V -. "everything right of V<br/>has over-run → SKIPPED" .-> L4
  classDef eligible fill:#dff0d8,stroke:#3c763d,color:#1b3a1b;
  classDef inelig fill:#f2dede,stroke:#a94442,color:#4a1f1f;
  classDef pivot fill:#fcf8e3,stroke:#8a6d3b,color:#4a3a12,stroke-width:3px;

The eligibility test as a position on the virtual-time number line. What it shows: V = avg_vruntime() is a moving pivot; tasks to its left have positive lag and are eligible, tasks to its right have negative lag and are not. Task C has the earliest virtual deadline in the whole run queue (145) and would win outright under a pure Earliest-Deadline-First rule — but it sits to the right of V, so it is skipped, and the pick falls to A or B. The insight: this is the exact scenario the ordinary reading of the algorithm’s name gets wrong. “Earliest Eligible Virtual Deadline First” parses as “earliest (eligible virtual deadline)”, not “earliest-eligible, (virtual deadline)”: eligibility is a hard filter applied before the deadline comparison, which is why pick_eevdf() cannot simply take the leftmost node of a deadline-sorted tree.

The implementation avoids the division that the formula suggests, because dividing loses precision and a task exactly at V must test as eligible:

static int vruntime_eligible(struct cfs_rq *cfs_rq, u64 vruntime)
{
	struct sched_entity *curr = cfs_rq->curr;
	s64 avg = cfs_rq->avg_vruntime;
	long load = cfs_rq->avg_load;
 
	if (curr && curr->on_rq) {
		unsigned long weight = scale_load_down(curr->load.weight);
 
		avg += entity_key(cfs_rq, curr) * weight;
		load += weight;
	}
 
	return avg >= (s64)(vruntime - cfs_rq->min_vruntime) * load;
}

Line by line: cfs_rq->avg_vruntime is not V — it is the numerator Σ (v_i − v0)·w_i, kept relative to the origin v0 = cfs_rq->min_vruntime so the products fit in 64 bits (the comment records that the measured maximum of key * weight was “~44 bits for a kernel build”). cfs_rq->avg_load is the denominator Σ w_i. The if (curr && curr->on_rq) block folds in the currently running entity, which is deliberately not in the red-black tree — forgetting it would bias V. The return statement is the inequality V ≥ v_i with both sides multiplied through by load, turning a division into a multiplication and eliminating rounding error entirely. entity_eligible() is a one-line wrapper that passes se->vruntime.

Why V is not min_vruntime

cfs_rq->min_vruntime is a monotonic floor used as a numeric origin, not the fairness pivot. avg_vruntime() returns cfs_rq->min_vruntime + avg, and the comment above it warns that the rounding must have “a left bias” so that avg_vruntime() + 0 still tests as eligible. Confusing the two is the most common source of wrong mental models here; the floor’s own semantics are covered in Virtual Runtime and the Fair Scheduling Invariant.

Virtual Deadlines: Turning a Request Size into a Priority

Once the eligible set is known, the winner is the one with the earliest virtual deadline. The paper derives it in two steps: the request becomes eligible at virtual time ve_i (Eq. 7), and the deadline is set so that the service owed between ve_i and the deadline equals the request length r (Eq. 8), giving V(d) = V(e) + r/w_i. Linux writes the same formula in update_deadline():

	/*
	 * EEVDF: vd_i = ve_i + r_i / w_i
	 */
	se->deadline = se->vruntime + calc_delta_fair(se->slice, se);

Symbol by symbol: vd_i is the virtual deadline (se->deadline). ve_i is the virtual eligible time — in Linux, simply the task’s current se->vruntime, since a task that has just consumed a request is by construction at its own zero-lag point. r_i is the request size, held in se->slice, in real nanoseconds. w_i is the nice-derived weight (see Nice Values Weights and Priority Scaling). calc_delta_fair(delta, se) performs the r_i / w_i division in fixed point — it scales a real-time duration by NICE_0_LOAD / se->load.weight, so for a nice-0 task it is the identity.

flowchart TB
  R["r_i = se->slice<br/>request size, real ns<br/>default: base_slice_ns"] --> CD
  W["w_i = se->load.weight<br/>from nice via prio_to_weight[]<br/>nice 0 → 1024"] --> CD
  CD["calc_delta_fair(r_i, se)<br/>= r_i × NICE_0_LOAD / w_i<br/>convert real ns to virtual ns"] --> ADD
  VE["ve_i = se->vruntime<br/>virtual eligible time<br/>(zero-lag point)"] --> ADD
  ADD["se->deadline = ve_i + r_i/w_i"] --> KEY["rbtree key<br/>(entity_before compares deadlines)"]
  ADD --> CMP{"smaller r_i<br/>or larger w_i?"}
  CMP -- "smaller r_i" --> EARLY["earlier deadline<br/>→ picked sooner<br/>→ same total CPU"]
  CMP -- "larger w_i" --> EARLY2["earlier deadline AND<br/>slower vruntime growth<br/>→ picked sooner AND more CPU"]

How a request size and a weight become a scheduling position. What it shows: the two inputs enter calc_delta_fair() together, and the result is added to the task’s current virtual runtime to produce the tree key. The insight — and this is the whole point of EEVDF over CFS: the two inputs have different consequences. Shrinking r_i moves the deadline earlier and nothing else; the task’s vruntime still advances at the same rate while it runs, so its long-run share is untouched. Raising w_i moves the deadline earlier and slows vruntime growth, so it buys both latency and throughput. That is why “make it more responsive” and “give it more CPU” became two separate knobs, where CFS had only nice.

Worked numerically, at CONFIG_HZ=1000 on a machine where base_slice_ns has settled at 3 ms (see the scaling table below), with NICE_0_LOAD = 1024 and the weights from prio_to_weight[]:

Tasknicew_ise->slice (r_i)r_i / w_i in virtual nsDeadline offset from vruntime
bulk build job010243,000,000 (default)3,000,000 × 1024/1024+3.00 ms
audio thread01024200,000 (sched_setattr)200,000 × 1024/1024+0.20 ms
nice −5 compile−531213,000,000 (default)3,000,000 × 1024/3121+0.98 ms
nice +5 batch+53353,000,000 (default)3,000,000 × 1024/335+9.17 ms

Virtual-deadline offsets for four tasks sharing a CPU. What it shows: the audio thread, at the same nice as the build job, lands a deadline fifteen times closer purely by asking for a 200 µs slice. The nice −5 task gets a closer deadline too, but it also runs a 3× slower vruntime clock, so it additionally consumes about 3× the CPU. The insight: columns 4 and 6 are the two independent levers. A latency-sensitive thread should reach for column 4 (the slice request), not for nice — and that option simply did not exist before EEVDF.

Mechanical Walk-through: How a Pick Happens in fair.c

The shared substrate (recapped from Virtual Runtime and the Fair Scheduling Invariant): every runnable fair task is a struct sched_entity carrying a vruntime that advances, when the task runs, by its real runtime scaled by weight (calc_delta_fair()); the run queue tracks a virtual-time origin. EEVDF adds three per-entity fields visible in the 6.12 sched_entity: vlag (the task’s virtual lag), slice (its requested time slice r_i), and deadline (its virtual deadline). Here is the actual path:

1. Eligibility. entity_eligible() calls vruntime_eligible(), which compares the task’s vruntime against the run queue’s weighted-average virtual time V (computed by avg_vruntime()). The source comment gives the relation precisely: “Entity is eligible once it received less service than it ought to have, eg. lag >= 0. lag_i = S - s_i = w_i*(V - v_i); lag_i >= 0 -> V >= v_i (fair.c, vruntime_eligible). In words: a task is eligible iff its own virtual runtime v_i is at or behind the fleet-wide average V. The lag itself is clamped to a bounded range to stop it growing unboundedly when tasks are added/removed (entity_lag() clamps to ± roughly twice the slice, with a TICK_NSEC floor).

2. Deadline. When a task is enqueued (place_entity()) or has consumed its request (update_deadline()), its virtual deadline is set as se->deadline = se->vruntime + calc_delta_fair(se->slice, se). The comment labels this exactly: “EEVDF: vd_i = ve_i + r_i / w_i — virtual deadline equals (virtual) eligible time plus the requested slice divided by weight (fair.c, update_deadline / place_entity). The /w_i is the same weighting as vruntime: a heavier (higher-priority) task’s slice consumes less virtual time, so its deadline advances more slowly.

3. Pick. pick_eevdf() is where the two gates meet, and in 6.12 it is not one algorithm but four paths tried in order, each cheaper than the next. Reading it top to bottom:

flowchart TB
  START["pick_eevdf(cfs_rq)"] --> P0{"cfs_rq->nr_running == 1?"}
  P0 -- yes --> R0["return curr (or the sole entity)<br/><b>path: direct</b><br/>no eligibility test at all"]:::fast
  P0 -- no --> CURR["drop curr if it is<br/>!on_rq or !entity_eligible()"]
  CURR --> P1{"RUN_TO_PARITY and<br/>curr->vlag == curr->deadline?"}
  P1 -- yes --> R1["return curr — slice protection<br/><b>path: parity</b>"]:::fast
  P1 -- no --> P2{"leftmost node of the<br/>deadline-sorted tree<br/>eligible?"}
  P2 -- yes --> R2["return it — it has the<br/>earliest deadline overall<br/><b>path: O(1) fastpath</b>"]:::fast
  P2 -- no --> HEAP["augmented heap search:<br/>descend left while<br/>vruntime_eligible(left->min_vruntime),<br/>else test node, else go right"]:::slow
  HEAP --> R3["earliest-deadline eligible entity<br/><b>path: heap search</b> · O(log n)"]:::slow
  R0 --> FIN["if curr is eligible and<br/>entity_before(curr, best),<br/>prefer curr"]
  R1 --> FIN
  R2 --> FIN
  R3 --> FIN
  classDef fast fill:#dff0d8,stroke:#3c763d,color:#1b3a1b;
  classDef slow fill:#fcf8e3,stroke:#8a6d3b,color:#4a3a12;

The four paths through pick_eevdf() in v6.12. What it shows: the expensive tree walk is the last resort, guarded by three progressively cheaper checks. The insight: the O(log n) “find the earliest-deadline node that is also eligible” search — the part that makes EEVDF sound costly — is almost never executed. Abel Wu measured the distribution when he added the O(1) fastpath, across hackbench, netperf, tbench and schbench: the single-runnable direct path took 67.6–93.0% of picks, RUN_TO_PARITY a further 2.0–11.2%, the leftmost-eligible fastpath 4.9–25.7%, and the heap search only 0.12–1.71% (Wu, ee4373dc902c “sched/eevdf: O(1) fastpath for task selection”). EEVDF’s asymptotic cost is O(log n); its measured cost is dominated by two pointer comparisons.

The heap search itself deserves a sentence, because it is the piece that makes the two gates composable in one tree. Each node carries se->min_vruntime = min(se->vruntime, left->min_vruntime, right->min_vruntime) as augmented data. Since the tree is sorted by deadline, a left subtree always holds earlier deadlines; if that subtree’s minimum vruntime passes vruntime_eligible(), then some entity in it is eligible and is strictly better than anything at or to the right of the current node, so the walk descends left. If not, the current node itself is the earliest remaining candidate — test it, and on failure go right. That is why the tree is described in the source comment as “sorted on deadline, but also functions as a heap based on the vruntime.”

Pick pathConditionCostShare of picks (Wu’s measurement)
directnr_running == 1O(1), no eligibility test67.6 % – 93.0 %
parityRUN_TO_PARITY and current still holds its sliceO(1)2.0 % – 11.2 %
fastpathleftmost (earliest-deadline) node is eligibleO(1) (cached leftmost)4.9 % – 25.7 %
heap searchleftmost is ineligibleO(log n)0.12 % – 1.71 %

Measured pick-path distribution on a dual-socket Xeon Platinum 8260 (2 NUMA nodes, 24C/48T each), turbo disabled, inside a normal CPU cgroup, across four benchmarks. The insight: the shape of this table is the answer to “isn’t EEVDF slower than CFS?” — for the overwhelming majority of picks it does strictly less work than CFS’s leftmost-node walk, because a single-runnable run queue skips the eligibility test entirely.

4. Run-to-parity, and the vlag sentinel. RUN_TO_PARITY is the feature that keeps EEVDF from thrashing. Its guard reads curr->vlag == curr->deadline, which looks nonsensical — vlag is a lag, deadline is a virtual timestamp — until you find the comment in set_next_entity():

		/*
		 * HACK, stash a copy of deadline at the point of pick in vlag,
		 * which isn't used until dequeue.
		 */
		se->vlag = se->deadline;

What this actually means: se->vlag is only read as a lag on the dequeue path, so while a task is running the field is free. The scheduler reuses it to snapshot the deadline at the moment of the pick. update_deadline() overwrites se->deadline the instant the task consumes its request. So the test curr->vlag == curr->deadline is really asking “has this task’s deadline been recomputed since it was picked?” — equivalently, “is it still inside the slice it was granted?” If yes, pick_eevdf() returns it immediately and the scheduler does not re-decide. The features file states the intent: “Inhibit (wakeup) preemption until the current task has either matched the 0-lag point or until it has exhausted its slice” (features.h). Without it, several equally-eligible tasks would ping-pong every tick and destroy cache locality.

That protection is deliberately breakable. PREEMPT_SHORT (added for 6.12) lets a waking task with a shorter slice cancel it, by the blunt expedient of making the sentinel no longer match:

	if (do_preempt_short(cfs_rq, pse, se) && se->vlag == se->deadline)
		se->vlag = se->deadline + 1;

do_preempt_short() fires only when the waking entity pse genuinely has pse->slice < se->slice and is itself eligible; the + 1 then forces the next pick_eevdf() to re-evaluate honestly (fair.c, check_preempt_wakeup_fair). A symmetrical did_preempt_short() in update_curr() reschedules a task that lost its protection this way as soon as it becomes ineligible.

5. Charge and re-deadline. As the task runs, update_curr() advances its vruntime via calc_delta_fair() and calls update_deadline(). The instant vruntime crosses deadline, the task has consumed its request: update_deadline() resets se->slice to sysctl_sched_base_slice (unless the task set a custom slice), computes a new deadline, and returns true to signal a reschedule. The task is now further right in the tree and another eligible task with an earlier deadline can win.

Placement, Sleep, and Wake — the Full Task Lifecycle

Picking is only half the algorithm. The other half is placement: what vruntime and deadline a task gets when it enters or re-enters the competition. Get this wrong and the fairness invariant leaks — a task that sleeps and wakes could reset its debt, or be handed an unearned head start. In v6.12 the lifecycle is a genuine state machine, and the sched_delayed state at its centre is new since 6.6.

stateDiagram-v2
  [*] --> Fresh: fork
  Fresh --> Queued: place_entity with ENQUEUE_INITIAL<br/>PLACE_DEADLINE_INITIAL halves the first vslice<br/>— ease into the competition
  Queued --> Running: pick_eevdf selects it<br/>set_next_entity stashes vlag = deadline<br/>— slice protection armed
  Running --> Queued: slice consumed — update_deadline resets<br/>se.slice, recomputes deadline, returns true
  Running --> Queued: preempted — an earlier deadline woke,<br/>or PREEMPT_SHORT cancelled the protection
  Running --> CheckLag: task blocks, DEQUEUE_SLEEP
  Queued --> CheckLag: task blocks
  CheckLag --> Delayed: DELAY_DEQUEUE and NOT eligible<br/>sched_delayed = 1, dequeue_entity returns false<br/>— stays on the rq, still counted in nr_running
  CheckLag --> Sleeping: eligible, lag is non-negative<br/>update_entity_lag stores vlag, really leaves the rq
  Delayed --> Sleeping: pick_next_entity selects it, sees sched_delayed,<br/>calls dequeue_entities with DEQUEUE_DELAYED<br/>DELAY_ZERO clips a positive vlag back to 0
  Delayed --> Queued: woken while still on the rq —<br/>requeue_delayed_entity re-places it at zero lag<br/>if vlag turned positive, else keeps the debt
  Sleeping --> Queued: wakeup — place_entity restores the stored vlag,<br/>inflated by PLACE_LAG to survive its own effect on V
  Queued --> [*]: exit
  Running --> [*]: exit

The v6.12 lifecycle of a fair sched_entity, with the code path that drives each transition. What it shows: an ineligible task that blocks does not leave the run queue — it enters the Delayed state and keeps competing (in the sense of accruing virtual time) until either the scheduler picks it, at which point it is really dequeued, or it wakes up again. The insight: this state is the entire answer to the “sleep to wipe your debt” attack. With DELAY_DEQUEUE on — the v6.12 default — a task cannot reach Sleeping while it still owes service; the only routes out of Delayed either burn the debt off first or carry it verbatim. Turn the feature off in /sys/kernel/debug/sched/features and the CheckLag → Delayed edge disappears, collapsing the machine back to its 6.6 shape.

Placement on wake (place_entity). The naive implementation — restore the saved vlag and set se->vruntime = V − vlag — is subtly wrong, because adding the task changes V. The comment in place_entity() derives the correction in full: with W = Σw_j before the join, the post-join average is V' = V − w_i·vl_i/(W + w_i), so the lag the task actually ends up with is vl'_i = vl_i − w_i·vl_i/(W + w_i), which is strictly smaller than what was stored. Left uncorrected, “lag can quickly evaporate” — a task that over-ran would silently have its debt forgiven a little on every sleep/wake cycle. The fix is to invert the relation and inflate the lag before placement:

		load = cfs_rq->avg_load;
		if (curr && curr->on_rq)
			load += scale_load_down(curr->load.weight);
 
		lag *= load + scale_load_down(se->load.weight);
		if (WARN_ON_ONCE(!load))
			load = 1;
		lag = div_s64(lag, load);
	}
 
	se->vruntime = vruntime - lag;

Line by line: load is W, the summed weight already on the run queue, plus the running entity if there is one. The multiply-then-divide implements vl_i = (W + w_i)·vl'_i / W — scale the stored lag up by the factor the join will scale it down by. se->vruntime = vruntime - lag then positions the task relative to V such that after the join its lag is exactly what it was at dequeue. This is PLACE_LAG, on by default; disabling it reverts to what the source calls “EEVDF placement strategy #2”, i.e. joining at zero lag.

Three placement features modify the result, all default-on in v6.12:

FeatureEffectWhy
PLACE_LAGRestore the pre-sleep lag, inflated as abovePreserves the fairness ledger across sleep/wake; without it, sleeping is a free debt reset
PLACE_DEADLINE_INITIALA brand-new task (ENQUEUE_INITIAL) gets vslice /= 2“The existing tasks will be, on average, halfway through their slice, as such start tasks off with half a slice to ease into the competition”
PLACE_REL_DEADLINEOn a non-sleep dequeue (migration, cgroup move) store deadline − vruntime and restore it on the far sideKeeps a task’s relative urgency when it changes CPU or run queue, instead of resetting it to a full slice

The three placement features. The insight: each one exists to stop a specific way of gaming or degrading the invariant — respectively “sleep to forget”, “fork bomb to jump the queue”, and “migrate to refresh your deadline”.

Where sleeper fairness went

CFS had an explicit FAIR_SLEEPERS heuristic that credited waking tasks with a bonus, plus GENTLE_FAIR_SLEEPERS to blunt it. EEVDF deleted both. Zijlstra’s 86bfbb7ce4f6 says why in one sentence: “the FAIR_SLEEPERS thing places things too far to the left and messes up the deadline aspect of EEVDF” (commit 86bfbb7ce4f6, “sched/fair: Add lag based placement”). Lag-based placement replaces the heuristic with an accounting identity: a task that genuinely under-ran wakes with positive lag and is therefore eligible immediately, which is sleeper fairness, derived rather than tuned.

Configuration: base_slice, custom slices, and the request-size lever

In 6.12 the default request size is a compile-time constant in fair.c:

/*
 * Minimal preemption granularity for CPU-bound tasks:
 *
 * (default: 0.75 msec * (1 + ilog(ncpus)), units: nanoseconds)
 */
unsigned int sysctl_sched_base_slice			= 750000ULL;
static unsigned int normalized_sysctl_sched_base_slice	= 750000ULL;

The 750 µs figure is almost never the effective default

sched_init_granularity() calls update_sysctl() at boot, which multiplies the normalized value by a CPU-count factor from get_update_sysctl_factor(). With the default SCHED_TUNABLESCALING_LOG the factor is 1 + ilog2(min(num_online_cpus(), 8)). So base_slice_ns on a real machine is 1.5 ms, 2.25 ms, or 3 ms — not 750 µs. This matches the field report from the ChromeOS team, who described “the base EEVDF time-slice length, which defaults to 1.5 ms for a two-core machine” (OSPM 2024 report, LWN 981371). Note also that the doc comment above the constant still calls it “minimal preemption granularity”, a CFS-era phrase with no meaning under EEVDF — one of several stale comments in this file.

Online CPUsilog2(min(cpus, 8))factoreffective base_slice_ns
101750 µs
2–3121.5 ms
4–7232.25 ms
8 or more343.0 ms

Boot-time scaling of the base slice. What it shows: the factor saturates at 8 CPUs — num_online_cpus() is clamped with min_t(unsigned int, num_online_cpus(), 8) — so a 4-socket server and an 8-core laptop get the same 3 ms. The insight: if you are reasoning about deadlines on a real box, read /sys/kernel/debug/sched/base_slice_ns rather than assuming the source constant. Writing to that file sets the value directly and is not re-scaled.

Lowering base_slice_ns shortens every default-slice task’s deadline (more preemption, lower latency, more context-switch overhead); raising it does the opposite. The in-tree documentation adds one caveat worth knowing: “In case CONFIG_HZ results in base_slice_ns < TICK_NSEC, the value of base_slice_ns will have little to no impact on the workloads” (sched-design-CFS.rst) — with CONFIG_HZ=250, TICK_NSEC is 4 ms and a 3 ms slice simply cannot be enforced by the periodic tick.

The headline EEVDF feature is per-task slice requests via sched_setattr(2). A task fills sched_attr.sched_runtime (nanoseconds) to ask for a specific slice. Before EEVDF, sched_runtime was meaningful only for SCHED_DEADLINE tasks; EEVDF gives it meaning for ordinary tasks too:

struct sched_attr attr = {
    .size          = sizeof(attr),
    .sched_policy  = SCHED_OTHER,   /* ordinary fair task */
    .sched_runtime = 200000,        /* request a 200 us slice -> earlier deadline */
};
sched_setattr(0, &attr, 0);         /* applies to the calling thread */

Line by line: sched_policy = SCHED_OTHER keeps the task on the fair class (this is not real-time); sched_runtime = 200000 requests a 200 µs slice. A shorter request than the 750 µs default produces a tighter virtual deadline (gate 2), so this thread is picked sooner when it wakes — exactly what an audio or UI thread wants — but its vruntime accounting (gate 1) is unchanged, so it gets no extra total CPU. This is the clean realization of the goal the latency-nice saga chased for four years. Full mechanics: Time Slices and Request Sizes in EEVDF.

What the kernel actually does with sched_runtime

The 100 µs–100 ms range that secondary sources quote is real, but it is a clamp, not a validation — an out-of-range request is silently adjusted, not rejected. From __setscheduler_params() in v6.12:

	} else if (fair_policy(policy)) {
		p->static_prio = NICE_TO_PRIO(attr->sched_nice);
		if (attr->sched_runtime) {
			p->se.custom_slice = 1;
			p->se.slice = clamp_t(u64, attr->sched_runtime,
					      NSEC_PER_MSEC/10,   /* HZ=1000 * 10 */
					      NSEC_PER_MSEC*100); /* HZ=100  / 10 */
		} else {
			p->se.custom_slice = 0;
			p->se.slice = sysctl_sched_base_slice;
		}
	}

Line by line: fair_policy(policy) covers SCHED_NORMAL and SCHED_BATCH. A non-zero sched_runtime sets the custom_slice flag — which is what stops update_deadline() and place_entity() from resetting se->slice back to base_slice_ns on every request boundary. NSEC_PER_MSEC/10 is 100,000 ns = 100 µs; NSEC_PER_MSEC*100 is 100,000,000 ns = 100 ms. The comments explain the choice: 100 µs is a tenth of a tick at CONFIG_HZ=1000, and 100 ms is ten ticks at CONFIG_HZ=100. Passing zero is the documented way to go back to the shared default — it clears custom_slice and restores sysctl_sched_base_slice.

Three consequences follow that are easy to get wrong:

  • A too-short request is not an error. sched_setattr(0, &attr, 0) with sched_runtime = 1000 returns success and you get 100 µs. Read the value back to know what you actually have.
  • You can read it back, since 6.12. get_params() now reports attr.sched_runtime = p->se.slice for fair tasks; in 6.6 it only filled in sched_nice. sched_getattr(2) is therefore the supported way to observe a task’s effective slice, and it reports the clamped, scaled value.
  • No privilege is required. Unlike lowering nice, requesting a shorter slice needs no capability — it buys latency, not CPU share, so there is nothing to gate.
sched_attr fieldMeaning for a fair (SCHED_OTHER/SCHED_BATCH) task in 6.12Notes
sizesizeof(struct sched_attr)Mismatch handling returns E2BIG and writes back the kernel’s size
sched_policySCHED_NORMAL (0) or SCHED_BATCH (3)SCHED_IDLE (5) is fair too; check_preempt_wakeup_fair() gates wakeup preemption on normal_policy(p->policy), so neither BATCH nor IDLE tasks preempt on wakeup
sched_flagsSCHED_FLAG_RESET_ON_FORK, the UTIL_CLAMP flagsThe DL_* flags are rejected for fair policies
sched_nice−20…19, becomes the weight w_iThe CPU-share lever
sched_prioritymust be 0Fair tasks have no static RT priority
sched_runtimerequest size r_i, clamped to [100 µs, 100 ms]; 0 means “use base_slice_nsThe latency lever — new meaning as of 6.12
sched_deadline, sched_periodignoredSCHED_DEADLINE only
sched_util_min / sched_util_maxutilisation clamps, 0…1024Frequency/placement hints, orthogonal to EEVDF

The sched_attr surface as it applies to an ordinary task. The insight: exactly two rows matter for EEVDF, and they are the two orthogonal levers — sched_nice for share, sched_runtime for latency. Everything else in the structure belongs to another scheduling class.

Uncertain

Verify: whether the sched_setattr(2) manual page has been updated to document sched_runtime for ordinary tasks. Reason: the copy at man7.org, page footer dated 2026-05-30 still comments the field /* For SCHED_DEADLINE */ in its struct sched_attr listing and says nothing about EEVDF slices — and the in-tree UAPI header include/uapi/linux/sched/types.h at v6.12 likewise still asserts “As of now, the SCHED_DEADLINE policy (sched_dl scheduling class) is the only user of this new interface”, which the code four directories away contradicts. The kernel behaviour is verified against kernel/sched/syscalls.c at the v6.12 tag and is not in doubt; only the documentation status is. To resolve: check the man-pages git history for sched_setattr.2 and the UAPI header on a later tag. uncertain

Observing it on a running system

With CONFIG_SCHED_DEBUG=y, /sys/kernel/debug/sched/ exposes the whole mechanism. The per-task table in .../sched/debug prints the EEVDF state directly — print_task() emits, per task, the vruntime, a computed E/N eligibility flag, the deadline, an S marker when custom_slice is set, and the slice:

 S            task   PID       vruntime   eligible    deadline             slice  ...
 S     pipewire     1421   12345.678901   E   12348.678901 S      0.200000  ...
 R     make         9032   12344.100000   E   12347.100000        3.000000  ...
 R     cc1          9101   12351.900000   N   12354.900000        3.000000  ...
Path under /sys/kernel/debug/sched/What it isPresent in
base_slice_nswritable default r_i, post-scaling6.6 onward
featureswrite NO_RUN_TO_PARITY, NO_DELAY_DEQUEUE, … to toggle at runtimelong-standing
debugper-CPU cfs_rq dump: left_deadline, left_vruntime, min_vruntime, avg_vruntime, right_vruntime, spread, nr_running — plus the per-task table above6.6 onward (avg_vruntime/left_deadline are EEVDF-era additions)
tunable_scaling0 = none, 1 = linear, 2 = log — controls the boot-time base_slice_ns factorlong-standing
fair_server/cpu<N>/runtime, .../periodbandwidth reserved for the whole fair class against RT/DL starvationnew in 6.12
latency_ns, min_granularity_ns, idle_min_granularity_ns, wakeup_granularity_nsCFS-era tunablesremoved in 6.6 — see the version table below

The debugfs surface. The insight: avg_vruntime and the E/N column are the two fields that let you check the theory against a live machine — dump debug, and every task marked N is one the scheduler is currently forbidden to pick no matter how urgent its deadline looks.

The fair_server entries deserve a note because they are new in this LTS and they change what “the fair class” means. Every run queue gains a struct sched_dl_entity fair_server, initialised by fair_server_init() and started with a 50 ms runtime per 1000 ms period in deferred mode (dl_defer = 1) (kernel/sched/deadline.c, dl_server_start). In effect the fair class is now a client of SCHED_DEADLINE with a 5%-of-a-second reservation that only activates if fair tasks are actually being starved by real-time work. Writable bounds are a period between 100 µs and roughly 4 seconds. This is the mechanism that is gradually replacing the old global RT throttle; see SCHED_DEADLINE and Earliest Deadline First and Real-Time Throttling and the RT Bandwidth Limit.

Why EEVDF Beats CFS on Latency

The core win is structural, not a tuning trick. Under CFS, a latency-sensitive task and a CPU-bound task with equal nice have equal vruntime growth rates and equal claim on the CPU; the only way to make the latency-sensitive one wake-and-run faster was to lower its nice — which also handed it more CPU over time, often undesirable. EEVDF decouples the two: the latency task requests a small slice, which gives it an early virtual deadline, so when it wakes it is (a) eligible — it has been sleeping, so it is owed service, lag ≥ 0 — and (b) has the earliest deadline, so it preempts the CPU-bound task immediately. But because its vruntime still advances normally while it runs, over many wakeups it consumes only its fair share. The kernel doc states the payoff directly: “this allows latency-sensitive tasks with shorter time slices to be prioritized, which helps with their responsiveness” (sched-eevdf.rst). Early benchmarks showed “latency wins” with a mix of throughput wins and losses (Corbet, LWN 925371).

EEVDF also added wakeup preemption by deadline: a waking task whose virtual deadline is earlier than the running task’s preempts it, giving “more consistent timings for short-time-slice tasks” (Corbet, LWN 969062). The implementation is literally to re-run the picker and see who wins — check_preempt_wakeup_fair() ends with if (pick_eevdf(cfs_rq) == pse) goto preempt; — which is the cleanest possible statement that wakeup preemption and periodic picking now obey exactly the same rule.

A worked schedule, side by side

Zijlstra worked the steady-state schedules out by hand in the changelog of the commit that exposed sched_runtime, and they are the clearest demonstration of what the slice lever buys. Take a quantum q = 8 units, four equal-weight tasks (W = 4), and give three of them a request of r = 16 (two quanta) while B asks for r = 8 (one quantum). The resulting repeating schedule is B A A C C B D D (commit 857b158dc5e8).

gantt
  title One full EEVDF period (8 quanta) vs. what CFS would produce
  dateFormat X
  axisFormat %s
  section EEVDF r_B=8, others r=16
  B (short slice) :b1, 0, 1
  A              :a1, 1, 3
  C              :c1, 3, 5
  B (short slice) :b2, 5, 6
  D              :d1, 6, 8
  section CFS equal nice
  A :ca, 0, 2
  B :cb, 2, 4
  C :cc, 4, 6
  D :cd, 6, 8

The same four tasks under EEVDF with a short-slice request versus CFS with equal nice. What it shows: in the EEVDF row, B runs twice — once at the very start and once at the midpoint — in one-quantum bursts, while A, C and D each run one uninterrupted two-quantum burst. In the CFS row every task is interchangeable, so B runs once, in the middle of the period, for the same total time but at half the frequency and with up to 4 quanta of wait between activations. The insight: both rows give B exactly 2 of 8 quanta — one quarter, its fair share. What differs is when. Zijlstra’s own arithmetic on this trace: the full schedule period is P = W·max(r_i/w_i) = 4 × 2q = 8q, while B’s activation period is W·(r_B/w_B) = 4 × 1q = 4qhalf that of A, C and D. This is the entire value proposition: EEVDF changed the distribution of a task’s CPU time without changing its amount, and CFS had no way to express that request at all.

Zijlstra derived the general steady-state result from these traces, and it is worth stating because it makes the trade-off quantitative. For a system in steady state:

  • the total period of the repeating schedule is P = W · max(r_i / w_i),
  • the average period of task i — how often it gets a turn — is W · (r_i / w_i),
  • and each task still obtains the fair share w_i / W of every full period P.

Walking the symbols: W is the summed weight of all runnable tasks, r_i the task’s request size, w_i its weight. The second line is the one that matters for latency: halve r_i and you halve your activation period, i.e. you get called twice as often for half as long. The third line is the one that matters for fairness: your share is a function of w_i/W alone and does not appear anywhere in the first two. That separation is the theorem behind the marketing.

Zijlstra’s guidance on choosing r_i is correspondingly concrete: “Applications should strive to use their periodic runtime at a high confidence interval (95%+) as the target slice. Using a smaller slice will introduce undue preemptions, while using a larger value will increase latency.” In other words, measure how long one unit of your work actually takes, take the 95th percentile, and ask for that — do not ask for the smallest number the clamp will accept.

That choice of interface was deliberate and is worth recording, because the obvious alternative had four years of patches behind it. Zijlstra’s cover letter for the completion series explains why he passed on it:

This very much includes the new interface that exposes the extra parameter that EEVDF has. I’ve chosen to use sched_attr::sched_runtime for this over a nice-like value because some workloads actually know their slice length (can be dynamically measured in the same way as for deadline using CLOCK_THREAD_CPUTIME_ID) and using the real request size is much more effective than some relative measure. [[ using too short a request size will increase job preemption overhead, using too long a request size will decrease timeliness ]]

Zijlstra, “[RFC][PATCH 00/10] sched/fair: Complete EEVDF”, 2024-04-05

A “latency nice” value would have been relative — nicer than what, exactly, and by how much? A request size is absolute and measurable: a thread can time its own work units with clock_gettime(CLOCK_THREAD_CPUTIME_ID, ...) and feed the answer straight back to the kernel. That is why the field that shipped is a duration in nanoseconds rather than a −20…19 dial, and and it is why no latency_nice symbol appears anywhere in v6.12’s kernel/sched/{fair,core,syscalls}.c, kernel/sched/sched.h, include/linux/sched.h, or include/uapi/linux/sched/types.h — the interface that shipped is not a renamed latency-nice.

The 2024 “Completion”: Delayed Dequeue

When EEVDF first merged in 6.6 it was incomplete; the most important gap was a way to handle sleeping tasks’ lag honestly. A task that runs, gets ahead of its share (negative lag), then briefly sleeps could, on a naive design, return with its lag reset — letting it game the scheduler by sleeping to wipe out the “debt” of having over-run. Zijlstra called the fix, delayed dequeue / DELAY_DEQUEUE, “a fundamental thing that was missing from the EEVDF paper; without something like this EEVDF will simply not work right” (Corbet, “Completing the EEVDF scheduler”, LWN 969062; patch series “sched/fair: Complete EEVDF”, LWN 968575).

Zijlstra’s own commit message states the problem and rejects the two obvious fixes in turn:

Extend / fix 86bfbb7ce4f6 (“sched/fair: Add lag based placement”) by noting that lag is fundamentally a temporal measure. It should not be carried around indefinitely. OTOH it should also not be instantly discarded, doing so will allow a task to game the system by purposefully (micro) sleeping at the end of its time quantum. Since lag is intimately tied to the virtual time base, a wall-time based decay is also insufficient, notably competition is required for any of this to make sense. Instead, delay the dequeue and keep the ‘tasks’ on the runqueue, competing until they are eligible.

commit 152e11f6df29, “sched/fair: Implement delayed dequeue”, 2024-05-23

Read that carefully, because it rules out the design most people would reach for. Forgetting lag on sleep is exploitable. Decaying it against wall-clock time is meaningless, since lag is only defined relative to competition — a task alone on an idle CPU has nothing to be behind or ahead of. The only coherent decay axis is virtual runtime, and virtual runtime only advances when there is contention. So the fix is to keep the task in the contention.

The mechanism in dequeue_entity() is four lines:

		if (sched_feat(DELAY_DEQUEUE) && delay &&
		    !entity_eligible(cfs_rq, se)) {
			if (cfs_rq->next == se)
				cfs_rq->next = NULL;
			update_load_avg(cfs_rq, se, 0);
			se->sched_delayed = 1;
			return false;
		}

Line by line: delay is true for an ordinary DEQUEUE_SLEEP but is forced false for DEQUEUE_SPECIAL states — the features comment notes that “DELAY_DEQUEUE relies on spurious wakeups, special task states must not suffer spurious wakeups”. The eligibility test is the gate: only an ineligible task is delayed; a task that blocks while it is owed service leaves immediately, with its positive lag stored by update_entity_lag(). Clearing cfs_rq->next removes any buddy nomination. Then sched_delayed = 1 and — critically — return false, which propagates all the way up: dequeue_entities() returns -1 before decrementing h_nr_running, and block_task() in core.c skips __block_task(), so p->on_rq stays 1.

The task is finally reaped in pick_next_entity(), not by a timer:

	struct sched_entity *se = pick_eevdf(cfs_rq);
	if (se->sched_delayed) {
		dequeue_entities(rq, se, DEQUEUE_SLEEP | DEQUEUE_DELAYED);
		/*
		 * Must not reference @se again, see __block_task().
		 */
		return NULL;
	}

Since pick_eevdf() only ever returns eligible entities, a delayed task being picked proves its lag has come back to non-negative — the debt is paid. finish_delayed_dequeue_entity() then applies DELAY_ZERO: if (sched_feat(DELAY_ZERO) && se->vlag > 0) se->vlag = 0;. That clip matters. Without it, a task that over-ran, slept, and sat in the delayed state slightly past its zero-lag point would wake up with a credit it never earned by waiting. The same clip appears on the other exit, in requeue_delayed_entity(), which handles a delayed task that is woken while still on the queue: if its vlag has turned positive it is re-placed at zero lag, and if it is still negative the debt is simply carried on.

LWN's description of the trigger is an approximation

The 2024 LWN write-up says “Once the lag goes positive, the scheduler will notice the task and remove it from the run queue” (LWN 969062). The v6.12 code has no such notice: nothing watches a delayed task’s lag. Zijlstra says so explicitly in the same commit — “Strictly speaking, we only care about keeping them until the 0-lag point, but that is a difficult proposition, instead carry them around until they get picked again, and dequeue them at that point.” The task therefore lingers, possibly well past its zero-lag point, until pick_eevdf() happens to select it. DELAY_ZERO exists precisely to neutralise the credit that overshoot would otherwise create.

Uncertain

Verify: whether DELAY_DEQUEUE measurably inflates the reported load average. Reason: this is a code-derived inference, not something observed. calc_load_fold_active() computes nr_active = this_rq->nr_running - adjust + this_rq->nr_uninterruptible, and a delayed task keeps on_rq = 1 and stays counted in rq->nr_running while not incrementing nr_uninterruptible (since __block_task() never runs). The arithmetic says a blocked-but-delayed task therefore counts once toward loadavg until it is picked; the magnitude in practice, and whether it is bounded tightly enough to be invisible, is not established here. To resolve: run a sleep-heavy workload on 6.6 (NO_DELAY_DEQUEUE era) versus 6.12 with DELAY_DEQUEUE on and off via /sys/kernel/debug/sched/features, comparing /proc/loadavg. uncertain

What Actually Changed Between 6.6 and 6.12

The 6.6 merge announcement is not a description of the 6.12 scheduler. Diffing the two tags directly, the substantive changes are these.

timeline
  title EEVDF in mainline — 6.5 to 6.12
  section 6.5 and earlier · CFS
    CFS tunables : sched_latency_ns : min_granularity_ns : idle_min_granularity_ns : wakeup_granularity_ns : GENTLE_FAIR_SLEEPERS : START_DEBIT
  section 6.6 · EEVDF replaces CFS
    Core algorithm lands : avg_vruntime and lag-based placement : rbtree keyed on VRUNTIME, augmented with min_deadline : PLACE_LAG, PLACE_DEADLINE_INITIAL, RUN_TO_PARITY : base_slice_ns replaces min_granularity_ns : four CFS tunables and both sleeper heuristics deleted
  section 6.8 · tree inverted
    Abel Wu re-keys the tree : sorted by DEADLINE, augmented with min_vruntime : O(1) leftmost-eligible fastpath : sched_entity.min_deadline becomes min_vruntime
  section 6.12 · the completion series
    DELAY_DEQUEUE and DELAY_ZERO : PREEMPT_SHORT plus did_preempt_short : PLACE_REL_DEADLINE : sched_attr.sched_runtime sets the slice : sched_getattr reports the slice : custom_slice, sched_delayed, rel_deadline fields : per-rq fair_server DL reservation

The four-release arc from CFS to the 6.12 EEVDF. What it shows: 6.6 was a replacement, not a completion — two of the three headline user-visible features (sched_setattr slice requests and delayed dequeue) plus the data-structure that makes picking cheap all landed after it. The insight: any advice written against “EEVDF as merged in 6.6” is describing a scheduler that cannot honour a latency request and cannot stop the micro-sleep exploit. Pin your reading to a release.

The single most surprising item is the tree inversion, because it reverses the arrangement in the original paper. Stoica and Abdel-Wahab’s reference implementation keys the search tree on the virtual eligible time ve and carries min_vd as augmented data (paper §7: “virtual eligible time ve is used as a key in the binary search tree”). Linux 6.6 followed that shape: entity_before() compared vruntime, and struct sched_entity carried a min_deadline field. Abel Wu inverted it for 6.8 — entity_before() now compares deadline, and the augmented field became min_vruntime — with the explicit goal of exploiting the cached leftmost node:

Sort the task timeline by virtual deadline and keep the min_vruntime in the augmented tree, so we can avoid doubling the worst case cost and make full use of the cached leftmost node to enable O(1) fastpath picking in next patch.

commit 2227a957e1d5, “sched/eevdf: Sort the rbtree by virtual deadline”

Aspectv6.5 (CFS)v6.6 (EEVDF as merged)v6.12 (this LTS)
rbtree keyvruntimevruntimedeadline
augmented fieldmin_vruntime on cfs_rqse->min_deadlinese->min_vruntime (plus se->min_slice)
pick fastpathleftmost nodefull heap search every timenr_running==1 → parity → leftmost-eligible → heap
default slice knobmin_granularity_ns (0.75 ms)base_slice_ns (0.75 ms × factor)same as 6.6
removed tunableslatency_ns, idle_min_granularity_ns, wakeup_granularity_nsstill absent
sleeper heuristicsGENTLE_FAIR_SLEEPERS, START_DEBITdeleted; replaced by PLACE_LAG + PLACE_DEADLINE_INITIALunchanged
per-task latency requestnonenonesched_attr.sched_runtime
readback of slicen/asched_getattr returns nice onlysched_getattr returns se->slice
sleep/lag handlingFAIR_SLEEPERS bonuslag stored and restoredDELAY_DEQUEUE + DELAY_ZERO
wakeup preemptionwakeup_granularity_ns thresholddeadline comparisondeadline comparison + PREEMPT_SHORT
starvation guard for fairglobal RT throttleglobal RT throttleper-rq fair_server, 50 ms / 1000 ms, deferred
new sched_entity fieldsdeadline, min_deadline, vlag, slice+ min_vruntime, min_slice, sched_delayed, rel_deadline, custom_slice

Release-by-release diff, taken from the tags themselves rather than from release notes. The insight: the base_slice_ns row is the one that has not moved — the default request size has been 750,000 ns since 6.6 and is unchanged in 6.12. Everything around it changed.

Dated: what happened after 6.12

Development continued well past this LTS. Between 6.13 and mid-2026 mainline gained, among much else: sched/fair: Reimplement NEXT_BUDDY to align with EEVDF goals (e837456fdca8, 2025-11-12); sched/fair: Rename cfs_rq::avg_load to cfs_rq::sum_weight (4ff674fa986c, 2025-11-26), which finally names that field for what it is; sched/eevdf: Move to a single runqueue (85570f10a4c6, 2025-12-06); and sched/fair: Only set slice protection at pick time (bcd74b2ffdd0, 2026-01-23) together with sched/eevdf: Update se->vprot in reweight_entity() (ff38424030f9, 2026-01-20), which replace the vlag-stashing HACK described above with a dedicated se->vprot field. There is also a commit literally titled sched/fair: Fix stale comments referring to removed CFS concepts. None of this is in 6.12 — if you are reading a newer tree, the vlag == deadline sentinel will not be there.

Failure Modes and Common Misunderstandings

  • “EEVDF picks the leftmost (earliest-deadline) task.” Only among eligible tasks. The leftmost node may be ineligible (lag < 0); pick_eevdf() must find the earliest-deadline node that also passes the eligibility test, which is why the tree carries augmented min-vruntime data to prune branches.
  • “A smaller slice means more CPU.” The opposite — a smaller requested slice means earlier deadline → picked sooner, but vruntime accounting is unchanged, so total CPU share is unchanged. Latency, not throughput, is what the slice buys.
  • “EEVDF replaced CFS as a config option you can toggle back.” No. In 6.6 it replaced the picking algorithm inside fair.c; there is no CONFIG to revert to CFS in 6.12 LTS. CFS’s vruntime/weight/rbtree code was inherited, not preserved as an alternative. See The Completely Fair Scheduler and Its History.
  • RUN_TO_PARITY hurts fairness.” It defers re-picking until the running task is no longer eligible, which is within its owed share — it trades a tiny amount of latency granularity for far fewer context switches and better cache behavior, without violating the fairness gate.
  • “Sleeping resets a task’s debt.” Delayed dequeue specifically prevents this; negative lag decays over virtual time rather than vanishing on sleep.
  • base_slice_ns is 750 µs.” It is 750 µs before boot-time scaling. On any machine with 8 or more online CPUs it is 3 ms, four times larger — and every default virtual deadline is correspondingly four times further out. Read the debugfs file, not the source constant.
  • avg_vruntime is the average vruntime.” The field named cfs_rq->avg_vruntime is the numerator Σ(v_i − v0)·w_i, not an average; the average is what the function avg_vruntime() returns. Mainline later renamed the companion field avg_load to sum_weight for exactly this reason. Reading the raw field as if it were V will give nonsense.
  • “EEVDF is O(log n) per pick, so it is slower than CFS.” Asymptotically yes, empirically no — the heap search runs in well under 2% of picks (see the pick-path table above), and the single-runnable fast path skips work CFS always did.

Real failure modes, with symptoms

The misconceptions above are cheap to fix. These are the ones that show up as production regressions.

SymptomLikely causeHow to checkWhat to do
A long-running interactive thread is chopped up and misses frames after upgrading past 6.5Eligibility lets short-slice background threads preempt it mid-work; EEVDF assumes “needs to start fast” implies “finishes fast”perf sched latency; dump /sys/kernel/debug/sched/debug and look for the UI thread flipping between E and NRaise base_slice_ns, or give the thread a larger sched_runtime so its slice protection lasts longer
Context-switch rate jumped, throughput down a few percentPREEMPT_SHORT (new in 6.12) letting short-slice tasks cancel slice protectionvmstat 1 cs column; toggle echo NO_PREEMPT_SHORT > /sys/kernel/debug/sched/featuresConfirm the cause with the toggle, then fix the offending task’s requested slice rather than leaving the feature off
A thread asked for a 20 µs slice and did not get fasterThe request was silently clamped to 100 µs, and 100 µs is below TICK_NSEC on a CONFIG_HZ=1000 kernel anywaysched_getattr() and read back sched_runtimeAsk for the p95 of real per-activation runtime, per Zijlstra’s guidance, not the minimum
Load average reads slightly high on an otherwise idle, sleep-heavy workloadDelayed-dequeue tasks stay counted in rq->nr_running until pickedCompare with NO_DELAY_DEQUEUE set in the features fileNothing — but see the uncertainty callout above; this one is inferred from code, not measured
A SCHED_FIFO storm no longer completely starves fair tasks (6.12+)The new per-rq fair_server deadline reservation activatingcat /sys/kernel/debug/sched/fair_server/cpu0/runtimeExpected behaviour; tune runtime/period rather than reaching for the global RT throttle

Diagnosis grid. The insight: four of the five rows are diagnosed by toggling a single scheduler feature at runtime through /sys/kernel/debug/sched/features. That file is the fastest bisection tool available for EEVDF behaviour, and it needs no reboot.

Production Notes

The most instructive real-world report is Google’s, presented at OSPM 2024 by Youssef Esmat and written up by Giovanni Gherdovich (LWN 981371). ChromeOS’s dominant workload is the Chrome browser, whose threads split into user-interface threads that capture input, background threads for inactive tabs, and everything in between. The finding is uncomfortable for EEVDF and is worth quoting precisely: Chrome’s UI threads “have to be serviced promptly, and at the same time are vulnerable to preemption as they are long-running. The EEVDF algorithm is based on the assumption that tasks, in general, don’t behave like that. Instead, it assumes that, if a task needs to be scheduled quickly, it also completes quickly.”

Measuring input latency and dropped frames on Google Meet and Google Docs, the team found stock EEVDF performed worse than CFS. Quadrupling the base time slice — from the 1.5 ms default on their two-core test machines — restored parity. The larger win came from something more radical: removing the eligibility check entirely and scheduling purely on virtual deadline, which beat CFS by roughly 30% on their metrics. The explanation is exactly the mechanism drawn in the eligibility diagram above: background threads accumulate positive lag while idle, become eligible, and preempt a UI thread before it can finish its work unit. That variant — large base slice, no eligibility — went to field testing and beat CFS on page-load speed (first and largest contentful paint) and on key-press, mouse-press and touch-press latency, with one regression in gesture-scrolling latency still under investigation at the time of the report.

Two things follow. First, the eligibility gate is not free; it is a fairness guarantee purchased with preemptions, and a workload whose value depends on run-to-completion of long units can lose more from the preemptions than it gains from the fairness. Second, this is a strong argument for sched_ext, mainlined in the same 6.12 release: “EEVDF without eligibility” is not a knob the mainline scheduler offers, but it is a perfectly reasonable ~200-line BPF scheduler.

The upstream numbers for PREEMPT_SHORT show the same trade-off from the other side. Zijlstra and Mike Galbraith benchmarked massive_intr with a 100 ms slice against cyclictest with a 500 µs slice, alongside Chromium, three runs each (commit 85e511df3cec):

WorkloadmetricNO_PREEMPT_SHORTPREEMPT_SHORT
cyclictest (500 µs slice)avg delay0.471 / 0.448 / 0.475 ms0.373 / 0.374 / 0.370 ms
cyclictest (500 µs slice)sum delay301.4 / 287.6 / 302.4 s249.2 / 248.5 / 247.7 s
cyclictest (500 µs slice)max delay32.3 / 44.2 / 25.5 ms38.2 / 33.5 / 37.8 ms
massive_intr (100 ms slice)switches779k / 792k / 771k837k / 845k / 837k

Zijlstra’s PREEMPT_SHORT measurements, three runs per configuration. What it shows: the short-slice task’s average delay drops about 21% and its summed delay about 17%, and — the stated goal — the run-to-run spread of both collapses. The maximum delay does not improve and is arguably slightly worse; the long-slice task pays with roughly 7–9% more context switches. The insight: the commit message’s own summary is “this makes cyclictest its max-delay more consistent and consistency drops the sum-delay. The trade-off is that the massive_intr gets more context switches and a slight increase in sum-delay.” EEVDF’s latency features buy predictability, not a lower worst case — if you need a bounded worst case, you need SCHED_DEADLINE, not a shorter slice.

Finally, a note on what most people should expect: nothing. EEVDF changed the picking algorithm, not the fairness contract. A workload with no latency requirement, no nice tuning, and no sched_setattr call sees a scheduler that divides the CPU the same way CFS did, with the same weights, from the same vruntime. Zijlstra’s own summary of the first benchmark round was “there’s a bunch of wins and losses, but nothing that indicates a total fail” (Corbet, LWN 925371) — and the fact that the transition happened without a CONFIG escape hatch, and without a wave of regression reports, is the strongest evidence that the substrate really was preserved.

Alternatives and When to Choose Them

EEVDF is the default for almost everything. Reach past it only when you need a different guarantee: SCHED_RR for fixed-priority real-time work that must preempt all fair tasks; SCHED_DEADLINE when you can state a (runtime, period, deadline) triple and want EDF guarantees with admission control; and sched_ext (BPF schedulers, mainlined in 6.12) when you need a custom policy you can load and iterate without rebuilding the kernel. Within EEVDF, the only knobs are nice (CPU share) and the per-task slice request (latency), which is the point — one fair class, two orthogonal levers.

The decisive question is what kind of guarantee you actually need, and it separates the options cleanly:

flowchart TB
  Q1{"Can you state a bound the<br/>system must never violate?"}
  Q1 -- "no — I just want it<br/>to feel responsive" --> Q2{"Is the responsiveness<br/>expressible as a<br/>per-activation runtime?"}
  Q2 -- yes --> EEVDF["<b>SCHED_OTHER + sched_setattr</b><br/>sched_runtime = p95 runtime<br/>no privilege, no admission control,<br/>share unchanged"]:::good
  Q2 -- "no — my threads are<br/>long-running AND latency-<br/>sensitive (the Chrome case)" --> EXT["<b>sched_ext</b><br/>write the policy you need<br/>load and iterate without a reboot"]:::alt
  Q1 -- "yes, and I can state<br/>(runtime, period, deadline)" --> DL["<b>SCHED_DEADLINE</b><br/>EDF with admission control<br/>kernel refuses over-subscription"]:::alt
  Q1 -- "yes, but only as<br/>'higher priority than everything'" --> RT["<b>SCHED_FIFO / SCHED_RR</b><br/>fixed priority, preempts all fair work<br/>privileged; needs a throttle or a<br/>fair_server reservation to be safe"]:::warn
  classDef good fill:#dff0d8,stroke:#3c763d,color:#1b3a1b;
  classDef alt fill:#d9edf7,stroke:#31708f,color:#123a4a;
  classDef warn fill:#f2dede,stroke:#a94442,color:#4a1f1f;

Choosing a scheduling policy in 6.12. What it shows: the first question is not “how fast” but “guarantee or best-effort”. EEVDF’s slice request is a hint with no guarantee — nothing stops the run queue from being so crowded that even the earliest deadline waits. The insight: the option that did not exist before 6.12 is the top-right green box, and it is the right answer far more often than people reach for SCHED_FIFO. A privileged real-time policy used to buy latency is the classic way to turn a responsiveness problem into a system-wide starvation problem.

EEVDF (SCHED_OTHER)SCHED_BATCHSCHED_FIFO/RRSCHED_DEADLINEsched_ext
Class order in pick_next_task4th4th (same class)3rd2nd5th (below fair)
Latency leversched_runtime slice requestnoneabsolute priorityexplicit deadlinewhatever you write
Share levernice / weightnice / weightnone (priority is absolute)bandwidth (runtime/period)whatever you write
Guaranteenone — best effort, fair sharenonepreempts everything belowhard, with admission controlnone by construction
Privilege needednonenoneCAP_SYS_NICECAP_SYS_NICEprivileged BPF struct_ops load
Starvation risk it createsnonenonehighbounded by admission controldepends on the BPF program
Available since6.6 (slice request: 6.12)2.6.16POSIX.1b, long-standing3.146.12

Policy comparison as of 6.12. The insight: the “privilege needed” row explains why the EEVDF slice request matters organisationally as well as technically — it is the first latency lever an unprivileged application can pull, which means it can ship in a normal userspace program instead of requiring a capability or a systemd unit setting.

Contrast: a userspace M:N scheduler

Go’s runtime schedules goroutines onto OS threads (the GMP model) with per-P run queues and work-stealing, and is itself scheduled as a set of OS threads by EEVDF. The contrast is sharp: EEVDF enforces weighted fairness with a latency lever across untrusted tasks via virtual time and eligibility; Go’s scheduler optimizes throughput and cheap switching among cooperative goroutines it fully controls and has no fairness invariant, no lag, and no virtual deadlines — it preempts a goroutine (since Go 1.14, asynchronously via signals) mainly to avoid starvation and stop-the-world stalls, not to equalize CPU. A Go program’s goroutines all live inside whatever EEVDF slice the Go threads receive, so the two schedulers compose: EEVDF decides when Go’s threads run, Go decides which goroutine runs on them. See GMP Scheduler Model, Goroutine Preemption, and System Calls and the Scheduler.

See Also