被当成性能回归回滚的改动,其实只是噪声
目标
先对同一个对象重复测量五次来测出噪声的大小,再分别对延迟稍微增加的版本和大幅增加的版本各测五次,用规则判定哪一个超出了噪声。把这条规则做成一个接收基线目录和新测量目录、给出退出码的 CI 门禁脚本,并用数字确认一次性的比较会把同样的代码也变成回归。
为什么重要
性能回归的判定不是统计问题,而是运维问题。阈值设错,两种情况必居其一——误报频繁,人们把门禁关掉;或者阈值宽松,真正的回归溜了过去。两种情况下,门禁都等于不存在。所以阈值不应该是会议室里定下的百分比,而应该是当时在那台机器上实际测得的噪声。要测量噪声,就必须在什么都不改变的情况下,把同样的测试运行多次,这样就会自然地显现出“要运行几次”是时间与灵敏度之间的权衡。只运行一次,阈值就变成 0,什么都成了回归;运行得越多,就越有把握地捕获更小的回归。不把这个判断交给人的眼睛,而是留在脚本的退出码里,就是本实验的最后一步。
步骤
- 用
VERSION=base DELAY_MS=20 JITTER_MS=20 SEED=1234把/opt/lab/lt/lt-regression/svc.py启动在 127.0.0.1:8080 上。运行五次hey -n 200 -c 5 -o csv http://127.0.0.1:8080/work,把输出从/root/lt-regression/base/run1.csv到run5.csv分别保存。每次运行求出 p50,用五行写入/root/lt-regression/01-noise.tsv——每行是以制表符分隔的两个字段run<번호> <p50 밀리초>(占位符依次为编号、p50 毫秒数),毫秒保留到小数点后第三位。p50 的求法是:把 csv 第一个字段(response-time,以秒为单位)换算成毫秒,升序排序后,取第int(n/2)+1个(从 1 开始计数)值。然后在/root/lt-regression/01-spread.txt中写四行min=、max=、spread_ms=<max - min>、spread_pct=<(max - min) ÷ min × 100, 소수 첫째 자리>(占位符为 (max - min) ÷ min × 100,保留到小数点后第一位)。 - 从第 1 步的五次运行中,挑出 p50 最小的一次和最大的一次。在
/root/lt-regression/02-illusion.txt中写六行——fastest_run=<번호>、slowest_run=<번호>、fastest_p50=<밀리초>、slowest_p50=<밀리초>、apparent_change_pct=<(느린 값 - 빠른 값) ÷ 빠른 값 × 100, 소수 첫째 자리>、same_code=yes(占位符依次为编号、编号、毫秒数、毫秒数、(较慢的值 - 较快的值) ÷ 较快的值 × 100,保留到小数点后第一位)。毫秒保留到小数点后第三位。这两次测的是同一个版本、同样的条件。 - 再用
VERSION=small DELAY_MS=21 JITTER_MS=20 SEED=1234把同一个/opt/lab/lt/lt-regression/svc.py额外启动在 127.0.0.1:8081 上(8080 保持不动)。施加与第 1 步完全相同的负载五次,把结果从/root/lt-regression/small/run1.csv到run5.csv分别保存,并在/root/lt-regression/03-small.tsv中以与第 1 步相同的形式写五行。然后在/root/lt-regression/03-median.txt中写一行median_of_medians=<다섯 p50 의 중앙값, 소수 셋째 자리>(占位符为五个 p50 的中位数,保留到小数点后第三位)。中位数的定义与第 1 步相同。 - 用
VERSION=big DELAY_MS=30 JITTER_MS=20 SEED=1234把/opt/lab/lt/lt-regression/svc.py额外启动在 127.0.0.1:8082 上,施加同样的负载五次,把结果从/root/lt-regression/big/run1.csv到run5.csv分别保存。在/root/lt-regression/04-big.tsv中写五行,在/root/lt-regression/04-median.txt中以与第 3 步相同的形式写一行median_of_medians=。 - 在
/root/lt-regression/05-rule.txt中写四行——metric=p50、stat=median_of_5、noise_ms=<1단계 다섯 실행의 최대 - 최소, 소수 셋째 자리>(占位符为第 1 步五次运行的最大值 - 最小值,保留到小数点后第三位)、rule=<이 규칙을 한 문장으로, 40자 이상>(占位符为用一句话写出这条规则,至少 40 个字符)。然后在/root/lt-regression/05-verdict.tsv中写三行。每行是以制表符分隔的三个字段<이름> <delta_ms> <judgment>(占位符依次为名称、delta_ms、judgment),名称依次为base、small、big。delta 是该版本五次运行的中位数减去 base 五次运行的中位数所得的值(保留到小数点后第三位),judgment 在 delta 大于 noise_ms 时为regression,否则为noise。 - 创建
/root/lt-regression/gate.sh。以bash gate.sh <기준선 디렉터리> <새 측정 디렉터리>(占位符依次为基线目录、新测量目录)调用时,分别读取两个目录中的*.csv,为每次运行求出 p50,再给出每一组的中位数。输出是一行,delta=<밀리초> noise=<밀리초> REGRESSION或delta=<밀리초> noise=<밀리초> OK(占位符均为毫秒数)。delta 是新测量的中位数减去基线的中位数所得的值,noise 是基线各次运行 p50 的最大值减去最小值所得的值,两者都保留到小数点后第三位。如果 delta 大于 noise,就以退出码 1 结束,否则以 0 结束。评分器会用它自己制作的三组输入来运行这个脚本,确认通过和失败两种情况。 - 把第 1 步五次运行中 p50 最小的复制为
/root/lt-regression/one-base/run.csv,最大的复制为/root/lt-regression/one-new/run.csv(两个文件都是 base 版本的测量)。用这两个目录运行门禁,把输出保存到/root/lt-regression/07-gate-out.txt,并在/root/lt-regression/07-onerun.txt中写五行——noise_ms=<게이트가 낸 값>、delta_ms=<게이트가 낸 값>、gate_exit=<종료 코드>、false_positive=<yes 또는 no>、min_runs=<기준선을 최소 몇 번 돌려야 한다고 볼 것인가, 2 이상의 정수>(占位符依次为门禁给出的值、门禁给出的值、退出码、yes 或 no、基线至少需要运行几次,不小于 2 的整数)。 - 再运行两次门禁。把
bash gate.sh base big的输出保存到/root/lt-regression/08-gate-big.txt,把bash gate.sh base base的输出保存到/root/lt-regression/08-gate-same.txt。然后在/root/lt-regression/08-decision.txt中写六行——big_exit=、same_exit=、rollback=<yes 또는 no, big 판을 되돌릴 것인가>、min_runs=<5 이상의 정수>、threshold_rule=<문턱을 어떻게 정할 것인가, 40자 이상>、why=<60자 이상>(占位符依次为 yes 或 no(是否回滚 big 版本)、不小于 5 的整数、如何确定阈值(至少 40 个字符)、至少 60 个字符)。
参考
- 工作目录是
/root/lt-regression。如果不存在,请先创建。 - 压力对象是
/opt/lab/lt/lt-regression/svc.py。文件开头的注释中写有环境变量和启动三个版本的方法。 - 这个 Pod 中无法启动容器(seccomp)。对象直接在
127.0.0.1上启动 Python 标准库服务器来使用。要重新启动版本,请用pkill -f 'lt-regression/svc.py'杀掉前一个版本。 hey -o csv不输出摘要,而是每个请求输出一行。第一个字段是响应时间(秒),第一行是表头。- 常见错误:对每个版本给出不同的
-n或-c。负载条件不同,两次测量就不是可以比较的一对。 - 常见错误:把阈值固定成“5%”这样。在本实验的对象上,噪声也可能比这更大。
- hey (rakyll/hey)、k6 automated performance testing、k6 thresholds、Median absolute deviation、wrk2
什么都不改变,测五次来求噪声
用 VERSION=base DELAY_MS=20 JITTER_MS=20 SEED=1234 把 /opt/lab/lt/lt-regression/svc.py 启动在 127.0.0.1:8080 上。运行五次 hey -n 200 -c 5 -o csv http://127.0.0.1:8080/work,把输出从 /root/lt-regression/base/run1.csv 到 run5.csv 分别保存。每次运行求出 p50,用五行写入 /root/lt-regression/01-noise.tsv——每行是以制表符分隔的两个字段 run<번호> <p50 밀리초>(占位符依次为编号、p50 毫秒数),毫秒保留到小数点后第三位。p50 的求法是:把 csv 第一个字段(response-time,以秒为单位)换算成毫秒,升序排序后,取第 int(n/2)+1 个(从 1 开始计数)值。然后在 /root/lt-regression/01-spread.txt 中写四行 min=、max=、spread_ms=<max - min>、spread_pct=<(max - min) ÷ min × 100, 소수 첫째 자리>(占位符为 (max - min) ÷ min × 100,保留到小数点后第一位)。
csv 的第一行是表头,必须跳过。用 gawk 的 asort 排序,一行就能搞定。这个对象不消耗 CPU,只是睡眠,所以这里看到的波动不是机器争用,而是对象故意加入的、可复现的波动。可以理解为生产中的垃圾回收或相邻负载替代了这个位置。
即使是同样的代码,也会拉开这么大的差距
从第 1 步的五次运行中,挑出 p50 最小的一次和最大的一次。在 /root/lt-regression/02-illusion.txt 中写六行——fastest_run=<번호>、slowest_run=<번호>、fastest_p50=<밀리초>、slowest_p50=<밀리초>、apparent_change_pct=<(느린 값 - 빠른 값) ÷ 빠른 값 × 100, 소수 첫째 자리>、same_code=yes(占位符依次为编号、编号、毫秒数、毫秒数、(较慢的值 - 较快的值) ÷ 较快的值 × 100,保留到小数点后第一位)。毫秒保留到小数点后第三位。这两次测的是同一个版本、同样的条件。
用 sort -k2 -n 01-noise.tsv 排序,两端立刻就出来了。这里得到的百分比,就是“什么都没改变时,仅凭一次测量就能声称的变化量”。如果把固定阈值设得比它还小,门禁就会随机地拦住发布。
把慢了 5% 的版本测五次
再用 VERSION=small DELAY_MS=21 JITTER_MS=20 SEED=1234 把同一个 /opt/lab/lt/lt-regression/svc.py 额外启动在 127.0.0.1:8081 上(8080 保持不动)。施加与第 1 步完全相同的负载五次,把结果从 /root/lt-regression/small/run1.csv 到 run5.csv 分别保存,并在 /root/lt-regression/03-small.tsv 中以与第 1 步相同的形式写五行。然后在 /root/lt-regression/03-median.txt 中写一行 median_of_medians=<다섯 p50 의 중앙값, 소수 셋째 자리>(占位符为五个 p50 的中位数,保留到小数点后第三位)。中位数的定义与第 1 步相同。
如果改变负载条件,两个版本就没法比较——请让 -n 和 -c 与第 1 步完全相同。五个值的中位数,排序后就是第三个值。之所以使用五次运行的中位数,而不是某一次运行的值,是为了不让一次倒霉的运行颠倒判定。
把慢了 50% 的版本也测五次
用 VERSION=big DELAY_MS=30 JITTER_MS=20 SEED=1234 把 /opt/lab/lt/lt-regression/svc.py 额外启动在 127.0.0.1:8082 上,施加同样的负载五次,把结果从 /root/lt-regression/big/run1.csv 到 run5.csv 分别保存。在 /root/lt-regression/04-big.tsv 中写五行,在 /root/lt-regression/04-median.txt 中以与第 3 步相同的形式写一行 median_of_medians=。
三个版本同时运行,也不会互相干扰——因为对象只是睡眠,不会争抢 CPU。这一步得到的差异大到肉眼也能看出来,但本实验的要点,恰恰是不把“肉眼看得出”当作判定的依据。判定由下一步的规则来做。
把判定规则写成文字,并应用到三个版本上
在 /root/lt-regression/05-rule.txt 中写四行——metric=p50、stat=median_of_5、noise_ms=<1단계 다섯 실행의 최대 - 최소, 소수 셋째 자리>(占位符为第 1 步五次运行的最大值 - 最小值,保留到小数点后第三位)、rule=<이 규칙을 한 문장으로, 40자 이상>(占位符为用一句话写出这条规则,至少 40 个字符)。然后在 /root/lt-regression/05-verdict.tsv 中写三行。每行是以制表符分隔的三个字段 <이름> <delta_ms> <judgment>(占位符依次为名称、delta_ms、judgment),名称依次为 base、small、big。delta 是该版本五次运行的中位数减去 base 五次运行的中位数所得的值(保留到小数点后第三位),judgment 在 delta 大于 noise_ms 时为 regression,否则为 noise。
base 行的 delta 是与自己的差,所以是 0.000。small 会被判定为哪一边,请不要事先定好——由当天测得的噪声来决定。这就是它与固定百分比阈值的不同之处。不等号是“大于”,而不是“大于等于”。
把规则固定成 CI 门禁
创建 /root/lt-regression/gate.sh。以 bash gate.sh <기준선 디렉터리> <새 측정 디렉터리>(占位符依次为基线目录、新测量目录)调用时,分别读取两个目录中的 *.csv,为每次运行求出 p50,再给出每一组的中位数。输出是一行,delta=<밀리초> noise=<밀리초> REGRESSION 或 delta=<밀리초> noise=<밀리초> OK(占位符均为毫秒数)。delta 是新测量的中位数减去基线的中位数所得的值,noise 是基线各次运行 p50 的最大值减去最小值所得的值,两者都保留到小数点后第三位。如果 delta 大于 noise,就以退出码 1 结束,否则以 0 结束。评分器会用它自己制作的三组输入来运行这个脚本,确认通过和失败两种情况。
不要事先确定目录中 csv 的个数——评分器也会用不是五个的组来运行。如果一个 csv 都没有,不要以 0 结束,请用别的退出码告知。新测量更快的情况(delta 为负数)不是回归。用数字比较值时,请像 awk 'BEGIN{exit !(d > n)}' 这样使用 awk 的退出码。
一次性的比较,会把同样的代码也变成回归
把第 1 步五次运行中 p50 最小的复制为 /root/lt-regression/one-base/run.csv,最大的复制为 /root/lt-regression/one-new/run.csv(两个文件都是 base 版本的测量)。用这两个目录运行门禁,把输出保存到 /root/lt-regression/07-gate-out.txt,并在 /root/lt-regression/07-onerun.txt 中写五行——noise_ms=<게이트가 낸 값>、delta_ms=<게이트가 낸 값>、gate_exit=<종료 코드>、false_positive=<yes 또는 no>、min_runs=<기준선을 최소 몇 번 돌려야 한다고 볼 것인가, 2 이상의 정수>(占位符依次为门禁给出的值、门禁给出的值、退出码、yes 或 no、基线至少需要运行几次,不小于 2 的整数)。
如果基线只有一次运行,最大值和最小值相同,噪声就是 0。阈值是 0 时,任何大于 0 的差异都是回归。这一步中被报告为回归的两次测量,测的是同一个版本,在同一台机器上,用同样的条件。在 min_runs 中,请写下你自己的规则中要使用的最少重复次数。
要不要回滚——门禁的答案与人的答案
再运行两次门禁。把 bash gate.sh base big 的输出保存到 /root/lt-regression/08-gate-big.txt,把 bash gate.sh base base 的输出保存到 /root/lt-regression/08-gate-same.txt。然后在 /root/lt-regression/08-decision.txt 中写六行——big_exit=、same_exit=、rollback=<yes 또는 no, big 판을 되돌릴 것인가>、min_runs=<5 이상의 정수>、threshold_rule=<문턱을 어떻게 정할 것인가, 40자 이상>、why=<60자 이상>(占位符依次为 yes 或 no(是否回滚 big 版本)、不小于 5 的整数、如何确定阈值(至少 40 个字符)、至少 60 个字符)。
把同样的数据与它自己比较,差异是 0,所以在任何规则下都不应该是回归——如果门禁在这种情况下也拦住了,错的不是规则,而是实现。在 threshold_rule 中,请写下用什么来代替固定百分比。在 why 中,把决定回滚(或决定不回滚)的依据,与前面步骤的数字衔接起来写。