启动 PSOCK 集群并不快。在仅使用几年前的笔记本电脑上使用 R 3.6,使用 8 个逻辑内核,运行 Windows,启动一个包含 8 个节点的集群大约需要 1.7 秒
library(parallel); system.time(cl <- makePSOCKcluster(8))
一个好的设计是在 R 会话期间仅启动一次集群,然后将其传递给可以利用它的计算。需要这样做,以便最终用户始终可以完全控制总共使用了多少个内核。在用户无法直接控制的情况下,在程序包代码中启动集群通常会通过使机器过载来导致大幅减速,从而导致性能远比顺序执行差。
因此,1.7 秒可能看起来可以接受,但如果我们在具有许多内核的服务器机器上启动一个更大的集群,每个逻辑内核一个节点,则启动时间会变得非常长。在最近的具有 64 个逻辑内核的 Fedora 服务器上,大约需要 14 秒。在具有 64 个逻辑内核的旧 Solaris 服务器上,大约需要 211 秒!
在 R-devel 中,我们扩展了套接字 API 并重新设计了 PSOCK 集群的启动。上面提到的 Windows 笔记本电脑现在在 R-devel 中以 0.5 秒启动集群。其他几台具有更多内核的机器的时序为
R 3.6 | R-devel | |
---|---|---|
Fedora 服务器(64 个内核) | 14 秒 | 0.4 秒 |
Ubuntu 服务器(40 个内核) | 6.6 秒 | 0.4 秒 |
Windows 服务器(48 个内核) | 9.3 秒 | 0.5 秒 |
Solaris 服务器(64 个内核) | 211 秒 | 7 秒 |
macOS 台式机(12 个内核) | 4.2 秒 | 0.7 秒 |
当内核数量多但各个内核速度慢时,加速幅度很大。
兼容性
本帖的其余部分将详细描述如何实现这些性能改进。套接字层改进不会更改现有 API 的已记录行为,但会更改可观察到的行为(有时会在以前没有超时时强制执行超时,在连接的服务器端会尊重 blocking = FALSE
)。通过比较程序包检查结果来测试 R 更改的通常方式在这里帮助不大,因为 CRAN 策略将检查的内核数量限制为 2(以防止检查机器过载)。因此,邀请并鼓励依赖并行化/套接字通信的用户在大型系统上运行他们拥有的任何测试,并报告任何新错误。
在 R 3.6 中启动 PSOCK 集群
R 3.6 及更早版本按顺序启动集群。对于每个节点,服务器都会发出一个 system()
命令来启动节点,然后通过 socketConnection(server = TRUE)
等待节点连接,然后对第二个节点执行相同操作,依此类推。启动集群几乎所有时间都花在启动所有 R 会话上。
尽管在服务器上很简单,但在节点中并不简单:当一个节点启动时,它会尝试通过 socketConnection(server = FALSE)
连接到服务器,但连接可能会由于竞争条件而失败:操作系统可能会决定在服务器中的 socketConnection
之前在节点中运行 socketConnection
,然后连接将失败。因此,即使在 R 3.6 集群设置中,节点在发生故障时也必须重试,并以指数退避的方式进行。
没有办法避免这种竞争条件,因为 socketConnection(server = TRUE)
执行所有三个服务器套接字操作:bind()
、listen()
和 accept()
。它总是创建一个临时服务器套接字并将其绑定,使其处于侦听状态,通过 accept()
等待连接,然后销毁服务器套接字。在服务器上给定端口上没有侦听服务器套接字的时间间隔内,来自节点的连接将失败。
在实践中,这工作得很好,因为在典型的操作系统上,服务器将很快被调度,并且重试次数将很少。但是,如果我们并行启动节点,那么重试次数可能会随着节点数的增加而急剧增加,这将损害性能。
服务器套接字连接
相反,我们扩展了 R 连接 API,以便可以直接使用服务器套接字连接,重新使用它们来接受多个套接字连接
serverSocket(port)
socketAccept(socket, blocking = FALSE, open = "a+",
encoding = getOption("encoding"),
timeout = getOption("timeout"))
serverSocket
创建一个侦听服务器套接字连接,该连接属于一个新类 "servsockconn"
。socketAccept
接受到给定服务器套接字的传入连接。socketSelect
可用于侦听服务器套接字,以等待连接准备好被接受。服务器套接字连接可以通过 close
照常关闭。
侦听积压
操作系统有一个部分和已建立的传入连接队列。已建立的连接已准备好通过 accept()
(socketAccept()
) 提供给应用程序。此队列的长度有限,可以通过 listen()
的参数对其施加影响,但这只是一个提示,无法通过编程方式找出队列的实际大小。操作系统可能会施加限制并缩短该队列。我们已经修改了 R-devel 以使用每个系统支持的最大长度,但不能保证它足以满足所有节点。因此,当队列太短时,集群设置需要表现得正确。
TCP 在建立连接时使用 3 次握手。在队列未满的正常成功情况下,节点发送 SYN
数据包,服务器将连接放入队列并使用 SYN+ACK
响应,然后节点通过 connect()
/socketConnection(server=FALSE)
将连接作为已建立的连接提供给应用程序,并向服务器发送 ACK
。然后,服务器将连接标记为已建立(某些系统会将其放入不同的队列)并将其提供给应用程序(通过 accept()
/socketAccept()
)。
当队列已满时,可能会发生多种情况。Linux 实际上有两个队列,一个用于已建立的连接,另一个用于部分建立的连接。只有已建立队列的大小受 listen()
的 backlog 参数影响,当该队列已满时,Linux 已降低将连接添加到部分建立队列的速率。它可能只是丢弃一个 SYN
数据包,而不将传入连接放入部分建立队列。节点上的 TCP 层将重试几次,重新发送 SYN
,但最终放弃,发送 RST
(重置),连接将失败。因此,即使使用新的服务器套接字 API,也必须在节点中保留失败后的重试代码。
此外,即使在 Linux 上,也可能仍然发生以下情况:服务器收到来自节点的 ACK
,但已建立队列已满。然后,服务器可能会向节点发送 RST
,并且节点将失败,因为它已阻塞,等待服务器在它认为已建立的连接上发送命令。当服务器丢弃 ACK
数据包但将连接保留在部分建立队列中时,也会出现类似的情况。然后,它可能会在超时后向节点重新发送 SYN+ACK
,节点重新发送其 ACK
,最终连接可能在服务器上真正成功,或者服务器可能会发送 RST
并将其从部分建立队列中移除。有关 Linux 如何实现 backlog 的更多信息,请参阅 此处。
另一个复杂之处在于服务器可能会丢弃来自节点的 ACK
,并将连接从部分建立队列中移除。根据经验,我们在压力测试期间看到了这种情况,当时来自客户端的连接已旧,并且出现次数随着连接的增加而增加,因此这可能是超时后发生的。因此,节点不会收到 RST
,并且将无限期地等待服务器的命令。
这种情况称为半打开连接,在 TCP 通信中可能以多种方式出现。如果节点开始通过连接进行通信,则 TCP 层将解决此问题,但在 PSOCK 协议中,服务器是开始通信的一方,因此需要专门处理此问题。
服务器发起的握手
因此,我们更改了集群设置过程,以便服务器在从节点获得连接后立即向节点发送初始命令作为握手。节点在设置阶段等待此类初始命令,如果在一段时间内未收到命令(服务器上关闭的半打开连接)或等待失败(从服务器收到RST
),则节点会重试建立与服务器的连接并等待新的握手。这不会更改网络协议:握手只是一个常规命令,节点会运行它并发送响应。
连接超时
R 3.6 套接字 API 允许在创建时为套接字连接定义超时。然后,超时会影响套接字上的大多数操作(单独应用于低级操作,但 R 级别的函数总共可能等待更长时间)。PSOCK 集群使用 30 天的连接超时:如果在该时间内没有来自服务器(例如服务器崩溃或连接丢失)的命令发送到节点,则节点会自行退出。对于集群设置期间的握手,我们需要更短的超时。因此,我们扩展了 API 以允许修改套接字连接的超时
socketTimeout(socket, timeout = -1)
此新函数允许在握手期间和节点上的socketConnection
调用中使用较短的超时,而这在以前实际上是阻塞的(在 Linux 上由于 select() 错误而阻塞,在其他系统上超时为 30 天)。在握手之后,将调用socketTimeout
以再次将超时增加到 30 天。
服务器发起的握手除了通过(超时)帮助仅在客户端打开的半打开连接之外,还消除了仅在服务器上打开的任何半打开连接。TCP 层将检测到这些连接并在服务器开始通信时失败。在我们的初始压力测试期间,我们也观察到了这些情况。
并行 PSOCK 集群设置
在 R-devel 中,当所有节点都在localhost
上运行时,对于所有节点自动启动的同构集群,默认情况下使用新的并行集群设置。对于其他情况,仍然支持原始顺序启动代码。有一个新的集群选项setup_strategy
,其值包括"parallel"
和"sequential"
。"parallel"
是默认值,它告诉 R 在所有支持的集群上使用并行策略。
R 套接字层改进
作为这项工作的一部分,修复了 R 套接字层实现的几个问题。
现在在 Linux 上对 socketConnection(server = FALSE)
的连接超时进行了强制执行。之前,由于 select()
的 Linux 特定行为,该调用意外地被阻塞。
当连接失败时,现在 socketConnection(server = FALSE)
会在 Windows 上立即返回。之前,由于在等待连接时 select()
的 Windows 特定行为,必须等待超时到期。
现在 socketConnection(server = FALSE)
会检测到何时连接立即可用而不必等待(可能不太可能,并且仅在 localhost
上可能),然后返回该连接。之前,R 会无论如何等待并尝试再次连接,可能泄露连接。
现在 socketConnection(server = TRUE)
(和 socketAccept()
)强制执行连接超时。之前,由于当连接似乎可被 select()
接受,但当调用 accept()
时被客户端重新设置,从而导致竞争条件,它们可能会无限期地阻塞。在某些系统上,accept()
随后会阻塞。在其他系统上(我们在 Solaris 上触发了这种情况),accept()
随后会失败;在更改之后,R 将继续等待良好连接,同时遵守超时。
现在套接字的读取操作对套接字的虚假可读性具有鲁棒性(select()
将其报告为可读,但随后例如由于无效的校验和 recv()
会阻塞)。此问题可能发生在 Linux 上。
现在套接字的写入操作对套接字的虚假可写性具有鲁棒性。之前,此情况可能导致不可预测的行为。但是,据我们所知,尚未在任何系统上报告过此类 select()
错误。
现在套接字连接(socketConnection()
)上的 blocking = FALSE
参数在套接字的服务器端也受到尊重。之前,即使使用 blocking = FALSE
,套接字服务器端的读/写操作也会意外地阻塞。
已针对 WinSock2 更新了 Windows 上套接字操作的状态代码的内部处理。