在进程中断期间使用 python ctypes 时高延迟背后的原因

Roh*_*ala 4 python performance ctypes mmap python-multiprocessing

在调查 Python 代码库中的关键路径时,我们发现 ctypes 在延迟方面的行为是相当不可预测的。

我们的应用程序的更多背景。我们有很多进程,每个进程都通过共享内存进行通信。我们利用 python 库multiprocessing.RawValuemultiprocessing.RawArray它在内部用于ctypes数据管理。在生产环境中运行时,我们发现即使get()对这些共享数据类型进行简单的访问也需要大约 30-50 us,有时需要 100 us,而且速度相当慢。即使对于蟒蛇来说也是如此。

我创建了这个简单的示例,它创建了一个ctype结构并公开了get()方法

import ctypes
import sys
import time
import numpy as np
import random
from decimal import Decimal

def get_time_ns():
    return Decimal(str(time.time_ns()))

class Point(ctypes.Structure):
    _fields_ = [("x", ctypes.c_int),
                ("y", ctypes.c_int)]

    def __init__(self, x, y):
        return super().__init__(x, y)

    def get(self):
        return self.x
        #return str(self.x) + "," + str(self.y)

def benchmark(delay_mode):
    p = Point(10, 20)
    iters = 10
    while iters:
        start_ts = get_time_ns()
        _ = p.get()
        end_ts = get_time_ns()
        print("Time: {} ns".format(end_ts - start_ts))
        iters -= 1
        if delay_mode == 1:
            time.sleep(random.uniform(0, 0.1))

benchmark(int(sys.argv[1]))
Run Code Online (Sandbox Code Playgroud)

当我在非睡眠模式下运行它时,延迟数字如下

[root@centos-s-4vcpu-8gb-fra1-01 experiments]# python3.9 simple_ctype.py 0
Time: 9556 ns
Time: 2246 ns
Time: 1124 ns
Time: 1174 ns
Time: 1091 ns
Time: 1126 ns
Time: 1081 ns
Time: 1066 ns
Time: 1077 ns
Time: 1138 ns
Run Code Online (Sandbox Code Playgroud)

当我在睡眠模式下运行它时,延迟数字如下

[root@centos-s-4vcpu-8gb-fra1-01 experiments]# python3.9 simple_ctype.py 1
Time: 27233 ns
Time: 27592 ns
Time: 31687 ns
Time: 32817 ns
Time: 26234 ns
Time: 32651 ns
Time: 29468 ns
Time: 36981 ns
Time: 31313 ns
Time: 34667 ns
Run Code Online (Sandbox Code Playgroud)

使用的原因sleep是为了模拟我们的生产环境,其中应用程序所做的不仅仅是运行这个循环

有人可以解释一下,与上述热循环相比,当存在中断时,延迟会增加 10 - 20 倍吗?我最好的猜测是 CPU 缓存未命中,但这仍然不能解释这种延迟增加。我对 ctypes 实际如何管理内存也很困惑。是简单的mallocmmapmalloc. 最后但并非最不重要的一点是,如果有人可以帮助我们优化这一点,那就太好了。

系统信息:CentOS 7.9,4核CPU,16 GB RAM。taskset将特定 CPU 核心固定到脚本

仅供参考,我们已经知道 C++/Rust 的高精度性能比 Python 等高级语言更好,但考虑到时间敏感性和其他业务原因,我们希望在真正遇到语言障碍之前优化 Python 代码的性能

Jér*_*ard 7

代码睡眠速度变慢的原因有多种。这里,4个主要原因是频率缩放TLB/缓存未命中分支未命中。所有这些都是由于上下文切换加上 CPU 长时间不活动造成的。该问题独立于ctypes.

\n
\n

频率缩放

\n

当主流现代处理器没有密集的计算任务时,它会自动降低频率(在可配置的操作系统的同意下)。它类似于人的睡眠:当你无事可做时,你可以睡觉,当你醒来时,需要一段时间才能快速操作(即头晕状态)。这对于处理器来说也是一样的:处理器从低频(在睡眠调用期间使用)切换到高频(在计算代码期间使用)需要一些时间。AFAIK,这主要是因为处理器需要调整其电压。这是预期的行为,因为直接切换到最高频率并不节能,因为目标代码可能不会长时间运行(请参阅滞后)并且功耗会随之增长~ frequency**3(由于更高频率所需的电压增加) 。

\n

有一种方法可以在 Linux 上轻松检查这一点。您可以使用固定频率并禁用任何类似涡轮的模式。在我的 i5-9600KF 处理器上,我使用了以下行:

\n
echo performance | sudo tee /sys/devices/system/cpu/cpu*/cpufreq/scaling_governor\necho 1 | sudo tee /sys/devices/system/cpu/intel_pstate/no_turbo\n
Run Code Online (Sandbox Code Playgroud)\n

您可以使用以下行检查 CPU 的状态:

