<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
  <channel>
    <title>Akumuli</title>
    <description>Time-series database.</description>
    <link>http://akumuli.org/</link>
    <atom:link href="http://akumuli.org/feed.xml" rel="self" type="application/rss+xml" />
    
      <item>
        <title>Analyzing 500 Billion Rows Using Akumuli</title>
        <description>&lt;p&gt;It’s been a while since &lt;a href=&quot;https://www.scylladb.com/2019/12/12/how-scylla-scaled-to-one-billion-rows-a-second/&quot;&gt;Scylla team demonstrated&lt;/a&gt; that their highly capable database can read 1B (1.000.000.000) rows per second on an 83-node cluster. This is roughly 12M rows per second per server.&lt;/p&gt;

&lt;p&gt;The task was simple: store temperature measurements from one million homes for one year with one-minute readings. That about a half of a trillion data points - 1.000.000 * 60 * 24 * 365 = 525.600.000.000 rows! Moreover, as if the sheer volume was not enough they introduced an outlier to the dataset. The outlier is a single reading that’s supposed to be found by the query. This setup was meant to show how well their consistent hashing works.&lt;/p&gt;

&lt;p&gt;It didn’t end there though. Folks from Altinity decided to repeat the test with Clickhouse &lt;a href=&quot;https://www.altinity.com/blog/2020/1/1/clickhouse-cost-efficiency-in-action-analyzing-500-billion-rows-on-an-intel-nuc&quot;&gt;using Intel NUC as their server!&lt;/a&gt; The NUC despite being a tiny PC with inadequate cooling was outfitted with 4-core Intel i7 CPU, 32G of RAM, and most importantly 1Tb NVMe SSD. Clickhouse was able to load that dataset in 17 hours and find the ‘needle’ in 8.6 seconds (using the pre-built index). Their compression algorithm was able to trim every data point to just 1.6 bytes on average. This benchmark is interesting because it demonstrates that with the right software you can outmaneuver a big expensive setup using cheap commodity hardware. Kudos to the Altinity team!&lt;/p&gt;

&lt;p&gt;I decided to build own version of the test. I don’t have an Intel NUC so I ended up using 12 vCPU ‘Standard’ DigitalOcean droplet (which is DO’s word for VM) because it’s the cheapest option that offers 1TB of SSD storage. This instance has 12 shared vCPUs which means three things. First, the CPUs are shared between different VMs on the same host so you can expect inconsistent performance. The CPUs are hyper threads, not real CPUs. Third, this setup is nowhere near as good as the real dedicated machine so it’s cheap! My goal was to spend less than $50 on hosting while doing this test to demonstrate that Akumuli is cost-effective.&lt;/p&gt;

&lt;h3 id=&quot;input-data&quot;&gt;Input Data&lt;/h3&gt;

&lt;p&gt;I wrote a &lt;a href=&quot;https://github.com/Lazin/iot_app_test&quot;&gt;script that generates the test data&lt;/a&gt;. I wanted to use 8 vCPU ‘Standard’ droplet to run the script and to load the data into Akumuli. This droplet has 640GB of storage. I had to tweak the input to fit this droplet. It still has 525.6B unique data-points and 1M homes but everything was divided evenly between 10 metrics (‘temp0’ - ‘temp9’). It allowed to batch up to 10 values together in the input file and as result ‘gzip’ was able to compress everything way better.&lt;/p&gt;

&lt;p&gt;The data was generated using a random walk within reasonable bounds for temperature (19C to 35C). Instead of introducing a single outlier the script introduced a series of outliers. It selected 100 homes and for each of them, it generated a series that was growing randomly up to 100C.&lt;/p&gt;

&lt;p&gt;It took around two days to generate the input files. The script that does this is called ‘gen.sh’. The output was around 200GB compressed and around 4TB uncompressed.&lt;/p&gt;

&lt;h3 id=&quot;loading-the-data&quot;&gt;Loading the Data&lt;/h3&gt;

&lt;p&gt;Because I used the shared VM I repeated the experiment several times. The best load time I’ve seen was 6 hours, the worst one - 7. The variance was because both VMs had shared vCPUs.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/images/525B-benchmark-ingestion-cpu.png&quot; alt=&quot;Ingestion CPU load&quot; /&gt;&lt;/p&gt;

&lt;p&gt;You can see that this run was affected by other VMs in the cloud by looking at this graph. I’ve been using 8 connections to load the data. This means that the Akumuli server could process the data using 8 threads. Akumuli doesn’t use all available CPUs for ingestion. It leaves some CPU power for queries so ingestion never makes the database unresponsive. On a 12 CPU machine, it uses only 8 CPUs for ingestion by default.
The best-case ingestion speed was &lt;strong&gt;24.319.822 elements / second&lt;/strong&gt;. The network was quite busy too.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/images/525B-benchmark-ingestion-net.png&quot; alt=&quot;Ingestion network bandwidth&quot; /&gt;&lt;/p&gt;

&lt;p&gt;You can see that after 3 am something happened on the host machine. I never saw uneven distribution like this on dedicated hardware.
The disk stats are also interesting:&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/images/525B-benchmark-ingestion-space.png&quot; alt=&quot;Disk space&quot; /&gt;&lt;/p&gt;

&lt;p&gt;The disk usage at the end of the test was &lt;strong&gt;554GB&lt;/strong&gt;. This means that Akumuli used &lt;strong&gt;1.1 bytes/reading&lt;/strong&gt; on average. Akumuli uses a novel time-series compression algorithm which leverages &lt;a href=&quot;https://akumuli.org/akumuli/2017/02/05/compression_part2/&quot;&gt;next value prediction&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/images/525B-benchmark-ingestion-disk.png&quot; alt=&quot;Disk write speed&quot; /&gt;&lt;/p&gt;

&lt;p&gt;Curiously, you can see this 3 am dent on every graph. This graph tells us that &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;akumulid&lt;/code&gt; was using just a tad above 50Mbps of disk bandwidth to sustain that 24M elements per second write rate. This is why we need good time-series compression in our lives!&lt;/p&gt;

&lt;h3 id=&quot;aggregate-queries&quot;&gt;Aggregate Queries&lt;/h3&gt;

&lt;p&gt;Akumuli can find a single ‘needle’ without much trouble because it supports aggregation on the storage level out of the box. The ‘aggregate’ query can give you the outlier and its timestamp but you have to run this query for every series. This query can run faster if the series was compacted. Because of that, I ran it for both compacted and not compacted storage.&lt;/p&gt;

&lt;p&gt;Also, Akumuli runs every query in a single thread by design. To leverage parallelism you have to run several queries in parallel. Because of that, I have single aggregate queries that run in one thread using 10% of data, and parallel queries that run in ten threads using all data. All queries were performed on a full year time range.&lt;/p&gt;

&lt;h4 id=&quot;single-aggregate&quot;&gt;Single Aggregate&lt;/h4&gt;

&lt;p&gt;&lt;img src=&quot;/images/525B-benchmark-aggregate-1-thread-cpu.png&quot; alt=&quot;Aggregate CPU&quot; /&gt;&lt;/p&gt;

&lt;p&gt;This query was performed on non-compacted storage. You can see that it took around 5 minutes to aggregate 52B data-points. This translates to a whooping &lt;strong&gt;175M elements per second&lt;/strong&gt; scan speed or 3ms execution time per series. Of cause it’s not a brute force scan.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/images/525B-benchmark-aggregate-1-thread-disk.png&quot; alt=&quot;Aggregate disk usage&quot; /&gt;&lt;/p&gt;

&lt;p&gt;Only a small portion of the storage was scanned to compute the aggregates. You can read about the algorithm that makes this possible &lt;a href=&quot;https://akumuli.org/akumuli/2018/04/28/scaleable-downsampling/&quot;&gt;here&lt;/a&gt;.
This query wasn’t limited by disk bandwidth or CPU speed but by the disk IOPS. This is a network-attached SSD and the IOPS throttled at around 3K.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/images/525B-benchmark-aggregate-1-thread-iops.png&quot; alt=&quot;Aggregate IOPS&quot; /&gt;&lt;/p&gt;

&lt;h3 id=&quot;compacted-single-aggregate&quot;&gt;Compacted Single Aggregate&lt;/h3&gt;

&lt;p&gt;If the storage is compacted aggregate query works faster. Long story short, it took &lt;strong&gt;3 seconds&lt;/strong&gt; to aggregate 52B data points.&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;Run aggregate query in one thread @ 20200303T085219
11305509        
                           
real    0m2.512s                                  
user    0m0.046s
sys     0m0.126s  
Completed @ 20200303T085222
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;&lt;img src=&quot;/images/525B-benchmark-aggregate-1-thread-compacted-cpu.png&quot; alt=&quot;Aggregate compacted CPU&quot; /&gt;&lt;/p&gt;

&lt;p&gt;The graph doesn’t have enough resolution to show that. Of cause, the reason for that performance is caching. Index nodes were compact and hot in the cache.&lt;/p&gt;

&lt;h4 id=&quot;parallel-aggregate&quot;&gt;Parallel Aggregate&lt;/h4&gt;

&lt;p&gt;Let’s run the same query using 10 threads and 10 times more data!&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/images/525B-benchmark-aggregate-10-threads-cpu.png.png&quot; alt=&quot;10 threads aggregate CPU&quot; /&gt;&lt;/p&gt;

&lt;p&gt;You can see that it took around 45 minutes to aggregate 525B data points. This was because the query hit that 3K IOPS wall.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/images/525B-benchmark-aggregate-10-threads-iops.png&quot; alt=&quot;10 threads aggregate IOPS&quot; /&gt;&lt;/p&gt;

&lt;p&gt;But it’s still good considering that we’re aggregated 1M series one by one wasting less than 3 ms per series.&lt;/p&gt;

&lt;h4 id=&quot;compacted-parallel-aggregate&quot;&gt;Compacted Parallel Aggregate&lt;/h4&gt;

&lt;p&gt;On compacted storage, the same query was way faster since it needed to touch a smaller number of disk pages.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/images/525B-benchmark-aggregate-10-threads-compacted-cpu.png&quot; alt=&quot;Aggregate 10 threads CPU&quot; /&gt;&lt;/p&gt;

&lt;p&gt;It took only &lt;strong&gt;13 seconds&lt;/strong&gt; to complete. The memory requirements for the query were quite moderate BTW.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/images/525B-benchmark-aggregate-10-threads-compacted-mem.png&quot; alt=&quot;Aggreagate 10 threads memory&quot; /&gt;&lt;/p&gt;

&lt;p&gt;You can see that the aggregate query does the job pretty quickly, but the downside is that it returns an outlier for every unique time-series. In our case, it returns 1M values which is a 100MB data transfer. This may not be what you want since your app has to parse all this data to find the top outlier among them. To get rid of this last step we can use a filter. The filter can be added to any other query (except aggregate) and it will filter out all data points that don’t match the filter requirements. Akumuli has &lt;a href=&quot;https://akumuli.org/akumuli/2018/04/28/scaleable-downsampling/&quot;&gt;some tricks under a sleeve&lt;/a&gt; to make this filter queries fast.&lt;/p&gt;

&lt;h4 id=&quot;single-filter&quot;&gt;Single Filter&lt;/h4&gt;

&lt;p&gt;Let’s see how a single filter query performs. I’d set the threshold to 95C for this workload.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/images/525B-benchmark-filter-1-thread-cpu.png&quot; alt=&quot;Filter CPU&quot; /&gt;&lt;/p&gt;

&lt;p&gt;The query completed in &lt;strong&gt;204 seconds&lt;/strong&gt;. It streamed the outliers it found to the client.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/images/525B-benchmark-filter-1-thread-net.png&quot; alt=&quot;Filter network bandwidth&quot; /&gt;&lt;/p&gt;

&lt;p&gt;Filter query doesn’t depend that much on compaction so there is no need to run it for both compacted and not compacted states.&lt;/p&gt;

&lt;h4 id=&quot;parallel-filter&quot;&gt;Parallel Filter&lt;/h4&gt;

&lt;p&gt;Let’s try the same approach using 10 queries.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/images/525B-benchmark-filter-10-threads-cpu.png&quot; alt=&quot;Filter CPU 10 threads&quot; /&gt;&lt;/p&gt;

&lt;p&gt;The filter query found every temperature reading above 95C in &lt;strong&gt;30 minutes&lt;/strong&gt;. The database transferred back around 3GB of data at 15Mbps.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/images/525B-benchmark-filter-10-threads-net.png&quot; alt=&quot;Filter network 10 threads&quot; /&gt;&lt;/p&gt;

&lt;p&gt;The bottleneck here was once again a 3000 IOPS limit. This workload can run up to 10 times faster on dedicated hardware with normal SSD (tens of thousands IOPS) or up to 100 times faster on NVMe SSD (hundreds of thousand IOPS).&lt;/p&gt;

&lt;h3 id=&quot;conclusion&quot;&gt;Conclusion&lt;/h3&gt;

&lt;p&gt;This benchmark wasn’t meant to represent real-world performance. Most likely you won’t be running queries like these and you won’t be loading the data the same way I did. If you want to benchmark Akumuli more realistically, try the &lt;a href=&quot;https://github.com/timescale/tsbs&quot;&gt;TSBS benchmark suite&lt;/a&gt;. It supports Akumuli now.&lt;/p&gt;

&lt;p&gt;Nevertheless, this shows what the database can do. Also, note that typical monitoring workloads don’t require the database to run such high-cardinality queries. And if you will run queries used in this test on single series you’ll often end up with sub-millisecond latencies. Also, note that Akumuli was never optimized for heavy queries that end up scanning the entire dataset. Given that, this is a great result. You can indeed load a huge dataset and analyze it. And most importantly, you can build interactive applications (e.g. monitoring dashboards) without much trouble.&lt;/p&gt;
</description>
        <pubDate>Tue, 10 Mar 2020 12:00:00 +0000</pubDate>
        <link>http://akumuli.org/akumuli/2020/03/10/analyzing-500B-rows/</link>
        <guid isPermaLink="true">http://akumuli.org/akumuli/2020/03/10/analyzing-500B-rows/</guid>
      </item>
    
      <item>
        <title>Runtime Cost of Write-Ahead Logging</title>
        <description>&lt;p&gt;Recent major Akumuli release added support for write-ahead logging. This is a huge milestone for several reasons.&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;It enables durable writes. Every time-series has a separate write buffer that could be lost in case of crash. Originally, Akumuli was developed with high-frequency data in mind. In this case it’s more important to restart the database after the crash as soon as possible. But if every series is updated rarely write buffers would contain hours of monitoring data.&lt;/li&gt;
  &lt;li&gt;Previously, Akumuli used to open for writing every time-series in the database. Even the ones that wasn’t updated. With new WAL individual time-series could be opened for writing only when needed and also closed when they no longer needed. This helps to deal with situation when new series are created on the fly and the cardinality grows but only a subset of series are being updated.&lt;/li&gt;
  &lt;li&gt;In the future it will allow to implement data replication via log shipping.&lt;/li&gt;
  &lt;li&gt;Also, it will allow to implement backfill. Akumuli have a means to write data to the past but the process can be efficient only if the data is batched and WAL provides the batching mechanism.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;There are some cons though. The most important one is performance degradation. Akumuli in WAL mode have to do some extra job. Every data point have to be logged and WAL volumes have to be rotated from time to time. I don’t want to go into details to much (wait for the next post) but you should know that WAL have to be updated more frequently than main storage and the data in WAL is less compact.&lt;/p&gt;

&lt;p&gt;I decided to measure the difference between Akumuli with and without WAL. Here is my test setup!&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Load generator is a standard DigitalOcean droplet with 6-cores, 16GB of RAM and 320GB of disk space.&lt;/li&gt;
  &lt;li&gt;Target server runs on general purpose DigitalOcean droplet with 8-cores, 32GB of RAM and 100GB of disk space.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both droplets have 6TB of transfer which is enough for the test.&lt;/p&gt;

&lt;h3 id=&quot;test-data&quot;&gt;Test Data&lt;/h3&gt;

&lt;p&gt;All benchmark code lives in &lt;a href=&quot;https://github.com/Lazin/roundtrip_test&quot;&gt;this repository&lt;/a&gt;. To generate the data you have to clone it and run &lt;strong&gt;bash gen.sh&lt;/strong&gt;. This script generates 6 gzip archives with test data in Akumuli format (the one that is normally used for ingestion). It takes many hours to finish and uses 222GB of disk space. This dataset should contain ~60-billion data-points from ~350000 unique time-series.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/images/walterm.png&quot; alt=&quot;Test data&quot; /&gt;&lt;/p&gt;

&lt;p&gt;Individual series are generated using random walk algorithm. Every series name has six tags.&lt;/p&gt;

&lt;h3 id=&quot;target&quot;&gt;Target&lt;/h3&gt;

&lt;p&gt;On the target machine I installed Akumuli from the &lt;a href=&quot;https://packagecloud.io/Lazin/Akumuli&quot;&gt;package repository&lt;/a&gt;. Then I configured it by running &lt;strong&gt;akumulid –init&lt;/strong&gt; and editing &lt;em&gt;~/.akumulid&lt;/em&gt; configuration file. First of all I increased number of volumes to 20 (to use 80GB of disk out of available 100GB) and commented &lt;em&gt;[WAL]&lt;/em&gt; section and all its elements. After that I created the database by running &lt;strong&gt;akumulid –create&lt;/strong&gt; and started the server by running &lt;strong&gt;akumulid&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;At this point I also setup the monitoring for both load generator and the target. I installed and configured Akumuli on load generator (all parameters left default). Also, I installed netdata on both machines. Both netdata instances was configured to send metrics in OpenTSDB format to the second Akumuli instance on load generator box. I also used &lt;em&gt;apps&lt;/em&gt; plugin to generate metrics for the akumulid process. Also, I set up Grafana and installed &lt;a href=&quot;https://grafana.com/plugins/akumuli-datasource&quot;&gt;Akumuli datasource plugin&lt;/a&gt;.&lt;/p&gt;

&lt;h3 id=&quot;running-the-tests&quot;&gt;Running The Tests&lt;/h3&gt;

&lt;p&gt;To run the test ssh to the load generator and run &lt;strong&gt;bash run.sh&lt;/strong&gt;. This script does two things. First of all it send data from every generated file to the Akumuli instance on the target machine in parallel. This is why 6-core droplet is needed. The data is sent via a bash itself using this syntax:&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;cat test-file.gz | gunzip &amp;gt; /dev/tcp/$IP/8282 &amp;amp; 
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Note that this command send uncompressed data so the actual transferred volume was not 222GB but close to 775GB.
The second thing script does happens when all data is sent. It queries Akumuli instance on the target machine trying to fetch last data-point it had sent. It’s needed to make sure that all input was processed by the database server.&lt;/p&gt;

&lt;p&gt;When &lt;strong&gt;run.sh&lt;/strong&gt; finished I recorded the results, reconfigured target machine by resetting the database via&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;akumulid --delete
akumulid --create 
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;and uncommenting the &lt;em&gt;[WAL]&lt;/em&gt; section in the configuration file. After that I started &lt;strong&gt;akumulid&lt;/strong&gt; on the target machine and &lt;strong&gt;run.sh&lt;/strong&gt; on load generator.&lt;/p&gt;

&lt;p&gt;Here is how the first run looked like.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/images/nowal1_crop.png&quot; alt=&quot;Test data&quot; /&gt;
&lt;img src=&quot;/images/nowal3_crop.png&quot; alt=&quot;Test data&quot; /&gt;&lt;/p&gt;

&lt;p&gt;And here is the second one (with WAL).&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/images/wal1_crop.png&quot; alt=&quot;Test data&quot; /&gt;
&lt;img src=&quot;/images/wal3_crop.png&quot; alt=&quot;Test data&quot; /&gt;&lt;/p&gt;

&lt;p&gt;You can see that all graphs are somewhat close. Test duration was tad longer in the second run. And the Disk I/O graph is the most interesting one. It clearly shows that second run was writing more data.&lt;/p&gt;

&lt;h3 id=&quot;result&quot;&gt;Result&lt;/h3&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt; &lt;/th&gt;
      &lt;th&gt;No WAL&lt;/th&gt;
      &lt;th&gt;WAL&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;Duration&lt;/td&gt;
      &lt;td&gt;11090 seconds (3:04:50)&lt;/td&gt;
      &lt;td&gt;13160 seconds (3:39:20)&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Write rate&lt;/td&gt;
      &lt;td&gt;5.410.244 datapoints/sec&lt;/td&gt;
      &lt;td&gt;4.559.241 datapoints/sec&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Relative speed&lt;/td&gt;
      &lt;td&gt;100%&lt;/td&gt;
      &lt;td&gt;82%&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;The results are very predictable. I had the same close to 20% slowdown on my laptop with WAL enabled. But considering the overall performance the impact is not that big. In software engineering, performance was always traded for variety of good things like security or durability. Given the fact that Akumuli scales with added CPU-cores almost linearly and that every CPU-core gives you extra +1M writes per second the choice could be easy.&lt;/p&gt;

&lt;p&gt;On the other hand, WAL uses extra disk throughput. This means that with WAL Akumuli will wear SSD faster. Also, WAL significantly increases recovery time.&lt;/p&gt;
</description>
        <pubDate>Wed, 13 Mar 2019 12:00:00 +0000</pubDate>
        <link>http://akumuli.org/akumuli/2019/03/13/benchmarking-WAL/</link>
        <guid isPermaLink="true">http://akumuli.org/akumuli/2019/03/13/benchmarking-WAL/</guid>
      </item>
    
      <item>
        <title>High-cardinality support</title>
        <description>&lt;p&gt;Every TSDB have to deal with vast data volumes. But it’s not only writes per second. 
There is a different dimension to the problem which is not tackled by most TSDB vendors. 
This problem is a dataset cardinality, the number of individual series in the database. It plays a really huge role.&lt;/p&gt;

&lt;p&gt;It’s safe to assume that any TSDB will work reasonably well if the cardinality is small (100K of metrics). But when cardinality is high (millions of metrics) we have to deal with the hard problem. Most TSDBs will start to generate write timeout errors or eat up the RAM. For instance, Akumuli will use a lot of RAM because it allocates some memory for every time-series stored on disk.&lt;/p&gt;

&lt;p&gt;Akumuli’s memory requirements depend on cardinality. To handle 1M unique time-series it needs around 10GB of RAM. 2M unique time-series will need 20GB and so on.&lt;/p&gt;

&lt;p&gt;Where did this numbers came from? First of all, Akumuli writes data in fixed-size blocks. Each block is 4KB. When you create a time-series and write data into it, Akumuli allocates a memory block and fills it with compressed data. When it gets full it writes it to disk and allocates the next one. It have to write in 4KB blocks to minimize wear leveling and write amplification of the SSD. It will always allocate 4KB of memory for every time-series for this purpose. Even if only small fraction of this memory is used. So, the memory y expenses for all series will be &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;cardinality * 4KB&lt;/code&gt;. If the database stores one million series the memory resident pages will eat around 3.8GB of RAM.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/images/4KB-page.png&quot; alt=&quot;4KB page write&quot; /&gt;&lt;/p&gt;

&lt;p&gt;This problem was greatly reduced in latest release. New algorithm allocates 4KB pages partially by 1KB chunks! When you start writing it allocates first 1KB chunk (so almost empty block will use only 1KB of RAM, not 4KB). When the first chunk gets full it allocates the second one and so on, until all four chunks (4KB) will be allocated and filled with data. Now it’s time to write it to disk.&lt;/p&gt;

&lt;p&gt;The problem is that we have four disjoint 1KB memory chunks that we need to write into one 4KB page on disk. We can’t just perform four 1KB writes because they won’t be atomic. If something will happen in the middle we will end up with partially updated block in the database! The database will be corrupted. Also, multiple updates are bad for SSDs. SSD can’t update pages in-place. It’s controller uses read-update-modify protocol to fetch old page, update it and write it to the new place. If we will write data this way it increase the wear leveling and will introduce a lot of work for the garbage collector inside the SSD controller.&lt;/p&gt;

&lt;p&gt;So, we need to write this four 1KB chunks as one 4KB block. We can actually do this using the vector I/O (aka scatter/gather I/O). Using one &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;writev&lt;/code&gt; call on Linux we can pack all four chunks into one &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;iovec&lt;/code&gt; array which will be written atomically. Using only one write operation. This method is as efficient as ordinary write operation given that it involves only one syscall. But the most important, it provides atomicity so there is no in-between state. The page will be written completely or not written at all.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/images/1KB-page.png&quot; alt=&quot;4KB page by 1KB chunks&quot; /&gt;&lt;/p&gt;

&lt;p&gt;This simple trick allows to lower memory requirements by 50%. This is due to the fact that on average every series will have just two 1KB chunks allocated. I’m not going to bore you but it’s easy to prove if you curious.&lt;/p&gt;

&lt;p&gt;This trick is already used by recent version of Akumuli but it’s not the entire solution. I’ll write about different memory optimizations later.&lt;/p&gt;
</description>
        <pubDate>Fri, 03 Aug 2018 12:00:00 +0000</pubDate>
        <link>http://akumuli.org/akumuli/2018/08/03/high-cardinality-support/</link>
        <guid isPermaLink="true">http://akumuli.org/akumuli/2018/08/03/high-cardinality-support/</guid>
      </item>
    
      <item>
        <title>Scaling TSDB-specific operations</title>
        <description>&lt;p&gt;The point of using a specialized time-series database is to have an edge over conventional databases in time-series specific operations. Most often, TSDB’s are judged by their write speed. In my opinion, the read performance is as important if not the most. Moreover, not only plain reads but filtering, aggregation, and downsampling operations which are used in every monitoring dashboard or alerting system.&lt;/p&gt;

&lt;p&gt;Many TSDB’s use rollup aggregation mechanism to enable fast data resampling and aggregation. It works the following way. The user needs to create a set of rollup aggregation rules in advance. For instance, for every time-series in the database, she may want to store raw data, average values with 5-minute step, and average values with 1-hour step. The database will update these 5-minute and 1-hour rollups periodically. The user may set different retention policies for different rollups.&lt;/p&gt;

&lt;p&gt;The problem with this approach is that the user must know in advance, what rollups she will need. Regenerating rollups can be costly and in some circumstances, impossible. Different time-series will need different aggregation function. For instance, for latencies we may want to use &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;max&lt;/code&gt; but for free disk-space &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;min&lt;/code&gt; is more suitable. Another problem is that the user must use proper rollup table in queries. Otherwise, the query will turn into a full table scan.&lt;/p&gt;

&lt;p&gt;Another often ignored problem is that not all aggregations are composable. For instance, if you have averages with the 5-minute step but you need averages with 1-hour step you can’t just use these first 5-minute averages to compute the result. You need to compute it from raw data or you will get an incorrect result.&lt;/p&gt;

&lt;p&gt;The final downside is the cardinality growth. Most modern TSDB’s struggles with high cardinality datasets (the ones that have a lot of unique time-series).&lt;/p&gt;

&lt;p&gt;Akumuli solves this problem by resampling/aggregating the data on the fly. It uses some tricks to make this fast.&lt;/p&gt;

&lt;p&gt;Akumuli stores each time-series in a B+tree. B+trees has two types of nodes. The leaf nodes are used to store compressed time-series data. Leaf nodes have fixed size. Because of that, each leaf has different capacity that depends on randomness and precision of the actual data.
The inner nodes are used to store the links to other nodes. Akumuli stores the set of aggregates (like min, max, and sum) along with child address. Each aggregate corresponds to the entire subtree. The storage engine can use these values to your advantage.&lt;/p&gt;

&lt;h2 id=&quot;aggregation&quot;&gt;Aggregation&lt;/h2&gt;

&lt;p&gt;This aggregates can be leveraged for various TSDB specific computations. The most simple use case is a data aggregation. Suppose that we need to find the largest value inside the time interval.&lt;/p&gt;

&lt;p&gt;In the simplest case, the interval will span a whole number of leaf nodes. We just need to find the root nodes of all subtrees over which the time interval spans. No matter how large the time-span is and how many data-points it contains. We will read just a handful of pages from disk and use them to compose the result.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/images/simple-aggregation.svg&quot; alt=&quot;Fig 1 - Note that there is only two links per node. Real code uses fan out ratio of 32 that makes it really hard to draw&quot; /&gt;&lt;/p&gt;

&lt;p&gt;In this example, the time interval spans over nodes 10, 11, 12, and 13. To calculate an aggregate for it we need to read the root node 1 and proceed to inner nodes 2 and 3. Both of which has the links to subtrees that have the data we need (subtree 5, 10, 11, and subtree 6, 12, 13). The algorithm can extract the aggregates from 2-&amp;gt;5 link, and from 3-&amp;gt;6 link. After that this aggregates can be combined into the final result.&lt;/p&gt;

&lt;p&gt;In the more complex scenario, the interval will span a fractional number of leaf nodes. The beginning and the end of the interval will be located in the middle of the corresponding leaf nodes. But here we may use divide and conquer approach. We can split the task into three parts:&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;Read the first page that partially crosses the time-interval. This will read the actual leaf node from disk, decompress it and compute &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;max&lt;/code&gt; for every data-point that sits inside the interval.&lt;/li&gt;
  &lt;li&gt;Compute the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;max&lt;/code&gt; for the subtrees that sits inside the interval entirely, as described above.&lt;/li&gt;
  &lt;li&gt;Read the last page that partially crosses the time-interval, as in 1).&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;After that, we can compute final result by composing three &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;max&lt;/code&gt; values (we should just get the largest one).&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/images/generic-aggregation.svg&quot; alt=&quot;Fig 2 - Note that there is only two links per node. Real code uses fan out ratio of 32 that makes it really hard to draw&quot; /&gt;&lt;/p&gt;

&lt;p&gt;For instance, let’s look at figure 2.&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;First, we need to read page 9 (by following the path 1-&amp;gt;2-&amp;gt;4) and find the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;max&lt;/code&gt; value.&lt;/li&gt;
  &lt;li&gt;After that, we need to compute the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;max&lt;/code&gt; value in pages 10..13 by using pages 2 and 3 (the same way as in the previous example).&lt;/li&gt;
  &lt;li&gt;And finally, we need to read page 14 (by following the 1-&amp;gt;3-&amp;gt;7 path) and find the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;max&lt;/code&gt; value.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2 id=&quot;downsampling&quot;&gt;Downsampling&lt;/h2&gt;

&lt;p&gt;First of all, ‘group-aggregate’ query in Akumuli is not supposed to outperform pre-computed rollups. It supposed to be used in conjunction with Grafana and similar interactive tools. Because of this, some assumptions have to be made. Let’s state that the user usually reads the same number of data points with varying step and interval. This makes perfect sense for Grafana. If the size of the graph doesn’t change Grahana will vary the step and time interval in such way the number of points will be about the same. For instance, if the interval is 30 minutes, the step will be 1 second, but if the interval is 3 hours the step will be increased to 6 seconds or so. In both cases, the database should return about the same number of points.&lt;/p&gt;

&lt;p&gt;Let’s look at naive approach first. The naive algorithm scans the series and calculates the downsampled series. For small steps, it’s a pretty good algorithm. But when the step becomes larger the amount of I/O needed growth linearly.&lt;/p&gt;

&lt;p&gt;The optimized version utilizes aggregation as a subroutine. For every step interval, we should compute an aggregate. It’s done using the algorithm described above. The only trick is that the step 3) of the first interval and step 1) of the next step are combined. This algorithm requires the same amount of I/O as naive algorithm if the step is small. But the amount of I/O operations wouldn’t grow linearly. It have an upper bound that depends on the number of extracted data-points.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/images/downsampling.svg&quot; alt=&quot;Fig 3 - Note that there is only two links per node. Real code uses fan out ratio of 32 that makes it really hard to draw&quot; /&gt;&lt;/p&gt;

&lt;p&gt;Let’s perform some back of the envelope calculations. Let’s state that 1 block always has 1300 data points with 1-second step (around 3 bytes per data-point) and the query should return 400 data-points.&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt; &lt;/th&gt;
      &lt;th&gt;1 sec&lt;/th&gt;
      &lt;th&gt;30 sec&lt;/th&gt;
      &lt;th&gt;5 min&lt;/th&gt;
      &lt;th&gt;30 min&lt;/th&gt;
      &lt;th&gt;1 hour&lt;/th&gt;
      &lt;th&gt;12 hour&lt;/th&gt;
      &lt;th&gt;1 day&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;Optimized&lt;/td&gt;
      &lt;td&gt;4KB&lt;/td&gt;
      &lt;td&gt;37KB&lt;/td&gt;
      &lt;td&gt;370KB&lt;/td&gt;
      &lt;td&gt;1.5MB&lt;/td&gt;
      &lt;td&gt;1.5MB&lt;/td&gt;
      &lt;td&gt;1.5MB&lt;/td&gt;
      &lt;td&gt;1.5MB&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Naive&lt;/td&gt;
      &lt;td&gt;4KB&lt;/td&gt;
      &lt;td&gt;37KB&lt;/td&gt;
      &lt;td&gt;370KB&lt;/td&gt;
      &lt;td&gt;2.1MB&lt;/td&gt;
      &lt;td&gt;4.3MB&lt;/td&gt;
      &lt;td&gt;52MB&lt;/td&gt;
      &lt;td&gt;104MB&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;&lt;img src=&quot;/images/downsampl-perf-chart.png&quot; alt=&quot;Fig 4&quot; /&gt;&lt;/p&gt;

&lt;p&gt;The naive approach will require the database to read around 37KB if the step is 30-seconds but if the step is 1-day the algorithm will read more than 100MB of data from disk. For the same query, the algorithm used by Akumuli will read only about 1.5MB. This is quite expensive compared to the rollup query. But on the other hand, it’s comparable with simple image download from a server.&lt;/p&gt;

&lt;h2 id=&quot;filtering&quot;&gt;Filtering&lt;/h2&gt;

&lt;p&gt;Akumuli also supports &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;filter&lt;/code&gt; statement that can be added to &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;select&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;join&lt;/code&gt;, and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;group-aggregate&lt;/code&gt; queries. This statement allows filtering time-series by value. This operation also utilizes pre-computed aggregates stored inside the B+tree nodes. The optimization is pretty simple. As the algorithm traverses the tree it may check does the link has any values that match the filter. If the subtree doesn’t have any it can be omitted. For instance, if we filtering out all values below 100 and the link to subtree has &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;max=90&lt;/code&gt; than we can safely prune the subtree from the search.&lt;/p&gt;

&lt;p&gt;This approach works really well if we need to filter out everything but outliers. For instance, if no data point matches the filter the query will touch only root node of the tree. If all data points that match the filter are located in one leaf-node, the query will load only a couple of nodes. It will start from the root node and will follow the path until it reaches the leaf-node that has all the data we need.&lt;/p&gt;

&lt;h2 id=&quot;conslusions&quot;&gt;Conslusions&lt;/h2&gt;

&lt;p&gt;These optimizations make a huge difference for many types of queries. An &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;aggregate&lt;/code&gt; query execution lies within a millisecond range no matter the dataset. This means that it can be used as a subroutine in more complex algorithms. The &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;filter&lt;/code&gt; query performance depends only on the number of returned data-points and works really fast if you need to search for outliers. And the downsampling aggregation can finally be used in interactive applications without any prior knowledge about the data and configuration.&lt;/p&gt;
</description>
        <pubDate>Sat, 28 Apr 2018 12:00:00 +0000</pubDate>
        <link>http://akumuli.org/akumuli/2018/04/28/scaleable-downsampling/</link>
        <guid isPermaLink="true">http://akumuli.org/akumuli/2018/04/28/scaleable-downsampling/</guid>
      </item>
    
      <item>
        <title>Inverted Index</title>
        <description>&lt;p&gt;Tag support is very important for any modern time-series database. The world from which time-series data is coming is complex. Time-series data is not just a time-ordered values (measurements), this time ordered values form individual series and different series can relate to each other in numerous ways. The simplest example is an object that produces many measurements of different types. E.g. the server can have hundreds of different metrics like “CPU User”, “CPU System”, but more interestingly, it can have series names like “Number of software interrupts/sec of type X on core=Y” metric.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/images/softirqs.png&quot; alt=&quot;Fig 0&quot; /&gt;&lt;/p&gt;

&lt;p&gt;The particular series may look like this: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;proc.softirqs host=dev cpu=0 type=SCHED&lt;/code&gt;. The &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;type&lt;/code&gt; tag may have tens of possible values as well as the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;cpu&lt;/code&gt; tag. This can be expressed using hierarchical naming scheme of Graphite in several ways, e.g. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;proc.softirqs.dev.0.SCHED&lt;/code&gt; or &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;proc.softirqs.dev.SCHED.0&lt;/code&gt; but here is the problem, the database user should know what each level of the name means! The hierarchical naming scheme is not self-descriptive. There should be a schema somewhere, it should be available to both readers and writers and it’s not stored in the database! So each agent on every machine should use &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;proc.softirqs.dev.0.SCHED&lt;/code&gt; and not &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;proc.softirqs.dev.SCHED.0&lt;/code&gt; otherwise, you’re doomed. And when you see this &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;proc.softirqs.dev.0.SCHED&lt;/code&gt; name you may only guess what this bloody ‘0’ means.
The most critical problem is that this schema enforces hierarchy even when there is no hierarchy at all. In our example, both &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;type&lt;/code&gt; and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;cpu&lt;/code&gt; names are equally important.&lt;/p&gt;

&lt;p&gt;In Akumuli the tags are supported from the start. There is no fields or tables since everything can be modeled using tags. Tags not only can but should be redundant. There is a hard limit on series name length thought (4KB). Akumuli uses some modern techniques to enable this multi-dimensional queries and joins using tags. I’ll try to explain them in this article.&lt;/p&gt;

&lt;h2 id=&quot;forward-search&quot;&gt;Forward Search&lt;/h2&gt;

&lt;p&gt;At the beginning, there was no index at all. Akumuli had been storing all series names in RAM in the string pool. There was a hash table that mapped ids to offsets inside the string pool, and the other hash table that mapped offsets to ids. New names were simply added to the string pool. Each series name was converted to the canonical form. In the canonical form, all unnecessary whitespace characters were removed and tags were sorted alphabetically.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/images/memview.png&quot; alt=&quot;Fig 1&quot; /&gt;&lt;/p&gt;

&lt;p&gt;To query this data structure regular expressions were used. Since all series names were stored contiguously it was possible to search the entire set of names using one regex search. This is quite dumb but it worked really well and it was quite fast. Regular expression libraries are fast and you can search through hundreds of megabytes of text in milliseconds. In IR this is called a forward search. Normal search engines used to deal with large text documents so forward search is not an option but time-series names are short so, one can use forward search with acceptable results even in interactive applications.&lt;/p&gt;

&lt;h2 id=&quot;inverted-index&quot;&gt;Inverted Index&lt;/h2&gt;

&lt;p&gt;However, some time ago I started to work on Grafana data source plugin. This plugin needed an autocomplete functionality from the database. The autocomplete is implemented using three queries:&lt;/p&gt;
&lt;ul&gt;
  &lt;li&gt;suggest metric names by prefix (proc.cpu.???);&lt;/li&gt;
  &lt;li&gt;suggest tag name by tag prefix and metric name (proc.cpu.user hos???);&lt;/li&gt;
  &lt;li&gt;suggest tag value by value prefix, tag name, and metric name (proc.cpu.user host=dev-???);&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It turned out that it’s problematic to implement this queries using regular expressions so I decided to implement an inverted index. In normal search engines, inverted index maps words to documents so all documents containing particular word combinations can be found without scanning each and every one of them. In Akumuli I’m using tag=value pairs instead of words and series names instead of documents. Check this example:&lt;/p&gt;

&lt;p&gt;Let’s say that the data inside the index looks like this:&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Id&lt;/th&gt;
      &lt;th&gt;Series Name&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;0&lt;/td&gt;
      &lt;td&gt;proc.softirqs host=dev cpu=0 type=SCHED&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;1&lt;/td&gt;
      &lt;td&gt;proc.softirqs host=dev cpu=1 type=SCHED&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;2&lt;/td&gt;
      &lt;td&gt;proc.softirqs host=dev cpu=0 type=TIMER&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;3&lt;/td&gt;
      &lt;td&gt;proc.softirqs host=dev cpu=1 type=TIMER&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;4&lt;/td&gt;
      &lt;td&gt;proc.softirqs host=test cpu=0 type=SCHED&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;5&lt;/td&gt;
      &lt;td&gt;proc.softirqs host=test cpu=1 type=SCHED&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;6&lt;/td&gt;
      &lt;td&gt;proc.softirqs host=test cpu=2 type=SCHED&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;7&lt;/td&gt;
      &lt;td&gt;proc.softirqs host=test cpu=3 type=SCHED&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;8&lt;/td&gt;
      &lt;td&gt;proc.softirqs host=test cpu=0 type=TIMER&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;9&lt;/td&gt;
      &lt;td&gt;proc.softirqs host=test cpu=1 type=TIMER&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;A&lt;/td&gt;
      &lt;td&gt;proc.softirqs host=test cpu=2 type=TIMER&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;B&lt;/td&gt;
      &lt;td&gt;proc.softirqs host=test cpu=3 type=TIMER&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;In this case these posting lists will be built:&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Tag-Value pair&lt;/th&gt;
      &lt;th&gt;Posting list&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;host=dev&lt;/td&gt;
      &lt;td&gt;0, 1&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;host=test&lt;/td&gt;
      &lt;td&gt;3, 4, 5, 6, 7, 8, 9, A, B&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;cpu=0&lt;/td&gt;
      &lt;td&gt;0, 2, 4, 8&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;cpu=1&lt;/td&gt;
      &lt;td&gt;1, 3, 5, 9&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;cpu=2&lt;/td&gt;
      &lt;td&gt;6, A&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;cpu=3&lt;/td&gt;
      &lt;td&gt;7, B&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;type=SCHED&lt;/td&gt;
      &lt;td&gt;0, 1, 4, 5, 6, 7&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;type=TIMER&lt;/td&gt;
      &lt;td&gt;2, 3, 8, 9, A, B&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;So, suppose we need to find all the metrics from host ‘test’ that has a ‘SCHED’ type. We should read ‘host=test’ and ‘type=SCHED’ posting lists and find the intersection:&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Tag-Value pair&lt;/th&gt;
      &lt;th&gt;Posting list&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;host=test&lt;/td&gt;
      &lt;td&gt;3, 4, 5, 6, 7, 8, 9, A, B&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;type=SCHED&lt;/td&gt;
      &lt;td&gt;0, 1, 4, 5, 6, 7&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;Query result is 4, 5, 6, 7:&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Id&lt;/th&gt;
      &lt;th&gt;Series Name&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;4&lt;/td&gt;
      &lt;td&gt;proc.softirqs host=test cpu=0 type=SCHED&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;5&lt;/td&gt;
      &lt;td&gt;proc.softirqs host=test cpu=1 type=SCHED&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;6&lt;/td&gt;
      &lt;td&gt;proc.softirqs host=test cpu=2 type=SCHED&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;7&lt;/td&gt;
      &lt;td&gt;proc.softirqs host=test cpu=3 type=SCHED&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;That’s some basic &lt;a href=&quot;https://en.wikipedia.org/wiki/Standard_Boolean_model&quot;&gt;IR&lt;/a&gt; stuff.&lt;/p&gt;

&lt;h2 id=&quot;optimizations&quot;&gt;Optimizations&lt;/h2&gt;

&lt;p&gt;The main problem that I encountered was a high memory usage. Each series has a dozen of tags. Each tag-value pair has an associated postings list (an array that contains id’s of all series that contains this tag-value pair) so a value should be added to a dozen of posting lists. The value is 64-bit wide and a cardinality can be huge so these posting lists grow big. Apart from posting lists, I used series names as keys in a hash table that been used to map them to ids.&lt;/p&gt;

&lt;p&gt;The first problem was solved by compressing posting lists in memory. Each posting list is just a sorted array of 64-bit integers. I started to build positing lists using delta encoding combined with LEB-128 and get 4x improvement as a result.&lt;/p&gt;

&lt;p&gt;The second problem was solved using a string pool. I started to use the pointer to the value inside the string pool as a hash table key.&lt;/p&gt;

&lt;h2 id=&quot;high-cardinality&quot;&gt;High Cardinality&lt;/h2&gt;

&lt;p&gt;The most complex problem that I have only appeared with high-cardinality data sets. There is quite a lot of unique tag-value pairs inside high-cardinality data sets. As result, a lot of small posting lists should be created. Compression won’t be effective in this situation (since every posting list will contain small number of ids) and memory fragmentation will become a problem.&lt;/p&gt;

&lt;p&gt;This problem was solved using &lt;a href=&quot;https://en.wikipedia.org/wiki/Count%E2%80%93min_sketch&quot;&gt;Count-Min Sketch&lt;/a&gt;. The normal CM-sketch used to count events. It consists of several arrays of counters. When the new event arrives, we should calculate N different hash values (N is a number of arrays we have), locate N counters, and increment them. To estimate the count for the event we should do almost the same - calculate the hashes, locate the counters but instead of incrementing them we should get the smallest one. This guarantees that hash collisions will affect the result the least.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/images/count-min1.jpg&quot; alt=&quot;Fig 2&quot; /&gt;&lt;/p&gt;

&lt;p&gt;In Akumuli’s index, the counters were replaced by the posting lists. When new series shows up, we need to compute N=3 hashes, locate 3 posting list, and add the new element to every one of them. As result, each posting list will have values from many tag-value pairs, not just one.
To read the posting list we should do the same trick. Calculate 3 hashes, read 3 posting list, and then find the intersection between all of them. In a case of hash collision, the id of the series will end up in one posting lists out of three (way less likely in two or all of them). To eliminate the collisions further we should check that every series name that we’ve found contains the tag-value pair that we need using simple substring search.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/images/sketch_index1.png&quot; alt=&quot;Fig 3&quot; /&gt;&lt;/p&gt;

&lt;p&gt;This trick saves the day when we have a high cardinality set of tags. In case of Akumuli, the size of the table is fixed so there is no unneeded allocations and fragmentation. This inverted index can store really high number of series names and query it very efficiently.&lt;/p&gt;

</description>
        <pubDate>Fri, 17 Nov 2017 22:00:00 +0000</pubDate>
        <link>http://akumuli.org/akumuli/2017/11/17/indexing/</link>
        <guid isPermaLink="true">http://akumuli.org/akumuli/2017/11/17/indexing/</guid>
      </item>
    
      <item>
        <title>Storage engine design (part 2)</title>
        <description>&lt;p&gt;In the previous article, I wrote about the reasons that made me choose B+tree based data-structure for &lt;a href=&quot;https://github.com/akumuli/Akumuli&quot;&gt;Akumuli&lt;/a&gt;. In this article, I want to tell about another advantage of the B+tree based design compared to LSM-tree.&lt;/p&gt;

&lt;h3 id=&quot;a-shallow-introduction-into-the-lsm-tree-algorithm&quot;&gt;A shallow introduction into the LSM-tree algorithm&lt;/h3&gt;

&lt;p&gt;LSM-tree was designed for the world where the sequential read and write throughput was relatively high and the random read and write throughput was relatively low. In a nutshell, it trades increased I/O bandwidth use for reduced random I/O. LSM-tree is composed of several components (C0, C1, …, CK). The C0 component is usually kept in memory, new records are added to the C0 component until it gets full. When this happens the C0 component can be sorted by key and written to disk into C1 component. The C1 components can be merged into C2 components and so on. All this operations are sequential.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/images/media-20170801.png&quot; alt=&quot;Fig 1&quot; /&gt;&lt;/p&gt;

