Ferenc Hegedus
Compression is usually discussed in terms of algorithm and level while there is another parameter that can have an equally important effect on the result: the logical block size(LBS).
KX have written a case study on the performance impact of the compression algos themselves – in this blog we are further experimenting with choosing various LBS to best fit our needs.
Logical block size affects the amount of data available to the compressor at once, the number of compressed blocks that must be read for a query, and the amount of work required to decompress those blocks. Moreover, kdb+ allows us to specify different compression setting to different columns using a dictionary.
This note explores the effect of changing LBS in isolation. We are going to show how different query patterns behave in terms of runtime, disk space and the count of OS read instructions.
The measurements below use a quote table with 5 000 000 records parted on sym(1000 instruments) and sorted by time(sym and time has ~equal distribution) inside. We aren’t going to clear the OS cache between tests(you ever ran the same query twice? I’m one of those!) – we want to know the cost of decompressing that we always pay when working with compressed files.
A larger logical block gives the compressor a larger region in which to find repeated patterns. This matters particularly for sorted or sparse columns, where similar values may be spread across a wider range. Here we compare the different algos for the sym and time columns with different LBS showing the achieved compression ratio in each case(the larger the better):
alg c | 12 14 16 17 18 20 ----------- | --------------------------------------------- gzip sym | 93.03 249.45 428.64 489.27 523.05 556.34 lz4hc sym | 100.2 172.16 212 220.3 224.71 228.12 snappy sym | 19.5 20.66 21 21.03 21.05 21.07 zstd sym | 130.9 456 1255.3 1719.3 1927.2 2205 gzip time | 1.36 1.38 1.38 1.38 1.38 1.38 lz4hc time | 1 1.01 1.03 1.05 1.06 1.06 snappy time | 1 1 1 1 1 1 zstd time | 1.18 1.17 1.14 1.14 1.2 1.14
The sym column shows the strongest effect. With gzip/zstd, increasing the block size from 12 to 20 produces a dramatic improvement in compression ratio. The same direction is visible with the other algos, although the gain is smaller.
The time column behaves differently. Its compression ratio changes very little(if at all). This is an important reminder that when considering block size for compression columns with different distributions can have very different sweet spots.
A better compression ratio is valuable, but it is only one part of the cost. A query that consumes an entire column must read every block in that column regardless of whether the blocks are large or small. In that situation, fewer and larger blocks can reduce storage and I/O overhead, while the extra CPU cost of decompression may be a reasonable trade-off.
The following test measures read count for an aggregate over a dense column(5 000 000 rows):
select avg price from quote
The read counts were(for any algo):
alg | 12 14 16 17 18 20 ---- | ------------------------ zstd | 4888 2445 614 309 156 42
While time taken(relative, no compression is 1):
alg | 12 14 16 17 18 20 -----|-------------------------------- zstd | 42.24 12.12 6.60 5.57 5.42 8.33
For this whole-column query, the larger logical block sizes reduce the number of read system calls substantially. In this benchmark the reduction in I/O and the better compression outweigh the CPU cost of handling larger blocks.
The size of the requested range also changes the balance. A single-row query cannot make much use of a large compression window, whereas a large continuous range can amortise the cost of reading and decompressing a block.
The next experiment queries continuous ranges of different lengths and measures read system call count for gzip:
select from quote where i in 100000+til x alg x | 12 14 16 17 18 20 ------------| ------------------- gzip 1 | 21 22 21 21 21 17 gzip 10 | 21 22 21 21 21 17 gzip 100 | 21 22 21 21 21 17 gzip 1000 | 23 25 21 21 21 17 gzip 10000 | 43 37 24 21 21 17 gzip 100000 | 263 169 57 39 30 21
For very small ranges the difference is negligible. As the range grows, the smaller blocks require more read system calls, while the larger blocks keep the read count low. For the largest range, the benchmark issued 263 read system calls at 12 LBS, but only 21 at 20 LBS.
The relative elapsed-time(non-compressed is 1) results show a similar, though less uniform pattern:
alg x | 12 14 16 17 18 20 ------------|-------------------------------- gzip 1 | 3.72 3.13 5.31 9.16 17.03 18.98 gzip 10 | 4.43 4.43 7.63 13 23.50 26.15 gzip 100 | 4.69 4.65 7.92 13.7 24.86 28.09 gzip 1000 | 5.14 5.34 7.28 12.4 22.40 24.56 gzip 10000 | 8.91 7.13 7.94 8.68 15.93 16.94 gzip 100000 | 13.9 9.45 8.65 9.14 10.54 12.88
With 20 LBS we managed to reach the same performance as 12 LBS, but we also saved disk space and reduced the number of I/O operations. Think about how that might affect you when you are reading the data from NFS or from a cloud-storage!
Random access exposes the main cost of a large logical block. If a query needs one value from a block, the system still has to read and decompress the whole block. Small blocks can therefore be much better for small random lookups.
The following test selects random rows and measures system read count:
select from quote where i in x?5000000
alg x | 12 14 16 17 18 20 ------------|---------------------------- gzip 1 | 21 22 21 21 21 21 gzip 10 | 56 54 52 52 52 46 gzip 100 | 342 327 314 293 253 129 gzip 1000 | 2501 2495 1509 899 479 141 gzip 10000 | 9878 7262 1857 941 482 141 gzip 100000 | 12268 7358 1857 941 482 141
For one random row, all block sizes have roughly the same read count. As the number of random rows increases, larger blocks become increasingly useful because the query touches enough data to benefit from them. At the same time, the result shows the opposite behaviour for genuinely small random lookups: larger blocks can bring in more irrelevant data.
Elapsed times can clear out the picture more on how well we are doing: larger random workloads can make larger blocks attractive:
Time taken in (relative, non-compressed is 1):
select from quote where i in x?5000000 alg x 12 14 16 17 18 20 ----------------------------------- gzip 1 2.3 1.2 2.4 4.5 8.3 30 gzip 10 5 5.2 12 21 40 145 gzip 100 25 32 95 162 251 504 gzip 1000 138 193 448 527 495 450 gzip 10000 664 596 528 516 475 463 gzip 100000 953 634 554 531 525 513
We can see that when random access increase from 1000->10000 we start being better off with larger LBS.
KDB+ allows different compression parameters for different columns. That makes it possible to avoid choosing one compromise for the entire table. Instead, compression can reflect the role and distribution of each column:
An asof-join on disk can benefit from the columnwise compression setting.
Here we have 50 000 000 quotes for 5 000 000 trades.
Compression settings:
fixed: zstd with 17 lbs
vs
colwise: zstd with sym, price comressed with 20 and 17 lbs, time left uncompressed
fixed compr db: 663M
vs
colwise compr db: 723M
aj[`sym`time;select from trade;select sym,time,quote:price from quote] comprType | time Read(MB) readCount ----------|------------------------ colwise | 1.48 19 221.2 fixed | 1.65 48 482.3
Time here is again relative to the non-compressed case. If not the execution time enhancement then the required I/O minimization can encourage us for customizing the compression settings in this case.
A fair advice for individuals planning on using different lbs for compression could be:
If after reading all this you decide to go with a mixed compression setting in your database let us know your feedback! I hope this note will help you to make use of different block size in compression, and you will appreciate the flexibility of kdb+ more 🙂
Share this: