Ray:评估代码性能

版权声明:本文为博主原创文章,未经博主允许不得转载。 https://blog.csdn.net/u011254180/article/details/82143016

本文档适用于想要了解如何在Ray上运行时评估其代码性能的Ray用户。分析代码的性能对于确定性能瓶颈或找出可能无法正确并行化的代码的位置非常有用。如果你有兴趣查明为什么你的Ray应用程序可能无法实现预期的加速,仔细阅读本文。

 

一个基础分析示例

尝试分析一个简单的例子,并比较编写简单循环的不同方式会如何影响性能。

作为计算密集且可能运行比较慢的函数的代表,我们将远程函数定义为只睡眠0.5秒:

import ray
import time
# Our time-consuming remote function
@ray.remote
def func():
    time.sleep(0.5)

在我们的示例中,我们希望将远程函数func()调用五次,并将每个调用的结果存储到列表中。 为了比较以不同方式循环调用我们的远程函数的性能,我们可以在driver脚本上将每个循环版本定义为单独函数。

 

对于第一个版本ex1,循环的每次迭代都会调用远程函数,然后调用ray.get以尝试将当前结果存储到列表中,如下所示:

# This loop is suboptimal in Ray, and should only be used for the sake of this example
def ex1():
    list1 = []
    for i in range(5):
        list1.append(ray.get(func.remote()))

 

对于第二个版本ex2,循环的每次迭代都会调用远程函数,并将其存储到列表中,而不是每次调用ray.get。循环结束后使用ray.get,准备处理func()的结果:

# This loop is more proper in Ray
def ex2():
    list2 = []
    for i in range(5):
        list2.append(func.remote())
    ray.get(list2)

 

最后,对于一个不可如此并行化的示例,创建第三个版本ex3,其中driver必须在每次调用远程函数func()之间调用本地函数:

# A local function executed on the driver, not on Ray
def other_func():
    time.sleep(0.3)
def ex3():
    list3 = []
    for i in range(5):
        other_func()
        list3.append(func.remote())
    ray.get(list3)

 

使用Python的时间戳测试时间性能

合理地检查三个循环函数性能的一种方法是简单地计算完成每个循环所需的时间。我们可以使用python的内置时间模块来完成这个任务。

time模块包含一个有用的time()函数,它在调用时以unix时间返回当前时间戳。我们可以创建一个通用的函数装饰器,在每个循环函数之前和之后调用time()来打印每个循环总体占用的时间:

# This is a generic wrapper for any driver function you want to time
def time_this(f):
    def timed_wrapper(*args, **kw):
        start_time = time.time()
        result = f(*args, **kw)
        end_time = time.time()

        # Time taken = end_time - start_time
        print('| func:%r args:[%r, %r] took: %2.4f seconds |' % \
              (f.__name__, args, kw, end_time - start_time))
        return result
    return timed_wrapper

为了每次调用循环函数ex1()时总是打印出循环运行了多长时间,我们可以用函数装饰器调用time_this包装器。这可以类似地对函数ex2()和ex3()进行:

@time_this  # Added decorator
def ex1():
    list1 = []
    for i in range(5):
        list1.append(ray.get(func.remote()))
def main():
    ray.init()
    ex1()
    ex2()
    ex3()
if __name__ == "__main__":
    main()

然后,运行这三个循环应该产生类似于这样的输出:

| func:'ex1' args:[(), {}] took: 2.5083 seconds |
| func:'ex2' args:[(), {}] took: 1.0032 seconds |
| func:'ex3' args:[(), {}] took: 2.0039 seconds |

 

开始解释这些结果。

这里,ex1()花费的时间比ex2()多得多,其中唯一的区别是ex1()在将远程函数添加到列表之前调用远程函数上的ray.get,而ex2()等待ray.get获取整个列表。

@ray.remote
def func(): # A single call takes 0.5 seconds
    time.sleep(0.5)
def ex1():  # Took Ray 2.5 seconds
    list1 = []
    for i in range(5):
        list1.append(ray.get(func.remote()))
def ex2():  # Took Ray 1 second
    list2 = []
    for i in range(5):
        list2.append(func.remote())
    ray.get(list2)

注意ex1()花了2.5秒,恰好五次0.5秒,或连续五次等待我们的远程函数所花费的时间。

通过在每次调用远程函数后调用ray.get,ex1()失去并行化工作的能力,强制驱动程序连续等待每个func()的结果。没有利用Ray并行化。

同时,ex2()大约需要1秒,比通常需要五次迭代调用func()快得多。Ray并行运行调用func(),节省了我们的时间。

 

ex1()实际上是Ray中常见的用户错误。在将func()的结果添加到列表之前,不需要执行ray.get。相反,驱动程序应该将所有可并行调用的远程函数调用发送给Ray,然后再等待通过ray.get接收结果。只需使用这种简单的时间测试就可以发现ex1()的次优行为。

 

但实际上,许多应用程序不像ex2()那样具有高度可并行性,并且有些应用程序包含代码必须以串行方式运行的部分。ex3()就是这样一个例子,其中本地函数other_func()必须首先运行,才能调用func()来提交给Ray。

# A local function that must run in serial
def other_func():
    time.sleep(0.3)
def ex3():  # Took Ray 2 seconds, vs. ex1 taking 2.5 seconds
    list3 = []
    for i in range(5):
        other_func()
        list2.append(func.remote())
    ray.get(list3)

结果是虽然ex3()与完全序列化的ex1()版本相比仍然获得了0.5秒的加速,但这种加速仍然远不及ex2()的理想加速。

 

ex2()的显着加速是可能的,因为ex2()在理论上是完全可并行化的:如果我们给了5个CPU,那么对func()的所有5个调用都可以并行运行。然而,ex3()发生的事情是每个对func()的并行调用都会被等待0.3秒,以便本地的other_func()完成。

 

ex3()因此是阿姆达尔定律的一种表现形式:从应用程序并行化的理论上,可行的最快执行时间仅限制于以串行方式运行所有串行部件所需的时间。

 

由于阿姆达尔定律,ex3()必须至少花1.5秒,完成对other_func()的5次串行调用所需的时间,再过0.5秒后执行func并得到结果,计算完成。

 

使用外部分析器(Line Profiler)进行分析

使用Ray分析代码性能的一种方法是使用第三方分析器,例如Line_profiler。Line_profiler是一个用于纯Python应用程序的逐行分析器,它将其输出与分析的代码本身并排格式化。

或者使用另一个第三方分析器(本文档中未涉及)Pyflame,它可以生成分析图。

首先使用pip安装line_profiler:

pip install line_profiler

 

line_profiler需要将分析的每个驱动程序代码段作为其自己的独立功能。方便的是,我们已经通过将每个循环函数定义为自己的函数来实现。告诉line_profiler要分析哪些函数,只需将@profile装饰器添加到ex1(),ex2()和ex3()。请注意,无需将line_profiler导入Ray应用程序:

@profile  # Added decorator
def ex1():
    list1 = []
    for i in range(5):
        list1.append(ray.get(func.remote()))
def main():
    ray.init()
    ex1()
    ex2()
    ex3()
if __name__ == "__main__":
    main()

然后,当我们想从命令行执行我们的Python脚本时,不是使用python your_script_here.py,而使用以下shell命令来运行启用line_profiler的脚本:

kernprof -l your_script_here.py

此命令运行脚本并像往常一样仅打印脚本的输出。Line_profiler将其分析结果输出到名为your_script_here.py.lprof的相应二进制文件。

 

要将line_profiler的结果读取到终端,请使用以下shell命令:

python -m line_profiler your_script_here.py.lprof

在循环示例中,此命令输出ex1()的结果,如下所示。请注意,执行时间以1e-06秒为单位:

返回结果中 具体 含义:

Total Time:测试代码的总运行时间

Line:代码行号 
Hits:表示每行代码运行的次数 
Time:每行代码运行的总时间 
Per Hits:每行代码运行一次的时间 
% Time:每行代码运行时间的百分比

请注意,第33行中每次命中list1.append(ray.get(func.remote()))都需要0.5秒等待func()完成。 同时,在下面的ex2()中,每次调用func.remote()在第40行只需要0.127毫秒,大部分时间(大约1秒)用于等待最后的ray.get():

最后,line_profiler的ex3()输出。在第50行每次调用func.remote()的速度仍然超过0.5秒,表明Ray成功并行远程调用。但是,对本地函数other_func()的每次调用都需要0.3秒,总计保证的最小应用程序执行时间为1.5秒。

 

使用Python的CProfile进行分析

分析Ray应用程序性能的第二种方法是使用Python的cProfile分析模块。 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.run()时,都会向终端输出一个cDrofile函数调用列表。在cProfile输出的最顶部给出'ex1()'的总执行时间:

以下是“ex1()”函数调用的分析片段。这些调用中的大多数都是快速的,大约需要0.000秒,因此值得注意的函数是执行时间非零的函数:

ncalls : 函数的被调用次数,如果这一列有两个值,就表示有递归调用,第二个值是原生调用次数,第一个值 
          是总调用次数。