&lt;p&gt;(source: &lt;a href=&quot;http://www.cs.umb.edu/~poneil/lsmtree.pdf&quot;&gt;http://www.cs.umb.edu/~poneil/lsmtree.pdf&lt;/a&gt;)&lt;/p&gt;

&lt;p&gt;To read the data we may need to scan a range of keys inside the C0, C1, …CK components and merge the results. Each scan is a sequential read operation. To locate the begining of the range inside each component we can use binary search because each storage component is sorted by key.&lt;/p&gt;

&lt;h3 id=&quot;the-problem&quot;&gt;The Problem&lt;/h3&gt;

&lt;p&gt;What’s the problem here, one may ask. The problem is that we need to read old data to be able to write the new! The same data gets written to disk many times during every merge. The key spaces of different storage components can overlap, thus we need to actually read the data and perform the 2-way merge and write the result! E.g. it’s quite possible that we may need to read much more than 1GB from disk (the existing storage component and the new one) to add 1GB of data to the database. In addition to this, we may need to run compaction procedure from time to time.&lt;/p&gt;

&lt;p&gt;The typical TSDB workload looks like this, the writers are always active and the write rate is about the same (and quite high) all the time, but the readers are also active. It’s quite possible that the TSDB should send data to Grafana, and BI tools, and some background batch jobs, all at the same time. The database built on top on top of LSM-tree often needs to perform large sequential scans to read the required data. This processes can consume all read throughput of the storage device!&lt;/p&gt;

&lt;p&gt;But the lack of the read bandwidth can stop merge and compaction procedures from keeping up with the writing process. Because we need to read data during the merge and during the compaction the writing process can be quite slow if the readers are too active. If compaction is not keeping up with writes, we may run out of disk space. If merge process is not fast enough, we may run out of file descriptors. The other possible problem is that the reads and the startup time can become slower if the merge is not keeping up because we’ll need to read from many unmerged files.&lt;/p&gt;

&lt;p&gt;Another incarnation of the problem is ZFS compaction. Some time-series databases are relying on ZFS for compression. They write uncompressed data to disk in a format that can be easily compressed by the general purpose compression algorithms threefold. At some point, the ZFS will kick in and compress this data down to several bytes per data point but before that each data point will use 16 or even 24 bytes. I never saw this behavior in real life but I suspect that it’s quite possible that ZFS compression can be unable to keep up with the write rate if the readers are too active. In this case, we may also run out of disk space pretty soon.&lt;/p&gt;

&lt;h3 id=&quot;is-there-anything-we-can-do-about-it&quot;&gt;Is there anything we can do about it?&lt;/h3&gt;

&lt;p&gt;Let’s see how Akumuli improves this situation. First and foremost, Akumuli doesn’t need to read anything to be able to write new data. Well, it needs to read old data if you want to insert something or update or delete (and these operations is not fully implemented yet) but if you’re adding new data with increasing timestamps there will be no reads at all.&lt;/p&gt;

&lt;p&gt;Another property that greatly reduces the hassle between readers and writers is that all I/O is aligned. If you’re appending new data to a file unaligned, the system will read old data first. As result, the read throughput will be wasted even if your algorithm doesn’t need to read anything at all. I can recommend &lt;a href=&quot;https://www.usenix.org/system/files/conference/inflow14/inflow14-yang.pdf&quot;&gt;this fantastic paper&lt;/a&gt; if you want to dig deeper into the problem of the unaligned writes.&lt;/p&gt;

&lt;p&gt;In the past the seek time of the hard drives was the main limiting factor but today the limiting factor is a bandwidth of the storage device. Modern SSD and NVMe drives may have very small latency and great random I/O throughput but the bandwidth is still limited. Because of this, the old optimization strategies (linearize all the things!) is not that effective anymore. Being able to read and write data only sequentially doesn’t hurt at all but the ability to read and write data precisely is more important because it saves the I/O bandwidth. Also, it worth to mention that this design makes the database prepared for the next gen &lt;a href=&quot;https://arstechnica.com/information-technology/2017/03/intels-first-optane-ssd-375gb-that-you-can-also-use-as-ram/&quot;&gt;byte-addressable non-volatile memory&lt;/a&gt;.&lt;/p&gt;

&lt;h3 id=&quot;no-free-lunch&quot;&gt;No free lunch&lt;/h3&gt;

&lt;p&gt;There are downsides of cause. There is a &lt;a href=&quot;http://daslab.seas.harvard.edu/rum-conjecture/&quot;&gt;RUM conjecture&lt;/a&gt; that states that we can’t build the system that doesn’t have any read overhead, no update overhead, and no memory overhead at the same time. Akumuli sacrifices memory overhead to have great read and write performance. It needs to store some information in memory to enable this “blind” writes. As a result, the memory footprint grows with the cardinality of the data set (number of metrics). Just to be honest, you may need up to 16GB of RAM to work with 1M unique time-series, 32GB for 2M and so on. I’m planning to add an ability to disable “blind” writes to support higher cardinalities and limit memory use in the future. But this will come at the expense of decreased write throughput. This is a trade off to think about.&lt;/p&gt;
</description>
        <pubDate>Tue, 01 Aug 2017 13:00:00 +0000</pubDate>
        <link>http://akumuli.org/akumuli/2017/08/01/storage-engine-design2/</link>
        <guid isPermaLink="true">http://akumuli.org/akumuli/2017/08/01/storage-engine-design2/</guid>
      </item>
    
      <item>
        <title>Storage engine design</title>
        <description>&lt;p&gt;In time-series databases the querying pattern differs from the write pattern. We usually write data in time order by updating many series every second. But querying is a different story. We usually want to read only a handful of series leaving most of the data behind. Here lies the biggest problem, which is we don’t know how data will be read and can’t put the data that will be read together close on disk. However, at least we can put each series to the separate file. Graphite does this and it somewhat works (with a fair amount of batching and in-memory caching).&lt;/p&gt;

&lt;p&gt;Some other TSDBs use &lt;a href=&quot;https://en.wikipedia.org/wiki/Log-structured_merge-tree&quot;&gt;LSM-tree&lt;/a&gt; or similar structures. In these databases, the LSM-tree partitions the key space in the time dimension and the data points are stored in chunks in some column-oriented format that allows locating individual series quickly. Each chunk stores data points from many series (not necessarily from all of them). This design leads to high read amplification because it’s impossible to read only the data we need without reading and decompressing all the other data in the chunk. Another problem is data in the chunk is not aligned. This is a fundamental limitation; the data of the individual series in the chunk can’t be properly aligned by block boundary because everything is partitioned by time.&lt;/p&gt;

&lt;p&gt;Akumuli’s goal is to maintain separate disk backed data structure for each series in the database and to make everything aligned on a page-sized boundary to minimize read amplification. This is not feasible with LSM-trees for many reasons:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;LSM tree needs lots of RAM for memory resident part to batch up many values into one large write operation.&lt;/li&gt;
  &lt;li&gt;High write amplification. Each data element gets written to disk several times. When dealing with time-series data, this seemed unnecessary because most of the data is never changed.&lt;/li&gt;
  &lt;li&gt;Each SSTable is stored in its own file. Because of this, the database must open many files simultaneously. Storing each time-series in its own LSM-tree is not feasible. We will need a handful of files for each time-series and we might want to have millions of them.&lt;/li&gt;
  &lt;li&gt;Merging SSTables is expensive. We must read two or more files and write another one. If we want to store each time-series in its own tree, we will need to perform this operation frequently.&lt;/li&gt;
  &lt;li&gt;This design was created for spinning disks, not for modern era SSD and NVMe drives. Random wires are not that slow anymore.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is the reason for the LSM-tree based design described above.&lt;/p&gt;

&lt;h3 id=&quot;meet-the-numeric-btree&quot;&gt;Meet the Numeric B+tree&lt;/h3&gt;

&lt;p&gt;Akumuli is based on novel data-structure called “Numeric B+tree”. It’s somewhat close to the LSM tree but has some nice properties that the latter doesn’t have. BTW, it’s called “numeric” only because it’s supposed to store numeric data (timestamps and values). The data structure itself can be described as B+LSM-tree. Think about LSM-tree but with B+trees instead of SSTables.&lt;/p&gt;

&lt;p&gt;Akumuli uses timestamps as keys and keys should be inserted in increasing order (backfill can be implemented but this goes far beyond the subject of this article). This means that timestamps in each B+tree don’t overlap and that we can build full trees incrementally &lt;a href=&quot;https://en.wikipedia.org/wiki/B%2B_tree#Insertion&quot;&gt;without node splitting&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Each NB+tree instance is a series of extents, each extent is a B+tree instance.&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;The first extent is a single leaf node of the B+tree stored in memory;&lt;/li&gt;
  &lt;li&gt;The second extent is a B+tree of height 2 (an inner node that stores up to 32 references to leaf nodes);&lt;/li&gt;
  &lt;li&gt;The third extent is a B+tree of height 3 (an inner node that stores up to 32 references to inner nodes that stores references to leaf nodes);&lt;/li&gt;
  &lt;li&gt;Etc.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each extent can be seen as a single inner node that stores references to extents of the previous level (e.g. level 3 extent is a single inner node that stores references to level 2 extents, whiile level 2 extent is a single inner node that stores references to level 1 extents/leaf nodes). This property allows us to build extents easily.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/images/NBtreeExtents_no_shadow.png&quot; alt=&quot;Fig 1&quot; /&gt;&lt;/p&gt;

&lt;p&gt;The picture shows B+trees with three extents and a fan-out ratio of 4 but real B+tree used by Akumuli have a fan-out ratio of 32. Note that each B+tree can be incomplete only from the right.&lt;/p&gt;

&lt;h4 id=&quot;tree-construction&quot;&gt;Tree construction&lt;/h4&gt;

&lt;p&gt;The tree construction algorithm is simple:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Write new values to the level-1 extent (memory resident leaf node);&lt;/li&gt;
  &lt;li&gt;If level-1 extent is full - write it to disk and add reference to it to the level-2 extent;&lt;/li&gt;
  &lt;li&gt;If level-2 extent is full - write its root node to disk and add reference to it to the level-3 extent;&lt;/li&gt;
  &lt;li&gt;Etc.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;On the picture above, when the level 1 extent will be full, the leaf node will be committed to disk and take its place in level 2 extent (showed by dash lines), this will make the level 2 extent full and cause it to be merged with level 3 extent (showed by dash lines as well). After that, the level 4 extent will be created and extents 1-3 will become empty.&lt;/p&gt;

&lt;p&gt;This process is an equivalent of the SSTable merge in LSM-tree. The number of extents is expected to be small because each leaf node contains many values (it contains a variable number of values because of compression) and the fan-out ratio is 32. Let’s do some back of the envelope calculations! If leaf node can store about 1000 data points than level 2 extent will be able to store 32000 data points and level 3 will be able to store more than 10 million. Akumuli limits the number of extents in each tree by 10. This is more than enough because with 10 levels we can store a series with more than 10^16 data points, e.g this is enough to store more than 300 years of data with microsecond precision.&lt;/p&gt;

&lt;p&gt;Here come the advantages of the NB+tree over the LSM-tree:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Memory resident part of the tree is small (single leaf node + several inner nodes);&lt;/li&gt;
  &lt;li&gt;Many trees can live in one file and blocks of different trees can be interleaved on the storage level;&lt;/li&gt;
  &lt;li&gt;Merge is very efficient. It’s enough to just add an address of the root node of the one extent to the other extent; no need to read the data and merge anything.&lt;/li&gt;
  &lt;li&gt;We can store some information inside inner nodes to speed up aggregation and resampling of the time-series without maintaining some external data-structures (rollups) or indexes.&lt;/li&gt;
  &lt;li&gt;Parallel operation. It is possible to write to the database from many writer threads (and Akumuli is doing this).&lt;/li&gt;
  &lt;li&gt;Good for modern SSDs. All writes are aligned and performed in parallel. All tree nodes have the same size.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This data-structure allows Akumuli to maintain separate NB+tree instance for every time-series in the database. The primary disadvantage of this design is the complexity of the crash recovery process. It’s impossible to maintain WAL or command log per NB+tree instance. Akumuli uses a different approach to crash recovery that’s not discussed in this article but you can find &lt;a href=&quot;https://docs.google.com/document/d/1jFK8E3CZSqR5IPsMGojm2LknkNyUZA7tY51N6IgzW_g/pub&quot;&gt;more details about it in this article&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Ability to build separate NB+tree instance for every time-series is truly important. It enables queries in column-oriented fashion and, at the same time, it enables really fast parallel writes with very small write amplification. My recent experiment showed that it can write about &lt;a href=&quot;http://akumuli.org/akumuli/2017/03/10/benchmark2/&quot;&gt;16 million data points per second on a c3.8xlarge instance&lt;/a&gt;.&lt;/p&gt;

&lt;h5 id=&quot;update&quot;&gt;Update&lt;/h5&gt;

&lt;p&gt;&lt;a href=&quot;https://news.ycombinator.com/item?id=14245603&quot;&gt;Hacker News discussion.&lt;/a&gt;&lt;/p&gt;
</description>
        <pubDate>Sat, 29 Apr 2017 18:00:00 +0000</pubDate>
        <link>http://akumuli.org/akumuli/2017/04/29/nbplustree/</link>
        <guid isPermaLink="true">http://akumuli.org/akumuli/2017/04/29/nbplustree/</guid>
      </item>
    
      <item>
        <title>Benchmarking Akumuli on 32-core machine</title>
        <description>&lt;p&gt;Recently I &lt;a href=&quot;http://akumuli.org/akumuli/2017/02/13/benchmark/&quot;&gt;tested Akumulil&lt;/a&gt; on the m3.2xlarge EC2 instance. Write throughput was around 4.5 million elements/second. This number might look unrealistic at first but in fact, that’s less than 20MB/s of disk write throughput because each data point is tiny (less than five bytes in that particular case) and all data is compressed in real time.&lt;/p&gt;

&lt;p&gt;Next step was to try it on a bigger machine. I chose c3.8xlarge EC2 instance with 32-core Intel Xeon E5-2680 v2 and SSD. This is what I did:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;I created test data beforehand (32 gzip archives, 16GB compressed) and uploaded it to S3.&lt;/li&gt;
  &lt;li&gt;Then I created 4 m3.xlarge EC2 instances to generate load.&lt;/li&gt;
  &lt;li&gt;From each m3.xlarge instance, I downloaded the test data.&lt;/li&gt;
  &lt;li&gt;Then I started a single c3.8xlarge instance, build and installed Akumuli, set &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;TCP.pool_size&lt;/code&gt; parameter to 32, created the database and started the network daemon.&lt;/li&gt;
  &lt;li&gt;After that I downloaded and edited the &lt;a href=&quot;https://github.com/akumuli/test_input_generator/blob/master/run.sh&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;run.sh&lt;/code&gt;&lt;/a&gt; script on each m3.xlarge box. I set &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;TARGET_HOST&lt;/code&gt; variable correctly and decreased the number of archives from 32 to 8 (each host had been sending the unique subset of test data).&lt;/li&gt;
  &lt;li&gt;Then I started &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;run.sh&lt;/code&gt; on all machines simultaneously using &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;parallel-ssh&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;During the test run, each m3.xlarge instance had been sending data in parallel using 8 threads (32 threads total).  All this data was written to disk in parallel on the c3.8xlarge instance. This is how everything looked in &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;htop&lt;/code&gt;:&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/images/32corerun.png&quot; alt=&quot;c3.8xlarge htop&quot; /&gt;&lt;/p&gt;

&lt;p&gt;The dataset contained 2764832000 data points in 32000 series.
All nodes finished sending data in less than 3 minutes and write throughput was above 16 million elements/second. Resulting database size was 11GB. SSD drive was underutilized, it can write far more than 64MB/s that was demanded by this test run.&lt;/p&gt;

&lt;p&gt;This puts Akumuli in the same ballpark as &lt;a href=&quot;http://btrdb.io/&quot;&gt;BTrDB&lt;/a&gt;. As far as I know, these are the only two open source time-series databases that can write data in parallel. All other solutions still struggle with single writer solutions.&lt;/p&gt;
</description>
        <pubDate>Fri, 10 Mar 2017 18:00:00 +0000</pubDate>
        <link>http://akumuli.org/akumuli/2017/03/10/benchmark2/</link>
        <guid isPermaLink="true">http://akumuli.org/akumuli/2017/03/10/benchmark2/</guid>
      </item>
    
      <item>
        <title>Understanding Akumuli Performance</title>
        <description>&lt;p&gt;Akumuli was designed with performance in mind from the very beginning. I set the lower bound for the write throughput at the 1M writes/second level as one of the project goals. Every version so far delivers this performance, that’s why this number is mentioned on the &lt;a href=&quot;https://github.com/akumuli/Akumuli&quot;&gt;project page&lt;/a&gt;. But this is only a lower bound. It would be interesting to see what level of performance is achievable with today’s hardware!&lt;/p&gt;

&lt;p&gt;To answer this question I tested Akumuli on AWS and it managed to sustain 4.5 million write operations per second on a dedicated m3.2xlarge node. This isn’t a peak but steady write throughput over the network. And my tests showed that it scales almost linearly on a multicore machine.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/images/benchmark_bar_plot.png&quot; alt=&quot;Akumuli benchmark results on AWS m3.2xlarge instance&quot; /&gt;&lt;/p&gt;

&lt;h3 id=&quot;methodology&quot;&gt;Methodology&lt;/h3&gt;

&lt;p&gt;Akumuli has a configuration option called &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;TCP.pool_size&lt;/code&gt;. It controls the server side parallelism. By default, this option is set to one. But it can be increased on multicore machine. I ran the benchmark with different &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;TCP.pool_size&lt;/code&gt; values on the eight core machine and here are the results:&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Number of threads&lt;/th&gt;
      &lt;th&gt;Throughput datapoints/sec&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;1&lt;/td&gt;
      &lt;td&gt;693 984&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;4&lt;/td&gt;
      &lt;td&gt;2 433 831&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;8&lt;/td&gt;
      &lt;td&gt;4 547 421&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;The input was constructed using &lt;a href=&quot;https://github.com/akumuli/test_input_generator&quot;&gt;this script&lt;/a&gt;. All test data was generated beforehand and was divided into eight independent archives (each archive had its own set of time-series). Each archive contained exactly 86401000 data elements. All eight archives had around 691M data elements (3.5G compressed).
I started Akumuli with different &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;TCP.pool_size&lt;/code&gt; values and measured the time it took to write all the data. After each run, the resulting database size was 2.7GB (4.2 bytes per element).&lt;/p&gt;

&lt;p&gt;Single &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;m3.xlarge&lt;/code&gt; instance was used to generate load. I had been using 8 writer processes working in parallel each process transmitting its own set of series to feed the data to &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;akumulid&lt;/code&gt; daemon.&lt;/p&gt;

&lt;h3 id=&quot;what-makes-akumuli-so-fast&quot;&gt;What makes Akumuli so fast?&lt;/h3&gt;

&lt;p&gt;Storage in Akumuli is based on append-only B+tree. Each series is represented using a separate B+tree instance and can be modified independently. All B+trees reside in the same file.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/images/btree_schema.png&quot; alt=&quot;B+tree mapping&quot; /&gt;&lt;/p&gt;

&lt;p&gt;Code in write-path is based on the observation that different TCP-sessions usually write data to the different time-series. When session writes something to the series first time the corresponding B+tree that stores series data get assigned to that session. After that, the session will be able to write data to this B+tree without any synchronization. If B+tree is already owned by another thread, additional synchronization with the owner thread will be needed.&lt;/p&gt;

&lt;h3 id=&quot;final-notes&quot;&gt;Final notes&lt;/h3&gt;

&lt;p&gt;Using a single-node instance of Akumuli running on the m3.2xlarge instance I managed to write 4.5M data points / second. This is not a high-end machine by any means. I’m looking forward to running this test on more performant hardware and see the results. That would be very interesting to try.&lt;/p&gt;
</description>
        <pubDate>Mon, 13 Feb 2017 18:00:00 +0000</pubDate>
        <link>http://akumuli.org/akumuli/2017/02/13/benchmark/</link>
        <guid isPermaLink="true">http://akumuli.org/akumuli/2017/02/13/benchmark/</guid>
      </item>
    
      <item>
        <title>Time-series compression (part 2)</title>
        <description>&lt;p&gt;In the &lt;a href=&quot;http://akumuli.org/akumuli/2016/12/30/compression_part1/&quot;&gt;previous article&lt;/a&gt; I discussed timestamps compression, now it’s time to talk about floating point data compression. This problem is not new, there are some good papers about it, e.g. &lt;a href=&quot;http://www.vldb.org/pvldb/vol8/p1816-teller.pdf&quot;&gt;Gorilla paper&lt;/a&gt;, and &lt;a href=&quot;http://citeseerx.ist.psu.edu/viewdoc/download?doi=10.1.1.86.262&amp;amp;rep=rep1&amp;amp;type=pdf&quot;&gt;also this&lt;/a&gt;, and &lt;a href=&quot;https://pdfs.semanticscholar.org/7e8b/0ac17f11dcfd13fea4aadf0b86598f3d1d72.pdf&quot;&gt;this one too&lt;/a&gt;. None of them is a good fit for &lt;a href=&quot;https://github.com/akumuli/akumuli&quot;&gt;Akumuli&lt;/a&gt; because the algorithms described in these papers are too specialized. The paper authors usually have a very specific dataset in mind. For example, Gorilla is optimized for slowly changed time-series and for integer time-series, and FCM algorithm will work best for scientific data.&lt;/p&gt;

&lt;p&gt;So, I had to come up with a new algorithm. Simple delta encoding wouldn’t work at all. If you subtract one floating point number from the other, you won’t end up with the number that can be represented using a smaller number of bits. The solution here is to use bitwise XOR and get a binary diff instead of arithmetic delta.&lt;/p&gt;

&lt;p&gt;Let’s get a closer look at IEEE 754 double-precision floating point number representation:&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/images/IEEE754.png&quot; alt=&quot;By Codekaizen - Own work, GFDL, https://commons.wikimedia.org/w/index.php?curid=3595583&quot; /&gt;&lt;/p&gt;

&lt;p&gt;Fractional part occupies low 52 bits and sign and mantissa are located in high 12 bits. This means that if two numbers have close absolute values their high bits will be the same so when we XOR this values together, we will get a lot of leading zeroes in the result. Example: binary representation of 1.0 is &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;3ff0 0000 0000 0000&lt;/code&gt; and binary representation of 1.0000000000000004 is &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;3ff0 0000 0000 0002&lt;/code&gt;. We will get &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;0000 0000 0000 0002&lt;/code&gt; by XOR-ing this two values. This value occupies only two bits.&lt;/p&gt;

&lt;p&gt;But some numbers will have very small (if any) number of leading zeroes when XOR-ed. E.g. 1.0 is represented as &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;3ff0 0000 0000 0000&lt;/code&gt; and 2.0 is &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;4000 0000 0000 0000&lt;/code&gt;. By XOR-ing this two numbers we will get this result &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;7ff0 0000 0000 0000&lt;/code&gt;. This happens a lot in practice because many sources generate integers (e.g. number of bytes process is using, number of packets sent by the peer, etc).&lt;/p&gt;

&lt;p&gt;So, we need to XOR not any but close values together. To achieve this Akumuli uses predictive coding. The idea is that we can try to predict next value and XOR it with the actual one. Basically, we will store prediction error. The predictor can be stateful, it can store some context information about the predicted signal. The signal usually is not totally random but stationary thus it can be predicted with some accuracy.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/images/predictor_schema.png&quot; alt=&quot;Predictor&quot; /&gt;&lt;/p&gt;

&lt;p&gt;If our predictor is good prediction error will be small and we will be able to store it using a small number of bits. But what predictors should be used?&lt;/p&gt;

&lt;p&gt;I’ve evaluated three predictors:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Last value predictor.&lt;/li&gt;
  &lt;li&gt;Finite Context Method (FCM) predictor.&lt;/li&gt;
  &lt;li&gt;Differential FCM predictor.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;(Actually, I’ve evaluated compositions of different predictors, but this is too much for the blog post)&lt;/p&gt;

&lt;p&gt;Last value predictor (or delta modulator) assumes that the next value will be the same as the previous one. So, to use this predictor we can just XOR adjacent values of the array and we’re done.&lt;/p&gt;

&lt;p&gt;FCM predictor is more complex. It predicts next value based on a finite number of preceding values. FCM predictor contains fixed sized table. This table maps contexts to values that were observed after particular contexts. The data structure is very simple:&lt;/p&gt;

&lt;div class=&quot;language-c highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;struct&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;FCM&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;kt&quot;&gt;double&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;table&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;TABLE_SIZE&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;];&lt;/span&gt;
  &lt;span class=&quot;kt&quot;&gt;int&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;last_hash&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;The &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;update&lt;/code&gt; procedure should be used to add new values to the table:&lt;/p&gt;

