x4204

Better Lookup Table Compression

§0 Table of Contents

§1 Introduction

In the previous post[1] I described the techniques I used to compress a lookup table, namely unaccent.rules[2]. While they were fun to explore and implement, something felt off. I felt like we can do better than ~13 KiB. In a comment under my post on Reddit, u/alex-van-02[3] mentioned that it should be possible to produce a much smaller compressed version, and referenced his work[4] on compressing a lookup table for fast character case conversion. After studying the techniques he used, I came up with a new lookup table compression algorithm that performs much better, both in terms of speed and compression. In the following, we will have a look at the new version.

§2 Excessive Padding

The biggest problem in the previous version was the data structure we used to represent a single entry in the lookup table:

1
2
3
4
5
6
7
#define ENTRY_DATA_LEN 8

typedef struct UnaccentEntry {
  uint8_t symb_len;
  uint8_t repl_len;
  uint8_t data[ENTRY_DATA_LEN];
} UnaccentEntry;

It stores the symbol and replacement together in data, which is a field of fixed size. In reality, not all pairs occupy 8 bytes, so a lot of space is wasted, because we pad all entries to the same size. This can be observed from the following distribution by length:

1
2
3
 length |    2 |    3 |    4 |    5 |    6 |    7 |    8
--------+------+------+------+------+------+------+------
  count |   99 |  394 |  728 | 1033 |  348 |   58 |    2

If we were to just store all pairs right next to each other and without any padding in between, we would need 11967 bytes, which is already less than my previous version, even without any compression techniques applied. This makes it crystal clear that we have to approach storage in memory in such a way that any kind of padding in between entries is avoided.

§3 Data properties

Before jumping into the details of the new design, it is important to look at the data and see what properties it has that we can make use of.

First, when looking at compressed entries, I noticed that the symbol part from data was never affected by dictionary encoding. This is bad, because there are actually a lot of repeating byte pairs in symbols, since many of them are consecutive and therefore have the same prefix. Unfortunately, the modified algorithm for byte pair encoding considers only the byte pairs from the longest entries only. As a result, it can't exploit the redundancy present in symbols. This indicates that maybe we should look into compressing symbols separately.

Second, symbols are very sparse. In total, we have 2662 symbols, but they are spread across the unicode codepoint range 161..127273 (¡ and 🄩) with big gaps in between. On average, there is one symbol for every ~47 codepoints. This might be the place where we apply techniques similar to those in u/alex-van-02's post and get rid of the biggest gaps.

Third, not all replacements are unique. Quite the opposite actually. Out of 2662 replacements, only 453 are unique (~17%). This means that we can deduplicate replacements and store an offset instead of the full replacement, thus saving space on that.

§4 Storing Symbols

My idea was to store symbols using their unicode codepoint instead of the UTF-8 encoding. By doing that, we already reduce the space needed from at most 4 bytes to at most 3 bytes. For example, the maximum symbol 🄩 requires 32 bits (4 bytes) when UTF-8 encoded. On the other hand, its codepoint is 127273, which requires only 17 bits (3 bytes).

Those 17 bits are annoyingly close to 16 bits (2 bytes). It would be great if we could do something to represent a symbol in just 16 bits or less. This is where we exploit the fact that symbols are sparse and that we can get rid of the biggest gaps.

First, let's look at where and how big the gaps are. This small snippet prints all symbol codepoints and the gaps between them:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
codes = [
    ord(symb.decode())
    for symb, repl in entries
]

a = codes[0]
for code in codes:
    gap = code - a
    if gap > 1:
        print(f'------ {gap=}')
    a = code
    print(code)

Here is a sample:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
[...]
1105
------ gap=6319
7424
[...]
9397
------ gap=1232
10629
[...]
13279
------ gap=29521
42800
[...]

To produce a more compact representation for symbols, we could split the range 161..127273 into smaller ranges, so that the value can be stored using less bits, by storing only the offset from the start of the range. Ideally, the smaller ranges must be chosen in such a way that they don't contain any of the big gaps. This script will find these ranges:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
a = codes[0]
b = a
ranges = []
max_gap = 1000
for code in codes:
    if code - a > max_gap:
        ranges.append((b, a, a-b+1))
        b = code
    a = code
