本文学习下 Lucene 在存储大量整数时使用到的编码方法。java
DirectWriter 用 bit 编码方式进行数组压缩的功能,它在整个数组的全部元素都不大的状况下能带来不错的压缩效果。git
DirectWriter 是 Lucene 为整型数组重编码成字节数组的工具,它的底层包含一系列编码器,将整型数组的全部元素按固定位长度的位存储。它按 Bit 存储,预留长度过长会浪费空间,短了会由于截断致使错误。所以须要在数组中查找最大值,由它的长度做为存储的长度。github
假设有一组数据{4,5,9,0},它们的二进制表示是{100, 101, 1001, 0}。占用有效位最长的是 1001(4 个 bit),所以须要用 4 个 bits 来表示一个数值,获得以下结果。apache
正好占用了 16 位,两个 byte 的数据。数组
因为 DirectWriter 在写完后会写入三个 byte 的 0 值,所以上面的数据写入文件以后,使用 xxd 命令查看文件内容为:缓存
很巧合有没有,使用十六进制读取文件,和咱们的原始值居然同样。实际上是由于 16 进制每一个进位是 4 个 bit, 正好和咱们的数据同样而已。微信
带有注释源码能够查看 org.apache.lucene.util.packed.DirectWritermarkdown
// 每个值须要几个 bit
final int bitsPerValue;
// 总数
final long numValues;
// 输出方
final DataOutput output;
// 当前写了多少
long count;
boolean finished;
// for now, just use the existing writer under the hood
// 当前写入了多少个,在 nextValues 里面的偏移
int off;
// 这两个是符合对应关系的,所以 nextValues.length * bitsPerValue = nextBlocks.length * 8
// 编码后的全部数据
final byte[] nextBlocks;
// 全部的原始数据,打算存这么多数字,每一个数字用 bitsPerValue. 那么总共须要 nextValues.length * bitsPerValue.
// 这些都要存在 nextBlocks 里面,因此除以 8 就是 nextBlocks 的长度。
final long[] nextValues;
// 编码器
final BulkOperation encoder;
// 1024 内存可以缓存多少个完整的块。
final int iterations;
复制代码
注释的比较详细,就很少少了。函数
DirectWriter(DataOutput output, long numValues, int bitsPerValue) {
this.output = output;
this.numValues = numValues;
this.bitsPerValue = bitsPerValue;
// 由于你须要的位不同,那么须要的顺序读写的编码器就不同,为了性能吧,搞了不少东西
// 搞了不少个编码解码器,根据存储的位数不同而不同
encoder = BulkOperation.of(PackedInts.Format.PACKED, bitsPerValue);
// 这里计算一下的目的是,内存 buffer 尽可能刚恰好用 1024 字节,不要过小,致使吞吐量下降,不要太大,致使 oom.
// 用 1024 字节的内存,能缓存多少个编码块。若是用不了 1024, 就只申请刚刚的大小。
iterations = encoder.computeIterations((int) Math.min(numValues, Integer.MAX_VALUE), PackedInts.DEFAULT_BUFFER_SIZE);
// 申请内存里的对应 buffer array.
nextBlocks = new byte[iterations * encoder.byteBlockCount()];
nextValues = new long[iterations * encoder.byteValueCount()];
}
复制代码
这里着重解释一下,属性中interations
的做用。构造函数中对他的初始化也不是特别容易懂。工具
DirectWriter 是按照位对数字进行存储,那就有所谓的block
(块)的概念。
设想下,你想让每一个数字用 12 个 bit 存储。并且你只写入一个数字,也就是总共只用 12 位,这时候怎么办?还能向文件中写入 1.5 个字节么?所以,经过计算bitsPerValue
和byte-bits=8
的最小公倍数,来造成一个block
概念。
好比每一个数字使用 12 位存储,每一个 byte 是 8 个 bit, 那么最小公倍数是 24, 也就是 3 个 byte 为一个 block, 用来存储 2 个 12 位的数字。 申请空间时,直接按照 block 为单位进行申请,若是能写满,就写满。写不满剩余的 bit 位使用 0 填充。
当你仅写入一个 12bit 数字时,实际上会写入三个字节,共 24bit. 前 12bit 是你的数字,后 12bit 用 0 填充。
那么直接按 block 进行写入不就完事了么?为何须要interations
参数呢?
众所周知,每次都写文件很慢,通常的写文件都使用内存进行 buffer. 缓冲一部分的数据在内存,等到 buffer 满了以后一次性写入一堆数据,这样能够提升吞吐量。
对于 DirectWriter 而言,buffer 多少个数据是个问题。所以每一个数字多是 1bit, 也多是 64bit, 使用固定的数量来缓冲,内存占用很不稳定,差别可能达到 64 倍。一来占用内存不稳定,容易形成 OOM. 二来做为一个 Writer. 占用内存忽大忽小的,很不帅气。
所以 DirectWriter 使用固定大小的 buffer. 通常设定为 1024 字节。也就是 1KB 数据进行一次实际的写入磁盘操做。
上面说了,DirectWriter 写入数据必须按照 block 来写入,那么因为每一个数字使用 bit 数量不一样,block 的内存大小也是不肯定的,1024 个字节可以包含多少个 block. 也是不肯定的,须要根据bitsPerValue
来进行计算,而不是能够直接定义成静态常量。
内存中缓冲一个 block. 须要:
byteValueCount
个 long 型数据,占用内存为 byteValueCount * 8
个字节。byteBlockCount
个字节的数据。占用内存为:byteBlockCount
.那么 1024 个字节,可以 buffer 多少个 block 呢。1024 / (byteBlockCount + 8 * byteValueCount)
.
咱们查看一下interations
的计算方法。
/** * For every number of bits per value, there is a minimum number of * blocks (b) / values (v) you need to write in order to reach the next block * boundary: * - 16 bits per value -> b=2, v=1 * - 24 bits per value -> b=3, v=1 * - 50 bits per value -> b=25, v=4 * - 63 bits per value -> b=63, v=8 * - ... * * A bulk read consists in copying <code>iterations*v</code> values that are * contained in <code>iterations*b</code> blocks into a <code>long[]</code> * (higher values of <code>iterations</code> are likely to yield a better * throughput): this requires n * (b + 8v) bytes of memory. * * This method computes <code>iterations</code> as * <code>ramBudget / (b + 8v)</code> (since a long is 8 bytes). * * @param ramBudget : 每一个 budget 的字节数?, 一般为 1024 */
// Bulk 操做的时候,内存里面有 buffer. 这个 buffer 一共只有 1024bytes.
// 可是有两个变量。
// 注意:这里是算内存的,也就是算,1024 个 byte 的内存,够干啥。固然同时要知足 pack 自己的要求。
// 也就是 一个块要能正好写到边界,别多了少了 bit 位。
public final int computeIterations(int valueCount, int ramBudget) {
// 1024 个字节的内存。有两个变量,都要用。
// 1. 原始数据。一个完整的块,要 byteValueCount() 个原始数据,每一个数据用 long 存储。因此一个完整的块,要 8 * byteValueCount() 个字节。
// 2. 编码后的数据。一个完整的块,要 byteBlockCount() 个字节。
// 因此 iterations. 表明的是,1024 个字节的内存,够缓存多少个完整的块。
final int iterations = ramBudget / (byteBlockCount() + 8 * byteValueCount());
// 至少缓存一个
if (iterations == 0) {
// at least 1
return 1;
// 块的数量 * 每块里面原始数据的数量 > 你要存储的总数,也就是说,总共也用不了 1024 字节,申请多了。
} else if ((iterations - 1) * byteValueCount() >= valueCount) {
// don't allocate for more than the size of the reader
// 因此只缓存,(总共要存的数量 / 每块里面能存的数量 ) 个完整的块。所以总共也用不完 1024 字节嘛
return (int) Math.ceil((double) valueCount / byteValueCount());
} else {
return iterations;
}
}
复制代码
能够看到和咱们分析一致。
所以,当你须要写的数据不少,DirectWriter 类内部nextValues
和nextBlocks
两个属性总共占用的内存,应该很接近于 1024bytes.
/** Adds a value to this writer * 添加一个值 * */
public void add(long l) throws IOException {
// 几个校验
assert bitsPerValue == 64 || (l >= 0 && l <= PackedInts.maxValue(bitsPerValue)) : bitsPerValue;
assert !finished;
if (count >= numValues) {
throw new EOFException("Writing past end of stream");
}
// 当前缓冲的数量,够了就 flush
nextValues[off++] = l;
if (off == nextValues.length) {
flush();
}
count++;
}
复制代码
比较简单,将要添加的 long, 写进内存中的数组里,以后检查 buffer 是否满了,满了就写一次磁盘。调用 flush 方法。
private void flush() throws IOException {
// 把当前缓冲的值,编码起来到 nextBlocks
// 当前缓冲的在 nextValues,把他按照编码,搞到 nextBlocks 里面
// 反正就是存储啦,编码没搞懂,草
encoder.encode(nextValues, 0, nextBlocks, 0, iterations);
final int blockCount = (int) PackedInts.Format.PACKED.byteCount(PackedInts.VERSION_CURRENT, off, bitsPerValue);
// 写入到磁盘
output.writeBytes(nextBlocks, blockCount);
// 缓冲归 0
Arrays.fill(nextValues, 0L);
off = 0;
}
复制代码
把当前缓冲的原始数据值,调用 encoder 进行编码,按照 bitsPerValue 编码后,写入输出文件。
在全部数据写完以后,buffer 可能有一些不满的数据,要调用 finish 进行处理。
/** finishes writing * * 检查数据,检查完最后一次 flush 掉 */
public void finish() throws IOException {
if (count != numValues) {
throw new IllegalStateException("Wrong number of values added, expected: " + numValues + ", got: " + count);
}
assert !finished;
flush();
// pad for fast io: we actually only need this for certain BPV, but its just 3 bytes...
for (int i = 0; i < 3; i++) {
output.writeByte((byte) 0);
}
finished = true;
}
复制代码
首先进行了一些参数的 check. 而后把当前内存里 buffer 的数据调用 flush 写入磁盘。以后写入了 3 个字节的 0 值。具体用来作什么,未知。
它对一个整数数组进行编码,以后写入文件。
它使用数组中最大的数字须要的 bit 数量进行编码。所以在数组总体比较小,且标准差也很小的时候(就是最大的别太大), 能够起到不错的压缩写入效果。
阅读源码须要注意的是,DirectWriter 在内存中进行了 buffer. 不论你的数据集是什么,都使用固定的 1024byte 进行 buffer. 所以有一些针对 buffer 大小的计算须要了解下。
此类为写入方,具体的读取方:org.apache.lucene.util.packed.DirectReader
, 虽然有一些代码组织上的不一样,可是底层思想是同样的,就再也不赘述了。
完。
以上皆为我的所思所得,若有错误欢迎评论区指正。
欢迎转载,烦请署名并保留原文连接。
更多学习笔记见我的博客或关注微信公众号 < 呼延十 >------>呼延十