0/4 已展开

LLM 分析

sched/cache:减少 task_cache_work 的扫描开销

系列概况

  • 标题: [PATCH v6 0/2] Cache aware scheduling: Reduce the overhead of task_cache_work
  • 作者: Luo Gengkun <luogengkun2@huawei.com>
  • 版本: v6(已历经 v1 到 v6 多轮迭代;cache-aware scheduling 主体已合入主干,本系列属于后继优化补丁)
  • 规模: 2 个 patch,主体 +35/-10 行,附加调试 patch +43/-5 行
  • 修改文件:
    • include/linux/mm_types.h
    • include/linux/sched.h
    • kernel/sched/fair.c
    • include/trace/events/sched.h(调试 patch)
    • kernel/sched/features.h(调试 patch)
  • 代码统计:主体补丁 35+/10-,调试补丁 43+/5-
  • Message-ID: 20260717041231.3324851-1-luogengkun2@huawei.com
  • 完整性: 完整 series;含 cover letter、主体 patch、显式标注 DO-NOT-APPLY 的调试 patch,以及 maintainer/Reviewer 回复(Chen Yu)

补丁目的

cache-aware scheduling 在每个 mm 上记录 pcpu_sched->epoch,由 task_cache_work() 周期性地扫描 LLC 域内全部 CPU,按 fraction_mm_schedpref_llc、更新 nr_running_avg。在多 NUMA、CPU 数量很大的系统里这种全量扫描非常昂贵:perf top -e cycles:k 显示 task_cache_work 占 0.81%;Redis 在 400000 RPS、NUMA balancing 关闭时,p99 latency 相对 baseline 退化 25.68%。

本系列要解决的问题:task_cache_work 只扫"最近真正跑过本 mm"的 CPU,把扫描集合从全 LLC 收窄到 llc_epoch_affinity_timeout 内的访问子集。

旧流程的问题

旧实现每次都遍历 sched_domain_span(sd) 内全部 CPU,绝大多数 CPU 上的 pcpu_sched 长期为 0,但仍要走 fraction_mm_sched 取锁、读 epoch、做统计;CPU 越多浪费越严重。

task_cache_work() {
    sd = ...; // pick LLC sched domain
    for_each_cpu(i, sched_domain_span(sd)) {
        occ = fraction_mm_sched(cpu_rq(i), pcpu_sched_i);
        // ... update pref_llc / nr_running_avg
    }
}

新流程

新实现维护两个新成员:

  • mm->sc_stat.visited_cpus:位图,记录"近期跑过本 mm 的 CPU"
  • mm->pcpu_sched->epoch_last_visit:per-CPU,记录"该 CPU 最近一次为该 mm 更新 epoch 的时刻"
account_mm_sched():               task_cache_work():
  epoch_last_visit = epoch          cpumask_and(cpus, sd_span,
  if (!visited[cpu])                       visited_cpus)
       visited[cpu] = 1           for_each_cpu_and(i, sd, visited) {
                                          nr_running++ if same mm
fraction_mm_sched():                       occ = fraction_mm_sched(i)
  if (epoch - last_visit                if (occ == 0) continue;
      > affinity_timeout)         }
       visited[cpu] = 0

简而言之:写入侧记录访问、读取侧只扫记录、超时自动回收

Patch 概览

  • Patch 1/2(主体,sched/cache):在 mm_struct 内加 cpumask_var_t visited_cpusepoch_last_visit 字段;改 fraction_mm_sched 接受 (int cpu, struct mm_struct *mm),并对超时的 CPU 清位;改 task_cache_workfor_each_cpu_and 仅扫 visited_cpus 子集;v6 把 cpumask_var_t 改成动态分配,并直接把 LLC span 与 visited_cpus 求交以避免跨节点 LLC 拓扑下的漏扫。
  • Patch 2/2-- DO NOT APPLY!!! --,sched/cache/debug):调试补丁,引入 SC_VISIT sched feature(默认 false)和 sched_cache_scan tracepoint,方便 A/B 对比扫描数量,不应合入

关键实现

1) include/linux/mm_types.h:动态分配 visited_cpus

static inline int mm_alloc_sched_noprof(struct mm_struct *mm)
{
    ...
    if (!zalloc_cpumask_var(&mm->sc_stat.visited_cpus, GFP_KERNEL)) {
        free_percpu(pcpu_sched);
        return -ENOMEM;
    }
    ...
}

static inline void mm_destroy_sched(struct mm_struct *mm)
{
    free_cpumask_var(mm->sc_stat.visited_cpus);
    ...
}

v6 用 cpumask_var_t 代替内嵌 cpumask_t,避免 NR_CPUS 很大时每个 mm 多占用 CPUMASK_BYTES 字节内存。Chen Yu 在回复中提示这里需要保证 zalloc_cpumask_var / free_cpumask_var 成对。

2) kernel/sched/fair.cfraction_mm_sched 接收 (cpu, mm) 并加过期回收

static unsigned long fraction_mm_sched(int cpu, struct mm_struct *mm)
{
    struct sched_cache_time *pcpu_sched =
        per_cpu_ptr(mm->sc_stat.pcpu_sched, cpu);
    struct rq *rq = cpu_rq(cpu);

    __update_mm_sched(rq, pcpu_sched);

    /* Skip the rq that has not been hit for a long time */
    if ((rq->cpu_epoch - pcpu_sched->epoch_last_visit) >
        llc_epoch_affinity_timeout) {
        cpumask_clear_cpu(cpu, mm->sc_stat.visited_cpus);
        return 0;
    }
    ...
}