ranges.append((b, a, a-b+1))

The maximum gap was chosen somewhat arbitrarily to be 1000. There is no other meaning behind this number. From the script above we get the following ranges:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
 # |  start |    end | size
---+--------+--------+------
 0 |    161 |   1105 |  945
 1 |   7424 |   9397 | 1974
 2 |  10629 |  13279 | 2651
 3 |  42800 |  42996 |  197
 4 |  64256 |  65515 | 1260
 5 |  67493 |  67493 |    1
 6 | 119808 | 120770 |  963
 7 | 127232 | 127273 |   42

Using them we can now represent a symbol in just 16 bits. The format will be: tag (3 bits) followed by offset (13 bits). Here, tag is the index of the range the symbol code falls into and offset is how far into the range we have to go to find the code (an offset from the start of the range).

Let's go through an example. Suppose we want to pack symbol . First, we find its codepoint, which in this case is 8356. Second, we find the tag. Codepoint 8356, falls into the range 7424..9397, so tag = 1 (001). Third, we find the offset. This can be done by computing codepoint - start. This gives us offset = 932 (0001110100100). Finally, we concatenate the two and get the packed binary representation: 0010001110100100.

This looks good. Since we have 2662 symbols, it's not possible to pack a symbol into less than two bytes, so we stop here. Next, let's turn our attention to replacements.

§5 Storing Replacements

We can store replacements, as they are, directly in the entry, but then we would have to pad all entries to the same size so that we can perform binary search. We know that there are only 453 unique replacements, so to avoid padding the entries, we instead store all replacements one after another in a single buffer and then keep an offset in the entry that points into that buffer. Since it's just an offset into a memory buffer, it doesn't matter in what order we store the replacements there. Here is the length distribution for replacements:

1
2
3
 length |   0 |   1 |   2 |   3 |   4 |   5
--------+-----+-----+-----+-----+-----+-----
  count |   1 |  94 | 207 | 114 |  35 |   2

Let's see how much space we would need in total. Every replacement needs 1 byte to keep track of its length, followed by that amount of bytes. We get that we need 453 + 0*1 + 1*94 + 2*207 + 3*114 + 4*35 + 5*2 = 1453 bytes. We also have to store 2662 entries. Each entry contains a packed symbol and an offset. Every packed symbol takes 2 bytes and same for the offset. Here, we need 2662 * (2 + 2) = 10648 bytes. In total: 1453 + 10648 = 12101 bytes. This is already an improvement over the 13314 bytes in the previous version. But we can do a bit better.

Many replacements share the same length. We can save a few bytes if we avoid storing the length of each replacement. This is possible if, inside the buffer, we store all replacements in increasing order of their lengths. This makes it so the buffer is partitioned by replacement length. That is, replacement of length 0 is stored between some offsets A and B, all replacements of length 1 between B and C, all replacements of length 2 between C and D and so on. By doing this, we save 453 bytes.

There is one last thing left. Since we stripped off the byte that indicates the length of the replacement, we need to deduce this information from somewhere else. Luckily, we know where the start of each buffer partition starts. To find the length, we find the partition that the offset falls into. To store the partition offsets, we need an additional 2 * 7 = 14 bytes.

§6 More Efficient Packing

Currently, we need 14 + 1001 + 10648 = 11663 bytes to store everything. We can reduce this amount if we make the following observations. First, replacements buffer length is 1001, which means that any offset into the buffer will need at most 10 bits, not all 16 bits allocated to it at the moment.

