Python解压缩的相对性能?

12

简而言之:在Python中可用的各种压缩算法,包括gzipbz2lzma等,哪一个具有最佳的解压缩性能?

完整讨论:

Python 3中有各种模块可用于压缩/解压缩数据 ,包括gzipbz2lzma。此外,gzipbz2还有不同的压缩级别可供设置。

如果我要平衡文件大小(/压缩比)和解压缩速度(不关心压缩速度),哪个选择会是最好的?解压缩速度比文件大小更重要,但因为所涉及到的未压缩文件每个约为600-800MB(32位RGB .png图像文件),而且我有十几个这样的文件,所以我确实需要一些压缩。

我的使用案例是从磁盘加载十几张图片,对它们进行一些处理(作为numpy数组),然后在程序中使用处理后的数组数据。这些图片永远不会改变,我每次运行程序时只需要加载它们。处理时间与加载时间大约相同(几秒钟),因此我正在尝试通过保存处理后的数据(使用pickle)而不是每次加载原始未经处理的图像来节省一些加载时间。最初的测试结果很有希望——加载原始/未压缩的pickled数据只需不到一秒钟,而加载和处理原始图像需要3或4秒钟——但文件大小约为600-800MB,而原始png图像仅约5MB。因此,我希望能够在加载时间和文件大小之间取得平衡,通过以压缩格式存储选定数据来实现。
更新:实际上,情况比我上面描述的要复杂一些。我的应用程序使用PySide2,因此我可以访问Qt库。如果我使用pillow(PIL.Image)读取图像并将其转换为numpy数组,则实际上不需要进行任何处理,但将图像读入数组的总时间约为4秒。如果我改为使用QImage读取图像,则必须对结果进行一些处理,以使其可用于我的程序的其余部分,由于QImage加载数据的字节顺序问题,基本上我必须交换位顺序,然后旋转每个“像素”,使α通道(显然由QImage添加)最后而不是最前。整个过程大约需要3.8秒,因此比仅使用PIL略快。如果我以未压缩的方式保存numpy数组,则可以在0.8秒内重新加载它们,因此速度最快,但文件大小较大。
┌────────────┬────────────────────────┬───────────────┬─────────────┐
│ Python Ver │     Library/Method     │ Read/unpack + │ Compression │
│            │                        │ Decompress (s)│    Ratio    │
├────────────┼────────────────────────┼───────────────┼─────────────┤
│ 3.7.2      │ pillow (PIL.Image)     │ 4.0           │ ~0.006      │
│ 3.7.2      │ Qt (QImage)            │ 3.8           │ ~0.006      │
│ 3.7.2      │ numpy (uncompressed)   │ 0.8           │ 1.0         │
│ 3.7.2      │ gzip (compresslevel=9) │ ?             │ ?           │
│ 3.7.2      │ gzip (compresslevel=?) │ ?             │ ?           │
│ 3.7.2      │ bz2 (compresslevel=9)  │ ?             │ ?           │
│ 3.7.2      │ bz2 (compresslevel=?)  │ ?             │ ?           │
│ 3.7.2      │ lzma                   │ ?             │ ?           │
├────────────┼────────────────────────┼───────────────┼─────────────┤
│ 3.7.3      │ ?                      │ ?             │ ?           │  
├────────────┼────────────────────────┼───────────────┼─────────────┤
│ 3.8beta1   │ ?                      │ ?             │ ?           │
├────────────┼────────────────────────┼───────────────┼─────────────┤
│ 3.8.0final │ ?                      │ ?             │ ?           │
├────────────┼────────────────────────┼───────────────┼─────────────┤
│ 3.5.7      │ ?                      │ ?             │ ?           │
├────────────┼────────────────────────┼───────────────┼─────────────┤
│ 3.6.10     │ ?                      │ ?             │ ?           │
└────────────┴────────────────────────┴───────────────┴─────────────┘

示例.png图像:这个大小为5.0Mb的png图像,是阿拉斯加海岸线的相当高分辨率图像为例。

用于png/PIL情况的代码(加载到numpy数组中):

from PIL import Image
import time
import numpy

start = time.time()
FILE = '/path/to/file/AlaskaCoast.png'
Image.MAX_IMAGE_PIXELS = None
img = Image.open(FILE)
arr = numpy.array(img)
print("Loaded in", time.time()-start)

这个加载过程在我的Python 3.7.2机器上大约需要4.2秒。

或者,我可以加载由上面创建的数组生成的未压缩pickle文件。

未压缩pickle加载案例的代码:

import pickle
import time