tottime :函数总计运行时间,除去函数中调用的函数运行时间
percall :函数运行一次的平均时间,等于tottime/ncalls
cumtime :函数总计运行时间,含调用的函数运行时间
percall :函数运行一次的平均时间,等于cumtime/ncalls
filename:lineno(function) 函数所在的文件名,函数的行号,函数名

在worker.py:2535(get)中注意到5次单独调用Ray的get,每次调用执行0.502秒。同时,在remote_function.py:103(remote)上调用远程函数,在5次调用中只需要0.001秒,因此不是ex1()性能缓慢的来源。

 

使用cProfile分析Ray的Actors

考虑到cProfile的详细输出可能会根据我们使用的Ray功能而有很大不同,如果示例涉及Actors,cProfile的输出又如何。

现在,创建一个新的示例,并循环五次调用actor中的远程函数,而不是像ex1那样循环调用远程函数。actor的远程函数再次实现睡眠0.5秒:

# Our actor
@ray.remote
class Sleeper(object):
    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的部分输出如下:

事实证明,整个示例仍需要2.5秒执行,即五次调用actor_func()以串行方式运行的时间。 在ex1中这种行为是因为我们没有等到提交所有五个远程函数任务后才调用ray.get(),但是在cProfile的输出行worker.py:2535(get)上验证ray.get()最后只调用一次,持续2.509秒。

事实证明Ray无法并行化这个例子,因为我们只初始化了一个Sleeper actor。 因为每个actor都是一个有状态的worker,所以整个代码是提交并运行在一个单独的worker上。

为了更好地并行化ex4中的actor,可以创建五个Sleeper actor,每次调用独立的actor_func()。这样,创建了五个可以并行运行的worker,而不是创建一个只能一次处理一次actor_func()调用的worker。

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秒。

 

在Ray的时间轴中可视化任务

Ray带有自己的可视化Web UI,可视化用户提交给Ray的并行任务。

但是,这种方法确实有其局限性。Ray Timeline只能显示有关Ray任务的时序信息,而不能显示一般Python函数的时序。这是一个问题,特别是对于调试在驱动程序上运行比较慢的Python代码,而不是作为其中一个worker上的任务运行。上面的其他分析技术是涵盖分析一般Python函数的可选项。

目前,每当初始化Ray时,都会在终端中生成并打印URL。此URL可用于将Ray的Web UI视为Jupyter笔记本。

使用Jupyter前首先需要安装。

pip install jupyter ipywidgets bokeh

 

Ray的Web UI尝试在localhost的8888端口上运行,如果失败则尝试其余的连续端口,直到找到一个打开的端口。在上面的例子中,它已在端口8897上打开。

 

由于此Web UI仅在Ray应用程序当前正在运行时才可用,因此需要添加用户提示以防止Ray应用程序在完成执行后退出。如下所示,可以根据需要浏览Web UI:

def main():
    ray.init()
    ex1()
    ex2()
    ex3()

    # Require user input confirmation before exiting
    hang = input('Examples finished executing. Press enter to exit:')
if __name__ == "__main__":
    main()

现在,在执行python脚本时,可以通过将Web UI URL复制到Ray计算机上的Web浏览器来访问Ray时间轴。要在jupyter笔记本中加载Web UI,请在jupyter菜单中选择Kernel - > Restart and Run All。

如果出现错误“tracing/bin/trace_viewer_full.html is missing”,参考文档

 

通过使用任务过滤器选项,然后单击“View task timeline”按钮,可以在UI笔记本的第四个单元格中查看Ray时间轴。

例如,以下是执行Ray时间轴中可视化的ex1(),ex2()和ex3()的结果。每个红色块都是对用户定义的远程函数的调用,即func(),它睡眠0.5秒:

(为此示例添加了ex1(),ex2()和ex3()突出显示的颜色框)

注意ex1()在串行中执行对func()的所有五次调用,而ex2()和ex3()能够并行化它们的远程函数调用。

因为我们的机器上有4个CPU可用,所以只能并行执行多达4个远程功能。因此,对ex2()中的远程函数的第五次调用必须等到第一批func()调用完成。

在ex3()中,由于对other_func()的串行依赖,甚至无法使用所有4个内核来并行调用func()。 func()块之间的时间间隔是在等待other_func()函数0.3秒后对func()的调用的结果。

此外,请注意,由于上述Ray时间轴的限制,other_func()作为驱动程序函数而非Ray任务,从而未在Ray时间轴上可视化。

 

 

 

 

阅读更多
换一批

没有更多推荐了,返回首页