优化性能#

无加速#

您刚刚运行了一个使用 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 工作进程)

../../../_images/profile.png

单击“Stack Trace”会返回使用 py-spy 获取的当前堆栈跟踪采样。默认情况下,只显示 Python 堆栈跟踪。要显示原生代码帧,请设置 URL 参数 native=1(仅在 Linux 上受支持)。

../../../_images/stack.png

单击“CPU Flame Graph”会获取一系列堆栈跟踪采样,并将它们组合成一个火焰图可视化。此火焰图有助于理解特定进程的 CPU 活动。要调整火焰图的持续时间,您可以更改 URL 中的 duration 参数。同样,您可以更改 native 参数来启用原生剖析。

../../../_images/flamegraph.png

剖析功能需要安装 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 时,您可能会遇到权限错误。要解决此问题:

使用 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 开发者的剖析