start = time.time()    
with open('/tmp/test_file.pickle','rb') as picklefile:
  arr = pickle.load(picklefile)    
print("Loaded in", time.time()-start)

在我的机器上,从这个未压缩的pickle文件加载需要大约0.8秒。


评论不适合进行长时间的讨论;此对话已被移至聊天室 - Samuel Liew
您的示例图像没有 alpha 通道 - 它是 RGB 而不是您建议的 RGBA。 - Mark Setchell
@MarkSetchell 是的,但是当我使用QImage加载它时,QImage会添加一个alpha通道。显然,这可能会使问题更加复杂 - PIL不会这样做。 - ibrewster
4个回答

9

容易实现的目标

numpy.savez_compressed('AlaskaCoast.npz', arr)
arr = numpy.load('AlaskaCoast.npz')['arr_0']

加载速度比您基于PIL的代码快2.3倍。

它使用zipfile.ZIP_DEFLATED,请参见savez_compressed文档。

您的PIL代码还有一个不必要的复制:array(img) 应该改为 asarray(img)。这只会花费5%的加载时间。但是经过优化后,这将是非常显著的,您必须记住哪些numpy运算符会创建副本。

快速解压缩

根据zstd基准测试,在优化解压缩时,lz4是一个不错的选择。只需将其插入到pickle中,即可获得另外2.4倍的增益,并且仅比未压缩的pickling慢30%。

import pickle
import lz4.frame

# with lz4.frame.open('AlaskaCoast.lz4', 'wb') as f:
#     pickle.dump(arr, f)

with lz4.frame.open('AlaskaCoast.lz4', 'rb') as f:
    arr = pickle.load(f)

基准测试

method                 size   load time
------                 ----   ---------
original (PNG+PIL)     5.1M   7.1
np.load (compressed)   6.7M   3.1
pickle + lz4           7.1M   1.3
pickle (uncompressed)  601M   1.0 (baseline)

加载时间是在我的桌面上,使用 Python(3.7.3)内部进行测量的,使用 20 次运行中的最小挂钟时间。根据偶尔查看 top 的情况,似乎总是在单个核心上运行。

对于好奇的人:分析

我不确定 Python 版本是否重要,大多数工作都应该在 C 库内部完成。为了验证这一点,我已经对 pickle + lz4 变体进行了分析:

perf record ./test.py && perf report -s dso
Overhead  Shared Object
  60.16%  [kernel.kallsyms]  # mostly page_fault and alloc_pages_vma
  27.53%  libc-2.28.so       # mainly memmove
   9.75%  liblz4.so.1.8.3    # only LZ4_decompress_*
   2.33%  python3.7
   ...

大部分时间都花在了 Linux 内核中,执行page_fault(页面错误)和与(重新)分配内存相关的操作,可能还包括磁盘 I/O。高数量的memmove看起来很可疑。可能是 Python 每次接收到一个新的解压块时都会重新分配(调整)最终数组的大小。如果有人想更仔细地查看:Python 和性能分析


好的。这是针对2.7.3还是2.7.2?(请编辑您的答案来说明)。您可能会喜欢复制我在OP问题中提供的表格格式(其中有版本列)。我们知道性能数字会随版本而变化,因此将该问题保持“实时”状态将是很不错的。 - smci
我有点过头了,加了一个性能分析部分。 - maxy
我还没有测试过lz4库,但是使用lz4的Python-Blosc应该非常相似。我猜你可以通过将元数据(数组形状、dtype)与实际数据分开处理来获得更好的性能,这些数据都在数组对象中。通常情况下,将某些东西进行Pickling会很慢(正如你所观察到的那样,需要大量的memmove或memcopy)。 - max9111
lz4库非常好,我真的很喜欢这个答案。然而,事实证明,blosc速度指数级增长,至少在我的机器上,加载时间在子100毫秒范围内,因此它获得了认可。 - ibrewster

8

您可以使用Python-blosc

它非常快速,对于小数组(<2GB),使用起来也相当容易。对于像您的示例这样易于压缩的数据,压缩数据以进行IO操作通常更快。(SATA-SSD:约500 MB / s,PCIe-SSD:高达3500MB / s)在解压缩步骤中,数组分配是最昂贵的部分。如果您的图像形状类似,则可以避免重复的内存分配。

示例

以下示例假定为连续数组。

import blosc
import pickle

