Joblib是用于高效并行计算的Python开源库,其提供了简单易用的内存映射和并行计算的工具,以将任务分发到多个工作进程中。Joblib库特别适合用于需要进行重复计算或大规模数据处理的任务。Joblib库的官方仓库见: joblib,官方文档见: joblib-doc。
Jolib库安装代码如下:
pip install joblib
1 使用说明
Joblib库主要功能涵盖以下三大块:
- 记忆模式:Memory类将函数的返回值缓存到磁盘。下次调用时,如果输入参数不变,就直接从缓存中加载结果,避免重复计算。
- 并行计算:Parallel类将任务拆分到多个进程或者线程中并行执行,加速计算过程。
- 高效的序列化:针对NumPy数组等大型数据对象进行了优化,且序列化和反序列化速度快。
1.1 Memory类
Joblib库的Memory类支持通过记忆模式,将函数的计算结果存储起来,以便在下次使用时直接调用。这种机制的优势在于加速计算过程、节约资源以及简化管理。
Memory类构造函数如下:
参数介绍如下:
- location: 缓存文件的存放位置。如果设置为 None,则不缓存。
- backend: 缓存的后端存储方式。默认是 “local”,表示使用本地文件系统。
- mmap_mode: 一个字符串,表示内存映射文件的模式(None, ‘r+’, ‘r’, ‘w+’, ‘c’)。
- compress: 表示是否压缩缓存文件。压缩可以节省磁盘空间,但会增加 I/O 操作的时间。
- verbose: 一个整数,表示日志的详细程度。0 表示没有输出,1 表示只输出警告,2 表示输出信息,3 表示输出调试信息。
- bytes_limit: 一个整数或 None,表示缓存使用的字节数限制。如果缓存超过了这个限制,最旧的缓存文件将被删除。
- backend_options: 传递给缓存后端的选项。
Memory类简单使用
下面代码展示第一次调用函数并缓存结果:
第二次调用函数:
调用其他函数:
将Memory类应用于numpy数组
直接计算和缓存结果是等同的:
直接调用缓存结果
类中使用缓存
Memory类不建议将其直接用于类方法。如果想在类中使用缓存,建议的模式是在类中使用单独定义的缓存函数,如下所示:
1.2 Parallel类
Joblib库的Parallel类用于简单快速将任务分解为多个子任务,并分配到不同的CPU核心或机器上执行,从而显著提高程序的运行效率。
Parallel类构造函数及主要参数如下:
参数介绍如下:
- n_jobs: 指定并行任务的数量,为-1时表示使用所有可用的CPU核心;为None时表示使用单个进程。
- backend:指定并行化的后端,可选项:
- ‘loky’:使用 loky库实现多进程,该库由joblib开发者开发,默认选项。
- ‘threading’:使用threading库实现多线程。
- ‘multiprocessing’:使用multiprocessing库实现多进程。
- return_as:返回结果格式,可选项:
- 'list:列表。
- generator:按照任务提交顺序生成结果的生成器。
- generator_unordered:按照执行结果完成先后顺序的生成器。
- verbose: 一个整数,表示日志的详细程度。0 表示没有输出,1 表示只输出警告,2 表示输出信息,3 表示输出调试信息。
- timeout:单个任务最大运行时长,超时将引发TimeOutError。仅适用于n_jobs不为1的情况。
- batch_size:当Parallel类执行任务时,会将任务分批处理。batch_size参数决定了每个批次中包含的任务数。
- pre_dispatch: 用来决定在并行计算开始之前,每个批次有多少个任务会被预先准备好并等待被分配给单个工作进程。默认值为“2*n_jobs”,表示并行计算时可以使用2倍工作进程的任务数量。
- temp_folder:指定临时文件的存储路径。
- max_nbytes:传递给工作程序的数组大小的阈值。
- require:对运行任务的要求,可选None和sharedmem。sharedmem表示将使用共享内存来执行并行任务,但会影响计算性能。
简单示例
以下代码展示了单线程直接运行计算密集型任务结果:
以下代码展示利用Parallel类创建多进程运行计算密集型任务结果:
可以看到joblib库利用多进程技术显著提高了任务执行的效率。然而,当面对I/O密集型任务或执行时间极短的任务时,多线程或多进程的优势可能并不明显。这是因为线程创建和上下文切换的开销有时可能超过任务本身的执行时间。以上述的compute_heavy_task函数为例,如果移除了其中的time.sleep函数,多进程执行所需的时间将会显著增加。
此外获取当前系统的cpu核心数(逻辑处理器)代码如下:
不同并行方式对比
以下代码展示了不同并行方式在Parallel类中的应用。默认使用loky多进程:
以下代码展示了threading多线程的使用,注意由于Python的全局解释器锁(GIL)确保在任何时刻只有一个线程执行Python字节码。这表明即使在多核处理器上,Python的线程也无法实现真正的并行计算。然而,当涉及到处理I/O密集型任务或需要快速响应的小规模任务时,多线程依然具有优势:
以下代码展示了multiprocessing多进程的使用,注意Windows下需要将multiprocessing相关代码放在main函数中:
以下是loky
、threading
和 multiprocessing
的一些关键特性对比:
特性/库 | loky | threading | multiprocessing |
---|---|---|---|
适用平台 | 跨平台 | 跨平台 | 跨平台,但Windows上存在限制 |
进程/线程模型 | 进程 | 线程 | 进程 |
GIL影响 | 无 | 有 | 无 |
适用场景 | CPU密集型任务 | I/O密集型任务 | CPU密集型任务 |
启动开销 | 较小 | 较小 | 较大 |
内存使用 | 较高 | 较低 | 较高 |
进程间通信 | 通过管道、队列等 | 通过共享数据结构 | 通过管道、队列等 |
线程间通信 | 共享数据结构 | 共享数据结构 | 不适用 |
异常处理 | 进程间独立 | 线程间共享 | 进程间独立 |
调试难度 | 较高 | 较低 | 较高 |
适用框架 | 通用 | 通用 | 通用 |
Python中线程和进程简单对比如下:
- 资源共享:线程共享同一进程的内存和资源,而进程拥有独立的内存空间。
- GIL影响:线程受GIL限制,进程不受GIL限制。
- 开销:线程的创建和切换开销小,进程的创建和切换开销大。
- 适用性:线程适合I/O密集型任务,进程适合CPU密集型任务。
- 通信:线程间通信简单但需要处理同步问题,进程间通信复杂但天然隔离。
在实际应用中,选择使用线程还是进程取决于任务的特性和性能需求。如果任务主要是I/O密集型,使用线程可以提高性能;如果任务是CPU密集型,使用进程可以更好地利用多核处理器的计算能力。
共享内存
默认情况下,Parallel类执行任务时各个任务不共享内存,如下所示:
通过设置require='sharedmem’可以实现内存共享:
上下文管理器
一些算法需要对一个并行函数进行多次连续调用,但在循环中多次调用joblib.Parallel是次优的,因为这将多次创建和销毁一组工作进程,从而导致显著的性能开销。
对于这种情况,使用joblib.Parallel类的上下文管理器API更为高效,可以重用同一组工作进程进行多次调用joblib.Parallel对象。如下所示:
parallel_config
Joblib提供parallel_config类用于配置并行执行的参数,比如并行的后端类型、批处理大小等,这些配置可以影响后续所有的parallel实例。它通常在调用Parallel类之前使用。关于parallel_config使用见: parallel_config。
1.3 序列化
joblib.dump()和joblib.load()提供了一种替代pickle库的方法,可以高效地序列化处理包含大量数据的任意Python对象,特别是大型的NumPy数组。关于pickle库使用见: Python数据序列化模块pickle使用笔记 。两者效果对比见:
特点 | pickle | joblib |
---|---|---|
性能 | 一般 | 针对NumPy数组等大数据类型有优化,通常更快 |
并行处理 | 不支持 | 内置并行处理功能,可以加速任务 |
内存映射 | 不支持 | 支持内存映射,可以高效处理大文件 |
压缩 | 支持 | 支持压缩,可以减少存储空间 |
附加功能 | 少 | 提供了一些额外的功能,如缓存、延迟加载等 |
以下代码展示了joblib.dump的基本使用:
使用joblib.load函数从指定的文件中加载之前保存的序列化数据:
joblib.dump和joblib.load函数还接受文件对象:
此外joblib.dump也支持设置compress参数以实现数据压缩:
默认情况下,joblib.dump使用zlib压缩方法,因为它在速度和磁盘空间之间实现了最佳平衡。其他支持的压缩方法包括“gzip”、“bz2”、“lzma”和“xz”。compress参数输入带有压缩方法和压缩级别就可以选择不同压缩方法:
除了默认压缩方法,lz4压缩算法也可以用于数据压缩。前提是需要安装lz4压缩库:
pip install lz4
在这些压缩方法中,lz4和默认方法效果较好。lz4使用方式与其他压缩方式一样:
2 实例
2.1 joblib缓存和并行
本实例展示了利用joblib缓存和并行来加速任务执行。以下代码展示了一个高耗时任务:
下段代码演示了如何使用joblib库来缓存和并行化计算上述任务:
再次执行相同的过程,可以看到结果被缓存而不是重新执行函数:
2.2 序列化
以下示例展示了在joblib.Parallel中使用序列化内存映射(numpy.memmap)。内存映射可以将大型数据集分割成小块,并在需要时将其加载到内存中。这种方法可以减少内存使用,并提高处理速度。
定义耗时函数:
以下代码是直接调用函数的运行结果:
以下代码是调用Parallel类2个进程运行的结果,由于整体任务计算耗时较少。所以Parallel类并行计算并没有比直接调用函数有太多速度优势,因为进程启动销毁需要额外时间:
以下代码提供了joblib.dump和load函数加速数据读取。其中dump函数用于将data对象序列化并保存到磁盘上的文件中,同时创建了一个内存映射,使得该文件可以像内存数组一样被访问。当程序再次加载这个文件时,可以使用load函数以内存映射模式打开:
2.3 内存监视
本实例展示不同并行方式的内存消耗情况。
创建内存监视器
并行任务
结果返回list的并行任务:
如果改为输出生成器,那么内存使用量将会大大减少:
下图展示了以上两种方法的内存消耗情况,第一种情况涉及到将所有结果存储在内存中,直到处理完成,这可能导致内存使用量随着时间线性增长。而第二种情况generator则涉及到流式处理,即结果被实时处理,因此不需要同时在内存中存储所有结果,从而减少了内存使用的需求:
进一步节省内存
前一个例子中的生成器是保持任务提交的顺序的。如果某些进程任务提交晚,但比其他任务更早完成。相应的结果会保持在内存中,以等待其他任务完成。如果任务对结果返回顺序无要求,例如最后只是对所有结果求和,可以使用generator_unordered减少内存消耗。如下所示:
返回为generator格式的内存使用:
返回为generator_unordered格式的内存使用:
内存使用结果对比如下。基于generator_unordered选项在执行任务时,能够独立地处理每个任务,而不需要依赖于其他任务的完成状态。但是要注意的是由于系统负载、后端实现等多种可能影响任务执行顺序的因素,结果的返回顺序是不确定的: