Linux replaced its CPU scheduler and nobody noticed
In October 2023 the code that decides which process runs on your CPU was taken out and replaced. Not tuned — replaced. The Completely Fair Scheduler had run essentially every Linux machine on earth for sixteen years, almost exactly to the month, and in version 6.6 it was deleted and something called EEVDF was put in its place.
Almost nobody noticed. For a scheduler change that is the correct outcome and also a shame, because the reason behind it is one of those ideas that quietly rearranges how you think about sharing anything.
Here it is, up front:
How much CPU a task gets and how soon it gets it are different questions. CFS gave you a knob for the first and none at all for the second. That was the bug.
Everything below is an argument for why that sentence is worth caring about — and, along the way, two attempts at making it that turned out to be wrong.
Act 1 — Virtual time is a clock that runs at different speeds
Start with the problem any scheduler has to solve. Several tasks want one CPU. You can only run one at a time. You want to be fair. What does fair even mean?
The obvious answer — give everyone equal time — is wrong as soon as you admit that some tasks matter more than others. So the real target is proportional fairness: each task gets a share of the CPU in proportion to its weight.
CFS found a beautiful way to track this. Instead of accounting for real time, give every task a virtual clock, and make that clock run at a speed inversely proportional to the task’s weight:
vruntime += real_time_used × 1024 / weight
A task with a heavy weight accumulates virtual time slowly. A light task races ahead. Then CFS’s rule is simply: at each scheduling decision, run whoever has the lowest virtual time.
That one line does all the work. You never compute a share, never track a budget, never divide anything by anything — proportional fairness falls out of a comparison. And the weighting is what makes it proportional: a heavy task’s clock runs slow, so it can do several times as much real work before its virtual time catches up with everyone else’s.
Both halves of the rule earn their keep, though. At each decision, because a task that wins the CPU keeps it for a whole timeslice, and partway through that slice it is very often no longer the lowest — you will see that happen below. And CFS’s rule, because Act 4 replaces it with something subtly different.
Omits: single CPU; no sleeping or I/O; no group scheduling; no other scheduling classes. See the fidelity note at the end for the full list.
Omits: single CPU; no sleeping or I/O; no group scheduling; no other scheduling classes. See the fidelity note at the end for the full list.
Act 2 — nice is exponential, and only differences matter
The weight in that formula comes from the task’s nice value, through a table the
kernel has carried, byte for byte identical, for well over a decade:
/* -20 */ 88761 71755 56483 46273 36291
...
/* 0 */ 1024 820 655 526 423
...
/* 15 */ 36 29 23 18 15
Two things about this table surprise people.
The first is that it is exponential, not linear. Each step is roughly a factor of 1.25, which is chosen so the effect is relative: from wherever you are, one step up costs you about 10% of your CPU and one step down gains you about 10%. Going from nice 0 to nice 5 does not cost you five units of something. It costs you a factor of three.
The second is that only the ratio matters. A nice 0 task competing against a nice 5 task gets 75.35% of the CPU. A nice 10 task competing against a nice 15 task gets 75.34%. Nice values have no absolute meaning whatsoever — they exist only relative to whatever else happens to be runnable, and shifting both by the same amount changes nothing you could measure.
Not quite nothing, if you push on it. The table is rounded integers, not a real geometric sequence, so equal gaps are equal only to about a percentage point. The widest disagreement anywhere is 1.55 points, between nice 17-vs-18 and nice 18-vs-19, where the ratios are 1.28 and 1.20 rather than 1.25. It is a rounding artefact rather than a design, and no workload will ever notice — but the figure below prints enough digits to show it, so it is better said than discovered.
Omits: single CPU; no sleeping or I/O; no group scheduling; no other scheduling classes. See the fidelity note at the end for the full list.
Act 3 — What CFS would not let you ask for
Now the trap, which is not the trap most people expect.
You have a machine doing something heavy — a compile, a video encode, a batch job. It wants every cycle it can get. Alongside it you have something small and interactive: an audio thread, an input handler, a UI event loop. It barely wants any CPU at all. It wants a fraction of a millisecond. But it wants it now, and it wants it every few milliseconds, and if it is late the user hears a click or sees a dropped frame.
Here is where the story usually goes wrong, including in the first draft of this page. The tempting claim is that CFS handles this badly. It does not. CFS is excellent at it.
When a task wakes up, CFS places it half a latency period behind the runqueue’s
floor — place_entity(), in full:
vruntime = cfs_rq->min_vruntime;
if (!initial) {
thresh = sysctl_sched_latency;
if (sched_feat(GENTLE_FAIR_SLEEPERS))
thresh >>= 1;
vruntime -= thresh;
}
/* ensure we never gain time by being placed backwards. */
se->vruntime = max_vruntime(se->vruntime, vruntime);
Three milliseconds of virtual time behind a hog that is sitting exactly at the floor is an enormous head start. Wakeup preemption sees it and kicks the hog off the CPU on the spot. Our interactive task is served practically the instant it asks — model it faithfully and CFS’s p95 wake-to-run latency on this workload comes out better than stock EEVDF’s. That is not an accident. It is why Linux desktops felt responsive for sixteen years.
So if CFS is so good at this, what is the problem?
The problem is not what the credit costs. It is that you cannot control it.
It is a heuristic, not a contract. Half the latency target is a number someone chose. It is not connected to what your task actually needs. You cannot ask for less, and you cannot ask for more.
And it is the only knob you have. If the credit is not right for your workload,
your sole remaining lever is nice — which buys latency with throughput, for a task
that never wanted more CPU in the first place.
So the indictment of CFS is not that it is slow, and not that it is unfair. It is that its responsiveness is a fixed side effect you cannot ask for, cannot tune, and cannot decline.
perf sched. The track is what is happening; the strip is the receipt.Omits: as above, and CFS is modelled from the Linux 6.1 source. Unlike the EEVDF figures it cannot be validated against a trace — CFS was removed from the kernel in 6.6.
Omits: as above, and CFS is modelled from the Linux 6.1 source. Unlike the EEVDF figures it cannot be validated against a trace — CFS was removed from the kernel in 6.6.
Act 4 — Fairness as a debt
EEVDF starts from a different question. Not “who is furthest behind?” but “who are we in debt to?”
Take all the runnable tasks and compute the weighted average of their virtual times. Call it V:
V = (Σ wⱼ·vⱼ) / Σ wⱼ
V is the point where nobody is owed anything. The kernel calls it the zero-lag
point, and as of 7.0 the variable is named zero_vruntime, which is about as clear
as kernel naming gets. Every task’s lag is measured against it:
lag_i = w_i · (V - v_i) real CPU owed
vl_i = V - v_i virtual lag — what the kernel actually tracks
Positive lag means the scheduler owes you time. Negative lag means you have had more than your share — you are overdrawn.
Keep the two apart. The kernel stores vl_i, the virtual lag, because dropping the
w_i saves it a multiply everywhere. But the two order tasks differently whenever
weights differ — on the workload below they disagree about who is owed most in about
a third of frames — so the figures below say vl, and mean it.
And now the rule that makes EEVDF different. A task is eligible to run only if its lag is not negative:
eligible ⟺ v_i ≤ V
If you are ahead of the zero-lag point, you sit out. Not forever — V keeps moving forward as other tasks run, and it will catch up to you. But you do not get to run again until it does.
This is the part worth pausing on. Selection is no longer “pick the minimum”. It is filter, then choose. First discard everyone who has already had more than their share, then choose among the rest. Those are two different operations, and separating them is what creates room for a second knob.
vl = V − v; multiply by the task’s weight to get the CPU it is actually owed. Left of V, the scheduler owes you. Right of V, you are overdrawn.Omits: single CPU; no sleeping or I/O; no group scheduling; no other scheduling classes. See the fidelity note at the end for the full list.
Omits: single CPU; no sleeping or I/O; no group scheduling; no other scheduling classes. See the fidelity note at the end for the full list.
So: filter to the eligible. Then what? Then EEVDF asks each eligible task when it would like to be finished by. Every task has a request — a slice of CPU it wants in one go — and from that comes a virtual deadline:
vd_i = ve_i + r_i / w_i
The eligible task with the earliest virtual deadline wins the pick. A smaller request puts your deadline nearer, which gets you picked sooner.
Winning the pick is not the same as being on the CPU. Once a task is chosen, EEVDF
lets it finish the request it asked for — RUN_TO_PARITY — rather than re-deciding
every tick. So at any given instant the task holding the CPU is often not the one
with the nearest deadline, and the figure below will say so when that happens. This
matters more than it sounds: it is why shrinking a slice costs context switches, which
is the bill Act 5 asks you to pay.
Omits: single CPU; no sleeping or I/O; no group scheduling; no other scheduling classes. See the fidelity note at the end for the full list.
Act 5 — Two knobs
Read that deadline formula again, and notice what is not in it.
The request r_i is a separate input from the weight w_i. They both affect the
deadline, but they are independently settable. So a task can ask for a small
slice without asking for a large share.
That is the whole thing. That is what CFS had no way to express.
Go back to the compile and the audio thread. The audio thread keeps nice 0 — it takes no throughput away from anything. Instead it asks for a slice of, say, 100 microseconds instead of the default 700. Its deadline now lands much sooner after every wake, so it gets picked sooner. It does not receive any more CPU than before — it barely wanted any — it just receives what it did want in smaller, earlier pieces.
Notice what actually changed. Not the latency — CFS could already deliver that. What changed is that the latency is now something the task asked for: a number it chose, bounded, and visible to anyone reading the code. A heuristic became a parameter.
That is a smaller-sounding claim than “EEVDF is faster”, and it is the one that survives contact with a measurement. It is also the one that matters if you are the person who has to make an audio thread hit a deadline on a machine you do not control.
The cost is real and worth naming: more context switches. Shrinking a slice buys latency with switch overhead, and that is a genuine trade. But it is a trade you opted into, with a knob, at a price you can see — which is a different kind of thing from a number in the scheduler deciding on your behalf.
Two knobs. nice is how much CPU the interactive task is entitled to; slice is how soon it wants it. Fix the latency with the slice alone, leaving nice at 0, and watch the switch counter pay for it. Then put the slice back and try nice instead — and notice that almost nothing happens, in either direction. A task that only ever wants 200 µs out of every 3.2 ms cannot be helped by being told it deserves more; there is no extra CPU for the priority to claim. That is the sharper version of Act 3’s point. The CFS row has no slice knob at all, so nice is the only thing it can be given, and nice is not the answer.
Omits: single CPU; no group scheduling; no other scheduling classes. CFS is modelled with its wake-up placement and wakeup preemption — without those it would look far worse at latency than it is — but it comes from the Linux 6.1 source and, unlike the EEVDF side, cannot be validated against a trace, because it was removed from the kernel in 6.6.
On Linux 7.0 you reach this through sched_attr:
struct sched_attr attr = {
.size = sizeof(attr),
.sched_policy = SCHED_NORMAL,
.sched_runtime = 100000, /* 100 us request */
};
sched_setattr(0, &attr, 0);
It is backed by se->custom_slice in the kernel, which is worth knowing about
because it dates the feature precisely. Search for it in 6.8 and you will find
nothing at all — the field does not exist. It arrives in 6.12. So “EEVDF landed in
6.6” is true and also misleading: the scheduler arrived two years before the knob
that justifies it.
How much of this should you believe?
Every figure above is driven by a simulation, and simulations are very easy to get
quietly wrong — this page has the scars to prove it. So here it is checked against the real thing: the same workload
run on an actual Linux 7.0 kernel, traced with bpftrace, and put next to the model.
Three tasks at nice 0, +5 and −5, pinned to one CPU, eight seconds. The top band is what the kernel actually did. The bottom is the model. Grey in the kernel band is real: the CPU was held by something other than our three tasks.
The claim is cumulative CPU share within a stated tolerance, not that the two timelines are identical — they cannot be, and anyone telling you otherwise is selling something.
What to take away
If you keep one thing from this, make it the shape of the problem rather than the name of the algorithm.
Any time a resource is shared — a CPU, a disk, a network link, a queue of support tickets — there are two questions hiding inside the word fairness. How much of it do you get. And how soon. They feel like one question, and almost every system built to answer the first ends up answering the second silently, on your behalf, with a number somebody picked once and nobody has revisited.
CFS answered it well for sixteen years. Well enough that essentially nobody discovered they had no say in it, which is the highest praise a default can earn. The change in 6.6 is not that the answer got better. It is that it became a question you are allowed to ask.
And that is why it took a replacement rather than a patch. You cannot bolt a second
knob onto a rule that reads run whoever has the lowest number, because there is
nowhere in that sentence to put a request. vd_i = ve_i + r_i / w_i has somewhere to
put it. That is the entire difference, and it is the reason sixteen years of
excellent heuristics had to be thrown away to get it.
What this page is and is not
Everything above runs on a model, not on a kernel. The model was written against the source of one specific version and checked against a trace from that same version. Here is exactly what that does and does not buy you.
The version
This page describes Linux 7.0, as shipped in Ubuntu 24.04'slinux-generic-hwe-24.04 (package 7.0.0-28.28~24.04.1), on aarch64. 7.0 is not an LTS release. It was chosen because the essay's payoff — the per-task slice — is backed by se->custom_slice, which haszero occurrences in 6.8 and first appears in 6.12; the 6.12 LTS was not reachable from this distribution, so the alternative was describing a kernel we could not trace.
If you are cross-referencing older write-ups, two things moved.sysctl_sched_base_slice is 700 µs here, not the 750 µs of 6.12 and earlier, and min_vruntime has been renamedzero_vruntime.
What the model does not do
- One CPU. No SMP, no load balancing, no migration, no NUMA.
- No group scheduling. No cgroup hierarchies, no bandwidth throttling.
- One scheduling class. No
SCHED_FIFO,SCHED_RRorSCHED_DEADLINE. - Exact arithmetic instead of the kernel's. The kernel computes virtual time with a fixed-point reciprocal (
__calc_delta,WMULT_SHIFT = 32); we use integer division. The divergence is far below the tolerance below. - Scheduler features that were live during the capture but are not modelled:
PREEMPT_SHORT,DELAY_DEQUEUE,DELAY_ZERO,PLACE_REL_DEADLINE,PICK_BUDDY,CACHE_HOT_BUDDY. The full list for each run is recorded inenvironment.txt. - No tick granularity, timer slack or interrupt accounting.
What the model does implement faithfully: virtual-time accumulation, the realsched_prio_to_weight table, the weighted-average zero-lag point, eligibility, per-entity slices, virtual deadlines, RUN_TO_PARITY, lag preservation across sleep, and — on the CFS side — the wake-up credit and wakeup preemption.
The CFS half cannot be validated
CFS was removed from the kernel in 6.6. It cannot be run, let alone traced, on the kernel this page describes. Everything said about CFS is modelled from the 6.1source and is not trace-validated. Treat the EEVDF numbers and the CFS numbers as having different standards of evidence, because they do.
Claims this page tried to make and could not support
Two, both about CFS, both caught by the model rather than by review.
“CFS cannot deliver low latency without sacrificing throughput.”Wrong. Once wakeup preemption was modelled properly, CFS turned out to be good at interactive latency — better than stock EEVDF on the workload used here.
“CFS's wake-up credit is free time, so a sleeping task ends up with more than its weight entitles it to.” Also wrong, and it replaced the first claim. The placement rule is a max(): it can only move a waking task forward in virtual time, so it caps how much credit may be banked rather than granting any. On this workload it pulls the task from 5.8 ms behind to 3.0 ms behind at every binding wake. And the interactive task receives 6.25% of the CPU — not more than its 50% weight share, but exactly the 6.25% it asked for. A task limited by its own demand cannot meaningfully over-consume, so served-versus-entitled measures nothing here.
Fairness and the fragility of CFS's heuristics were genuinely part of the case for replacing it. This page does not argue them, because this page's model does not demonstrate them, and an argument you cannot show is an argument you should not make. What is demonstrated is the second knob: latency you can request, bounded, at a price the figure prints.
Both retractions are left visible in the essay rather than edited away. They are the clearest evidence for the thing this section exists to say — an unvalidated model flatters whatever argument you brought to it, and it does so persuasively.
The tolerance
The comparison above is cumulative CPU share, within 1.0 percentage point. The figure prints the worst deviation it actually measured, rather than repeating a number from here — the two are computed over slightly different model runs and would drift apart.
That number is worth one more paragraph, because a single run flatters itself. Re-running the capture nine times produced this:
| capture length | runs | worst deviation |
|---|---|---|
| 2 seconds | 6 | 0.08 – 0.98 pp |
| 8 seconds | 3 | 0.005 – 0.24 pp |
The page originally shipped a two-second capture that happened to land at 0.16 pp, quoted as though it were the result. It was not: a later two-second run reached 0.98 pp, which all but touches the tolerance. The noise is concentrated in task B — the lightest of the three, so it gets the fewest and shortest slices and has the least opportunity to average out a stolen millisecond.
The fix was a longer capture rather than a looser tolerance. The figure now ships themedian of three eight-second runs, not the best of them.
It is not a claim that the two timelines are identical. They cannot be. Timer granularity, interrupts and unrelated kernel work all intrude, and the capture ran in a VM on a laptop, where the vCPUs are themselves being scheduled by a host. Anyone claiming bit-identical agreement between a simulator and a live scheduler is selling something.
Reproduce it
The capture harness is in tools/trace/: three spinners at nice 0, +5 and −5, pinned to one CPU, traced with bpftrace onsched:sched_switch. One detail is easy to get wrong and silently ruins the result: sysctl_sched_base_slice is scaled by CPU count at runtime, so on a 4 vCPU box it reads 2,100,000 rather than 700,000. The harness pins it before capturing. Without that the comparison is off by 3× and still looks plausible.
Sources
kernel/sched/fair.cat v7.0 — and at v6.1for the CFS side.- Ion Stoica and Hussein Abdel-Wahab, “Earliest Eligible Virtual Deadline First: A Flexible and Accurate Mechanism for Proportional Share Resource Allocation”, Old Dominion University CS, TR-95-22, 1995 — the algorithm, thirty years before the kernel adopted it.
- An EEVDF CPU scheduler for Linux, LWN, 2023.
- Completing the EEVDF scheduler, LWN, 2024 — the series that added the per-task slice this essay is about.