代码睡眠速度变慢的原因有多种。这里,主要有4个原因频率缩放, the TLB/缓存未命中和分支未命中。所有这些都是由于上下文切换加上 CPU 长时间不活动造成的。问题是独立于ctypes
.
频率缩放
当主流现代处理器没有密集型计算任务时,它自动降低其频率(需经操作系统同意才可配置)。它类似于人类的睡眠:当你无事可做时,你可以睡觉,当你醒来时,需要一些时间才能快速操作(即头晕状态)。对于处理器来说也是同样的事情:处理器从低频(在睡眠调用期间使用)切换到高频(在计算代码期间使用)需要一些时间。 AFAIK,这主要是因为处理器需要调整其电压。这是预期的行为,因为直接切换到最高频率并不节能,因为目标代码可能不会运行很长时间(请参阅滞后现象)和能量消耗一起成长~ frequency**3
(由于更高频率所需的电压增加)。
有一种方法可以在 Linux 上轻松检查这一点。您可以使用固定频率并禁用任何类似涡轮的模式。在我的 i5-9600KF 处理器上,我使用了以下行:
echo performance | sudo tee /sys/devices/system/cpu/cpu*/cpufreq/scaling_governor
echo 1 | sudo tee /sys/devices/system/cpu/intel_pstate/no_turbo
您可以使用以下行检查 CPU 的状态:
cat /sys/devices/system/cpu/cpu*/cpufreq/scaling_max_freq
cat /proc/cpuinfo | grep MHz # Current frequency for each core
以下是我的机器上更改前后的结果:
# ---------- BEFORE ----------
$ python3 lat-test.py 0
Time: 12387 ns
Time: 2023 ns
Time: 1272 ns
Time: 1096 ns
Time: 1070 ns
Time: 998 ns
Time: 1022 ns
Time: 956 ns
Time: 1002 ns
Time: 1378 ns
$ python3 lat-test.py 1
Time: 6941 ns
Time: 3772 ns
Time: 3544 ns
Time: 9502 ns
Time: 25475 ns
Time: 18734 ns
Time: 23800 ns
Time: 9503 ns
Time: 19520 ns
Time: 17306 ns
# ---------- AFTER ----------
$ python3 lat-test.py 0
Time: 7512 ns
Time: 2262 ns
Time: 1488 ns
Time: 1441 ns
Time: 1413 ns
Time: 1434 ns
Time: 1426 ns
Time: 1424 ns
Time: 1443 ns
Time: 1444 ns
$ python3 lat-test.py 1
Time: 8659 ns
Time: 5133 ns
Time: 3720 ns
Time: 4057 ns
Time: 3888 ns
Time: 4187 ns
Time: 4136 ns
Time: 3922 ns
Time: 4456 ns
Time: 3946 ns
我们可以看到差距明显变小了。此外,结果更加稳定(并且可重复)。请注意,当延迟很小时,性能会降低,因为涡轮增压已被禁用(因此我的处理器不会以其最高可能频率运行)。在我的机器上,最小频率 (0.8 GHz) 和最大频率 (4.6 GHz) 之间的系数为 5.75,这是相当大的,并且证明启用频率缩放(默认)时性能差距的很大一部分是合理的。
有偏差的基准
A 延迟的很大一部分在执行过程中丢失了get_time_ns
。这是一个关键点:CPython 是一个缓慢的解释器,因此您无法用它非常精确地测量时间。在我的机器上,CPython 中的空函数调用大约需要 45 ns!表达方式Decimal(str('1676949210508126547'))
大约需要 250 纳秒。考虑这一点至关重要,因为您测量的延迟仅比此大 10 倍,而在这种情况下,由于许多开销(包括缓存变冷 - 请参阅下文),此类代码可能会明显变慢。
为了提高基准测试的准确性,我删除了 Decimal 模块的使用以及昂贵且仅使用整数的字符串转换。请注意,即使是基本整数在 CPython 中也远非便宜,因为它们具有可变长度并且是动态分配的,更不用说 CPython 在运行时解释字节码了。一个简单的integer_1 - integer_2
在我的机器上大约需要 35 纳秒,而在本机编译代码中则需要不到 1 纳秒。获取函数time_ns
来自time
模块也大约需要相同的时间,更不用说计时函数本身需要约 50 ns 来执行(1 次获取+执行总共约 85 ns)。
我还将迭代次数从 10 增加到 10_000,以便在接下来的分析部分中效果更加明显。
最终,延迟从 1400/4000 纳秒降至 200/1300 纳秒。这是一个巨大的差异。事实上,200 ns 是如此之小,以至于至少一半的时间仍然丢失了计时开销,并且不是调用p.get()
!话虽如此,差距仍然存在。
缓存和 TLB 未命中
剩余开销的一部分是由于高速缓存未命中和 TLB 未命中造成的。事实上,当发生上下文切换时(由于调用sleep
), the CPU缓存可以被刷新(不知何故)。事实上,据我所知,它们在上下文切换期间间接刷新主流现代处理器:TLBCPU单元这是一个负责将虚拟内存转换为物理内存的缓存,它被刷新,导致在线程被调度回来时重新加载缓存行。它有一个流程重新安排后对性能产生重大影响因为数据通常需要从慢速 RAM 或至少是较高延迟的缓存(例如 LLC)重新加载。请注意,即使情况并非如此,线程也可以被调度回具有自己的私有 TLB 单元的不同核心上,因此会导致许多缓存未命中。
关于进程之间如何共享内存,您可能还会遇到“TLB 击落”,这也是相当昂贵的。看这个帖子 and this one有关此效果的更多信息。
在Linux上,我们可以使用很棒的perf
工具以便跟踪CPU的性能性能事件。以下是 TLB 的两个用例的结果:
# Low latency use-case
84 429 dTLB-load-misses # 0,02% of all dTLB cache hits
467 241 669 dTLB-loads
412 744 dTLB-store-misses
263 837 789 dTLB-stores
47 541 iTLB-load-misses # 39,53% of all iTLB cache hits
120 254 iTLB-loads
70 332 mem_inst_retired.stlb_miss_loads
8 435 mem_inst_retired.stlb_miss_stores
# High latency use-case
1 086 543 dTLB-load-misses # 0,19% of all dTLB cache hits
566 052 409 dTLB-loads
598 092 dTLB-store-misses
321 672 512 dTLB-stores
834 482 iTLB-load-misses # 443,76% of all iTLB cache hits
188 049 iTLB-loads
986 330 mem_inst_retired.stlb_miss_loads
93 237 mem_inst_retired.stlb_miss_stores
dTLB 是每核 TLB,用于存储数据页的映射。 sTLB 在内核之间共享。 iTLB 是每个核心的 TLB,用于存储代码页的映射。
我们可以看到 dTLB 加载未命中和 iTLB 加载未命中以及 sTLB 加载/存储的数量大幅增加。这证实了性能问题很可能是由 TLB 未命中引起的。
TLB 未命中会导致更多缓存未命中,从而降低性能。这是我们在实践中可以看到的。事实上,以下是缓存的性能结果:
# Low latency use-case
463 214 319 mem_load_retired.l1_hit
4 184 769 mem_load_retired.l1_miss
2 527 800 mem_load_retired.l2_hit
1 659 963 mem_load_retired.l2_miss
1 568 506 mem_load_retired.l3_hit
96 549 mem_load_retired.l3_miss
# High latency use-case
558 344 514 mem_load_retired.l1_hit
7 280 721 mem_load_retired.l1_miss
3 564 001 mem_load_retired.l2_hit
3 720 610 mem_load_retired.l2_miss
3 547 260 mem_load_retired.l3_hit
105 502 mem_load_retired.l3_miss
分支未命中
另一部分开销是由于长时间睡眠后条件跳转的预测不太好。这是一个复杂的话题,但人们应该知道,主流现代处理器根据包括过去结果在内的许多参数来预测分支。例如,如果条件始终为真,则处理器可以推测执行条件如果预测实际上是错误的,稍后再恢复它(代价高昂)。现代处理器无法同时预测大量条件跳转:它们为此拥有一个小型缓存,并且随着时间的推移可以快速刷新。问题是 CPython 像大多数解释器一样做了很多条件跳转。因此,上下文切换可能会导致分支跳转缓存的刷新,增加在这种情况下使用的条件跳转的开销,从而导致更高的延迟。
以下是我机器上的实验结果:
# Low latency use-case
350 582 368 branch-instructions
4 629 149 branch-misses # 1,32% of all branches
# High latency use-case
421 207 541 branch-instructions
8 392 124 branch-misses # 1,99% of all branches
请注意,在我的机器上,分支未命中应该需要大约 14 个周期。这意味着 14 ms 的间隙,因此每次迭代约 1400 ns。话虽如此,在两次函数调用之间只测量了一小部分时间get_time_ns()
.
有关此主题的更多信息,请阅读这个帖子.