def compress(arr,Path):
    #c = blosc.compress_ptr(arr.__array_interface__['data'][0], arr.size, arr.dtype.itemsize, clevel=3,cname='lz4',shuffle=blosc.SHUFFLE)
    c = blosc.compress_ptr(arr.__array_interface__['data'][0], arr.size, arr.dtype.itemsize, clevel=3,cname='zstd',shuffle=blosc.SHUFFLE)
    f=open(Path,"wb")
    pickle.dump((arr.shape, arr.dtype),f)
    f.write(c)
    f.close()
    return c,arr.shape, arr.dtype

def decompress(Path):
    f=open(Path,"rb")
    shape,dtype=pickle.load(f)
    c=f.read()
    #array allocation takes most of the time
    arr=np.empty(shape,dtype)
    blosc.decompress_ptr(c, arr.__array_interface__['data'][0])
    return arr

#Pass a preallocated array if you have many similar images
def decompress_pre(Path,arr):
    f=open(Path,"rb")
    shape,dtype=pickle.load(f)
    c=f.read()
    #array allocation takes most of the time
    blosc.decompress_ptr(c, arr.__array_interface__['data'][0])
    return arr

基准测试

#blosc.SHUFFLE, cname='zstd' -> 4728KB,  
%timeit compress(arr,"Test.dat")
1.03 s ± 12.5 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)
#611 MB/s
%timeit decompress("Test.dat")
146 ms ± 481 µs per loop (mean ± std. dev. of 7 runs, 10 loops each)
#4310 MB/s
%timeit decompress_pre("Test.dat",arr)
50.9 ms ± 438 µs per loop (mean ± std. dev. of 7 runs, 10 loops each)
#12362 MB/s

#blosc.SHUFFLE, cname='lz4' -> 9118KB, 
%timeit compress(arr,"Test.dat")
32.1 ms ± 437 µs per loop (mean ± std. dev. of 7 runs, 10 loops each)
#19602 MB/s
%timeit decompress("Test.dat")
146 ms ± 332 µs per loop (mean ± std. dev. of 7 runs, 10 loops each)
#4310 MB/s
%timeit decompress_pre("Test.dat",arr)
53.6 ms ± 82.9 µs per loop (mean ± std. dev. of 7 runs, 10 loops each)
#11740 MB/s

编辑

这个版本更适合一般用途。它可以处理f-contiguous,c-contiguous和非连续的数组以及大于2GB的数组。还请查看bloscpack

import blosc
import pickle

def compress(file, arr,clevel=3,cname='lz4',shuffle=1):
    """
    file           path to file
    arr            numpy nd-array
    clevel         0..9
    cname          blosclz,lz4,lz4hc,snappy,zlib
    shuffle        0-> no shuffle, 1->shuffle,2->bitshuffle
    """
    max_blk_size=100_000_000 #100 MB 

    shape=arr.shape
    #dtype np.object is not implemented
    if arr.dtype==np.object:
        raise(TypeError("dtype np.object is not implemented"))

    #Handling of fortran ordered arrays (avoid copy)
    is_f_contiguous=False
    if arr.flags['F_CONTIGUOUS']==True:
        is_f_contiguous=True
        arr=arr.T.reshape(-1)
    else:
        arr=np.ascontiguousarray(arr.reshape(-1))

    #Writing
    max_num=max_blk_size//arr.dtype.itemsize
    num_chunks=arr.size//max_num

    if arr.size%max_num!=0:
        num_chunks+=1

    f=open(file,"wb")
    pickle.dump((shape,arr.size,arr.dtype,is_f_contiguous,num_chunks,max_num),f)
    size=np.empty(1,np.uint32)
    num_write=max_num
    for i in range(num_chunks):
        if max_num*(i+1)>arr.size:
            num_write=arr.size-max_num*i
        c = blosc.compress_ptr(arr[max_num*i:].__array_interface__['data'][0], num_write, 
                               arr.dtype.itemsize, clevel=clevel,cname=cname,shuffle=shuffle)
        size[0]=len(c)
        size.tofile(f)
        f.write(c)
    f.close()

def decompress(file,prealloc_arr=None):
    f=open(file,"rb")
    shape,arr_size,dtype,is_f_contiguous,num_chunks,max_num=pickle.load(f)

    if prealloc_arr is None:
        if prealloc_arr.flags['F_CONTIGUOUS']==True
            prealloc_arr=prealloc_arr.T
        if prealloc_arr.flags['C_CONTIGUOUS']!=True
            raise(TypeError("Contiguous array is needed"))
        arr=np.empty(arr_size,dtype)
    else:
        arr=np.frombuffer(prealloc_arr.data, dtype=dtype, count=arr_size)

    for i in range(num_chunks):
        size=np.fromfile(f,np.uint32,count=1)
        c=f.read(size[0])
        blosc.decompress_ptr(c, arr[max_num*i:].__array_interface__['data'][0])
    f.close()

    #reshape
    if is_f_contiguous:
        arr=arr.reshape(shape[::-1]).T
    else:
        arr=arr.reshape(shape)
    return arr

