Wednesday, February 17, 2016

HBase - Compression vs Block Encoding

By Lars Hofhansl

(Updated on February 28th, 2016, to correct spelling errors and clarifications, and to add section about single threaded scanning)

HBase has many options to encode or compress that data on disk.

In this excellent blog post Doug Meil and Thomas Murphy outline the effects of block encoding and compression on the storage footprint.

In this post I will explore the effects of encoding and compression options on (read) performance. I performed the Scan performance tests with the excellent Apache Phoenix  the relational layer over HBase. The Get tests use a straight HBase client, without Phoenix.

Background


In short:
Block encoding refers to ability to encode KeyValues or Cells on-the-fly as blocks are written or read, exploiting duplicate information between consecutive Cells. The main advantage is that the blocks can be cached in encoded form and decoded as needed in a streaming way. The encodings used most commonly in HBase are FAST_DIFF and (to a lesser extent) PREFIX encoding.

Compression here means full compression of HBase blocks using SNAPPY, GZIP, and others. Newer versions of HBase have the ability cache block in compressed form, but I did not test this here.

(Compression and block encoding for HBase are described in more detail here.)

The most important property to note is that the cost of decompression is proportional to the number of blocks that have to be decompressed, whereas the cost of decoding is proportional to the number of Cells visited. Keep that in mind when looking at the numbers below.


The Setup


For my test I created Phoenix tables of with the following schema:

create table test (pk integer primary key, v1 float, v2 float);

Then I seed the pk from a sequence, and v1/v2 with random values like so:

upsert into test values(next value for pkey, rand(), rand());

followed by a few (doubling the size of the table each time):

upsert into test select next value for pkey, rand(), rand() from test;

This data set, except for the monotonically increasing key, would not compress well.

With that I created tables with 32m rows, or 128m Cells altogether (including the one that Phoenix creates for book-keeping), on a single machine.

Let's first look at the footprint on disk:

 595MB GZIP
 875MB FAST_DIFF
 993MB SNAPPY
1739MB PREFIX
2922MB NONE

So, the first take away: You have to use some kind of compression or encoding. Since every column needs to be stored as its own cell it clocks in with about 24 bytes per column uncompressed - or about 100 bytes per "row". (In a relational database with a dense row format that entire row would only take about 12 bytes.)

I tested the following four scenarios:
  • 12 core machine, 3GB ram, 4 DDR3 memory channels
  • from disk (7200rpm drive, 8ms seek time or about 125 IOPS, about 120MB/s)
  • from SSD (about 800MB/s, about 2500 IOPS)
  • from OS cache
  • from HBase cache

The region server was configured with:
-Xmn2g -XX:+UseParNewGC -XX:+UseConcMarkSweepGC -XX:CMSInitiatingOccupancyFraction=70 -XX:+UseCompressedOops -XX:+UseCMSInitiatingOccupancyOnly -XX:MaxGCPauseMillis=5000 -XX:GCTimeLimit=90 -XX:GCHeapFreeLimit=10

Short Circuit Reads were enabled for HDFS.

Now let's do some scanning


In order to avoid caching in HBase I used the NO_CACHE hint in Phoenix that I added a while ago (PHOENIX-13)

 select /*+ NO_CACHE */ count(*) from test;
   (This will actually issue 11 scan requests to HBase in parallel)

I cleared the OS Cache with

 echo 3 | sudo tee /proc/sys/vm/drop_caches



We can see that reading from spinning disk is (not surprisingly) dominated by the amount of data to be read. Extra CPU time consumed by compression is negligible, we are clearly IO bound.
We also see that in all cases - even GZIP - HBase was able to push the drive close to it's limit. In fact in all cases I saw 90-95MB/s read from disk, the performance gain from compression is close to proportional to the compression ratio.

Let's look at the same data without the HDD numbers to see better what's going on:



Here we see the fact the decoders incur per Cell overhead, the HBase block cache does not help for FAST_DIFF or PREFIX. For PREFIX encoding the HBase block cache actually does worse than the OS Cache, that is something I need to investigate further.

We also see that for Scans the block cache has no/little advantage over the OS cache, or even reading straight form SSD - and in fact HBase wasn't able to keep the SSD fully busy. In other words for SSDs HBase scans are CPU bound. This is interesting, I will also investigate this part further.

All schemes except FAST_DIFF/PREFIX store the blocks in uncompressed form (note that newer version of HBase have the option to cache blocks in their compressed form, I did not test that). In this case the uncompressed data fits in the block cache, so it's not entirely fair to FAST_DIFF and PREFIX.

So use FAST_DIFF only when you expect your working set to fit in the aggregate block cache when compressed, but not uncompressed.

We can now also see the relative CPU cost of the compression. FAST_DIFF is actually worst(!), followed by GZIP, PREFIX, and then SNAPPY, which is most efficient.
GZIP consumed to most CPU to decompress a block, but after decompressing a block scanning goes through the block without any extra work.

Since for scans the HBase block cache generally does not buy much over Linux' disk block caching ("OS Cache" in the graph) as we're CPU bound anyway; in these situations it might be better to disable cacheBlocks (setCacheBlocks(false)) on the Scan object - even when the entire scanned data would fit into the HBase block cache.

February 28, 2016

Note that Phoenix uses parallel scans across regions and uses equal-width "guide posts" to guide how Phoenix should parallelize the work across HBase region servers. In the Scan examples Phoenix chose 11-way parallelization to execute the queries. I have since added a SERIAL hint to Phoenix to force serial execution of a query. See PHOENIX-2697.

