优化性能#
无加速#
您刚刚运行了一个使用 Ray 的应用程序,但它的速度不如您预期的。更糟糕的是,它可能比应用程序的串行版本还要慢!最常见的原因如下。
核心数: Ray 使用了多少个核心?当您启动 Ray 时,它会使用
psutil.cpu_count()来确定每台机器上的 CPU 数量。Ray 通常不会并行调度比 CPU 数量更多的任务。因此,如果 CPU 数量是 4,您应该期望的最大加速倍数是 4 倍。物理核心与逻辑核心: 您运行的机器上的 **物理** 核心数是否少于 **逻辑** 核心数?您可以使用
psutil.cpu_count()检查逻辑核心数,使用psutil.cpu_count(logical=False)检查物理核心数。这在许多机器上很常见,尤其是在 EC2 上。对于许多工作负载(尤其是数值工作负载),您通常不能期望获得比物理 CPU 数量更多的加速。小任务: 您的任务是否非常小?Ray 会为每个任务引入一些开销(开销的大小取决于传递的参数)。如果您的任务执行时间少于十毫秒,您不太可能看到加速。对于许多工作负载,您可以通过将它们批处理在一起来轻松增加任务的大小。
可变时长: 您的任务是否有可变时长?如果您并行运行 10 个具有可变时长的任务,您不应该期望 N 倍的加速(因为您最终会等待最慢的任务)。在这种情况下,请考虑使用
ray.wait来开始处理先完成的任务。多线程库: 您的所有任务是否都试图使用机器上的所有核心?如果是这样,它们很可能会遇到争用,并阻止您的应用程序实现加速。这在某些版本的
numpy中很常见。为避免争用,请设置一个环境变量,例如MKL_NUM_THREADS(或根据您的安装情况使用等效变量)为1。对于许多(但并非全部)库,您可以通过在应用程序运行时打开
top来诊断此问题。如果一个进程使用了大部分 CPU,而其他进程只使用少量 CPU,这可能是问题所在。最常见的例外是 PyTorch,它会显示正在使用所有核心,尽管需要调用torch.set_num_threads(1)来避免争用。
如果您仍然遇到速度下降的问题,但以上任何问题都不适用,我们非常希望了解!请创建一个 GitHub issue 并提交一个最小化的代码示例来演示该问题。
本文档讨论了人们在使用 Ray 时遇到的一些常见问题以及一些已知问题。如果您遇到其他问题,请 告诉我们。
使用 Ray Timeline 可视化任务#
请查看 如何使用 Dashboard 中的 Ray Timeline 以获取更多详细信息。
除了使用 Dashboard UI 下载跟踪文件外,您还可以通过从命令行运行 ray timeline 或从 Python API 运行 ray.timeline 将跟踪文件导出为 JSON 文件。
import ray
ray.init()
ray.timeline(filename="timeline.json")
Dashboard 中的 Python CPU 剖析#
Ray Dashboard 允许您通过单击活动工作进程、Actor 和作业的“Stack Trace”或“CPU Flame Graph”操作来剖析 Ray 工作进程。 (剖析 Ray 工作进程)
单击“Stack Trace”会返回使用 py-spy 获取的当前堆栈跟踪采样。默认情况下,只显示 Python 堆栈跟踪。要显示原生代码帧,请设置 URL 参数 native=1(仅在 Linux 上受支持)。
单击“CPU Flame Graph”会获取一系列堆栈跟踪采样,并将它们组合成一个火焰图可视化。此火焰图有助于理解特定进程的 CPU 活动。要调整火焰图的持续时间,您可以更改 URL 中的 duration 参数。同样,您可以更改 native 参数来启用原生剖析。
剖析功能需要安装 py-spy。如果未安装 py-spy,或者 py-spy 二进制文件没有 root 权限,Dashboard 会显示提示信息,指导您如何正确设置 py-spy。
This command requires `py-spy` to be installed with root permissions. You
can install `py-spy` and give it root permissions as follows:
$ pip install py-spy
$ sudo chown root:root `which py-spy`
$ sudo chmod u+s `which py-spy`
Alternatively, you can start Ray with passwordless sudo / root permissions.
注意
在使用 docker 容器中的 py-spy 时,您可能会遇到权限错误。要解决此问题:
如果您在 Docker 容器中手动启动 Ray,请遵循 py-spy 文档来解决。
如果您是 KubeRay 用户,请遵循 配置 KubeRay 的指南并解决。
使用 Python 的 cProfile 进行剖析#
您可以使用 Python 的原生 cProfile 剖析模块来剖析您的 Ray 应用程序的性能。cProfile 不仅跟踪应用程序代码的逐行执行,还可以列出每个循环函数的总运行时间,以及在被剖析代码中进行的所有函数调用的次数和执行时间。
与上面的 line_profiler 不同,这个详细的被剖析函数调用列表 **包括** 内部函数调用和 Ray 中进行的函数调用。
然而,与 line_profiler 类似,cProfile 可以通过对应用程序代码进行最小的更改来启用(前提是您要剖析的每个代码段都定义为自己的函数)。要使用 cProfile,请添加一个 import 语句,然后像这样替换对循环函数的调用
import cProfile # Added import statement
def ex1():
list1 = []
for i in range(5):
list1.append(ray.get(func.remote()))
def main():
ray.init()
cProfile.run('ex1()') # Modified call to ex1
cProfile.run('ex2()')
cProfile.run('ex3()')
if __name__ == "__main__":
main()
现在,当您执行 Python 脚本时,cProfile 会在终端打印每个对 cProfile.run() 的调用所产生的被剖析函数调用的列表。cProfile 输出的最顶部给出了 'ex1()' 的总执行时间。
601 function calls (595 primitive calls) in 2.509 seconds
下面是 'ex1()' 的被剖析函数调用的一个片段。其中大部分调用都很快,大约需要 0.000 秒,因此我们感兴趣的函数是那些执行时间非零的函数。
ncalls tottime percall cumtime percall filename:lineno(function)
...
1 0.000 0.000 2.509 2.509 your_script_here.py:31(ex1)
5 0.000 0.000 0.001 0.000 remote_function.py:103(remote)
5 0.000 0.000 0.001 0.000 remote_function.py:107(_remote)
...
10 0.000 0.000 0.000 0.000 worker.py:2459(__init__)
5 0.000 0.000 2.508 0.502 worker.py:2535(get)
5 0.000 0.000 0.000 0.000 worker.py:2695(get_global_worker)
10 0.000 0.000 2.507 0.251 worker.py:374(retrieve_and_deserialize)
5 0.000 0.000 2.508 0.502 worker.py:424(get_object)
5 0.000 0.000 0.000 0.000 worker.py:514(submit_task)
...
在 worker.py:2535(get) 处可以看到 Ray 的 get 函数的 5 次单独调用,每次调用花费了完整的 0.502 秒。与此同时,调用远程函数本身在 remote_function.py:103(remote) 处仅花费 0.001 秒(5 次调用),因此并不是 ex1() 性能缓慢的根源。
使用 cProfile 剖析 Ray Actor#
考虑到 cProfile 的详细输出可能因我们使用的 Ray 功能而异,让我们看看如果我们的示例涉及 Actor,cProfile 的输出可能是什么样的(有关 Ray Actor 的介绍,请参阅我们的 Actor 文档)。
现在,让我们创建一个新示例,并在 Actor 中循环调用远程函数,而不是像 ex1 中那样循环调用远程函数。我们的 Actor 的远程函数再次只休眠 0.5 秒。
# Our actor
@ray.remote
class Sleeper:
def __init__(self):
self.sleepValue = 0.5
# Equivalent to func(), but defined within an actor
def actor_func(self):
time.sleep(self.sleepValue)
回想一下 ex1 的次优性,让我们首先看看如果我们尝试在单个 Actor 中执行所有五个 actor_func() 调用会发生什么。
def ex4():
# This is suboptimal in Ray, and should only be used for the sake of this example
actor_example = Sleeper.remote()
five_results = []
for i in range(5):
five_results.append(actor_example.actor_func.remote())
# Wait until the end to call ray.get()
ray.get(five_results)
我们像这样为该示例启用 cProfile。
def main():
ray.init()
cProfile.run('ex4()')
if __name__ == "__main__":
main()
运行我们的新 Actor 示例,cProfile 的缩写输出如下。
12519 function calls (11956 primitive calls) in 2.525 seconds
ncalls tottime percall cumtime percall filename:lineno(function)
...
1 0.000 0.000 0.015 0.015 actor.py:546(remote)
1 0.000 0.000 0.015 0.015 actor.py:560(_remote)
1 0.000 0.000 0.000 0.000 actor.py:697(__init__)
...
1 0.000 0.000 2.525 2.525 your_script_here.py:63(ex4)
...
9 0.000 0.000 0.000 0.000 worker.py:2459(__init__)
1 0.000 0.000 2.509 2.509 worker.py:2535(get)
9 0.000 0.000 0.000 0.000 worker.py:2695(get_global_worker)
4 0.000 0.000 2.508 0.627 worker.py:374(retrieve_and_deserialize)
1 0.000 0.000 2.509 2.509 worker.py:424(get_object)
8 0.000 0.000 0.001 0.000 worker.py:514(submit_task)
...
结果发现整个示例仍然花费了 2.5 秒才执行完毕,即五个 actor_func() 调用串行运行的时间。如果您还记得 ex1,出现这种情况是因为我们在提交所有五个远程函数任务后才调用 ray.get(),但我们可以通过 cProfile 输出的 worker.py:2535(get) 行来验证 ray.get() 只在最后被调用了一次,花费了 2.509 秒。发生了什么?
结果是 Ray 无法并行化此示例,因为我们只初始化了一个 Sleeper Actor。由于每个 Actor 都是一个单一的、有状态的工作进程,我们的所有代码都在一个工作进程上提交和运行。
为了更好地并行化 ex4 中的 Actor,我们可以利用 actor_func() 的每次调用都是独立的这一事实,而是创建五个 Sleeper Actor。这样,我们就创建了五个可以并行运行的工作进程,而不是创建一个只能一次处理一个 actor_func() 调用的工作进程。
def ex4():
# Modified to create five separate Sleepers
five_actors = [Sleeper.remote() for i in range(5)]
# Each call to actor_func now goes to a different Sleeper
five_results = []
for actor_example in five_actors:
five_results.append(actor_example.actor_func.remote())
ray.get(five_results)
我们的示例总共现在只花费了 1.5 秒来运行。
1378 function calls (1363 primitive calls) in 1.567 seconds
ncalls tottime percall cumtime percall filename:lineno(function)
...
5 0.000 0.000 0.002 0.000 actor.py:546(remote)
5 0.000 0.000 0.002 0.000 actor.py:560(_remote)
5 0.000 0.000 0.000 0.000 actor.py:697(__init__)
...
1 0.000 0.000 1.566 1.566 your_script_here.py:71(ex4)
...
21 0.000 0.000 0.000 0.000 worker.py:2459(__init__)
1 0.000 0.000 1.564 1.564 worker.py:2535(get)
25 0.000 0.000 0.000 0.000 worker.py:2695(get_global_worker)
3 0.000 0.000 1.564 0.521 worker.py:374(retrieve_and_deserialize)
1 0.000 0.000 1.564 1.564 worker.py:424(get_object)
20 0.001 0.000 0.001 0.000 worker.py:514(submit_task)
...
使用 PyTorch Profiler 进行 GPU 剖析#
以下是在训练中使用 Ray Train 或进行批量推理时使用 PyTorch Profiler 的步骤。
请遵循 PyTorch Profiler 文档来记录 PyTorch 代码中的事件。
将您的 PyTorch 脚本转换为 Ray Train 训练脚本或 Ray Data 批量推理脚本。(您的剖析相关代码无需更改)
运行您的训练或批量推理脚本。
从所有节点收集剖析结果(与非分布式设置中的 1 个节点相比)。
您可能希望将每个节点上的结果上传到 NFS 或对象存储(如 S3),这样您就不必分别从每个节点获取结果。
使用 Tensorboard 等工具可视化结果。
使用 Nsight System Profiler 进行 GPU 剖析#
GPU 剖析对于 ML 训练和推理至关重要。Ray 允许用户使用 Ray Actor 和任务运行 Nsight System Profiler。 查看详情。
面向开发者的剖析#
如果您正在开发 Ray Core 或调试一些系统级故障,剖析 Ray Core 可能会有所帮助。在这种情况下,请参阅 面向 Ray 开发者的剖析。