Windows 上的套接字速度 send/recv

Speed of socket send/recv on Windows

在 Windows + Python 3.7 + i5 笔记本电脑上,通过 socket 接收 100MB 数据需要 200 毫秒,与 RAM 速度相比,这显然非常低。

如何在 Windows 上提高此套接字速度?

# SERVER
import socket, time
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.bind(('127.0.0.1', 1234))
s.listen()
conn, addr = s.accept()
t0 = time.time()
while True:
    data = conn.recv(8192)  # 8192 instead of 1024 improves from 0.5s to 0.2s
    if data == b'':
        break
print(time.time() - t0)  # ~ 0.200s

# CLIENT
import socket, time
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect(('127.0.0.1', 1234))
a = b"a" * 100_000_000  # 100 MB of data
t0 = time.time()
s.send(a)
print(time.time() - t0)  # ~ 0.020s

注意:问题How to improve the send/receive speed of this Python socket?是关于socket周围的wrapper,所以我想直接用纯socket测试,并且没有包装纸。

如果您有大量数据要同时发送,请不要两次调用 send。当实现看到第一个 send 时,它没有理由认为会有第二个,所以立即发送数据。但是当它看到第二个 send 时,它没有理由认为不会有第三个,因此延迟发送数据以尝试聚合完整的数据包。

如果它们是两条不同的 application-level 消息并且另一方确认了第一条消息,那么这实际上没问题。但这里不是这样。

如果您正在设计一个 application-level 协议来使用 TCP,那么如果您关心性能,则必须使用 TCP。

如果您没有 application-level 消息,请确保在每个 send 调用中尽可能多地收集数据 -- 至少 4KB。

如果您确实有 application-level 条消息被对方确认,请尝试在每个 send 通话中包含完整的消息。

但是您在代码中所做的事情违反了所有这些原则,并且使实现无法正常执行。

TL;DR: 基于主要在我的机器上收集的事实(并在另一台机器上确认)我可以在其上重现类似的行为,问题似乎主要来自来自 Windows 网络 TCP 堆栈 的低效实现。更具体地说,Windows 执行大量 temporary-buffer 副本 导致 RAM 被密集使用。此外,整体资源没有得到有效利用。也就是说,基准还可以改进


设置

用于执行基准测试的主要目标平台具有以下属性:

  • OS: Windows 10 粉彩 N(版本 21H1)
  • 处理器:i5-9600KF
  • RAM:2 x 8GiB DDR4 通道 @ 3200GHz 在实践中能够达到 40 GiB/s。
  • CPython 3.8.1

请记住,不同平台的结果可能不同。


改进 code/benchmark

首先,a = b"a" * 100_000_000行需要一点时间,这包含在服务器的计时中,因为客户端在执行它之前已经连接,并且服务器应该在这段时间内接受客户端。最好在 s.connect 调用之前 移动此行。

另外,8192的缓冲区很小。以 8 KiB 的块读取 100 MB 意味着必须执行 12208 次 C 调用,并且可能需要执行类似数量的系统调用。由于 系统调用非常昂贵 因为它们在大多数平台上往往至少需要几毫秒,因此最好 将缓冲区大小 增加到至少主流处理器上为 32 KiB。缓冲区应该足够小以适应快速 CPU 缓存,但也应该足够大以减少系统调用的数量。在我的机器上,使用 256 KiB 的缓冲区可以提高 70% 的速度。

此外,您需要在客户端代码中关闭套接字,以便服务器代码不挂起。确实,否则 conn.recv 应该等待传入数据。事实上,检查 data == b'' 是否不是一个好主意,因为这不是检查流是否结束的安全方法。您需要发送发送缓冲区的大小或等待给定的预定义大小。例如,流可能会过早中断。或者,客户端可以关闭连接,服务器不会总是直接收到通知(尽管回环速度很快,但有时可能需要很长时间)。

这是 modified/improved 基准:

# CLIENT
import socket, time
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
a = b"a" * 100_000_000  # 100 MB of data
s.connect(('127.0.0.1', 1234))
t0 = time.time()
s.send(a)
s.close()
print(time.time() - t0)

