IT / 气象/Meteorology · 2022年3月6日 3

浅析GRIB与NetCDF数据格式的特点及性能对比

在气象圈子里,GRIB和NetCDF文件格式可谓家喻户晓,但是这两种文件格式到底有哪些优势或劣势?在工程实践上两种格式各适合于什么样的应用场景以及性能如何?这些问题似乎在国内的气象技术圈子里很少见到有人讨论,今天我就结合我这些年来和这两种格式打交道的经验,跟大家简单分享一下这两种数据格式的特点及性能对比,供各位参考。

GRIB的基本特点

GRIB文件是由一种名为Message的自描述数据单元组成的二进制文件集合,而且它是由多个Message直接拼接而成的,是一种散装的数据格式。甚至一个GRIB文件内可以同时包含GRIB1格式和GRIB2格式的Message,这也就是为什么我们通常看到的ECMWF和GFS的GRIB文件没有像常规的数据文件一样带有后缀名的原因。

由于GRIB格式自描述的基本单位是Message,所以它可以以Message作为基本单元进行排列组合而不会影响使用,比如说,只要你恰如其分地从两个相邻的Message中间把一个GRIB文件切开,那么它就会直接变成两个GRIB文件,就像蚯蚓一样。同样地,你把两个GRIB文件直接拼接到一起,它又会直接形成了一个大的GRIB文件。

这种特征给它带来的最大好处是:灵活性,非常有利于网络传输。

举一个GFS的例子,众所周知,GFS数据集非常的庞大,单个时次的文件能达到好几百GB,如此庞大的数据量的下载是一个挑战。而且GFS输出的要素太多,对于一些只需要几个要素的需求,如果我们把它整个数据集全部下载下来对磁盘和带宽资源就太浪费了。所以我们可能需要一种方法可以切出其中一部分要素要素来下载,这时候GRIB文件格式的散装特征就发挥作用了,NCEP官方在发布GFS数据文件时,都会附带一个.idx的索引文件,它会记录每一条Message的字节范围,而HTTP协议天然支持截断请求,那么你只需要根据.idx文件计算出自己想要的变量,然后在用HTTP请求的时候传入请求的字节范围即可。

然而,GRIB的散装结构也不都是优点,它也有它的问题,那就是文件校验会比较麻烦。通常情况下,我们在使用其他常见的二进制文件的时候,如果文件损坏,那么它就无法正常打开,例如一个损坏的.docx文件,你是无法用Word正常打开的,但GRIB与众不同,如果你在下载GRIB文件的时候,有几个字节没有下载完整,它仅会影响它所在的那个Message,其他Message可以正常使用,当你没有读到残缺的Message时,它看起来就是个正常的文件,所以你就无法通过文件是否正常打开来判断文件是否完整,还需要结合字节数的对比以及重建索引等操作才能更有把握地判断文件的完整性。

GRIB在官方介绍的时候,自诩为一种紧凑且体积小的数据格式,但是事实上这一所谓的“优势”早就已经被NetCDF4格式按在地上摩擦了,而且它的文件读取效率也远低于NetCDF4,基本上已经算是被NetCDF4全方位超越和碾压了,这一点后面我们会讲到。

NetCDF的基本特点

说到NetCDF格式,不得不把NetCDF3和NetCDF4分开来说。虽然它们都是NetCDF家族的成员,但是它们基本上算是两个物种。你知道为什么GRIB会自诩为紧凑而小巧的数据格式吗?那因为它在和NetCDF3做对比,跟GRIB的文件大小相比,同要素的NetCDF3格式的文件体积确实很大,不吹不黑基本2倍起步。

但是后来,NetCDF4出来了,NC4完全舍弃了NC3的底层架构,而采用了HDF5的底层,或者换句话说,其实NC4就是一种特殊的HDF5,它俩的关系就类似于geojson与json之间的关系,你甚至可以直接用h5py去打开nc4的文件,或者用NetCDF4的包去读.h5文件。

NC4对于NC3是全方位的提升,NC4文件支持压缩,其压缩比相当的优秀,并且压缩后的文件可以直接读取也不影响速度,至于为什么HDF5的性能会这么好,其实我也没仔细研究过。

上面讲了这么多,都是空口白牙,没有证据,下面我就用几个实际案例来证实上面的这些结论。

文件体积对比

下面演示的例子所使用的数据,一部分来自ERA5,一部分来自GFS。ERA5的数据我是下载了地面层13个要素的单一时次的GRIB和NetCDF文件,为了公平起见,两种格式就是在官网下载文件时分别选GRIB和NetCDF输出格式下载的,不是我自己转换的。而GFS数据仅用于演示pygrib的两种读取效率,并没有参与到与NetCDF格式的性能比较,这些数据都上传到和鲸社区了,可以去那里下载。

