PELT这个名字我在两个完全不同的圈子里都见过。搞时序分析的人说PELT指的是 Pruned Exact Linear Time 变点检测算法用来在一串数据里找出“关键转折点”做 Linux 内核的人说 PELT指的是 Per-Entity Load Tracking用来追踪每个调度实体在过去一段时间给 CPU 制造的负载。两拨人用着同一个缩写聊的却是完全两码事。我最初查资料时也被绕晕过后来把两边都啃了一遍才发现这名字撞得挺有意思——一个负责“找到变化发生的时刻”一个负责“记录变化引起的后果”放到一起看正好能拼出一套从数据到系统的完整观测链路。这篇文章我会把两个 PELT 分开讲透先讲变点检测算法的核心原理再给你一套可以直接跑的代码示例把惩罚项、最小分段这些参数掰开揉碎然后深入内核的 PELT 负载跟踪讲清 1024 微秒衰减、半衰期、层级聚合这些关键机制。最后再把它们串起来用“内核算出的负载曲线 变点检测”做一个组合玩法。适合做数据分析、算法工程、容器与系统性能优化的同学收藏。1. 先分清同一个 PELT两种完全不同的“变”1.1 时间序列里的变点找的是数据状态的转折在统计与机器学习领域变点检测要做的事情非常直白给定一串按照时间排列的数据找到若干个时刻这些时刻前后数据的分布发生了显著变化。比如一段广告点击率曲线前 100 分钟的均值是 5%突然变成了 9%中间那一刻就是变点再比如服务器 CPU 使用率平时稳定在 20%某次发布之后稳定在 60%中间切换的那一刻也是变点。变点检测算法处理的就是这类问题。它的应用面比想象中宽得多金融里的波动率突变、工业设备的传感器异常、生物信号里的状态切换、运维监控里的指标突跳甚至 A/B 实验里策略上线时间的复核都会用到。PELT 是这类算法里非常经典的一个核心优势在于“精确”和“快”它在限定条件下能找到全局最优的分段方式同时通过剪枝手段把计算复杂度从动态规划的平方级拉到接近线性级。1.2 内核调度里的变负载追踪的是任务的忙碌程度而在 Linux 内核里PELT 完全是另一回事。它是内核调度器用于负载追踪的一套机制名字全称是 Per-Entity Load Tracking。所谓“Entity”指的是调度实体——一个线程、一个 task_group 里的组任务都可以是实体。旧内核统计负载非常粗只统计整个 CPU 运行队列的平均负载想知道“到底是哪个任务在消耗 CPU”几乎不可能。PELT 把统计粒度下沉到每个实体上每个实体维护两个核心指标load_avg 和 util_avg。load_avg 记录的是“可运行状态”下的平均权重贡献只要任务在运行队列里哪怕没抢到 CPU就会贡献负载util_avg 记录的是任务真正在 CPU 上执行时间的平均占比。这两个指标对调度器的决策影响很大负载均衡找最忙的 CPU、EAS 节能调度判断任务放哪个核、DVFS 根据利用率调频率背后用的都是 PELT 的数据。两个同名机制的对比我放在下表里方便你建立整体印象。对比维度变点检测 PELT内核负载跟踪 PELT全称Pruned Exact Linear TimePer-Entity Load Tracking所属领域统计学、时间序列分析Linux 内核调度器核心问题数据分布发生突变的时刻在哪每个调度实体贡献了多少 CPU 负载关键机制动态规划 剪枝 惩罚项1024 微秒周期 半衰期衰减 层级聚合输出结果变点位置列表task、sched_entity、rq 上的 load/util 均值典型使用者数据分析师、算法工程师内核开发者、系统性能工程师一句话记法变点检测 PELT 告诉你“数据在哪个时刻变了”负载跟踪 PELT 告诉你“系统里是谁一直在变忙”。搞清楚这两个的区别后面读任何资料都不会再被绕晕。2. 手把手玩转变点检测 PELT2.1 从动态规划到剪枝PELT 为什么带 Linear先理解变点检测的基本模型。假设有一串时间序列 y我们要把它划分成若干段每一段内部的数据服从同一个分布。问题是分几段、每段从哪里切才能让整体效果最好最直接的思路是穷举所有分段方式那是指数级别的复杂度根本算不动。于是引入动态规划。定义 F(t) 表示“从序列起点到第 t 个观测点的最优分段得分”。状态转移方程是F(t) min { F(s) cost(s1 到 t) β }其中 s 是上一次分割点cost 是这一段数据与分布模型之间的拟合代价通常是负对数似然或平方误差累积β 是每增加一段的惩罚项。动态规划把问题压成了 O(n²) 的复杂度每到达一个新位置要扫描之前所有可能的 s。n 是几万还好如果是几百万个数据点O(n²) 依然跑不动。PELT 的关键改进在于剪枝。剪枝条件可以这样理解假设当前已经做到位置 t手上有一个候选分割点 s同时还存在另一个更早的分割点 t*如果“用 s 的最优得分 s 到 t 的代价”已经比“用 t* 的最优得分 t* 到 t 的代价”更差那 s 这个点从今往后再也不可能是全局最优路径的一部分直接扔掉。这个剪枝逻辑有严格的前提约束代价函数必须满足某种“连续可分”的性质代数上要求 cost 的变化满足特定不等式。实际使用时你不用操心底层数学条件是否成立常见模型均值变化、方差变化、非参数核模型都满足。剪枝之后候选点列表通常非常短绝大多数时候接近 O(n) 的线性复杂度。这就是 PELT 名字里“Pruned”和“Exact”同时存在的原因——剪枝剪掉的是“注定不会最优”的候选不牺牲最优性。2.2 两条命令跑通第一个检测我自己最常用的工具是 Python 的 ruptures 库封装了 PELT 以及多种代价模型API 简洁到离谱。安装只需一条命令pip install rupturesruptures 依赖 numpy、scipy、matplotlibPython 3.7 以上都能正常安装。装好后用一段随时可以复现的模拟数据测试import numpy as np import ruptures as rpt # 生成三段均值不同的信号每段内部是高斯噪声 n 2000 x np.concatenate([ np.random.normal(0, 1, 600), np.random.normal(3, 1, 800), np.random.normal(0, 1, 600) ]) true_bkps [600, 1400, n] # 使用 PELT 检测l2 模型适用于均值发生变化的数据 algo rpt.Pelt(modell2, min_size10, jump5) pred_bkps algo.fit_predict(x, pen20) print(真实变点:, true_bkps) print(检测变点:, pred_bkps)代码跑完pred_bkps 会输出类似 [586, 1412, 2000] 这样的数组最后一项一定是序列长度 n。min_size 是允许的最小分段长度jump 是加速参数含义是“每隔 5 个点扫描一次候选位置”。这两个参数能大幅减少计算量代价是变点位置的精度会被限制到 jump 的粒度。如果你更习惯 R也可以用 changepoint 包PELT 是它的核心方法之一library(changepoint) set.seed(2024) x - c(rnorm(100, 0, 1), rnorm(100, 2, 1), rnorm(100, 0, 1)) fit - cpt.mean(x, method PELT, penalty BIC) cpts(fit)cpt.mean 按均值变化检测cpt.var 按方差变化检测cpt.meanvar 则两者同时考虑。R 的实现和 ruptures 在数学内核上是一致的主要差异在默认参数和处理边界的方式上。实际项目里我建议哪个生态熟就用哪个。2.3 惩罚项选不对变点全是假的说到参数PELT 里最影响结果的就是惩罚项 β这个概念必须讲透。β 表示“每增加一个段付出的代价”它直接决定了算法对“多分一段”的容忍度。β 越小算法越倾向于分出更多段任何微小的波动都可能被当作变点β 越大算法越保守可能把小规模的真实变化也吞掉。我用一个比喻理解它你手里有一张地图上面有几十个“可疑点”惩罚项就是雇佣考古队去验证每个可疑点的成本。成本低你恨不得每个土堆都挖一遍成本高你只挖最像的那几个。惩罚项的理论参考值有几个经典选择AIC 用 β2pBIC 用 βp·ln(n)其中 p 是每个段额外引入的参数个数n 是序列长度。对均值变化模型段参数就是均值p1对均值加方差同时变化的模型p2。在 ruptures 里pen 的常用起点可以设成 2·ln(n)我随手写了个小实验pen_start 2 * np.log(n) # 约等于 15.2实际跑下来pen 取 15 到 30 之间通常能得到与真实变点比较接近的结果。pen 太小你会看到大量假变点pen 太大则会漏掉小幅度变化。我把几个试验参数对应的效果整理了一下pen 取值典型现象可能的场景2~5变点数量爆炸噪声段也被切碎惩罚过轻需要放大10~20能识别主要转折噪声影响小多数模拟数据的最优区间50 以上只保留极其剧烈的变化惩罚过重小变化直接丢失如果业务场景不允许拍脑袋设参数更工程化的做法是做一次惩罚项扫描从小到大跑一组 pen记录每个 pen 下检测出的变点数量画出“变点数量 vs 惩罚项”曲线选曲线出现明显平台区间的位置。稳定平台意味着再减小惩罚也不会增加太多变点说明这些变点大概率是真实的。3. 内核调度器里的 PELT 负载跟踪3.1 1024 微秒的“遗忘曲线”PELT 的数学内核把视角切到 Linux 内核。PELT 负载跟踪要解决一个本质问题如何用“过去一段时间的历史”来估计“任务当前对 CPU 的需求”。历史太短的瞬时采样噪声大历史太长则对负载变化反应迟钝。内核的做法是给历史贡献加指数衰减权重——越久远的时间片对当前负载的贡献越小。具体的机制是PELT 把时间分成 1024 微秒的周期每个周期结束时旧的负载贡献统一乘上一个衰减系数 y然后加上本周期新产生的贡献。用公式表达就是当前负载 上个周期负载 × y 本周期采样值y 的取值非常讲究内核定义 y 使得 32 个周期后历史贡献衰减到一半也就是 y³² 0.5。算一下y ≈ 0.978572。这个选择让系统对负载变化保持一种“温和记忆”大约每 32 毫秒1024 微秒 × 32过去的影响折半。经过 7 个半衰期也就是 224 毫秒左右历史贡献衰减到 1% 以下一个新创建的任务需要大约 672 毫秒才能真正把 util_avg 累积到稳态水平。这个“遗忘曲线”让 PELT 具备两个特性平滑和响应。时间窗口越长曲线越平滑但正因为有半衰期的存在系统又能在几十毫秒内感知到负载的显著变化。我之前用过朴素滑动窗口做过类似统计窗口定长了跟不上变化窗口定短了数值乱跳PELT 的指数衰减思路其实更适合调度这种对时效性要求高的场景。3.2 从单任务到多级队列负载是怎么聚合的内核里负载不是只存在于任务这一层。现代 Linux 调度器使用 CFS 调度器调度实体sched_entity既可以是单个任务也可以是一个任务组比如一个容器、一个 cgroup。这就形成了任务到调度实体、调度实体到运行队列的树形结构。PELT 的负载统计会沿着这棵树逐级上报。先看最底层的任务。每个任务维护自己的 util_avg 和 load_avg。任务在 CPU 上执行执行时间被累计进 1024 微秒的周期里任务睡眠时不再贡献新采样但历史贡献会继续按衰减系数逐周期变小。所以一个任务长期睡眠后它的 util_avg 会自然趋近于零。再看上一层。一个 CPU 的运行队列cfs_rq上面挂着多个任务cfs_rq 的 load_avg 不是简单把任务值相加就完事而是一边吸收任务贡献一边做周期衰减。这层统计对负载均衡极端重要——调度器定期计算每个 CPU 的 load挑出最忙的 CPU把任务迁移过去靠的就是 cfs_rq 层的负载数据。最复杂的是任务组task group这一层。一个容器里可能有几十个线程它们各自算力相加非常可观但从宿主机的角度看这个容器整体在某个 CPU 上只占用了一个“槽位”。内核的处理方式是group 的调度实体把内部所有任务的贡献收集起来再按该 group 在父级运行队列中的权重占比做归一化生出一个代表该 group 的 se 负载向更上层传递。层级之间不是简单的求和而是做了一次权重再分配所以在查看某个 cgroup 的 CPU 统计时数字会比“容器里所有线程 util 之和”小这是正常现象不代表负载丢了。3.3 负载数据的实际用途与调试命令PELT 的数据在调度器里有三处主要消费方。第一处是负载均衡内核周期性调用 load_balance遍历所有 CPU 的运行队列比较 load_avg找出最忙的 CPU再挑出合适的任务迁移。如果 PELT 数据不准会出现任务挤在一起、部分 CPU 空闲的“跷跷板”现象。第二处是 CFS 带宽控制quota 消耗和周期利用率判断依赖 util_avg防止某个进程组长期超过配额。第三处是 EAS 能耗感知调度当系统里同时存在大小核调度器需要判断“任务到底放在性能核还是效率核”依据就是 util_avg 预估值再加上唤醒时的队列状态预测。想在实际系统里观察 PELT 数据有几个入口可以试。最轻量的是读 /proc/schedstat能看到每个 CPU 的调度统计。想看得更细可以看内核调试接口# 查看每个 CPU 运行队列的负载统计 cat /proc/schedstat # 有 CONFIG_SCHED_DEBUG 开启时查看 sched_debug 中的 rq 信息 grep -E load /sys/kernel/debug/sched/debug更推荐用 perf 跟踪调度事件对负载追踪的时序会清晰很多sudo perf trace -e sched:* -- sleep 5注意 load_avg 和 util_avg 的区别load_avg 反映的是“队列压力”包含正在等待 CPU 的任务数值可以大于 1util_avg 反映的是“真实跑满 CPU 的占比”最大 100%。负载均衡选迁移对象时主要看 loadEAS 和频率调节主要看 util两者别搞混。4. 调参、避坑与工程化经验4.1 变点检测 PELT 的调参实战我把实际项目里踩过的坑总结成一套调参流程按顺序执行基本能避开大部分问题。第一步是数据预处理PELT 对缺失值和离群点比较敏感先做缺失值补齐再用中值滤波或滑动平均做轻度平滑。第二步是确定模型和惩罚项均值漂移用 l2非参数不确定的情况下推 rbf 内核模型。ruptures 的 Pelt 支持多种模型modelrbf 在变化模式不明确时更稳健。第三步是用“变点数量随惩罚项变化”曲线选惩罚项的落点参考我在 2.3 节说的方法。还有一个非常容易被忽略的细节min_size 一定要结合业务含义设置。比如你做秒级指标的变点检测业务上能接受的最短变化间隔是 1 分钟那 min_size 至少设 60。如果设得太小PELT 会把一次长变化的“中间过程”也切出好几个点看起来像多次变化实际是一次事件被肢解了。我处理过一份流量突变数据正确结果是“凌晨一次上升”因为 min_size 被默认值 2 支配输出了一串点后来调到符合业务粒度的 30结果一下子就干净了。4.2 内核 PELT 的已知坑位内核 PELT 也有几个老熟人级别的坑做性能分析和容器优化时经常碰到。第一个坑是新任务冷启动。一个刚 fork 出来的任务 util_avg 从 0 开始慢慢累积大概要 672 毫秒才能反映真实负载。假如你的扩容策略基于 PELT 负载数据判断“容器是否忙”新容器在启动后几十毫秒内看起来负载极低可能触发错误缩容。解决办法是扩容冷却时间至少留够 1 秒或者让调度器在唤醒时结合 runqueue 深度做预判。第二个坑是任务迁移导致负载“失真”。任务从 CPU A 迁移到 CPU B 后A 侧的 util_avg 会立刻衰减B 侧要从 0 开始重新累积中间会有几百毫秒的总负载“蒸发”。跨 CPU 迁移越频繁整体统计偏差越明显。做性能监控报表时看到某段时间系统总 util 无明显原因地下降可以先去查是不是有大规模任务迁移发生。第三个坑是 group 层级里的“隐藏负载”。容器里线程很多但宿主机 cfs_rq 上的 group se 只按权重归一化展示容器内线程的忙碌不会直接等价成宿主机 CPU 的忙碌。你需要同时看容器自己的 cgroup CPU 统计和宿主机的 per-cpu util两相对照才能定位是容器内线程竞争还是外部干扰。4.3 常见问题速查表问题现象可能的根因解决思路变点检测结果特别碎到处都是点惩罚项偏小调大 pen尝试 3~5 倍当前值明显的大变化没被检出惩罚项偏大或 min_size 超过变化间隔调小 pen或把 min_size 降到业务最小间隔不同随机种子跑出的变点位置波动大数据噪声强或 jump 取值过大先做平滑减小 jump 到 1新进程的 CPU 使用率前几百毫秒偏低PELT 冷启动历史累积未完成等待 1 秒后再做判断或结合队列长度容器 CPU 统计明显小于内部线程使用之和group 层级的权重归一化效应同时看 cgroup 统计和 per-cpu util任务迁移后系统总负载下降PELT 迁移后重新累积减少强制迁移频率或检查 cpuset 绑定4.4 这两个 PELT 能放在一起用吗能而且效果意外地好。内核 PELT 负责持续输出负载曲线变点检测 PELT 负责在负载曲线上找“转折点”。我之前做过一个小工具专门监控一组服务器的 CPU 使用率变化每秒采集一次 /proc/stat 计算 CPU 利用率攒够 1800 个点后丢给 ruptures 的 Pelt 做变点检测一旦检测出变点就触发告警。这个方案在识别“发布后 CPU 陡增”“双击热点的流量切换”“定时任务启动导致抖动”这类场景里非常好用。实现上不复杂核心就几步第一用 metric 采集工具把 CPU util 按固定间隔落成 CSV第二对序列做一次 5 点中值滤波去掉秒级毛刺第三调用 penalty 扫描确定合理的 pen 区间第四检测出变点后记录时间戳逆序回查前后 1 分钟的负载均值变化幅度变化超过阈值才报。这套组合不需要额外的训练样本纯无监督启动成本极低。5. 一个可复用的落地套路负载曲线变点监控我现在把上面组合玩法的完整流程再展开一点方便你直接抄作业。假设机器上已经装了 Python 环境需要的库只有 pandas、numpy、ruptures。第一步采集数据。最简单的 CPU 利用率采集可以直接解析 /proc/stat 的差值我用的采集脚本大概这样import time def read_cpu(): with open(/proc/stat) as f: line f.readline() parts line.split() vals list(map(int, parts[1:])) idle vals[3] vals[4] total sum(vals) return total, idle prev read_cpu() util_series [] while len(util_series) 1800: time.sleep(1) cur read_cpu() total_diff cur[0] - prev[0] idle_diff cur[1] - prev[1] util_series.append(100.0 * (1 - idle_diff / max(total_diff, 1))) prev cur第二步变点检测。这里要注意把数据转成 numpy 数组并且用我前面说的 min_size 控制最小分段。比如出现“持续 1 分钟以上的 CPU 上升”才算变化那 min_size 就要给到 60 个采样点import numpy as np import ruptures as rpt arr np.array(util_series) algo rpt.Pelt(modell2, min_size60, jump5).fit(arr) pen 4 * np.log(len(arr)) # 惩罚项按序列长度缩放 found algo.predict(penpen)第三步核对变点。predict 返回的数组最后一个元素肯定是序列长度 n前面的点就是检测出的变点位置。把变点位置的索引换算成时间戳回到原始指标里看该时刻前后的均值之差如果变化幅度小于阈值就过滤掉避免把缓慢漂移也报成事件。这套组合的实际价值在于它把“内核视角的负载结果”和“统计视角的变化检测”打通了。如果你已经在做监控告警系统不用自己写复杂的阈值判断直接把这条流程接进管线告警的自适应能力会强很多——不同业务峰值的绝对值差异大但“从某个时刻起负载形态发生改变”这件事是可以用变点检测统一捕捉的。我个人在实际操作中的体会是把两个同名 PELT 理解成“记录变化”和“发现变化”的搭档很多监控和分析问题都会变得简单。数据侧先用负载跟踪把指标采出来再用变点检测把转折点挑出来两者配合能省掉大量手工画阈值的精力。最后再提醒一句无论用哪个 PELT改造前先在模拟数据上跑通参数再用历史数据回验比直接上生产环境试要稳妥得多。