Second, a packed symbol stores a 3 bit tag and a 13 bit range offset. Maximum range offset that we can ever get is 2651 (range #2), which needs only 12 bits, not 13 bits that are currently allocated to it. Therefore, a packed symbol actually needs 15 bits and together with the buffer offset, they need a total of 15 + 10 = 25 bits. Again, 25 bits (4 bytes) is annoyingly close to 24 bits (3 bytes). If we manage to somehow pack the symbol more efficiently, we could save even more space.

We can do this if we split the ranges into even smaller ranges. This means that their count increases, and therefore, we have to allocate 4 bits instead of 3 bits for tag. We are left with 24 - 4 - 10 = 10 bits for the range offset if we want to fit everything nicely into just 24 bits. As a reminder, these are the ranges that we are currently working with:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
 # |  start |    end | size
---+--------+--------+------
 0 |    161 |   1105 |  945
 1 |   7424 |   9397 | 1974
 2 |  10629 |  13279 | 2651
 3 |  42800 |  42996 |  197
 4 |  64256 |  65515 | 1260
 5 |  67493 |  67493 |    1
 6 | 119808 | 120770 |  963
 7 | 127232 | 127273 |   42

Having only 10 bits left for the range offset means that the maximum range size should be capped at 1024. Given the table above, it is obvious that ranges #1, #2 and #4 have to be split. After playing around with range endpoints, I settled on the following setup:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
  # |  start |    end | size
----+--------+--------+------
  0 |    161 |   1105 |  945
  1 |   7424 |   8378 |  955
  2 |   8413 |   9397 |  985
  3 |  10629 |  11391 |  763
  4 |  12289 |  12318 |   30
  5 |  13169 |  13279 |  111
  6 |  42800 |  42996 |  197
  7 |  64256 |  64262 |    7
  8 |  65040 |  65515 |  476
  9 |  67493 |  67493 |    1
 10 | 119808 | 120770 |  963
 11 | 127232 | 127273 |   42

All ranges have a size that is less than 1024, which means that we can now fit a packed symbol and a buffer offset into exactly 24 bits (3 bytes). This makes the total space estimate 14 + 1001 + 3*2662 = 9001 bytes.

§7 Conclusion

In the new version we achieved an improvement of ~32% over the previous version. The nice part is that it doesn't need any somewhat fancy compression techniques like before. Everything is pretty simple and also much less code. As a nice side effect, it's also much faster, since entries are not decompressed at every step of binary search:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
---------------------- BEFORE ----------------------
$ time ./indexer pages-all.txt /dev/null > /dev/null

real    0m8.843s
user    0m8.825s
sys     0m0.017s

---------------------- AFTER -----------------------
$ time ./indexer pages-all.txt /dev/null > /dev/null

real    0m0.825s
user    0m0.803s
sys     0m0.022s

I tried applying dictionary compression to replacements like in my previous post, but we save maybe 100 bytes at best, which I considered not worth it really. So I will keep the implementation as is for now.

It does suffer from the same problem as the previous version. The most fragile part is the list of ranges used for symbol packing, since they are currently hand picked. I think it should be possible to automate the process of picking the ranges, but I didn't look into it. Still, every range has some head room left in case new rules are added, and even if a range becomes too big, we have space for 4 more ranges in the list before we need to allocate more bits for the tag. Overall, the current version is actually less sensitive to changes in unaccent.rules than the previous one.

The code for search can be found in Annex A.

§8 References

  1. Compressing Lookup Tables
    https://blog.x4204.xyz/posts/compressing-lookup-tables.html
  2. PostgreSQL / unaccent.rules
    https://github.com/postgres/postgres/blob/57ee397953985feb95cffa65a06fcaf6a2cd5367/contrib/unaccent/unaccent.rules
  3. Comment by u/alex-van-02
    https://www.reddit.com/r/C_Programming/comments/1w3icnl/comment/p72rbhd/
  4. Fast Case Conversion
    https://github.com/apankrat/notes/tree/master/fast-case-conversion
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
#define UNACCENT_ENTRIES_LEN      2662
#define UNACCENT_REPL_OFFSETS_LEN 7
#define UNACCENT_REPL_AREA_LEN    1001
#define UNACCENT_CODE_INVALID     0xffff

uint32_t unaccent_symb_ranges[] = {
  161, 1105,
  7424, 8378,
  8413, 9397,
  10629, 11391,
  12289, 12318,
  13169, 13279,
  42800, 42996,
  64256, 64262,
  65040, 65515,
  67493, 67493,
  119808, 120770,
  127232, 127273,
};
int32_t unaccent_symb_ranges_len =
  sizeof(unaccent_symb_ranges) / sizeof(unaccent_symb_ranges[0]);

typedef struct UnaccentEntry {
  uint8_t data[3];
} UnaccentEntry;

typedef struct UnaccentTable {
  UnaccentEntry entries[UNACCENT_ENTRIES_LEN];
  uint16_t repl_offsets[UNACCENT_REPL_OFFSETS_LEN];
  uint8_t repl_area[UNACCENT_REPL_AREA_LEN];
} UnaccentTable;

uint32_t
unaccent_utf8_code(uint8_t* symb)
{
  uint32_t code = 0;

  if ((symb[0] & 0x80) == 0x00) {
    code = (code | (symb[0] & 0x7f)) << 0;
  } else if ((symb[0] & 0xe0) == 0xc0) {
    code = (code | (symb[0] & 0x1f)) << 6;
    code = (code | (symb[1] & 0x3f)) << 0;
  } else if ((symb[0] & 0xf0) == 0xe0) {
    code = (code | (symb[0] & 0x0f)) << 6;
    code = (code | (symb[1] & 0x3f)) << 6;
    code = (code | (symb[2] & 0x3f)) << 0;
  } else if ((symb[0] & 0xf8) == 0xf0) {
    code = (code | (symb[0] & 0x07)) << 6;
    code = (code | (symb[1] & 0x3f)) << 6;
    code = (code | (symb[2] & 0x3f)) << 6;
    code = (code | (symb[3] & 0x3f)) << 0;
  } else {
    assert(false);
  }

  return code;
}

// NOTE: assumes `symb` points to a valid utf8 encoded symbol
uint16_t
unaccent_symb_code(uint8_t* symb)
{
  uint32_t code = unaccent_utf8_code(symb);

  for (int32_t i = 0; i < unaccent_symb_ranges_len; i += 2) {
    uint32_t a = unaccent_symb_ranges[i + 0];
    uint32_t b = unaccent_symb_ranges[i + 1];
    if (code >= a && code <= b) {
      uint16_t tag = i / 2;
      uint16_t off = code - a;
      return (tag << 12) | (off << 2);
    }
  }

  return UNACCENT_CODE_INVALID;
}

uint16_t
UnaccentEntry_code(UnaccentEntry* entry)
{
  return (entry->data[0] << 8) | (entry->data[1] & 0xfc);
}

uint16_t
UnaccentEntry_offset(UnaccentEntry* entry)
{
  return ((entry->data[1] & 0x03) << 8) | entry->data[2];
}

// NOTE: receives the symbol to find and its length through `repl`
// and `repl_len`. If symbol found in the lookup table, then it
// returns the replacement by overriding `repl` and `repl_len`.
// Otherwise, none of the two parameters are changed
void
UnaccentTable_find(
  UnaccentTable* table,
  uint8_t** repl, int32_t* repl_len
)
{
  uint8_t* symb = *repl;
  uint16_t code = unaccent_symb_code(symb);
  if (code == UNACCENT_CODE_INVALID) return;

  int32_t a = 0;
  int32_t b = UNACCENT_ENTRIES_LEN - 1;

  while (a != b) {
    int32_t m = a + (b - a + 1) / 2;
    UnaccentEntry* entry = &table->entries[m];
    if (code < UnaccentEntry_code(entry)) {
      b = m - 1;
    } else {
      a = m;
    }
  }

  UnaccentEntry* entry = &table->entries[a];
  if (code != UnaccentEntry_code(entry)) return;

  uint16_t entry_offset = UnaccentEntry_offset(entry);
  *repl_len = UNACCENT_REPL_OFFSETS_LEN - 1;
  assert(entry_offset >= 0);
  assert(entry_offset < table->repl_offsets[*repl_len]);

  *repl = &table->repl_area[entry_offset];
  for (; *repl_len >= 0; *repl_len -= 1) {
    if (entry_offset >= table->repl_offsets[*repl_len]) break;
  }
}