我们现在有两个文件,分别是era5-sample.gribera5-sample.nc3

$ ls -lh era5-sample*
-rw-r--r--  1 clarmylee  staff    80M  3  5 19:26 era5-sample.grib
-rw-r--r--@ 1 clarmylee  staff   161M  3  5 19:29 era5-sample.nc3

我们把GRIB文件转为GRIB2存一份:

$ grib_set -s edition=2 era5-sample.grib era5-sample.grib2
$ ls -lh era5-sample.grib*
-rw-r--r--  1 clarmylee  staff    80M  3  5 19:26 era5-sample.grib
-rw-r--r--  1 clarmylee  staff    80M  3  6 02:14 era5-sample.grib2

可以看到GRIBGRIB2的体积几乎没有变化。而在初始的状态下,NC3的文件是GRIB文件的2倍。这也是GRIB曾经引以为豪的资本,但如果我们用bzip2对二者进行压缩的话:

$ bzip2 -k era5-sample.nc3
$ bzip2 -k era5-sample.grib
$ ls -lh era5-sample*
-rw-r--r--  1 clarmylee  staff    80M  3  5 19:26 era5-sample.grib
-rw-r--r--  1 clarmylee  staff    52M  3  5 19:26 era5-sample.grib.bz2
-rw-r--r--@ 1 clarmylee  staff   161M  3  5 19:29 era5-sample.nc3
-rw-r--r--  1 clarmylee  staff    32M  3  5 19:29 era5-sample.nc3.bz2

会发现NC3的压缩比反而比GRIB更低。

当然,经过bzip2压缩的文件会丧失直接读取的能力,但是如果你想存储用于归档的冷数据,那么存储经过bzip2压缩的NC3可能也比存储bzip2压缩的GRIB来得实在。

下面我们再来看看NC4,NC4支持一种直接的压缩方式,可以在不丧失读取能力的情况下对数据进行压缩,下面我们就用nccopy命令将NC3文件转为不同压缩等级的NC4。

$ nccopy -4 -d 0 era5-sample.nc3 era5-sample-c0.nc4
$ nccopy -4 -d 1 era5-sample.nc3 era5-sample-c1.nc4
...
$ nccopy -4 -d 8 era5-sample.nc3 era5-sample-c8.nc4
$ nccopy -4 -d 9 era5-sample.nc3 era5-sample-c9.nc4

按照上面的命令,可以将原始的era5-sample.nc3文件转为0-9个压缩等级的nc4文件。其中-4表示的是输出文件为NetCDF4格式,-d为压缩等级,默认为0,不压缩。我们再来看看压缩后的nc4文件的体积:

$ ls -lh era5-sample-c*.nc4
-rw-r--r--  1 clarmylee  staff   161M  3  5 20:55 era5-sample-c0.nc4
-rw-r--r--  1 clarmylee  staff    44M  3  5 20:22 era5-sample-c1.nc4
-rw-r--r--  1 clarmylee  staff    44M  3  5 20:54 era5-sample-c2.nc4
-rw-r--r--  1 clarmylee  staff    44M  3  5 20:54 era5-sample-c3.nc4
-rw-r--r--  1 clarmylee  staff    44M  3  5 20:55 era5-sample-c4.nc4
-rw-r--r--  1 clarmylee  staff    44M  3  5 20:55 era5-sample-c5.nc4
-rw-r--r--  1 clarmylee  staff    44M  3  5 20:55 era5-sample-c6.nc4
-rw-r--r--  1 clarmylee  staff    44M  3  5 20:55 era5-sample-c7.nc4
-rw-r--r--  1 clarmylee  staff    44M  3  5 20:55 era5-sample-c8.nc4
-rw-r--r--  1 clarmylee  staff    44M  3  5 20:56 era5-sample-c9.nc4

可以看出来,当NC4为0级压缩的时候,它的体积和NC3没什么差别,当压缩级别>=1的时候,压缩比达到了0.27,而1-9级的压缩比几乎没有差别。因此可以知道,NC4格式的文件压缩与不压缩的差别很大,而不同级别的压缩其实差别不大。

文件读取效率对比

在我们面对大体积文件的时候,无非需要考虑两个问题,一个是存,一个是用,那么GRIB和NetCDF文件在读取效率上的对比谁更胜一筹呢?下面我们用代码和数据说话。

 In [1]:
 import netCDF4 as nc
 import pygrib

