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(默认)操作,而另一个使用共享内存操作。
在 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(默认)操作,而另一个使用共享内存操作。