&lt;div class=&quot;language-c highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;kt&quot;&gt;double&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;predict_next&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;FCM&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;*&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;fcm&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;fcm&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;-&amp;gt;&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;table&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;fcm&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;-&amp;gt;&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;last_hash&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;];&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;  
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;To understand how it works we should know how these values are calculated:&lt;/p&gt;

&lt;div class=&quot;language-c highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;kt&quot;&gt;void&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;update&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;FCM&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;*&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;fcm&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;double&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;value&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;n&quot;&gt;fcm&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;-&amp;gt;&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;table&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;fcm&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;-&amp;gt;&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;last_hash&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;value&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
  &lt;span class=&quot;n&quot;&gt;fcm&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;-&amp;gt;&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;last_hash&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;hash&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;value&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;%&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;TABLE_SIZE&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;To understand how it works we can imagine how FCM predictor of size two will be dealing with this input sequence: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;[-1, 1, -1, 1, -1, 1]&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Obviously, last value predictor will fail here. Let’s define the hash function first. We need to extract some context from each floating point number, for FCM predictor of size two we can use sign bit:&lt;/p&gt;

&lt;div class=&quot;language-c highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;kt&quot;&gt;int&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;hash&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;double&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;value&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; 
  &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;value&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;&amp;lt;&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;1&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
  &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
