From 3a890804956cbdf8caee59ed0169aba0f0b2aafd Mon Sep 17 00:00:00 2001 From: elwrcl Date: Fri, 4 Sep 2026 23:53:20 +0300 Subject: [PATCH] docs --- docs/bitmap-clear.md | 92 ++++++++++++++++++++++++ docs/boost-on-wake.md | 104 +++++++++++++++++++++++++++ docs/cgroup-and-donation.md | 135 ++++++++++++++++++++++++++++++++++++ docs/cpu-release.md | 89 ++++++++++++++++++++++++ docs/service.md | 8 ++- docs/ule-ratio.md | 87 +++++++++++++++++++++++ docs/vtime-within-level.md | 78 +++++++++++++++++++++ 7 files changed, 590 insertions(+), 3 deletions(-) create mode 100644 docs/bitmap-clear.md create mode 100644 docs/boost-on-wake.md create mode 100644 docs/cgroup-and-donation.md create mode 100644 docs/cpu-release.md create mode 100644 docs/ule-ratio.md create mode 100644 docs/vtime-within-level.md diff --git a/docs/bitmap-clear.md b/docs/bitmap-clear.md new file mode 100644 index 0000000..4b0562f --- /dev/null +++ b/docs/bitmap-clear.md @@ -0,0 +1,92 @@ +# `--clear-bitmap`: measured worse, default off + +`lvl_bitmap` advertises which levels hold tasks. Bits were originally set-only +-- never cleared -- because clearing one needs a store/load barrier that LFBMQ +gets wrong at `alt_core.c:4260`, and getting it wrong strands tasks forever. + +The cost of not clearing is that `ops.dispatch` walks every level ever used on +every call. Measured before any change: a median of **55 DSQ probes to place +one task**, peaking at 279, against 33 levels. Higher than the level count +because most scanning happens on dispatch calls that find nothing and never +increment `dispatch` -- a CPU going idle was touching 25 DSQs to learn it had +no work. + +So clearing was implemented, correctly, with a repair sweep as a safety net. +It works: probes per dispatch fall from ~19.5 to 0.53 under compile load. + +**And it is off by default, because it costs interactive latency.** + +## The measurement + +Same binary, same instrument, same source tree, one flag. Two rounds run back +to back, with the order reversed between them so session drift could not align +with the result. + +| round | order | n | clear=false better | clear=true better | sign test | median ratio | +| --- | --- | --- | --- | --- | --- | --- | +| 1 | true then false | 35 | 23 | 11 | p = 0.058 | 0.84 | +| 2 | false then true | 35 | 24 | 10 | p = 0.024 | 0.81 | +| **pooled** | | **70** | **46** | **23** | **p = 0.008** | **0.84** | + +Round 1 is the more trustworthy of the two: `false` ran second there, so drift +worked against the result and it still won. Round 2 has order and result +aligned, so it likely overstates. The pooled figure is the honest middle. + +Corroborating and drift-independent, the p99 bucket median: + +| | p99 | p99.9 | +| --- | --- | --- | +| clear=true (both rounds) | 524 us | 1.00 ms | +| clear=false (both rounds) | 459 us | 0.92 / 1.00 ms | + +One bucket, consistent across rounds, while absolute tail percentages moved +between 0.098 and 0.160. + +## Why + +The likely mechanism, unproven: set-only left `lvl_bitmap` effectively +read-only after warmup, and the scan touched 25 different DSQ cache lines with +unlocked reads every CPU can hold shared. Clearing turns one u64 into a +write-hot line -- roughly 600k atomic RMWs on top of the ~1.1M the enqueue side +already does -- that four threads must take exclusive turns owning. + +Twenty-five shared reads are cheaper than one contended line. That is a +statement about a 2c/4t Ivy Bridge and may not survive a wider machine. + +## The asymmetry that decides it + +Clearing buys 37x fewer probes per dispatch. That scan never had a *measured* +latency cost: `consume_dispatch_q()` tests `list_empty()` unlocked and returns +before taking `dsq->lock` (`ext.c:2517`), so a miss is a cache read and a kfunc +call. Paying a measured cost for an unmeasured saving is the wrong trade. + +The knob stays. On a machine with more cores, more cache, or a longer level +scan, the arithmetic could invert. + +## What clearing did not buy + +It looked like clearing was also required for anti-starvation to work: +`starve_serve` was 27 with bits set-only against 244 with them cleared, because +the probe was gated on `lvl_bitmap` and most probes landed on levels that had +been empty for minutes. + +That was an argument for not asking the bitmap, not for clearing it. The probe +now calls `scx_bpf_dsq_nr_queued()` directly -- one unlocked read, and only +after the free timestamp compare has already shown the level is past +`starve_ns`. With bits set-only and the probe decoupled, `starve_serve` is 268: +the protection works without paying for the clear. + +## Open + +`p99` tracks `starve_serve` rather than `clear_bitmap` across all five runs: +459 us when starvation is quiet (27, 32 serves), 524 us when it is active (244, +268, 283). That suggests clearing and active anti-starvation are two +independent causes of the same one-bucket loss, which would also explain why +disabling anti-starvation entirely while clearing was still on changed nothing +(17 of 35 intervals, p = 1.0) -- the bucket was already spent. + +If that holds, a working stall protection costs about one p99 bucket, and that +is a policy price worth paying rather than a bug. It is built on cross-run +comparisons, which this project has already been burned by once, so it is +recorded as a hypothesis and not a result. Deciding it needs a knob for the +probe gating and another back-to-back pair. diff --git a/docs/boost-on-wake.md b/docs/boost-on-wake.md new file mode 100644 index 0000000..d6211b2 --- /dev/null +++ b/docs/boost-on-wake.md @@ -0,0 +1,104 @@ +# `--boost-on-wake`: the typo wins + +BMQ's `sched_task_ttwu()` is meant to boost a waking task in proportion to how +long it slept. That is what makes sleeping for 1 us worth nothing and closes +the sleep-gaming hole. The line reads + +```c +s64 delta = this_rq()->clock_task > p->last_ran; +``` + +A comparison, not a subtraction. `delta` is 0 or 1, the shift takes it to 0, +and the function has been dead code in every shipped BMQ build. `soryu` made it +a knob rather than silently picking a side: `--boost-on-wake=true` is what BMQ +meant to do, `false` is what BMQ actually does. + +**Result: off is better, and it is not close.** + +## What was run + +The scheduler compiling itself, twice per configuration, on the Ivy Bridge +target (i5-3210M, 2c/4t, kernel 7.1.6). Each run logged one report every 10 s +with `--report-sec 10`, giving ~34 intervals per run. Rounds 1 and 2 were run +in separate working trees. + +Every number below is per-interval, not cumulative. That matters: before the +histograms were differenced, a run printed one figure blended across every +phase the build went through, which described none of them. + +## Latency + +Interactive (`boosted` class) wake->run, median across intervals: + +| | p99 | p99.9 | tail >1 ms | +| --- | --- | --- | --- | +| on, round 1 | 524 us | 1.30 ms | 0.152% | +| on, round 2 | 524 us | 1.80 ms | 0.168% | +| **off, round 1** | **459 us** | **0.92 ms** | **0.075%** | +| **off, round 2** | **459 us** | **0.92 ms** | **0.077%** | + +The two `off` runs agree to the last digit on all three columns. + +Pairing the runs interval by interval on the >1 ms tail: + +| | intervals | off better | on better | sign test | median ratio | +| --- | --- | --- | --- | --- | --- | +| round 1 | 34 | 31 | 3 | p = 7.7e-07 | 0.53 | +| round 2 | 34 | 32 | 2 | p = 6.9e-08 | 0.45 | +| pooled | 68 | 63 | 5 | p = 7.7e-14 | 0.47 | + +Worst single interval, which is the number a stutter would live in: + +| | worst tail >1 ms | worst p99.9 | worst `max_wait` | +| --- | --- | --- | --- | +| on, round 1 | 0.299% | 4.2 ms | 21 j | +| on, round 2 | 1.787% | 21.0 ms | 38 j | +| off, round 1 | 0.656% | 7.3 ms | 22 j | +| off, round 2 | 0.129% | 0.92 ms | 21 j | + +Each configuration produced one bad round, and `on`'s was worse: two +consecutive intervals at 21 ms p99.9. + +## Throughput + +| | round 1 | round 2 | +| --- | --- | --- | +| on | 5 m 34 s | 5 m 45 s | +| off | 5 m 39 s | 5 m 32 s | + +The ordering flips between rounds. With n = 2 per configuration and a ~10 s +spread already known from earlier runs, there is no throughput difference to +report in either direction. The latency result stands on 68 paired intervals; +the timing result stands on nothing, and is not claimed. + +## Health + +`insert_fail = 0`, `starve_serve` 22-26 across all four runs, `max_wait` 21-22 +jiffies except the one 38 j interval noted above. No stalls, no ejections. + +## Why the default flipped + +Two independent arguments point the same way. The measurement says off. And +off is BMQ as actually shipped -- the behaviour a decade of field use already +ran, rather than a resurrection nobody has tested. The knob stays so the +question can be reopened on other hardware. + +## Open question + +The mechanism is not understood. The wake boost grants up to 24 steps at once, +so a waking task is slammed to the floor rather than climbing at +1 per sleep, +and the guess is that this makes a task's level change often enough that +`cpu_level[]` is stale when `try_preempt()` reads it. That is a guess. It would +be tested with a per-task counter of how often the level computed at enqueue +differs from the one seen at `ops.running`. + +Note this is not the boost distribution collapsing. Under compile load both +configurations sit bimodal at roughly 54% on the -12 floor and 39% on the +12 +ceiling, with about 5% spread across the twenty-three values between, and +`preempt_none` stays near 1.2% in both. + +That bimodality was expected to disable preemption -- if everything sits at one +level, nothing outranks anything and `try_preempt()` finds nobody worth an IPI. +It does not, because the range saturates at *both* ends rather than one: the +crowd on the floor always has a ceiling hog to displace. Saturation at one end +would have killed preemption; saturation at both preserves it. diff --git a/docs/cgroup-and-donation.md b/docs/cgroup-and-donation.md new file mode 100644 index 0000000..c1cbc98 --- /dev/null +++ b/docs/cgroup-and-donation.md @@ -0,0 +1,135 @@ +# cgroup weights, and priority donation down the wake chain + +Two additions, both knobs, both default off. One closes a functional gap, the +other tested an idea and came back null. + +## cgroup cpu.weight + +Until now the scheduler was completely deaf to it. `ops.cgroup_set_weight` was +not implemented, so systemd's `CPUWeight=`, and with it the whole +`system.slice` / `user.slice` / `app.slice` arrangement every desktop already +has, did nothing. nice worked; cgroups did not. + +`ops.cgroup_init`, `ops.cgroup_set_weight` and `ops.cgroup_move` are now +implemented, with weight held in `BPF_MAP_TYPE_CGRP_STORAGE` keyed by the +cgroup pointer. + +`cpu.weight` is `[1, 10000]` with 100 the default and it is geometric: one nice +step is a factor of 1.25, so an octave of weight is `log(2)/log(1.25)` = 3.1 +nice steps. Rounded down to 3, which errs toward giving cgroups slightly less +authority than nice rather than more. + +The awkward part is caching. `ops.cgroup_set_weight` affects every task in a +cgroup and there is no way to walk them, so a generation counter is bumped on +any weight change; each task compares its own `cgrp_seen` against it and +refreshes once, in `ops.enqueue`. `task_level()` only reads the cached value, +so the hot paths never pay for `scx_bpf_task_cgroup()`'s acquire/release. + +`--cgrp-weight-levels` caps the movement in each direction. 0 is the default. + +It works, and what it does is not what `cpu.weight` means anywhere else. + +### Measured + +`tools/cgroup-share.sh`: two cgroups at `cpu.weight` 1000 and 10, N spinners in +each, on a 4-CPU machine. CPU time each group actually got over 30 s. + +| spinners per group | ratio hi:lo | +| --- | --- | +| knob off, 4 | **1.00 : 1** | +| 2 (half the CPUs) | 1.36 : 1 | +| 4 (exactly the CPUs) | 3.83 : 1 | +| 8 (twice the CPUs) | **497 : 1** | + +The first line is the gap this closed: `cpu.weight` had precisely no effect +before. + +The rest is the shape of the thing. At 4 spinners per group the ratio looks +almost reasonable, and it is an artifact of the test: a hi task whose slice +expires is not yet back in its DSQ when that CPU calls `ops.dispatch`, and the +other three are on CPUs, so its level is momentarily empty and dispatch falls +through to lo. Nothing is violated -- hi genuinely was not queued. + +Oversubscribe the group and that window closes. At 8 per group, lo got 200 ms +out of 30 s. The scheduler log says why: `enq_local` 0 throughout, so no idle +fast path, and `starve_serve` at up to 30/s. That 200 ms *is* the +anti-starvation crumb and nothing else. + +### It is a switch, not a dial + +BMQ is strict priority. Once two groups land on different levels, the better +one wins whenever it has anything queued -- and the size of the weight +difference does not enter into it, only whether it crossed a level boundary. + +Lowering `--cgrp-weight-levels` does not soften that. It only raises the weight +ratio needed to cross a boundary. 1000-against-100 at `levels 2` is still one +level apart and still 497:1. + +CFS gives a 100:1 weight ratio a 100:1 share. Here a 100:1 weight ratio means +the lighter group is off. + +### The number that decides the default + +`max_wait` reached **1067 jiffies -- 1.07 seconds** -- during the 8-spinner run, +with `l25` (the lo group) as the level waiting. The watchdog ejects at 5 s. + +So this knob, at 6, on an oversubscribed machine, runs at a fifth of the stall +threshold, and the only thing between it and an ejection is the anti-starvation +mechanism. That is a real dependency and it is worth stating: `starve_ns = 0` +together with `cgrp_weight_levels > 0` is a combination that will eject. + +Default stays 0. Anyone enabling it should know they are getting a priority +band, not a share. Proportional behaviour is not reachable from a level +adjustment at all -- it would need a second mechanism underneath the levels, +which is a different scheduler. + +## Wake-chain donation: null + +XNU propagates priority through blocking chains with turnstiles; Linux does the +same for `rt_mutex` but only inside the RT class. From BPF the lock graph is +invisible, but the wake graph is not, and it is a decent proxy: if A wakes B, B +is usually about to do something for A. + +`ops.runnable` runs in the waker's context for a synchronous wakeup, so +`bpf_get_current_task_btf()` is the waker. If the waker's boost is better, the +wakee is pulled toward it, capped by the gap so it never overtakes -- without +that cap a chain of wakeups ratchets everyone to the floor, which is the +saturation boost already suffers from. + +This was deliberately the opposite kind of idea from the ULE ratio. ULE graded +more finely and the tail did not move, which said the problem is not priority +resolution. Donation does not grade; it changes *who* is urgent. + +`--wake-donate 4` against 0, back to back on the same compile: + +| | intervals | wake_donate | tail >1 ms | p99 | p99.9 | boost floor | +| --- | --- | --- | --- | --- | --- | --- | +| 4 | 37 | 5035 | 0.155% | 655 us | 1.00 ms | 50.9% | +| 0 | 38 | 0 | 0.142% | 655 us | 1.00 ms | 50.8% | + +Paired: donate better in 14 intervals, off in 22, sign test p = 0.24. p99 and +p99.9 bit-identical. + +## Why it did nothing, which is the useful part + +Donation fired 5035 times, roughly 0.6% of wakeups, and the boost floor came +out at 50.9% against 50.8% -- it did not move the population at all. + +The condition needs the waker to be strictly better than the wakee, and half +the population is already pinned at -12. Waker at the floor, wakee at the +floor, no gap, no donation. + +So saturation kills both ideas from opposite ends: ULE produces a gradient and +grading turns out not to help, while donation needs a gradient and boost does +not have one. Feeding ULE's gradient into donation is the obvious next chain, +and it is built on the half of the pair that was already measured not to +matter, so it was not attempted. + +A larger dose is the one cheap variant left. Moving 0.6% of the population +further is not going to move a p99 computed over all of it, so the expectation +is another null. + +## Defaults + +Both 0. The cgroup path is a gap worth having closed and awaits a share +measurement; donation is a tested null kept for the knob and the record. diff --git a/docs/cpu-release.md b/docs/cpu-release.md new file mode 100644 index 0000000..80910e0 --- /dev/null +++ b/docs/cpu-release.md @@ -0,0 +1,89 @@ +# cpu_release: a blind spot, an answer, and a knob that changed nothing + +The interactive tail on an idle-ish desktop is consistently worse than during a +full compile -- 0.29% past 1 ms against 0.15% -- which is backwards, and the +one number that tracked it was `cpu_release`: about 130 a second on the desktop +against 3 during a compile. + +## The instrument was blind to it + +`scx_bpf_reenqueue_local()` throws the local DSQ back through `ops.enqueue` +with `SCX_ENQ_REENQ`. `ops.runnable` is not part of that path, so `wake_at` is +never stamped, and `ops.running` records a latency sample only when `wake_at` +is set. + +So every task bounced by a higher sched class spent that trip invisible. The +tail that every decision on this branch was measured against excluded exactly +the population under suspicion. `bounce_hist` was added to close that, stamped +at `SCX_ENQ_REENQ` in `ops.enqueue` and read at `ops.running`, recorded +alongside the wake sample rather than instead of it. + +## What takes the CPU + +`ops.cpu_release` now splits by `enum scx_cpu_preempt_reason`. + +| | releases | rt | stop | dl | +| --- | --- | --- | --- | --- | +| compile, 6 min | 662 | 307 | 355 | 0 | +| desktop, 5 min | 38916 | 38775 | 141 | 0 | + +Desktop is **99.6% RT**, at roughly 130 releases a second, each bouncing 0.98 +tasks. No deadline tasks at all. + +## Bounced tasks wait about twice as long + +| | wake->run p99 | bounce->run p99 | bounce share | +| --- | --- | --- | --- | +| compile | 655 us | 786 us | 0.1% | +| desktop | 655 us | 1.3 ms | 1.8% | + +Real, and too small to matter. At 1.8% of samples with a p99 of 1.3 ms, +bounces contribute roughly 18 events past 1 ms per interval against 193 from +the wake population -- about 9% more tail, not the doubling that needed +explaining. + +## So the answer is the boring one + +The desktop's *non-bounced* population is already worse: `wake->run` p99.9 of +1.45 ms against 1.00 ms during a compile. RT preemption at 130 a second does +not mainly hurt the task it bounces, it steals CPU from everyone and raises +queueing delay across the board. + +That is RT working as designed. The scheduler has no move here. + +## The knob, and its null + +If the RT burst is short -- an audio callback is tens of microseconds -- then +re-enqueueing is waste: the task pays a full trip through `ops.enqueue`, level +recomputed and DSQ reinserted, to avoid a wait it would have outlasted. +`--reenqueue-on-release=false` tests that. + +| | intervals | cpu_release/iv | enq_reenq | tail >1 ms | p99 | p99.9 | max_wait | +| --- | --- | --- | --- | --- | --- | --- | --- | +| true | 14 | 1138 | 15580 | 0.368% | 655 us | 1.60 ms | 32 j | +| false | 30 | 897 | 0 | 0.410% | 655 us | 1.60 ms | 22 j | + +Paired: `false` better in 4 intervals, `true` in 10, sign test p = 0.18. + +The knob works -- `enq_reenq` and the bounce histogram both go to zero -- and +changes nothing measurable. p99 and p99.9 are bit-identical. This was predicted +before the run, on the arithmetic above, and the prediction is recorded here +because on this branch it is the third or fourth hypothesis to be tested and +the first to survive. + +The comparison is weak on its own terms: the two runs differ by 27% in release +rate and by more than double in length, because desktop activity is not +reproducible the way a compile is. It is not worth strengthening. The prior and +the data agree, and they agree on "no effect". + +## Default + +`reenqueue_on_release = true`. It is the conventional behaviour, it is what the +paired direction leans toward, and it is the safe side of a risk this test did +not exercise: with it off, tasks in the local DSQ of a CPU taken by a *long* +RT or DL task cannot migrate away, and `starve_ns` walks the shared level DSQs, +not local ones. Short audio bursts are fine. An RT hog would be a 5 s watchdog +ejection. + +`bounce_hist` and the reason split stay regardless. They cost nothing and they +closed a hole in the instrument that had been open the whole time. diff --git a/docs/service.md b/docs/service.md index 3bf3ee3..9e87d0f 100644 --- a/docs/service.md +++ b/docs/service.md @@ -23,8 +23,9 @@ services.scx-soryu = { }; ``` -that's it. the defaults are measured values: `sliceUs = 1000` -([`slice.md`](slice.md)), `starveMs = 128` ([`stage6-starve.md`](stage6-starve.md)), +that's it. the defaults are measured values: `sliceUs = 1000` (the same +compile took 4m49s at 4194 and 4m39s at both 1000 and 500), `starveMs = 128`, +`boostOnWake = false` ([`boost-on-wake.md`](boost-on-wake.md)), `timeoutMs = 5000`, `reportSec = 60`. to turn it off: `services.scx-soryu.enable = false;` or @@ -75,7 +76,8 @@ sudo scx_soryu --monitor 3 ## verified in a VM `nixosConfigurations.servicevm` — the same test VM, but running the -scheduler as a service instead of by hand. `tools/servicevm.sh`: +scheduler as a service instead of by hand. the checks that were run against +it: | check | result | | --- | --- | diff --git a/docs/ule-ratio.md b/docs/ule-ratio.md new file mode 100644 index 0000000..704d656 --- /dev/null +++ b/docs/ule-ratio.md @@ -0,0 +1,87 @@ +# ULE's interactivity ratio: it separates, and separating does not help + +BMQ grades a task with a saturating counter: one step better per sleep, one +step worse per burnt slice. Measured under compile load, that counter is not a +grade -- it is a one-bit classifier. The distribution sits bimodal at roughly +50% on the -12 floor and 44% on the +12 ceiling, with about 6% spread across +the twenty-three values between. + +FreeBSD ULE asks a different question: not how many times you slept, but what +fraction of your recent life was sleep, over a decaying window. That is a +continuum by construction. The hypothesis was that it would place the tasks +BMQ cannot -- a render thread, a compositor, a decoder, things that both sleep +a lot and burn a lot and so oscillate around the middle under a counter. + +**The hypothesis was half right, and the useful half was the wrong half.** + +## The score does separate + +`ule_weight = 0`, so the score was computed and histogrammed without being +allowed to touch a scheduling decision. Split by what BMQ's boost thought of +the same task at the same moment: + +| class | share of events | ULE mean | off both extremes | +| --- | --- | --- | --- | +| boosted (boost < 0) | 52.8% | 4.7 | 18.5% | +| neutral (boost == 0) | 0.7% | 58.7 | 72.5% | +| deboosted (boost > 0) | 46.4% | 94.8 | 2.7% | + +On the hog end the two metrics agree completely: 95.3% of the deboosted class +scores 95-99. ULE adds nothing there and is exactly as saturated as boost. + +On the interactive end it adds a lot. Boost puts 50.4% of all events at +precisely -12, which is about 95% of the boosted class on a single value. ULE +splits that same population: + +``` + 0-4 67.9% + 5-9 14.6% + 10-14 16.5% +``` + +Three populations where boost sees one. + +## Letting it decide changes nothing + +`--ule-weight 12`, the largest setting that keeps ULE's authority comparable to +nice's, run back to back against `--ule-weight 0` on the same tree. + +| | intervals | tail >1 ms | p50 | p99 | p99.9 | distinct levels | max_wait | +| --- | --- | --- | --- | --- | --- | --- | --- | +| W=12 | 34 | 0.108% | 16.4 us | 393 us | 1.00 ms | 26 (span 0-28) | 21 j | +| W=0 | 34 | 0.106% | 16.4 us | 459 us | 1.00 ms | 15 (span 0-22) | 20 j | + +Paired: W=12 better in 16 intervals, W=0 in 17. **Sign test p = 1.0.** + +The mechanism engaged -- distinct levels in use went from 15 to 26 and the +span from 0-22 to 0-28, so the score really did reach the level arithmetic and +stretch the distribution. It simply did not move the interactive tail. + +p99 came out one bucket better, on one pair. This project has already been +burned once by trusting a single-run bucket comparison, so that is recorded as +a hint and not a result. + +No harm either: `insert_fail` 0, `max_wait` 21 j against 20 j, and the deeper +level span was absorbed by anti-starvation (392 serves against 281). + +## What this rules out + +The interactive tail is not limited by priority resolution. Grading more +finely, carrying the grade into the level, and roughly doubling the width of +the priority distribution all failed to move it. + +That is worth knowing because it was the premise behind the whole ULE detour. + +## Default + +`ule_weight = 0`. The score is still computed and reported, because measuring +it is free and it may answer a different question later. + +## Caveat on the mapping + +`adj = (score - 50) * weight / 50`, and the level is `(... + adj) / 2`, so the +0-14 band that holds 99% of the interactive population maps to only `0.28 * +weight` units and then halves again. At W=12 that is three distinct adjustments +(-11, -10, -9) and one or two distinct levels. A mapping fitted to the observed +distribution would extract more separation -- and would be fitted to one +workload on one machine, which is why it was not done. diff --git a/docs/vtime-within-level.md b/docs/vtime-within-level.md new file mode 100644 index 0000000..f7d840f --- /dev/null +++ b/docs/vtime-within-level.md @@ -0,0 +1,78 @@ +# vtime ordering inside a level: null, because the target was already gone + +Every mature sched_ext scheduler orders its DSQs by virtual time -- +`scx_bpf_dsq_insert_vtime` is used by bpfland, flash, lavd, p2dq, cake, +mitosis, beerland and forge. soryu inserted plain FIFO, and was the outlier. + +It was worth trying here for a specific reason. The ULE ratio had already shown +that grading more finely does nothing (p = 1.0), so priority *resolution* is +not the problem. vtime is a different axis: it changes ordering *within* a +band, which nothing had touched. + +And FIFO's worst case had the shape of the tail that was left. Arrive at a +level behind k tasks and wait k slices; at 1 ms slices, k = 5 is 5 ms, which +was the >4.2 ms band. Roughly half of all events sit on the boost floor, so +that queue is the deep one and FIFO is at its worst there. + +## Design + +Unweighted, on purpose. Tasks sharing a level share a `(nice + boost)` by +construction, so priority already lives in the level and vtime inside one means +only "who has used least CPU goes first". BMQ's strict priority between levels +is untouched. + +- `ops.stopping` charges `dsq_vtime += ran`, actual runtime +- `ops.running` advances `vtime_now`, the reference clock +- `ops.enqueue` clamps a sleeper's credit to one slice, then inserts by vtime +- `init_task` starts a task at `vtime_now`; starting at 0 would read as an + enormous credit and hand every new task the head of its level + +`time_before()` rather than `<`, so the comparison survives `vtime_now` passing +2^63. A DSQ must be used consistently as FIFO or vtime and never both; the knob +is read-only after load and the other paths use built-in DSQs, so the level +DSQs stay consistent either way. + +## Measured + +Back to back, same tree, `cargo clean` before each build: + +| | intervals | >1 ms | >4.2 ms | >16.8 ms | p50 | p99 | p99.9 | +| --- | --- | --- | --- | --- | --- | --- | --- | +| vtime | 40 | 0.170% | 0.002% | 0.0000% | 16.4 us | 655 us | 1.00 ms | +| FIFO | 41 | 0.161% | 0.000% | 0.0000% | 16.4 us | 655 us | 1.00 ms | + +Paired: >1 ms, 22 to 18, p = 0.64. >4.2 ms, 9 to 14 with 17 ties, p = 0.40. +>16.8 ms, 33 of 40 intervals tied at zero. p50, p99 and p99.9 bit-identical. + +## Why it is null + +The band it was written to fix is empty. Across the day: + +| | >1 ms | >4.2 ms | +| --- | --- | --- | +| boost_on_wake on (original) | 0.156% | 0.057% | +| boost_on_wake off | 0.075% | 0.004% | +| vtime on | 0.170% | 0.002% | +| vtime off | 0.161% | 0.000% | + +Flipping `boost_on_wake` took the far tail from 0.057% to 0.004%, and it now +sits at 0.000-0.002%, which is the measurement floor. FIFO depth is not +producing a far tail because there is no far tail left. + +The prediction was not wrong so much as unemployed. There was no way to know +without measuring: that the >4.2 ms band had bottomed out only became visible +with every run of the day lined up side by side. + +The >1 ms tail that remains, ~0.16% and identical in both configurations, is +most likely irreducible -- four saturated threads and real work queued ahead. +vtime never claimed to change average wait, only worst-case wait, and the +worst case is gone. + +## Kept, default off + +The mechanism is sound and the rest of the ecosystem relies on it. What failed +was not the idea but its applicability to a 4-thread machine with a single LLC, +where level queues do not get deep enough for ordering within one to matter. + +On the 6c/12t targets the queues are deeper and this is worth re-running before +concluding anything. The knob is there for that.