v6 change #4 把 __update_mm_sched 提前到 timeout 检查之前,确保即使 CPU 已过期仍然在 visited_cpus 里,回收仍能正确生效。

3) account_mm_sched 跟踪"最近访问过"

pcpu_sched->epoch_last_visit = epoch;
if (!cpumask_test_cpu(cpu_of(rq), mm->sc_stat.visited_cpus))
    cpumask_set_cpu(cpu_of(rq), mm->sc_stat.visited_cpus);

每次任务在本 CPU 上累加运行时间时,把该 CPU 标记为"访问过",并打上 epoch 时间戳。

4) task_cache_work 只扫访问过的 CPU

cpumask_and(cpus, cpus, mm->sc_stat.visited_cpus);
...
for_each_cpu_and(i, sched_domain_span(sd), mm->sc_stat.visited_cpus) {
    cur = rcu_dereference_all(cpu_rq(i)->curr);
    if (cur && !(cur->flags & (PF_EXITING | PF_KTHREAD)) &&
        cur->mm == mm)
        nr_running++;
    occ = fraction_mm_sched(i, mm);
    if (occ == 0)
        continue;
    ...
}

nr_running++ 提前到 fraction_mm_sched 之前,避免过期 CPU 影响运行队列计数。

5) 调试 patch 透出可观测性

// features.h
SCHED_FEAT(SC_VISIT, false)

// fair.c
if (sched_feat(SC_VISIT) &&
    (rq->cpu_epoch - pcpu_sched->epoch_last_visit) >
    llc_epoch_affinity_timeout) {
    cpumask_clear_cpu(cpu, mm->sc_stat.visited_cpus);
    return 0;
}
...
trace_sched_cache_scan(p, scanned);

通过 /sys/kernel/debug/sched/features 切换 SC_VISIT 即可在 trace 中直接对比"扫了多少 CPU"。

类比

把 cache-aware scheduling 想象成公司前台的总机接线员:旧方案是"每次客人来访,都把全公司 800 个工位的电话打一遍问最近有没有见过他",绝大多数工位其实根本没见过。新方案改成"先看门禁刷卡记录,只给今天进过这栋楼的几个人打电话"。门禁记录(visited_cpus)会自动做"过期清理",一个月没刷卡就从名单里划掉(cpumask_clear_cpu),名单不会无限增长。

Highlight:风险与注意点

  1. 跨节点 LLC 拓扑for_each_cpu_and 直接用 sched_domain_span(sd) 做交集;如果宿主 LLC 跨 NUMA,务必保证 sd 取得正确(v6 change #2 强调"intersected with LLC scheduling domain span directly")。后续若引入 sched_cluster_active 路径,要重新审视等价检查。
  2. 配对与泄漏zalloc_cpumask_var 失败时必须 free_percpu(pcpu_sched) 再返回 -ENOMEMmm_destroy_sched 必须无条件 free_cpumask_var(即使 alloc 失败也要保持对称)。Chen Yu 在回复中专门提醒这套配对。
  3. race 窗口__update_mm_sched(持有 cpu_epoch_lock)与 task_cache_work(无锁读 epoch)之间存在窗口,单纯用 epoch 做判据会被周期性刷新掩盖。v4 引入 epoch_last_visit、v3 把比较基准挪进 fraction_mm_sched 都是为了消除这个 race。
  4. debug patch 真的不要合入:patch 2/2 显式 DO NOT APPLY!!!。如果合入,SC_VISIT=false 默认会让原本的优化被绕过,使 task_cache_work 又退回到全量扫描;仅用于 echo SC_VISIT > /sys/kernel/debug/sched/features 做 A/B 测试。
  5. 结果解读perf 上 0.81% -> 0.02% 是显著收益;但 hackbench 中 threads 1 20threads 4 10 等少量组合仍报 REGRESSEDschedcache vs schedcache_visit 也有零星回退,PR 上游需要继续做更广基准。
  6. 统计口径差异:cover letter 的 p99 latencycycles:k 数据来自 AMD 服务器;不同 LLC 拓扑(特别是单 LLC 域覆盖全部 CPU 时)收益会显著缩小,需先看拓扑再外推。

版本变化

  • v6:用 cpumask_var_t 动态分配 visited_cpusfor_each_cpu_and 直接对 LLC span 与 visited_cpus 取交;epoch_timeout 重命名为 epoch_last_visit__update_mm_sched 前移到 timeout 检查之前。
  • v5:恢复 get_scan_cpumasks() 以满足 NUMA_BALANCING 约束;用 for_each_cpu_and() 在 LLC 域内过滤。
  • v4:基于 master rebase;引入 epoch_last_visit 替换 epoch;nr_running 自增前移;删除尾部冗余 work->next 重置;新增 debug patch。
  • v3:移除 static key 默认开启;复用 llc_epoch_affinity_timeout;epoch 比较移入 fraction_mm_sched 防 race;尾部重置 work->next 防同进程并发。

一句话总结

通过 visited_cpus 位图与 epoch_last_visit 的过期回收,把 task_cache_work 的扫描集合从全 LLC 收窄到最近真正跑过本 mm 的 CPU 子集:在 384 CPU 的多 NUMA 平台上把 task_cache_workcycles 占比从 0.81% 降到 0.02%,Redis p99 退化从 25.68% 收敛到 1.14%。