Hi Matthew, its been a number of days since we heard anything regarding this
bug, from a stability perspective we're keen to have this resolved as soon as
possible, aside from the initial assessment neither Canonical nor AWS have
given us anymore progress reports or working theories or temporary mitigations
to help us overcome this issue. I've used a combination of my local knowledge
of the problem and an LLM to help me distill down the problem, below is an
initial analysis. I'm sure there will be gaps in theory/analysis but I hope
that at the very least it stimulates some dialogue/discussion with others in
Canonical to hopefully drive us to a resolution. Attached is the analysis.
# LP: #2165410 — systemd (PID 1) D-state during cpuset migration on
nohz_full CPUs
Root-cause analysis and proposed fix for the Ubuntu 26.04 (resolute)
`linux-aws` 7.0 kernel.
Prepared from the Launchpad bug, its attachments (reproducer, test logs) and the
`Ubuntu-aws-7.0.0-1013.13` source tree. All commit hashes below were verified
against
Linus' mainline tree (`git describe --contains` gives the first release tag).
Status of the proposal: **compile-tested and link-tested** with the real `aws`
amd64
config (`make vmlinux` succeeds, no new warnings); patches apply cleanly to
`Ubuntu-aws-7.0.0-1011.11`, `-1013.13`, and upstream `v7.0`/`v7.1`. **Not yet
runtime-tested** on a c8a.metal-48xl; the validation plan in section 7 covers
that.
---
## 1. Executive summary
The bug is real, and it has **two independent kernel-side causes**, both of
which
come down to the same thing: a 7.0 kernel with `isolcpus=nohz,<list>
nohz_full=<list>`
(no `domain` flag) still targets the nohz_full CPUs with deferred kernel work
that is
executed by `SCHED_NORMAL` kernel threads, and a `SCHED_FIFO` userspace busy
loop on
those CPUs never lets such a thread run. PID 1 ends up waiting for that work.
1. **The captured stacks (all three tested kernels) are not `flush_work` in
`__lru_add_drain_all()` at all** — they show the `cpuset_migrate_mm` kworker
blocked
in `synchronize_rcu_expedited()` inside `lru_cache_disable()`, with
`rcu_exp_gp_kthread_worker` in `kthread_flush_work()`. The per-`rcu_node`
expedited
grace-period workers (`rcu_exp_par_gp_kthread_worker/N`) are **pinned to the
nohz_full
CPUs** on this configuration, because 7.0's `kthread_fetch_affinity()`
filters a
kthread's preferred affinity only against `HK_TYPE_DOMAIN`, and with
`isolcpus=nohz`
the domain-housekeeping mask is *all* CPUs. Being `SCHED_NORMAL`
(`CONFIG_RCU_BOOST=n` so `kthread_prio=0`), they starve behind the FIFO
spinners,
and **every** `synchronize_rcu_expedited()` on the system stalls with them.
2. **The mechanism described in the bug (Matthew Ruffell's reproducer)** is
also real:
`__lru_add_drain_all()` queues `lru_add_drain_per_cpu` onto every CPU whose
per-CPU
folio batch is non-empty, then `flush_work()`s it; on a FIFO-hogged CPU that
per-CPU
kworker never runs. The reproducer keeps 1–14 folios in the `lru_add` batch
of every
isolated CPU at all times, so every `lru_cache_disable()` /
`lru_add_drain_all()`
caller in the system blocks.
Why this looks new in 26.04: three things changed between 6.8 and 7.0 for this
configuration. RT bandwidth throttling, which used to give non-RT tasks 50 ms
per second
regardless of FIFO hogs, is gone (v6.12; replaced by the deadline "fair
server", which
is evidently not rescuing these kworkers — see section 4 and the open
questions).
`cpu_is_isolated()` stopped considering nohz_full CPUs isolated (v7.0-rc1), so
every
isolation-aware user (buffer_head LRU, vmstat, memcg drain, blk-mq) now targets
them.
And kthread affinity management moved from "inherit kthreadd's `HK_TYPE_KTHREAD`
mask" to "filter with `HK_TYPE_DOMAIN` only" (v7.0-rc1). `CONFIG_PREEMPT_LAZY`
itself
is a red herring as far as we can tell (section 4).
Upstream explicitly made the assumption that "nohz_full implies domain
isolation"
(commit messages quoted in section 3). The kernel does not enforce that, the
customer's
command line violates it, and the fix proposed here makes the kernel honour
`nohz_full` on its own again, plus stops mm from ever queueing LRU drain work on
isolated CPUs. **Independently of any kernel patch, adding `domain` to
`isolcpus`
(`isolcpus=domain,nohz,18-95,108-178`) removes cause 1 on the stock kernel, and
is
what upstream considers the supported configuration** (section 8).
## 2. Evidence, reconciled
Source: the three test-log bundles attached to the bug (comment #2) and the
reproducer (comment #5). Copies are in `bug-attachments/`.
Captured on 7.0.0-1006, -1008 and -1011, twice per run, 6 s apart,
identical:
```
PID 1 (systemd) Ds wchan flush_workqueue
__flush_workqueue+0x14e/0x430
flush_migrate_mm_task_workfn+0x19/0x30
task_work_run+0x62/0x90
exit_to_user_mode_loop ...
kworker/u768:1+cpuset_migrate_mm D psr 3/7 (housekeeping CPU)
synchronize_rcu_expedited+0x1b2/0x220 <- wait_event(rnp->exp_wq[...]) in
tree_exp.h:976
lru_cache_disable+0x15/0x30 <- mm/swap.c:929
do_migrate_pages+0x4e/0x2b0
cpuset_migrate_mm_workfn+0x26/0x50
rcu_exp_gp_kthread_worker (PID 19) D psr 100/101 (housekeeping CPU), wchan
kthread_flush_work
```
`pgmigrate_success` advanced ~36 pages/s during the stall and in bursts of
~8200 pages/s
otherwise; PID 1 stayed in D for 60–62 s (1006/1011) and 16 s (1008). Matthew's
improved reproducer produces 2–5 minutes; production sees 5–10 minutes.
What the RCU stack means (7.0 `kernel/rcu/tree_exp.h`):
* `synchronize_rcu_expedited()` hands the grace period to
`rcu_exp_gp_kthread_worker`
and waits (`tree_exp.h:924-981`).
* That worker runs `sync_rcu_exp_select_cpus()` which, for every leaf
`rcu_node` except
the last, `kthread_queue_work(rnp->exp_kworker, ...)` and then
`kthread_flush_work(&rnp->rew.rew_work)` (`tree_exp.h:505-533`, `468-482`).
* `kthread_flush_work()` completes only after the
`rcu_exp_par_gp_kthread_worker/N`
thread has *run* `__sync_rcu_exp_select_node_cpus()` and the flush item
behind it
(`kernel/kthread.c:1346-1375`). The work itself is microseconds (an EQS
snapshot per
CPU plus an async IPI to CPUs that are in the kernel). A seconds-long wait
therefore
means **the par worker is not being scheduled**.
Where the par workers live (7.0):
* `rcu_spawn_exp_par_gp_kworker()` creates one `kthread_worker` per leaf
`rcu_node`,
leaves it `SCHED_NORMAL` unless `CONFIG_RCU_EXP_KTHREAD` (not set: needs
`RCU_BOOST`), and calls `rcu_thread_affine_rnp()`
(`kernel/rcu/tree.c:4179-4202`).
* `rcu_thread_affine_rnp()` prefers the CPUs of that `rcu_node`
(`kthread_affine_preferred()`, `tree.c:4161-4175`).
* `kthread_fetch_affinity()` intersects the preferred mask with
`housekeeping_cpumask(HK_TYPE_DOMAIN)` **only** (`kernel/kthread.c:330-348`).
With `isolcpus=nohz,18-95,108-178` no `domain` flag is given, so
`HK_FLAG_DOMAIN` is not
set (`kernel/sched/isolation.c:314-366`) and
`housekeeping_cpumask(HK_TYPE_DOMAIN)`
returns `cpu_possible_mask` (`isolation.c:64-75`). With `RCU_FANOUT_LEAF=16`
and 192
CPUs, the leaf nodes covering CPUs 32–95 and 112–175 consist **only** of
nohz_full CPUs,
so their expedited workers are pinned to nothing but FIFO-hogged CPUs. This is a
deterministic explanation of the captured `kthread_flush_work` wait, and it
affects
every `synchronize_rcu_expedited()` user on the box, not just mm.
The bug's original theory (`flush_work` in `__lru_add_drain_all()`) is the
second stage
of the same `lru_cache_disable()` call (`mm/swap.c:913-935`:
`synchronize_rcu_expedited()`
first, then `__lru_add_drain_all(true)`), so once the RCU wait is fixed the
drain wait
is next in line. Matthew's reproducer, whose worker keeps folios batched on
every
isolated CPU, exercises it directly. Both must be fixed.
A note on the 36 pages/s and 8200 pages/s pattern in the sampler logs: the
`cpuset_migrate_mm` workqueue is *ordered*, so the mms of the 32 stress-ng
workers are
migrated one after another, each paying one `lru_cache_disable()` (RCU + drain
wait).
The regular ~8200-page bursts are consistent with one ~32 MB mm being migrated
each time
a wait completes; the ~36 pages/s stretches are the long RCU waits.
## 3. Root cause in the 7.0 source
### 3.1 `cpu_is_isolated()` no longer covers nohz_full CPUs
`include/linux/sched/isolation.h` (7.0):
```c
static inline bool cpu_is_isolated(int cpu)
{
return !housekeeping_test_cpu(cpu, HK_TYPE_DOMAIN);
}
```
v6.8 had `!housekeeping_test_cpu(cpu, HK_TYPE_DOMAIN) ||
!housekeeping_test_cpu(cpu,
HK_TYPE_TICK) || cpuset_cpu_is_isolated(cpu)`. The change is
**1c3329df409b** "sched/isolation: Remove HK_TYPE_TICK test from
cpu_is_isolated()"
(Frederic Weisbecker, v7.0-rc1), whose message says:
> It doesn't make sense to use nohz_full without also isolating the related
> CPUs from
> the domain topology, either through the use of isolcpus= or cpuset isolated
> partitions. [...] Therefore if a CPU is not HK_TYPE_DOMAIN, it shouldn't be
> HK_TYPE_KERNEL_NOISE either. Testing the former is then enough.
On the customer's command line `cpu_is_isolated()` is therefore **false for
every CPU**
(`/sys/devices/system/cpu/isolated` will read empty while `nohz_full` reads
`18-95,108-178`). Users that silently lost isolation awareness versus 6.8:
| user | effect on nohz_full-only CPUs in 7.0 |
|---|---|
| `fs/buffer.c bh_lru_install()` (line 1335) | buffer_heads cached again →
`has_bh_in_lru()` true → `__lru_add_drain_all()` queues work there |
| `mm/vmstat.c vmstat_shepherd()` (line 2138) | `vmstat_update` queued there
again |
| `mm/memcontrol.c schedule_drain_work()` (line 2031) | memcg stock drain work
queued there |
| `block/blk-mq.c` (line 4279) | CPUs kept in `hctx->cpumask` |
### 3.2 Unbound kthreads may be affined to nohz_full CPUs
`kernel/kthread.c:330-348` (7.0) filters preferred/node affinity with
`HK_TYPE_DOMAIN`
only. Introduced by **041ee6f3727a** "kthread: Rely on HK_TYPE_DOMAIN for
preferred
affinity management" (Frederic Weisbecker, v7.0-rc1):
> Unbound kthreads want to run neither on nohz_full CPUs nor on domain isolated
> CPUs.
> And since nohz_full implies domain isolation, checking the latter is enough
> to verify
> both.
History: up to v6.13 `kthreadd` did `set_cpus_allowed_ptr(tsk,
housekeeping_cpumask(HK_TYPE_KTHREAD))` and children inherited it (`v6.8
kernel/kthread.c:743`, and `:370` in `kthread()`); v6.14's **d1a89197589c** /
**4d13f4304fa4** introduced `kthread_fetch_affinity()` filtering with
`HK_TYPE_KTHREAD`;
v7.0 switched the filter to `HK_TYPE_DOMAIN` (041ee6f3727a) and included
kthreadd itself
in the managed list (**92a734606e90**, **5564c12385b7**). `nohz_full=` sets
`HK_FLAG_KERNEL_NOISE` = `HK_TYPE_KTHREAD` precisely to keep kthreads away
(`isolation.c:304-311`), and 7.0 ignores it for placement.
Affected threads on this box: `rcu_exp_par_gp_kthread_worker/N` (the captured
stall),
`rcub/N` (RCU boost, only if `RCU_BOOST`), and any NUMA-node-affine kthread
(`kswapd1`, `kcompactd1`, ... on node 1 = CPUs 96–191, which includes 108–178).
### 3.3 `__lru_add_drain_all()` queues per-CPU work on isolated CPUs
`mm/swap.c:776-890` (7.0). `cpu_needs_drain(cpu)` is true if any of the six
`cpu_fbatches` batches, the `mlock_fbatch`, or the buffer_head LRU of that CPU
is
non-empty; work is then `queue_work_on(cpu, mm_percpu_wq, ...)`'d and
`flush_work()`'d.
`mm_percpu_wq` is a bound (per-CPU) workqueue; there is no housekeeping check
for bound
work and there cannot be one (the batches are protected by a `local_lock`, so
only the
owning CPU can drain them).
The batches are filled by `__folio_batch_add_and_move()` (`swap.c:182-203`) and
`mlock_folio()/mlock_new_folio()/munlock_folio()` (`mm/mlock.c:242-305`); both
already
bypass batching when `lru_cache_disabled()` is set — the same code path the fix
reuses.
`lru_cache_disable()` (`swap.c:913-935`) is what `do_migrate_pages()`
(cpuset.mems,
`mm/mempolicy.c:1343`), `do_mbind()` (`mempolicy.c:1531`), `move_pages()`
(`mm/migrate.c:2360`), `alloc_contig_range()` (`mm/page_alloc.c:6865`) and
memory
offlining (`mm/memory_hotplug.c:1967`) call. Plain `lru_add_drain_all()` is
called by
compaction, khugepaged, memfd, ksm, memory-failure, vmscan, fadvise,
drop_caches,
bdev invalidation, GUP long-term pinning and cgroup v1 memcg reclaim. All of
them are
exposed to the same hang.
### 3.4 Why PID 1 is the victim
`cpuset_write_resmask()` (`kernel/cgroup/cpuset.c:3217-3260`) →
`update_nodemask()` →
`cpuset_update_tasks_nodemask()` iterates **every thread** of the cpuset and
queues one
`cpuset_migrate_mm_workfn` per thread that has an mm onto the **global,
ordered**
`cpuset_migrate_mm_wq`; each work item runs `do_migrate_pages(mm, from, to,
MPOL_MF_MOVE_ALL)`. The writer then runs `flush_migrate_mm_task_workfn()` as
task_work
(`TWA_RESUME`) before returning to userspace and sleeps `TASK_UNINTERRUPTIBLE`
in
`flush_workqueue()` until *every* queued migration — including ones queued by
other
writers — has finished. Because the workqueue is ordered, one migration stuck in
`lru_cache_disable()` holds up all the others. On cgroup v2 memory migration is
unconditional (`CS_MEMORY_MIGRATE` forced at `cpuset_css_alloc()`); the only
short cut is
`nodes_equal()` when the nodemask does not change.
The writer is systemd itself (PID 1): `AllowedMemoryNodes=` is applied through
`cgroup_apply_cpuset()` → `cg_set_attribute()` at unit start, on `systemctl
set-property`,
and again at boot finish for `Startup*` settings. Since systemd v259 (Ubuntu
26.04 ships
259.5) `cg_set_attribute()` opens every cgroup v2 attribute file with
**`O_NONBLOCK`**,
explicitly to opt into asynchronous semantics wherever the kernel offers them —
the 7.0
cpuset code ignores that flag (optional patch 5 below honours it). While PID 1
is in D
state, logind's requests to PID 1 are not serviced, `pam_systemd` cannot create
sessions,
and SSH logins fail.
History (verified): e93ad19d0564 "cpuset: make mm migration asynchronous"
(v4.5-rc4,
introduced the workqueue plus flush "so operations still seem synchronous to
userland");
ee9707e8593d (v5.15-rc1, migration unconditional on v2); 9f72daf7edfa
(v5.15-rc1, skip
when nodemasks match, citing the `lru_cache_disable()` cost); c0fb16ef887d +
3514309e0322 (v6.18-rc1, flush moved to task_work to get it out from under
`cgroup_mutex`); **eda17a3a7084** "cgroup/cpuset: Rebind/migrate mm only for
threadgroup
leader" (v7.3-rc1) — a backport candidate on its own, since it removes the
per-*thread* multiplication of migration work items (32 stress-ng workers ×
threads in the comment-#2 test). Full details in `research/cpuset-systemd.md`.
### 3.5 Kernel configuration facts (`linux-aws` amd64, exported from the
annotations)
```
CONFIG_PREEMPT_LAZY=y CONFIG_PREEMPT_DYNAMIC=n (aws override, LP: #2051342)
CONFIG_PREEMPTION=y CONFIG_TREE_RCU=y (PREEMPT_RCU is *not* set with LAZY
alone)
CONFIG_RCU_BOOST=n CONFIG_RCU_EXP_KTHREAD=n -> RCU kthreads are
SCHED_NORMAL
CONFIG_NO_HZ_FULL=y CONFIG_RCU_NOCB_CPU=y CONFIG_HZ=1000
CONFIG_RT_GROUP_SCHED=n -> no RT bandwidth throttling at all in 7.0
CONFIG_CPU_ISOLATION=y CONFIG_CSD_LOCK_WAIT_DEBUG=n
CONFIG_RCU_EXP_CPU_STALL_TIMEOUT=0
```
## 4. Why 24.04's 6.8 kernel behaved differently
Verified in the v6.8 sources:
* **RT throttling existed** (`kernel/sched/rt.c:25 sysctl_sched_rt_runtime =
950000`):
a FIFO spinner was throttled for 50 ms every second, so a starved per-CPU
kworker ran
within ≤1 s and any single `lru_cache_disable()` cost at most a second or
two. v6.12
removed root RT bandwidth control in favour of the deadline fair server. On
this box
the fair server is evidently not giving `SCHED_NORMAL` tasks their 50 ms/s
(stress-ng
workers confined to the isolated CPUs got ~1% CPU in total; kworkers waited
minutes) —
whether that is expected behaviour of the deferred server or a separate
scheduler bug
is analysed in `research/scheduler-fair-server.md` (see open questions).
* **`cpu_is_isolated()` included `HK_TYPE_TICK`** (`v6.8
include/linux/sched/isolation.h:68-73`),
so bh_lru/vmstat/blk-mq treated nohz_full CPUs as isolated.
* **kthreads inherited `HK_TYPE_KTHREAD` housekeeping affinity from kthreadd**
(`v6.8 kernel/kthread.c:743`), so no unbound kthread lived on nohz_full CPUs.
* The 6.8 expedited GP (without `RCU_EXP_KTHREAD`) queued its per-node work on
the
first online CPU of each `rcu_node` via the per-CPU `rcu_par_gp_wq`
(`v6.8 tree_exp.h:486-497`), which *could* also be an isolated CPU — but RT
throttling bounded that wait to ~1 s as well.
`CONFIG_PREEMPT_LAZY` changes when a *fair* task may preempt another fair task;
it does
not let a `SCHED_NORMAL` kworker preempt a `SCHED_FIFO` task and does not affect
cross-class preemption by the deadline server. Nothing in the two mechanisms
above
depends on it. The aws flavour also builds with `PREEMPT_LAZY` but *without*
`PREEMPT_RCU`, so blocked-reader effects of preemptible RCU are not in play
either.
## 5. Upstream prior art (verified)
| commit | subject | first tag | relevance |
|---|---|---|---|
| ff042f4a9b05 | mm: lru_cache_disable: replace work queue synchronization with
synchronize_rcu (M. Tosatti) | v5.18-rc1 | removed the *first* "queue work on
every CPU" step from `lru_cache_disable()` for isolated-CPU reasons |
| 31733463372e | mm: lru_cache_disable: use synchronize_rcu_expedited (M.
Tosatti) | v5.19-rc4 | the expedited GP that stalls here |
| 8a237adf213d | fs/buffer.c: disable per-CPU buffer_head cache for isolated
CPUs (M. Tosatti) | v6.6-rc1 | direct template for patch 3 (`if (...
cpu_is_isolated(smp_processor_id())) return;`) |
| d1a89197589c / 4d13f4304fa4 | kthread: Default affine kthread to its
preferred NUMA node / Implement preferred affinity (F. Weisbecker) | v6.14-rc1
| introduced `kthread_fetch_affinity()` (then filtered with `HK_TYPE_KTHREAD`) |
| 6010d245ddc9 / 1174b9344bc7 | sched/isolation: Consolidate housekeeping
cpumasks / Make "isolcpus=nohz" equivalent to "nohz_full" (W. Long) | v6.14-rc1
| `HK_TYPE_KERNEL_NOISE`; explains why `isolcpus=nohz` behaves as `nohz_full=` |
| 03ff73510169 / 6440966067dc | cpuset: Update HK_TYPE_DOMAIN cpumask from
cpuset / Remove cpuset_cpu_is_isolated() (F. Weisbecker) | v7.0-rc1 | cpuset
isolated partitions folded into `HK_TYPE_DOMAIN` |
| 1c3329df409b | sched/isolation: Remove HK_TYPE_TICK test from
cpu_is_isolated() (F. Weisbecker) | v7.0-rc1 | **regression 1** (patch 1
`Fixes:`) |
| 5564c12385b7 / 92a734606e90 / 041ee6f3727a | kthread: managed affinity list /
kthreadd included / Rely on HK_TYPE_DOMAIN (F. Weisbecker) | v7.0-rc1 |
**regression 2** (patch 2 `Fixes:` 041ee6f3727a) |
Also relevant in 7.0 (same design direction as patch 3): `mm/memcontrol.c
schedule_drain_work()` refuses to queue memcg stock drains on isolated CPUs, and
`housekeeping_update()` flushes `mm_percpu_wq` and `memcg_wq` when a cpuset
isolated
partition changes (`kernel/sched/isolation.c:121-163`). The fuller prior-art
list with
lore references is in `research/prior-art.md` (if present).
Post-7.0 mainline that matters for forward-porting: `mm/swap.c` was renamed to
`mm/folio.c` for 7.3 (0ba14428abc2), `cpu_needs_drain()` got a data-race
annotation
(f078b0a0727c) and LRU drain tracepoints were added (094470f3f45b). Nothing in
7.1/7.2/7.3-rc2 changes `kthread_fetch_affinity()` or `cpu_is_isolated()`, so
the two
regressions are still present upstream.
## 6. The proposed fix (`patches/`)
Based on `Ubuntu-aws-7.0.0-1013.13`; all five patches also apply to `-1011.11`
and to
upstream `v7.0` and `v7.1` (`git apply --check`); patches 1, 2, 4 and 5 also
apply to
`v7.2`. Patch 3 needs a trivial include-context refresh for `v7.2`, and a
rename to
`mm/folio.c` for 7.3+. Patches 1–3 are the fix; 4 and 5 are optional hardening
and
mitigation that Canonical can take or drop independently.
### 0001 sched/isolation: Consider nohz_full CPUs isolated in cpu_is_isolated()
again
* `include/linux/sched/isolation.h`: test `HK_TYPE_KERNEL_NOISE` in addition to
`HK_TYPE_DOMAIN`, through the inline `housekeeping_cpu()` wrapper. Partial
revert of
1c3329df409b, restoring 6.x semantics.
* Effect: bh_lru, vmstat shepherd, memcg drain, blk-mq (and patch 3) treat
nohz_full
CPUs as isolated again. No change where nohz_full CPUs are also domain
isolated.
* Risk: low. On systems without any CPU isolation the whole test is a
patched-out
static branch (previously one out-of-line call); on isolated systems it is
two cpumask
tests instead of one.
### 0002 kthread: Keep unbound kthreads off nohz_full CPUs
* `kernel/kthread.c kthread_fetch_affinity()`: intersect with
`housekeeping_cpumask(HK_TYPE_KTHREAD)` too; fall back to the CPUs
housekeeping for
both types (never empty: `housekeeping_setup()` and cpuset's
`isolated_cpus_can_update()` guarantee one), and finally to the domain mask.
* Effect: `rcu_exp_par_gp_kthread_worker/N`, `rcub/N`, node-affine kthreads
leave the
nohz_full CPUs → the observed `synchronize_rcu_expedited()` stall disappears.
* Risk: low; restores the 6.14–6.19 behaviour. Alternative, narrower form if
preferred:
filter with `housekeeping_cpumask(HK_TYPE_RCU)` inside
`rcu_thread_affine_rnp()` only
(what 6.10–6.13 `rcutree_affinity_setting()` did) — but that leaves other
kthreads on
the nohz_full CPUs.
### 0003 mm/swap, mm/mlock: Don't batch folios in per-CPU LRU caches on
isolated CPUs
* New helper `lru_batching_disabled()` in `mm/internal.h`:
`lru_cache_disabled() || cpu_is_isolated(smp_processor_id())`, used at the
four batch
sites (`__folio_batch_add_and_move()`, `mlock_folio()`, `mlock_new_folio()`,
`munlock_folio()`). On an isolated CPU the folio goes to the LRU immediately
(exactly
the existing `lru_cache_disabled()` path), so `cpu_needs_drain()` is always
false for
isolated CPUs and `__lru_add_drain_all()` never queues work there.
* This is the "avoid batching on isolated CPUs, like the buffer_head cache"
suggestion
from comment #7, made complete: it covers all six `cpu_fbatches` batches and
the mlock
batch, and — together with patch 1 — the buffer_head term of
`cpu_needs_drain()`.
* Cost: one lruvec-lock round trip per folio instead of per 15 folios, on
isolated CPUs
only. On systems without isolation `cpu_is_isolated()` is a patched-out
static branch.
* Not chosen: draining isolated CPUs by IPI (comment #6 experiment). The
batches are
`local_lock`-protected (preempt-disable, not irq-disable, for five of the
six), so an
IPI handler racing with the owning CPU is unsafe without a locking redesign,
and it
did not help in Matthew's test anyway; skipping isolated CPUs in
`__lru_add_drain_all()`
would leave folios unmigratable and break `lru_cache_disable()`'s contract.
### 0004 sched/isolation: Drain per-CPU LRU batches when CPUs become isolated
(optional hardening)
* `housekeeping_update()`: call `lru_add_drain_all()` once after the domain
mask update,
next to the existing vmstat/memcg workqueue flushes, so CPUs that become
isolated at
runtime via a cpuset isolated partition do not keep pre-isolation folios in
their
batches. Boot-time isolation (this bug) does not need it; Canonical may drop
it.
### 0005 cgroup/cpuset: Don't wait for memory migration when cpuset.mems is
written with O_NONBLOCK (optional mitigation)
* `cpuset_write_resmask()`: skip `schedule_flush_migrate_mm()` when the writer
opened the
file with `O_NONBLOCK`; the migration still runs to completion on
`cpuset_migrate_mm_wq`, the writer just does not wait for it. Mirrors memcg's
`memory.max`/`memory.high` behaviour (c8e6002bd611, v6.16-rc1). Blocking
writers and
the `cgroup.procs` attach path are unchanged.
* Effect: systemd ≥ 259 already opens cgroup attributes with `O_NONBLOCK`, so
PID 1 stops
going into D state on `AllowedMemoryNodes=` writes at all, whatever else
stalls the
migration. It protects init (and SSH) against any *future* migration stall,
but it does
not make the migration itself faster — patches 1–3 do that.
* Risk: uAPI semantics change for non-blocking writers only ("write returned
but memory
may still be moving"); needs the cgroup maintainers' agreement (Tejun Heo,
Waiman Long,
Chuyi Zhou). Applies cleanly to v7.0, v7.1 and v7.2. Note also that upstream
v7.3-rc1's
eda17a3a7084 (migrate once per thread group instead of once per thread) is a
cheap,
independent backport that shrinks the amount of queued migration work.
Build verification performed here (x86-64, gcc 13.3, aws amd64 config exported
from
`debian.aws/config/annotations`, BTF/signing keys disabled for the local build):
`make mm/ kernel/sched/ kernel/rcu/ fs/buffer.o block/blk-mq.o
kernel/kthread.o` clean;
`make vmlinux` clean (only the three pre-existing apparmor/maple_tree warnings).
`checkpatch.pl --strict`: 0 errors; the only warnings are the Ubuntu-style
`BugLink:`
trailer, the `Co-Authored-By` casing and "unknown commit id" (the shallow tree
lacks
upstream history). The patches carry no `Signed-off-by` yet — add yours (and
DCO) before
sending.
## 7. Validation plan (c8a.metal-48xl)
Build: apply `patches/000[1-4]*.patch` on the resolute `linux-aws` tree (or
`master-next`), build the aws flavour as usual, install on a c8a.metal-48xl
with the
customer's command line (`isolcpus=nohz,18-95,108-178 nohz_full=18-95,108-178
rcu_nocbs=18-95,108-178 rcu_nocb_poll idle=poll ...`). Keep an unpatched
`7.0.0-1011`/`-1013` to compare.
Static A/B (30 s, no workload), with `validate.sh snapshot`:
| check | unpatched | patched |
|---|---|---|
| `/sys/devices/system/cpu/isolated` | empty | empty (unchanged; DOMAIN mask is
what it is) |
| `Cpus_allowed_list` of `rcu_exp_par_gp_kthread_worker/*`, `rcub/*`,
`kswapd*`, `kcompactd*` | inside 18-95 / 108-178 for several | never intersects
18-95,108-178 |
Dynamic A/B with Matthew's reproducer
(`bug-attachments/reproducer/run_repro.sh`) and
the comment-#2 baseline (`repro-baseline-v3.sh`), while `validate.sh watch 900`
and
`validate.sh trace 120` run:
| check | unpatched | patched |
|---|---|---|
| PID 1 D-state duration (`run_repro.sh` prints it) | 2–5 min (1006/1011: ~60 s
baseline) | only the migration itself: seconds for 50 GB at ~8 K–40 K pages/s,
no plateau |
| `trace`: `workqueue_queue_work function=lru_add_drain_per_cpu req_cpu=` |
many `req_cpu` in 18-95,108-178 | none in 18-95,108-178 |
| `watch`: `cpuset_migrate_mm` kworker stack | `synchronize_rcu_expedited`
(then `flush_work`) for tens of seconds | only
`migrate_pages`/`folio_lock`-style frames, progressing |
| `watch`: `rcu_exp_gp_kthread_worker` | D in `kthread_flush_work` | never
blocked for >1 tick |
| `sampler.log` pgmigrate | 36 pages/s plateaus | steady progress |
| `ssh` login during the run | fails/hangs | works |
Bisecting the two causes on the unpatched kernel (each confirms one
half):
* boot with `isolcpus=domain,nohz,18-95,108-178` (everything else unchanged) →
the RCU
wait disappears (`/sys/devices/system/cpu/isolated` now lists the CPUs, the
par workers
move to housekeeping CPUs, `cpu_is_isolated()` is true). The `flush_work`
wait remains
reproducible with `run_repro.sh` because per-CPU kworkers stay per-CPU; that
isolates
patch 3's contribution.
* boot with `rcupdate.rcu_normal=1` (0444 module param, boot only) → expedited
GPs become
normal GPs, which need no per-node kworker; the RCU half disappears without
patch 2.
* run the FIFO workers as `SCHED_OTHER` (drop `chrt -f 80`) → both halves
disappear on
any kernel; confirms the FIFO starvation dependency.
Regression checks for the patched kernel: `stress-ng --vm` / page-fault
microbenchmark
pinned to an isolated CPU vs housekeeping CPU (expect the isolated CPU to pay a
small
per-fault cost, housekeeping unchanged); `mlock`/`munlock` of a large region on
an
isolated CPU; cpuset isolated-partition create/destroy under load (patch 4);
NUMA
`migratepages`/`mbind` throughput on housekeeping CPUs (unchanged); RCU
expedited GP
latency (`rcutorture` or `perf stat -e rcu:rcu_exp_grace_period` — should
improve).
## 8. Mitigations available without a new kernel
1. **`isolcpus=domain,nohz,18-95,108-178`** (or an equivalent cpuset isolated
partition)
instead of `isolcpus=nohz,...`. This is the configuration upstream assumes;
it moves the
RCU expedited workers and all unbound kthreads/workqueues off the isolated
CPUs and
re-enables the buffer_head/vmstat/memcg isolation logic. It does **not** fix
the
`__lru_add_drain_all()` per-CPU drain wait (patch 3). Side effect: the
isolated CPUs
leave the load balancer; pinned threads are unaffected.
2. **Do not change `cpuset.mems` (AllowedMemoryNodes=) of a slice while it
contains
large-RSS processes**; set it before tasks join, or use
`numactl`/`set_mempolicy` in
the application. This avoids the synchronous migration behind PID 1 entirely.
3. **`rcupdate.rcu_normal=1`** on the command line removes the expedited-GP
half at the
cost of slower `synchronize_rcu_expedited()` system-wide (ms instead of µs).
RT users
commonly run this way (`PREEMPT_RT` defaults `rcu_normal_after_boot=1`).
4. Lower the spinners below `SCHED_FIFO`, or make them yield periodically, so
per-CPU
kworkers can run (comment #7 suggestion 1). Only if the workload allows it.
## 9. Open questions and caveats
* **Runtime confirmation is outstanding.** Everything above is derived from the
captured
stacks and the 7.0 sources plus compile/link testing; the validation plan in
section 7
is the acceptance test. In particular the stacks of the
`rcu_exp_par_gp_kthread_worker/N`
threads were never captured; `validate.sh watch` does that and should show
them
runnable-but-not-running on isolated CPUs on the unpatched kernel.
* **Fair server behaviour.** With RT throttling gone, the deadline fair server
(50 ms /
1 s, deferred) is supposed to keep `SCHED_NORMAL` tasks alive under a FIFO
hog. The
observed minutes-long starvation says it did not on this box. If that is a
scheduler
bug (rather than intended deferred-server semantics on nohz_full CPUs), it is
a third,
independent issue worth its own report; the patches here do not depend on its
outcome.
See `research/scheduler-fair-server.md`.
* **Upstream acceptance.** Patches 1 and 2 argue against an explicit upstream
design
assumption ("nohz_full implies domain isolation"). They restore pre-7.0
behaviour for a
configuration the kernel accepts and documents, so they are defensible, but
upstream
may instead answer "use `isolcpus=domain`" and/or make the kernel warn or
imply domain
isolation. Patch 3 follows established precedent (8a237adf213d, memcg
`schedule_drain_work()`) and is the piece with the clearest upstream path; it
should be
posted to linux-mm with Marcelo Tosatti, Frederic Weisbecker, Waiman Long and
Minchan Kim
on Cc. For an Ubuntu SRU all four can go in as-is with `BugLink`.
* Patch 3 changes behaviour for every CPU reported isolated, including cpuset
isolated
partitions on systems that never set nohz_full; that is intended (those CPUs
also cannot
be drained remotely) but should be called out in the SRU justification.
ANALYSIS.md this document
patches/0001-*.patch .. 0004-* the series (git format-patch, base
Ubuntu-aws-7.0.0-1013.13)
validate.sh A/B evidence collector for the c8a.metal-48xl
(snapshot | trace | watch)
bug-attachments/ reproducer sources, comment-#2 test logs,
baseline script, all LP comments
research/ supporting research reports (prior art,
scheduler, cpuset/systemd), when complete
** Attachment added: "analysis-files.tar.gz"
https://bugs.launchpad.net/ubuntu/+source/linux-aws/+bug/2165410/+attachment/5999653/+files/analysis-files.tar.gz
--
You received this bug notification because you are a member of Ubuntu
Bugs, which is subscribed to Ubuntu.
https://bugs.launchpad.net/bugs/2165410
Title:
Ubuntu LTS 26.04 linux-aws: systemd enters D state and blocks SSH
during cpuset migration on nohz_full CPUs
To manage notifications about this bug go to:
https://bugs.launchpad.net/ubuntu/+source/linux-aws/+bug/2165410/+subscriptions
--
ubuntu-bugs mailing list
[email protected]
https://lists.ubuntu.com/mailman/listinfo/ubuntu-bugs