How does performance look like when all scanning is single threaded?
Now this is interesting. Without compression we still run into the spinning disk limit throughput limit. Everything else is mostly CPU bound.

Side note, memory bandwidth:

In the next chart I mapped the scan throughput out of the OS cache seen by 1 thread and by 11 threads. (I used the OS cache, and not the HBase block cache, as this shows the internal end-to-end performance of HBase.):

MB/s is the actual throughput seen out of the OS cache. "raw" indicates the throughput in terms of uncompressed data. We see that the throughput in terms of raw uncompressed bytes is very similar across all schemes.

We run into some internal HBase/Phoenix limit around 710MB/s.

Now look at the single threaded performance!
The fastest is (of course) uncompressed scanning, which utilizes about 168MB/s of the available memory bandwidth. It seems we need throw a lot of cores at the problem to utilize the available memory bandwidth. Luckily Apache Phoenix does that automatically where possible; but this is still something to look in HBase land.

In order to put this into context... According to spec this machine has 4 DDR memory channels for a total maximum bandwidth of 51.2 GB/s.

I wrote a simple bandwidth tester in C++. It allocates 1GB of memory and then accesses it sequentially as bytes or as 64bit longs. (for good measure I wrote the same in Java, and came to the same numbers, within 10%). Please note that I am not hardware guy.

Here's what I measured (bandwidth, GB/s):
Measured B/W1 process4 processes
long13.342.6
byte2.249.1

So the HBase numbers are quite a bit lower. We see that the maximum throughput (710MB/s) is close 4x the single threaded throughput (168MB/s), indicating that this is probably related to memory throughput and not CPU cycles (we should have seen an 11x speed up, the machine has enough cores).

Now, this is from the Linux block cache, so we need account for the fact that the blocks are copied at least 2 times (into a direct byte buffer, from there into a byte[]), but since the block cache is only marginally better, this looks to be all internal Phoenix and HBase friction.

OK. That was scanning. Let's try some random gets.


I wanted to explore the decompression cost more. Here I wrote a little tool that issues 1000 random gets. Now each get will incur a block read followed by decompression (if not in the block cache), or a seek + decoding.

The numbers from HDDs are absolutely dominated by the seek time. In all cases this was close to 6.2s, which is close to what you would expect from doing 1000 seeks in 7200rpm drives (actually you would expect 8s for 1000 Gets, presumably it's a little faster, since some gets might hit the same blocks again).
Use SSDs for random Gets, or make sure that your working set fits into the OS cache or block cache. Otherwise each Get will require HBase to perform a disk seek (about 4ms for 15k drive, 8ms for 7.2k dive, 12ms for 5k drive).




So here we see the cost of the decompression again. This time HBase needs to load and decompress a block for each get - unless that block is found in the HBase block cache. We can estimate that the "seek time" on the SSD is about 0.4ms, or that the drive can do about 2500 IOPS (which does seems low).

We also see that FAST_DIFF is not doing as well with the data in block cache, again because it's the only scheme that stores blocks in their compressed form - and the cost of decoding is proportional to the number of Cells traversed.

When the data is on SSD or in the OS Cache, we do see that encoders are doing slightly better. They do not have to decode the entire block, but only the portion needed to decode the Cells we're interested in.

Most prominently for Gets we do see the value of the OS Cache and especially the HBase block cache - assuming the working set will likely fit into the aggregate block cache across the cluster.

TL;DR:

  1. Not using compression or encoding is not an option. I think HBase should not even allow that. Both SNAPPY and FAST_DIFF are good all around options.
  2. If you regularly scan large data sets from spinning disk, you're best of with GZIP. (but watch write speed, which I haven't tested here)
  3. For large scans, consider not using the HBase block cache. (Scan.setCacheBlocks(false)), even if the whole scan could fit into the block cache.
  4. If you mostly perform large scans you might even want to consider running HBase with a much smaller heap and size the block cache down, to only rely on the OS Cache. This will alleviate some garbage collection related issues.
  5. We need to throw a lot of cores at a Scan to utilize the available memory bandwidth. When spec'ing machines for HBase, do not skimp on cores, HBase needs those. Apache Phoenix makes it easy to utilize more cores to increase scan performance.
  6. If there is a chance that your data set would fit into the block cache, FAST_DIFF is a good option. It will allow more data to fit into the block cache, since the data is cached in its encoded form.
  7. While for Scans the HBase block cache shows fairly little advantage, for Gets it is quite important to have your data set cached.
  8. If you do lots of random gets, make sure you use SSDs, or that your working set fits into RAM (either OS cache or the HBase block cache), or performance will be truly terrible.
What I didn't test:
  1. CPU constrained environments.
  2. Write performance
What I also didn't test:
  • Multiple different scans in parallel. I'd expect the cached scenarios would behave similarly w.r.t. each other. Obviously scanning and Gets from rotating disks will do much worse.

2 comments:

  1. Hi Lars
    In the latest results that you have posted, (BlockCache test) u did using L1 cache right? Total cache size and RS max heap size?

    -Anoop-

    ReplyDelete
    Replies
    1. Yes. L1 cache.
      I ran the RSs with a 12GB heap. I sized it so that the data can all fit into the HBase L1 cache and the Linux OS cache, for easy testing.

      During the test from the block cache I made sure the nothing is read from the OS cache (by clearing the OS cache, and watching that there is no disk activity).

      Delete