\n
cat /sys/devices/system/cpu/cpu*/cpufreq/scaling_max_freq\ncat /proc/cpuinfo  | grep MHz       # Current frequency for each core\n
Run Code Online (Sandbox Code Playgroud)\n

以下是我的机器上更改前后的结果:

\n
# ---------- BEFORE ----------\n\n$ python3 lat-test.py 0\nTime: 12387 ns\nTime: 2023 ns\nTime: 1272 ns\nTime: 1096 ns\nTime: 1070 ns\nTime: 998 ns\nTime: 1022 ns\nTime: 956 ns\nTime: 1002 ns\nTime: 1378 ns\n\n$ python3 lat-test.py 1\nTime: 6941 ns\nTime: 3772 ns\nTime: 3544 ns\nTime: 9502 ns\nTime: 25475 ns\nTime: 18734 ns\nTime: 23800 ns\nTime: 9503 ns\nTime: 19520 ns\nTime: 17306 ns\n\n# ---------- AFTER ----------\n\n$ python3 lat-test.py 0\nTime: 7512 ns\nTime: 2262 ns\nTime: 1488 ns\nTime: 1441 ns\nTime: 1413 ns\nTime: 1434 ns\nTime: 1426 ns\nTime: 1424 ns\nTime: 1443 ns\nTime: 1444 ns\n\n$ python3 lat-test.py 1\nTime: 8659 ns\nTime: 5133 ns\nTime: 3720 ns\nTime: 4057 ns\nTime: 3888 ns\nTime: 4187 ns\nTime: 4136 ns\nTime: 3922 ns\nTime: 4456 ns\nTime: 3946 ns\n
Run Code Online (Sandbox Code Playgroud)\n

我们可以看到差距明显变小了。此外,结果更加稳定(并且可重复)。请注意,当延迟很小时,性能会降低,因为涡轮增压已被禁用(因此我的处理器不会以其最高可能频率运行)。在我的机器上,最小频率 (0.8 GHz) 和最大频率 (4.6 GHz) 之间的系数为 5.75,这是相当大的,并且证明启用频率缩放(默认)时性能差距的很大一部分是合理的。

\n
\n

有偏差的基准

\n

延迟的很大一部分get_time_ns. 这是一个关键点:CPython 是一个缓慢的解释器,因此您无法用它非常精确地测量时间。在我的机器上,CPython 中的空函数调用大约需要 45 ns!该表达式Decimal(str(\'1676949210508126547\'))大约需要 250 ns。考虑这一点至关重要,因为您测量的延迟仅比此大 10 倍,而在这种情况下,由于许多开销(包括缓存变冷 - 请参阅下文),此类代码可能会明显变慢。

\n

为了提高基准测试的准确性,我删除了 Decimal 模块的使用以及昂贵且仅使用整数的字符串转换。请注意,即使是基本整数在 CPython 中也远非便宜,因为它们具有可变长度并且是动态分配的,更不用说 CPython 在运行时解释字节码了。在我的机器上,一个简单的integer_1 - integer_2任务大约需要 35 纳秒,而在本机编译的代码中则需要不到 1 纳秒。time_ns从模块中获取函数time也大约需要相同的时间,更不用说计时函数本身需要大约 50 ns 来执行(1 次获取+执行总共大约需要 85 ns)。

\n

我还将迭代次数从 10 增加到 10_000,以便在接下来的分析部分中效果更加明显。

\n

最终,延迟从 1400/4000 纳秒降至 200/1300 纳秒。这是一个巨大的差异。事实上,200 ns 是如此之小,以至于至少有一半的时间仍然丢失了计时开销,并且不是对p.get()! 话虽如此,差距仍然存在。

\n
\n

缓存和 TLB 未命中

\n

剩余开销的一部分是由于高速缓存未命中和 TLB 未命中造成的。事实上,当发生上下文切换时(由于调用sleep),CPU 缓存可以被刷新(以某种方式)。事实上,据我所知,它们在上下文切换期间间接刷新主流现代处理器:TLB CPU 单元(负责将虚拟内存转换为物理内存的高速缓存)被刷新,导致在调度线程时重新加载高速缓存行后退。进程被调度回来后,它会对性能产生重大影响,因为数据通常需要从慢速 RAM 或至少是较高延迟的缓存(例如 LLC)重新加载。请注意,即使情况并非如此,线程也可以被调度回具有自己的私有 TLB 单元的不同核心上,因此会导致许多缓存未命中。

\n

关于进程之间如何共享内存,您可能还会遇到“TLB 击落”,这也是相当昂贵的。有关此效果的更多信息,请参阅这篇文章这篇文章。

\n

在Linux上,我们可以使用很棒的perf工具来跟踪CPU的性能事件。以下是 TLB 的两个用例的结果:

\n
# Low latency use-case\n\n            84\xe2\x80\xaf429      dTLB-load-misses          #    0,02% of all dTLB cache hits \n       467\xe2\x80\xaf241\xe2\x80\xaf669      dTLB-loads                                                  \n           412\xe2\x80\xaf744      dTLB-store-misses                                           \n       263\xe2\x80\xaf837\xe2\x80\xaf789      dTLB-stores                                                    \n            47\xe2\x80\xaf541      iTLB-load-misses          #   39,53% of all iTLB cache hits \n           120\xe2\x80\xaf254      iTLB-loads \n            70\xe2\x80\xaf332      mem_inst_retired.stlb_miss_loads                                   \n             8\xe2\x80\xaf435      mem_inst_retired.stlb_miss_stores       \n\n# High latency use-case\n\n         1\xe2\x80\xaf086\xe2\x80\xaf543      dTLB-load-misses          #    0,19% of all dTLB cache hits \n       566\xe2\x80\xaf052\xe2\x80\xaf409      dTLB-loads                                                  \n           598\xe2\x80\xaf092      dTLB-store-misses                                           \n       321\xe2\x80\xaf672\xe2\x80\xaf512      dTLB-stores            \n           834\xe2\x80\xaf482      iTLB-load-misses          #  443,76% of all iTLB cache hits \n           188\xe2\x80\xaf049      iTLB-loads \n           986\xe2\x80\xaf330      mem_inst_retired.stlb_miss_loads                                   \n            93\xe2\x80\xaf237      mem_inst_retired.stlb_miss_stores \n
Run Code Online (Sandbox Code Playgroud)\n

dTLB 是每核 TLB,用于存储数据页的映射。sTLB 在内核之间共享。iTLB 是每个核心的 TLB,用于存储代码页的映射。

\n

我们可以看到 dTLB 加载未命中和 iTLB 加载未命中以及 sTLB 加载/存储的数量大幅增加。这证实了性能问题很可能是由 TLB 未命中引起的。

\n

TLB 未命中会导致更多缓存未命中,从而降低性能。这是我们在实践中可以看到的。事实上,以下是缓存的性能结果:

\n
# Low latency use-case\n\n       463\xe2\x80\xaf214\xe2\x80\xaf319      mem_load_retired.l1_hit                                     \n         4\xe2\x80\xaf184\xe2\x80\xaf769      mem_load_retired.l1_miss                                    \n         2\xe2\x80\xaf527\xe2\x80\xaf800      mem_load_retired.l2_hit                                     \n         1\xe2\x80\xaf659\xe2\x80\xaf963      mem_load_retired.l2_miss    \n         1\xe2\x80\xaf568\xe2\x80\xaf506      mem_load_retired.l3_hit                                     \n            96\xe2\x80\xaf549      mem_load_retired.l3_miss    \n\n# High latency use-case\n\n       558\xe2\x80\xaf344\xe2\x80\xaf514      mem_load_retired.l1_hit                                     \n         7\xe2\x80\xaf280\xe2\x80\xaf721      mem_load_retired.l1_miss                                    \n         3\xe2\x80\xaf564\xe2\x80\xaf001      mem_load_retired.l2_hit                                     \n         3\xe2\x80\xaf720\xe2\x80\xaf610      mem_load_retired.l2_miss         \n         3\xe2\x80\xaf547\xe2\x80\xaf260      mem_load_retired.l3_hit                                     \n           105\xe2\x80\xaf502      mem_load_retired.l3_miss          \n
Run Code Online (Sandbox Code Playgroud)\n
\n

分支未命中

\n

另一部分开销是由于长时间睡眠后条件跳转的预测不太好。这是一个复杂的话题,但我们应该知道,分支是由主流现代处理器根据包括过去结果在内的许多参数来预测的。例如,如果条件始终为真,则处理器可以推测执行该条件,并在预测实际上错误时恢复它(代价高昂)。现代处理器无法同时预测大量条件跳转:它们为此拥有一个小型缓存,并且随着时间的推移可以快速刷新。问题是 CPython 像大多数解释器一样做了很多条件跳转。因此,上下文切换可能会导致分支跳转缓存的刷新,增加在这种情况下使用的条件跳转的开销,从而导致更高的延迟。

\n

以下是我机器上的实验结果:

\n
# Low latency use-case\n\n       350\xe2\x80\xaf582\xe2\x80\xaf368      branch-instructions                                         \n         4\xe2\x80\xaf629\xe2\x80\xaf149      branch-misses             #    1,32% of all branches        \n\n# High latency use-case\n\n       421\xe2\x80\xaf207\xe2\x80\xaf541      branch-instructions                                         \n         8\xe2\x80\xaf392\xe2\x80\xaf124      branch-misses             #    1,99% of all branches     \n
Run Code Online (Sandbox Code Playgroud)\n

请注意,在我的机器上,分支未命中应该需要大约 14 个周期。这意味着 14 ms 的间隙,因此每次迭代约 1400 ns。话虽如此,在两次函数调用之间只测量了一小部分时间get_time_ns()

\n

有关此主题的更多信息,请阅读这篇文章

\n