# SERVER
import socket, time
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.bind(('127.0.0.1', 1234))
s.listen()
conn, addr = s.accept()
s = 0
t0 = time.time()
while True:
    data = conn.recv(256*1024)
    s += len(data)
    if s == 100_000_000:
        break
print(time.time() - t0)

我重复了 s.send 调用和基于 recv 的循环 100 次以获得稳定的结果。有了它,我可以达到 2.2 GiB/s。 TCP 套接字在大多数平台上往往很慢,但这个结果显然不是很好(Linux 成功实现了更好的吞吐量)。

在配备 Windows 10 Professional、Skylake Xeon 处理器和 RAM 达到 40 GiB/s 的另一台机器上,我达到了 0.8~1.0 GiB/s,这非常糟糕。


分析

性能分析表明,客户端进程经常使TCP缓冲区饱和并休眠一小段时间(20~40毫秒)等待服务器接收数据。下面是两个进程的调度示例(上面一个是服务器,中间一个是客户端,下面一个是内核线程,light-green部分是空闲时间):

可以看到,当客户端填充 Windows 调度程序的 missed-optimization TCP 缓冲区时,服务器不会立即被唤醒。事实上,调度程序可以在服务器饥饿之前唤醒客户端,从而减少延迟问题。请注意,non-negligible 部分时间花费在内核进程中,并且时间片与客户端匹配 activity。

总的来说,55% 的时间花在 ws2_32.dll 的 recv 函数上,10% 花在同一个 DLL 的 send 函数上,25% 花在同步函数上, 10% 用于其他功能,包括 CPython 解释器的功能。因此,修改后的基准测试 不会被 CPython 减慢。此外,同步并不是减速的主要原因

调度进程时,内存吞吐量从 16 GiB/s 上升到 34 GiB/s,平均约为 20 GiB/s,这是相当大的(特别是考虑到所花费的时间通过同步)。这意味着 Windows 执行大量临时缓冲区副本,尤其是在 recv 调用期间。

注意Xeon-based平台之所以慢肯定是因为处理器只顺序成功达到14GiB/s而i5-9600KF处理器达到24GiB/s顺序的。 Xeon 处理器也以较低的频率运行。对于主要关注 可扩展性 .

的 server-based 处理器来说,这样的事情很常见

对 ws2_32.dll 的更深入分析表明 recv 的几乎所有时间都花在晦涩的指令 call qword ptr [rip+0x3440f] 上,我猜这是一个内核调用,用于从中复制数据给用户一个内核缓冲区。同样的事情适用于 send。这意味着 副本不是在 user-land 中完成的,而是在 Windows 内核本身中完成的 ...

如果您想在 Windows 上的两个进程之间共享数据,我强烈建议您使用 共享内存 而不是套接字。一些消息传递库在此基础上提供了抽象(例如 ZeroMQ)。


注释

这里是评论中指出的一些注意事项:

如果增加缓冲区大小不会显着影响性能,那么这肯定意味着代码已经在目标机器上受到内存限制。例如,在 3 年旧 PC 上常见的 1 DDR4 内存通道 @ 2400 GHz,那么最大实际吞吐量约为 14 GiB/s,我预计套接字吞吐量明显小于 1 GiB/s.在具有基本的 1 通道 DDR3 的更旧的 PC 上,吞吐量甚至应该接近 500 MiB/s。速度应该受到类似 maxMemThroughput / K where K = (N+1) * P and where:

的限制
  • N是操作系统执行的拷贝数;
  • P 在具有 write-through 缓存策略的处理器或使用 non-temporal SIMD 指令的操作系统上等于 2,否则为 3。

Low-level 分析器显示 K ~= 8 在 Windows 上。他们还表明 send 执行了一个有效的复制,受益于 non-temporal 存储并使 RAM 吞吐量相当饱和,而 recv 似乎没有使用 non-temporal 存储,显然不会饱和RAM 吞吐量并执行比写入更多的读取(出于某些未知原因)。

在像最近的 AMD 处理器 (Zen) 或 multi-socket 系统这样的 NUMA 系统上,这应该更糟,因为 NUMA 节点的互连和饱和会减慢传输速度。 Windows 已知在这种情况下表现不佳。

据我所知,ZeroMQ 有多个后端(又名“Multi-Transport”),其中一个使用 TCP(默认)操作,而另一个使用共享内存操作。