&lt;span class=&quot;err&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Now let’s look how predictor will do its job:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;FCM initialized, table == [0, 0], last_hash == 0;&lt;/li&gt;
  &lt;li&gt;predict_next(fcm) returned 0;&lt;/li&gt;
  &lt;li&gt;update(fcm, -1), table == [-1, 0], last_hash == 1;&lt;/li&gt;
  &lt;li&gt;predict_next(fcm) returned 0;&lt;/li&gt;
  &lt;li&gt;update_next(fcm, 1), table == [-1, 1], last_hash == 0;&lt;/li&gt;
  &lt;li&gt;predict_next(fcm) returned -1;&lt;/li&gt;
  &lt;li&gt;update_next(fcm, -1), table == [-1, 1], last_hash == 1;&lt;/li&gt;
  &lt;li&gt;predict_next(fcm) returned 1;&lt;/li&gt;
  &lt;li&gt;udpate_next(fcm, 1), table == [-1, 1], last_hash == 0;&lt;/li&gt;
  &lt;li&gt;predict_next(fcm) returned -1;&lt;/li&gt;
  &lt;li&gt;…&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So, FCM predictor can learn simple patterns like that. Basically, it remembers that some context (hash value) preceded some value and then when it meets this context it assumes that the value will follow. All we need to do is to give it some memory and proper hash function. It will be able to predict more complex patterns using more memory.&lt;/p&gt;

&lt;p&gt;Famous Gorilla paper uses the algorithm that can be seen as a special case of the FCM compression with the table of size one. We already know it as “last value predictor”.&lt;/p&gt;

&lt;p&gt;In Akumuli I’m using this hash function:&lt;/p&gt;

&lt;div class=&quot;language-c highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;last_hash&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;5&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;^&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;value&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;50&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;It extracts the sign, exponent, and two mantissa bits out of floating point number and blends it with some bits from the previous hash value. Blending previous hash bits is very important because it makes context depend on several adjacent values, not only the last.&lt;/p&gt;

&lt;p&gt;DFCM works almost the same as FCM, but it works with differences, not the actual values.&lt;/p&gt;

&lt;p&gt;I created &lt;a href=&quot;https://gist.github.com/Lazin/a28ba28b1a51a95e193e13575eef6509&quot;&gt;this script&lt;/a&gt; to evaluate different predictors in different situations. It runs different predictors on several classes of input. Some of this classes are:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Random walk;&lt;/li&gt;
  &lt;li&gt;Steady growth trend;&lt;/li&gt;
  &lt;li&gt;Pattern;&lt;/li&gt;
&lt;/ul&gt;

&lt;h3 id=&quot;random-walk&quot;&gt;Random walk&lt;/h3&gt;

&lt;p&gt;This class of input is very important because it mimics a stationary process. This is the most important and general case. DFCM certainly has some advantage here, not overwhelming but noticeable.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/images/random_walk.png&quot; alt=&quot;Random walk&quot; /&gt;&lt;/p&gt;

&lt;p&gt;This graph shows number of bits needed to store each value using different predictors. X axis is a value index and Y axis is a number of bits needed to represent prediction error so, the smaller the value the better compression.&lt;/p&gt;

&lt;h3 id=&quot;pattern&quot;&gt;Pattern&lt;/h3&gt;

&lt;p&gt;Sometimes we need to represent the state of the system using discrete values (e.g. use 1 and 0 to represent on and off states). In many cases, this discrete values will form stable patterns because each value represents a state of some finite state machine and the state machine often goes through the same sequence of state transitions over and over again. Predictor should learn this patterns.&lt;/p&gt;

&lt;p&gt;This is how three predictors compress repeating pattern composed of three discrete values:&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/images/pattern_2.png&quot; alt=&quot;3 val pattern&quot; /&gt;&lt;/p&gt;

&lt;p&gt;After just six iterations DFCM was able to predict the input perfectly. Both FCM and last value predictors performed poorly.&lt;/p&gt;

&lt;h3 id=&quot;steady-growth-trend&quot;&gt;Steady growth trend&lt;/h3&gt;

&lt;p&gt;Some data sources are producing time-series with increasing values (e.g. number of packets sent, electricity consumption, etc). This is how our predictors performed on simple linear trend:&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/images/steady_growth.png&quot; alt=&quot;Linear trend&quot; /&gt;&lt;/p&gt;

&lt;p&gt;After several iterations, DFCM predictor started to predict input perfectly. Other two predictors couldn’t comply. We can see the same picture with integer time-series:&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/images/steady_growth_int.png&quot; alt=&quot;Linear int trend&quot; /&gt;&lt;/p&gt;

&lt;p&gt;What if the trend will be random?&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/images/fast_rand_growth.png&quot; alt=&quot;Random growth&quot; /&gt;&lt;/p&gt;

&lt;p&gt;You can see that DFCM still outperforms other predictors but there are some spikes. We definitely will need more space to store this.&lt;/p&gt;

&lt;h3 id=&quot;final-thoughts&quot;&gt;Final thoughts&lt;/h3&gt;

&lt;p&gt;As result of this evaluation, I’ve chosen DFCM predictor for Akumuli. It performs relatively good on monitoring data as well as scientific data.
You can see that compression ratio depends on dataset heavily and predictive compression algorithm do make a difference in some cases.
This article covers only small part of the algorithm, but you can check this &lt;a href=&quot;https://docs.google.com/document/d/1yLsN1j8xxnm_b0oN6rFSgWOnCHP-OlJC5pBKZQwTAPc/pub&quot;&gt;design document&lt;/a&gt; for more details.&lt;/p&gt;

</description>
        <pubDate>Sun, 05 Feb 2017 18:00:00 +0000</pubDate>
        <link>http://akumuli.org/akumuli/2017/02/05/compression_part2/</link>
        <guid isPermaLink="true">http://akumuli.org/akumuli/2017/02/05/compression_part2/</guid>
      </item>
    
  </channel>
</rss>