这种方法不仅在我的机器上是迄今为止最快的(即使每次分配数组),而且还能产生最小的文件。双赢! - ibrewster
这对我来说比lz4效果更好,在NVMe上将“有效”读取速度提高了一倍。虽然分配需要最多的时间并不一定是这样,特别是对于低精度数据类型。 - OverLordGoldDragon
2
用于在实际函数中,压缩应该以 arr = np.ascontiguousarray(arr) 开始,以防 arr 是数组的视图,且在内存中不连续。我尝试编辑答案,但编辑队列已满。 - Atnas
1
@Atnas 我会编辑答案。我现在时间有点短。还有其他有用的东西需要实现,例如处理大于2GB的数组和处理Fortran有序数组,其中ascontigousarry没有意义,而只需要一个指示它是Fortran连续的标志。 - max9111
2
@Atnas 我添加了更高级的版本(支持超过2GB的数组、Fortran-C连续数组和非连续数组) - max9111

4

你可以继续使用现有的PNG文件并享受节省空间,但是如果使用 libvips ,则可以获得更快的速度。这里进行了比较,但不是测试我笔记本电脑和你的速度,而是展示了3种不同的方法,以便您可以看到相对速度。我使用了:

  • PIL
  • OpenCV
  • pyvips

#!/usr/bin/env python3

import numpy as np
import pyvips
import cv2
from PIL import Image

def usingPIL(f):
    im = Image.open(f)
    return np.asarray(im)

def usingOpenCV(f):
    arr = cv2.imread(f,cv2.IMREAD_UNCHANGED)
    return arr

def usingVIPS(f):
    image = pyvips.Image.new_from_file(f)
    mem_img = image.write_to_memory()
    imgnp=np.frombuffer(mem_img, dtype=np.uint8).reshape(image.height, image.width, 3) 
    return imgnp

接着我在IPython中检查了一下性能,因为它有很好的计时函数。正如您所看到的,pyvips比PIL快13倍,即使PIL比原始版本快2倍也是如此,因为避免了数组拷贝:

In [49]: %timeit usingPIL('Alaska1.png')                                                            
3.66 s ± 31.8 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)

In [50]: %timeit usingOpenCV('Alaska1.png')                                                         
6.82 s ± 23.3 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)

In [51]: %timeit usingVIPS('Alaska1.png')                                                           
276 ms ± 4.24 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)

# Quick test results match
np.sum(usingVIPS('Alaska1.png') - usingPIL('Alaska1.png')) 
0

0

我认为应该快速处理的内容是

  1. 使用gzip(或其他)进行压缩
  2. 将压缩后的数据直接存储在Python模块中作为字面量字节
  3. 直接将解压后的形式加载到numpy数组中

即编写一个生成类似源代码的程序

import gzip, numpy
data = b'\x00\x01\x02\x03'
unpacked = numpy.frombuffer(gzip.uncompress(data), numpy.uint8)

打包的数据最终会直接编码到 .pyc 文件中

对于低熵数据,gzip 解压缩应该非常快(编辑:毫不奇怪地,lzma 更快,而且它仍然是一个预定义的 Python 模块)

使用您的“阿拉斯加”数据,这种方法在我的机器上表现如下:

compression   source module size   bytecode size   import time
-----------   ------------------   -------------   -----------
gzip -9               26,133,461       9,458,176          1.79
lzma                  11,534,009       2,883,695          1.08

即使您可以控制使用的Python版本,您甚至可以仅分发.pyc文件;在Python 2中加载.pyc的代码只有一行,但现在更加复杂(显然已经决定加载.pyc不应该方便)。

请注意,模块的编译速度相当快(例如,lzma版本在我的计算机上编译只需0.1秒),但浪费11Mb的磁盘空间没有真正的理由。


只是想澄清一下,您认为在解压缩方面,gzip应该比bz2lzma更快吗? - ibrewster
@ibrewster:确实,在我提供的文件上,lzma 比 gzip 快大约 1.7 倍。在我的机器上,将 numpy 数组加载到内存中的程序的总运行时间约为 1 秒... - 6502
@CharlesDuffy:如果速度取决于实际数据,我不会感到惊讶。例如,对于提供的阿拉斯加文件,lzma解压缩速度快约1.7倍。 - 6502

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接