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.

One task, alone on the CPU. The top rail is real time; the bottom is the task’s virtual time. At nice 0 they advance together, tick for tick — the two markers never separate, because a nice-0 task’s clock runs at exactly real speed. That is the calibration point. Everything from here on is a departure from it. Arrow keys step one scheduler tick; space plays and pauses.

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.

Two tasks, one rail each, sharing both scales — and a nice slider on the second. The slope is the lesson: for the same CPU, a heavier task’s clock climbs more shallowly. Drag it to the extreme and one line rises roughly nine times more gently than the other, yet both finish at a similar height. That is what “the same virtual time for more real work” looks like.

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.

Two tasks, two nice values. Try 0 against 5, then 10 against 15 — the same gap, and within a hundredth of a point the same split. Then try 0 against 1, and 0 against 10, and watch how fast a single step compounds. Equal gaps agree to about a percentage point; they are not identical, because the weight table is rounded.

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.

CFS in one rule: at each decision, whoever has the lowest virtual time runs — and then keeps the CPU for its whole timeslice, so in between it is often no longer the lowest. The strip along the bottom is not the mechanism — it is the trace the track above produced, the thing you would have seen in 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.

A CPU hog and a task that wakes every 3 ms wanting 0.2 ms. Note first that CFS serves it fast — this is not a figure about CFS being slow. What it shows is where the waking task gets placed, and that the placement is the same every time no matter what you do to the nice slider. That is the point: the responsiveness is real, and none of it is yours to set.

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.

The dashed line is V, the weighted average of every task’s virtual time — the point at which nobody is owed anything. The bar joining each task to V is its virtual lag, 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.

Now the rule. Tasks ahead of V grey out — they have had more than their share and cannot be picked at all. As V advances they come back. Watch the order of operations: first discard the overdrawn, then choose among what is left.

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.

Each eligible task projects a deadline marker ahead of itself, at a distance set by the slice it asked for. The nearest marker wins the pick — though the task actually on the CPU is often a previous winner still finishing its request, and the figure marks that case. Shrink the slice and watch the markers pull in: a smaller request gets served sooner, and you never touched its nice value.

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

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 lengthrunsworst deviation
2 seconds60.08 – 0.98 pp
8 seconds30.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