在与NetCDF做PK之前,我觉得有必要再简单讲一下pygrib怎么读取GRIB文件是最高效的方法。我们使用pygrib的时候,有两种方式读取,一种是pygrib.open,还有一种是pygrib.index。前一种是先打开文件,然后指针停留在第0个字节上,再根据你的指令去遍历查找;后一种是先遍历Message,根据指定的键构建一个B-tree索引(类似于MySQL中给表创建索引),后续在查找的时候根据索引规则快速定位到指定的位置进行读取,速度非常快。

下面我们来测试一下open方法和index的效率。

GRIB:

In [2]:
 grb_open = pygrib.open('./era5-sample.grib')
 %timeit grb_open.select(shortName='2t')

89.5 ms ± 762 µs per loop (mean ± std. dev. of 7 runs, 10 loops each)
In [3]:
 grb_idx = pygrib.index('./era5-sample.grib', 'shortName')
 %timeit grb_idx.select(shortName='2t')

2.82 ms ± 48.8 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)

GRIB2:

In [4]:
 grb_open = pygrib.open('./era5-sample.grib2')
 %timeit grb_open.select(shortName='2t')

140 ms ± 3.42 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)
In [5]:
 grb_idx = pygrib.index('./era5-sample.grib', 'shortName')
 %timeit grb_idx.select(shortName='2t')

4.57 ms ± 200 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)

可以看出来,不管是GRIB还是GRIB2,index模式下的select方法的耗时基本上都是open模式下的3%左右,因此使用index模式可以大大提高GRIB文件的查找效率。而且GRIB文件的要素越多,效果就越明显,上面例子的GRIB文件里只有13个Message,下面我们试一下对一个包含740多个Message的GFS文件进行两种模式的读取对比:

In [6]:
 grb_open = pygrib.open('./gfs.t12z.pgrb2.0p25.f001')
 %timeit grb_open.select(shortName='2t')

1.43 s ± 57 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)
In [7]:
 grb_idx = pygrib.index('./gfs.t12z.pgrb2.0p25.f001', 'shortName')
 %timeit grb_idx.select(shortName='2t')

1.23 ms ± 88.1 µs per loop (mean ± std. dev. of 7 runs, 1000 loops each)

上例中用open模式查找单个Message的耗时已经突破了1秒,而在index模式下select平均仅用了1.23ms,耗时相差了1000多倍。

下面我们使用index模式来读取数据。

In [8]:
 %timeit data, lat, lon = grb_idx.select(shortName='2t')[0].data()

241 ms ± 6.73 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)

共耗时241ms,根据前面的结果可以推算出来.data()方法的耗时达到了240ms左右。

下面我们再来看看NetCDF3的表现,为了公平起见,对NetCDF的读取我们需要写一个函数,来与pygrib中的.data()方法的返回值保持一致。

In [9]:
 def nc_get_data(fp, varname):
    ds = nc.Dataset(fp)
    data = ds.variables[varname][:]
    lon = ds.variables['longitude'][:]
    lat = ds.variables['latitude'][:]
    return data, lat, lon

NetCDF3:

In [10]:
 %timeit data, lat, lon = nc_get_data('./era5-sample.nc3', '2t')

80.7 ms ± 2.08 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)

80ms, 比GRIB的表现要好。

NetCDF4:

无压缩模式

In [11]:
 %timeit data, lat, lon = nc_get_data('./era5-sample-c0.nc4', '2t')

64.2 ms ± 921 µs per loop (mean ± std. dev. of 7 runs, 10 loops each)

1级压缩模式

In [12]:
 %timeit data, lat, lon = nc_get_data('./era5-sample-c1.nc4', '2t')

67.2 ms ± 1.58 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)

9级压缩模式

In [13]:
 %timeit data, lat, lon = nc_get_data('./era5-sample-c9.nc4', '2t')

67.6 ms ± 2.03 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)

可以看出来,不同压缩级别下NetCDF4的数据读取效率的表现差别不大,且明显都比NetCDF3和GRIB都要好。

总结

我们从文件体积、读取效率上分别对NetCDF和GRIB文件格式进行了对比评估。

从文件体积上来看,在不丧失读取能力的情况下,相同要素下文件的体积排序是:NetCDF4 < GRIB ≈ GRIB2 < NetCDF3,NetCDF4具有明显的优势。

从读取效率上来看,相同要素下查找并加载相同的数据,加载耗时排序是:NetCDF4 < NetCDF3 < GRIB ≈ GRIB2。仍然是NetCDF4胜出。 综上所述,如果没有特殊条件约束的话,在GRIB和NetCDF的抉择中,我们应该首选NetCDF4格式作为主力存储格式。